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/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/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/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 &&