forked from github/plane
Merge pull request #3171 from makeplane/develop
Promote: develop change to preview
This commit is contained in:
commit
6f2cce081f
51
.github/workflows/create-sync-pr.yml
vendored
51
.github/workflows/create-sync-pr.yml
vendored
@ -1,11 +1,13 @@
|
||||
name: Create PR in Plane EE Repository to sync the changes
|
||||
name: Create Sync Action
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
- preview
|
||||
types:
|
||||
- closed
|
||||
env:
|
||||
SOURCE_BRANCH_NAME: ${{github.event.pull_request.base.ref}}
|
||||
|
||||
jobs:
|
||||
create_pr:
|
||||
@ -16,27 +18,13 @@ jobs:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check SOURCE_REPO
|
||||
id: check_repo
|
||||
env:
|
||||
SOURCE_REPO: ${{ secrets.SOURCE_REPO_NAME }}
|
||||
run: |
|
||||
echo "::set-output name=is_correct_repo::$(if [[ "$SOURCE_REPO" == "makeplane/plane" ]]; then echo 'true'; else echo 'false'; fi)"
|
||||
|
||||
- name: Checkout Code
|
||||
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Branch Name
|
||||
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||
run: |
|
||||
echo "SOURCE_BRANCH_NAME=${{ github.head_ref }}" >> $GITHUB_ENV
|
||||
|
||||
- name: Setup GH CLI
|
||||
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||
run: |
|
||||
type -p curl >/dev/null || (sudo apt update && sudo apt install curl -y)
|
||||
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
|
||||
@ -45,35 +33,14 @@ jobs:
|
||||
sudo apt update
|
||||
sudo apt install gh -y
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check_repo.outputs.is_correct_repo == 'true'
|
||||
- name: Push Changes to Target Repo
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.ACCESS_TOKEN }}
|
||||
run: |
|
||||
TARGET_REPO="${{ secrets.TARGET_REPO_NAME }}"
|
||||
TARGET_BRANCH="${{ secrets.TARGET_REPO_BRANCH }}"
|
||||
TARGET_REPO="${{ secrets.SYNC_TARGET_REPO_NAME }}"
|
||||
TARGET_BRANCH="${{ secrets.SYNC_TARGET_BRANCH_NAME }}"
|
||||
SOURCE_BRANCH="${{ env.SOURCE_BRANCH_NAME }}"
|
||||
|
||||
git checkout $SOURCE_BRANCH
|
||||
git remote add target "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
git push target $SOURCE_BRANCH:$SOURCE_BRANCH
|
||||
|
||||
PR_TITLE="${{ github.event.pull_request.title }}"
|
||||
PR_BODY="${{ github.event.pull_request.body }}"
|
||||
|
||||
# Remove double quotes
|
||||
PR_TITLE_CLEANED="${PR_TITLE//\"/}"
|
||||
PR_BODY_CLEANED="${PR_BODY//\"/}"
|
||||
|
||||
# Construct PR_BODY_CONTENT using a here-document
|
||||
PR_BODY_CONTENT=$(cat <<EOF
|
||||
$PR_BODY_CLEANED
|
||||
EOF
|
||||
)
|
||||
|
||||
gh pr create \
|
||||
--base $TARGET_BRANCH \
|
||||
--head $SOURCE_BRANCH \
|
||||
--title "[SYNC] $PR_TITLE_CLEANED" \
|
||||
--body "$PR_BODY_CONTENT" \
|
||||
--repo $TARGET_REPO
|
||||
git remote add target-origin "https://$GH_TOKEN@github.com/$TARGET_REPO.git"
|
||||
git push target-origin $SOURCE_BRANCH:$TARGET_BRANCH
|
@ -33,8 +33,8 @@ The backend is a django project which is kept inside apiserver
|
||||
1. Clone the repo
|
||||
|
||||
```bash
|
||||
git clone https://github.com/makeplane/plane
|
||||
cd plane
|
||||
git clone https://github.com/makeplane/plane.git [folder-name]
|
||||
cd [folder-name]
|
||||
chmod +x setup.sh
|
||||
```
|
||||
|
||||
@ -44,33 +44,12 @@ chmod +x setup.sh
|
||||
./setup.sh
|
||||
```
|
||||
|
||||
3. Define `NEXT_PUBLIC_API_BASE_URL=http://localhost` in **web/.env** and **space/.env** file
|
||||
3. Start the containers
|
||||
|
||||
```bash
|
||||
echo "\nNEXT_PUBLIC_API_BASE_URL=http://localhost\n" >> ./web/.env
|
||||
docker compose -f docker-compose-local.yml up
|
||||
```
|
||||
|
||||
```bash
|
||||
echo "\nNEXT_PUBLIC_API_BASE_URL=http://localhost\n" >> ./space/.env
|
||||
```
|
||||
|
||||
4. Run Docker compose up
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. Install dependencies
|
||||
|
||||
```bash
|
||||
yarn install
|
||||
```
|
||||
|
||||
6. Run the web app in development mode
|
||||
|
||||
```bash
|
||||
yarn dev
|
||||
```
|
||||
|
||||
## Missing a Feature?
|
||||
|
||||
|
@ -49,5 +49,5 @@ USER captain
|
||||
# Expose container port and run entry point script
|
||||
EXPOSE 8000
|
||||
|
||||
# CMD [ "./bin/takeoff" ]
|
||||
CMD [ "./bin/takeoff.local" ]
|
||||
|
||||
|
31
apiserver/bin/takeoff.local
Executable file
31
apiserver/bin/takeoff.local
Executable file
@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
python manage.py wait_for_db
|
||||
python manage.py migrate
|
||||
|
||||
# Create the default bucket
|
||||
#!/bin/bash
|
||||
|
||||
# Collect system information
|
||||
HOSTNAME=$(hostname)
|
||||
MAC_ADDRESS=$(ip link show | awk '/ether/ {print $2}' | head -n 1)
|
||||
CPU_INFO=$(cat /proc/cpuinfo)
|
||||
MEMORY_INFO=$(free -h)
|
||||
DISK_INFO=$(df -h)
|
||||
|
||||
# Concatenate information and compute SHA-256 hash
|
||||
SIGNATURE=$(echo "$HOSTNAME$MAC_ADDRESS$CPU_INFO$MEMORY_INFO$DISK_INFO" | sha256sum | awk '{print $1}')
|
||||
|
||||
# Export the variables
|
||||
export MACHINE_SIGNATURE=$SIGNATURE
|
||||
|
||||
# Register instance
|
||||
python manage.py register_instance $MACHINE_SIGNATURE
|
||||
# Load the configuration variable
|
||||
python manage.py configure_instance
|
||||
|
||||
# Create the default bucket
|
||||
python manage.py create_bucket
|
||||
|
||||
python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local
|
||||
|
@ -145,6 +145,16 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
)
|
||||
)
|
||||
)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"project_projectmember",
|
||||
queryset=ProjectMember.objects.filter(
|
||||
workspace__slug=self.kwargs.get("slug"),
|
||||
is_active=True,
|
||||
).select_related("member"),
|
||||
to_attr="members_list",
|
||||
)
|
||||
)
|
||||
.distinct()
|
||||
)
|
||||
|
||||
@ -160,16 +170,6 @@ class ProjectViewSet(WebhookMixin, BaseViewSet):
|
||||
projects = (
|
||||
self.get_queryset()
|
||||
.annotate(sort_order=Subquery(sort_order_query))
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"project_projectmember",
|
||||
queryset=ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
is_active=True,
|
||||
).select_related("member"),
|
||||
to_attr="members_list",
|
||||
)
|
||||
)
|
||||
.order_by("sort_order", "name")
|
||||
)
|
||||
if request.GET.get("per_page", False) and request.GET.get("cursor", False):
|
||||
@ -679,6 +679,25 @@ class ProjectMemberViewSet(BaseViewSet):
|
||||
)
|
||||
)
|
||||
|
||||
# Check if the user is already a member of the project and is inactive
|
||||
if ProjectMember.objects.filter(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member_id=member.get("member_id"),
|
||||
is_active=False,
|
||||
).exists():
|
||||
member_detail = ProjectMember.objects.get(
|
||||
workspace__slug=slug,
|
||||
project_id=project_id,
|
||||
member_id=member.get("member_id"),
|
||||
is_active=False,
|
||||
)
|
||||
# Check if the user has not deactivated the account
|
||||
user = User.objects.filter(pk=member.get("member_id")).first()
|
||||
if user.is_active:
|
||||
member_detail.is_active = True
|
||||
member_detail.save(update_fields=["is_active"])
|
||||
|
||||
project_members = ProjectMember.objects.bulk_create(
|
||||
bulk_project_members,
|
||||
batch_size=10,
|
||||
@ -991,11 +1010,18 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
|
||||
|
||||
def get(self, request):
|
||||
files = []
|
||||
s3 = boto3.client(
|
||||
"s3",
|
||||
aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
|
||||
aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
|
||||
)
|
||||
s3_client_params = {
|
||||
"service_name": "s3",
|
||||
"aws_access_key_id": settings.AWS_ACCESS_KEY_ID,
|
||||
"aws_secret_access_key": settings.AWS_SECRET_ACCESS_KEY,
|
||||
}
|
||||
|
||||
# Use AWS_S3_ENDPOINT_URL if it is present in the settings
|
||||
if hasattr(settings, "AWS_S3_ENDPOINT_URL") and settings.AWS_S3_ENDPOINT_URL:
|
||||
s3_client_params["endpoint_url"] = settings.AWS_S3_ENDPOINT_URL
|
||||
|
||||
s3 = boto3.client(**s3_client_params)
|
||||
|
||||
params = {
|
||||
"Bucket": settings.AWS_STORAGE_BUCKET_NAME,
|
||||
"Prefix": "static/project-cover/",
|
||||
@ -1008,6 +1034,16 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView):
|
||||
if not content["Key"].endswith(
|
||||
"/"
|
||||
): # This line ensures we're only getting files, not "sub-folders"
|
||||
if (
|
||||
hasattr(settings, "AWS_S3_CUSTOM_DOMAIN")
|
||||
and settings.AWS_S3_CUSTOM_DOMAIN
|
||||
and hasattr(settings, "AWS_S3_URL_PROTOCOL")
|
||||
and settings.AWS_S3_URL_PROTOCOL
|
||||
):
|
||||
files.append(
|
||||
f"{settings.AWS_S3_URL_PROTOCOL}//{settings.AWS_S3_CUSTOM_DOMAIN}/{content['Key']}"
|
||||
)
|
||||
else:
|
||||
files.append(
|
||||
f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}"
|
||||
)
|
||||
|
@ -70,6 +70,7 @@ from plane.app.permissions import (
|
||||
WorkSpaceAdminPermission,
|
||||
WorkspaceEntityPermission,
|
||||
WorkspaceViewerPermission,
|
||||
WorkspaceUserPermission,
|
||||
)
|
||||
from plane.bgtasks.workspace_invitation_task import workspace_invitation
|
||||
from plane.utils.issue_filters import issue_filters
|
||||
@ -495,6 +496,18 @@ class WorkSpaceMemberViewSet(BaseViewSet):
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
def get_permissions(self):
|
||||
if self.action == "leave":
|
||||
self.permission_classes = [
|
||||
WorkspaceUserPermission,
|
||||
]
|
||||
else:
|
||||
self.permission_classes = [
|
||||
WorkspaceEntityPermission,
|
||||
]
|
||||
|
||||
return super(WorkSpaceMemberViewSet, self).get_permissions()
|
||||
|
||||
search_fields = [
|
||||
"member__display_name",
|
||||
"member__first_name",
|
||||
|
@ -65,7 +65,7 @@ def send_export_email(email, slug, csv_buffer, rows):
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=bool(EMAIL_USE_TLS),
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
|
@ -51,7 +51,7 @@ def forgot_password(first_name, email, uidb64, token, current_site):
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=bool(EMAIL_USE_TLS),
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
|
@ -41,7 +41,7 @@ def magic_link(email, key, token, current_site):
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=bool(EMAIL_USE_TLS),
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
|
@ -60,7 +60,7 @@ def project_invitation(email, project_id, token, current_site, invitor):
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=bool(EMAIL_USE_TLS),
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
|
@ -70,7 +70,7 @@ def workspace_invitation(email, workspace_id, token, current_site, invitor):
|
||||
port=int(EMAIL_PORT),
|
||||
username=EMAIL_HOST_USER,
|
||||
password=EMAIL_HOST_PASSWORD,
|
||||
use_tls=bool(EMAIL_USE_TLS),
|
||||
use_tls=EMAIL_USE_TLS == "1",
|
||||
)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
|
@ -12,7 +12,6 @@ volumes:
|
||||
|
||||
services:
|
||||
plane-redis:
|
||||
container_name: plane-redis
|
||||
image: redis:6.2.7-alpine
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
@ -21,7 +20,6 @@ services:
|
||||
- redisdata:/data
|
||||
|
||||
plane-minio:
|
||||
container_name: plane-minio
|
||||
image: minio/minio
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
@ -36,7 +34,6 @@ services:
|
||||
MINIO_ROOT_PASSWORD: ${AWS_SECRET_ACCESS_KEY}
|
||||
|
||||
plane-db:
|
||||
container_name: plane-db
|
||||
image: postgres:15.2-alpine
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
@ -53,7 +50,6 @@ services:
|
||||
PGDATA: /var/lib/postgresql/data
|
||||
|
||||
web:
|
||||
container_name: web
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./web/Dockerfile.dev
|
||||
@ -61,8 +57,7 @@ services:
|
||||
networks:
|
||||
- dev_env
|
||||
volumes:
|
||||
- .:/app
|
||||
command: yarn dev --filter=web
|
||||
- ./web:/app/web
|
||||
env_file:
|
||||
- ./web/.env
|
||||
depends_on:
|
||||
@ -73,22 +68,17 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: ./space/Dockerfile.dev
|
||||
container_name: space
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dev_env
|
||||
volumes:
|
||||
- .:/app
|
||||
command: yarn dev --filter=space
|
||||
env_file:
|
||||
- ./space/.env
|
||||
- ./space:/app/space
|
||||
depends_on:
|
||||
- api
|
||||
- worker
|
||||
- web
|
||||
|
||||
api:
|
||||
container_name: api
|
||||
build:
|
||||
context: ./apiserver
|
||||
dockerfile: Dockerfile.dev
|
||||
@ -99,7 +89,7 @@ services:
|
||||
- dev_env
|
||||
volumes:
|
||||
- ./apiserver:/code
|
||||
command: /bin/sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local"
|
||||
# command: /bin/sh -c "python manage.py migrate && python manage.py runserver 0.0.0.0:8000 --settings=plane.settings.local"
|
||||
env_file:
|
||||
- ./apiserver/.env
|
||||
depends_on:
|
||||
@ -107,7 +97,6 @@ services:
|
||||
- plane-redis
|
||||
|
||||
worker:
|
||||
container_name: bgworker
|
||||
build:
|
||||
context: ./apiserver
|
||||
dockerfile: Dockerfile.dev
|
||||
@ -127,7 +116,6 @@ services:
|
||||
- plane-redis
|
||||
|
||||
beat-worker:
|
||||
container_name: beatworker
|
||||
build:
|
||||
context: ./apiserver
|
||||
dockerfile: Dockerfile.dev
|
||||
@ -147,10 +135,9 @@ services:
|
||||
- plane-redis
|
||||
|
||||
proxy:
|
||||
container_name: proxy
|
||||
build:
|
||||
context: ./nginx
|
||||
dockerfile: Dockerfile
|
||||
dockerfile: Dockerfile.dev
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- dev_env
|
||||
|
10
nginx/Dockerfile.dev
Normal file
10
nginx/Dockerfile.dev
Normal file
@ -0,0 +1,10 @@
|
||||
FROM nginx:1.25.0-alpine
|
||||
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
COPY nginx.conf.dev /etc/nginx/nginx.conf.template
|
||||
|
||||
COPY ./env.sh /docker-entrypoint.sh
|
||||
|
||||
RUN chmod +x /docker-entrypoint.sh
|
||||
# Update all environment variables
|
||||
CMD ["/docker-entrypoint.sh"]
|
@ -18,7 +18,7 @@ server {
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
location /space/ {
|
||||
location /spaces/ {
|
||||
proxy_pass http://localhost:4000/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
|
36
nginx/nginx.conf.dev
Normal file
36
nginx/nginx.conf.dev
Normal file
@ -0,0 +1,36 @@
|
||||
events {
|
||||
}
|
||||
|
||||
http {
|
||||
sendfile on;
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
root /www/data/;
|
||||
access_log /var/log/nginx/access.log;
|
||||
|
||||
client_max_body_size ${FILE_SIZE_LIMIT};
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header Referrer-Policy "no-referrer-when-downgrade" always;
|
||||
add_header Permissions-Policy "interest-cohort=()" always;
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
|
||||
location / {
|
||||
proxy_pass http://web:3000/;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8000/api/;
|
||||
}
|
||||
|
||||
location /spaces/ {
|
||||
rewrite ^/spaces/?$ /spaces/login break;
|
||||
proxy_pass http://space:4000/spaces/;
|
||||
}
|
||||
|
||||
location /${BUCKET_NAME}/ {
|
||||
proxy_pass http://plane-minio:9000/uploads/;
|
||||
}
|
||||
}
|
||||
}
|
1
setup.sh
1
setup.sh
@ -6,7 +6,6 @@ export LC_ALL=C
|
||||
export LC_CTYPE=C
|
||||
|
||||
cp ./web/.env.example ./web/.env
|
||||
cp ./space/.env.example ./space/.env
|
||||
cp ./apiserver/.env.example ./apiserver/.env
|
||||
|
||||
# Generate the SECRET_KEY that will be used by django
|
||||
|
@ -7,5 +7,8 @@ WORKDIR /app
|
||||
COPY . .
|
||||
RUN yarn global add turbo
|
||||
RUN yarn install
|
||||
EXPOSE 3000
|
||||
EXPOSE 4000
|
||||
ENV NEXT_PUBLIC_DEPLOY_WITH_NGINX=1
|
||||
|
||||
VOLUME [ "/app/node_modules", "/app/space/node_modules"]
|
||||
CMD ["yarn","dev", "--filter=space"]
|
||||
|
@ -8,4 +8,5 @@ COPY . .
|
||||
RUN yarn global add turbo
|
||||
RUN yarn install
|
||||
EXPOSE 3000
|
||||
VOLUME [ "/app/node_modules", "/app/web/node_modules" ]
|
||||
CMD ["yarn", "dev", "--filter=web"]
|
||||
|
@ -19,7 +19,7 @@ type Props = {
|
||||
icon?: any;
|
||||
text: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
} | null;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
|
@ -1,14 +1,14 @@
|
||||
import React, { useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
// react hook form
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { SubmitHandler, useForm } from "react-hook-form";
|
||||
// headless ui
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react";
|
||||
import useSWR from "swr";
|
||||
// hooks
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import useToast from "hooks/use-toast";
|
||||
// services
|
||||
import { IssueService } from "services/issue";
|
||||
// hooks
|
||||
import useToast from "hooks/use-toast";
|
||||
// ui
|
||||
import { Button, LayersIcon } from "@plane/ui";
|
||||
// icons
|
||||
@ -30,17 +30,25 @@ type Props = {
|
||||
|
||||
const issueService = new IssueService();
|
||||
|
||||
export const BulkDeleteIssuesModal: React.FC<Props> = (props) => {
|
||||
export const BulkDeleteIssuesModal: React.FC<Props> = observer((props) => {
|
||||
const { isOpen, onClose } = props;
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
// states
|
||||
const [query, setQuery] = useState("");
|
||||
// store hooks
|
||||
const {
|
||||
user: { hasPermissionToCurrentProject },
|
||||
} = useMobxStore();
|
||||
// fetching project issues.
|
||||
const { data: issues } = useSWR(
|
||||
workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null,
|
||||
workspaceSlug && projectId ? () => issueService.getIssues(workspaceSlug as string, projectId as string) : null
|
||||
workspaceSlug && projectId && hasPermissionToCurrentProject
|
||||
? PROJECT_ISSUES_LIST(workspaceSlug.toString(), projectId.toString())
|
||||
: null,
|
||||
workspaceSlug && projectId && hasPermissionToCurrentProject
|
||||
? () => issueService.getIssues(workspaceSlug.toString(), projectId.toString())
|
||||
: null
|
||||
);
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
@ -222,4 +230,4 @@ export const BulkDeleteIssuesModal: React.FC<Props> = (props) => {
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
@ -118,6 +118,7 @@ export const LinkModal: FC<Props> = (props) => {
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.url)}
|
||||
placeholder="https://..."
|
||||
pattern="^(https?://).*"
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
|
@ -50,8 +50,8 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEdit
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{!isNotAllowed && (
|
||||
<div className="z-[1] flex flex-shrink-0 items-center gap-2">
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
@ -63,6 +63,7 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEdit
|
||||
>
|
||||
<Pencil className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
</button>
|
||||
)}
|
||||
<a
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
@ -71,6 +72,7 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEdit
|
||||
>
|
||||
<ExternalLinkIcon className="h-3 w-3 stroke-[1.5] text-custom-text-200" />
|
||||
</a>
|
||||
{!isNotAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center p-1 hover:bg-custom-background-80"
|
||||
@ -82,9 +84,9 @@ export const LinksList: React.FC<Props> = ({ links, handleDeleteLink, handleEdit
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-5">
|
||||
<p className="mt-0.5 stroke-[1.5] text-xs text-custom-text-300">
|
||||
Added {timeAgo(link.created_at)}
|
||||
|
@ -14,6 +14,7 @@ import { SingleProgressStats } from "components/core";
|
||||
import { Avatar, StateGroupIcon } from "@plane/ui";
|
||||
// types
|
||||
import {
|
||||
IIssueFilterOptions,
|
||||
IModule,
|
||||
TAssigneesDistribution,
|
||||
TCompletionChartDistribution,
|
||||
@ -35,6 +36,9 @@ type Props = {
|
||||
roundedTab?: boolean;
|
||||
noBackground?: boolean;
|
||||
isPeekView?: boolean;
|
||||
isCompleted?: boolean;
|
||||
filters?: IIssueFilterOptions;
|
||||
handleFiltersUpdate: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
};
|
||||
|
||||
export const SidebarProgressStats: React.FC<Props> = ({
|
||||
@ -44,7 +48,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
module,
|
||||
roundedTab,
|
||||
noBackground,
|
||||
isCompleted = false,
|
||||
isPeekView = false,
|
||||
filters,
|
||||
handleFiltersUpdate,
|
||||
}) => {
|
||||
const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees");
|
||||
|
||||
@ -140,19 +147,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
}
|
||||
completed={assignee.completed_issues}
|
||||
total={assignee.total_issues}
|
||||
{...(!isPeekView && {
|
||||
onClick: () => {
|
||||
// TODO: set filters here
|
||||
// if (filters?.assignees?.includes(assignee.assignee_id ?? ""))
|
||||
// setFilters({
|
||||
// assignees: filters?.assignees?.filter((a) => a !== assignee.assignee_id),
|
||||
// });
|
||||
// else
|
||||
// setFilters({
|
||||
// assignees: [...(filters?.assignees ?? []), assignee.assignee_id ?? ""],
|
||||
// });
|
||||
},
|
||||
// selected: filters?.assignees?.includes(assignee.assignee_id ?? ""),
|
||||
{...(!isPeekView &&
|
||||
!isCompleted && {
|
||||
onClick: () => handleFiltersUpdate("assignees", assignee.assignee_id ?? ""),
|
||||
selected: filters?.assignees?.includes(assignee.assignee_id ?? ""),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
@ -200,16 +198,10 @@ export const SidebarProgressStats: React.FC<Props> = ({
|
||||
}
|
||||
completed={label.completed_issues}
|
||||
total={label.total_issues}
|
||||
{...(!isPeekView && {
|
||||
// TODO: set filters here
|
||||
onClick: () => {
|
||||
// if (filters.labels?.includes(label.label_id ?? ""))
|
||||
// setFilters({
|
||||
// labels: filters?.labels?.filter((l) => l !== label.label_id),
|
||||
// });
|
||||
// else setFilters({ labels: [...(filters?.labels ?? []), label.label_id ?? ""] });
|
||||
},
|
||||
// selected: filters?.labels?.includes(label.label_id ?? ""),
|
||||
{...(!isPeekView &&
|
||||
!isCompleted && {
|
||||
onClick: () => handleFiltersUpdate("labels", label.label_id ?? ""),
|
||||
selected: filters?.labels?.includes(label.label_id ?? `no-label-${index}`),
|
||||
})}
|
||||
/>
|
||||
))
|
||||
|
@ -75,7 +75,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
|
||||
useSWR(
|
||||
const { isLoading } = useSWR(
|
||||
workspaceSlug && projectId ? `ACTIVE_CYCLE_ISSUE_${projectId}_CURRENT` : null,
|
||||
workspaceSlug && projectId ? () => cycleStore.fetchCycles(workspaceSlug, projectId, "current") : null
|
||||
);
|
||||
@ -94,7 +94,7 @@ export const ActiveCycleDetails: React.FC<IActiveCycleDetails> = observer((props
|
||||
// : null
|
||||
// ) as { data: IIssue[] | undefined };
|
||||
|
||||
if (!cycle)
|
||||
if (!cycle && isLoading)
|
||||
return (
|
||||
<Loader>
|
||||
<Loader.Item height="250px" />
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useForm } from "react-hook-form";
|
||||
@ -29,7 +29,10 @@ import {
|
||||
renderShortMonthDate,
|
||||
} from "helpers/date-time.helper";
|
||||
// types
|
||||
import { ICycle } from "types";
|
||||
import { ICycle, IIssueFilterOptions } from "types";
|
||||
import { EFilterType } from "store/issues/types";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
// fetch-keys
|
||||
import { CYCLE_STATUS } from "constants/cycle";
|
||||
|
||||
@ -52,7 +55,9 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
|
||||
const {
|
||||
cycle: cycleDetailsStore,
|
||||
cycleIssuesFilter: { issueFilters, updateFilters },
|
||||
trackEvent: { setTrackElement },
|
||||
user: { currentProjectRole },
|
||||
} = useMobxStore();
|
||||
|
||||
const cycleDetails = cycleDetailsStore.cycle_details[cycleId] ?? undefined;
|
||||
@ -242,6 +247,25 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleFiltersUpdate = useCallback(
|
||||
(key: keyof IIssueFilterOptions, value: string | string[]) => {
|
||||
if (!workspaceSlug || !projectId) return;
|
||||
const newValues = issueFilters?.filters?.[key] ?? [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((val) => {
|
||||
if (!newValues.includes(val)) newValues.push(val);
|
||||
});
|
||||
} else {
|
||||
if (issueFilters?.filters?.[key]?.includes(value)) newValues.splice(newValues.indexOf(value), 1);
|
||||
else newValues.push(value);
|
||||
}
|
||||
|
||||
updateFilters(workspaceSlug.toString(), projectId.toString(), EFilterType.FILTERS, { [key]: newValues }, cycleId);
|
||||
},
|
||||
[workspaceSlug, projectId, cycleId, issueFilters, updateFilters]
|
||||
);
|
||||
|
||||
const cycleStatus =
|
||||
cycleDetails?.start_date && cycleDetails?.end_date
|
||||
? getDateRangeStatus(cycleDetails?.start_date, cycleDetails?.end_date)
|
||||
@ -270,10 +294,11 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
</Loader>
|
||||
);
|
||||
|
||||
const endDate = new Date(cycleDetails.end_date ?? "");
|
||||
const startDate = new Date(cycleDetails.start_date ?? "");
|
||||
const endDate = new Date(watch("end_date") ?? cycleDetails.end_date ?? "");
|
||||
const startDate = new Date(watch("start_date") ?? cycleDetails.start_date ?? "");
|
||||
|
||||
const areYearsEqual = startDate.getFullYear() === endDate.getFullYear();
|
||||
const areYearsEqual =
|
||||
startDate.getFullYear() === endDate.getFullYear() || isNaN(startDate.getFullYear()) || isNaN(endDate.getFullYear());
|
||||
|
||||
const currentCycle = CYCLE_STATUS.find((status) => status.value === cycleStatus);
|
||||
|
||||
@ -286,6 +311,8 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
: `${cycleDetails.total_issues}`
|
||||
: `${cycleDetails.completed_issues}/${cycleDetails.total_issues}`;
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<>
|
||||
{cycleDetails && workspaceSlug && projectId && (
|
||||
@ -312,7 +339,7 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<button onClick={handleCopyText}>
|
||||
<LinkIcon className="h-3 w-3 text-custom-text-300" />
|
||||
</button>
|
||||
{!isCompleted && (
|
||||
{!isCompleted && isEditingAllowed && (
|
||||
<CustomMenu width="lg" placement="bottom-end" ellipsis>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={() => {
|
||||
@ -349,8 +376,10 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<div className="relative flex h-full w-52 items-center gap-2.5">
|
||||
<Popover className="flex h-full items-center justify-center rounded-lg">
|
||||
<Popover.Button
|
||||
disabled={isCompleted ?? false}
|
||||
className="cursor-default text-sm font-medium text-custom-text-300"
|
||||
className={`text-sm font-medium text-custom-text-300 ${
|
||||
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
|
||||
}`}
|
||||
disabled={isCompleted || !isEditingAllowed}
|
||||
>
|
||||
{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")}
|
||||
</Popover.Button>
|
||||
@ -373,10 +402,10 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
handleStartDateChange(val);
|
||||
}
|
||||
}}
|
||||
startDate={watch("start_date") ? `${watch("start_date")}` : null}
|
||||
endDate={watch("end_date") ? `${watch("end_date")}` : null}
|
||||
startDate={watch("start_date") ?? watch("end_date") ?? null}
|
||||
endDate={watch("end_date") ?? watch("start_date") ?? null}
|
||||
maxDate={new Date(`${watch("end_date")}`)}
|
||||
selectsStart
|
||||
selectsStart={watch("end_date") ? true : false}
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
@ -385,8 +414,10 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
<Popover className="flex h-full items-center justify-center rounded-lg">
|
||||
<>
|
||||
<Popover.Button
|
||||
disabled={isCompleted ?? false}
|
||||
className="cursor-default text-sm font-medium text-custom-text-300"
|
||||
className={`text-sm font-medium text-custom-text-300 ${
|
||||
isEditingAllowed ? "cursor-pointer" : "cursor-not-allowed"
|
||||
}`}
|
||||
disabled={isCompleted || !isEditingAllowed}
|
||||
>
|
||||
{areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")}
|
||||
</Popover.Button>
|
||||
@ -409,10 +440,10 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
handleEndDateChange(val);
|
||||
}
|
||||
}}
|
||||
startDate={watch("start_date") ? `${watch("start_date")}` : null}
|
||||
endDate={watch("end_date") ? `${watch("end_date")}` : null}
|
||||
startDate={watch("start_date") ?? watch("end_date") ?? null}
|
||||
endDate={watch("end_date") ?? watch("start_date") ?? null}
|
||||
minDate={new Date(`${watch("start_date")}`)}
|
||||
selectsEnd
|
||||
selectsEnd={watch("start_date") ? true : false}
|
||||
/>
|
||||
</Popover.Panel>
|
||||
</Transition>
|
||||
@ -528,6 +559,9 @@ export const CycleDetailsSidebar: React.FC<Props> = observer((props) => {
|
||||
}}
|
||||
totalIssues={cycleDetails.total_issues}
|
||||
isPeekView={Boolean(peekCycle)}
|
||||
isCompleted={isCompleted}
|
||||
filters={issueFilters?.filters}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
@ -15,7 +15,6 @@ import { IGanttBlock, IBlockUpdateData } from "components/gantt-chart/types";
|
||||
import { IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void;
|
||||
blocks: IGanttBlock[] | null;
|
||||
enableReorder: boolean;
|
||||
@ -33,7 +32,6 @@ type Props = {
|
||||
export const IssueGanttSidebar: React.FC<Props> = (props) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {
|
||||
title,
|
||||
blockUpdateHandler,
|
||||
blocks,
|
||||
enableReorder,
|
||||
|
@ -155,7 +155,10 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
key={cycle.id}
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ContrastIcon className="h-3 w-3" />
|
||||
{truncateText(cycle.name, 40)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
@ -192,10 +195,12 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
|
||||
{canUserCreateIssue && (
|
||||
<>
|
||||
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
|
||||
Analytics
|
||||
</Button>
|
||||
{canUserCreateIssue && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTrackElement("CYCLE_PAGE_HEADER");
|
||||
@ -206,6 +211,7 @@ export const CycleIssuesHeader: React.FC = observer(() => {
|
||||
>
|
||||
Add Issue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
@ -16,6 +16,7 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
|
||||
// constants
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
|
||||
import { EFilterType } from "store/issues/types";
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
const GLOBAL_VIEW_LAYOUTS = [
|
||||
{ key: "list", title: "List", link: "/workspace-views", icon: List },
|
||||
@ -38,7 +39,7 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => {
|
||||
workspace: { workspaceLabels },
|
||||
workspaceMember: { workspaceMembers },
|
||||
project: { workspaceProjects },
|
||||
|
||||
user: { currentWorkspaceRole },
|
||||
workspaceGlobalIssuesFilter: { issueFilters, updateFilters },
|
||||
} = useMobxStore();
|
||||
|
||||
@ -77,6 +78,8 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => {
|
||||
[workspaceSlug, updateFilters]
|
||||
);
|
||||
|
||||
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<>
|
||||
<CreateUpdateWorkspaceViewModal isOpen={createViewModal} onClose={() => setCreateViewModal(false)} />
|
||||
@ -142,10 +145,11 @@ export const GlobalIssuesHeader: React.FC<Props> = observer((props) => {
|
||||
</FiltersDropdown>
|
||||
</>
|
||||
)}
|
||||
|
||||
{isAuthorizedUser && (
|
||||
<Button variant="primary" size="sm" prependIcon={<PlusIcon />} onClick={() => setCreateViewModal(true)}>
|
||||
New View
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
@ -11,7 +11,7 @@ import { ProjectAnalyticsModal } from "components/analytics";
|
||||
// ui
|
||||
import { Breadcrumbs, Button, CustomMenu, DiceIcon } from "@plane/ui";
|
||||
// icons
|
||||
import { ArrowRight, ContrastIcon, Plus } from "lucide-react";
|
||||
import { ArrowRight, Plus } from "lucide-react";
|
||||
// helpers
|
||||
import { truncateText } from "helpers/string.helper";
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
@ -143,7 +143,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
<CustomMenu
|
||||
label={
|
||||
<>
|
||||
<ContrastIcon className="h-3 w-3" />
|
||||
<DiceIcon className="h-3 w-3" />
|
||||
{moduleDetails?.name && truncateText(moduleDetails.name, 40)}
|
||||
</>
|
||||
}
|
||||
@ -156,7 +156,10 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
key={module.id}
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/modules/${module.id}`)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<DiceIcon className="h-3 w-3" />
|
||||
{truncateText(module.name, 40)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
@ -193,10 +196,12 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
|
||||
{canUserCreateIssue && (
|
||||
<>
|
||||
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
|
||||
Analytics
|
||||
</Button>
|
||||
{canUserCreateIssue && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTrackElement("MODULE_PAGE_HEADER");
|
||||
@ -207,6 +212,7 @@ export const ModuleIssuesHeader: React.FC = observer(() => {
|
||||
>
|
||||
Add Issue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
|
@ -202,10 +202,12 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
||||
</span>
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{canUserCreateIssue && (
|
||||
<>
|
||||
<Button onClick={() => setAnalyticsModal(true)} variant="neutral-primary" size="sm">
|
||||
Analytics
|
||||
</Button>
|
||||
{canUserCreateIssue && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTrackElement("PROJECT_PAGE_HEADER");
|
||||
@ -216,6 +218,7 @@ export const ProjectIssuesHeader: React.FC = observer(() => {
|
||||
>
|
||||
Add Issue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -138,7 +138,10 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
key={view.id}
|
||||
onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/views/${view.id}`)}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PhotoFilterIcon height={12} width={12} />
|
||||
{truncateText(view.name, 40)}
|
||||
</div>
|
||||
</CustomMenu.MenuItem>
|
||||
))}
|
||||
</CustomMenu>
|
||||
@ -152,7 +155,8 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
onChange={(layout) => handleLayoutChange(layout)}
|
||||
selectedLayout={activeLayout}
|
||||
/>
|
||||
<FiltersDropdown title="Filters" placement="bottom-end">
|
||||
|
||||
<FiltersDropdown title="Filters" placement="bottom-end" disabled={!canUserCreateIssue}>
|
||||
<FilterSelection
|
||||
filters={issueFilters?.filters ?? {}}
|
||||
handleFiltersUpdate={handleFiltersUpdate}
|
||||
@ -175,7 +179,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
handleDisplayPropertiesUpdate={handleDisplayProperties}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
{
|
||||
{canUserCreateIssue && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
setTrackElement("PROJECT_VIEW_PAGE_HEADER");
|
||||
@ -186,7 +190,7 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
|
||||
>
|
||||
Add Issue
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -7,15 +7,24 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { Breadcrumbs, PhotoFilterIcon, Button } from "@plane/ui";
|
||||
// helpers
|
||||
import { renderEmoji } from "helpers/emoji.helper";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
export const ProjectViewsHeader: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
const { project: projectStore, commandPalette } = useMobxStore();
|
||||
const {
|
||||
project: projectStore,
|
||||
commandPalette,
|
||||
user: { currentProjectRole },
|
||||
} = useMobxStore();
|
||||
const { currentProjectDetails } = projectStore;
|
||||
|
||||
const canUserCreateIssue =
|
||||
currentProjectRole && [EUserWorkspaceRoles.ADMIN, EUserWorkspaceRoles.MEMBER].includes(currentProjectRole);
|
||||
|
||||
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">
|
||||
@ -50,6 +59,7 @@ export const ProjectViewsHeader: React.FC = observer(() => {
|
||||
</Breadcrumbs>
|
||||
</div>
|
||||
</div>
|
||||
{canUserCreateIssue && (
|
||||
<div className="flex flex-shrink-0 items-center gap-2">
|
||||
<div>
|
||||
<Button
|
||||
@ -62,6 +72,7 @@ export const ProjectViewsHeader: React.FC = observer(() => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -5,6 +5,8 @@ import { Breadcrumbs, Button } from "@plane/ui";
|
||||
// hooks
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
export const ProjectsHeader = observer(() => {
|
||||
const router = useRouter();
|
||||
@ -15,10 +17,13 @@ export const ProjectsHeader = observer(() => {
|
||||
project: projectStore,
|
||||
commandPalette: commandPaletteStore,
|
||||
trackEvent: { setTrackElement },
|
||||
user: { currentWorkspaceRole },
|
||||
} = useMobxStore();
|
||||
|
||||
const projectsList = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : [];
|
||||
|
||||
const isAuthorizedUser = !!currentWorkspaceRole && currentWorkspaceRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
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="flex w-full flex-grow items-center gap-2 overflow-ellipsis whitespace-nowrap">
|
||||
@ -44,7 +49,7 @@ export const ProjectsHeader = observer(() => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAuthorizedUser && (
|
||||
<Button
|
||||
prependIcon={<Plus />}
|
||||
size="sm"
|
||||
@ -55,6 +60,7 @@ export const ProjectsHeader = observer(() => {
|
||||
>
|
||||
Add Project
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -225,7 +225,7 @@ export const InboxMainContent: React.FC = observer(() => {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mb-5 flex items-center">
|
||||
<div className="mb-2.5 flex items-center">
|
||||
{currentIssueState && (
|
||||
<StateGroupIcon
|
||||
className="mr-3 h-4 w-4"
|
||||
|
@ -21,6 +21,7 @@ export interface EmailFormValues {
|
||||
EMAIL_HOST_PASSWORD: string;
|
||||
EMAIL_USE_TLS: string;
|
||||
// EMAIL_USE_SSL: string;
|
||||
EMAIL_FROM: string;
|
||||
}
|
||||
|
||||
export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
|
||||
@ -45,6 +46,7 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
|
||||
EMAIL_HOST_PASSWORD: config["EMAIL_HOST_PASSWORD"],
|
||||
EMAIL_USE_TLS: config["EMAIL_USE_TLS"],
|
||||
// EMAIL_USE_SSL: config["EMAIL_USE_SSL"],
|
||||
EMAIL_FROM: config["EMAIL_FROM"],
|
||||
},
|
||||
});
|
||||
|
||||
@ -168,6 +170,31 @@ export const InstanceEmailForm: FC<IInstanceEmailForm> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid-col grid w-full max-w-4xl grid-cols-1 items-center justify-between gap-x-16 gap-y-8 lg:grid-cols-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="text-sm">From address</h4>
|
||||
<Controller
|
||||
control={control}
|
||||
name="EMAIL_FROM"
|
||||
render={({ field: { value, onChange, ref } }) => (
|
||||
<Input
|
||||
id="EMAIL_FROM"
|
||||
name="EMAIL_FROM"
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
ref={ref}
|
||||
hasError={Boolean(errors.EMAIL_FROM)}
|
||||
placeholder="no-reply@projectplane.so"
|
||||
className="w-full rounded-md font-medium"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<p className="text-xs text-custom-text-400">
|
||||
You will have to verify your email address to being sending emails.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-md flex-col gap-y-8 px-1">
|
||||
<div className="mr-8 flex items-center gap-10 pt-4">
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import React, { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/router";
|
||||
import useSWR from "swr";
|
||||
@ -24,7 +24,14 @@ import { IIssueAttachment } from "types";
|
||||
const issueAttachmentService = new IssueAttachmentService();
|
||||
const projectMemberService = new ProjectMemberService();
|
||||
|
||||
export const IssueAttachments = () => {
|
||||
type Props = {
|
||||
editable: boolean;
|
||||
};
|
||||
|
||||
export const IssueAttachments: React.FC<Props> = (props) => {
|
||||
const { editable } = props;
|
||||
|
||||
// states
|
||||
const [deleteAttachment, setDeleteAttachment] = useState<IIssueAttachment | null>(null);
|
||||
const [attachmentDeleteModal, setAttachmentDeleteModal] = useState<boolean>(false);
|
||||
|
||||
@ -86,6 +93,7 @@ export const IssueAttachments = () => {
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{editable && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setDeleteAttachment(file);
|
||||
@ -94,6 +102,7 @@ export const IssueAttachments = () => {
|
||||
>
|
||||
<X className="h-4 w-4 text-custom-text-200 hover:text-custom-text-100" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
|
@ -135,7 +135,9 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
||||
debouncedFormSave();
|
||||
}}
|
||||
required
|
||||
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-xl outline-none ring-0 focus:ring-1 focus:ring-custom-primary"
|
||||
className={`min-h-min block w-full resize-none overflow-hidden rounded border-none bg-transparent px-3 py-2 text-2xl font-medium outline-none ring-0 focus:ring-1 focus:ring-custom-primary ${
|
||||
!isAllowed ? "hover:cursor-not-allowed" : ""
|
||||
}`}
|
||||
hasError={Boolean(errors?.description)}
|
||||
role="textbox"
|
||||
disabled={!isAllowed}
|
||||
@ -170,7 +172,9 @@ export const IssueDescriptionForm: FC<IssueDetailsProps> = (props) => {
|
||||
setShouldShowAlert={setShowAlert}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
dragDropEnabled
|
||||
customClassName={isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"}
|
||||
customClassName={
|
||||
isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200 pointer-events-none"
|
||||
}
|
||||
noBorder={!isAllowed}
|
||||
onChange={(description: Object, description_html: string) => {
|
||||
setShowAlert(true);
|
||||
|
@ -227,6 +227,7 @@ export const IssueForm: FC<IssueFormProps> = observer((props) => {
|
||||
reset({
|
||||
...defaultValues,
|
||||
...initialData,
|
||||
project: projectId,
|
||||
});
|
||||
}, [setFocus, initialData, reset]);
|
||||
|
||||
|
@ -120,8 +120,8 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => {
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={peekProjectId.toString()}
|
||||
issueId={peekIssueId.toString()}
|
||||
handleIssue={async (issueToUpdate) =>
|
||||
await handleIssues(issueToUpdate.target_date ?? "", issueToUpdate as IIssue, EIssueActions.UPDATE)
|
||||
handleIssue={async (issueToUpdate, action: EIssueActions) =>
|
||||
await handleIssues(issueToUpdate.target_date ?? "", issueToUpdate as IIssue, action)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,3 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { Droppable } from "@hello-pangea/dnd";
|
||||
// components
|
||||
@ -48,11 +49,12 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
|
||||
quickAddCallback,
|
||||
viewId,
|
||||
} = props;
|
||||
|
||||
const [showAllIssues, setShowAllIssues] = useState(false);
|
||||
const calendarLayout = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.layout ?? "month";
|
||||
|
||||
const issueIdList = groupedIssueIds ? groupedIssueIds[renderDateFormat(date.date)] : null;
|
||||
|
||||
const totalIssues = issueIdList?.length ?? 0;
|
||||
return (
|
||||
<>
|
||||
<div className="group relative flex h-full w-full flex-col bg-custom-background-90">
|
||||
@ -87,7 +89,13 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
|
||||
{...provided.droppableProps}
|
||||
ref={provided.innerRef}
|
||||
>
|
||||
<CalendarIssueBlocks issues={issues} issueIdList={issueIdList} quickActions={quickActions} />
|
||||
<CalendarIssueBlocks
|
||||
issues={issues}
|
||||
issueIdList={issueIdList}
|
||||
quickActions={quickActions}
|
||||
showAllIssues={showAllIssues}
|
||||
/>
|
||||
|
||||
{enableQuickIssueCreate && !disableIssueCreation && (
|
||||
<div className="px-2 py-1">
|
||||
<CalendarQuickAddIssueForm
|
||||
@ -98,9 +106,23 @@ export const CalendarDayTile: React.FC<Props> = observer((props) => {
|
||||
}}
|
||||
quickAddCallback={quickAddCallback}
|
||||
viewId={viewId}
|
||||
onOpen={() => setShowAllIssues(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalIssues > 4 && (
|
||||
<div className="flex items-center px-2.5 py-1">
|
||||
<button
|
||||
type="button"
|
||||
className="w-min whitespace-nowrap rounded text-xs px-1.5 py-1 text-custom-text-400 font-medium hover:bg-custom-background-80 hover:text-custom-text-300"
|
||||
onClick={() => setShowAllIssues((prevData) => !prevData)}
|
||||
>
|
||||
{showAllIssues ? "Hide" : totalIssues - 4 + " more"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{provided.placeholder}
|
||||
</div>
|
||||
)}
|
||||
|
@ -10,30 +10,43 @@ import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// types
|
||||
import { IIssue } from "types";
|
||||
import { IIssueResponse } from "store/issues/types";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
type Props = {
|
||||
issues: IIssueResponse | undefined;
|
||||
issueIdList: string[] | null;
|
||||
quickActions: (issue: IIssue, customActionButton?: React.ReactElement) => React.ReactNode;
|
||||
showAllIssues?: boolean;
|
||||
};
|
||||
|
||||
export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
|
||||
const { issues, issueIdList, quickActions } = props;
|
||||
const { issues, issueIdList, quickActions, showAllIssues = false } = props;
|
||||
// router
|
||||
const router = useRouter();
|
||||
|
||||
// states
|
||||
const [isMenuActive, setIsMenuActive] = useState(false);
|
||||
|
||||
// mobx store
|
||||
const {
|
||||
user: { currentProjectRole },
|
||||
} = useMobxStore();
|
||||
|
||||
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleIssuePeekOverview = (issue: IIssue) => {
|
||||
const handleIssuePeekOverview = (issue: IIssue, event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
const { query } = router;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
const issueUrl = `/${issue.workspace_detail.slug}/projects/${issue.project_detail.id}/issues/${issue?.id}`;
|
||||
window.open(issueUrl, "_blank"); // Open link in a new tab
|
||||
} else {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
|
||||
@ -50,21 +63,23 @@ export const CalendarIssueBlocks: React.FC<Props> = observer((props) => {
|
||||
</div>
|
||||
);
|
||||
|
||||
const isEditable = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<>
|
||||
{issueIdList?.map((issueId, index) => {
|
||||
{issueIdList?.slice(0, showAllIssues ? issueIdList.length : 4).map((issueId, index) => {
|
||||
if (!issues?.[issueId]) return null;
|
||||
|
||||
const issue = issues?.[issueId];
|
||||
return (
|
||||
<Draggable key={issue.id} draggableId={issue.id} index={index}>
|
||||
<Draggable key={issue.id} draggableId={issue.id} index={index} isDragDisabled={!isEditable}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className="relative cursor-pointer p-1 px-2"
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
ref={provided.innerRef}
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
onClick={(e) => handleIssuePeekOverview(issue, e)}
|
||||
>
|
||||
{issue?.tempId !== undefined && (
|
||||
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
|
||||
|
@ -27,6 +27,7 @@ type Props = {
|
||||
viewId?: string
|
||||
) => Promise<IIssue | undefined>;
|
||||
viewId?: string;
|
||||
onOpen?: () => void;
|
||||
};
|
||||
|
||||
const defaultValues: Partial<IIssue> = {
|
||||
@ -57,7 +58,7 @@ const Inputs = (props: any) => {
|
||||
};
|
||||
|
||||
export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
|
||||
const { formKey, groupId, prePopulatedData, quickAddCallback, viewId } = props;
|
||||
const { formKey, groupId, prePopulatedData, quickAddCallback, viewId, onOpen } = props;
|
||||
|
||||
// router
|
||||
const router = useRouter();
|
||||
@ -146,6 +147,11 @@ export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpen = () => {
|
||||
setIsOpen(true);
|
||||
if (onOpen) onOpen();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{isOpen && (
|
||||
@ -169,7 +175,7 @@ export const CalendarQuickAddIssueForm: React.FC<Props> = observer((props) => {
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-x-[6px] rounded-md px-2 py-1.5 text-custom-primary-100"
|
||||
onClick={() => setIsOpen(true)}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<PlusIcon className="h-3.5 w-3.5 stroke-2" />
|
||||
<span className="text-sm font-medium text-custom-primary-100">New Issue</span>
|
||||
|
@ -15,6 +15,8 @@ import emptyIssue from "public/empty-state/issue.svg";
|
||||
// types
|
||||
import { ISearchIssueResponse } from "types";
|
||||
import { EProjectStore } from "store/command-palette.store";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string | undefined;
|
||||
@ -31,6 +33,7 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
|
||||
cycleIssues: cycleIssueStore,
|
||||
commandPalette: commandPaletteStore,
|
||||
trackEvent: { setTrackElement },
|
||||
user: { currentProjectRole: userRole },
|
||||
} = useMobxStore();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
@ -49,6 +52,8 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
|
||||
});
|
||||
};
|
||||
|
||||
const isEditingAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExistingIssuesListModal
|
||||
@ -75,10 +80,12 @@ export const CycleEmptyState: React.FC<Props> = observer((props) => {
|
||||
variant="neutral-primary"
|
||||
prependIcon={<PlusIcon className="h-3 w-3" strokeWidth={2} />}
|
||||
onClick={() => setCycleIssuesListModal(true)}
|
||||
disabled={!isEditingAllowed}
|
||||
>
|
||||
Add an existing issue
|
||||
</Button>
|
||||
}
|
||||
disabled={!isEditingAllowed}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
@ -10,6 +10,8 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
||||
import { ISearchIssueResponse } from "types";
|
||||
import useToast from "hooks/use-toast";
|
||||
import { useState } from "react";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
type Props = {
|
||||
workspaceSlug: string | undefined;
|
||||
@ -26,6 +28,7 @@ export const ModuleEmptyState: React.FC<Props> = observer((props) => {
|
||||
moduleIssues: moduleIssueStore,
|
||||
commandPalette: commandPaletteStore,
|
||||
trackEvent: { setTrackElement },
|
||||
user: { currentProjectRole: userRole },
|
||||
} = useMobxStore();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
@ -44,6 +47,8 @@ export const ModuleEmptyState: React.FC<Props> = observer((props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const isEditingAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<>
|
||||
<ExistingIssuesListModal
|
||||
@ -70,10 +75,12 @@ export const ModuleEmptyState: React.FC<Props> = observer((props) => {
|
||||
variant="neutral-primary"
|
||||
prependIcon={<PlusIcon className="h-3 w-3" strokeWidth={2} />}
|
||||
onClick={() => setModuleIssuesListModal(true)}
|
||||
disabled={!isEditingAllowed}
|
||||
>
|
||||
Add an existing issue
|
||||
</Button>
|
||||
}
|
||||
disabled={!isEditingAllowed}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
@ -4,6 +4,8 @@ import { PlusIcon } from "lucide-react";
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { NewEmptyState } from "components/common/new-empty-state";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
// assets
|
||||
import emptyIssue from "public/empty-state/empty_issues.webp";
|
||||
import { EProjectStore } from "store/command-palette.store";
|
||||
@ -12,8 +14,11 @@ export const ProjectEmptyState: React.FC = observer(() => {
|
||||
const {
|
||||
commandPalette: commandPaletteStore,
|
||||
trackEvent: { setTrackElement },
|
||||
user: { currentProjectRole },
|
||||
} = useMobxStore();
|
||||
|
||||
const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<div className="grid h-full w-full place-items-center">
|
||||
<NewEmptyState
|
||||
@ -26,14 +31,19 @@ export const ProjectEmptyState: React.FC = observer(() => {
|
||||
description:
|
||||
"Redesign the Plane UI, Rebrand the company, or Launch the new fuel injection system are examples of issues that likely have sub-issues.",
|
||||
}}
|
||||
primaryButton={{
|
||||
primaryButton={
|
||||
isEditingAllowed
|
||||
? {
|
||||
text: "Create your first issue",
|
||||
icon: <PlusIcon className="h-3 w-3" strokeWidth={2} />,
|
||||
onClick: () => {
|
||||
setTrackElement("PROJECT_EMPTY_STATE");
|
||||
commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.PROJECT);
|
||||
},
|
||||
}}
|
||||
}
|
||||
: null
|
||||
}
|
||||
disabled={!isEditingAllowed}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import {
|
||||
AppliedDateFilters,
|
||||
@ -16,6 +16,8 @@ import { X } from "lucide-react";
|
||||
import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssueFilterOptions, IIssueLabel, IProject, IState, IUserLite } from "types";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
type Props = {
|
||||
appliedFilters: IIssueFilterOptions;
|
||||
@ -33,10 +35,16 @@ const dateFilters = ["start_date", "target_date"];
|
||||
export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
const { appliedFilters, handleClearAllFilters, handleRemoveFilter, labels, members, projects, states } = props;
|
||||
|
||||
const {
|
||||
user: { currentProjectRole },
|
||||
} = useMobxStore();
|
||||
|
||||
if (!appliedFilters) return null;
|
||||
|
||||
if (Object.keys(appliedFilters).length === 0) return null;
|
||||
|
||||
const isEditingAllowed = currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-stretch gap-2 bg-custom-background-100">
|
||||
{Object.entries(appliedFilters).map(([key, value]) => {
|
||||
@ -53,6 +61,7 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
{membersFilters.includes(filterKey) && (
|
||||
<AppliedMembersFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter(filterKey, val)}
|
||||
members={members}
|
||||
values={value}
|
||||
@ -63,16 +72,22 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
{filterKey === "labels" && (
|
||||
<AppliedLabelsFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter("labels", val)}
|
||||
labels={labels}
|
||||
values={value}
|
||||
/>
|
||||
)}
|
||||
{filterKey === "priority" && (
|
||||
<AppliedPriorityFilters handleRemove={(val) => handleRemoveFilter("priority", val)} values={value} />
|
||||
<AppliedPriorityFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter("priority", val)}
|
||||
values={value}
|
||||
/>
|
||||
)}
|
||||
{filterKey === "state" && states && (
|
||||
<AppliedStateFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter("state", val)}
|
||||
states={states}
|
||||
values={value}
|
||||
@ -86,11 +101,13 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
)}
|
||||
{filterKey === "project" && (
|
||||
<AppliedProjectFilters
|
||||
editable={isEditingAllowed}
|
||||
handleRemove={(val) => handleRemoveFilter("project", val)}
|
||||
projects={projects}
|
||||
values={value}
|
||||
/>
|
||||
)}
|
||||
{isEditingAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
@ -98,10 +115,12 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
<X size={12} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{isEditingAllowed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClearAllFilters}
|
||||
@ -110,6 +129,7 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
Clear all
|
||||
<X size={12} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
@ -9,10 +9,11 @@ type Props = {
|
||||
handleRemove: (val: string) => void;
|
||||
labels: IIssueLabel[] | undefined;
|
||||
values: string[];
|
||||
editable: boolean | undefined;
|
||||
};
|
||||
|
||||
export const AppliedLabelsFilters: React.FC<Props> = observer((props) => {
|
||||
const { handleRemove, labels, values } = props;
|
||||
const { handleRemove, labels, values, editable } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -30,6 +31,7 @@ export const AppliedLabelsFilters: React.FC<Props> = observer((props) => {
|
||||
}}
|
||||
/>
|
||||
<span className="normal-case">{labelDetails.name}</span>
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
@ -37,6 +39,7 @@ export const AppliedLabelsFilters: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
<X size={10} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
@ -9,10 +9,11 @@ type Props = {
|
||||
handleRemove: (val: string) => void;
|
||||
members: IUserLite[] | undefined;
|
||||
values: string[];
|
||||
editable: boolean | undefined;
|
||||
};
|
||||
|
||||
export const AppliedMembersFilters: React.FC<Props> = observer((props) => {
|
||||
const { handleRemove, members, values } = props;
|
||||
const { handleRemove, members, values, editable } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -25,6 +26,7 @@ export const AppliedMembersFilters: React.FC<Props> = observer((props) => {
|
||||
<div key={memberId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||
<Avatar name={memberDetails.display_name} src={memberDetails.avatar} showTooltip={false} />
|
||||
<span className="normal-case">{memberDetails.display_name}</span>
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
@ -32,6 +34,7 @@ export const AppliedMembersFilters: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
<X size={10} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
@ -9,10 +9,11 @@ import { TIssuePriorities } from "types";
|
||||
type Props = {
|
||||
handleRemove: (val: string) => void;
|
||||
values: string[];
|
||||
editable: boolean | undefined;
|
||||
};
|
||||
|
||||
export const AppliedPriorityFilters: React.FC<Props> = observer((props) => {
|
||||
const { handleRemove, values } = props;
|
||||
const { handleRemove, values, editable } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -20,6 +21,7 @@ export const AppliedPriorityFilters: React.FC<Props> = observer((props) => {
|
||||
<div key={priority} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||
<PriorityIcon priority={priority as TIssuePriorities} className={`h-3 w-3`} />
|
||||
{priority}
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
@ -27,6 +29,7 @@ export const AppliedPriorityFilters: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
<X size={10} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
|
@ -10,10 +10,11 @@ type Props = {
|
||||
handleRemove: (val: string) => void;
|
||||
projects: IProject[] | undefined;
|
||||
values: string[];
|
||||
editable: boolean | undefined;
|
||||
};
|
||||
|
||||
export const AppliedProjectFilters: React.FC<Props> = observer((props) => {
|
||||
const { handleRemove, projects, values } = props;
|
||||
const { handleRemove, projects, values, editable } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -34,6 +35,7 @@ export const AppliedProjectFilters: React.FC<Props> = observer((props) => {
|
||||
</span>
|
||||
)}
|
||||
<span className="normal-case">{projectDetails.name}</span>
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
@ -41,6 +43,7 @@ export const AppliedProjectFilters: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
<X size={10} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
@ -10,10 +10,11 @@ type Props = {
|
||||
handleRemove: (val: string) => void;
|
||||
states: IState[];
|
||||
values: string[];
|
||||
editable: boolean | undefined;
|
||||
};
|
||||
|
||||
export const AppliedStateFilters: React.FC<Props> = observer((props) => {
|
||||
const { handleRemove, states, values } = props;
|
||||
const { handleRemove, states, values, editable } = props;
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -26,6 +27,7 @@ export const AppliedStateFilters: React.FC<Props> = observer((props) => {
|
||||
<div key={stateId} className="flex items-center gap-1 rounded bg-custom-background-80 p-1 text-xs">
|
||||
<StateGroupIcon color={stateDetails.color} stateGroup={stateDetails.group} height="12px" width="12px" />
|
||||
{stateDetails.name}
|
||||
{editable && (
|
||||
<button
|
||||
type="button"
|
||||
className="grid place-items-center text-custom-text-300 hover:text-custom-text-200"
|
||||
@ -33,6 +35,7 @@ export const AppliedStateFilters: React.FC<Props> = observer((props) => {
|
||||
>
|
||||
<X size={10} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
@ -11,10 +11,11 @@ type Props = {
|
||||
children: React.ReactNode;
|
||||
title?: string;
|
||||
placement?: Placement;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export const FiltersDropdown: React.FC<Props> = (props) => {
|
||||
const { children, title = "Dropdown", placement } = props;
|
||||
const { children, title = "Dropdown", placement, disabled = false } = props;
|
||||
|
||||
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
@ -32,6 +33,7 @@ export const FiltersDropdown: React.FC<Props> = (props) => {
|
||||
<>
|
||||
<Popover.Button as={React.Fragment}>
|
||||
<Button
|
||||
disabled={disabled}
|
||||
ref={setReferenceElement}
|
||||
variant="neutral-primary"
|
||||
size="sm"
|
||||
|
@ -1,4 +1,4 @@
|
||||
import React from "react";
|
||||
import React, { useCallback } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// mobx store
|
||||
@ -25,6 +25,8 @@ import {
|
||||
IViewIssuesStore,
|
||||
} from "store/issues";
|
||||
import { TUnGroupedIssues } from "store/issues/types";
|
||||
import { EIssueActions } from "../types";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
interface IBaseGanttRoot {
|
||||
@ -35,10 +37,15 @@ interface IBaseGanttRoot {
|
||||
| IViewIssuesFilterStore;
|
||||
issueStore: IProjectIssuesStore | IModuleIssuesStore | ICycleIssuesStore | IViewIssuesStore;
|
||||
viewId?: string;
|
||||
issueActions: {
|
||||
[EIssueActions.DELETE]: (issue: IIssue) => Promise<void>;
|
||||
[EIssueActions.UPDATE]?: (issue: IIssue) => Promise<void>;
|
||||
[EIssueActions.REMOVE]?: (issue: IIssue) => Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGanttRoot) => {
|
||||
const { issueFiltersStore, issueStore, viewId } = props;
|
||||
const { issueFiltersStore, issueStore, viewId, issueActions } = props;
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, peekIssueId, peekProjectId } = router.query;
|
||||
@ -64,11 +71,14 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
|
||||
await issueStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, payload, viewId);
|
||||
};
|
||||
|
||||
const updateIssue = async (projectId: string, issueId: string, payload: Partial<IIssue>) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
await issueStore.updateIssue(workspaceSlug.toString(), projectId, issueId, payload, viewId);
|
||||
};
|
||||
const handleIssues = useCallback(
|
||||
async (issue: IIssue, action: EIssueActions) => {
|
||||
if (issueActions[action]) {
|
||||
await issueActions[action]!(issue);
|
||||
}
|
||||
},
|
||||
[issueActions]
|
||||
);
|
||||
|
||||
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||
|
||||
@ -102,8 +112,8 @@ export const BaseGanttRoot: React.FC<IBaseGanttRoot> = observer((props: IBaseGan
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={peekProjectId.toString()}
|
||||
issueId={peekIssueId.toString()}
|
||||
handleIssue={async (issueToUpdate) => {
|
||||
await updateIssue(peekProjectId.toString(), peekIssueId.toString(), issueToUpdate);
|
||||
handleIssue={async (issueToUpdate, action) => {
|
||||
await handleIssues(issueToUpdate as IIssue, action);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
@ -9,13 +9,17 @@ import { IIssue } from "types";
|
||||
export const IssueGanttBlock = ({ data }: { data: IIssue }) => {
|
||||
const router = useRouter();
|
||||
|
||||
const handleIssuePeekOverview = () => {
|
||||
const handleIssuePeekOverview = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
const { query } = router;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
const issueUrl = `/${data?.workspace_detail.slug}/projects/${data?.project_detail.id}/issues/${data?.id}`;
|
||||
window.open(issueUrl, "_blank"); // Open link in a new tab
|
||||
} else {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekIssueId: data?.id, peekProjectId: data?.project },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
|
@ -4,15 +4,43 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { BaseGanttRoot } from "./base-gantt-root";
|
||||
import { useRouter } from "next/router";
|
||||
// types
|
||||
import { EIssueActions } from "../types";
|
||||
import { IIssue } from "types";
|
||||
|
||||
export const CycleGanttLayout: React.FC = observer(() => {
|
||||
const router = useRouter();
|
||||
const { cycleId } = router.query;
|
||||
const { cycleId, workspaceSlug } = router.query;
|
||||
|
||||
const { cycleIssues: cycleIssueStore, cycleIssuesFilter: cycleIssueFilterStore } = useMobxStore();
|
||||
|
||||
const issueActions = {
|
||||
[EIssueActions.UPDATE]: async (issue: IIssue) => {
|
||||
if (!workspaceSlug || !cycleId) return;
|
||||
|
||||
await cycleIssueStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue, cycleId.toString());
|
||||
},
|
||||
[EIssueActions.DELETE]: async (issue: IIssue) => {
|
||||
if (!workspaceSlug || !cycleId) return;
|
||||
|
||||
await cycleIssueStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id, cycleId.toString());
|
||||
},
|
||||
[EIssueActions.REMOVE]: async (issue: IIssue) => {
|
||||
if (!workspaceSlug || !cycleId || !issue.bridge_id) return;
|
||||
|
||||
await cycleIssueStore.removeIssueFromCycle(
|
||||
workspaceSlug.toString(),
|
||||
issue.project,
|
||||
cycleId.toString(),
|
||||
issue.id,
|
||||
issue.bridge_id
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseGanttRoot
|
||||
issueActions={issueActions}
|
||||
issueFiltersStore={cycleIssueFilterStore}
|
||||
issueStore={cycleIssueStore}
|
||||
viewId={cycleId?.toString()}
|
||||
|
@ -4,15 +4,43 @@ import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { BaseGanttRoot } from "./base-gantt-root";
|
||||
import { useRouter } from "next/router";
|
||||
// types
|
||||
import { EIssueActions } from "../types";
|
||||
import { IIssue } from "types";
|
||||
|
||||
export const ModuleGanttLayout: React.FC = observer(() => {
|
||||
const router = useRouter();
|
||||
const { moduleId } = router.query;
|
||||
const { moduleId, workspaceSlug } = router.query;
|
||||
|
||||
const { moduleIssues: moduleIssueStore, moduleIssuesFilter: moduleIssueFilterStore } = useMobxStore();
|
||||
|
||||
const issueActions = {
|
||||
[EIssueActions.UPDATE]: async (issue: IIssue) => {
|
||||
if (!workspaceSlug || !moduleId) return;
|
||||
|
||||
await moduleIssueStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue, moduleId.toString());
|
||||
},
|
||||
[EIssueActions.DELETE]: async (issue: IIssue) => {
|
||||
if (!workspaceSlug || !moduleId) return;
|
||||
|
||||
await moduleIssueStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id, moduleId.toString());
|
||||
},
|
||||
[EIssueActions.REMOVE]: async (issue: IIssue) => {
|
||||
if (!workspaceSlug || !moduleId || !issue.bridge_id) return;
|
||||
|
||||
await moduleIssueStore.removeIssueFromModule(
|
||||
workspaceSlug.toString(),
|
||||
issue.project,
|
||||
moduleId.toString(),
|
||||
issue.id,
|
||||
issue.bridge_id
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseGanttRoot
|
||||
issueActions={issueActions}
|
||||
issueFiltersStore={moduleIssueFilterStore}
|
||||
issueStore={moduleIssueStore}
|
||||
viewId={moduleId?.toString()}
|
||||
|
@ -1,12 +1,36 @@
|
||||
import React from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
// hooks
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { BaseGanttRoot } from "./base-gantt-root";
|
||||
// types
|
||||
import { EIssueActions } from "../types";
|
||||
import { IIssue } from "types";
|
||||
|
||||
export const GanttLayout: React.FC = observer(() => {
|
||||
const { projectIssues: projectIssuesStore, projectIssuesFilter: projectIssueFiltersStore } = useMobxStore();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
return <BaseGanttRoot issueFiltersStore={projectIssueFiltersStore} issueStore={projectIssuesStore} />;
|
||||
const issueActions = {
|
||||
[EIssueActions.UPDATE]: async (issue: IIssue) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
await projectIssuesStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue);
|
||||
},
|
||||
[EIssueActions.DELETE]: async (issue: IIssue) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
await projectIssuesStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id);
|
||||
},
|
||||
};
|
||||
return (
|
||||
<BaseGanttRoot
|
||||
issueActions={issueActions}
|
||||
issueFiltersStore={projectIssueFiltersStore}
|
||||
issueStore={projectIssuesStore}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -1,11 +1,35 @@
|
||||
import { observer } from "mobx-react-lite";
|
||||
import { useRouter } from "next/router";
|
||||
// mobx store
|
||||
import { useMobxStore } from "lib/mobx/store-provider";
|
||||
// components
|
||||
import { BaseGanttRoot } from "./base-gantt-root";
|
||||
// types
|
||||
import { EIssueActions } from "../types";
|
||||
import { IIssue } from "types";
|
||||
|
||||
export const ProjectViewGanttLayout: React.FC = observer(() => {
|
||||
const { viewIssues: projectIssueViewStore, viewIssuesFilter: projectIssueViewFiltersStore } = useMobxStore();
|
||||
const router = useRouter();
|
||||
const { workspaceSlug } = router.query;
|
||||
|
||||
return <BaseGanttRoot issueFiltersStore={projectIssueViewFiltersStore} issueStore={projectIssueViewStore} />;
|
||||
const issueActions = {
|
||||
[EIssueActions.UPDATE]: async (issue: IIssue) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
await projectIssueViewStore.updateIssue(workspaceSlug.toString(), issue.project, issue.id, issue);
|
||||
},
|
||||
[EIssueActions.DELETE]: async (issue: IIssue) => {
|
||||
if (!workspaceSlug) return;
|
||||
|
||||
await projectIssueViewStore.removeIssue(workspaceSlug.toString(), issue.project, issue.id);
|
||||
},
|
||||
};
|
||||
return (
|
||||
<BaseGanttRoot
|
||||
issueActions={issueActions}
|
||||
issueFiltersStore={projectIssueViewFiltersStore}
|
||||
issueStore={projectIssueViewStore}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
@ -346,8 +346,8 @@ export const BaseKanBanRoot: React.FC<IBaseKanBanLayout> = observer((props: IBas
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={peekProjectId.toString()}
|
||||
issueId={peekIssueId.toString()}
|
||||
handleIssue={async (issueToUpdate) =>
|
||||
await handleIssues(sub_group_by, group_by, issueToUpdate as IIssue, EIssueActions.UPDATE)
|
||||
handleIssue={async (issueToUpdate, action: EIssueActions) =>
|
||||
await handleIssues(sub_group_by, group_by, issueToUpdate as IIssue, action)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import { memo } from "react";
|
||||
import { Draggable } from "@hello-pangea/dnd";
|
||||
import { Draggable, DraggableStateSnapshot } from "@hello-pangea/dnd";
|
||||
import isEqual from "lodash/isEqual";
|
||||
// components
|
||||
import { KanBanProperties } from "./properties";
|
||||
@ -32,11 +32,23 @@ interface IssueDetailsBlockProps {
|
||||
quickActions: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => React.ReactNode;
|
||||
displayProperties: IIssueDisplayProperties | null;
|
||||
isReadOnly: boolean;
|
||||
snapshot: DraggableStateSnapshot;
|
||||
isDragDisabled: boolean;
|
||||
}
|
||||
|
||||
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = (props) => {
|
||||
const { sub_group_id, columnId, issue, showEmptyGroup, handleIssues, quickActions, displayProperties, isReadOnly } =
|
||||
props;
|
||||
const {
|
||||
sub_group_id,
|
||||
columnId,
|
||||
issue,
|
||||
showEmptyGroup,
|
||||
handleIssues,
|
||||
quickActions,
|
||||
displayProperties,
|
||||
isReadOnly,
|
||||
snapshot,
|
||||
isDragDisabled,
|
||||
} = props;
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
@ -44,20 +56,29 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = (props) => {
|
||||
if (issueToUpdate) handleIssues(sub_group_by, group_by, issueToUpdate, EIssueActions.UPDATE);
|
||||
};
|
||||
|
||||
const handleIssuePeekOverview = () => {
|
||||
const handleIssuePeekOverview = (event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
const { query } = router;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
const issueUrl = `/${issue.workspace_detail.slug}/projects/${issue.project_detail.id}/issues/${issue?.id}`;
|
||||
window.open(issueUrl, "_blank"); // Open link in a new tab
|
||||
} else {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`flex flex-col space-y-2 cursor-pointer rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm shadow-custom-shadow-2xs transition-all w-full ${
|
||||
isDragDisabled ? "" : "hover:cursor-grab"
|
||||
} ${snapshot.isDragging ? `border-custom-primary-100` : `border-transparent`}`}
|
||||
onClick={handleIssuePeekOverview}
|
||||
>
|
||||
{displayProperties && displayProperties?.key && (
|
||||
<div className="relative">
|
||||
<div className="line-clamp-1 text-xs text-custom-text-300">
|
||||
<div className="relative w-full ">
|
||||
<div className="line-clamp-1 text-xs text-left text-custom-text-300">
|
||||
{issue.project_detail.identifier}-{issue.sequence_id}
|
||||
</div>
|
||||
<div className="absolute -top-1 right-0 hidden group-hover/kanban-block:block">
|
||||
@ -70,9 +91,7 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<div className="line-clamp-2 text-sm font-medium text-custom-text-100" onClick={handleIssuePeekOverview}>
|
||||
{issue.name}
|
||||
</div>
|
||||
<div className="line-clamp-2 text-sm font-medium text-custom-text-100">{issue.name}</div>
|
||||
</Tooltip>
|
||||
<div>
|
||||
<KanBanProperties
|
||||
@ -85,7 +104,7 @@ const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = (props) => {
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@ -121,10 +140,10 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Draggable draggableId={draggableId} index={index}>
|
||||
<Draggable draggableId={draggableId} index={index} isDragDisabled={!canEditIssueProperties}>
|
||||
{(provided, snapshot) => (
|
||||
<div
|
||||
className="group/kanban-block relative p-1.5 hover:cursor-default"
|
||||
className="group/kanban-block relative p-1.5"
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
ref={provided.innerRef}
|
||||
@ -132,11 +151,6 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
|
||||
{issue.tempId !== undefined && (
|
||||
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
|
||||
)}
|
||||
<div
|
||||
className={`space-y-2 rounded border-[0.5px] border-custom-border-200 bg-custom-background-100 px-3 py-2 text-sm shadow-custom-shadow-2xs transition-all ${
|
||||
isDragDisabled ? "" : "hover:cursor-grab"
|
||||
} ${snapshot.isDragging ? `border-custom-primary-100` : `border-transparent`}`}
|
||||
>
|
||||
<KanbanIssueMemoBlock
|
||||
sub_group_id={sub_group_id}
|
||||
columnId={columnId}
|
||||
@ -146,9 +160,10 @@ export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
|
||||
quickActions={quickActions}
|
||||
displayProperties={displayProperties}
|
||||
isReadOnly={!canEditIssueProperties}
|
||||
snapshot={snapshot}
|
||||
isDragDisabled={isDragDisabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Draggable>
|
||||
</>
|
||||
|
@ -57,7 +57,7 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
|
||||
);
|
||||
};
|
||||
|
||||
const handleStartDate = (date: string) => {
|
||||
const handleStartDate = (date: string | null) => {
|
||||
handleIssues(
|
||||
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
|
||||
!group_id && group_id === "null" ? null : group_id,
|
||||
@ -65,7 +65,7 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
|
||||
);
|
||||
};
|
||||
|
||||
const handleTargetDate = (date: string) => {
|
||||
const handleTargetDate = (date: string | null) => {
|
||||
handleIssues(
|
||||
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
|
||||
!group_id && group_id === "null" ? null : group_id,
|
||||
@ -122,7 +122,7 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
|
||||
{displayProperties && displayProperties?.start_date && (
|
||||
<IssuePropertyDate
|
||||
value={issue?.start_date || null}
|
||||
onChange={(date: string) => handleStartDate(date)}
|
||||
onChange={(date) => handleStartDate(date)}
|
||||
disabled={isReadOnly}
|
||||
type="start_date"
|
||||
/>
|
||||
@ -132,7 +132,7 @@ export const KanBanProperties: React.FC<IKanBanProperties> = observer((props) =>
|
||||
{displayProperties && displayProperties?.due_date && (
|
||||
<IssuePropertyDate
|
||||
value={issue?.target_date || null}
|
||||
onChange={(date: string) => handleTargetDate(date)}
|
||||
onChange={(date) => handleTargetDate(date)}
|
||||
disabled={isReadOnly}
|
||||
type="target_date"
|
||||
/>
|
||||
|
@ -168,7 +168,9 @@ export const BaseListRoot = observer((props: IBaseListRoot) => {
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={peekProjectId.toString()}
|
||||
issueId={peekIssueId.toString()}
|
||||
handleIssue={async (issueToUpdate) => await handleIssues(issueToUpdate as IIssue, EIssueActions.UPDATE)}
|
||||
handleIssue={async (issueToUpdate, action: EIssueActions) =>
|
||||
await handleIssues(issueToUpdate as IIssue, action)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
@ -25,20 +25,27 @@ export const IssueBlock: React.FC<IssueBlockProps> = (props) => {
|
||||
handleIssues(issueToUpdate, EIssueActions.UPDATE);
|
||||
};
|
||||
|
||||
const handleIssuePeekOverview = () => {
|
||||
const handleIssuePeekOverview = (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
|
||||
const { query } = router;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
const issueUrl = `/${issue.workspace_detail.slug}/projects/${issue.project_detail.id}/issues/${issue?.id}`;
|
||||
window.open(issueUrl, "_blank"); // Open link in a new tab
|
||||
} else {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const canEditIssueProperties = canEditProperties(issue.project);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm">
|
||||
<button
|
||||
className="relative flex items-center gap-3 bg-custom-background-100 p-3 text-sm w-full"
|
||||
onClick={handleIssuePeekOverview}
|
||||
>
|
||||
{displayProperties && displayProperties?.key && (
|
||||
<div className="flex-shrink-0 text-xs font-medium text-custom-text-300">
|
||||
{issue?.project_detail?.identifier}-{issue.sequence_id}
|
||||
@ -49,10 +56,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = (props) => {
|
||||
<div className="absolute left-0 top-0 z-[99999] h-full w-full animate-pulse bg-custom-background-100/20" />
|
||||
)}
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<div
|
||||
className="line-clamp-1 w-full cursor-pointer text-sm font-medium text-custom-text-100"
|
||||
onClick={handleIssuePeekOverview}
|
||||
>
|
||||
<div className="line-clamp-1 w-full cursor-pointer text-sm font-medium text-custom-text-100 text-left">
|
||||
{issue.name}
|
||||
</div>
|
||||
</Tooltip>
|
||||
@ -75,7 +79,7 @@ export const IssueBlock: React.FC<IssueBlockProps> = (props) => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -40,11 +40,11 @@ export const ListProperties: FC<IListProperties> = observer((props) => {
|
||||
handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, assignees: ids });
|
||||
};
|
||||
|
||||
const handleStartDate = (date: string) => {
|
||||
const handleStartDate = (date: string | null) => {
|
||||
handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, start_date: date });
|
||||
};
|
||||
|
||||
const handleTargetDate = (date: string) => {
|
||||
const handleTargetDate = (date: string | null) => {
|
||||
handleIssues(!group_id && group_id === "null" ? null : group_id, { ...issue, target_date: date });
|
||||
};
|
||||
|
||||
@ -106,7 +106,7 @@ export const ListProperties: FC<IListProperties> = observer((props) => {
|
||||
{displayProperties && displayProperties?.start_date && (
|
||||
<IssuePropertyDate
|
||||
value={issue?.start_date || null}
|
||||
onChange={(date: string) => handleStartDate(date)}
|
||||
onChange={(date) => handleStartDate(date)}
|
||||
disabled={isReadonly}
|
||||
type="start_date"
|
||||
/>
|
||||
@ -116,7 +116,7 @@ export const ListProperties: FC<IListProperties> = observer((props) => {
|
||||
{displayProperties && displayProperties?.due_date && (
|
||||
<IssuePropertyDate
|
||||
value={issue?.target_date || null}
|
||||
onChange={(date: string) => handleTargetDate(date)}
|
||||
onChange={(date) => handleTargetDate(date)}
|
||||
disabled={isReadonly}
|
||||
type="target_date"
|
||||
/>
|
||||
|
@ -42,7 +42,7 @@ export const IssuePropertyAssignee: React.FC<IIssuePropertyAssignee> = observer(
|
||||
// store
|
||||
const {
|
||||
workspace: workspaceStore,
|
||||
projectMember: { projectMembers: _projectMembers, fetchProjectMembers },
|
||||
projectMember: { members: _members, fetchProjectMembers },
|
||||
} = useMobxStore();
|
||||
const workspaceSlug = workspaceStore?.workspaceSlug;
|
||||
// states
|
||||
@ -51,14 +51,14 @@ export const IssuePropertyAssignee: React.FC<IIssuePropertyAssignee> = observer(
|
||||
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
||||
const [isLoading, setIsLoading] = useState<Boolean>(false);
|
||||
|
||||
const getWorkspaceMembers = () => {
|
||||
const getProjectMembers = () => {
|
||||
setIsLoading(true);
|
||||
if (workspaceSlug && projectId) fetchProjectMembers(workspaceSlug, projectId).then(() => setIsLoading(false));
|
||||
};
|
||||
|
||||
const updatedDefaultOptions: IProjectMember[] =
|
||||
defaultOptions.map((member: any) => ({ member: { ...member } })) ?? [];
|
||||
const projectMembers = _projectMembers ?? updatedDefaultOptions;
|
||||
const projectMembers = projectId && _members[projectId] ? _members[projectId] : updatedDefaultOptions;
|
||||
|
||||
const options = projectMembers?.map((member) => ({
|
||||
value: member.member.id,
|
||||
@ -100,7 +100,7 @@ export const IssuePropertyAssignee: React.FC<IIssuePropertyAssignee> = observer(
|
||||
|
||||
const label = (
|
||||
<Tooltip tooltipHeading="Assignee" tooltipContent={getTooltipContent()} position="top">
|
||||
<div className="flex h-full w-full cursor-pointer items-center gap-2 text-custom-text-200">
|
||||
<div className="flex h-full w-full items-center gap-2 text-custom-text-200">
|
||||
{value && value.length > 0 && Array.isArray(value) ? (
|
||||
<AvatarGroup showTooltip={false}>
|
||||
{value.map((assigneeId) => {
|
||||
@ -142,7 +142,10 @@ export const IssuePropertyAssignee: React.FC<IIssuePropertyAssignee> = observer(
|
||||
className={`flex w-full items-center justify-between gap-1 text-xs ${
|
||||
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer"
|
||||
} ${buttonClassName}`}
|
||||
onClick={() => !projectMembers && getWorkspaceMembers()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
(!projectId || !_members[projectId]) && getProjectMembers();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
||||
@ -168,7 +171,7 @@ export const IssuePropertyAssignee: React.FC<IIssuePropertyAssignee> = observer(
|
||||
<div className={`mt-2 max-h-48 space-y-1 overflow-y-scroll`}>
|
||||
{isLoading ? (
|
||||
<p className="text-center text-custom-text-200">Loading...</p>
|
||||
) : filteredOptions.length > 0 ? (
|
||||
) : filteredOptions && filteredOptions.length > 0 ? (
|
||||
filteredOptions.map((option) => (
|
||||
<Combobox.Option
|
||||
key={option.value}
|
||||
@ -178,6 +181,7 @@ export const IssuePropertyAssignee: React.FC<IIssuePropertyAssignee> = observer(
|
||||
active && !selected ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
|
@ -12,11 +12,11 @@ import { Tooltip } from "@plane/ui";
|
||||
// hooks
|
||||
import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown";
|
||||
// helpers
|
||||
import { renderDateFormat } from "helpers/date-time.helper";
|
||||
import { renderDateFormat, renderFormattedDate } from "helpers/date-time.helper";
|
||||
|
||||
export interface IIssuePropertyDate {
|
||||
value: any;
|
||||
onChange: (date: any) => void;
|
||||
value: string | null;
|
||||
onChange: (date: string | null) => void;
|
||||
disabled?: boolean;
|
||||
type: "start_date" | "target_date";
|
||||
}
|
||||
@ -56,7 +56,17 @@ export const IssuePropertyDate: React.FC<IIssuePropertyDate> = observer((props)
|
||||
return (
|
||||
<>
|
||||
<Popover.Button
|
||||
as="button"
|
||||
type="button"
|
||||
ref={dropdownBtn}
|
||||
className="border-none outline-none"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tooltip
|
||||
tooltipHeading={dateOptionDetails.placeholder}
|
||||
tooltipContent={value ? renderFormattedDate(value) : "None"}
|
||||
>
|
||||
<div
|
||||
className={`flex h-5 w-full items-center rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 outline-none duration-300 ${
|
||||
disabled
|
||||
? "pointer-events-none cursor-not-allowed text-custom-text-200"
|
||||
@ -67,10 +77,7 @@ export const IssuePropertyDate: React.FC<IIssuePropertyDate> = observer((props)
|
||||
<dateOptionDetails.icon className="h-3 w-3" strokeWidth={2} />
|
||||
{value && (
|
||||
<>
|
||||
<Tooltip tooltipHeading={dateOptionDetails.placeholder} tooltipContent={value ?? "None"}>
|
||||
<div className="text-xs">{value}</div>
|
||||
</Tooltip>
|
||||
|
||||
<div
|
||||
className="flex flex-shrink-0 items-center justify-center"
|
||||
onClick={() => {
|
||||
@ -82,6 +89,8 @@ export const IssuePropertyDate: React.FC<IIssuePropertyDate> = observer((props)
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Popover.Button>
|
||||
|
||||
<div className={`${open ? "fixed left-0 top-0 z-20 h-full w-full cursor-auto" : ""}`}>
|
||||
@ -92,7 +101,8 @@ export const IssuePropertyDate: React.FC<IIssuePropertyDate> = observer((props)
|
||||
{({ close }) => (
|
||||
<DatePicker
|
||||
selected={value ? new Date(value) : new Date()}
|
||||
onChange={(val: any) => {
|
||||
onChange={(val, e) => {
|
||||
e?.stopPropagation();
|
||||
if (onChange && val) {
|
||||
onChange(renderDateFormat(val));
|
||||
close();
|
||||
|
@ -116,6 +116,7 @@ export const IssuePropertyEstimates: React.FC<IIssuePropertyEstimates> = observe
|
||||
className={`flex h-5 w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs ${
|
||||
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{label}
|
||||
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
||||
@ -150,6 +151,7 @@ export const IssuePropertyEstimates: React.FC<IIssuePropertyEstimates> = observe
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
|
@ -107,7 +107,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
||||
{projectLabels
|
||||
?.filter((l) => value.includes(l.id))
|
||||
.map((label) => (
|
||||
<Tooltip position="top" tooltipHeading="Labels" tooltipContent={label.name ?? ""}>
|
||||
<Tooltip position="top" tooltipHeading="Label" tooltipContent={label.name ?? ""}>
|
||||
<div
|
||||
key={label.id}
|
||||
className={`flex overflow-hidden hover:bg-custom-background-80 ${
|
||||
@ -145,6 +145,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Tooltip position="top" tooltipHeading="Labels" tooltipContent="None">
|
||||
<div
|
||||
className={`h-full flex items-center justify-center gap-2 rounded px-2.5 py-1 text-xs hover:bg-custom-background-80 ${
|
||||
noLabelBorder ? "" : "border-[0.5px] border-custom-border-300"
|
||||
@ -153,6 +154,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
||||
<Tags className="h-3.5 w-3.5" strokeWidth={2} />
|
||||
{placeholderText}
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@ -177,7 +179,10 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
||||
? "cursor-pointer"
|
||||
: "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
onClick={() => !storeLabels && fetchLabels()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
!storeLabels && fetchLabels();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
||||
@ -214,6 +219,7 @@ export const IssuePropertyLabels: React.FC<IIssuePropertyLabels> = observer((pro
|
||||
selected ? "text-custom-text-100" : "text-custom-text-200"
|
||||
}`
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
|
@ -121,7 +121,10 @@ export const IssuePropertyState: React.FC<IIssuePropertyState> = observer((props
|
||||
className={`flex h-5 w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs ${
|
||||
disabled ? "cursor-not-allowed text-custom-text-200" : "cursor-pointer hover:bg-custom-background-80"
|
||||
} ${buttonClassName}`}
|
||||
onClick={() => !storeStates && fetchProjectStates()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
!storeStates && fetchProjectStates();
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
{!hideDropdownArrow && !disabled && <ChevronDown className="h-3 w-3" aria-hidden="true" />}
|
||||
@ -157,6 +160,7 @@ export const IssuePropertyState: React.FC<IIssuePropertyState> = observer((props
|
||||
active ? "bg-custom-background-80" : ""
|
||||
} ${selected ? "text-custom-text-100" : "text-custom-text-200"}`
|
||||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{({ selected }) => (
|
||||
<>
|
||||
|
@ -58,7 +58,12 @@ export const AllIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
||||
}}
|
||||
currentStore={EProjectStore.PROJECT}
|
||||
/>
|
||||
<CustomMenu placement="bottom-start" customButton={customActionButton} ellipsis>
|
||||
<CustomMenu
|
||||
placement="bottom-start"
|
||||
customButton={customActionButton}
|
||||
ellipsis
|
||||
menuButtonOnClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
@ -40,7 +40,12 @@ export const ArchivedIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
|
||||
handleClose={() => setDeleteIssueModal(false)}
|
||||
onSubmit={handleDelete}
|
||||
/>
|
||||
<CustomMenu placement="bottom-start" customButton={customActionButton} ellipsis>
|
||||
<CustomMenu
|
||||
placement="bottom-start"
|
||||
customButton={customActionButton}
|
||||
ellipsis
|
||||
menuButtonOnClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
@ -58,7 +58,12 @@ export const CycleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
||||
}}
|
||||
currentStore={EProjectStore.CYCLE}
|
||||
/>
|
||||
<CustomMenu placement="bottom-start" customButton={customActionButton} ellipsis>
|
||||
<CustomMenu
|
||||
placement="bottom-start"
|
||||
customButton={customActionButton}
|
||||
ellipsis
|
||||
menuButtonOnClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
@ -58,7 +58,13 @@ export const ModuleIssueQuickActions: React.FC<IQuickActionProps> = (props) => {
|
||||
}}
|
||||
currentStore={EProjectStore.MODULE}
|
||||
/>
|
||||
<CustomMenu placement="bottom-start" customButton={customActionButton} ellipsis>
|
||||
|
||||
<CustomMenu
|
||||
placement="bottom-start"
|
||||
customButton={customActionButton}
|
||||
ellipsis
|
||||
menuButtonOnClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
@ -68,7 +68,12 @@ export const ProjectIssueQuickActions: React.FC<IQuickActionProps> = (props) =>
|
||||
}}
|
||||
currentStore={EProjectStore.PROJECT}
|
||||
/>
|
||||
<CustomMenu placement="bottom-start" customButton={customActionButton} ellipsis>
|
||||
<CustomMenu
|
||||
placement="bottom-start"
|
||||
customButton={customActionButton}
|
||||
ellipsis
|
||||
menuButtonOnClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CustomMenu.MenuItem
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
|
@ -9,7 +9,7 @@ import { ArchivedIssueListLayout, ArchivedIssueAppliedFiltersRoot } from "compon
|
||||
|
||||
export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string };
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const {
|
||||
projectArchivedIssues: { getIssues, fetchIssues },
|
||||
@ -18,8 +18,8 @@ export const ArchivedIssueLayoutRoot: React.FC = observer(() => {
|
||||
|
||||
useSWR(workspaceSlug && projectId ? `ARCHIVED_FILTERS_AND_ISSUES_${projectId.toString()}` : null, async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
await fetchFilters(workspaceSlug, projectId);
|
||||
await fetchIssues(workspaceSlug, projectId, getIssues ? "mutation" : "init-loader");
|
||||
await fetchFilters(workspaceSlug.toString(), projectId.toString());
|
||||
await fetchIssues(workspaceSlug.toString(), projectId.toString(), getIssues ? "mutation" : "init-loader");
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -24,11 +24,7 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
const [transferIssuesModal, setTransferIssuesModal] = useState(false);
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, cycleId } = router.query as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
cycleId: string;
|
||||
};
|
||||
const { workspaceSlug, projectId, cycleId } = router.query;
|
||||
|
||||
const {
|
||||
cycle: cycleStore,
|
||||
@ -40,8 +36,13 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
workspaceSlug && projectId && cycleId ? `CYCLE_ISSUES_V3_${workspaceSlug}_${projectId}_${cycleId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId && cycleId) {
|
||||
await fetchFilters(workspaceSlug, projectId, cycleId);
|
||||
await fetchIssues(workspaceSlug, projectId, getIssues ? "mutation" : "init-loader", cycleId);
|
||||
await fetchFilters(workspaceSlug.toString(), projectId.toString(), cycleId.toString());
|
||||
await fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
getIssues ? "mutation" : "init-loader",
|
||||
cycleId.toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -69,7 +70,11 @@ export const CycleLayoutRoot: React.FC = observer(() => {
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(getIssues ?? {}).length == 0 ? (
|
||||
<CycleEmptyState workspaceSlug={workspaceSlug} projectId={projectId} cycleId={cycleId} />
|
||||
<CycleEmptyState
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
cycleId={cycleId?.toString()}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
{activeLayout === "list" ? (
|
||||
|
@ -11,7 +11,7 @@ import { DraftKanBanLayout } from "../kanban/roots/draft-issue-root";
|
||||
|
||||
export const DraftIssueLayoutRoot: React.FC = observer(() => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string };
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const {
|
||||
projectDraftIssuesFilter: { issueFilters, fetchFilters },
|
||||
@ -20,8 +20,8 @@ export const DraftIssueLayoutRoot: React.FC = observer(() => {
|
||||
|
||||
useSWR(workspaceSlug && projectId ? `DRAFT_FILTERS_AND_ISSUES_${projectId.toString()}` : null, async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
await fetchFilters(workspaceSlug, projectId);
|
||||
await fetchIssues(workspaceSlug, projectId, getIssues ? "mutation" : "init-loader");
|
||||
await fetchFilters(workspaceSlug.toString(), projectId.toString());
|
||||
await fetchIssues(workspaceSlug.toString(), projectId.toString(), getIssues ? "mutation" : "init-loader");
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -20,11 +20,7 @@ import { Spinner } from "@plane/ui";
|
||||
|
||||
export const ModuleLayoutRoot: React.FC = observer(() => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, moduleId } = router.query as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
moduleId: string;
|
||||
};
|
||||
const { workspaceSlug, projectId, moduleId } = router.query;
|
||||
|
||||
const {
|
||||
moduleIssues: { loader, getIssues, fetchIssues },
|
||||
@ -35,8 +31,13 @@ export const ModuleLayoutRoot: React.FC = observer(() => {
|
||||
workspaceSlug && projectId && moduleId ? `MODULE_ISSUES_V3_${workspaceSlug}_${projectId}_${moduleId}` : null,
|
||||
async () => {
|
||||
if (workspaceSlug && projectId && moduleId) {
|
||||
await fetchFilters(workspaceSlug, projectId, moduleId);
|
||||
await fetchIssues(workspaceSlug, projectId, getIssues ? "mutation" : "init-loader", moduleId);
|
||||
await fetchFilters(workspaceSlug.toString(), projectId.toString(), moduleId.toString());
|
||||
await fetchIssues(
|
||||
workspaceSlug.toString(),
|
||||
projectId.toString(),
|
||||
getIssues ? "mutation" : "init-loader",
|
||||
moduleId.toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
@ -54,7 +55,11 @@ export const ModuleLayoutRoot: React.FC = observer(() => {
|
||||
) : (
|
||||
<>
|
||||
{Object.keys(getIssues ?? {}).length == 0 ? (
|
||||
<ModuleEmptyState workspaceSlug={workspaceSlug} projectId={projectId} moduleId={moduleId} />
|
||||
<ModuleEmptyState
|
||||
workspaceSlug={workspaceSlug?.toString()}
|
||||
projectId={projectId?.toString()}
|
||||
moduleId={moduleId?.toString()}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full w-full overflow-auto">
|
||||
{activeLayout === "list" ? (
|
||||
|
@ -19,7 +19,7 @@ import { Spinner } from "@plane/ui";
|
||||
export const ProjectLayoutRoot: React.FC = observer(() => {
|
||||
// router
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string };
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
|
||||
const {
|
||||
projectIssues: { loader, getIssues, fetchIssues },
|
||||
@ -28,8 +28,8 @@ export const ProjectLayoutRoot: React.FC = observer(() => {
|
||||
|
||||
useSWR(workspaceSlug && projectId ? `PROJECT_ISSUES_V3_${workspaceSlug}_${projectId}` : null, async () => {
|
||||
if (workspaceSlug && projectId) {
|
||||
await fetchFilters(workspaceSlug, projectId);
|
||||
await fetchIssues(workspaceSlug, projectId, getIssues ? "mutation" : "init-loader");
|
||||
await fetchFilters(workspaceSlug.toString(), projectId.toString());
|
||||
await fetchIssues(workspaceSlug.toString(), projectId.toString(), getIssues ? "mutation" : "init-loader");
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -18,11 +18,7 @@ import { Spinner } from "@plane/ui";
|
||||
|
||||
export const ProjectViewLayoutRoot: React.FC = observer(() => {
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId, viewId } = router.query as {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
viewId?: string;
|
||||
};
|
||||
const { workspaceSlug, projectId, viewId } = router.query;
|
||||
|
||||
const {
|
||||
viewIssues: { loader, getIssues, fetchIssues },
|
||||
@ -31,8 +27,8 @@ export const ProjectViewLayoutRoot: React.FC = observer(() => {
|
||||
|
||||
useSWR(workspaceSlug && projectId && viewId ? `PROJECT_ISSUES_V3_${workspaceSlug}_${projectId}` : null, async () => {
|
||||
if (workspaceSlug && projectId && viewId) {
|
||||
await fetchFilters(workspaceSlug, projectId, viewId);
|
||||
await fetchIssues(workspaceSlug, projectId, getIssues ? "mutation" : "init-loader");
|
||||
await fetchFilters(workspaceSlug.toString(), projectId.toString(), viewId.toString());
|
||||
await fetchIssues(workspaceSlug.toString(), projectId.toString(), getIssues ? "mutation" : "init-loader");
|
||||
}
|
||||
});
|
||||
|
||||
|
@ -10,7 +10,7 @@ import { IIssue, IUserLite } from "types";
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
members: IUserLite[] | undefined;
|
||||
onChange: (data: Partial<IIssue>) => void;
|
||||
onChange: (issue: IIssue, data: Partial<IIssue>) => void;
|
||||
expandedIssues: string[];
|
||||
disabled: boolean;
|
||||
};
|
||||
@ -18,7 +18,7 @@ type Props = {
|
||||
export const SpreadsheetAssigneeColumn: React.FC<Props> = ({ issue, members, onChange, expandedIssues, disabled }) => {
|
||||
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
|
||||
|
||||
const { subIssues, isLoading } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded);
|
||||
const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -26,8 +26,13 @@ export const SpreadsheetAssigneeColumn: React.FC<Props> = ({ issue, members, onC
|
||||
projectId={issue.project_detail?.id ?? null}
|
||||
value={issue.assignees}
|
||||
defaultOptions={issue?.assignee_details ? issue.assignee_details : []}
|
||||
onChange={(data) => onChange({ assignees: data })}
|
||||
className="h-11 w-full border-b-[0.5px] border-custom-border-200"
|
||||
onChange={(data) => {
|
||||
onChange(issue, { assignees: data });
|
||||
if (issue.parent) {
|
||||
mutateSubIssues(issue, { assignees: data });
|
||||
}
|
||||
}}
|
||||
className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80"
|
||||
buttonClassName="!shadow-none !border-0 h-full w-full px-2.5 py-1 "
|
||||
noLabelBorder
|
||||
hideDropdownArrow
|
||||
|
@ -18,7 +18,7 @@ export const SpreadsheetAttachmentColumn: React.FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-11 w-full items-center px-2.5 py-1 text-xs border-b-[0.5px] border-custom-border-200">
|
||||
<div className="flex h-11 w-full items-center px-2.5 py-1 text-xs border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80">
|
||||
{issue.attachment_count} {issue.attachment_count === 1 ? "attachment" : "attachments"}
|
||||
</div>
|
||||
|
||||
|
@ -19,7 +19,7 @@ export const SpreadsheetCreatedOnColumn: React.FC<Props> = ({ issue, expandedIss
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-11 w-full items-center justify-center text-xs border-b-[0.5px] border-custom-border-200">
|
||||
<div className="flex h-11 w-full items-center justify-center text-xs border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80">
|
||||
{renderLongDetailDateFormat(issue.created_at)}
|
||||
</div>
|
||||
|
||||
|
@ -9,7 +9,7 @@ import { IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
onChange: (data: Partial<IIssue>) => void;
|
||||
onChange: (issue: IIssue, data: Partial<IIssue>) => void;
|
||||
expandedIssues: string[];
|
||||
disabled: boolean;
|
||||
};
|
||||
@ -17,14 +17,19 @@ type Props = {
|
||||
export const SpreadsheetDueDateColumn: React.FC<Props> = ({ issue, onChange, expandedIssues, disabled }) => {
|
||||
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
|
||||
|
||||
const { subIssues, isLoading } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded);
|
||||
const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewDueDateSelect
|
||||
issue={issue}
|
||||
onChange={(val) => onChange({ target_date: val })}
|
||||
className="flex !h-11 !w-full max-w-full items-center px-2.5 py-1 border-b-[0.5px] border-custom-border-200"
|
||||
onChange={(val) => {
|
||||
onChange(issue, { target_date: val });
|
||||
if (issue.parent) {
|
||||
mutateSubIssues(issue, { target_date: val });
|
||||
}
|
||||
}}
|
||||
className="flex !h-11 !w-full max-w-full items-center px-2.5 py-1 border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80"
|
||||
noBorder
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
@ -7,7 +7,7 @@ import { IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
onChange: (formData: Partial<IIssue>) => void;
|
||||
onChange: (issue: IIssue, formData: Partial<IIssue>) => void;
|
||||
expandedIssues: string[];
|
||||
disabled: boolean;
|
||||
};
|
||||
@ -17,15 +17,20 @@ export const SpreadsheetEstimateColumn: React.FC<Props> = (props) => {
|
||||
|
||||
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
|
||||
|
||||
const { subIssues, isLoading } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded);
|
||||
const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IssuePropertyEstimates
|
||||
projectId={issue.project_detail?.id ?? null}
|
||||
value={issue.estimate_point}
|
||||
onChange={(data) => onChange({ estimate_point: data })}
|
||||
className="h-11 w-full border-b-[0.5px] border-custom-border-200"
|
||||
onChange={(data) => {
|
||||
onChange(issue, { estimate_point: data });
|
||||
if (issue.parent) {
|
||||
mutateSubIssues(issue, { estimate_point: data });
|
||||
}
|
||||
}}
|
||||
className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80"
|
||||
buttonClassName="h-full w-full px-2.5 py-1 !shadow-none !border-0"
|
||||
hideDropdownArrow
|
||||
disabled={disabled}
|
||||
|
@ -34,13 +34,17 @@ export const IssueColumn: React.FC<Props> = ({
|
||||
|
||||
const menuActionRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const handleIssuePeekOverview = (issue: IIssue) => {
|
||||
const handleIssuePeekOverview = (issue: IIssue, event: React.MouseEvent<HTMLDivElement, MouseEvent>) => {
|
||||
const { query } = router;
|
||||
|
||||
if (event.ctrlKey || event.metaKey) {
|
||||
const issueUrl = `/${issue.workspace_detail.slug}/projects/${issue.project_detail.id}/issues/${issue?.id}`;
|
||||
window.open(issueUrl, "_blank"); // Open link in a new tab
|
||||
} else {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project },
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const paddingLeft = `${nestingLevel * 54}px`;
|
||||
@ -99,7 +103,7 @@ export const IssueColumn: React.FC<Props> = ({
|
||||
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
|
||||
<div
|
||||
className="h-full w-full cursor-pointer truncate px-4 py-2.5 text-left text-[0.825rem] text-custom-text-100"
|
||||
onClick={() => handleIssuePeekOverview(issue)}
|
||||
onClick={(e) => handleIssuePeekOverview(issue, e)}
|
||||
>
|
||||
{issue.name}
|
||||
</div>
|
||||
|
@ -9,7 +9,7 @@ import { IIssue, IIssueLabel } from "types";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
onChange: (formData: Partial<IIssue>) => void;
|
||||
onChange: (issue: IIssue, formData: Partial<IIssue>) => void;
|
||||
labels: IIssueLabel[] | undefined;
|
||||
expandedIssues: string[];
|
||||
disabled: boolean;
|
||||
@ -20,7 +20,7 @@ export const SpreadsheetLabelColumn: React.FC<Props> = (props) => {
|
||||
|
||||
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
|
||||
|
||||
const { subIssues, isLoading } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded);
|
||||
const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -28,8 +28,13 @@ export const SpreadsheetLabelColumn: React.FC<Props> = (props) => {
|
||||
projectId={issue.project_detail?.id ?? null}
|
||||
value={issue.labels}
|
||||
defaultOptions={issue?.label_details ? issue.label_details : []}
|
||||
onChange={(data) => onChange({ labels: data })}
|
||||
className="h-11 w-full border-b-[0.5px] border-custom-border-200"
|
||||
onChange={(data) => {
|
||||
onChange(issue, { labels: data });
|
||||
if (issue.parent) {
|
||||
mutateSubIssues(issue, { assignees: data });
|
||||
}
|
||||
}}
|
||||
className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80"
|
||||
buttonClassName="px-2.5 h-full"
|
||||
hideDropdownArrow
|
||||
maxRender={1}
|
||||
|
@ -18,7 +18,7 @@ export const SpreadsheetLinkColumn: React.FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-11 w-full items-center px-2.5 py-1 text-xs border-b-[0.5px] border-custom-border-200">
|
||||
<div className="flex h-11 w-full items-center px-2.5 py-1 text-xs border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80">
|
||||
{issue.link_count} {issue.link_count === 1 ? "link" : "links"}
|
||||
</div>
|
||||
|
||||
|
@ -9,7 +9,7 @@ import { IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
onChange: (data: Partial<IIssue>) => void;
|
||||
onChange: (issue: IIssue, data: Partial<IIssue>) => void;
|
||||
expandedIssues: string[];
|
||||
disabled: boolean;
|
||||
};
|
||||
@ -17,14 +17,19 @@ type Props = {
|
||||
export const SpreadsheetPriorityColumn: React.FC<Props> = ({ issue, onChange, expandedIssues, disabled }) => {
|
||||
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
|
||||
|
||||
const { subIssues, isLoading } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded);
|
||||
const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PrioritySelect
|
||||
value={issue.priority}
|
||||
onChange={(data) => onChange({ priority: data })}
|
||||
className="h-11 w-full border-b-[0.5px] border-custom-border-200"
|
||||
onChange={(data) => {
|
||||
onChange(issue, { priority: data });
|
||||
if (issue.parent) {
|
||||
mutateSubIssues(issue, { priority: data });
|
||||
}
|
||||
}}
|
||||
className="h-11 w-full border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80"
|
||||
buttonClassName="!shadow-none !border-0 h-full w-full px-2.5 py-1"
|
||||
showTitle
|
||||
highlightUrgentPriority={false}
|
||||
|
@ -9,7 +9,7 @@ import { IIssue } from "types";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
onChange: (formData: Partial<IIssue>) => void;
|
||||
onChange: (issue: IIssue, formData: Partial<IIssue>) => void;
|
||||
expandedIssues: string[];
|
||||
disabled: boolean;
|
||||
};
|
||||
@ -17,14 +17,19 @@ type Props = {
|
||||
export const SpreadsheetStartDateColumn: React.FC<Props> = ({ issue, onChange, expandedIssues, disabled }) => {
|
||||
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
|
||||
|
||||
const { subIssues, isLoading } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded);
|
||||
const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ViewStartDateSelect
|
||||
issue={issue}
|
||||
onChange={(val) => onChange({ start_date: val })}
|
||||
className="flex !h-11 !w-full max-w-full items-center px-2.5 py-1 border-b-[0.5px] border-custom-border-200"
|
||||
onChange={(val) => {
|
||||
onChange(issue, { start_date: val });
|
||||
if (issue.parent) {
|
||||
mutateSubIssues(issue, { start_date: val });
|
||||
}
|
||||
}}
|
||||
className="flex !h-11 !w-full max-w-full items-center px-2.5 py-1 border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80"
|
||||
noBorder
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
@ -9,7 +9,7 @@ import { IIssue, IState } from "types";
|
||||
|
||||
type Props = {
|
||||
issue: IIssue;
|
||||
onChange: (data: Partial<IIssue>) => void;
|
||||
onChange: (issue: IIssue, data: Partial<IIssue>) => void;
|
||||
states: IState[] | undefined;
|
||||
expandedIssues: string[];
|
||||
disabled: boolean;
|
||||
@ -20,7 +20,7 @@ export const SpreadsheetStateColumn: React.FC<Props> = (props) => {
|
||||
|
||||
const isExpanded = expandedIssues.indexOf(issue.id) > -1;
|
||||
|
||||
const { subIssues, isLoading } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded);
|
||||
const { subIssues, isLoading, mutateSubIssues } = useSubIssue(issue.project_detail?.id, issue.id, isExpanded);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -28,7 +28,12 @@ export const SpreadsheetStateColumn: React.FC<Props> = (props) => {
|
||||
projectId={issue.project_detail?.id ?? null}
|
||||
value={issue.state}
|
||||
defaultOptions={issue?.state_detail ? [issue.state_detail] : []}
|
||||
onChange={(data) => onChange({ state: data.id, state_detail: data })}
|
||||
onChange={(data) => {
|
||||
onChange(issue, { state: data.id, state_detail: data });
|
||||
if (issue.parent) {
|
||||
mutateSubIssues(issue, { state: data.id, state_detail: data });
|
||||
}
|
||||
}}
|
||||
className="w-full !h-11 border-b-[0.5px] border-custom-border-200"
|
||||
buttonClassName="!shadow-none !border-0 h-full w-full"
|
||||
hideDropdownArrow
|
||||
|
@ -18,7 +18,7 @@ export const SpreadsheetSubIssueColumn: React.FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-11 w-full items-center px-2.5 py-1 text-xs border-b-[0.5px] border-custom-border-200">
|
||||
<div className="flex h-11 w-full items-center px-2.5 py-1 text-xs border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80">
|
||||
{issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
|
||||
</div>
|
||||
|
||||
|
@ -21,7 +21,7 @@ export const SpreadsheetUpdatedOnColumn: React.FC<Props> = (props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex h-11 w-full items-center justify-center text-xs border-b-[0.5px] border-custom-border-200">
|
||||
<div className="flex h-11 w-full items-center justify-center text-xs border-b-[0.5px] border-custom-border-200 hover:bg-custom-background-80">
|
||||
{renderLongDetailDateFormat(issue.updated_at)}
|
||||
</div>
|
||||
|
||||
|
@ -163,18 +163,13 @@ export const SpreadsheetColumn: React.FC<Props> = (props) => {
|
||||
{issues?.map((issue) => {
|
||||
const disableUserActions = !canEditProperties(issue.project);
|
||||
return (
|
||||
<div
|
||||
key={`${property}-${issue.id}`}
|
||||
className={`h-fit ${
|
||||
disableUserActions ? "" : "cursor-pointer hover:bg-custom-background-80"
|
||||
}`}
|
||||
>
|
||||
<div key={`${property}-${issue.id}`} className={`h-fit ${disableUserActions ? "" : "cursor-pointer"}`}>
|
||||
{property === "state" ? (
|
||||
<SpreadsheetStateColumn
|
||||
disabled={disableUserActions}
|
||||
expandedIssues={expandedIssues}
|
||||
issue={issue}
|
||||
onChange={(data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||
onChange={(issue: IIssue, data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||
states={states}
|
||||
/>
|
||||
) : property === "priority" ? (
|
||||
@ -182,14 +177,14 @@ export const SpreadsheetColumn: React.FC<Props> = (props) => {
|
||||
disabled={disableUserActions}
|
||||
expandedIssues={expandedIssues}
|
||||
issue={issue}
|
||||
onChange={(data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||
onChange={(issue: IIssue, data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||
/>
|
||||
) : property === "estimate" ? (
|
||||
<SpreadsheetEstimateColumn
|
||||
disabled={disableUserActions}
|
||||
expandedIssues={expandedIssues}
|
||||
issue={issue}
|
||||
onChange={(data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||
onChange={(issue: IIssue, data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||
/>
|
||||
) : property === "assignee" ? (
|
||||
<SpreadsheetAssigneeColumn
|
||||
@ -197,7 +192,7 @@ export const SpreadsheetColumn: React.FC<Props> = (props) => {
|
||||
expandedIssues={expandedIssues}
|
||||
issue={issue}
|
||||
members={members}
|
||||
onChange={(data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||
onChange={(issue: IIssue, data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||
/>
|
||||
) : property === "labels" ? (
|
||||
<SpreadsheetLabelColumn
|
||||
@ -205,21 +200,21 @@ export const SpreadsheetColumn: React.FC<Props> = (props) => {
|
||||
expandedIssues={expandedIssues}
|
||||
issue={issue}
|
||||
labels={labels}
|
||||
onChange={(data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||
onChange={(issue: IIssue, data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||
/>
|
||||
) : property === "start_date" ? (
|
||||
<SpreadsheetStartDateColumn
|
||||
disabled={disableUserActions}
|
||||
expandedIssues={expandedIssues}
|
||||
issue={issue}
|
||||
onChange={(data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||
onChange={(issue: IIssue, data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||
/>
|
||||
) : property === "due_date" ? (
|
||||
<SpreadsheetDueDateColumn
|
||||
disabled={disableUserActions}
|
||||
expandedIssues={expandedIssues}
|
||||
issue={issue}
|
||||
onChange={(data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||
onChange={(issue: IIssue, data: Partial<IIssue>) => handleUpdateIssue(issue, data)}
|
||||
/>
|
||||
) : property === "created_on" ? (
|
||||
<SpreadsheetCreatedOnColumn expandedIssues={expandedIssues} issue={issue} />
|
||||
|
@ -194,7 +194,7 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
|
||||
workspaceSlug={workspaceSlug.toString()}
|
||||
projectId={peekProjectId.toString()}
|
||||
issueId={peekIssueId.toString()}
|
||||
handleIssue={async (issueToUpdate: any) => await handleIssues(issueToUpdate, EIssueActions.UPDATE)}
|
||||
handleIssue={async (issueToUpdate: any, action: EIssueActions) => await handleIssues(issueToUpdate, action)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -41,7 +41,7 @@ const issueService = new IssueService();
|
||||
const issueCommentService = new IssueCommentService();
|
||||
|
||||
export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||
const { issueDetails, submitChanges, uneditable = false } = props;
|
||||
const { issueDetails, submitChanges, uneditable } = props;
|
||||
// states
|
||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||
// router
|
||||
@ -152,7 +152,9 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const isAllowed = !!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER;
|
||||
const isAllowed =
|
||||
(!!currentProjectRole && currentProjectRole >= EUserWorkspaceRoles.MEMBER) ||
|
||||
(uneditable !== undefined && !uneditable);
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -216,7 +218,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||
</CustomMenu>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mb-5 flex items-center">
|
||||
<div className="mb-2.5 flex items-center">
|
||||
{currentIssueState && (
|
||||
<StateGroupIcon
|
||||
className="mr-3 h-4 w-4"
|
||||
@ -232,7 +234,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||
workspaceSlug={workspaceSlug as string}
|
||||
issue={issueDetails}
|
||||
handleFormSubmit={submitChanges}
|
||||
isAllowed={isAllowed || !uneditable}
|
||||
isAllowed={isAllowed}
|
||||
/>
|
||||
|
||||
{workspaceSlug && projectId && (
|
||||
@ -250,8 +252,8 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||
<div className="flex flex-col gap-3 py-3">
|
||||
<h3 className="text-lg">Attachments</h3>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4">
|
||||
<IssueAttachmentUpload disabled={uneditable} />
|
||||
<IssueAttachments />
|
||||
<IssueAttachmentUpload disabled={!isAllowed} />
|
||||
<IssueAttachments editable={isAllowed} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-5 pt-3">
|
||||
@ -264,7 +266,7 @@ export const IssueMainContent: React.FC<Props> = observer((props) => {
|
||||
/>
|
||||
<AddComment
|
||||
onSubmit={handleAddComment}
|
||||
disabled={uneditable}
|
||||
disabled={!isAllowed}
|
||||
showAccessSpecifier={projectDetails && projectDetails.is_deployed}
|
||||
/>
|
||||
</div>
|
||||
|
@ -153,10 +153,12 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
|
||||
debouncedFormSave();
|
||||
}}
|
||||
required={true}
|
||||
className="min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent !p-0 text-xl outline-none ring-0 focus:!px-3 focus:!py-2 focus:ring-1 focus:ring-custom-primary"
|
||||
className={`min-h-10 block w-full resize-none overflow-hidden rounded border-none bg-transparent !p-0 text-xl outline-none ring-0 focus:!px-3 focus:!py-2 focus:ring-1 focus:ring-custom-primary ${
|
||||
!isAllowed ? "hover:cursor-not-allowed" : ""
|
||||
}`}
|
||||
hasError={Boolean(errors?.description)}
|
||||
role="textbox"
|
||||
disabled={!true}
|
||||
disabled={!isAllowed}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@ -188,7 +190,9 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
|
||||
setShouldShowAlert={setShowAlert}
|
||||
setIsSubmitting={setIsSubmitting}
|
||||
dragDropEnabled
|
||||
customClassName={isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200"}
|
||||
customClassName={
|
||||
isAllowed ? "min-h-[150px] shadow-sm" : "!p-0 !pt-2 text-custom-text-200 pointer-events-none"
|
||||
}
|
||||
noBorder={!isAllowed}
|
||||
onChange={(description: Object, description_html: string) => {
|
||||
setShowAlert(true);
|
||||
|
@ -47,7 +47,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
} = useMobxStore();
|
||||
|
||||
const router = useRouter();
|
||||
const { workspaceSlug, projectId } = router.query;
|
||||
const { workspaceSlug, peekProjectId: projectId } = router.query;
|
||||
|
||||
const handleState = (_state: string) => {
|
||||
issueUpdate({ ...issue, state: _state });
|
||||
@ -116,7 +116,12 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
<p>State</p>
|
||||
</div>
|
||||
<div>
|
||||
<SidebarStateSelect value={issue?.state || ""} onChange={handleState} disabled={disableUserActions} />
|
||||
<SidebarStateSelect
|
||||
value={issue?.state || ""}
|
||||
projectId={projectId as string}
|
||||
onChange={handleState}
|
||||
disabled={disableUserActions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -129,6 +134,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
<div>
|
||||
<SidebarAssigneeSelect
|
||||
value={issue.assignees || []}
|
||||
projectId={projectId as string}
|
||||
onChange={handleAssignee}
|
||||
disabled={disableUserActions}
|
||||
/>
|
||||
@ -210,7 +216,12 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
<p>Parent</p>
|
||||
</div>
|
||||
<div>
|
||||
<SidebarParentSelect onChange={handleParent} issueDetails={issue} disabled={disableUserActions} />
|
||||
<SidebarParentSelect
|
||||
onChange={handleParent}
|
||||
issueDetails={issue}
|
||||
projectId={projectId as string}
|
||||
disabled={disableUserActions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -226,6 +237,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
<div>
|
||||
<SidebarCycleSelect
|
||||
issueDetail={issue}
|
||||
projectId={projectId as string}
|
||||
disabled={disableUserActions}
|
||||
handleIssueUpdate={handleCycleOrModuleChange}
|
||||
/>
|
||||
@ -240,6 +252,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
<div>
|
||||
<SidebarModuleSelect
|
||||
issueDetail={issue}
|
||||
projectId={projectId as string}
|
||||
disabled={disableUserActions}
|
||||
handleIssueUpdate={handleCycleOrModuleChange}
|
||||
/>
|
||||
@ -253,6 +266,7 @@ export const PeekOverviewProperties: FC<IPeekOverviewProperties> = observer((pro
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
<SidebarLabelSelect
|
||||
issueDetails={issue}
|
||||
projectId={projectId as string}
|
||||
labelList={issue.labels}
|
||||
submitChanges={handleLabels}
|
||||
isNotAllowed={disableUserActions}
|
||||
|
@ -11,6 +11,7 @@ import { IssueView } from "components/issues";
|
||||
import { copyUrlToClipboard } from "helpers/string.helper";
|
||||
// types
|
||||
import { IIssue, IIssueLink } from "types";
|
||||
import { EIssueActions } from "../issue-layouts/types";
|
||||
// constants
|
||||
import { EUserWorkspaceRoles } from "constants/workspace";
|
||||
|
||||
@ -18,7 +19,7 @@ interface IIssuePeekOverview {
|
||||
workspaceSlug: string;
|
||||
projectId: string;
|
||||
issueId: string;
|
||||
handleIssue: (issue: Partial<IIssue>) => void;
|
||||
handleIssue: (issue: Partial<IIssue>, action: EIssueActions) => Promise<void>;
|
||||
isArchived?: boolean;
|
||||
children?: ReactNode;
|
||||
}
|
||||
@ -30,8 +31,6 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
const { peekIssueId } = router.query;
|
||||
|
||||
const {
|
||||
user: { currentProjectRole },
|
||||
issue: { removeIssueFromStructure },
|
||||
issueDetail: {
|
||||
createIssueComment,
|
||||
updateIssueComment,
|
||||
@ -58,6 +57,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
},
|
||||
archivedIssues: { deleteArchivedIssue },
|
||||
project: { currentProjectDetails },
|
||||
workspaceMember: { currentWorkspaceUserProjectsRole },
|
||||
} = useMobxStore();
|
||||
|
||||
const { setToastAlert } = useToast();
|
||||
@ -98,7 +98,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
|
||||
const issueUpdate = async (_data: Partial<IIssue>) => {
|
||||
if (handleIssue) {
|
||||
await handleIssue(_data);
|
||||
await handleIssue(_data, EIssueActions.UPDATE);
|
||||
fetchIssueActivity(workspaceSlug, projectId, issueId);
|
||||
}
|
||||
};
|
||||
@ -133,7 +133,7 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
|
||||
const handleDeleteIssue = async () => {
|
||||
if (isArchived) await deleteArchivedIssue(workspaceSlug, projectId, issue!);
|
||||
else removeIssueFromStructure(workspaceSlug, projectId, issue!);
|
||||
else await handleIssue(issue!, EIssueActions.DELETE);
|
||||
const { query } = router;
|
||||
if (query.peekIssueId) {
|
||||
setPeekId(null);
|
||||
@ -146,7 +146,8 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
|
||||
}
|
||||
};
|
||||
|
||||
const userRole = currentProjectRole ?? EUserWorkspaceRoles.GUEST;
|
||||
const userRole =
|
||||
(currentWorkspaceUserProjectsRole && currentWorkspaceUserProjectsRole[projectId]) ?? EUserWorkspaceRoles.GUEST;
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { FC, ReactNode, useState } from "react";
|
||||
import { FC, ReactNode, useRef, useState } from "react";
|
||||
import { useRouter } from "next/router";
|
||||
import { observer } from "mobx-react-lite";
|
||||
import useSWR from "swr";
|
||||
@ -14,6 +14,8 @@ import {
|
||||
PeekOverviewIssueDetails,
|
||||
PeekOverviewProperties,
|
||||
} from "components/issues";
|
||||
// hooks
|
||||
import useOutsideClickDetector from "hooks/use-outside-click-detector";
|
||||
// ui
|
||||
import { Button, CenterPanelIcon, CustomSelect, FullScreenPanelIcon, SidePanelIcon, Spinner } from "@plane/ui";
|
||||
// types
|
||||
@ -107,6 +109,8 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
const [peekMode, setPeekMode] = useState<TPeekModes>("side-peek");
|
||||
const [deleteIssueModal, setDeleteIssueModal] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved");
|
||||
// ref
|
||||
const issuePeekOverviewRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const updateRoutePeekId = () => {
|
||||
if (issueId != peekIssueId) {
|
||||
@ -151,6 +155,8 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
|
||||
const currentMode = PEEK_OPTIONS.find((m) => m.key === peekMode);
|
||||
|
||||
useOutsideClickDetector(issuePeekOverviewRef, () => removeRoutePeekId());
|
||||
|
||||
return (
|
||||
<>
|
||||
{issue && !isArchived && (
|
||||
@ -178,6 +184,7 @@ export const IssueView: FC<IIssueView> = observer((props) => {
|
||||
|
||||
{issueId === peekIssueId && (
|
||||
<div
|
||||
ref={issuePeekOverviewRef}
|
||||
className={`fixed z-20 flex flex-col overflow-hidden rounded border border-custom-border-200 bg-custom-background-100 transition-all duration-300
|
||||
${peekMode === "side-peek" ? `bottom-0 right-0 top-0 w-full md:w-[50%]` : ``}
|
||||
${peekMode === "modal" ? `left-[50%] top-[50%] h-5/6 w-5/6 -translate-x-[50%] -translate-y-[50%]` : ``}
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user