diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index c8e27f322..5b5f958d3 100644 --- a/.github/workflows/create-sync-pr.yml +++ b/.github/workflows/create-sync-pr.yml @@ -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 <> ./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? diff --git a/README.md b/README.md index 3f7404305..5b96dbf6c 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Meet [Plane](https://plane.so). An open-source software development tool to mana > Plane is still in its early days, not everything will be perfect yet, and hiccups may happen. Please let us know of any suggestions, ideas, or bugs that you encounter on our [Discord](https://discord.com/invite/A92xrEGCge) or GitHub issues, and we will use your feedback to improve on our upcoming releases. -The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting). +The easiest way to get started with Plane is by creating a [Plane Cloud](https://app.plane.so) account. Plane Cloud offers a hosted solution for Plane. If you prefer to self-host Plane, please refer to our [deployment documentation](https://docs.plane.so/self-hosting/docker-compose). ## ⚡️ Contributors Quick Start @@ -63,7 +63,7 @@ Thats it! ## 🍙 Self Hosting -For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/self-hosting) documentation page +For self hosting environment setup, visit the [Self Hosting](https://docs.plane.so/self-hosting/docker-compose) documentation page ## 🚀 Features diff --git a/apiserver/Dockerfile.dev b/apiserver/Dockerfile.dev index d52020735..cb2d1ca28 100644 --- a/apiserver/Dockerfile.dev +++ b/apiserver/Dockerfile.dev @@ -49,5 +49,5 @@ USER captain # Expose container port and run entry point script EXPOSE 8000 -# CMD [ "./bin/takeoff" ] +CMD [ "./bin/takeoff.local" ] diff --git a/apiserver/bin/takeoff.local b/apiserver/bin/takeoff.local new file mode 100755 index 000000000..b89c20874 --- /dev/null +++ b/apiserver/bin/takeoff.local @@ -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 + diff --git a/apiserver/plane/app/views/project.py b/apiserver/plane/app/views/project.py index c67575db5..5b88e3652 100644 --- a/apiserver/plane/app/views/project.py +++ b/apiserver/plane/app/views/project.py @@ -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,9 +1034,19 @@ class ProjectPublicCoverImagesEndpoint(BaseAPIView): if not content["Key"].endswith( "/" ): # This line ensures we're only getting files, not "sub-folders" - files.append( - f"https://{settings.AWS_STORAGE_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" - ) + 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']}" + ) return Response(files, status=status.HTTP_200_OK) diff --git a/apiserver/plane/app/views/workspace.py b/apiserver/plane/app/views/workspace.py index ed72dbcf1..11170114a 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -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", diff --git a/apiserver/plane/bgtasks/analytic_plot_export.py b/apiserver/plane/bgtasks/analytic_plot_export.py index 4aa86f6ca..a4f5b194c 100644 --- a/apiserver/plane/bgtasks/analytic_plot_export.py +++ b/apiserver/plane/bgtasks/analytic_plot_export.py @@ -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( diff --git a/apiserver/plane/bgtasks/forgot_password_task.py b/apiserver/plane/bgtasks/forgot_password_task.py index 563cc8a40..d790f845d 100644 --- a/apiserver/plane/bgtasks/forgot_password_task.py +++ b/apiserver/plane/bgtasks/forgot_password_task.py @@ -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( diff --git a/apiserver/plane/bgtasks/magic_link_code_task.py b/apiserver/plane/bgtasks/magic_link_code_task.py index 55bbfa0d6..bb61e0ada 100644 --- a/apiserver/plane/bgtasks/magic_link_code_task.py +++ b/apiserver/plane/bgtasks/magic_link_code_task.py @@ -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( diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index 4ec06e623..b9221855b 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -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( diff --git a/apiserver/plane/bgtasks/workspace_invitation_task.py b/apiserver/plane/bgtasks/workspace_invitation_task.py index 1bdc48ca3..7039cb875 100644 --- a/apiserver/plane/bgtasks/workspace_invitation_task.py +++ b/apiserver/plane/bgtasks/workspace_invitation_task.py @@ -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( diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index 6832297e9..0e7a18fa8 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -30,7 +30,7 @@ openpyxl==3.1.2 beautifulsoup4==4.12.2 dj-database-url==2.1.0 posthog==3.0.2 -cryptography==41.0.5 +cryptography==41.0.6 lxml==4.9.3 boto3==1.28.40 diff --git a/deploy/selfhost/install.sh b/deploy/selfhost/install.sh index 645e99cb8..15150aa40 100755 --- a/deploy/selfhost/install.sh +++ b/deploy/selfhost/install.sh @@ -39,7 +39,7 @@ function download(){ echo "" echo "Latest version is now available for you to use" echo "" - echo "In case of Upgrade, your new setting file is availabe as 'variables-upgrade.env'. Please compare and set the required values in '.env 'file." + echo "In case of Upgrade, your new setting file is available as 'variables-upgrade.env'. Please compare and set the required values in '.env 'file." echo "" } diff --git a/docker-compose-local.yml b/docker-compose-local.yml index 58cab3776..4e1e3b39f 100644 --- a/docker-compose-local.yml +++ b/docker-compose-local.yml @@ -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 diff --git a/nginx/Dockerfile.dev b/nginx/Dockerfile.dev new file mode 100644 index 000000000..4b90c0dd5 --- /dev/null +++ b/nginx/Dockerfile.dev @@ -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"] diff --git a/nginx/nginx-single-docker-image.conf b/nginx/nginx-single-docker-image.conf index b9f50d664..a087d4e42 100644 --- a/nginx/nginx-single-docker-image.conf +++ b/nginx/nginx-single-docker-image.conf @@ -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; diff --git a/nginx/nginx.conf.dev b/nginx/nginx.conf.dev new file mode 100644 index 000000000..c78893f9f --- /dev/null +++ b/nginx/nginx.conf.dev @@ -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/; + } + } +} diff --git a/setup.sh b/setup.sh index e1fa026b7..a1d9bcbe1 100755 --- a/setup.sh +++ b/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 diff --git a/space/Dockerfile.dev b/space/Dockerfile.dev index d1128a588..862210c33 100644 --- a/space/Dockerfile.dev +++ b/space/Dockerfile.dev @@ -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"] diff --git a/web/Dockerfile.dev b/web/Dockerfile.dev index 29faedef7..5fa751338 100644 --- a/web/Dockerfile.dev +++ b/web/Dockerfile.dev @@ -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"] diff --git a/web/components/common/new-empty-state.tsx b/web/components/common/new-empty-state.tsx index 058a3988f..508d2e5da 100644 --- a/web/components/common/new-empty-state.tsx +++ b/web/components/common/new-empty-state.tsx @@ -19,7 +19,7 @@ type Props = { icon?: any; text: string; onClick: () => void; - }; + } | null; disabled?: boolean; }; diff --git a/web/components/core/modals/bulk-delete-issues-modal.tsx b/web/components/core/modals/bulk-delete-issues-modal.tsx index 42ff147fa..d745e1111 100644 --- a/web/components/core/modals/bulk-delete-issues-modal.tsx +++ b/web/components/core/modals/bulk-delete-issues-modal.tsx @@ -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) => { +export const BulkDeleteIssuesModal: React.FC = 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) => { ); -}; +}); diff --git a/web/components/core/modals/link-modal.tsx b/web/components/core/modals/link-modal.tsx index f80b58d33..9f0ec41bc 100644 --- a/web/components/core/modals/link-modal.tsx +++ b/web/components/core/modals/link-modal.tsx @@ -118,6 +118,7 @@ export const LinkModal: FC = (props) => { ref={ref} hasError={Boolean(errors.url)} placeholder="https://..." + pattern="^(https?://).*" className="w-full" /> )} diff --git a/web/components/core/sidebar/links-list.tsx b/web/components/core/sidebar/links-list.tsx index b57d6e61b..037d9175b 100644 --- a/web/components/core/sidebar/links-list.tsx +++ b/web/components/core/sidebar/links-list.tsx @@ -50,8 +50,8 @@ export const LinksList: React.FC = ({ links, handleDeleteLink, handleEdit - {!isNotAllowed && ( -
+
+ {!isNotAllowed && ( - - - + )} + + + + {!isNotAllowed && ( -
- )} + )} +

