diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml index c8e27f32216..5b5f958d3b6 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 3f74043053a..5b96dbf6c45 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 d5202073552..cb2d1ca280b 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 00000000000..b89c208741e --- /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 c67575db5ff..5b88e3652d1 100644 --- a/apiserver/plane/app/views/project.py +++ b/apiserver/plane/app/views/project.py @@ -145,6 +145,16 @@ def get_queryset(self): ) ) ) + .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 @@ def list(self, request, slug): 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 @@ def create(self, request, slug, project_id): ) ) + # 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 @@ def get(self, request): 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 ed72dbcf118..11170114aaa 100644 --- a/apiserver/plane/app/views/workspace.py +++ b/apiserver/plane/app/views/workspace.py @@ -70,6 +70,7 @@ 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 4aa86f6ca3a..a4f5b194c84 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 563cc8a4001..d790f845dff 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 55bbfa0d642..bb61e0adac1 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 4ec06e62381..b9221855bd1 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 1bdc48ca3a6..7039cb8755e 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 6832297e975..0e7a18fa86b 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 645e99cb8e6..15150aa40e7 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 58cab3776e8..4e1e3b39f3d 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 00000000000..4b90c0dd573 --- /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 b9f50d664cf..a087d4e426a 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 00000000000..c78893f9fe3 --- /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 e1fa026b78d..a1d9bcbe15a 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 d1128a58890..862210c333f 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 29faedef7d7..5fa751338d9 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 058a3988f5a..508d2e5dabd 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 42ff147fa0a..d745e111180 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"; -// services -import { IssueService } from "services/issue"; +import useSWR from "swr"; // hooks +import { useMobxStore } from "lib/mobx/store-provider"; import useToast from "hooks/use-toast"; +// services +import { IssueService } from "services/issue"; // 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 f80b58d3344..9f0ec41bce0 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 b57d6e61b6e..037d9175b4b 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 ac4ffcdc517..8cea3784fd3 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 64ed7613244..ea982099f2e 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 4ae5c9d8be4..1fd1cd05cbe 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 23f8f8d76bb..89ecbabbac9 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 e5b1167b264..2526199b594 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 f52b7264d78..62df6e7c8b9 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 de00424dc52..f9bcfc508ea 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 964110967d3..36c278e828e 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 2ba0c018451..370dfe6d439 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 298a33196a1..3a0faf248fd 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 86cafd7a96e..1b491557948 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 3373686ec77..677ab5e2292 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 bc325d95e22..c0d1ebc5c85 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 1d70e22894a..b080bc838a8 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 c9f022ebe2e..36caaff20a8 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 b880f4cc101..f8eead33fbc 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 70f79b4fa88..85a74a997e5 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 4d244807ee9..ed7f73358f8 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 458f02c537c..7db04b36a15 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 04329ec03b1..7ff8056b9e1 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 9cec9b2f724..08e7aee4458 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 bfa7e9a29da..1dd61d3390b 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 e00d0d829e4..88b39dc0033 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 01830986162..b1e17cfe3dc 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 8e759250557..9cff84d9b7a 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 0c2fa1c7e2d..9c0ef85115f 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 8b6f54010ec..eeff3b27393 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 1ce28d0088c..de579473bf9 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 2c8737e70fc..5d076a0cca4 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 b53f1c21527..01dec9b8379 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 &&