diff --git a/web/components/core/sidebar/sidebar-progress-stats.tsx b/web/components/core/sidebar/sidebar-progress-stats.tsx index ac4ffcdc5..8cea3784f 100644 --- a/web/components/core/sidebar/sidebar-progress-stats.tsx +++ b/web/components/core/sidebar/sidebar-progress-stats.tsx @@ -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 = ({ @@ -44,7 +48,10 @@ export const SidebarProgressStats: React.FC = ({ module, roundedTab, noBackground, + isCompleted = false, isPeekView = false, + filters, + handleFiltersUpdate, }) => { const { storedValue: tab, setValue: setTab } = useLocalStorage("tab", "Assignees"); @@ -140,20 +147,11 @@ export const SidebarProgressStats: React.FC = ({ } 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 ?? ""), + })} /> ); else @@ -200,17 +198,11 @@ export const SidebarProgressStats: React.FC = ({ } 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}`), + })} /> )) ) : ( diff --git a/web/components/cycles/active-cycle-details.tsx b/web/components/cycles/active-cycle-details.tsx index 64ed76132..ea982099f 100644 --- a/web/components/cycles/active-cycle-details.tsx +++ b/web/components/cycles/active-cycle-details.tsx @@ -75,7 +75,7 @@ export const ActiveCycleDetails: React.FC = 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 = observer((props // : null // ) as { data: IIssue[] | undefined }; - if (!cycle) + if (!cycle && isLoading) return ( @@ -187,12 +187,12 @@ export const ActiveCycleDetails: React.FC = observer((props cycleStatus === "current" ? "#09A953" : cycleStatus === "upcoming" - ? "#F7AE59" - : cycleStatus === "completed" - ? "#3F76FF" - : cycleStatus === "draft" - ? "rgb(var(--color-text-200))" - : "" + ? "#F7AE59" + : cycleStatus === "completed" + ? "#3F76FF" + : cycleStatus === "draft" + ? "rgb(var(--color-text-200))" + : "" }`} /> @@ -207,12 +207,12 @@ export const ActiveCycleDetails: React.FC = observer((props cycleStatus === "current" ? "bg-green-600/5 text-green-600" : cycleStatus === "upcoming" - ? "bg-orange-300/5 text-orange-300" - : cycleStatus === "completed" - ? "bg-blue-500/5 text-blue-500" - : cycleStatus === "draft" - ? "bg-neutral-400/5 text-neutral-400" - : "" + ? "bg-orange-300/5 text-orange-300" + : cycleStatus === "completed" + ? "bg-blue-500/5 text-blue-500" + : cycleStatus === "draft" + ? "bg-neutral-400/5 text-neutral-400" + : "" }`} > {cycleStatus === "current" ? ( diff --git a/web/components/cycles/sidebar.tsx b/web/components/cycles/sidebar.tsx index 4ae5c9d8b..1fd1cd05c 100644 --- a/web/components/cycles/sidebar.tsx +++ b/web/components/cycles/sidebar.tsx @@ -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 = 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 = 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 = observer((props) => { ); - 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 = 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 = observer((props) => { - {!isCompleted && ( + {!isCompleted && isEditingAllowed && ( { @@ -349,8 +376,10 @@ export const CycleDetailsSidebar: React.FC = observer((props) => {

{areYearsEqual ? renderShortDate(startDate, "_ _") : renderShortMonthDate(startDate, "_ _")} @@ -373,10 +402,10 @@ export const CycleDetailsSidebar: React.FC = 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} /> @@ -385,8 +414,10 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { <> {areYearsEqual ? renderShortDate(endDate, "_ _") : renderShortMonthDate(endDate, "_ _")} @@ -409,10 +440,10 @@ export const CycleDetailsSidebar: React.FC = 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} /> @@ -528,6 +559,9 @@ export const CycleDetailsSidebar: React.FC = observer((props) => { }} totalIssues={cycleDetails.total_issues} isPeekView={Boolean(peekCycle)} + isCompleted={isCompleted} + filters={issueFilters?.filters} + handleFiltersUpdate={handleFiltersUpdate} />
)} diff --git a/web/components/gantt-chart/sidebar/sidebar.tsx b/web/components/gantt-chart/sidebar/sidebar.tsx index 23f8f8d76..89ecbabba 100644 --- a/web/components/gantt-chart/sidebar/sidebar.tsx +++ b/web/components/gantt-chart/sidebar/sidebar.tsx @@ -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) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars const { - title, blockUpdateHandler, blocks, enableReorder, diff --git a/web/components/headers/cycle-issues.tsx b/web/components/headers/cycle-issues.tsx index e5b1167b2..2526199b5 100644 --- a/web/components/headers/cycle-issues.tsx +++ b/web/components/headers/cycle-issues.tsx @@ -155,7 +155,10 @@ export const CycleIssuesHeader: React.FC = observer(() => { key={cycle.id} onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/cycles/${cycle.id}`)} > - {truncateText(cycle.name, 40)} +
+ + {truncateText(cycle.name, 40)} +
))} @@ -192,20 +195,23 @@ export const CycleIssuesHeader: React.FC = observer(() => { handleDisplayPropertiesUpdate={handleDisplayProperties} /> - + {canUserCreateIssue && ( - + <> + + + )} + {isAuthorizedUser && ( + + )}
diff --git a/web/components/headers/module-issues.tsx b/web/components/headers/module-issues.tsx index f52b7264d..62df6e7c8 100644 --- a/web/components/headers/module-issues.tsx +++ b/web/components/headers/module-issues.tsx @@ -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(() => { - + {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}`)} > - {truncateText(module.name, 40)} +
+ + {truncateText(module.name, 40)} +
))}
@@ -193,20 +196,23 @@ export const ModuleIssuesHeader: React.FC = observer(() => { handleDisplayPropertiesUpdate={handleDisplayProperties} /> - + {canUserCreateIssue && ( - + <> + + + )} + {canUserCreateIssue && ( - + <> + + + )} diff --git a/web/components/headers/project-view-issues.tsx b/web/components/headers/project-view-issues.tsx index de00424dc..f9bcfc508 100644 --- a/web/components/headers/project-view-issues.tsx +++ b/web/components/headers/project-view-issues.tsx @@ -138,7 +138,10 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { key={view.id} onClick={() => router.push(`/${workspaceSlug}/projects/${projectId}/views/${view.id}`)} > - {truncateText(view.name, 40)} +
+ + {truncateText(view.name, 40)} +
))} @@ -152,7 +155,8 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => { onChange={(layout) => handleLayoutChange(layout)} selectedLayout={activeLayout} /> - + + { handleDisplayPropertiesUpdate={handleDisplayProperties} /> - { + {canUserCreateIssue && ( - } + )} ); diff --git a/web/components/headers/project-views.tsx b/web/components/headers/project-views.tsx index 964110967..36c278e82 100644 --- a/web/components/headers/project-views.tsx +++ b/web/components/headers/project-views.tsx @@ -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 ( <>
@@ -50,18 +59,20 @@ export const ProjectViewsHeader: React.FC = observer(() => {
-
-
- + {canUserCreateIssue && ( +
+
+ +
-
+ )}
); diff --git a/web/components/headers/projects.tsx b/web/components/headers/projects.tsx index 2ba0c0184..370dfe6d4 100644 --- a/web/components/headers/projects.tsx +++ b/web/components/headers/projects.tsx @@ -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 (
@@ -44,17 +49,18 @@ export const ProjectsHeader = observer(() => { />
)} - - + {isAuthorizedUser && ( + + )}
); diff --git a/web/components/inbox/main-content.tsx b/web/components/inbox/main-content.tsx index 298a33196..3a0faf248 100644 --- a/web/components/inbox/main-content.tsx +++ b/web/components/inbox/main-content.tsx @@ -165,16 +165,16 @@ export const InboxMainContent: React.FC = observer(() => { issueStatus === -2 ? "border-yellow-500 bg-yellow-500/10 text-yellow-500" : issueStatus === -1 + ? "border-red-500 bg-red-500/10 text-red-500" + : issueStatus === 0 + ? new Date(issueDetails.issue_inbox[0].snoozed_till ?? "") < new Date() ? "border-red-500 bg-red-500/10 text-red-500" - : issueStatus === 0 - ? new Date(issueDetails.issue_inbox[0].snoozed_till ?? "") < new Date() - ? "border-red-500 bg-red-500/10 text-red-500" - : "border-gray-500 bg-gray-500/10 text-custom-text-200" - : issueStatus === 1 - ? "border-green-500 bg-green-500/10 text-green-500" - : issueStatus === 2 - ? "border-gray-500 bg-gray-500/10 text-custom-text-200" - : "" + : "border-gray-500 bg-gray-500/10 text-custom-text-200" + : issueStatus === 1 + ? "border-green-500 bg-green-500/10 text-green-500" + : issueStatus === 2 + ? "border-gray-500 bg-gray-500/10 text-custom-text-200" + : "" }`} > {issueStatus === -2 ? ( @@ -225,7 +225,7 @@ export const InboxMainContent: React.FC = observer(() => { ) : null} -
+
{currentIssueState && ( = (props) => { @@ -45,6 +46,7 @@ export const InstanceEmailForm: FC = (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 = (props) => {
+
+
+

From address

+ ( + + )} + /> +

+ You will have to verify your email address to being sending emails. +

+
+
diff --git a/web/components/issues/attachment/attachments.tsx b/web/components/issues/attachment/attachments.tsx index 86cafd7a9..1b4915579 100644 --- a/web/components/issues/attachment/attachments.tsx +++ b/web/components/issues/attachment/attachments.tsx @@ -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) => { + const { editable } = props; + + // states const [deleteAttachment, setDeleteAttachment] = useState(null); const [attachmentDeleteModal, setAttachmentDeleteModal] = useState(false); @@ -86,14 +93,16 @@ export const IssueAttachments = () => {
- + {editable && ( + + )}
))} diff --git a/web/components/issues/description-form.tsx b/web/components/issues/description-form.tsx index 3373686ec..677ab5e22 100644 --- a/web/components/issues/description-form.tsx +++ b/web/components/issues/description-form.tsx @@ -135,7 +135,9 @@ export const IssueDescriptionForm: FC = (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 = (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); diff --git a/web/components/issues/form.tsx b/web/components/issues/form.tsx index bc325d95e..c0d1ebc5c 100644 --- a/web/components/issues/form.tsx +++ b/web/components/issues/form.tsx @@ -227,6 +227,7 @@ export const IssueForm: FC = observer((props) => { reset({ ...defaultValues, ...initialData, + project: projectId, }); }, [setFocus, initialData, reset]); diff --git a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx index 1d70e2289..b080bc838 100644 --- a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx +++ b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx @@ -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) } /> )} diff --git a/web/components/issues/issue-layouts/calendar/day-tile.tsx b/web/components/issues/issue-layouts/calendar/day-tile.tsx index c9f022ebe..36caaff20 100644 --- a/web/components/issues/issue-layouts/calendar/day-tile.tsx +++ b/web/components/issues/issue-layouts/calendar/day-tile.tsx @@ -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 = 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 ( <>
@@ -87,7 +89,13 @@ export const CalendarDayTile: React.FC = observer((props) => { {...provided.droppableProps} ref={provided.innerRef} > - + + {enableQuickIssueCreate && !disableIssueCreation && (
= observer((props) => { }} quickAddCallback={quickAddCallback} viewId={viewId} + onOpen={() => setShowAllIssues(true)} />
)} + + {totalIssues > 4 && ( +
+ +
+ )} + {provided.placeholder}
)} diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index b880f4cc1..f8eead33f 100644 --- a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -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 = 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(null); - const handleIssuePeekOverview = (issue: IIssue) => { + const handleIssuePeekOverview = (issue: IIssue, event: React.MouseEvent) => { const { query } = router; - - router.push({ - pathname: router.pathname, - query: { ...query, peekIssueId: issue?.id, peekProjectId: issue?.project }, - }); + 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 = observer((props) => { ); + 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 ( - + {(provided, snapshot) => (
handleIssuePeekOverview(issue)} + onClick={(e) => handleIssuePeekOverview(issue, e)} > {issue?.tempId !== undefined && (
diff --git a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx index 70f79b4fa..85a74a997 100644 --- a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx @@ -27,6 +27,7 @@ type Props = { viewId?: string ) => Promise; viewId?: string; + onOpen?: () => void; }; const defaultValues: Partial = { @@ -57,7 +58,7 @@ const Inputs = (props: any) => { }; export const CalendarQuickAddIssueForm: React.FC = 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 = observer((props) => { } }; + const handleOpen = () => { + setIsOpen(true); + if (onOpen) onOpen(); + }; + return ( <> {isOpen && ( @@ -169,7 +175,7 @@ export const CalendarQuickAddIssueForm: React.FC = observer((props) => { } + disabled={!isEditingAllowed} />
diff --git a/web/components/issues/issue-layouts/empty-states/module.tsx b/web/components/issues/issue-layouts/empty-states/module.tsx index 4d244807e..ed7f73358 100644 --- a/web/components/issues/issue-layouts/empty-states/module.tsx +++ b/web/components/issues/issue-layouts/empty-states/module.tsx @@ -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 = observer((props) => { moduleIssues: moduleIssueStore, commandPalette: commandPaletteStore, trackEvent: { setTrackElement }, + user: { currentProjectRole: userRole }, } = useMobxStore(); const { setToastAlert } = useToast(); @@ -44,6 +47,8 @@ export const ModuleEmptyState: React.FC = observer((props) => { ); }; + const isEditingAllowed = !!userRole && userRole >= EUserWorkspaceRoles.MEMBER; + return ( <> = observer((props) => { variant="neutral-primary" prependIcon={} onClick={() => setModuleIssuesListModal(true)} + disabled={!isEditingAllowed} > Add an existing issue } + disabled={!isEditingAllowed} />
diff --git a/web/components/issues/issue-layouts/empty-states/project.tsx b/web/components/issues/issue-layouts/empty-states/project.tsx index 458f02c53..7db04b36a 100644 --- a/web/components/issues/issue-layouts/empty-states/project.tsx +++ b/web/components/issues/issue-layouts/empty-states/project.tsx @@ -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 (
{ 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={{ - text: "Create your first issue", - icon: , - onClick: () => { - setTrackElement("PROJECT_EMPTY_STATE"); - commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.PROJECT); - }, - }} + primaryButton={ + isEditingAllowed + ? { + text: "Create your first issue", + icon: , + onClick: () => { + setTrackElement("PROJECT_EMPTY_STATE"); + commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.PROJECT); + }, + } + : null + } + disabled={!isEditingAllowed} />
); diff --git a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx index 04329ec03..7ff8056b9 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/filters-list.tsx @@ -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 = 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 (
{Object.entries(appliedFilters).map(([key, value]) => { @@ -53,6 +61,7 @@ export const AppliedFiltersList: React.FC = observer((props) => {
{membersFilters.includes(filterKey) && ( handleRemoveFilter(filterKey, val)} members={members} values={value} @@ -63,16 +72,22 @@ export const AppliedFiltersList: React.FC = observer((props) => { )} {filterKey === "labels" && ( handleRemoveFilter("labels", val)} labels={labels} values={value} /> )} {filterKey === "priority" && ( - handleRemoveFilter("priority", val)} values={value} /> + handleRemoveFilter("priority", val)} + values={value} + /> )} {filterKey === "state" && states && ( handleRemoveFilter("state", val)} states={states} values={value} @@ -86,30 +101,35 @@ export const AppliedFiltersList: React.FC = observer((props) => { )} {filterKey === "project" && ( handleRemoveFilter("project", val)} projects={projects} values={value} /> )} - + {isEditingAllowed && ( + + )}
); })} - + {isEditingAllowed && ( + + )} ); }); diff --git a/web/components/issues/issue-layouts/filters/applied-filters/label.tsx b/web/components/issues/issue-layouts/filters/applied-filters/label.tsx index 9cec9b2f7..08e7aee44 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/label.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/label.tsx @@ -9,10 +9,11 @@ type Props = { handleRemove: (val: string) => void; labels: IIssueLabel[] | undefined; values: string[]; + editable: boolean | undefined; }; export const AppliedLabelsFilters: React.FC = observer((props) => { - const { handleRemove, labels, values } = props; + const { handleRemove, labels, values, editable } = props; return ( <> @@ -30,13 +31,15 @@ export const AppliedLabelsFilters: React.FC = observer((props) => { }} /> {labelDetails.name} - + {editable && ( + + )} ); })} diff --git a/web/components/issues/issue-layouts/filters/applied-filters/members.tsx b/web/components/issues/issue-layouts/filters/applied-filters/members.tsx index bfa7e9a29..1dd61d339 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/members.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/members.tsx @@ -9,10 +9,11 @@ type Props = { handleRemove: (val: string) => void; members: IUserLite[] | undefined; values: string[]; + editable: boolean | undefined; }; export const AppliedMembersFilters: React.FC = observer((props) => { - const { handleRemove, members, values } = props; + const { handleRemove, members, values, editable } = props; return ( <> @@ -25,13 +26,15 @@ export const AppliedMembersFilters: React.FC = observer((props) => {
{memberDetails.display_name} - + {editable && ( + + )}
); })} diff --git a/web/components/issues/issue-layouts/filters/applied-filters/priority.tsx b/web/components/issues/issue-layouts/filters/applied-filters/priority.tsx index e00d0d829..88b39dc00 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/priority.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/priority.tsx @@ -9,10 +9,11 @@ import { TIssuePriorities } from "types"; type Props = { handleRemove: (val: string) => void; values: string[]; + editable: boolean | undefined; }; export const AppliedPriorityFilters: React.FC = observer((props) => { - const { handleRemove, values } = props; + const { handleRemove, values, editable } = props; return ( <> @@ -20,13 +21,15 @@ export const AppliedPriorityFilters: React.FC = observer((props) => {
{priority} - + {editable && ( + + )}
))} diff --git a/web/components/issues/issue-layouts/filters/applied-filters/project.tsx b/web/components/issues/issue-layouts/filters/applied-filters/project.tsx index 018309861..b1e17cfe3 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/project.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/project.tsx @@ -10,10 +10,11 @@ type Props = { handleRemove: (val: string) => void; projects: IProject[] | undefined; values: string[]; + editable: boolean | undefined; }; export const AppliedProjectFilters: React.FC = observer((props) => { - const { handleRemove, projects, values } = props; + const { handleRemove, projects, values, editable } = props; return ( <> @@ -34,13 +35,15 @@ export const AppliedProjectFilters: React.FC = observer((props) => { )} {projectDetails.name} - + {editable && ( + + )} ); })} diff --git a/web/components/issues/issue-layouts/filters/applied-filters/state.tsx b/web/components/issues/issue-layouts/filters/applied-filters/state.tsx index 8e7592505..9cff84d9b 100644 --- a/web/components/issues/issue-layouts/filters/applied-filters/state.tsx +++ b/web/components/issues/issue-layouts/filters/applied-filters/state.tsx @@ -10,10 +10,11 @@ type Props = { handleRemove: (val: string) => void; states: IState[]; values: string[]; + editable: boolean | undefined; }; export const AppliedStateFilters: React.FC = observer((props) => { - const { handleRemove, states, values } = props; + const { handleRemove, states, values, editable } = props; return ( <> @@ -26,13 +27,15 @@ export const AppliedStateFilters: React.FC = observer((props) => {
{stateDetails.name} - + {editable && ( + + )}
); })} diff --git a/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx b/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx index 0c2fa1c7e..9c0ef8511 100644 --- a/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx +++ b/web/components/issues/issue-layouts/filters/header/helpers/dropdown.tsx @@ -11,10 +11,11 @@ type Props = { children: React.ReactNode; title?: string; placement?: Placement; + disabled?: boolean; }; export const FiltersDropdown: React.FC = (props) => { - const { children, title = "Dropdown", placement } = props; + const { children, title = "Dropdown", placement, disabled = false } = props; const [referenceElement, setReferenceElement] = useState(null); const [popperElement, setPopperElement] = useState(null); @@ -32,6 +33,7 @@ export const FiltersDropdown: React.FC = (props) => { <> ); }; diff --git a/web/components/issues/issue-layouts/list/properties.tsx b/web/components/issues/issue-layouts/list/properties.tsx index 8b6f54010..eeff3b273 100644 --- a/web/components/issues/issue-layouts/list/properties.tsx +++ b/web/components/issues/issue-layouts/list/properties.tsx @@ -40,11 +40,11 @@ export const ListProperties: FC = 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 = observer((props) => { {displayProperties && displayProperties?.start_date && ( handleStartDate(date)} + onChange={(date) => handleStartDate(date)} disabled={isReadonly} type="start_date" /> @@ -116,7 +116,7 @@ export const ListProperties: FC = observer((props) => { {displayProperties && displayProperties?.due_date && ( handleTargetDate(date)} + onChange={(date) => handleTargetDate(date)} disabled={isReadonly} type="target_date" /> diff --git a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx index 1ce28d008..de579473b 100644 --- a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx @@ -19,23 +19,30 @@ export const CycleListLayout: React.FC = observer(() => { const router = useRouter(); const { workspaceSlug, cycleId } = router.query as { workspaceSlug: string; cycleId: string }; // store - const { cycleIssues: cycleIssueStore, cycleIssuesFilter: cycleIssueFilterStore } = useMobxStore(); + const { + cycleIssues: cycleIssueStore, + cycleIssuesFilter: cycleIssueFilterStore, + cycle: { fetchCycleWithId }, + } = useMobxStore(); const issueActions = { [EIssueActions.UPDATE]: async (group_by: string | null, issue: IIssue) => { if (!workspaceSlug || !cycleId) return; await cycleIssueStore.updateIssue(workspaceSlug, issue.project, issue.id, issue, cycleId); + fetchCycleWithId(workspaceSlug, issue.project, cycleId); }, [EIssueActions.DELETE]: async (group_by: string | null, issue: IIssue) => { if (!workspaceSlug || !cycleId) return; await cycleIssueStore.removeIssue(workspaceSlug, issue.project, issue.id, cycleId); + fetchCycleWithId(workspaceSlug, issue.project, cycleId); }, [EIssueActions.REMOVE]: async (group_by: string | null, issue: IIssue) => { if (!workspaceSlug || !cycleId || !issue.bridge_id) return; await cycleIssueStore.removeIssueFromCycle(workspaceSlug, issue.project, cycleId, issue.id, issue.bridge_id); + fetchCycleWithId(workspaceSlug, issue.project, cycleId); }, }; const getProjects = (projectStore: IProjectStore) => { diff --git a/web/components/issues/issue-layouts/list/roots/module-root.tsx b/web/components/issues/issue-layouts/list/roots/module-root.tsx index 2c8737e70..5d076a0cc 100644 --- a/web/components/issues/issue-layouts/list/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/module-root.tsx @@ -19,23 +19,30 @@ export const ModuleListLayout: React.FC = observer(() => { const router = useRouter(); const { workspaceSlug, moduleId } = router.query as { workspaceSlug: string; moduleId: string }; - const { moduleIssues: moduleIssueStore, moduleIssuesFilter: moduleIssueFilterStore } = useMobxStore(); + const { + moduleIssues: moduleIssueStore, + moduleIssuesFilter: moduleIssueFilterStore, + module: { fetchModuleDetails }, + } = useMobxStore(); const issueActions = { [EIssueActions.UPDATE]: async (group_by: string | null, issue: IIssue) => { if (!workspaceSlug || !moduleId) return; await moduleIssueStore.updateIssue(workspaceSlug, issue.project, issue.id, issue, moduleId); + fetchModuleDetails(workspaceSlug, issue.project, moduleId); }, [EIssueActions.DELETE]: async (group_by: string | null, issue: IIssue) => { if (!workspaceSlug || !moduleId) return; await moduleIssueStore.removeIssue(workspaceSlug, issue.project, issue.id, moduleId); + fetchModuleDetails(workspaceSlug, issue.project, moduleId); }, [EIssueActions.REMOVE]: async (group_by: string | null, issue: IIssue) => { if (!workspaceSlug || !moduleId || !issue.bridge_id) return; await moduleIssueStore.removeIssueFromModule(workspaceSlug, issue.project, moduleId, issue.id, issue.bridge_id); + fetchModuleDetails(workspaceSlug, issue.project, moduleId); }, }; diff --git a/web/components/issues/issue-layouts/properties/assignee.tsx b/web/components/issues/issue-layouts/properties/assignee.tsx index b53f1c215..01dec9b83 100644 --- a/web/components/issues/issue-layouts/properties/assignee.tsx +++ b/web/components/issues/issue-layouts/properties/assignee.tsx @@ -42,7 +42,7 @@ export const IssuePropertyAssignee: React.FC = 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 = observer( const [popperElement, setPopperElement] = useState(null); const [isLoading, setIsLoading] = useState(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 = observer( const label = ( -
+
{value && value.length > 0 && Array.isArray(value) ? ( {value.map((assigneeId) => { @@ -142,7 +142,10 @@ export const IssuePropertyAssignee: React.FC = 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 &&