From 7d1eae2756d8848be15ad30cf7e7dc83c4e5a6c9 Mon Sep 17 00:00:00 2001 From: Aaryan Khandelwal Date: Thu, 5 Oct 2023 15:29:30 +0530 Subject: [PATCH] chore: merge develop --- ...equest.yml => build-test-pull-request.yml} | 0 .github/workflows/create-sync-pr.yml | 77 +++ ...er_Images.yml => update-docker-images.yml} | 6 +- CONTRIBUTING.md | 74 +- README.md | 2 +- apiserver/.env.example | 13 +- apiserver/plane/api/permissions/workspace.py | 11 +- apiserver/plane/api/serializers/cycle.py | 22 +- apiserver/plane/api/urls.py | 34 +- apiserver/plane/api/views/__init__.py | 10 +- apiserver/plane/api/views/config.py | 40 ++ apiserver/plane/api/views/cycle.py | 134 +++- .../plane/api/views/{gpt.py => external.py} | 45 +- apiserver/plane/api/views/importer.py | 2 +- apiserver/plane/api/views/inbox.py | 21 +- apiserver/plane/api/views/issue.py | 137 ++-- apiserver/plane/api/views/module.py | 96 ++- apiserver/plane/api/views/project.py | 43 +- apiserver/plane/api/views/release.py | 21 - apiserver/plane/api/views/view.py | 2 +- apiserver/plane/api/views/workspace.py | 23 +- .../plane/bgtasks/exporter_expired_task.py | 2 +- .../plane/bgtasks/issue_activites_task.py | 44 +- .../plane/bgtasks/issue_automation_task.py | 19 +- ..._alter_analyticview_created_by_and_more.py | 5 +- .../db/migrations/0044_auto_20230913_0709.py | 26 +- .../db/migrations/0045_auto_20230915_0655.py | 24 - ...h_workspacemember_issue_props_and_more.py} | 44 +- apiserver/plane/db/models/view.py | 11 + apiserver/plane/db/models/workspace.py | 12 +- apiserver/plane/settings/local.py | 3 + apiserver/plane/settings/production.py | 231 ++++--- apiserver/plane/settings/selfhosted.py | 1 + apiserver/plane/settings/staging.py | 4 + apiserver/plane/utils/analytics_plot.py | 4 +- apiserver/plane/utils/issue_filters.py | 11 +- docker-compose-hub.yml | 107 +-- docker-compose.yml | 43 +- nginx/nginx.conf.template | 21 +- package.json | 1 + packages/eslint-config-custom/package.json | 2 +- packages/tailwind-config-custom/package.json | 2 +- packages/tsconfig/package.json | 2 +- packages/ui/README.md | 1 + packages/ui/package.json | 30 +- space/.env.example | 2 - .../accounts/github-login-button.tsx | 7 +- space/components/accounts/google-login.tsx | 13 +- space/components/accounts/sign-in.tsx | 104 ++- space/components/issues/navbar/index.tsx | 48 +- space/components/views/home.tsx | 13 - space/components/views/index.ts | 2 +- space/components/views/login.tsx | 19 + space/lib/mobx/store-init.tsx | 9 +- space/package.json | 2 +- space/pages/index.tsx | 21 +- space/pages/login/index.tsx | 8 + space/services/app-config.service.ts | 30 + space/services/file.service.ts | 18 - space/store/user.ts | 13 + turbo.json | 4 - web/.env.example | 22 +- .../account/email-password-form.tsx | 163 ++--- web/components/account/email-signup-form.tsx | 114 ++++ .../account/github-login-button.tsx | 16 +- web/components/account/google-login.tsx | 15 +- web/components/account/index.ts | 1 + .../analytics/scope-and-demand/scope.tsx | 19 +- .../automation/auto-archive-automation.tsx | 9 +- .../automation/auto-close-automation.tsx | 9 +- web/components/core/activity.tsx | 87 ++- web/components/core/filters/filters-list.tsx | 4 +- web/components/core/filters/index.ts | 1 + .../core/filters/issues-view-filter.tsx | 66 +- .../core/filters/workspace-filters-list.tsx | 364 ++++++++++ web/components/core/image-picker-popover.tsx | 202 ++++-- .../core/views/board-view/board-header.tsx | 17 +- web/components/core/views/board-view/index.ts | 1 + .../board-view/inline-create-issue-form.tsx | 62 ++ .../core/views/board-view/single-board.tsx | 121 +++- .../core/views/board-view/single-issue.tsx | 7 +- .../views/calendar-view/calendar-header.tsx | 283 +++----- .../core/views/calendar-view/calendar.tsx | 89 +-- .../core/views/calendar-view/index.ts | 1 + .../inline-create-issue-form.tsx | 102 +++ .../core/views/calendar-view/single-date.tsx | 122 ++-- .../core/views/calendar-view/single-issue.tsx | 4 +- .../inline-create-issue-form.tsx | 62 ++ web/components/core/views/index.ts | 1 + .../views/inline-issue-create-wrapper.tsx | 273 ++++++++ web/components/core/views/issues-view.tsx | 5 +- web/components/core/views/list-view/index.ts | 1 + .../list-view/inline-create-issue-form.tsx | 62 ++ .../core/views/list-view/single-issue.tsx | 7 +- .../core/views/list-view/single-list.tsx | 322 +++++---- .../assignee-column/assignee-column.tsx | 72 ++ .../spreadsheet-view/assignee-column/index.ts | 2 + .../spreadsheet-assignee-column.tsx | 62 ++ .../created-on-column/created-on-column.tsx | 34 + .../created-on-column/index.ts | 2 + .../spreadsheet-created-on-column.tsx | 62 ++ .../due-date-column/due-date-column.tsx | 38 ++ .../spreadsheet-view/due-date-column/index.ts | 2 + .../spreadsheet-due-date-column.tsx | 62 ++ .../estimate-column/estimate-column.tsx | 38 ++ .../spreadsheet-view/estimate-column/index.ts | 2 + .../spreadsheet-estimate-column.tsx | 62 ++ .../core/views/spreadsheet-view/index.ts | 15 +- .../spreadsheet-view/issue-column/index.ts | 2 + .../issue-column/issue-column.tsx | 180 +++++ .../spreadsheet-issue-column.tsx} | 32 +- .../spreadsheet-view/label-column/index.ts | 2 + .../label-column/label-column.tsx | 47 ++ .../label-column/spreadsheet-label-column.tsx | 62 ++ .../spreadsheet-view/priority-column/index.ts | 2 + .../priority-column/priority-column.tsx | 64 ++ .../spreadsheet-priority-column.tsx | 62 ++ .../views/spreadsheet-view/single-issue.tsx | 68 +- .../spreadsheet-view/spreadsheet-columns.tsx | 246 ------- .../spreadsheet-view/spreadsheet-view.tsx | 642 +++++++++++++++--- .../start-date-column/index.ts | 2 + .../spreadsheet-start-date-column.tsx | 62 ++ .../start-date-column/start-date-column.tsx | 38 ++ .../spreadsheet-view/state-column/index.ts | 2 + .../state-column/spreadsheet-state-column.tsx | 62 ++ .../state-column/state-column.tsx | 87 +++ .../updated-on-column/index.ts | 2 + .../spreadsheet-updated-on-column.tsx | 62 ++ .../updated-on-column/updated-on-column.tsx | 34 + web/components/cycles/single-cycle-list.tsx | 6 +- web/components/emoji-icon-picker/index.tsx | 22 +- web/components/gantt-chart/chart/index.tsx | 58 +- .../gantt-chart/helpers/draggable.tsx | 11 +- web/components/gantt-chart/sidebar.tsx | 46 +- web/components/icons/state/backlog.tsx | 24 +- web/components/icons/state/started.tsx | 31 +- web/components/issues/activity.tsx | 16 +- .../issues/delete-draft-issue-modal.tsx | 9 +- web/components/issues/draft-issue-form.tsx | 71 +- web/components/issues/draft-issue-modal.tsx | 46 +- web/components/issues/form.tsx | 33 +- .../issues/issue-layouts/gantt/blocks.tsx | 11 +- web/components/issues/main-content.tsx | 4 +- web/components/issues/modal.tsx | 43 +- .../my-issues/my-issues-view-options.tsx | 259 ++----- .../issues/sidebar-select/blocked.tsx | 8 +- .../issues/sidebar-select/blocker.tsx | 2 +- .../issues/sidebar-select/duplicate.tsx | 6 +- .../issues/sidebar-select/relates-to.tsx | 6 +- web/components/issues/sidebar.tsx | 6 +- web/components/issues/sub-issues-list.tsx | 243 ------- web/components/issues/sub-issues/issue.tsx | 244 ++++--- .../issues/sub-issues/issues-list.tsx | 23 +- .../issues/sub-issues/progressbar.tsx | 2 +- .../issues/sub-issues/properties.tsx | 19 +- web/components/issues/sub-issues/root.tsx | 369 ++++++---- .../workspace-views/workpace-view-issues.tsx | 232 +++++++ .../workspace-views/workspace-all-issue.tsx | 236 +++++++ .../workspace-assigned-issue.tsx | 148 ++++ .../workspace-created-issues.tsx | 147 ++++ .../workspace-issue-view-option.tsx | 116 ++++ .../workspace-subscribed-issue.tsx | 148 ++++ web/components/labels/single-label.tsx | 78 ++- web/components/onboarding/invite-members.tsx | 225 ++++-- web/components/onboarding/user-details.tsx | 8 + .../profile/profile-issues-view-options.tsx | 80 +-- .../profile/profile-issues-view.tsx | 3 +- web/components/project/card.tsx | 2 +- .../project/create-project-modal.tsx | 2 +- web/components/project/label-select.tsx | 10 +- web/components/project/member-select.tsx | 4 +- web/components/project/members-select.tsx | 10 +- web/components/states/state-select.tsx | 147 ++-- web/components/tiptap/index.tsx | 2 +- web/components/tiptap/table-menu/index.tsx | 20 +- web/components/ui/dropdowns/custom-menu.tsx | 9 +- web/components/ui/dropdowns/types.d.ts | 1 + web/components/ui/empty-state.tsx | 8 +- web/components/ui/toggle-switch.tsx | 6 +- web/components/views/delete-view-modal.tsx | 5 +- web/components/views/select-filters.tsx | 363 +++++----- web/components/views/single-view-item.tsx | 169 +++-- web/components/workspace/help-section.tsx | 86 ++- web/components/workspace/sidebar-menu.tsx | 4 +- .../workspace/sidebar-quick-action.tsx | 34 +- .../views/delete-workspace-view-modal.tsx | 141 ++++ web/components/workspace/views/form.tsx | 213 ++++++ .../workspace/views/global-select-filters.tsx | 301 ++++++++ web/components/workspace/views/modal.tsx | 157 +++++ .../views/single-workspace-view-item.tsx | 110 +++ .../views/workpace-view-navigation.tsx | 105 +++ web/constants/fetch-keys.ts | 35 +- web/constants/project.ts | 2 + web/contexts/workspace-view-context.tsx | 235 +++++++ web/helpers/string.helper.ts | 69 ++ web/hooks/gantt-chart/issue-view.tsx | 43 ++ web/hooks/my-issues/use-my-issues-filter.tsx | 2 +- web/hooks/use-estimate-option.tsx | 4 +- web/hooks/use-issues-view.tsx | 2 +- web/hooks/use-keypress.tsx | 19 + web/hooks/use-sub-issue.tsx | 4 +- web/hooks/use-workspace-view.tsx | 11 + web/layouts/app-layout-legacy/app-sidebar.tsx | 24 +- .../project-authorization-wrapper.tsx | 5 +- web/lib/mobx/store-init.tsx | 2 +- web/next.config.js | 1 + web/package.json | 2 +- web/pages/[workspaceSlug]/index.tsx | 75 +- .../[workspaceSlug]/me/profile/activity.tsx | 2 +- .../[workspaceSlug]/me/profile/index.tsx | 4 +- .../me/profile/preferences.tsx | 2 +- .../projects/[projectId]/cycles/[cycleId].tsx | 10 +- .../[projectId]/modules/[moduleId].tsx | 6 +- .../projects/[projectId]/modules/index.tsx | 2 +- .../[projectId]/settings/automations.tsx | 27 +- .../[projectId]/settings/features.tsx | 14 +- .../[projectId]/settings/integrations.tsx | 14 +- .../projects/[projectId]/settings/members.tsx | 16 +- .../[workspaceSlug]/settings/billing.tsx | 2 +- web/pages/[workspaceSlug]/settings/index.tsx | 77 +-- .../workspace-views/all-issues.tsx | 40 ++ .../workspace-views/assigned.tsx | 40 ++ .../workspace-views/created.tsx | 40 ++ .../[workspaceSlug]/workspace-views/index.tsx | 206 ++++++ .../workspace-views/issues.tsx | 40 ++ .../workspace-views/subscribed.tsx | 40 ++ web/pages/api/unsplash.ts | 7 +- web/pages/index.tsx | 102 +-- web/pages/sign-up.tsx | 25 +- web/services/app-config.service.ts | 30 + web/services/file.service.ts | 24 +- web/services/workspace.service.ts | 56 ++ web/store/global-views.ts | 0 web/types/view-props.d.ts | 57 +- web/types/workspace-views.d.ts | 22 + 235 files changed, 9645 insertions(+), 3366 deletions(-) rename .github/workflows/{Build_Test_Pull_Request.yml => build-test-pull-request.yml} (100%) create mode 100644 .github/workflows/create-sync-pr.yml rename .github/workflows/{Update_Docker_Images.yml => update-docker-images.yml} (95%) create mode 100644 apiserver/plane/api/views/config.py rename apiserver/plane/api/views/{gpt.py => external.py} (62%) delete mode 100644 apiserver/plane/api/views/release.py delete mode 100644 apiserver/plane/db/migrations/0045_auto_20230915_0655.py rename apiserver/plane/db/migrations/{0046_auto_20230919_1421.py => 0045_issueactivity_epoch_workspacemember_issue_props_and_more.py} (59%) create mode 100644 packages/ui/README.md delete mode 100644 space/components/views/home.tsx create mode 100644 space/components/views/login.tsx create mode 100644 space/pages/login/index.tsx create mode 100644 space/services/app-config.service.ts create mode 100644 web/components/account/email-signup-form.tsx create mode 100644 web/components/core/filters/workspace-filters-list.tsx create mode 100644 web/components/core/views/board-view/inline-create-issue-form.tsx create mode 100644 web/components/core/views/calendar-view/inline-create-issue-form.tsx create mode 100644 web/components/core/views/gantt-chart-view/inline-create-issue-form.tsx create mode 100644 web/components/core/views/inline-issue-create-wrapper.tsx create mode 100644 web/components/core/views/list-view/inline-create-issue-form.tsx create mode 100644 web/components/core/views/spreadsheet-view/assignee-column/assignee-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/assignee-column/index.ts create mode 100644 web/components/core/views/spreadsheet-view/assignee-column/spreadsheet-assignee-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/created-on-column/created-on-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/created-on-column/index.ts create mode 100644 web/components/core/views/spreadsheet-view/created-on-column/spreadsheet-created-on-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/due-date-column/due-date-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/due-date-column/index.ts create mode 100644 web/components/core/views/spreadsheet-view/due-date-column/spreadsheet-due-date-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/estimate-column/estimate-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/estimate-column/index.ts create mode 100644 web/components/core/views/spreadsheet-view/estimate-column/spreadsheet-estimate-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/issue-column/index.ts create mode 100644 web/components/core/views/spreadsheet-view/issue-column/issue-column.tsx rename web/components/core/views/spreadsheet-view/{spreadsheet-issues.tsx => issue-column/spreadsheet-issue-column.tsx} (74%) create mode 100644 web/components/core/views/spreadsheet-view/label-column/index.ts create mode 100644 web/components/core/views/spreadsheet-view/label-column/label-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/label-column/spreadsheet-label-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/priority-column/index.ts create mode 100644 web/components/core/views/spreadsheet-view/priority-column/priority-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/priority-column/spreadsheet-priority-column.tsx delete mode 100644 web/components/core/views/spreadsheet-view/spreadsheet-columns.tsx create mode 100644 web/components/core/views/spreadsheet-view/start-date-column/index.ts create mode 100644 web/components/core/views/spreadsheet-view/start-date-column/spreadsheet-start-date-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/start-date-column/start-date-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/state-column/index.ts create mode 100644 web/components/core/views/spreadsheet-view/state-column/spreadsheet-state-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/state-column/state-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/updated-on-column/index.ts create mode 100644 web/components/core/views/spreadsheet-view/updated-on-column/spreadsheet-updated-on-column.tsx create mode 100644 web/components/core/views/spreadsheet-view/updated-on-column/updated-on-column.tsx delete mode 100644 web/components/issues/sub-issues-list.tsx create mode 100644 web/components/issues/workspace-views/workpace-view-issues.tsx create mode 100644 web/components/issues/workspace-views/workspace-all-issue.tsx create mode 100644 web/components/issues/workspace-views/workspace-assigned-issue.tsx create mode 100644 web/components/issues/workspace-views/workspace-created-issues.tsx create mode 100644 web/components/issues/workspace-views/workspace-issue-view-option.tsx create mode 100644 web/components/issues/workspace-views/workspace-subscribed-issue.tsx create mode 100644 web/components/workspace/views/delete-workspace-view-modal.tsx create mode 100644 web/components/workspace/views/form.tsx create mode 100644 web/components/workspace/views/global-select-filters.tsx create mode 100644 web/components/workspace/views/modal.tsx create mode 100644 web/components/workspace/views/single-workspace-view-item.tsx create mode 100644 web/components/workspace/views/workpace-view-navigation.tsx create mode 100644 web/contexts/workspace-view-context.tsx create mode 100644 web/hooks/gantt-chart/issue-view.tsx create mode 100644 web/hooks/use-keypress.tsx create mode 100644 web/hooks/use-workspace-view.tsx create mode 100644 web/pages/[workspaceSlug]/workspace-views/all-issues.tsx create mode 100644 web/pages/[workspaceSlug]/workspace-views/assigned.tsx create mode 100644 web/pages/[workspaceSlug]/workspace-views/created.tsx create mode 100644 web/pages/[workspaceSlug]/workspace-views/index.tsx create mode 100644 web/pages/[workspaceSlug]/workspace-views/issues.tsx create mode 100644 web/pages/[workspaceSlug]/workspace-views/subscribed.tsx create mode 100644 web/services/app-config.service.ts create mode 100644 web/store/global-views.ts create mode 100644 web/types/workspace-views.d.ts diff --git a/.github/workflows/Build_Test_Pull_Request.yml b/.github/workflows/build-test-pull-request.yml similarity index 100% rename from .github/workflows/Build_Test_Pull_Request.yml rename to .github/workflows/build-test-pull-request.yml diff --git a/.github/workflows/create-sync-pr.yml b/.github/workflows/create-sync-pr.yml new file mode 100644 index 00000000000..28e47a0d66b --- /dev/null +++ b/.github/workflows/create-sync-pr.yml @@ -0,0 +1,77 @@ +name: Create PR in Plane EE Repository to sync the changes + +on: + pull_request: + types: + - closed + +jobs: + create_pr: + # Only run the job when a PR is merged + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + permissions: + 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 + sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null + sudo apt update + sudo apt install gh -y + + - name: Create Pull Request + if: steps.check_repo.outputs.is_correct_repo == 'true' + env: + GH_TOKEN: ${{ secrets.ACCESS_TOKEN }} + run: | + TARGET_REPO="${{ secrets.TARGET_REPO_NAME }}" + TARGET_BRANCH="${{ secrets.TARGET_REPO_BRANCH }}" + 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 +``` + +```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? If a feature is missing, you can directly _request_ a new one [here](https://github.com/makeplane/plane/issues/new?assignees=&labels=feature&template=feature_request.yml&title=%F0%9F%9A%80+Feature%3A+). You also can do the same by choosing "🚀 Feature" when raising a [New Issue](https://github.com/makeplane/plane/issues/new/choose) on our GitHub Repository. @@ -39,8 +81,8 @@ If you would like to _implement_ it, an issue with your proposal must be submitt To ensure consistency throughout the source code, please keep these rules in mind as you are working: -- All features or bug fixes must be tested by one or more specs (unit-tests). -- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier. +- All features or bug fixes must be tested by one or more specs (unit-tests). +- We use [Eslint default rule guide](https://eslint.org/docs/rules/), with minor changes. An automated formatter is available using prettier. ## Need help? Questions and suggestions @@ -48,11 +90,11 @@ Questions, suggestions, and thoughts are most welcome. We can also be reached in ## Ways to contribute -- Try Plane Cloud and the self hosting platform and give feedback -- Add new integrations -- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose) -- Share your thoughts and suggestions with us -- Help create tutorials and blog posts -- Request a feature by submitting a proposal -- Report a bug -- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations. +- Try Plane Cloud and the self hosting platform and give feedback +- Add new integrations +- Help with open [issues](https://github.com/makeplane/plane/issues) or [create your own](https://github.com/makeplane/plane/issues/new/choose) +- Share your thoughts and suggestions with us +- Help create tutorials and blog posts +- Request a feature by submitting a proposal +- Report a bug +- **Improve documentation** - fix incomplete or missing [docs](https://docs.plane.so/), bad wording, examples or explanations. diff --git a/README.md b/README.md index 3cbeed8c4bc..f9d969d72c9 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ chmod +x setup.sh - Run setup.sh ```bash -./setup.sh http://localhost +./setup.sh ``` > If running in a cloud env replace localhost with public facing IP address of the VM diff --git a/apiserver/.env.example b/apiserver/.env.example index 4969f176656..8193b5e7716 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -1,7 +1,7 @@ # Backend # Debug value for api server use it as 0 for production use DEBUG=0 -DJANGO_SETTINGS_MODULE="plane.settings.selfhosted" +DJANGO_SETTINGS_MODULE="plane.settings.production" # Error logs SENTRY_DSN="" @@ -59,3 +59,14 @@ DEFAULT_PASSWORD="password123" # SignUps ENABLE_SIGNUP="1" + + +# Enable Email/Password Signup +ENABLE_EMAIL_PASSWORD="1" + +# Enable Magic link Login +ENABLE_MAGIC_LINK_LOGIN="0" + +# Email redirections and minio domain settings +WEB_URL="http://localhost" + diff --git a/apiserver/plane/api/permissions/workspace.py b/apiserver/plane/api/permissions/workspace.py index d01b545ee18..66e8366146c 100644 --- a/apiserver/plane/api/permissions/workspace.py +++ b/apiserver/plane/api/permissions/workspace.py @@ -58,8 +58,17 @@ def has_permission(self, request, view): if request.user.is_anonymous: return False + ## Safe Methods -> Handle the filtering logic in queryset + if request.method in SAFE_METHODS: + return WorkspaceMember.objects.filter( + workspace__slug=view.workspace_slug, + member=request.user, + ).exists() + return WorkspaceMember.objects.filter( - member=request.user, workspace__slug=view.workspace_slug + member=request.user, + workspace__slug=view.workspace_slug, + role__in=[Owner, Admin], ).exists() diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 66436803333..ad214c52a7d 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -34,7 +34,6 @@ class CycleSerializer(BaseSerializer): unstarted_issues = serializers.IntegerField(read_only=True) backlog_issues = serializers.IntegerField(read_only=True) assignees = serializers.SerializerMethodField(read_only=True) - labels = serializers.SerializerMethodField(read_only=True) total_estimates = serializers.IntegerField(read_only=True) completed_estimates = serializers.IntegerField(read_only=True) started_estimates = serializers.IntegerField(read_only=True) @@ -50,11 +49,10 @@ def get_assignees(self, obj): members = [ { "avatar": assignee.avatar, - "first_name": assignee.first_name, "display_name": assignee.display_name, "id": assignee.id, } - for issue_cycle in obj.issue_cycle.all() + for issue_cycle in obj.issue_cycle.prefetch_related("issue__assignees").all() for assignee in issue_cycle.issue.assignees.all() ] # Use a set comprehension to return only the unique objects @@ -64,24 +62,6 @@ def get_assignees(self, obj): unique_list = [dict(item) for item in unique_objects] return unique_list - - def get_labels(self, obj): - labels = [ - { - "name": label.name, - "color": label.color, - "id": label.id, - } - for issue_cycle in obj.issue_cycle.all() - for label in issue_cycle.issue.labels.all() - ] - # Use a set comprehension to return only the unique objects - unique_objects = {frozenset(item.items()) for item in labels} - - # Convert the set back to a list of dictionaries - unique_list = [dict(item) for item in unique_objects] - - return unique_list class Meta: model = Cycle diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index c10c4a74562..2213c0d9d11 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -70,6 +70,7 @@ ProjectIdentifierEndpoint, ProjectFavoritesViewSet, LeaveProjectEndpoint, + ProjectPublicCoverImagesEndpoint, ## End Projects # Issues IssueViewSet, @@ -150,12 +151,11 @@ GlobalSearchEndpoint, IssueSearchEndpoint, ## End Search - # Gpt + # External GPTIntegrationEndpoint, - ## End Gpt - # Release Notes ReleaseNotesEndpoint, - ## End Release Notes + UnsplashEndpoint, + ## End External # Inbox InboxViewSet, InboxIssueViewSet, @@ -186,6 +186,9 @@ ## Exporter ExportIssuesEndpoint, ## End Exporter + # Configuration + ConfigurationEndpoint, + ## End Configuration ) @@ -573,6 +576,11 @@ LeaveProjectEndpoint.as_view(), name="project", ), + path( + "project-covers/", + ProjectPublicCoverImagesEndpoint.as_view(), + name="project-covers", + ), # End Projects # States path( @@ -1446,20 +1454,23 @@ name="project-issue-search", ), ## End Search - # Gpt + # External path( "workspaces//projects//ai-assistant/", GPTIntegrationEndpoint.as_view(), name="importer", ), - ## End Gpt - # Release Notes path( "release-notes/", ReleaseNotesEndpoint.as_view(), name="release-notes", ), - ## End Release Notes + path( + "unsplash/", + UnsplashEndpoint.as_view(), + name="release-notes", + ), + ## End External # Inbox path( "workspaces//projects//inboxes/", @@ -1728,4 +1739,11 @@ name="workspace-project-boards", ), ## End Public Boards + # Configuration + path( + "configs/", + ConfigurationEndpoint.as_view(), + name="configuration", + ), + ## End Configuration ] diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index c03d6d5b7f9..f7ad735c116 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -17,6 +17,7 @@ ProjectMemberEndpoint, WorkspaceProjectDeployBoardEndpoint, LeaveProjectEndpoint, + ProjectPublicCoverImagesEndpoint, ) from .user import ( UserEndpoint, @@ -147,16 +148,13 @@ from .search import GlobalSearchEndpoint, IssueSearchEndpoint -from .gpt import GPTIntegrationEndpoint +from .external import GPTIntegrationEndpoint, ReleaseNotesEndpoint, UnsplashEndpoint from .estimate import ( ProjectEstimatePointEndpoint, BulkEstimatePointEndpoint, ) - -from .release import ReleaseNotesEndpoint - from .inbox import InboxViewSet, InboxIssueViewSet, InboxIssuePublicViewSet from .analytic import ( @@ -169,4 +167,6 @@ from .notification import NotificationViewSet, UnreadNotificationEndpoint, MarkAllReadNotificationViewSet -from .exporter import ExportIssuesEndpoint \ No newline at end of file +from .exporter import ExportIssuesEndpoint + +from .config import ConfigurationEndpoint \ No newline at end of file diff --git a/apiserver/plane/api/views/config.py b/apiserver/plane/api/views/config.py new file mode 100644 index 00000000000..ea1b39d9ce8 --- /dev/null +++ b/apiserver/plane/api/views/config.py @@ -0,0 +1,40 @@ +# Python imports +import os + +# Django imports +from django.conf import settings + +# Third party imports +from rest_framework.permissions import AllowAny +from rest_framework import status +from rest_framework.response import Response +from sentry_sdk import capture_exception + +# Module imports +from .base import BaseAPIView + + +class ConfigurationEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request): + try: + data = {} + data["google"] = os.environ.get("GOOGLE_CLIENT_ID", None) + data["github"] = os.environ.get("GITHUB_CLIENT_ID", None) + data["github_app_name"] = os.environ.get("GITHUB_APP_NAME", None) + data["magic_login"] = ( + bool(settings.EMAIL_HOST_USER) and bool(settings.EMAIL_HOST_PASSWORD) + ) and os.environ.get("ENABLE_MAGIC_LINK_LOGIN", "0") == "1" + data["email_password_login"] = ( + os.environ.get("ENABLE_EMAIL_PASSWORD", "0") == "1" + ) + return Response(data, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/cycle.py b/apiserver/plane/api/views/cycle.py index 4f895aeba3e..e84b6dd0adb 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -80,7 +80,7 @@ def perform_destroy(self, instance): issue_id=str(self.kwargs.get("pk", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -102,48 +102,84 @@ def get_queryset(self): .select_related("workspace") .select_related("owned_by") .annotate(is_favorite=Exists(subquery)) - .annotate(total_issues=Count("issue_cycle")) + .annotate( + total_issues=Count( + "issue_cycle", + filter=Q( + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), + ) + ) .annotate( completed_issues=Count( "issue_cycle__issue__state__group", - filter=Q(issue_cycle__issue__state__group="completed"), + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), ) ) .annotate( cancelled_issues=Count( "issue_cycle__issue__state__group", - filter=Q(issue_cycle__issue__state__group="cancelled"), + filter=Q( + issue_cycle__issue__state__group="cancelled", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), ) ) .annotate( started_issues=Count( "issue_cycle__issue__state__group", - filter=Q(issue_cycle__issue__state__group="started"), + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), ) ) .annotate( unstarted_issues=Count( "issue_cycle__issue__state__group", - filter=Q(issue_cycle__issue__state__group="unstarted"), + filter=Q( + issue_cycle__issue__state__group="unstarted", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), ) ) .annotate( backlog_issues=Count( "issue_cycle__issue__state__group", - filter=Q(issue_cycle__issue__state__group="backlog"), + filter=Q( + issue_cycle__issue__state__group="backlog", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), ) ) .annotate(total_estimates=Sum("issue_cycle__issue__estimate_point")) .annotate( completed_estimates=Sum( "issue_cycle__issue__estimate_point", - filter=Q(issue_cycle__issue__state__group="completed"), + filter=Q( + issue_cycle__issue__state__group="completed", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), ) ) .annotate( started_estimates=Sum( "issue_cycle__issue__estimate_point", - filter=Q(issue_cycle__issue__state__group="started"), + filter=Q( + issue_cycle__issue__state__group="started", + issue_cycle__issue__archived_at__isnull=True, + issue_cycle__issue__is_draft=False, + ), ) ) .prefetch_related( @@ -196,17 +232,30 @@ def list(self, request, slug, project_id): .annotate(assignee_id=F("assignees__id")) .annotate(avatar=F("assignees__avatar")) .values("display_name", "assignee_id", "avatar") - .annotate(total_issues=Count("assignee_id")) + .annotate( + total_issues=Count( + "assignee_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) .annotate( completed_issues=Count( "assignee_id", - filter=Q(completed_at__isnull=False), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), ) ) .annotate( pending_issues=Count( "assignee_id", - filter=Q(completed_at__isnull=True), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), ) ) .order_by("display_name") @@ -222,17 +271,30 @@ def list(self, request, slug, project_id): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate(total_issues=Count("label_id")) + .annotate( + total_issues=Count( + "label_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ) + ) .annotate( completed_issues=Count( "label_id", - filter=Q(completed_at__isnull=False), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), ) ) .annotate( pending_issues=Count( "label_id", - filter=Q(completed_at__isnull=True), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), ) ) .order_by("label_name") @@ -385,17 +447,30 @@ def retrieve(self, request, slug, project_id, pk): .values( "first_name", "last_name", "assignee_id", "avatar", "display_name" ) - .annotate(total_issues=Count("assignee_id")) + .annotate( + total_issues=Count( + "assignee_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) .annotate( completed_issues=Count( "assignee_id", - filter=Q(completed_at__isnull=False), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), ) ) .annotate( pending_issues=Count( "assignee_id", - filter=Q(completed_at__isnull=True), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), ) ) .order_by("first_name", "last_name") @@ -412,17 +487,30 @@ def retrieve(self, request, slug, project_id, pk): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate(total_issues=Count("label_id")) + .annotate( + total_issues=Count( + "label_id", + filter=Q(archived_at__isnull=True, is_draft=False), + ), + ) .annotate( completed_issues=Count( "label_id", - filter=Q(completed_at__isnull=False), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), ) ) .annotate( pending_issues=Count( "label_id", - filter=Q(completed_at__isnull=True), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), ) ) .order_by("label_name") @@ -488,7 +576,7 @@ def perform_destroy(self, instance): issue_id=str(self.kwargs.get("pk", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -664,7 +752,7 @@ def create(self, request, slug, project_id, cycle_id): ), } ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) # Return all Cycle Issues diff --git a/apiserver/plane/api/views/gpt.py b/apiserver/plane/api/views/external.py similarity index 62% rename from apiserver/plane/api/views/gpt.py rename to apiserver/plane/api/views/external.py index 63c3f4f18f1..00a0270e498 100644 --- a/apiserver/plane/api/views/gpt.py +++ b/apiserver/plane/api/views/external.py @@ -2,9 +2,10 @@ import requests # Third party imports +import openai from rest_framework.response import Response from rest_framework import status -import openai +from rest_framework.permissions import AllowAny from sentry_sdk import capture_exception # Django imports @@ -15,6 +16,7 @@ from plane.api.permissions import ProjectEntityPermission from plane.db.models import Workspace, Project from plane.api.serializers import ProjectLiteSerializer, WorkspaceLiteSerializer +from plane.utils.integrations.github import get_release_notes class GPTIntegrationEndpoint(BaseAPIView): @@ -73,3 +75,44 @@ def post(self, request, slug, project_id): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class ReleaseNotesEndpoint(BaseAPIView): + def get(self, request): + try: + release_notes = get_release_notes() + return Response(release_notes, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + +class UnsplashEndpoint(BaseAPIView): + + def get(self, request): + try: + query = request.GET.get("query", False) + page = request.GET.get("page", 1) + per_page = request.GET.get("per_page", 20) + + url = ( + f"https://api.unsplash.com/search/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&query={query}&page=${page}&per_page={per_page}" + if query + else f"https://api.unsplash.com/photos/?client_id={settings.UNSPLASH_ACCESS_KEY}&page={page}&per_page={per_page}" + ) + + headers = { + "Content-Type": "application/json", + } + + resp = requests.get(url=url, headers=headers) + return Response(resp.json(), status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) diff --git a/apiserver/plane/api/views/importer.py b/apiserver/plane/api/views/importer.py index 0a92b3850fe..18d9a1d6930 100644 --- a/apiserver/plane/api/views/importer.py +++ b/apiserver/plane/api/views/importer.py @@ -384,7 +384,7 @@ def post(self, request, slug, project_id, service): sort_order=largest_sort_order, start_date=issue_data.get("start_date", None), target_date=issue_data.get("target_date", None), - priority=issue_data.get("priority", None), + priority=issue_data.get("priority", "none"), created_by=request.user, ) ) diff --git a/apiserver/plane/api/views/inbox.py b/apiserver/plane/api/views/inbox.py index 1a0284ea439..4bfc32f0198 100644 --- a/apiserver/plane/api/views/inbox.py +++ b/apiserver/plane/api/views/inbox.py @@ -173,12 +173,12 @@ def create(self, request, slug, project_id, inbox_id): ) # Check for valid priority - if not request.data.get("issue", {}).get("priority", None) in [ + if not request.data.get("issue", {}).get("priority", "none") in [ "low", "medium", "high", "urgent", - None, + "none", ]: return Response( {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST @@ -213,7 +213,7 @@ def create(self, request, slug, project_id, inbox_id): issue_id=str(issue.id), project_id=str(project_id), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) # create an inbox issue InboxIssue.objects.create( @@ -278,7 +278,7 @@ def partial_update(self, request, slug, project_id, inbox_id, pk): IssueSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) issue_serializer.save() else: @@ -370,6 +370,11 @@ def destroy(self, request, slug, project_id, inbox_id, pk): if project_member.role <= 10 and str(inbox_issue.created_by_id) != str(request.user.id): return Response({"error": "You cannot delete inbox issue"}, status=status.HTTP_400_BAD_REQUEST) + # Check the issue status + if inbox_issue.status in [-2, -1, 0, 2]: + # Delete the issue also + Issue.objects.filter(workspace__slug=slug, project_id=project_id, pk=inbox_issue.issue_id).delete() + inbox_issue.delete() return Response(status=status.HTTP_204_NO_CONTENT) except InboxIssue.DoesNotExist: @@ -480,12 +485,12 @@ def create(self, request, slug, project_id, inbox_id): ) # Check for valid priority - if not request.data.get("issue", {}).get("priority", None) in [ + if not request.data.get("issue", {}).get("priority", "none") in [ "low", "medium", "high", "urgent", - None, + "none", ]: return Response( {"error": "Invalid priority"}, status=status.HTTP_400_BAD_REQUEST @@ -520,7 +525,7 @@ def create(self, request, slug, project_id, inbox_id): issue_id=str(issue.id), project_id=str(project_id), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) # create an inbox issue InboxIssue.objects.create( @@ -585,7 +590,7 @@ def partial_update(self, request, slug, project_id, inbox_id, pk): IssueSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) issue_serializer.save() return Response(issue_serializer.data, status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index e653f3d447d..b5a62dd5d9a 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -24,7 +24,6 @@ from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page from django.db import IntegrityError -from django.conf import settings from django.db import IntegrityError # Third Party imports @@ -58,7 +57,6 @@ IssuePublicSerializer, ) from plane.api.permissions import ( - WorkspaceEntityPermission, ProjectEntityPermission, WorkSpaceAdminPermission, ProjectMemberPermission, @@ -130,7 +128,7 @@ def perform_update(self, serializer): current_instance=json.dumps( IssueSerializer(current_instance).data, cls=DjangoJSONEncoder ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return super().perform_update(serializer) @@ -151,7 +149,7 @@ def perform_destroy(self, instance): current_instance=json.dumps( IssueSerializer(current_instance).data, cls=DjangoJSONEncoder ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -318,7 +316,7 @@ def create(self, request, slug, project_id): issue_id=str(serializer.data.get("id", None)), project_id=str(project_id), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -577,7 +575,7 @@ def perform_create(self, serializer): issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) def perform_update(self, serializer): @@ -596,7 +594,7 @@ def perform_update(self, serializer): IssueCommentSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return super().perform_update(serializer) @@ -618,7 +616,7 @@ def perform_destroy(self, instance): IssueCommentSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -714,10 +712,18 @@ class LabelViewSet(BaseViewSet): ProjectMemberPermission, ] - def perform_create(self, serializer): - serializer.save( - project_id=self.kwargs.get("project_id"), - ) + def create(self, request, slug, project_id): + try: + serializer = LabelSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(project_id=project_id) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError: + return Response({"error": "Label with the same name already exists in the project"}, status=status.HTTP_400_BAD_REQUEST) + except Exception as e: + capture_exception(e) + return Response({"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST) def get_queryset(self): return self.filter_queryset( @@ -902,7 +908,7 @@ def perform_create(self, serializer): issue_id=str(self.kwargs.get("issue_id")), project_id=str(self.kwargs.get("project_id")), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) def perform_update(self, serializer): @@ -921,7 +927,7 @@ def perform_update(self, serializer): IssueLinkSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return super().perform_update(serializer) @@ -943,7 +949,7 @@ def perform_destroy(self, instance): IssueLinkSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -1022,7 +1028,7 @@ def post(self, request, slug, project_id, issue_id): serializer.data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1045,7 +1051,7 @@ def delete(self, request, slug, project_id, issue_id, pk): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return Response(status=status.HTTP_204_NO_CONTENT) @@ -1248,7 +1254,7 @@ def unarchive(self, request, slug, project_id, pk=None): issue_id=str(issue.id), project_id=str(project_id), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return Response(IssueSerializer(issue).data, status=status.HTTP_200_OK) @@ -1453,7 +1459,7 @@ def perform_create(self, serializer): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) def destroy(self, request, slug, project_id, issue_id, reaction_code): @@ -1477,7 +1483,7 @@ def destroy(self, request, slug, project_id, issue_id, reaction_code): "identifier": str(issue_reaction.id), } ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1526,7 +1532,7 @@ def perform_create(self, serializer): issue_id=None, project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) def destroy(self, request, slug, project_id, comment_id, reaction_code): @@ -1551,7 +1557,7 @@ def destroy(self, request, slug, project_id, comment_id, reaction_code): "comment_id": str(comment_id), } ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1648,7 +1654,7 @@ def create(self, request, slug, project_id, issue_id): issue_id=str(issue_id), project_id=str(project_id), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) if not ProjectMember.objects.filter( project_id=project_id, @@ -1698,7 +1704,7 @@ def partial_update(self, request, slug, project_id, issue_id, pk): IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1732,7 +1738,7 @@ def destroy(self, request, slug, project_id, issue_id, pk): IssueCommentSerializer(comment).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) comment.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1807,7 +1813,7 @@ def create(self, request, slug, project_id, issue_id): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1852,7 +1858,7 @@ def destroy(self, request, slug, project_id, issue_id, reaction_code): "identifier": str(issue_reaction.id), } ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) issue_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -1926,7 +1932,7 @@ def create(self, request, slug, project_id, comment_id): issue_id=None, project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -1978,7 +1984,7 @@ def destroy(self, request, slug, project_id, comment_id, reaction_code): "comment_id": str(comment_id), } ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) comment_reaction.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -2042,7 +2048,7 @@ def create(self, request, slug, project_id, issue_id): issue_id=str(self.kwargs.get("issue_id", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) serializer = IssueVoteSerializer(issue_vote) return Response(serializer.data, status=status.HTTP_201_CREATED) @@ -2077,7 +2083,7 @@ def destroy(self, request, slug, project_id, issue_id): "identifier": str(issue_vote.id), } ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) issue_vote.delete() return Response(status=status.HTTP_204_NO_CONTENT) @@ -2111,7 +2117,7 @@ def perform_destroy(self, instance): IssueRelationSerializer(current_instance).data, cls=DjangoJSONEncoder, ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -2145,7 +2151,7 @@ def create(self, request, slug, project_id, issue_id): issue_id=str(issue_id), project_id=str(project_id), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) if relation == "blocking": @@ -2400,27 +2406,6 @@ class IssueDraftViewSet(BaseViewSet): ] serializer_class = IssueFlatSerializer model = Issue - - - def perform_update(self, serializer): - requested_data = json.dumps(self.request.data, cls=DjangoJSONEncoder) - current_instance = ( - self.get_queryset().filter(pk=self.kwargs.get("pk", None)).first() - ) - if current_instance is not None: - issue_activity.delay( - type="issue_draft.activity.updated", - requested_data=requested_data, - actor_id=str(self.request.user.id), - issue_id=str(self.kwargs.get("pk", None)), - project_id=str(self.kwargs.get("project_id", None)), - current_instance=json.dumps( - IssueSerializer(current_instance).data, cls=DjangoJSONEncoder - ), - epoch = int(timezone.now().timestamp()) - ) - - return super().perform_update(serializer) def perform_destroy(self, instance): @@ -2439,6 +2424,7 @@ def perform_destroy(self, instance): current_instance=json.dumps( IssueSerializer(current_instance).data, cls=DjangoJSONEncoder ), + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -2602,7 +2588,7 @@ def create(self, request, slug, project_id): issue_id=str(serializer.data.get("id", None)), project_id=str(project_id), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return Response(serializer.data, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -2613,6 +2599,47 @@ def create(self, request, slug, project_id): ) + def partial_update(self, request, slug, project_id, pk): + try: + issue = Issue.objects.get( + workspace__slug=slug, project_id=project_id, pk=pk + ) + serializer = IssueSerializer( + issue, data=request.data, partial=True + ) + + if serializer.is_valid(): + if(request.data.get("is_draft") is not None and not request.data.get("is_draft")): + serializer.save(created_at=timezone.now(), updated_at=timezone.now()) + else: + serializer.save() + issue_activity.delay( + type="issue_draft.activity.updated", + requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), + actor_id=str(self.request.user.id), + issue_id=str(self.kwargs.get("pk", None)), + project_id=str(self.kwargs.get("project_id", None)), + current_instance=json.dumps( + IssueSerializer(issue).data, + cls=DjangoJSONEncoder, + ), + epoch=int(timezone.now().timestamp()) + ) + return Response(serializer.data, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except Issue.DoesNotExist: + return Response( + {"error": "Issue does not exists"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def retrieve(self, request, slug, project_id, pk=None): try: issue = Issue.objects.get( diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index c2a15da1cee..1489edb2d55 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -40,6 +40,7 @@ from plane.utils.issue_filters import issue_filters from plane.utils.analytics_plot import burndown_plot + class ModuleViewSet(BaseViewSet): model = Module permission_classes = [ @@ -78,35 +79,63 @@ def get_queryset(self): queryset=ModuleLink.objects.select_related("module", "created_by"), ) ) - .annotate(total_issues=Count("issue_module")) + .annotate( + total_issues=Count( + "issue_module", + filter=Q( + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), + ), + ) .annotate( completed_issues=Count( "issue_module__issue__state__group", - filter=Q(issue_module__issue__state__group="completed"), + filter=Q( + issue_module__issue__state__group="completed", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), ) ) .annotate( cancelled_issues=Count( "issue_module__issue__state__group", - filter=Q(issue_module__issue__state__group="cancelled"), + filter=Q( + issue_module__issue__state__group="cancelled", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), ) ) .annotate( started_issues=Count( "issue_module__issue__state__group", - filter=Q(issue_module__issue__state__group="started"), + filter=Q( + issue_module__issue__state__group="started", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), ) ) .annotate( unstarted_issues=Count( "issue_module__issue__state__group", - filter=Q(issue_module__issue__state__group="unstarted"), + filter=Q( + issue_module__issue__state__group="unstarted", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), ) ) .annotate( backlog_issues=Count( "issue_module__issue__state__group", - filter=Q(issue_module__issue__state__group="backlog"), + filter=Q( + issue_module__issue__state__group="backlog", + issue_module__issue__archived_at__isnull=True, + issue_module__issue__is_draft=False, + ), ) ) .order_by(order_by, "name") @@ -130,7 +159,7 @@ def perform_destroy(self, instance): issue_id=str(self.kwargs.get("pk", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -179,18 +208,36 @@ def retrieve(self, request, slug, project_id, pk): .annotate(assignee_id=F("assignees__id")) .annotate(display_name=F("assignees__display_name")) .annotate(avatar=F("assignees__avatar")) - .values("first_name", "last_name", "assignee_id", "avatar", "display_name") - .annotate(total_issues=Count("assignee_id")) + .values( + "first_name", "last_name", "assignee_id", "avatar", "display_name" + ) + .annotate( + total_issues=Count( + "assignee_id", + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ) + ) .annotate( completed_issues=Count( "assignee_id", - filter=Q(completed_at__isnull=False), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), ) ) .annotate( pending_issues=Count( "assignee_id", - filter=Q(completed_at__isnull=True), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), ) ) .order_by("first_name", "last_name") @@ -206,17 +253,33 @@ def retrieve(self, request, slug, project_id, pk): .annotate(color=F("labels__color")) .annotate(label_id=F("labels__id")) .values("label_name", "color", "label_id") - .annotate(total_issues=Count("label_id")) + .annotate( + total_issues=Count( + "label_id", + filter=Q( + archived_at__isnull=True, + is_draft=False, + ), + ), + ) .annotate( completed_issues=Count( "label_id", - filter=Q(completed_at__isnull=False), + filter=Q( + completed_at__isnull=False, + archived_at__isnull=True, + is_draft=False, + ), ) ) .annotate( pending_issues=Count( "label_id", - filter=Q(completed_at__isnull=True), + filter=Q( + completed_at__isnull=True, + archived_at__isnull=True, + is_draft=False, + ), ) ) .order_by("label_name") @@ -279,7 +342,7 @@ def perform_destroy(self, instance): issue_id=str(self.kwargs.get("pk", None)), project_id=str(self.kwargs.get("project_id", None)), current_instance=None, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return super().perform_destroy(instance) @@ -447,7 +510,7 @@ def create(self, request, slug, project_id, module_id): ), } ), - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) return Response( @@ -494,7 +557,6 @@ def get_queryset(self): class ModuleFavoriteViewSet(BaseViewSet): - serializer_class = ModuleFavoriteSerializer model = ModuleFavorite diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index 093c8ff7826..1ba2271778d 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -1,5 +1,6 @@ # Python imports import jwt +import boto3 from datetime import datetime # Django imports @@ -495,7 +496,7 @@ class ProjectMemberViewSet(BaseViewSet): serializer_class = ProjectMemberAdminSerializer model = ProjectMember permission_classes = [ - ProjectBasePermission, + ProjectMemberPermission, ] search_fields = [ @@ -617,7 +618,8 @@ def destroy(self, request, slug, project_id, pk): return Response(status=status.HTTP_204_NO_CONTENT) except ProjectMember.DoesNotExist: return Response( - {"error": "Project Member does not exist"}, status=status.HTTP_400_BAD_REQUEST + {"error": "Project Member does not exist"}, + status=status.HTTP_400_BAD_REQUEST, ) except Exception as e: capture_exception(e) @@ -1094,7 +1096,7 @@ def get(self, request, slug, project_id): project_id=project_id, workspace__slug=slug, member__is_bot=False, - ).select_related("project", "member") + ).select_related("project", "member", "workspace") serializer = ProjectMemberSerializer(project_members, many=True) return Response(serializer.data, status=status.HTTP_200_OK) except Exception as e: @@ -1209,3 +1211,38 @@ def delete(self, request, slug, project_id): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class ProjectPublicCoverImagesEndpoint(BaseAPIView): + permission_classes = [ + AllowAny, + ] + + def get(self, request): + try: + files = [] + s3 = boto3.client( + "s3", + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + ) + params = { + "Bucket": settings.AWS_S3_BUCKET_NAME, + "Prefix": "static/project-cover/", + } + + response = s3.list_objects_v2(**params) + # Extracting file keys from the response + if "Contents" in response: + for content in response["Contents"]: + if not content["Key"].endswith( + "/" + ): # This line ensures we're only getting files, not "sub-folders" + files.append( + f"https://{settings.AWS_S3_BUCKET_NAME}.s3.{settings.AWS_REGION}.amazonaws.com/{content['Key']}" + ) + + return Response(files, status=status.HTTP_200_OK) + except Exception as e: + capture_exception(e) + return Response([], status=status.HTTP_200_OK) diff --git a/apiserver/plane/api/views/release.py b/apiserver/plane/api/views/release.py deleted file mode 100644 index de827c896e3..00000000000 --- a/apiserver/plane/api/views/release.py +++ /dev/null @@ -1,21 +0,0 @@ -# Third party imports -from rest_framework.response import Response -from rest_framework import status -from sentry_sdk import capture_exception - -# Module imports -from .base import BaseAPIView -from plane.utils.integrations.github import get_release_notes - - -class ReleaseNotesEndpoint(BaseAPIView): - def get(self, request): - try: - release_notes = get_release_notes() - return Response(release_notes, status=status.HTTP_200_OK) - except Exception as e: - capture_exception(e) - return Response( - {"error": "Something went wrong please try again later"}, - status=status.HTTP_400_BAD_REQUEST, - ) diff --git a/apiserver/plane/api/views/view.py b/apiserver/plane/api/views/view.py index b6f1d7c4b73..435f8725a84 100644 --- a/apiserver/plane/api/views/view.py +++ b/apiserver/plane/api/views/view.py @@ -61,7 +61,7 @@ def get_queryset(self): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .select_related("workspace") - .order_by("-created_at") + .order_by(self.request.GET.get("order_by", "-created_at")) .distinct() ) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 2d1ee81328b..8d518b160ff 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -1197,7 +1197,7 @@ def get(self, request, slug, user_id): projects = request.query_params.getlist("project", []) queryset = IssueActivity.objects.filter( - ~Q(field__in=["comment", "vote", "reaction"]), + ~Q(field__in=["comment", "vote", "reaction", "draft"]), workspace__slug=slug, project__project_projectmember__member=request.user, actor=user_id, @@ -1239,13 +1239,21 @@ def get(self, request, slug, user_id): .annotate( created_issues=Count( "project_issue", - filter=Q(project_issue__created_by_id=user_id), + filter=Q( + project_issue__created_by_id=user_id, + project_issue__archived_at__isnull=True, + project_issue__is_draft=False, + ), ) ) .annotate( assigned_issues=Count( "project_issue", - filter=Q(project_issue__assignees__in=[user_id]), + filter=Q( + project_issue__assignees__in=[user_id], + project_issue__archived_at__isnull=True, + project_issue__is_draft=False, + ), ) ) .annotate( @@ -1254,6 +1262,8 @@ def get(self, request, slug, user_id): filter=Q( project_issue__completed_at__isnull=False, project_issue__assignees__in=[user_id], + project_issue__archived_at__isnull=True, + project_issue__is_draft=False, ), ) ) @@ -1267,6 +1277,8 @@ def get(self, request, slug, user_id): "started", ], project_issue__assignees__in=[user_id], + project_issue__archived_at__isnull=True, + project_issue__is_draft=False, ), ) ) @@ -1317,6 +1329,11 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): def get(self, request, slug, user_id): try: filters = issue_filters(request.query_params, "GET") + + # Custom ordering for priority and state + priority_order = ["urgent", "high", "medium", "low", "none"] + state_order = ["backlog", "unstarted", "started", "completed", "cancelled"] + order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( Issue.issue_objects.filter( diff --git a/apiserver/plane/bgtasks/exporter_expired_task.py b/apiserver/plane/bgtasks/exporter_expired_task.py index a77d68b4b55..45c53eaca05 100644 --- a/apiserver/plane/bgtasks/exporter_expired_task.py +++ b/apiserver/plane/bgtasks/exporter_expired_task.py @@ -32,7 +32,7 @@ def delete_old_s3_link(): else: s3 = boto3.client( "s3", - region_name="ap-south-1", + region_name=settings.AWS_REGION, aws_access_key_id=settings.AWS_ACCESS_KEY_ID, aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, config=Config(signature_version="s3v4"), diff --git a/apiserver/plane/bgtasks/issue_activites_task.py b/apiserver/plane/bgtasks/issue_activites_task.py index 733defe69a4..87c4fa1a4e8 100644 --- a/apiserver/plane/bgtasks/issue_activites_task.py +++ b/apiserver/plane/bgtasks/issue_activites_task.py @@ -121,36 +121,20 @@ def track_priority( epoch ): if current_instance.get("priority") != requested_data.get("priority"): - if requested_data.get("priority") == None: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("priority"), - new_value=None, - field="priority", - project=project, - workspace=project.workspace, - comment=f"updated the priority to None", - epoch=epoch, - ) - ) - else: - issue_activities.append( - IssueActivity( - issue_id=issue_id, - actor=actor, - verb="updated", - old_value=current_instance.get("priority"), - new_value=requested_data.get("priority"), - field="priority", - project=project, - workspace=project.workspace, - comment=f"updated the priority to {requested_data.get('priority')}", - epoch=epoch, - ) + issue_activities.append( + IssueActivity( + issue_id=issue_id, + actor=actor, + verb="updated", + old_value=current_instance.get("priority"), + new_value=requested_data.get("priority"), + field="priority", + project=project, + workspace=project.workspace, + comment=f"updated the priority to {requested_data.get('priority')}", + epoch=epoch, ) + ) # Track chnages in state of the issue @@ -1405,7 +1389,7 @@ def issue_activity( ): issue_subscribers = issue_subscribers + [issue.created_by_id] - for subscriber in issue_subscribers: + for subscriber in list(set(issue_subscribers)): for issue_activity in issue_activities_created: bulk_notifications.append( Notification( diff --git a/apiserver/plane/bgtasks/issue_automation_task.py b/apiserver/plane/bgtasks/issue_automation_task.py index f7b06c625f0..68c64403ac6 100644 --- a/apiserver/plane/bgtasks/issue_automation_task.py +++ b/apiserver/plane/bgtasks/issue_automation_task.py @@ -58,28 +58,31 @@ def archive_old_issues(): # Check if Issues if issues: + # Set the archive time to current time + archive_at = timezone.now() + issues_to_update = [] for issue in issues: - issue.archived_at = timezone.now() + issue.archived_at = archive_at issues_to_update.append(issue) # Bulk Update the issues and log the activity if issues_to_update: - updated_issues = Issue.objects.bulk_update( + Issue.objects.bulk_update( issues_to_update, ["archived_at"], batch_size=100 ) [ issue_activity.delay( type="issue.activity.updated", - requested_data=json.dumps({"archived_at": str(issue.archived_at)}), + requested_data=json.dumps({"archived_at": str(archive_at)}), actor_id=str(project.created_by_id), issue_id=issue.id, project_id=project_id, current_instance=None, subscriber=False, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) - for issue in updated_issues + for issue in issues_to_update ] return except Exception as e: @@ -139,7 +142,7 @@ def close_old_issues(): # Bulk Update the issues and log the activity if issues_to_update: - updated_issues = Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) + Issue.objects.bulk_update(issues_to_update, ["state"], batch_size=100) [ issue_activity.delay( type="issue.activity.updated", @@ -149,9 +152,9 @@ def close_old_issues(): project_id=project_id, current_instance=None, subscriber=False, - epoch = int(timezone.now().timestamp()) + epoch=int(timezone.now().timestamp()) ) - for issue in updated_issues + for issue in issues_to_update ] return except Exception as e: diff --git a/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py index 950189c5540..5a806c7046a 100644 --- a/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py +++ b/apiserver/plane/db/migrations/0043_alter_analyticview_created_by_and_more.py @@ -33,9 +33,8 @@ def create_issue_relation(apps, schema_editor): def update_issue_priority_choice(apps, schema_editor): IssueModel = apps.get_model("db", "Issue") updated_issues = [] - for obj in IssueModel.objects.all(): - if obj.priority is None: - obj.priority = "none" + for obj in IssueModel.objects.filter(priority=None): + obj.priority = "none" updated_issues.append(obj) IssueModel.objects.bulk_update(updated_issues, ["priority"], batch_size=100) diff --git a/apiserver/plane/db/migrations/0044_auto_20230913_0709.py b/apiserver/plane/db/migrations/0044_auto_20230913_0709.py index f3006237158..19a1449af46 100644 --- a/apiserver/plane/db/migrations/0044_auto_20230913_0709.py +++ b/apiserver/plane/db/migrations/0044_auto_20230913_0709.py @@ -26,19 +26,19 @@ def workspace_member_props(old_props): "calendar_date_range": old_props.get("calendarDateRange", ""), }, "display_properties": { - "assignee": old_props.get("properties", {}).get("assignee",None), - "attachment_count": old_props.get("properties", {}).get("attachment_count", None), - "created_on": old_props.get("properties", {}).get("created_on", None), - "due_date": old_props.get("properties", {}).get("due_date", None), - "estimate": old_props.get("properties", {}).get("estimate", None), - "key": old_props.get("properties", {}).get("key", None), - "labels": old_props.get("properties", {}).get("labels", None), - "link": old_props.get("properties", {}).get("link", None), - "priority": old_props.get("properties", {}).get("priority", None), - "start_date": old_props.get("properties", {}).get("start_date", None), - "state": old_props.get("properties", {}).get("state", None), - "sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", None), - "updated_on": old_props.get("properties", {}).get("updated_on", None), + "assignee": old_props.get("properties", {}).get("assignee", True), + "attachment_count": old_props.get("properties", {}).get("attachment_count", True), + "created_on": old_props.get("properties", {}).get("created_on", True), + "due_date": old_props.get("properties", {}).get("due_date", True), + "estimate": old_props.get("properties", {}).get("estimate", True), + "key": old_props.get("properties", {}).get("key", True), + "labels": old_props.get("properties", {}).get("labels", True), + "link": old_props.get("properties", {}).get("link", True), + "priority": old_props.get("properties", {}).get("priority", True), + "start_date": old_props.get("properties", {}).get("start_date", True), + "state": old_props.get("properties", {}).get("state", True), + "sub_issue_count": old_props.get("properties", {}).get("sub_issue_count", True), + "updated_on": old_props.get("properties", {}).get("updated_on", True), }, } return new_props diff --git a/apiserver/plane/db/migrations/0045_auto_20230915_0655.py b/apiserver/plane/db/migrations/0045_auto_20230915_0655.py deleted file mode 100644 index a8360c63d1d..00000000000 --- a/apiserver/plane/db/migrations/0045_auto_20230915_0655.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 4.2.3 on 2023-09-15 06:55 - -from django.db import migrations - - -def update_issue_activity(apps, schema_editor): - IssueActivityModel = apps.get_model("db", "IssueActivity") - updated_issue_activity = [] - for obj in IssueActivityModel.objects.all(): - if obj.field == "blocks": - obj.field = "blocked_by" - updated_issue_activity.append(obj) - IssueActivityModel.objects.bulk_update(updated_issue_activity, ["field"], batch_size=100) - - -class Migration(migrations.Migration): - - dependencies = [ - ('db', '0044_auto_20230913_0709'), - ] - - operations = [ - migrations.RunPython(update_issue_activity), - ] diff --git a/apiserver/plane/db/migrations/0046_auto_20230919_1421.py b/apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py similarity index 59% rename from apiserver/plane/db/migrations/0046_auto_20230919_1421.py rename to apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py index 4005a94d46f..4b9c1b1eb94 100644 --- a/apiserver/plane/db/migrations/0046_auto_20230919_1421.py +++ b/apiserver/plane/db/migrations/0045_issueactivity_epoch_workspacemember_issue_props_and_more.py @@ -1,28 +1,47 @@ -# Generated by Django 4.2.3 on 2023-09-19 14:21 +# Generated by Django 4.2.5 on 2023-09-29 10:14 from django.conf import settings from django.db import migrations, models import django.db.models.deletion +import plane.db.models.workspace import uuid -def update_epoch(apps, schema_editor): - IssueActivity = apps.get_model('db', 'IssueActivity') +def update_issue_activity_priority(apps, schema_editor): + IssueActivity = apps.get_model("db", "IssueActivity") updated_issue_activity = [] - for obj in IssueActivity.objects.all(): - obj.epoch = int(obj.created_at.timestamp()) + for obj in IssueActivity.objects.filter(field="priority"): + # Set the old and new value to none if it is empty for Priority + obj.new_value = obj.new_value or "none" + obj.old_value = obj.old_value or "none" updated_issue_activity.append(obj) - IssueActivity.objects.bulk_update(updated_issue_activity, ["epoch"], batch_size=100) + IssueActivity.objects.bulk_update( + updated_issue_activity, + ["new_value", "old_value"], + batch_size=2000, + ) +def update_issue_activity_blocked(apps, schema_editor): + IssueActivity = apps.get_model("db", "IssueActivity") + updated_issue_activity = [] + for obj in IssueActivity.objects.filter(field="blocks"): + # Set the field to blocked_by + obj.field = "blocked_by" + updated_issue_activity.append(obj) + IssueActivity.objects.bulk_update( + updated_issue_activity, + ["field"], + batch_size=1000, + ) class Migration(migrations.Migration): dependencies = [ - ('db', '0045_auto_20230915_0655'), + ('db', '0044_auto_20230913_0709'), ] operations = [ - migrations.CreateModel( + migrations.CreateModel( name='GlobalView', fields=[ ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), @@ -33,6 +52,7 @@ class Migration(migrations.Migration): ('query', models.JSONField(verbose_name='View Query')), ('access', models.PositiveSmallIntegerField(choices=[(0, 'Private'), (1, 'Public')], default=1)), ('query_data', models.JSONField(default=dict)), + ('sort_order', models.FloatField(default=65535)), ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='%(class)s_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='global_views', to='db.workspace')), @@ -44,10 +64,16 @@ class Migration(migrations.Migration): 'ordering': ('-created_at',), }, ), + migrations.AddField( + model_name='workspacemember', + name='issue_props', + field=models.JSONField(default=plane.db.models.workspace.get_issue_props), + ), migrations.AddField( model_name='issueactivity', name='epoch', field=models.FloatField(null=True), ), - migrations.RunPython(update_epoch), + migrations.RunPython(update_issue_activity_priority), + migrations.RunPython(update_issue_activity_blocked), ] diff --git a/apiserver/plane/db/models/view.py b/apiserver/plane/db/models/view.py index 6e0a47105d6..44bc994d0c8 100644 --- a/apiserver/plane/db/models/view.py +++ b/apiserver/plane/db/models/view.py @@ -17,12 +17,23 @@ class GlobalView(BaseModel): default=1, choices=((0, "Private"), (1, "Public")) ) query_data = models.JSONField(default=dict) + sort_order = models.FloatField(default=65535) class Meta: verbose_name = "Global View" verbose_name_plural = "Global Views" db_table = "global_views" ordering = ("-created_at",) + + def save(self, *args, **kwargs): + if self._state.adding: + largest_sort_order = GlobalView.objects.filter( + workspace=self.workspace + ).aggregate(largest=models.Max("sort_order"))["largest"] + if largest_sort_order is not None: + self.sort_order = largest_sort_order + 10000 + + super(GlobalView, self).save(*args, **kwargs) def __str__(self): """Return name of the View""" diff --git a/apiserver/plane/db/models/workspace.py b/apiserver/plane/db/models/workspace.py index c8526843568..d1012f54914 100644 --- a/apiserver/plane/db/models/workspace.py +++ b/apiserver/plane/db/models/workspace.py @@ -29,7 +29,7 @@ def get_default_props(): }, "display_filters": { "group_by": None, - "order_by": '-created_at', + "order_by": "-created_at", "type": None, "sub_issue": True, "show_empty_groups": True, @@ -54,6 +54,15 @@ def get_default_props(): } +def get_issue_props(): + return { + "subscribed": True, + "assigned": True, + "created": True, + "all_issues": True, + } + + class Workspace(BaseModel): name = models.CharField(max_length=80, verbose_name="Workspace Name") logo = models.URLField(verbose_name="Logo", blank=True, null=True) @@ -89,6 +98,7 @@ class WorkspaceMember(BaseModel): company_role = models.TextField(null=True, blank=True) view_props = models.JSONField(default=get_default_props) default_props = models.JSONField(default=get_default_props) + issue_props = models.JSONField(default=get_issue_props) class Meta: unique_together = ["workspace", "member"] diff --git a/apiserver/plane/settings/local.py b/apiserver/plane/settings/local.py index 9d293c0191e..6f4833a6c70 100644 --- a/apiserver/plane/settings/local.py +++ b/apiserver/plane/settings/local.py @@ -114,3 +114,6 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" + +# Unsplash Access key +UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") diff --git a/apiserver/plane/settings/production.py b/apiserver/plane/settings/production.py index e434f974271..9c6bd95a92b 100644 --- a/apiserver/plane/settings/production.py +++ b/apiserver/plane/settings/production.py @@ -7,6 +7,7 @@ import sentry_sdk from sentry_sdk.integrations.django import DjangoIntegration from sentry_sdk.integrations.redis import RedisIntegration +from urllib.parse import urlparse from .common import * # noqa @@ -89,90 +90,112 @@ profiles_sample_rate=1.0, ) -# The AWS region to connect to. -AWS_REGION = os.environ.get("AWS_REGION", "") - -# The AWS access key to use. -AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "") - -# The AWS secret access key to use. -AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "") - -# The optional AWS session token to use. -# AWS_SESSION_TOKEN = "" - -# The name of the bucket to store files in. -AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") - -# How to construct S3 URLs ("auto", "path", "virtual"). -AWS_S3_ADDRESSING_STYLE = "auto" - -# The full URL to the S3 endpoint. Leave blank to use the default region URL. -AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "") - -# A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. -AWS_S3_KEY_PREFIX = "" - -# Whether to enable authentication for stored files. If True, then generated URLs will include an authentication -# token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, -# and their permissions will be set to "public-read". -AWS_S3_BUCKET_AUTH = False - -# How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` -# is True. It also affects the "Cache-Control" header of the files. -# Important: Changing this setting will not affect existing files. -AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours. - -# A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting -# cannot be used with `AWS_S3_BUCKET_AUTH`. -AWS_S3_PUBLIC_URL = "" - -# If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you -# understand the consequences before enabling. -# Important: Changing this setting will not affect existing files. -AWS_S3_REDUCED_REDUNDANCY = False - -# The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a -# single `name` argument. -# Important: Changing this setting will not affect existing files. -AWS_S3_CONTENT_DISPOSITION = "" - -# The Content-Language header used when the file is downloaded. This can be a string, or a function taking a -# single `name` argument. -# Important: Changing this setting will not affect existing files. -AWS_S3_CONTENT_LANGUAGE = "" - -# A mapping of custom metadata for each file. Each value can be a string, or a function taking a -# single `name` argument. -# Important: Changing this setting will not affect existing files. -AWS_S3_METADATA = {} - -# If True, then files will be stored using AES256 server-side encryption. -# If this is a string value (e.g., "aws:kms"), that encryption type will be used. -# Otherwise, server-side encryption is not be enabled. -# Important: Changing this setting will not affect existing files. -AWS_S3_ENCRYPT_KEY = False - -# The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present. -# This is only relevant if AWS S3 KMS server-side encryption is enabled (above). -# AWS_S3_KMS_ENCRYPTION_KEY_ID = "" - -# If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their -# compressed size is smaller than their uncompressed size. -# Important: Changing this setting will not affect existing files. -AWS_S3_GZIP = True - -# The signature version to use for S3 requests. -AWS_S3_SIGNATURE_VERSION = None - -# If True, then files with the same name will overwrite each other. By default it's set to False to have -# extra characters appended. -AWS_S3_FILE_OVERWRITE = False - -STORAGES["default"] = { - "BACKEND": "django_s3_storage.storage.S3Storage", -} - +if DOCKERIZED and USE_MINIO: + INSTALLED_APPS += ("storages",) + STORAGES["default"] = {"BACKEND": "storages.backends.s3boto3.S3Boto3Storage"} + # The AWS access key to use. + AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "access-key") + # The AWS secret access key to use. + AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "secret-key") + # The name of the bucket to store files in. + AWS_STORAGE_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME", "uploads") + # The full URL to the S3 endpoint. Leave blank to use the default region URL. + AWS_S3_ENDPOINT_URL = os.environ.get( + "AWS_S3_ENDPOINT_URL", "http://plane-minio:9000" + ) + # Default permissions + AWS_DEFAULT_ACL = "public-read" + AWS_QUERYSTRING_AUTH = False + AWS_S3_FILE_OVERWRITE = False + + # Custom Domain settings + parsed_url = urlparse(os.environ.get("WEB_URL", "http://localhost")) + AWS_S3_CUSTOM_DOMAIN = f"{parsed_url.netloc}/{AWS_STORAGE_BUCKET_NAME}" + AWS_S3_URL_PROTOCOL = f"{parsed_url.scheme}:" +else: + # The AWS region to connect to. + AWS_REGION = os.environ.get("AWS_REGION", "") + + # The AWS access key to use. + AWS_ACCESS_KEY_ID = os.environ.get("AWS_ACCESS_KEY_ID", "") + + # The AWS secret access key to use. + AWS_SECRET_ACCESS_KEY = os.environ.get("AWS_SECRET_ACCESS_KEY", "") + + # The optional AWS session token to use. + # AWS_SESSION_TOKEN = "" + + # The name of the bucket to store files in. + AWS_S3_BUCKET_NAME = os.environ.get("AWS_S3_BUCKET_NAME") + + # How to construct S3 URLs ("auto", "path", "virtual"). + AWS_S3_ADDRESSING_STYLE = "auto" + + # The full URL to the S3 endpoint. Leave blank to use the default region URL. + AWS_S3_ENDPOINT_URL = os.environ.get("AWS_S3_ENDPOINT_URL", "") + + # A prefix to be applied to every stored file. This will be joined to every filename using the "/" separator. + AWS_S3_KEY_PREFIX = "" + + # Whether to enable authentication for stored files. If True, then generated URLs will include an authentication + # token valid for `AWS_S3_MAX_AGE_SECONDS`. If False, then generated URLs will not include an authentication token, + # and their permissions will be set to "public-read". + AWS_S3_BUCKET_AUTH = False + + # How long generated URLs are valid for. This affects the expiry of authentication tokens if `AWS_S3_BUCKET_AUTH` + # is True. It also affects the "Cache-Control" header of the files. + # Important: Changing this setting will not affect existing files. + AWS_S3_MAX_AGE_SECONDS = 60 * 60 # 1 hours. + + # A URL prefix to be used for generated URLs. This is useful if your bucket is served through a CDN. This setting + # cannot be used with `AWS_S3_BUCKET_AUTH`. + AWS_S3_PUBLIC_URL = "" + + # If True, then files will be stored with reduced redundancy. Check the S3 documentation and make sure you + # understand the consequences before enabling. + # Important: Changing this setting will not affect existing files. + AWS_S3_REDUCED_REDUNDANCY = False + + # The Content-Disposition header used when the file is downloaded. This can be a string, or a function taking a + # single `name` argument. + # Important: Changing this setting will not affect existing files. + AWS_S3_CONTENT_DISPOSITION = "" + + # The Content-Language header used when the file is downloaded. This can be a string, or a function taking a + # single `name` argument. + # Important: Changing this setting will not affect existing files. + AWS_S3_CONTENT_LANGUAGE = "" + + # A mapping of custom metadata for each file. Each value can be a string, or a function taking a + # single `name` argument. + # Important: Changing this setting will not affect existing files. + AWS_S3_METADATA = {} + + # If True, then files will be stored using AES256 server-side encryption. + # If this is a string value (e.g., "aws:kms"), that encryption type will be used. + # Otherwise, server-side encryption is not be enabled. + # Important: Changing this setting will not affect existing files. + AWS_S3_ENCRYPT_KEY = False + + # The AWS S3 KMS encryption key ID (the `SSEKMSKeyId` parameter) is set from this string if present. + # This is only relevant if AWS S3 KMS server-side encryption is enabled (above). + # AWS_S3_KMS_ENCRYPTION_KEY_ID = "" + + # If True, then text files will be stored using gzip content encoding. Files will only be gzipped if their + # compressed size is smaller than their uncompressed size. + # Important: Changing this setting will not affect existing files. + AWS_S3_GZIP = True + + # The signature version to use for S3 requests. + AWS_S3_SIGNATURE_VERSION = None + + # If True, then files with the same name will overwrite each other. By default it's set to False to have + # extra characters appended. + AWS_S3_FILE_OVERWRITE = False + + STORAGES["default"] = { + "BACKEND": "django_s3_storage.storage.S3Storage", + } # AWS Settings End # Enable Connection Pooling (if desired) @@ -193,16 +216,27 @@ REDIS_URL = os.environ.get("REDIS_URL") -CACHES = { - "default": { - "BACKEND": "django_redis.cache.RedisCache", - "LOCATION": REDIS_URL, - "OPTIONS": { - "CLIENT_CLASS": "django_redis.client.DefaultClient", - "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, - }, +if DOCKERIZED: + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + }, + } + } +else: + CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + "CONNECTION_POOL_KWARGS": {"ssl_cert_reqs": False}, + }, + } } -} WEB_URL = os.environ.get("WEB_URL", "https://app.plane.so") @@ -225,8 +259,12 @@ f"{redis_url}?ssl_cert_reqs={ssl.CERT_NONE.name}&ssl_ca_certs={certifi.where()}" ) -CELERY_RESULT_BACKEND = broker_url -CELERY_BROKER_URL = broker_url +if DOCKERIZED: + CELERY_BROKER_URL = REDIS_URL + CELERY_RESULT_BACKEND = REDIS_URL +else: + CELERY_BROKER_URL = broker_url + CELERY_RESULT_BACKEND = broker_url GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) @@ -238,3 +276,6 @@ SCOUT_KEY = os.environ.get("SCOUT_KEY", "") SCOUT_NAME = "Plane" +# Unsplash Access key +UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") + diff --git a/apiserver/plane/settings/selfhosted.py b/apiserver/plane/settings/selfhosted.py index 948ba22da45..ee529a7c332 100644 --- a/apiserver/plane/settings/selfhosted.py +++ b/apiserver/plane/settings/selfhosted.py @@ -126,3 +126,4 @@ OPENAI_API_BASE = os.environ.get("OPENAI_API_BASE", "https://api.openai.com/v1") OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", False) GPT_ENGINE = os.environ.get("GPT_ENGINE", "gpt-3.5-turbo") + diff --git a/apiserver/plane/settings/staging.py b/apiserver/plane/settings/staging.py index 5e274f8f32e..f776afd9117 100644 --- a/apiserver/plane/settings/staging.py +++ b/apiserver/plane/settings/staging.py @@ -218,3 +218,7 @@ GITHUB_ACCESS_TOKEN = os.environ.get("GITHUB_ACCESS_TOKEN", False) ENABLE_SIGNUP = os.environ.get("ENABLE_SIGNUP", "1") == "1" + + +# Unsplash Access key +UNSPLASH_ACCESS_KEY = os.environ.get("UNSPLASH_ACCESS_KEY") diff --git a/apiserver/plane/utils/analytics_plot.py b/apiserver/plane/utils/analytics_plot.py index 60e75145913..bffbb4c2a7a 100644 --- a/apiserver/plane/utils/analytics_plot.py +++ b/apiserver/plane/utils/analytics_plot.py @@ -74,10 +74,10 @@ def build_graph_plot(queryset, x_axis, y_axis, segment=None): sorted_data = grouped_data if temp_axis == "priority": - order = ["low", "medium", "high", "urgent", "None"] + order = ["low", "medium", "high", "urgent", "none"] sorted_data = {key: grouped_data[key] for key in order if key in grouped_data} else: - sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "None", x[0]))) + sorted_data = dict(sorted(grouped_data.items(), key=lambda x: (x[0] == "none", x[0]))) return sorted_data diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 226d909cd25..dae301c381d 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -40,9 +40,6 @@ def filter_priority(params, filter, method): priorities = params.get("priority").split(",") if len(priorities) and "" not in priorities: filter["priority__in"] = priorities - else: - if params.get("priority", None) and len(params.get("priority")): - filter["priority__in"] = params.get("priority") return filter @@ -166,17 +163,17 @@ def filter_target_date(params, filter, method): for query in target_dates: target_date_query = query.split(";") if len(target_date_query) == 2 and "after" in target_date_query: - filter["target_date__gt"] = target_date_query[0] + filter["target_date__gte"] = target_date_query[0] else: - filter["target_date__lt"] = target_date_query[0] + filter["target_date__lte"] = target_date_query[0] else: if params.get("target_date", None) and len(params.get("target_date")): for query in params.get("target_date"): target_date_query = query.split(";") if len(target_date_query) == 2 and "after" in target_date_query: - filter["target_date__gt"] = target_date_query[0] + filter["target_date__gte"] = target_date_query[0] else: - filter["target_date__lt"] = target_date_query[0] + filter["target_date__lte"] = target_date_query[0] return filter diff --git a/docker-compose-hub.yml b/docker-compose-hub.yml index 0014dfe86ed..498f37b8449 100644 --- a/docker-compose-hub.yml +++ b/docker-compose-hub.yml @@ -1,113 +1,61 @@ version: "3.8" -x-api-and-worker-env: - &api-and-worker-env - DEBUG: ${DEBUG} - SENTRY_DSN: ${SENTRY_DSN} - DJANGO_SETTINGS_MODULE: plane.settings.selfhosted - DATABASE_URL: postgres://${PGUSER}:${PGPASSWORD}@${PGHOST}:5432/${PGDATABASE} - REDIS_URL: redis://plane-redis:6379/ - EMAIL_HOST: ${EMAIL_HOST} - EMAIL_HOST_USER: ${EMAIL_HOST_USER} - EMAIL_HOST_PASSWORD: ${EMAIL_HOST_PASSWORD} - EMAIL_PORT: ${EMAIL_PORT} - EMAIL_FROM: ${EMAIL_FROM} - EMAIL_USE_TLS: ${EMAIL_USE_TLS} - EMAIL_USE_SSL: ${EMAIL_USE_SSL} - AWS_REGION: ${AWS_REGION} - AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID} - AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY} - AWS_S3_BUCKET_NAME: ${AWS_S3_BUCKET_NAME} - AWS_S3_ENDPOINT_URL: ${AWS_S3_ENDPOINT_URL} - FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT} - WEB_URL: ${WEB_URL} - GITHUB_CLIENT_SECRET: ${GITHUB_CLIENT_SECRET} - DISABLE_COLLECTSTATIC: 1 - DOCKERIZED: 1 - OPENAI_API_BASE: ${OPENAI_API_BASE} - OPENAI_API_KEY: ${OPENAI_API_KEY} - GPT_ENGINE: ${GPT_ENGINE} - SECRET_KEY: ${SECRET_KEY} - DEFAULT_EMAIL: ${DEFAULT_EMAIL} - DEFAULT_PASSWORD: ${DEFAULT_PASSWORD} - USE_MINIO: ${USE_MINIO} - ENABLE_SIGNUP: ${ENABLE_SIGNUP} - services: - plane-web: - container_name: planefrontend + web: + container_name: web image: makeplane/plane-frontend:latest restart: always command: /usr/local/bin/start.sh web/server.js web env_file: - - .env - environment: - NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL} - NEXT_PUBLIC_DEPLOY_URL: ${NEXT_PUBLIC_DEPLOY_URL} - NEXT_PUBLIC_GOOGLE_CLIENTID: "0" - NEXT_PUBLIC_GITHUB_APP_NAME: "0" - NEXT_PUBLIC_GITHUB_ID: "0" - NEXT_PUBLIC_SENTRY_DSN: "0" - NEXT_PUBLIC_ENABLE_OAUTH: "0" - NEXT_PUBLIC_ENABLE_SENTRY: "0" - NEXT_PUBLIC_ENABLE_SESSION_RECORDER: "0" - NEXT_PUBLIC_TRACK_EVENTS: "0" + - ./web/.env depends_on: - - plane-api - - plane-worker + - api + - worker - plane-deploy: - container_name: planedeploy - image: makeplane/plane-deploy:latest + space: + container_name: space + image: makeplane/plane-space:latest restart: always command: /usr/local/bin/start.sh space/server.js space env_file: - - .env - environment: - NEXT_PUBLIC_API_BASE_URL: ${NEXT_PUBLIC_API_BASE_URL} + - ./space/.env depends_on: - - plane-api - - plane-worker - - plane-web + - api + - worker + - web - plane-api: - container_name: planebackend + api: + container_name: api image: makeplane/plane-backend:latest restart: always command: ./bin/takeoff env_file: - - .env - environment: - <<: *api-and-worker-env + - ./apiserver/.env depends_on: - plane-db - plane-redis - plane-worker: - container_name: planebgworker + worker: + container_name: bgworker image: makeplane/plane-backend:latest restart: always command: ./bin/worker env_file: - - .env - environment: - <<: *api-and-worker-env + - ./apiserver/.env depends_on: - - plane-api + - api - plane-db - plane-redis - plane-beat-worker: - container_name: planebeatworker + beat-worker: + container_name: beatworker image: makeplane/plane-backend:latest restart: always command: ./bin/beat env_file: - - .env - environment: - <<: *api-and-worker-env + - ./apiserver/.env depends_on: - - plane-api + - api - plane-db - plane-redis @@ -157,8 +105,8 @@ services: - plane-minio # Comment this if you already have a reverse proxy running - plane-proxy: - container_name: planeproxy + proxy: + container_name: proxy image: makeplane/plane-proxy:latest ports: - ${NGINX_PORT}:80 @@ -168,8 +116,9 @@ services: FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} depends_on: - - plane-web - - plane-api + - web + - api + - space volumes: pgdata: diff --git a/docker-compose.yml b/docker-compose.yml index e3c1b37be27..0895aa1ae2b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,8 +1,8 @@ version: "3.8" services: - plane-web: - container_name: planefrontend + web: + container_name: web build: context: . dockerfile: ./web/Dockerfile.web @@ -11,11 +11,11 @@ services: restart: always command: /usr/local/bin/start.sh web/server.js web depends_on: - - plane-api - - plane-worker + - api + - worker - plane-deploy: - container_name: planedeploy + space: + container_name: space build: context: . dockerfile: ./space/Dockerfile.space @@ -24,12 +24,12 @@ services: restart: always command: /usr/local/bin/start.sh space/server.js space depends_on: - - plane-api - - plane-worker - - plane-web + - api + - worker + - web - plane-api: - container_name: planebackend + api: + container_name: api build: context: ./apiserver dockerfile: Dockerfile.api @@ -43,8 +43,8 @@ services: - plane-db - plane-redis - plane-worker: - container_name: planebgworker + worker: + container_name: bgworker build: context: ./apiserver dockerfile: Dockerfile.api @@ -55,12 +55,12 @@ services: env_file: - ./apiserver/.env depends_on: - - plane-api + - api - plane-db - plane-redis - plane-beat-worker: - container_name: planebeatworker + beat-worker: + container_name: beatworker build: context: ./apiserver dockerfile: Dockerfile.api @@ -71,7 +71,7 @@ services: env_file: - ./apiserver/.env depends_on: - - plane-api + - api - plane-db - plane-redis @@ -118,8 +118,8 @@ services: - plane-minio # Comment this if you already have a reverse proxy running - plane-proxy: - container_name: planeproxy + proxy: + container_name: proxy build: context: ./nginx dockerfile: Dockerfile @@ -130,8 +130,9 @@ services: FILE_SIZE_LIMIT: ${FILE_SIZE_LIMIT:-5242880} BUCKET_NAME: ${AWS_S3_BUCKET_NAME:-uploads} depends_on: - - plane-web - - plane-api + - web + - api + - space volumes: pgdata: diff --git a/nginx/nginx.conf.template b/nginx/nginx.conf.template index 36a68fa5513..4775dcbfad4 100644 --- a/nginx/nginx.conf.template +++ b/nginx/nginx.conf.template @@ -1,29 +1,36 @@ -events { } +events { +} http { sendfile on; server { - listen 80; - root /www/data/; + 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://planefrontend:3000/; + proxy_pass http://web:3000/; } location /api/ { - proxy_pass http://planebackend:8000/api/; + proxy_pass http://api:8000/api/; } location /spaces/ { - proxy_pass http://planedeploy:3000/spaces/; + rewrite ^/spaces/?$ /spaces/login break; + proxy_pass http://space:3000/spaces/; } location /${BUCKET_NAME}/ { proxy_pass http://plane-minio:9000/uploads/; } } -} \ No newline at end of file +} diff --git a/package.json b/package.json index de09c6ee97a..1f2f9641448 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,6 @@ { "repository": "https://github.com/makeplane/plane.git", + "version": "0.13.2", "license": "AGPL-3.0", "private": true, "workspaces": [ diff --git a/packages/eslint-config-custom/package.json b/packages/eslint-config-custom/package.json index 16fed7a78c7..12a7ab8c8da 100644 --- a/packages/eslint-config-custom/package.json +++ b/packages/eslint-config-custom/package.json @@ -1,6 +1,6 @@ { "name": "eslint-config-custom", - "version": "0.0.0", + "version": "0.13.2", "main": "index.js", "license": "MIT", "dependencies": { diff --git a/packages/tailwind-config-custom/package.json b/packages/tailwind-config-custom/package.json index 1bd5a0e1cf9..6edaa0ec44f 100644 --- a/packages/tailwind-config-custom/package.json +++ b/packages/tailwind-config-custom/package.json @@ -1,6 +1,6 @@ { "name": "tailwind-config-custom", - "version": "0.0.1", + "version": "0.13.2", "description": "common tailwind configuration across monorepo", "main": "index.js", "devDependencies": { diff --git a/packages/tsconfig/package.json b/packages/tsconfig/package.json index f4810fc3fa8..58bfb84516c 100644 --- a/packages/tsconfig/package.json +++ b/packages/tsconfig/package.json @@ -1,6 +1,6 @@ { "name": "tsconfig", - "version": "0.0.0", + "version": "0.13.2", "private": true, "files": [ "base.json", diff --git a/packages/ui/README.md b/packages/ui/README.md new file mode 100644 index 00000000000..ce447bf1c1a --- /dev/null +++ b/packages/ui/README.md @@ -0,0 +1 @@ +# UI Package diff --git a/packages/ui/package.json b/packages/ui/package.json index 1f95e468b3f..d107e711cf6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,33 +1,23 @@ { - "name": "@plane/ui", - "version": "0.0.1", - "main": "./dist/index.js", - "module": "./dist/index.mjs", - "types": "./dist/index.d.ts", - "sideEffects": false, + "name": "ui", + "version": "0.13.2", + "main": "./index.tsx", + "types": "./index.tsx", "license": "MIT", - "files": [ - "dist/**" - ], "scripts": { - "build": "tsup src/index.tsx --format esm,cjs --dts --external react", - "dev": "tsup src/index.tsx --format esm,cjs --watch --dts --external react", - "lint": "eslint src/", - "clean": "rm -rf .turbo && rm -rf node_modules && rm -rf dist" + "lint": "eslint *.ts*" }, "devDependencies": { - "@types/node": "^20.5.2", - "@types/react": "18.2.0", - "@types/react-dom": "18.2.0", + "@types/react": "^18.0.17", + "@types/react-dom": "^18.0.6", + "@typescript-eslint/eslint-plugin": "^5.51.0", "classnames": "^2.3.2", + "eslint": "^7.32.0", "eslint-config-custom": "*", + "next": "12.3.2", "react": "^18.2.0", "tsconfig": "*", "tailwind-config-custom": "*", - "tsup": "^5.10.1", "typescript": "4.7.4" - }, - "publishConfig": { - "access": "public" } } diff --git a/space/.env.example b/space/.env.example index 56e9f1e95af..7700ec94660 100644 --- a/space/.env.example +++ b/space/.env.example @@ -1,4 +1,2 @@ -# Google Client ID for Google OAuth -NEXT_PUBLIC_GOOGLE_CLIENTID="" # Flag to toggle OAuth NEXT_PUBLIC_ENABLE_OAUTH=0 \ No newline at end of file diff --git a/space/components/accounts/github-login-button.tsx b/space/components/accounts/github-login-button.tsx index e9b30ab73ed..b1bd586fe76 100644 --- a/space/components/accounts/github-login-button.tsx +++ b/space/components/accounts/github-login-button.tsx @@ -10,9 +10,12 @@ import githubWhiteImage from "public/logos/github-white.svg"; export interface GithubLoginButtonProps { handleSignIn: React.Dispatch; + clientId: string; } -export const GithubLoginButton: FC = ({ handleSignIn }) => { +export const GithubLoginButton: FC = (props) => { + const { handleSignIn, clientId } = props; + // states const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); const [gitCode, setGitCode] = useState(null); @@ -38,7 +41,7 @@ export const GithubLoginButton: FC = ({ handleSignIn })
- {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( - <> -

- Sign in to Plane -

-
-
- -
-
- - {/* */} -
+

Sign in to Plane

+ {data?.email_password_login && } + + {data?.magic_login && ( +
+
+
- - ) : ( - +
)} - {parseInt(process.env.NEXT_PUBLIC_ENABLE_OAUTH || "0") ? ( -

- By signing up, you agree to the{" "} - - Terms & Conditions - -

- ) : null} +
+ {data?.google && } +
+ +

+ By signing up, you agree to the{" "} + + Terms & Conditions + +

diff --git a/space/components/issues/navbar/index.tsx b/space/components/issues/navbar/index.tsx index 509d676b7bb..03f082f33a3 100644 --- a/space/components/issues/navbar/index.tsx +++ b/space/components/issues/navbar/index.tsx @@ -44,19 +44,43 @@ const IssueNavbar = observer(() => { }, [projectStore, workspace_slug, project_slug]); useEffect(() => { - if (workspace_slug && project_slug) { - if (!board) { - router.push({ - pathname: `/${workspace_slug}/${project_slug}`, - query: { - board: "list", - }, - }); - return projectStore.setActiveBoard("list"); + if (workspace_slug && project_slug && projectStore?.deploySettings) { + const viewsAcceptable: string[] = []; + let currentBoard: string | null = null; + + if (projectStore?.deploySettings?.views?.list) viewsAcceptable.push("list"); + if (projectStore?.deploySettings?.views?.kanban) viewsAcceptable.push("kanban"); + if (projectStore?.deploySettings?.views?.calendar) viewsAcceptable.push("calendar"); + if (projectStore?.deploySettings?.views?.gantt) viewsAcceptable.push("gantt"); + if (projectStore?.deploySettings?.views?.spreadsheet) viewsAcceptable.push("spreadsheet"); + + if (board) { + if (viewsAcceptable.includes(board.toString())) { + currentBoard = board.toString(); + } else { + if (viewsAcceptable && viewsAcceptable.length > 0) { + currentBoard = viewsAcceptable[0]; + } + } + } else { + if (viewsAcceptable && viewsAcceptable.length > 0) { + currentBoard = viewsAcceptable[0]; + } + } + + if (currentBoard) { + if (projectStore?.activeBoard === null || projectStore?.activeBoard !== currentBoard) { + projectStore.setActiveBoard(currentBoard); + router.push({ + pathname: `/${workspace_slug}/${project_slug}`, + query: { + board: currentBoard, + }, + }); + } } - projectStore.setActiveBoard(board.toString()); } - }, [board, workspace_slug, project_slug]); + }, [board, workspace_slug, project_slug, router, projectStore, projectStore?.deploySettings]); return (
@@ -105,7 +129,7 @@ const IssueNavbar = observer(() => {
) : (
- + Sign in diff --git a/space/components/views/home.tsx b/space/components/views/home.tsx deleted file mode 100644 index 999fce0734e..00000000000 --- a/space/components/views/home.tsx +++ /dev/null @@ -1,13 +0,0 @@ -// mobx -import { observer } from "mobx-react-lite"; -import { useMobxStore } from "lib/mobx/store-provider"; -// components -import { SignInView, UserLoggedIn } from "components/accounts"; - -export const HomeView = observer(() => { - const { user: userStore } = useMobxStore(); - - if (!userStore.currentUser) return ; - - return ; -}); diff --git a/space/components/views/index.ts b/space/components/views/index.ts index 84d36cd2911..f54d11bdd2c 100644 --- a/space/components/views/index.ts +++ b/space/components/views/index.ts @@ -1 +1 @@ -export * from "./home"; +export * from "./login"; diff --git a/space/components/views/login.tsx b/space/components/views/login.tsx new file mode 100644 index 00000000000..406d6be9877 --- /dev/null +++ b/space/components/views/login.tsx @@ -0,0 +1,19 @@ +// mobx +import { observer } from "mobx-react-lite"; +import { useMobxStore } from "lib/mobx/store-provider"; +// components +import { SignInView, UserLoggedIn } from "components/accounts"; + +export const LoginView = observer(() => { + const { user: userStore } = useMobxStore(); + + return ( + <> + {userStore?.loader ? ( +
Loading
+ ) : ( + <>{userStore.currentUser ? : } + )} + + ); +}); diff --git a/space/lib/mobx/store-init.tsx b/space/lib/mobx/store-init.tsx index 6e38d9c6d17..4fc761ad194 100644 --- a/space/lib/mobx/store-init.tsx +++ b/space/lib/mobx/store-init.tsx @@ -3,12 +3,14 @@ import { useEffect } from "react"; // next imports import { useRouter } from "next/router"; +// js cookie +import Cookie from "js-cookie"; // mobx store import { useMobxStore } from "lib/mobx/store-provider"; import { RootStore } from "store/root"; const MobxStoreInit = () => { - const store: RootStore = useMobxStore(); + const { user: userStore }: RootStore = useMobxStore(); const router = useRouter(); const { states, labels, priorities } = router.query as { states: string[]; labels: string[]; priorities: string[] }; @@ -19,6 +21,11 @@ const MobxStoreInit = () => { // store.issue.userSelectedStates = states || []; // }, [store.issue]); + useEffect(() => { + const authToken = Cookie.get("accessToken") || null; + if (authToken) userStore.fetchCurrentUser(); + }, [userStore]); + return <>; }; diff --git a/space/package.json b/space/package.json index 6ce9ecefee6..b539fbb65d4 100644 --- a/space/package.json +++ b/space/package.json @@ -1,6 +1,6 @@ { "name": "space", - "version": "0.0.1", + "version": "0.13.2", "private": true, "scripts": { "dev": "next dev -p 4000", diff --git a/space/pages/index.tsx b/space/pages/index.tsx index fe0b7d33aca..1ff239253ac 100644 --- a/space/pages/index.tsx +++ b/space/pages/index.tsx @@ -1,8 +1,19 @@ -import React from "react"; +import { useEffect } from "react"; -// components -import { HomeView } from "components/views"; +// next +import { NextPage } from "next"; +import { useRouter } from "next/router"; -const HomePage = () => ; +const Index: NextPage = () => { + const router = useRouter(); + const { next_path } = router.query as { next_path: string }; -export default HomePage; + useEffect(() => { + if (next_path) router.push(`/login?next_path=${next_path}`); + else router.push(`/login`); + }, [router, next_path]); + + return null; +}; + +export default Index; diff --git a/space/pages/login/index.tsx b/space/pages/login/index.tsx new file mode 100644 index 00000000000..9f20f099f4a --- /dev/null +++ b/space/pages/login/index.tsx @@ -0,0 +1,8 @@ +import React from "react"; + +// components +import { LoginView } from "components/views"; + +const LoginPage = () => ; + +export default LoginPage; diff --git a/space/services/app-config.service.ts b/space/services/app-config.service.ts new file mode 100644 index 00000000000..713cda3da1b --- /dev/null +++ b/space/services/app-config.service.ts @@ -0,0 +1,30 @@ +// services +import APIService from "services/api.service"; +// helper +import { API_BASE_URL } from "helpers/common.helper"; + +export interface IEnvConfig { + github: string; + google: string; + github_app_name: string | null; + email_password_login: boolean; + magic_login: boolean; +} + +export class AppConfigService extends APIService { + constructor() { + super(API_BASE_URL); + } + + async envConfig(): Promise { + return this.get("/api/configs/", { + headers: { + "Content-Type": "application/json", + }, + }) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } +} diff --git a/space/services/file.service.ts b/space/services/file.service.ts index d9783d29c8f..6df6423f4d7 100644 --- a/space/services/file.service.ts +++ b/space/services/file.service.ts @@ -74,24 +74,6 @@ class FileServices extends APIService { throw error?.response?.data; }); } - - async getUnsplashImages(page: number = 1, query?: string): Promise { - const url = "/api/unsplash"; - - return this.request({ - method: "get", - url, - params: { - page, - per_page: 20, - query, - }, - }) - .then((response) => response?.data?.results ?? response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } } const fileServices = new FileServices(); diff --git a/space/store/user.ts b/space/store/user.ts index 3a76c211177..cec2d340fdc 100644 --- a/space/store/user.ts +++ b/space/store/user.ts @@ -7,12 +7,17 @@ import { ActorDetail } from "types/issue"; import { IUser } from "types/user"; export interface IUserStore { + loader: boolean; + error: any | null; currentUser: any | null; fetchCurrentUser: () => void; currentActor: () => any; } class UserStore implements IUserStore { + loader: boolean = false; + error: any | null = null; + currentUser: IUser | null = null; // root store rootStore; @@ -22,6 +27,9 @@ class UserStore implements IUserStore { constructor(_rootStore: any) { makeObservable(this, { // observable + loader: observable.ref, + error: observable.ref, + currentUser: observable.ref, // actions setCurrentUser: action, @@ -73,14 +81,19 @@ class UserStore implements IUserStore { fetchCurrentUser = async () => { try { + this.loader = true; + this.error = null; const response = await this.userService.currentUser(); if (response) { runInAction(() => { + this.loader = false; this.currentUser = response; }); } } catch (error) { console.error("Failed to fetch current user", error); + this.loader = false; + this.error = error; } }; } diff --git a/turbo.json b/turbo.json index 59bbe741f85..e40a56ab7be 100644 --- a/turbo.json +++ b/turbo.json @@ -1,8 +1,6 @@ { "$schema": "https://turbo.build/schema.json", "globalEnv": [ - "NEXT_PUBLIC_GITHUB_ID", - "NEXT_PUBLIC_GOOGLE_CLIENTID", "NEXT_PUBLIC_API_BASE_URL", "NEXT_PUBLIC_DEPLOY_URL", "API_BASE_URL", @@ -12,8 +10,6 @@ "NEXT_PUBLIC_GITHUB_APP_NAME", "NEXT_PUBLIC_ENABLE_SENTRY", "NEXT_PUBLIC_ENABLE_OAUTH", - "NEXT_PUBLIC_UNSPLASH_ACCESS", - "NEXT_PUBLIC_UNSPLASH_ENABLED", "NEXT_PUBLIC_TRACK_EVENTS", "NEXT_PUBLIC_PLAUSIBLE_DOMAIN", "NEXT_PUBLIC_CRISP_ID", diff --git a/web/.env.example b/web/.env.example index 88a2064c53e..3868cd83486 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,24 +1,4 @@ -# Extra image domains that need to be added for Next Image -NEXT_PUBLIC_EXTRA_IMAGE_DOMAINS= -# Google Client ID for Google OAuth -NEXT_PUBLIC_GOOGLE_CLIENTID="" -# GitHub App ID for GitHub OAuth -NEXT_PUBLIC_GITHUB_ID="" -# GitHub App Name for GitHub Integration -NEXT_PUBLIC_GITHUB_APP_NAME="" -# Sentry DSN for error monitoring -NEXT_PUBLIC_SENTRY_DSN="" # Enable/Disable OAUTH - default 0 for selfhosted instance NEXT_PUBLIC_ENABLE_OAUTH=0 -# Enable/Disable Sentry -NEXT_PUBLIC_ENABLE_SENTRY=0 -# Enable/Disable session recording -NEXT_PUBLIC_ENABLE_SESSION_RECORDER=0 -# Enable/Disable event tracking -NEXT_PUBLIC_TRACK_EVENTS=0 -# Slack Client ID for Slack Integration -NEXT_PUBLIC_SLACK_CLIENT_ID="" -# For Telemetry, set it to "app.plane.so" -NEXT_PUBLIC_PLAUSIBLE_DOMAIN="" # Public boards deploy URL -NEXT_PUBLIC_DEPLOY_URL="http://localhost:3000/spaces" \ No newline at end of file +NEXT_PUBLIC_DEPLOY_URL="http://localhost/spaces" \ No newline at end of file diff --git a/web/components/account/email-password-form.tsx b/web/components/account/email-password-form.tsx index bb341b37140..7a95095ee0b 100644 --- a/web/components/account/email-password-form.tsx +++ b/web/components/account/email-password-form.tsx @@ -1,12 +1,5 @@ -import React, { useState } from "react"; - -import { useRouter } from "next/router"; -import Link from "next/link"; - -// react hook form +import React from "react"; import { useForm } from "react-hook-form"; -// components -import { EmailResetPasswordForm } from "components/account"; // ui import { Input, PrimaryButton } from "components/ui"; // types @@ -18,14 +11,12 @@ type EmailPasswordFormValues = { type Props = { onSubmit: (formData: EmailPasswordFormValues) => Promise; + setIsResettingPassword: (value: boolean) => void; }; -export const EmailPasswordForm: React.FC = ({ onSubmit }) => { - const [isResettingPassword, setIsResettingPassword] = useState(false); - - const router = useRouter(); - const isSignUpPage = router.pathname === "/sign-up"; - +export const EmailPasswordForm: React.FC = (props) => { + const { onSubmit, setIsResettingPassword } = props; + // form info const { register, handleSubmit, @@ -42,94 +33,62 @@ export const EmailPasswordForm: React.FC = ({ onSubmit }) => { return ( <> -

- {isResettingPassword - ? "Reset your password" - : isSignUpPage - ? "Sign up on Plane" - : "Sign in to Plane"} -

- {isResettingPassword ? ( - - ) : ( -
-
- - /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( - value - ) || "Email address is not valid", - }} - error={errors.email} - placeholder="Enter your email address..." - className="border-custom-border-300 h-[46px]" - /> -
-
- -
-
- {isSignUpPage ? ( - - - Already have an account? Sign in. - - - ) : ( - - )} -
-
- - {isSignUpPage - ? isSubmitting - ? "Signing up..." - : "Sign up" - : isSubmitting - ? "Signing in..." - : "Sign in"} - - {!isSignUpPage && ( - - - Don{"'"}t have an account? Sign up. - - - )} -
-
- )} +
+
+ + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + value + ) || "Email address is not valid", + }} + error={errors.email} + placeholder="Enter your email address..." + className="border-custom-border-300 h-[46px]" + /> +
+
+ +
+
+ +
+
+ + {isSubmitting ? "Signing in..." : "Sign in"} + +
+
); }; diff --git a/web/components/account/email-signup-form.tsx b/web/components/account/email-signup-form.tsx new file mode 100644 index 00000000000..0a219741fb1 --- /dev/null +++ b/web/components/account/email-signup-form.tsx @@ -0,0 +1,114 @@ +import React from "react"; +import Link from "next/link"; +import { useForm } from "react-hook-form"; +// ui +import { Input, PrimaryButton } from "components/ui"; +// types +type EmailPasswordFormValues = { + email: string; + password?: string; + confirm_password: string; + medium?: string; +}; + +type Props = { + onSubmit: (formData: EmailPasswordFormValues) => Promise; +}; + +export const EmailSignUpForm: React.FC = (props) => { + const { onSubmit } = props; + + const { + register, + handleSubmit, + watch, + formState: { errors, isSubmitting, isValid, isDirty }, + } = useForm({ + defaultValues: { + email: "", + password: "", + confirm_password: "", + medium: "email", + }, + mode: "onChange", + reValidateMode: "onChange", + }); + + return ( + <> +
+
+ + /^(([^<>()[\]\\.,;:\s@\"]+(\.[^<>()[\]\\.,;:\s@\"]+)*)|(\".+\"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/.test( + value + ) || "Email address is not valid", + }} + error={errors.email} + placeholder="Enter your email address..." + className="border-custom-border-300 h-[46px]" + /> +
+
+ +
+
+ { + if (watch("password") != val) { + return "Your passwords do no match"; + } + }, + }} + error={errors.confirm_password} + placeholder="Confirm your password..." + className="border-custom-border-300 h-[46px]" + /> +
+ +
+ + {isSubmitting ? "Signing up..." : "Sign up"} + +
+
+ + ); +}; diff --git a/web/components/account/github-login-button.tsx b/web/components/account/github-login-button.tsx index 2f4fcbc4d92..9ea5b7df2a4 100644 --- a/web/components/account/github-login-button.tsx +++ b/web/components/account/github-login-button.tsx @@ -1,29 +1,27 @@ import { useEffect, useState, FC } from "react"; - import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/router"; - -// next-themes import { useTheme } from "next-themes"; // images import githubBlackImage from "/public/logos/github-black.png"; import githubWhiteImage from "/public/logos/github-white.png"; -const { NEXT_PUBLIC_GITHUB_ID } = process.env; - export interface GithubLoginButtonProps { handleSignIn: React.Dispatch; + clientId: string; } -export const GithubLoginButton: FC = ({ handleSignIn }) => { +export const GithubLoginButton: FC = (props) => { + const { handleSignIn, clientId } = props; + // states const [loginCallBackURL, setLoginCallBackURL] = useState(undefined); const [gitCode, setGitCode] = useState(null); - + // router const { query: { code }, } = useRouter(); - + // theme const { theme } = useTheme(); useEffect(() => { @@ -42,7 +40,7 @@ export const GithubLoginButton: FC = ({ handleSignIn }) return (
+ )} + {!isArchivedIssues && ( +
+

Issue type

+
+ option.key === displayFilters.type + )?.name ?? "Select" + } + className="!w-full" + buttonClassName="w-full" + > + {FILTER_ISSUE_OPTIONS.map((option) => ( + + setDisplayFilters({ + type: option.key, + }) + } + > + {option.name} + + ))} + +
)} -
-

Issue type

-
- option.key === displayFilters.type)?.title ?? "Select" - } - className="!w-full" - buttonClassName="w-full" - > - {ISSUE_FILTER_OPTIONS.map((option) => ( - { - // setDisplayFilters({ - // type: option.key, - // }) - }} - > - {option.title} - - ))} - -
-
{displayFilters.layout !== "calendar" && displayFilters.layout !== "spreadsheet" && (
@@ -293,7 +303,7 @@ export const IssuesFilterView: React.FC = () => { displayFilters.layout !== "spreadsheet" && displayFilters.layout !== "gantt_chart" && (
-

Show empty states

+

Show empty groups

; + setFilters: (updatedFilter: Partial) => void; + clearAllFilters: (...args: any) => void; + labels: IIssueLabels[] | undefined; + members: IUserLite[] | undefined; + stateGroup: string[] | undefined; + project?: IProject[] | undefined; +}; + +export const WorkspaceFiltersList: React.FC = ({ + filters, + setFilters, + clearAllFilters, + labels, + members, + stateGroup, + project, +}) => { + if (!filters) return <>; + + const nullFilters = Object.keys(filters).filter( + (key) => filters[key as keyof IWorkspaceIssueFilterOptions] === null + ); + + return ( +
+ {Object.keys(filters).map((filterKey) => { + const key = filterKey as keyof typeof filters; + + if (filters[key] === null || (filters[key]?.length ?? 0) <= 0) return null; + + return ( +
+ + {key === "target_date" ? "Due Date" : replaceUnderscoreIfSnakeCase(key)}: + + {filters[key] === null || (filters[key]?.length ?? 0) <= 0 ? ( + None + ) : Array.isArray(filters[key]) ? ( +
+
+ {key === "state_group" + ? filters.state_group?.map((stateGroup) => { + const group = stateGroup as TStateGroups; + + return ( +

+ + + + {group} + + setFilters({ + state_group: filters.state_group?.filter((g) => g !== group), + }) + } + > + + +

+ ); + }) + : key === "priority" + ? filters.priority?.map((priority: any) => ( +

+ + + + {priority === "null" ? "None" : priority} + + setFilters({ + priority: filters.priority?.filter((p: any) => p !== priority), + }) + } + > + + +

+ )) + : key === "assignees" + ? filters.assignees?.map((memberId: string) => { + const member = members?.find((m) => m.id === memberId); + return ( +
+ + {member?.display_name} + + setFilters({ + assignees: filters.assignees?.filter((p: any) => p !== memberId), + }) + } + > + + +
+ ); + }) + : key === "subscriber" + ? filters.subscriber?.map((memberId: string) => { + const member = members?.find((m) => m.id === memberId); + + return ( +
+ + {member?.display_name} + + setFilters({ + assignees: filters.assignees?.filter((p: any) => p !== memberId), + }) + } + > + + +
+ ); + }) + : key === "created_by" + ? filters.created_by?.map((memberId: string) => { + const member = members?.find((m) => m.id === memberId); + + return ( +
+ + {member?.display_name} + + setFilters({ + created_by: filters.created_by?.filter( + (p: any) => p !== memberId + ), + }) + } + > + + +
+ ); + }) + : key === "labels" + ? filters.labels?.map((labelId: string) => { + const label = labels?.find((l) => l.id === labelId); + + if (!label) return null; + const color = label.color !== "" ? label.color : "#0f172a"; + return ( +
+
+ {label.name} + + setFilters({ + labels: filters.labels?.filter((l: any) => l !== labelId), + }) + } + > + + +
+ ); + }) + : key === "start_date" + ? filters.start_date?.map((date: string) => { + if (filters.start_date && filters.start_date.length <= 0) return null; + + const splitDate = date.split(";"); + + return ( +
+
+ + {splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])} + + + setFilters({ + start_date: filters.start_date?.filter((d: any) => d !== date), + }) + } + > + + +
+ ); + }) + : key === "target_date" + ? filters.target_date?.map((date: string) => { + if (filters.target_date && filters.target_date.length <= 0) return null; + + const splitDate = date.split(";"); + + return ( +
+
+ + {splitDate[1]} {renderShortDateWithYearFormat(splitDate[0])} + + + setFilters({ + target_date: filters.target_date?.filter((d: any) => d !== date), + }) + } + > + + +
+ ); + }) + : key === "project" + ? filters.project?.map((projectId) => { + const currentProject = project?.find((p) => p.id === projectId); + return ( +

+ {currentProject?.name} + + setFilters({ + project: filters.project?.filter((p) => p !== projectId), + }) + } + > + + +

+ ); + }) + : (filters[key] as any)?.join(", ")} + +
+
+ ) : ( +
+ {filters[key as keyof typeof filters]} + +
+ )} +
+ ); + })} + {Object.keys(filters).length > 0 && nullFilters.length !== Object.keys(filters).length && ( + + )} +
+ ); +}; diff --git a/web/components/core/image-picker-popover.tsx b/web/components/core/image-picker-popover.tsx index 957f1131cbe..cfe18cd97d9 100644 --- a/web/components/core/image-picker-popover.tsx +++ b/web/components/core/image-picker-popover.tsx @@ -1,32 +1,23 @@ import React, { useEffect, useState, useRef, useCallback } from "react"; - -// next import Image from "next/image"; import { useRouter } from "next/router"; - -// swr import useSWR from "swr"; - -// react-dropdown import { useDropzone } from "react-dropzone"; - -// headless ui import { Tab, Transition, Popover } from "@headlessui/react"; // services import fileService from "services/file.service"; - -// components -import { Input, Spinner, PrimaryButton, SecondaryButton } from "components/ui"; // hooks import useWorkspaceDetails from "hooks/use-workspace-details"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; - -const unsplashEnabled = - process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "true" || - process.env.NEXT_PUBLIC_UNSPLASH_ENABLED === "1"; +// components +import { Input, PrimaryButton, SecondaryButton, Loader } from "components/ui"; const tabOptions = [ + { + key: "unsplash", + title: "Unsplash", + }, { key: "images", title: "Images", @@ -64,8 +55,22 @@ export const ImagePickerPopover: React.FC = ({ search: "", }); - const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchParams}`, () => - fileService.getUnsplashImages(1, searchParams) + const { data: unsplashImages, error: unsplashError } = useSWR( + `UNSPLASH_IMAGES_${searchParams}`, + () => fileService.getUnsplashImages(searchParams), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } + ); + + const { data: projectCoverImages } = useSWR( + `PROJECT_COVER_IMAGES`, + () => fileService.getProjectCoverImages(), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + } ); const imagePickerRef = useRef(null); @@ -115,18 +120,17 @@ export const ImagePickerPopover: React.FC = ({ }; useEffect(() => { - if (!images || value !== null) return; - onChange(images[0].urls.regular); - }, [value, onChange, images]); + if (!unsplashImages || value !== null) return; - useOutsideClickDetector(imagePickerRef, () => setIsOpen(false)); + onChange(unsplashImages[0].urls.regular); + }, [value, onChange, unsplashImages]); - if (!unsplashEnabled) return null; + useOutsideClickDetector(imagePickerRef, () => setIsOpen(false)); return ( setIsOpen((prev) => !prev)} disabled={disabled} > @@ -141,15 +145,19 @@ export const ImagePickerPopover: React.FC = ({ leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - +
-
- - {tabOptions.map((tab) => ( + + {tabOptions.map((tab) => { + if (!unsplashImages && unsplashError && tab.key === "unsplash") return null; + if (projectCoverImages && projectCoverImages.length === 0 && tab.key === "images") + return null; + + return ( @@ -160,50 +168,106 @@ export const ImagePickerPopover: React.FC = ({ > {tab.title} - ))} - -
+ ); + })} + - -
- setFormData({ ...formData, search: e.target.value })} - placeholder="Search for images" - /> - setSearchParams(formData.search)} size="sm"> - Search - -
- {images ? ( -
- {images.map((image) => ( -
- {image.alt_description} { - setIsOpen(false); - onChange(image.urls.regular); - }} - /> -
- ))} -
- ) : ( -
- + {(unsplashImages || !unsplashError) && ( + +
+ setFormData({ ...formData, search: e.target.value })} + placeholder="Search for images" + /> + setSearchParams(formData.search)} size="sm"> + Search +
- )} -
- + {unsplashImages ? ( + unsplashImages.length > 0 ? ( +
+ {unsplashImages.map((image) => ( +
{ + setIsOpen(false); + onChange(image.urls.regular); + }} + > + {image.alt_description} +
+ ))} +
+ ) : ( +

+ No images found. +

+ ) + ) : ( + + + + + + + + + + + )} +
+ )} + {(!projectCoverImages || projectCoverImages.length !== 0) && ( + + {projectCoverImages ? ( + projectCoverImages.length > 0 ? ( +
+ {projectCoverImages.map((image, index) => ( +
{ + setIsOpen(false); + onChange(image); + }} + > + {`Default +
+ ))} +
+ ) : ( +

+ No images found. +

+ ) + ) : ( + + + + + + + + + + + )} +
+ )} +
= ({ : null ); + const { data: workspaceLabels } = useSWR( + workspaceSlug && displayFilters?.group_by === "labels" ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, + workspaceSlug && displayFilters?.group_by === "labels" + ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) + : null + ); + const { data: members } = useSWR( workspaceSlug && projectId && @@ -82,7 +89,9 @@ export const BoardHeader: React.FC = ({ title = addSpaceIfCamelCase(currentState?.name ?? ""); break; case "labels": - title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None"; + title = + [...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find((label) => label.id === groupTitle)?.name ?? + "None"; break; case "project": title = projects?.find((p) => p.id === groupTitle)?.name ?? "None"; @@ -131,7 +140,9 @@ export const BoardHeader: React.FC = ({ : null); break; case "labels": - const labelColor = issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000"; + const labelColor = + [...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find((label) => label.id === groupTitle)?.color ?? + "#000000"; icon = ; break; case "assignees": diff --git a/web/components/core/views/board-view/index.ts b/web/components/core/views/board-view/index.ts index 6e5cdf8bf67..a5a6ee497f1 100644 --- a/web/components/core/views/board-view/index.ts +++ b/web/components/core/views/board-view/index.ts @@ -2,3 +2,4 @@ export * from "./all-boards"; export * from "./board-header"; export * from "./single-board"; export * from "./single-issue"; +export * from "./inline-create-issue-form"; diff --git a/web/components/core/views/board-view/inline-create-issue-form.tsx b/web/components/core/views/board-view/inline-create-issue-form.tsx new file mode 100644 index 00000000000..1d6103d19df --- /dev/null +++ b/web/components/core/views/board-view/inline-create-issue-form.tsx @@ -0,0 +1,62 @@ +import { useEffect } from "react"; + +// react hook form +import { useFormContext } from "react-hook-form"; + +// components +import { InlineCreateIssueFormWrapper } from "components/core"; + +// hooks +import useProjectDetails from "hooks/use-project-details"; + +// types +import { IIssue } from "types"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + onSuccess?: (data: IIssue) => Promise | void; + prePopulatedData?: Partial; +}; + +const InlineInput = () => { + const { projectDetails } = useProjectDetails(); + + const { register, setFocus } = useFormContext(); + + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + return ( +
+

+ {projectDetails?.identifier ?? "..."} +

+ +
+ ); +}; + +export const BoardInlineCreateIssueForm: React.FC = (props) => ( + <> + + + + {props.isOpen && ( +

+ Press {"'"}Enter{"'"} to add another issue +

+ )} + +); diff --git a/web/components/core/views/board-view/single-board.tsx b/web/components/core/views/board-view/single-board.tsx index 1981e1f7cb8..6d583e772b3 100644 --- a/web/components/core/views/board-view/single-board.tsx +++ b/web/components/core/views/board-view/single-board.tsx @@ -6,7 +6,8 @@ import { useRouter } from "next/router"; import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import { Draggable } from "react-beautiful-dnd"; // components -import { BoardHeader, SingleBoardIssue } from "components/core"; +import { CreateUpdateDraftIssueModal } from "components/issues"; +import { BoardHeader, SingleBoardIssue, BoardInlineCreateIssueForm } from "components/core"; // ui import { CustomMenu } from "components/ui"; // icons @@ -34,31 +35,40 @@ type Props = { viewProps: IIssueViewProps; }; -export const SingleBoard: React.FC = ({ - addIssueToGroup, - currentState, - groupTitle, - disableUserActions, - disableAddIssueOption = false, - dragDisabled, - handleIssueAction, - handleDraftIssueAction, - handleTrashBox, - openIssuesListModal, - handleMyIssueOpen, - removeIssue, - user, - userAuth, - viewProps, -}) => { +export const SingleBoard: React.FC = (props) => { + const { + addIssueToGroup, + currentState, + groupTitle, + disableUserActions, + disableAddIssueOption = false, + dragDisabled, + handleIssueAction, + handleDraftIssueAction, + handleTrashBox, + openIssuesListModal, + handleMyIssueOpen, + removeIssue, + user, + userAuth, + viewProps, + } = props; + // collapse/expand const [isCollapsed, setIsCollapsed] = useState(true); + const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false); + const [isCreateDraftIssueModalOpen, setIsCreateDraftIssueModalOpen] = useState(false); + const { displayFilters, groupedIssues } = viewProps; const router = useRouter(); const { cycleId, moduleId } = router.query; + const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues"; + const isProfileIssuesPage = router.pathname.split("/")[2] === "profile"; + const isDraftIssuesPage = router.pathname.split("/")[4] === "draft-issues"; + const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; // Check if it has at least 4 tickets since it is enough to accommodate the Calendar height @@ -67,10 +77,48 @@ export const SingleBoard: React.FC = ({ const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; + const scrollToBottom = () => { + const boardListElement = document.getElementById(`board-list-${groupTitle}`); + + // timeout is needed because the animation + // takes time to complete & we can scroll only after that + const timeoutId = setTimeout(() => { + if (boardListElement) + boardListElement.scrollBy({ + top: boardListElement.scrollHeight, + left: 0, + behavior: "smooth", + }); + clearTimeout(timeoutId); + }, 10); + }; + + const onCreateClick = () => { + setIsInlineCreateIssueFormOpen(true); + scrollToBottom(); + }; + + const handleAddIssueToGroup = () => { + if (isDraftIssuesPage) setIsCreateDraftIssueModalOpen(true); + else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup(); + else onCreateClick(); + }; + return (
+ setIsCreateDraftIssueModalOpen(false)} + prePopulateData={{ + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + [displayFilters?.group_by! === "labels" ? "labels_list" : displayFilters?.group_by!]: + displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle, + }} + /> + = ({ )}
= ({ type={type} index={index} issue={issue} + projectId={issue.project_detail.id} groupTitle={groupTitle} editIssue={() => handleIssueAction(issue, "edit")} makeIssueCopy={() => handleIssueAction(issue, "copy")} @@ -169,21 +219,40 @@ export const SingleBoard: React.FC = ({ > <>{provided.placeholder} + + setIsInlineCreateIssueFormOpen(false)} + onSuccess={() => scrollToBottom()} + prePopulatedData={{ + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + [displayFilters?.group_by! === "labels" + ? "labels_list" + : displayFilters?.group_by!]: + displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle, + }} + />
{displayFilters?.group_by !== "created_by" && (
{type === "issue" - ? !disableAddIssueOption && ( + ? !disableAddIssueOption && + !isDraftIssuesPage && ( ) - : !disableUserActions && ( + : !disableUserActions && + !isDraftIssuesPage && ( = ({ position="left" noBorder > - + { + if (isDraftIssuesPage) setIsCreateDraftIssueModalOpen(true); + else if (isMyIssuesPage || isProfileIssuesPage) addIssueToGroup(); + else onCreateClick(); + }} + > Create new {openIssuesListModal && ( diff --git a/web/components/core/views/board-view/single-issue.tsx b/web/components/core/views/board-view/single-issue.tsx index 29cfe2c3598..7228b75e214 100644 --- a/web/components/core/views/board-view/single-issue.tsx +++ b/web/components/core/views/board-view/single-issue.tsx @@ -51,6 +51,7 @@ type Props = { provided: DraggableProvided; snapshot: DraggableStateSnapshot; issue: IIssue; + projectId: string; groupTitle?: string; index: number; editIssue: () => void; @@ -72,6 +73,7 @@ export const SingleBoardIssue: React.FC = ({ provided, snapshot, issue, + projectId, index, editIssue, makeIssueCopy, @@ -99,7 +101,7 @@ export const SingleBoardIssue: React.FC = ({ const { displayFilters, properties, mutateIssues } = viewProps; const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const { workspaceSlug, cycleId, moduleId } = router.query; const isDraftIssue = router.pathname.includes("draft-issues"); @@ -431,6 +433,7 @@ export const SingleBoardIssue: React.FC = ({ @@ -458,6 +461,7 @@ export const SingleBoardIssue: React.FC = ({ {properties.labels && issue.labels.length > 0 && ( = ({ {properties.assignee && ( >; currentDate: Date; setCurrentDate: React.Dispatch>; showWeekEnds: boolean; setShowWeekEnds: React.Dispatch>; - changeDateRange: (startDate: Date, endDate: Date) => void; }; export const CalendarHeader: React.FC = ({ - setIsMonthlyView, - isMonthlyView, currentDate, setCurrentDate, showWeekEnds, setShowWeekEnds, - changeDateRange, -}) => { - const updateDate = (date: Date) => { - setCurrentDate(date); - - changeDateRange(startOfWeek(date), lastDayOfWeek(date)); - }; +}) => ( +
+
+ + {({ open }) => ( + <> + +
+ {formatDate(currentDate, "Month")}{" "} + {formatDate(currentDate, "yyyy")} +
+
- return ( -
-
- - {({ open }) => ( - <> - -
- {formatDate(currentDate, "Month")}{" "} - {formatDate(currentDate, "yyyy")} + + +
+ {YEARS_LIST.map((year) => ( + + ))} +
+
+ {MONTHS_LIST.map((month) => ( + + ))}
- +
+
+ + )} + - - -
- {YEARS_LIST.map((year) => ( - - ))} -
-
- {MONTHS_LIST.map((month) => ( - - ))} -
-
-
- - )} - +
+ - -
-
+ const previousMonthFirstDate = new Date(previousMonthYear, previousMonthMonth, 1); -
+ setCurrentDate(previousMonthFirstDate); + }} + > + + +
+
- - {isMonthlyView ? "Monthly" : "Weekly"} -
- } - > - { - setIsMonthlyView(true); - changeDateRange(startOfWeek(currentDate), lastDayOfWeek(currentDate)); - }} - className="w-52 text-sm text-custom-text-200" - > -
- Monthly View - -
-
- { - setIsMonthlyView(false); - changeDateRange( - getCurrentWeekStartDate(currentDate), - getCurrentWeekEndDate(currentDate) - ); - }} - className="w-52 text-sm text-custom-text-200" - > -
- Weekly View - -
-
-
-

Show weekends

- setShowWeekEnds(!showWeekEnds)} /> +
+ + + + Options +
- -
+ } + > +
+

Show weekends

+ setShowWeekEnds(!showWeekEnds)} /> +
+
- ); -}; +
+); export default CalendarHeader; diff --git a/web/components/core/views/calendar-view/calendar.tsx b/web/components/core/views/calendar-view/calendar.tsx index 5173493783f..553c7b723f0 100644 --- a/web/components/core/views/calendar-view/calendar.tsx +++ b/web/components/core/views/calendar-view/calendar.tsx @@ -1,10 +1,6 @@ import React, { useEffect, useState } from "react"; - import { useRouter } from "next/router"; - import { mutate } from "swr"; - -// react-beautiful-dnd import { DragDropContext, DropResult } from "react-beautiful-dnd"; // services import issuesService from "services/issue.service"; @@ -42,30 +38,26 @@ export const CalendarView: React.FC = ({ userAuth, }) => { const [showWeekEnds, setShowWeekEnds] = useState(false); - const [currentDate, setCurrentDate] = useState(new Date()); - const [isMonthlyView, setIsMonthlyView] = useState(true); + + const { calendarIssues, mutateIssues, params, activeMonthDate, setActiveMonthDate } = useCalendarIssuesView(); const [calendarDates, setCalendarDates] = useState({ - startDate: startOfWeek(currentDate), - endDate: lastDayOfWeek(currentDate), + startDate: startOfWeek(activeMonthDate), + endDate: lastDayOfWeek(activeMonthDate), }); const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; - const { calendarIssues, mutateIssues, params, displayFilters, setDisplayFilters } = useCalendarIssuesView(); - - const totalDate = eachDayOfInterval({ - start: calendarDates.startDate, - end: calendarDates.endDate, - }); - - const onlyWeekDays = weekDayInterval({ - start: calendarDates.startDate, - end: calendarDates.endDate, - }); - - const currentViewDays = showWeekEnds ? totalDate : onlyWeekDays; + const currentViewDays = showWeekEnds + ? eachDayOfInterval({ + start: calendarDates.startDate, + end: calendarDates.endDate, + }) + : weekDayInterval({ + start: calendarDates.startDate, + end: calendarDates.endDate, + }); const currentViewDaysData = currentViewDays.map((date: Date) => { const filterIssue = @@ -138,25 +130,12 @@ export const CalendarView: React.FC = ({ .then(() => mutate(fetchKey)); }; - const changeDateRange = (startDate: Date, endDate: Date) => { + useEffect(() => { setCalendarDates({ - startDate, - endDate, - }); - - setDisplayFilters({ - calendar_date_range: `${renderDateFormat(startDate)};after,${renderDateFormat(endDate)};before`, + startDate: startOfWeek(activeMonthDate), + endDate: lastDayOfWeek(activeMonthDate), }); - }; - - useEffect(() => { - if (!displayFilters || displayFilters.calendar_date_range === "") - setDisplayFilters({ - calendar_date_range: `${renderDateFormat(startOfWeek(currentDate))};after,${renderDateFormat( - lastDayOfWeek(currentDate) - )};before`, - }); - }, [currentDate, displayFilters, setDisplayFilters]); + }, [activeMonthDate]); const isNotAllowed = userAuth.isGuest || userAuth.isViewer || disableUserActions; @@ -171,15 +150,15 @@ export const CalendarView: React.FC = ({ {calendarIssues ? (
-
+
= ({ {weeks.map((date, index) => (
- {isMonthlyView ? formatDate(date, "eee").substring(0, 3) : formatDate(date, "eee")} - {!isMonthlyView && {formatDate(date, "d")}} + {formatDate(date, "eee").substring(0, 3)}
))}
-
+
{currentViewDaysData.map((date, index) => ( = ({ date={date} handleIssueAction={handleIssueAction} addIssueToDate={addIssueToDate} - isMonthlyView={isMonthlyView} showWeekEnds={showWeekEnds} user={user} isNotAllowed={isNotAllowed} diff --git a/web/components/core/views/calendar-view/index.ts b/web/components/core/views/calendar-view/index.ts index 625ff1fb4b1..75d8a3a1eff 100644 --- a/web/components/core/views/calendar-view/index.ts +++ b/web/components/core/views/calendar-view/index.ts @@ -2,3 +2,4 @@ export * from "./calendar-header"; export * from "./calendar"; export * from "./single-date"; export * from "./single-issue"; +export * from "./inline-create-issue-form"; diff --git a/web/components/core/views/calendar-view/inline-create-issue-form.tsx b/web/components/core/views/calendar-view/inline-create-issue-form.tsx new file mode 100644 index 00000000000..51b6c518b42 --- /dev/null +++ b/web/components/core/views/calendar-view/inline-create-issue-form.tsx @@ -0,0 +1,102 @@ +import { useEffect, useRef, useState } from "react"; + +// next +import { useRouter } from "next/router"; + +// react hook form +import { useFormContext } from "react-hook-form"; + +import { InlineCreateIssueFormWrapper } from "components/core"; + +// hooks +import useProjectDetails from "hooks/use-project-details"; + +// types +import { IIssue } from "types"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + onSuccess?: (data: IIssue) => Promise | void; + prePopulatedData?: Partial; + dependencies: any[]; +}; + +const useCheckIfThereIsSpaceOnRight = (ref: React.RefObject, deps: any[]) => { + const [isThereSpaceOnRight, setIsThereSpaceOnRight] = useState(true); + + const router = useRouter(); + const { moduleId, cycleId, viewId } = router.query; + + const container = document.getElementById(`calendar-view-${cycleId ?? moduleId ?? viewId}`); + + useEffect(() => { + if (!ref.current) return; + + const { right } = ref.current.getBoundingClientRect(); + + const width = right; + + const innerWidth = container?.getBoundingClientRect().width ?? window.innerWidth; + + if (width > innerWidth) setIsThereSpaceOnRight(false); + else setIsThereSpaceOnRight(true); + }, [ref, deps, container]); + + return isThereSpaceOnRight; +}; + +const InlineInput = () => { + const { projectDetails } = useProjectDetails(); + + const { register, setFocus } = useFormContext(); + + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + return ( + <> +

+ {projectDetails?.identifier ?? "..."} +

+ + + ); +}; + +export const CalendarInlineCreateIssueForm: React.FC = (props) => { + const { isOpen, dependencies } = props; + + const ref = useRef(null); + + const isSpaceOnRight = useCheckIfThereIsSpaceOnRight(ref, dependencies); + + return ( + <> +
+ + + +
+ {/* Added to make any other element as outside click. This will make input also to be outside. */} + {isOpen &&
} + + ); +}; diff --git a/web/components/core/views/calendar-view/single-date.tsx b/web/components/core/views/calendar-view/single-date.tsx index ae1c018aa12..a67ca762b11 100644 --- a/web/components/core/views/calendar-view/single-date.tsx +++ b/web/components/core/views/calendar-view/single-date.tsx @@ -1,10 +1,14 @@ import React, { useState } from "react"; +// next +import { useRouter } from "next/router"; + // react-beautiful-dnd import { Draggable } from "react-beautiful-dnd"; // component import StrictModeDroppable from "components/dnd/StrictModeDroppable"; import { SingleCalendarIssue } from "./single-issue"; +import { CalendarInlineCreateIssueForm } from "./inline-create-issue-form"; // icons import { PlusSmallIcon } from "@heroicons/react/24/outline"; // helper @@ -20,23 +24,21 @@ type Props = { issues: IIssue[]; }; addIssueToDate: (date: string) => void; - isMonthlyView: boolean; showWeekEnds: boolean; user: ICurrentUserResponse | undefined; isNotAllowed: boolean; }; -export const SingleCalendarDate: React.FC = ({ - handleIssueAction, - date, - index, - addIssueToDate, - isMonthlyView, - showWeekEnds, - user, - isNotAllowed, -}) => { +export const SingleCalendarDate: React.FC = (props) => { + const { handleIssueAction, date, index, showWeekEnds, user, isNotAllowed } = props; + + const router = useRouter(); + const { cycleId, moduleId } = router.query; + const [showAllIssues, setShowAllIssues] = useState(false); + const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false); + + const [formPosition, setFormPosition] = useState({ x: 0, y: 0 }); const totalIssues = date.issues.length; @@ -48,8 +50,6 @@ export const SingleCalendarDate: React.FC = ({ ref={provided.innerRef} {...provided.droppableProps} className={`group relative flex min-h-[150px] flex-col gap-1.5 border-t border-custom-border-200 p-2.5 text-left text-sm font-medium hover:bg-custom-background-90 ${ - isMonthlyView ? "" : "pt-9" - } ${ showWeekEnds ? (index + 1) % 7 === 0 ? "" @@ -59,48 +59,66 @@ export const SingleCalendarDate: React.FC = ({ : "border-r" }`} > - {isMonthlyView && {formatDate(new Date(date.date), "d")}} - {totalIssues > 0 && - date.issues.slice(0, showAllIssues ? totalIssues : 4).map((issue: IIssue, index) => ( - - {(provided, snapshot) => ( - handleIssueAction(issue, "edit")} - handleDeleteIssue={() => handleIssueAction(issue, "delete")} - user={user} - isNotAllowed={isNotAllowed} - /> - )} - - ))} - {totalIssues > 4 && ( - - )} + <> + {formatDate(new Date(date.date), "d")} + {totalIssues > 0 && + date.issues.slice(0, showAllIssues ? totalIssues : 4).map((issue: IIssue, index) => ( + + {(provided, snapshot) => ( + handleIssueAction(issue, "edit")} + handleDeleteIssue={() => handleIssueAction(issue, "delete")} + user={user} + isNotAllowed={isNotAllowed} + /> + )} + + ))} + + setIsCreateIssueFormOpen(false)} + prePopulatedData={{ + target_date: date.date, + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + }} + /> + + {totalIssues > 4 && ( + + )} -
- -
+ +
- {provided.placeholder} + {provided.placeholder} +
)} diff --git a/web/components/core/views/calendar-view/single-issue.tsx b/web/components/core/views/calendar-view/single-issue.tsx index e37ad2c8556..d39c4e86e77 100644 --- a/web/components/core/views/calendar-view/single-issue.tsx +++ b/web/components/core/views/calendar-view/single-issue.tsx @@ -40,6 +40,7 @@ type Props = { provided: DraggableProvided; snapshot: DraggableStateSnapshot; issue: IIssue; + projectId: string; user: ICurrentUserResponse | undefined; isNotAllowed: boolean; }; @@ -51,11 +52,12 @@ export const SingleCalendarIssue: React.FC = ({ provided, snapshot, issue, + projectId, user, isNotAllowed, }) => { const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + const { workspaceSlug, cycleId, moduleId, viewId } = router.query; const { setToastAlert } = useToast(); diff --git a/web/components/core/views/gantt-chart-view/inline-create-issue-form.tsx b/web/components/core/views/gantt-chart-view/inline-create-issue-form.tsx new file mode 100644 index 00000000000..785eb3c5a14 --- /dev/null +++ b/web/components/core/views/gantt-chart-view/inline-create-issue-form.tsx @@ -0,0 +1,62 @@ +import { useEffect } from "react"; + +// react hook form +import { useFormContext } from "react-hook-form"; + +// hooks +import useProjectDetails from "hooks/use-project-details"; + +// components +import { InlineCreateIssueFormWrapper } from "components/core"; + +// types +import { IIssue } from "types"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + onSuccess?: (data: IIssue) => Promise | void; + prePopulatedData?: Partial; +}; + +const InlineInput = () => { + const { projectDetails } = useProjectDetails(); + + const { register, setFocus } = useFormContext(); + + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + return ( + <> +
+

{projectDetails?.identifier ?? "..."}

+ + + ); +}; + +export const GanttInlineCreateIssueForm: React.FC = (props) => ( + <> + + + + {props.isOpen && ( +

+ Press {"'"}Enter{"'"} to add another issue +

+ )} + +); diff --git a/web/components/core/views/index.ts b/web/components/core/views/index.ts index 8b2dc87cbf2..13da90d8e68 100644 --- a/web/components/core/views/index.ts +++ b/web/components/core/views/index.ts @@ -5,3 +5,4 @@ export * from "./list-view"; export * from "./spreadsheet-view"; export * from "./all-views"; export * from "./issues-view"; +export * from "./inline-issue-create-wrapper"; diff --git a/web/components/core/views/inline-issue-create-wrapper.tsx b/web/components/core/views/inline-issue-create-wrapper.tsx new file mode 100644 index 00000000000..ec5d8e79e01 --- /dev/null +++ b/web/components/core/views/inline-issue-create-wrapper.tsx @@ -0,0 +1,273 @@ +import { useEffect, useRef } from "react"; + +// next +import { useRouter } from "next/router"; + +// swr +import { mutate } from "swr"; + +// react hook form +import { useForm, FormProvider } from "react-hook-form"; + +// headless ui +import { Transition } from "@headlessui/react"; + +// services +import modulesService from "services/modules.service"; +import issuesService from "services/issues.service"; + +// hooks +import useToast from "hooks/use-toast"; +import useUser from "hooks/use-user"; +import useKeypress from "hooks/use-keypress"; +import useIssuesView from "hooks/use-issues-view"; +import useMyIssues from "hooks/my-issues/use-my-issues"; +import useGanttChartIssues from "hooks/gantt-chart/issue-view"; +import useCalendarIssuesView from "hooks/use-calendar-issues-view"; +import useOutsideClickDetector from "hooks/use-outside-click-detector"; +import useSpreadsheetIssuesView from "hooks/use-spreadsheet-issues-view"; + +// helpers +import { getFetchKeysForIssueMutation } from "helpers/string.helper"; + +// fetch-keys +import { + USER_ISSUE, + SUB_ISSUES, + CYCLE_ISSUES_WITH_PARAMS, + MODULE_ISSUES_WITH_PARAMS, + CYCLE_DETAILS, + MODULE_DETAILS, + PROJECT_ISSUES_LIST_WITH_PARAMS, + PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS, +} from "constants/fetch-keys"; + +// types +import { IIssue } from "types"; + +const defaultValues: Partial = { + name: "", +}; + +type Props = { + isOpen: boolean; + handleClose: () => void; + onSuccess?: (data: IIssue) => Promise | void; + prePopulatedData?: Partial; + className?: string; + children?: React.ReactNode; +}; + +export const addIssueToCycle = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + cycleId: string, + user: any, + params: any +) => { + if (!workspaceSlug || !projectId) return; + + await issuesService + .addIssueToCycle( + workspaceSlug as string, + projectId.toString(), + cycleId, + { + issues: [issueId], + }, + user + ) + .then(() => { + if (cycleId) { + mutate(CYCLE_ISSUES_WITH_PARAMS(cycleId, params)); + mutate(CYCLE_DETAILS(cycleId as string)); + } + }); +}; + +export const addIssueToModule = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + moduleId: string, + user: any, + params: any +) => { + await modulesService + .addIssuesToModule( + workspaceSlug as string, + projectId.toString(), + moduleId as string, + { + issues: [issueId], + }, + user + ) + .then(() => { + if (moduleId) { + mutate(MODULE_ISSUES_WITH_PARAMS(moduleId as string, params)); + mutate(MODULE_DETAILS(moduleId as string)); + } + }); +}; + +export const InlineCreateIssueFormWrapper: React.FC = (props) => { + const { isOpen, handleClose, onSuccess, prePopulatedData, children, className } = props; + + const ref = useRef(null); + + const router = useRouter(); + const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; + + const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues"; + + const { user } = useUser(); + + const { setToastAlert } = useToast(); + + const { displayFilters, params } = useIssuesView(); + const { params: calendarParams } = useCalendarIssuesView(); + const { ...viewGanttParams } = params; + const { params: spreadsheetParams } = useSpreadsheetIssuesView(); + const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); + const { params: ganttParams } = useGanttChartIssues( + workspaceSlug?.toString(), + projectId?.toString() + ); + + const method = useForm({ defaultValues }); + const { + reset, + handleSubmit, + getValues, + formState: { errors, isSubmitting }, + } = method; + + useOutsideClickDetector(ref, handleClose); + useKeypress("Escape", handleClose); + + useEffect(() => { + const values = getValues(); + + if (prePopulatedData) reset({ ...defaultValues, ...values, ...prePopulatedData }); + }, [reset, prePopulatedData, getValues]); + + useEffect(() => { + if (!isOpen) reset({ ...defaultValues }); + }, [isOpen, reset]); + + useEffect(() => { + if (!errors) return; + + Object.keys(errors).forEach((key) => { + const error = errors[key as keyof IIssue]; + + setToastAlert({ + type: "error", + title: "Error!", + message: error?.message?.toString() || "Some error occurred. Please try again.", + }); + }); + }, [errors, setToastAlert]); + + const { calendarFetchKey, ganttFetchKey, spreadsheetFetchKey } = getFetchKeysForIssueMutation({ + cycleId: cycleId, + moduleId: moduleId, + viewId: viewId, + projectId: projectId?.toString() ?? "", + calendarParams, + spreadsheetParams, + viewGanttParams, + ganttParams, + }); + + const onSubmitHandler = async (formData: IIssue) => { + if (!workspaceSlug || !projectId || !user || isSubmitting) return; + + reset({ ...defaultValues }); + + await (!isDraftIssues + ? issuesService.createIssues(workspaceSlug.toString(), projectId.toString(), formData, user) + : issuesService.createDraftIssue( + workspaceSlug.toString(), + projectId.toString(), + formData, + user + ) + ) + .then(async (res) => { + await mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(projectId.toString(), params)); + if (formData.cycle && formData.cycle !== "") + await addIssueToCycle( + workspaceSlug.toString(), + projectId.toString(), + res.id, + formData.cycle, + user, + params + ); + if (formData.module && formData.module !== "") + await addIssueToModule( + workspaceSlug.toString(), + projectId.toString(), + res.id, + formData.module, + user, + params + ); + + if (isDraftIssues) + await mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(projectId.toString() ?? "", params)); + if (displayFilters.layout === "calendar") await mutate(calendarFetchKey); + if (displayFilters.layout === "gantt_chart") await mutate(ganttFetchKey); + if (displayFilters.layout === "spreadsheet") await mutate(spreadsheetFetchKey); + if (groupedIssues) await mutateMyIssues(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Issue created successfully.", + }); + + if (onSuccess) await onSuccess(res); + + if (formData.assignees_list?.some((assignee) => assignee === user?.id)) + mutate(USER_ISSUE(workspaceSlug as string)); + + if (formData.parent && formData.parent !== "") mutate(SUB_ISSUES(formData.parent)); + }) + .catch((err) => { + Object.keys(err || {}).forEach((key) => { + const error = err?.[key]; + const errorTitle = error ? (Array.isArray(error) ? error.join(", ") : error) : null; + + setToastAlert({ + type: "error", + title: "Error!", + message: errorTitle || "Some error occurred. Please try again.", + }); + }); + }); + }; + + return ( + <> + + +
+ {children} +
+
+
+ + ); +}; diff --git a/web/components/core/views/issues-view.tsx b/web/components/core/views/issues-view.tsx index 2ba81cc25c3..6d28913285c 100644 --- a/web/components/core/views/issues-view.tsx +++ b/web/components/core/views/issues-view.tsx @@ -76,7 +76,9 @@ export const IssuesView: React.FC = ({ openIssuesListModal, disableUserAc const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId, viewId } = router.query; - const isDraftIssues = router.asPath.includes("draft-issues"); + + const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues"; + const isArchivedIssues = router.pathname?.split("/")?.[4] === "archived-issues"; const { user } = useUserAuth(); @@ -575,6 +577,7 @@ export const IssuesView: React.FC = ({ openIssuesListModal, disableUserAc params, properties, }} + disableAddIssueOption={isArchivedIssues} /> ); diff --git a/web/components/core/views/list-view/index.ts b/web/components/core/views/list-view/index.ts index c515ed1c247..4d59be1654d 100644 --- a/web/components/core/views/list-view/index.ts +++ b/web/components/core/views/list-view/index.ts @@ -1,3 +1,4 @@ export * from "./all-lists"; export * from "./single-issue"; export * from "./single-list"; +export * from "./inline-create-issue-form"; diff --git a/web/components/core/views/list-view/inline-create-issue-form.tsx b/web/components/core/views/list-view/inline-create-issue-form.tsx new file mode 100644 index 00000000000..b61420fc82a --- /dev/null +++ b/web/components/core/views/list-view/inline-create-issue-form.tsx @@ -0,0 +1,62 @@ +import { useEffect } from "react"; +// react hook form +import { useFormContext } from "react-hook-form"; + +// hooks +import useProjectDetails from "hooks/use-project-details"; + +// components +import { InlineCreateIssueFormWrapper } from "../inline-issue-create-wrapper"; + +// types +import { IIssue } from "types"; + +type Props = { + isOpen: boolean; + handleClose: () => void; + onSuccess?: (data: IIssue) => Promise | void; + prePopulatedData?: Partial; +}; + +const InlineInput = () => { + const { projectDetails } = useProjectDetails(); + + const { register, setFocus } = useFormContext(); + + useEffect(() => { + setFocus("name"); + }, [setFocus]); + + return ( + <> +

+ {projectDetails?.identifier ?? "..."} +

+ + + ); +}; + +export const ListInlineCreateIssueForm: React.FC = (props) => ( + <> + + + + {props.isOpen && ( +

+ Press {"'"}Enter{"'"} to add another issue +

+ )} + +); diff --git a/web/components/core/views/list-view/single-issue.tsx b/web/components/core/views/list-view/single-issue.tsx index b1eedce6975..03e910f28bf 100644 --- a/web/components/core/views/list-view/single-issue.tsx +++ b/web/components/core/views/list-view/single-issue.tsx @@ -46,6 +46,7 @@ import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES, USER_PROFILE_PROJECT_SEGREGA type Props = { type?: string; issue: IIssue; + projectId: string; groupTitle?: string; editIssue: () => void; index: number; @@ -64,6 +65,7 @@ type Props = { export const SingleListIssue: React.FC = ({ type, issue, + projectId, editIssue, index, makeIssueCopy, @@ -83,7 +85,7 @@ export const SingleListIssue: React.FC = ({ const [contextMenuPosition, setContextMenuPosition] = useState(null); const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId, userId } = router.query; + const { workspaceSlug, cycleId, moduleId, userId } = router.query; const isArchivedIssues = router.pathname.includes("archived-issues"); const isDraftIssues = router.pathname?.split("/")?.[4] === "draft-issues"; @@ -303,7 +305,7 @@ export const SingleListIssue: React.FC = ({
{ e.preventDefault(); setContextMenu(true); @@ -327,6 +329,7 @@ export const SingleListIssue: React.FC = ({ type="button" className="truncate text-[0.825rem] text-custom-text-100" onClick={() => { + if (isArchivedIssues) return router.push(issuePath); if (!isDraftIssues) openPeekOverview(issue); if (isDraftIssues && handleDraftIssueSelect) handleDraftIssueSelect(issue); }} diff --git a/web/components/core/views/list-view/single-list.tsx b/web/components/core/views/list-view/single-list.tsx index d2f2781252f..83d52fd00b7 100644 --- a/web/components/core/views/list-view/single-list.tsx +++ b/web/components/core/views/list-view/single-list.tsx @@ -1,3 +1,6 @@ +import { useState } from "react"; + +// next import { useRouter } from "next/router"; import useSWR from "swr"; @@ -10,7 +13,8 @@ import projectService from "services/project.service"; // hooks import useProjects from "hooks/use-projects"; // components -import { SingleListIssue } from "components/core"; +import { CreateUpdateDraftIssueModal } from "components/issues"; +import { SingleListIssue, ListInlineCreateIssueForm } from "components/core"; // ui import { Avatar, CustomMenu } from "components/ui"; // icons @@ -31,7 +35,7 @@ import { UserAuth, } from "types"; // fetch-keys -import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys"; +import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, WORKSPACE_LABELS } from "constants/fetch-keys"; // constants import { STATE_GROUP_COLORS } from "constants/state"; @@ -51,38 +55,64 @@ type Props = { viewProps: IIssueViewProps; }; -export const SingleList: React.FC = ({ - currentState, - groupTitle, - addIssueToGroup, - handleIssueAction, - openIssuesListModal, - handleDraftIssueAction, - handleMyIssueOpen, - removeIssue, - disableUserActions, - disableAddIssueOption = false, - user, - userAuth, - viewProps, -}) => { +export const SingleList: React.FC = (props) => { + const { + currentState, + groupTitle, + handleIssueAction, + openIssuesListModal, + handleDraftIssueAction, + handleMyIssueOpen, + addIssueToGroup, + removeIssue, + disableUserActions, + disableAddIssueOption = false, + user, + userAuth, + viewProps, + } = props; + const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false); + const [isDraftIssuesModalOpen, setIsDraftIssuesModalOpen] = useState(false); + + const isMyIssuesPage = router.pathname.split("/")[3] === "my-issues"; + const isProfileIssuesPage = router.pathname.split("/")[2] === "profile"; + const isDraftIssuesPage = router.pathname.split("/")[4] === "draft-issues"; + const isArchivedIssues = router.pathname.includes("archived-issues"); const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; const { displayFilters, groupedIssues } = viewProps; - const { data: issueLabels } = useSWR( - workspaceSlug && projectId ? PROJECT_ISSUE_LABELS(projectId as string) : null, - workspaceSlug && projectId ? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string) : null + const { data: issueLabels } = useSWR( + workspaceSlug && projectId && displayFilters?.group_by === "labels" + ? PROJECT_ISSUE_LABELS(projectId.toString()) + : null, + workspaceSlug && projectId && displayFilters?.group_by === "labels" + ? () => issuesService.getIssueLabels(workspaceSlug.toString(), projectId.toString()) + : null + ); + + const { data: workspaceLabels } = useSWR( + workspaceSlug && displayFilters?.group_by === "labels" ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, + workspaceSlug && displayFilters?.group_by === "labels" + ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) + : null ); const { data: members } = useSWR( - workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, - workspaceSlug && projectId + workspaceSlug && + projectId && + (displayFilters?.group_by === "created_by" || displayFilters?.group_by === "assignees") + ? PROJECT_MEMBERS(projectId as string) + : null, + workspaceSlug && + projectId && + (displayFilters?.group_by === "created_by" || displayFilters?.group_by === "assignees") ? () => projectService.projectMembers(workspaceSlug as string, projectId as string) : null ); @@ -97,7 +127,9 @@ export const SingleList: React.FC = ({ title = addSpaceIfCamelCase(currentState?.name ?? ""); break; case "labels": - title = issueLabels?.find((label) => label.id === groupTitle)?.name ?? "None"; + title = + [...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find((label) => label.id === groupTitle)?.name ?? + "None"; break; case "project": title = projects?.find((p) => p.id === groupTitle)?.name ?? "None"; @@ -145,7 +177,9 @@ export const SingleList: React.FC = ({ : null); break; case "labels": - const labelColor = issueLabels?.find((label) => label.id === groupTitle)?.color ?? "#000000"; + const labelColor = + [...(issueLabels ?? []), ...(workspaceLabels ?? [])]?.find((label) => label.id === groupTitle)?.color ?? + "#000000"; icon = ; break; case "assignees": @@ -162,108 +196,154 @@ export const SingleList: React.FC = ({ if (!groupedIssues) return null; return ( - - {({ open }) => ( -
-
- -
- {displayFilters?.group_by !== null &&
{getGroupIcon()}
} - {displayFilters?.group_by !== null ? ( -

+ setIsDraftIssuesModalOpen(false)} + prePopulateData={{ + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + [displayFilters?.group_by! === "labels" ? "labels_list" : displayFilters?.group_by!]: + displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle, + }} + /> + + + {({ open }) => ( +
+
+ +
+ {displayFilters?.group_by !== null &&
{getGroupIcon()}
} + {displayFilters?.group_by !== null ? ( +

+ {getGroupTitle()} +

+ ) : ( +

All Issues

+ )} + + {groupedIssues[groupTitle as keyof IIssue].length} + +
+
+ {isArchivedIssues ? ( + "" + ) : type === "issue" ? ( + !disableAddIssueOption && ( +

+ + + ) + ) : disableUserActions ? ( + "" + ) : ( + + +
+ } + position="right" + noBorder + > + setIsCreateIssueFormOpen(true)}>Create new + {openIssuesListModal && ( + Add an existing issue + )} + + )} +
+ + + {groupedIssues[groupTitle] ? ( + groupedIssues[groupTitle].length > 0 ? ( + groupedIssues[groupTitle].map((issue, index) => ( + handleIssueAction(issue, "edit")} + makeIssueCopy={() => handleIssueAction(issue, "copy")} + handleDeleteIssue={() => handleIssueAction(issue, "delete")} + handleDraftIssueSelect={ + handleDraftIssueAction ? () => handleDraftIssueAction(issue, "edit") : undefined + } + handleDraftIssueDelete={ + handleDraftIssueAction ? () => handleDraftIssueAction(issue, "delete") : undefined + } + handleMyIssueOpen={handleMyIssueOpen} + removeIssue={() => { + if (removeIssue !== null && issue.bridge_id) removeIssue(issue.bridge_id, issue.id); + }} + disableUserActions={disableUserActions} + user={user} + userAuth={userAuth} + viewProps={viewProps} + /> + )) + ) : ( +

No issues.

+ ) ) : ( -

All Issues

+
Loading...
)} - - {groupedIssues[groupTitle as keyof IIssue].length} - -
- - {isArchivedIssues ? ( - "" - ) : type === "issue" ? ( - !disableAddIssueOption && ( - - ) - ) : disableUserActions ? ( - "" - ) : ( - - + + setIsCreateIssueFormOpen(false)} + prePopulatedData={{ + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + [displayFilters?.group_by! === "labels" ? "labels_list" : displayFilters?.group_by!]: + displayFilters?.group_by === "labels" ? [groupTitle] : groupTitle, + }} + /> + + {!disableAddIssueOption && !isCreateIssueFormOpen && !isDraftIssuesPage && ( +
+
- } - position="right" - noBorder - > - Create new - {openIssuesListModal && ( - Add an existing issue )} -
- )} + +
- - - {groupedIssues[groupTitle] ? ( - groupedIssues[groupTitle].length > 0 ? ( - groupedIssues[groupTitle].map((issue, index) => ( - handleIssueAction(issue, "edit")} - makeIssueCopy={() => handleIssueAction(issue, "copy")} - handleDeleteIssue={() => handleIssueAction(issue, "delete")} - handleDraftIssueSelect={ - handleDraftIssueAction ? () => handleDraftIssueAction(issue, "edit") : undefined - } - handleDraftIssueDelete={ - handleDraftIssueAction ? () => handleDraftIssueAction(issue, "delete") : undefined - } - handleMyIssueOpen={handleMyIssueOpen} - removeIssue={() => { - if (removeIssue !== null && issue.bridge_id) removeIssue(issue.bridge_id, issue.id); - }} - disableUserActions={disableUserActions} - user={user} - userAuth={userAuth} - viewProps={viewProps} - /> - )) - ) : ( -

No issues.

- ) - ) : ( -
Loading...
- )} -
-
-
- )} - + )} + + ); }; diff --git a/web/components/core/views/spreadsheet-view/assignee-column/assignee-column.tsx b/web/components/core/views/spreadsheet-view/assignee-column/assignee-column.tsx new file mode 100644 index 00000000000..745083d5f8c --- /dev/null +++ b/web/components/core/views/spreadsheet-view/assignee-column/assignee-column.tsx @@ -0,0 +1,72 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +// components +import { MembersSelect } from "components/project"; +// services +import trackEventServices from "services/track-event.service"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const AssigneeColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => { + const router = useRouter(); + + const { workspaceSlug } = router.query; + + const handleAssigneeChange = (data: any) => { + const newData = issue.assignees ?? []; + + if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); + else newData.push(data); + + partialUpdateIssue({ assignees_list: data }, issue); + + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_ASSIGNEE", + user + ); + }; + + return ( +
+ + {properties.assignee && ( + + )} + +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/assignee-column/index.ts b/web/components/core/views/spreadsheet-view/assignee-column/index.ts new file mode 100644 index 00000000000..8750550beb3 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/assignee-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-assignee-column"; +export * from "./assignee-column"; diff --git a/web/components/core/views/spreadsheet-view/assignee-column/spreadsheet-assignee-column.tsx b/web/components/core/views/spreadsheet-view/assignee-column/spreadsheet-assignee-column.tsx new file mode 100644 index 00000000000..a864126c616 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/assignee-column/spreadsheet-assignee-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { AssigneeColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetAssigneeColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/created-on-column/created-on-column.tsx b/web/components/core/views/spreadsheet-view/created-on-column/created-on-column.tsx new file mode 100644 index 00000000000..cff1f99aaa2 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/created-on-column/created-on-column.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; +// helper +import { renderLongDetailDateFormat } from "helpers/date-time.helper"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const CreatedOnColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => ( +
+ + {properties.created_on && ( +
+ {renderLongDetailDateFormat(issue.created_at)} +
+ )} +
+
+); diff --git a/web/components/core/views/spreadsheet-view/created-on-column/index.ts b/web/components/core/views/spreadsheet-view/created-on-column/index.ts new file mode 100644 index 00000000000..28781aa1739 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/created-on-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-created-on-column"; +export * from "./created-on-column"; diff --git a/web/components/core/views/spreadsheet-view/created-on-column/spreadsheet-created-on-column.tsx b/web/components/core/views/spreadsheet-view/created-on-column/spreadsheet-created-on-column.tsx new file mode 100644 index 00000000000..3ce3f2dbe57 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/created-on-column/spreadsheet-created-on-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { CreatedOnColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetCreatedOnColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/due-date-column/due-date-column.tsx b/web/components/core/views/spreadsheet-view/due-date-column/due-date-column.tsx new file mode 100644 index 00000000000..e2d09ae0a9c --- /dev/null +++ b/web/components/core/views/spreadsheet-view/due-date-column/due-date-column.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +// components +import { ViewDueDateSelect } from "components/issues"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const DueDateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => ( +
+ + {properties.due_date && ( + + )} + +
+); diff --git a/web/components/core/views/spreadsheet-view/due-date-column/index.ts b/web/components/core/views/spreadsheet-view/due-date-column/index.ts new file mode 100644 index 00000000000..64b45487757 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/due-date-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-due-date-column"; +export * from "./due-date-column"; diff --git a/web/components/core/views/spreadsheet-view/due-date-column/spreadsheet-due-date-column.tsx b/web/components/core/views/spreadsheet-view/due-date-column/spreadsheet-due-date-column.tsx new file mode 100644 index 00000000000..1cd2eac262c --- /dev/null +++ b/web/components/core/views/spreadsheet-view/due-date-column/spreadsheet-due-date-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { DueDateColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetDueDateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/estimate-column/estimate-column.tsx b/web/components/core/views/spreadsheet-view/estimate-column/estimate-column.tsx new file mode 100644 index 00000000000..bb44cefa3ee --- /dev/null +++ b/web/components/core/views/spreadsheet-view/estimate-column/estimate-column.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +// components +import { ViewEstimateSelect } from "components/issues"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const EstimateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => ( +
+ + {properties.estimate && ( + + )} + +
+); diff --git a/web/components/core/views/spreadsheet-view/estimate-column/index.ts b/web/components/core/views/spreadsheet-view/estimate-column/index.ts new file mode 100644 index 00000000000..31f07e6a794 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/estimate-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-estimate-column"; +export * from "./estimate-column"; diff --git a/web/components/core/views/spreadsheet-view/estimate-column/spreadsheet-estimate-column.tsx b/web/components/core/views/spreadsheet-view/estimate-column/spreadsheet-estimate-column.tsx new file mode 100644 index 00000000000..a1cc74ad0b5 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/estimate-column/spreadsheet-estimate-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { EstimateColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetEstimateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/index.ts b/web/components/core/views/spreadsheet-view/index.ts index 7729d5e9353..9bf8ed1b0bd 100644 --- a/web/components/core/views/spreadsheet-view/index.ts +++ b/web/components/core/views/spreadsheet-view/index.ts @@ -1,4 +1,13 @@ +export * from "./assignee-column"; +export * from "./created-on-column"; +export * from "./due-date-column"; +export * from "./estimate-column"; +export * from "./issue-column"; +export * from "./label-column"; +export * from "./priority-column"; +export * from "./start-date-column"; +export * from "./state-column"; +export * from "./updated-on-column"; export * from "./spreadsheet-view"; -export * from "./single-issue"; -export * from "./spreadsheet-columns"; -export * from "./spreadsheet-issues"; +export * from "./issue-column/issue-column"; +export * from "./issue-column/spreadsheet-issue-column"; diff --git a/web/components/core/views/spreadsheet-view/issue-column/index.ts b/web/components/core/views/spreadsheet-view/issue-column/index.ts new file mode 100644 index 00000000000..b8d09d1df1d --- /dev/null +++ b/web/components/core/views/spreadsheet-view/issue-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-issue-column"; +export * from "./issue-column"; diff --git a/web/components/core/views/spreadsheet-view/issue-column/issue-column.tsx b/web/components/core/views/spreadsheet-view/issue-column/issue-column.tsx new file mode 100644 index 00000000000..c00f085b2cf --- /dev/null +++ b/web/components/core/views/spreadsheet-view/issue-column/issue-column.tsx @@ -0,0 +1,180 @@ +import React, { useState } from "react"; + +import { useRouter } from "next/router"; + +// components +import { Popover2 } from "@blueprintjs/popover2"; +// icons +import { Icon } from "components/ui"; +import { + EllipsisHorizontalIcon, + LinkIcon, + PencilIcon, + TrashIcon, +} from "@heroicons/react/24/outline"; +// hooks +import useToast from "hooks/use-toast"; +// types +import { IIssue, Properties, UserAuth } from "types"; +// helper +import { copyTextToClipboard } from "helpers/string.helper"; + +type Props = { + issue: IIssue; + projectId: string; + expanded: boolean; + handleToggleExpand: (issueId: string) => void; + properties: Properties; + handleEditIssue: (issue: IIssue) => void; + handleDeleteIssue: (issue: IIssue) => void; + setCurrentProjectId: React.Dispatch>; + disableUserActions: boolean; + userAuth: UserAuth; + nestingLevel: number; +}; + +export const IssueColumn: React.FC = ({ + issue, + projectId, + expanded, + handleToggleExpand, + properties, + handleEditIssue, + handleDeleteIssue, + setCurrentProjectId, + disableUserActions, + userAuth, + nestingLevel, +}) => { + const [isOpen, setIsOpen] = useState(false); + + const router = useRouter(); + + const { workspaceSlug } = router.query; + + const { setToastAlert } = useToast(); + + const openPeekOverview = () => { + const { query } = router; + setCurrentProjectId(issue.project_detail.id); + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: issue.id }, + }); + }; + + const handleCopyText = () => { + const originURL = + typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + copyTextToClipboard( + `${originURL}/${workspaceSlug}/projects/${projectId}/issues/${issue.id}` + ).then(() => { + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Issue link copied to clipboard.", + }); + }); + }; + + const paddingLeft = `${nestingLevel * 54}px`; + + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + + return ( +
+ {properties.key && ( +
+
+ + {issue.project_detail?.identifier}-{issue.sequence_id} + + + {!isNotAllowed && !disableUserActions && ( +
+ setIsOpen(nextOpenState)} + content={ +
+ + + + + +
+ } + placement="bottom-start" + > + +
+
+ )} +
+ + {issue.sub_issues_count > 0 && ( +
+ +
+ )} +
+ )} + + + +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/spreadsheet-issues.tsx b/web/components/core/views/spreadsheet-view/issue-column/spreadsheet-issue-column.tsx similarity index 74% rename from web/components/core/views/spreadsheet-view/spreadsheet-issues.tsx rename to web/components/core/views/spreadsheet-view/issue-column/spreadsheet-issue-column.tsx index 8290984fa3c..966852a5b31 100644 --- a/web/components/core/views/spreadsheet-view/spreadsheet-issues.tsx +++ b/web/components/core/views/spreadsheet-view/issue-column/spreadsheet-issue-column.tsx @@ -1,36 +1,34 @@ import React from "react"; // components -import { SingleSpreadsheetIssue } from "components/core"; +import { IssueColumn } from "components/core"; // hooks import useSubIssue from "hooks/use-sub-issue"; // types -import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; +import { IIssue, Properties, UserAuth } from "types"; type Props = { issue: IIssue; - index: number; + projectId: string; expandedIssues: string[]; setExpandedIssues: React.Dispatch>; properties: Properties; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; - gridTemplateColumns: string; + setCurrentProjectId: React.Dispatch>; disableUserActions: boolean; - user: ICurrentUserResponse | undefined; userAuth: UserAuth; nestingLevel?: number; }; -export const SpreadsheetIssues: React.FC = ({ - index, +export const SpreadsheetIssuesColumn: React.FC = ({ issue, + projectId, expandedIssues, setExpandedIssues, - gridTemplateColumns, properties, handleIssueAction, + setCurrentProjectId, disableUserActions, - user, userAuth, nestingLevel = 0, }) => { @@ -49,21 +47,20 @@ export const SpreadsheetIssues: React.FC = ({ const isExpanded = expandedIssues.indexOf(issue.id) > -1; - const { subIssues, isLoading } = useSubIssue(issue.id, isExpanded); + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); return (
- handleIssueAction(issue, "edit")} handleDeleteIssue={() => handleIssueAction(issue, "delete")} + setCurrentProjectId={setCurrentProjectId} disableUserActions={disableUserActions} - user={user} userAuth={userAuth} nestingLevel={nestingLevel} /> @@ -73,17 +70,16 @@ export const SpreadsheetIssues: React.FC = ({ subIssues && subIssues.length > 0 && subIssues.map((subIssue: IIssue) => ( - diff --git a/web/components/core/views/spreadsheet-view/label-column/index.ts b/web/components/core/views/spreadsheet-view/label-column/index.ts new file mode 100644 index 00000000000..a1b69c1a9ff --- /dev/null +++ b/web/components/core/views/spreadsheet-view/label-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-label-column"; +export * from "./label-column"; diff --git a/web/components/core/views/spreadsheet-view/label-column/label-column.tsx b/web/components/core/views/spreadsheet-view/label-column/label-column.tsx new file mode 100644 index 00000000000..1d1355eec03 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/label-column/label-column.tsx @@ -0,0 +1,47 @@ +import React from "react"; + +// components +import { LabelSelect } from "components/project"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const LabelColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => { + const handleLabelChange = (data: any) => { + partialUpdateIssue({ labels_list: data }, issue); + }; + + return ( +
+ + {properties.labels && ( + + )} + +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/label-column/spreadsheet-label-column.tsx b/web/components/core/views/spreadsheet-view/label-column/spreadsheet-label-column.tsx new file mode 100644 index 00000000000..5ab77e90956 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/label-column/spreadsheet-label-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { LabelColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetLabelColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/priority-column/index.ts b/web/components/core/views/spreadsheet-view/priority-column/index.ts new file mode 100644 index 00000000000..fc542331e96 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/priority-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-priority-column"; +export * from "./priority-column"; diff --git a/web/components/core/views/spreadsheet-view/priority-column/priority-column.tsx b/web/components/core/views/spreadsheet-view/priority-column/priority-column.tsx new file mode 100644 index 00000000000..eca140df1d7 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/priority-column/priority-column.tsx @@ -0,0 +1,64 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +// components +import { PrioritySelect } from "components/project"; +// services +import trackEventServices from "services/track-event.service"; +// types +import { ICurrentUserResponse, IIssue, Properties, TIssuePriorities } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const PriorityColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => { + const router = useRouter(); + + const { workspaceSlug } = router.query; + + const handlePriorityChange = (data: TIssuePriorities) => { + partialUpdateIssue({ priority: data }, issue); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_PRIORITY", + user + ); + }; + + return ( +
+ + {properties.priority && ( + + )} + +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/priority-column/spreadsheet-priority-column.tsx b/web/components/core/views/spreadsheet-view/priority-column/spreadsheet-priority-column.tsx new file mode 100644 index 00000000000..f0b84fb597a --- /dev/null +++ b/web/components/core/views/spreadsheet-view/priority-column/spreadsheet-priority-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { PriorityColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetPriorityColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/single-issue.tsx b/web/components/core/views/spreadsheet-view/single-issue.tsx index 12a9eeecb24..73ba6299404 100644 --- a/web/components/core/views/spreadsheet-view/single-issue.tsx +++ b/web/components/core/views/spreadsheet-view/single-issue.tsx @@ -19,12 +19,21 @@ import { StateSelect } from "components/states"; import { copyTextToClipboard } from "helpers/string.helper"; import { renderLongDetailDateFormat } from "helpers/date-time.helper"; // types -import { ICurrentUserResponse, IIssue, IState, Properties, TIssuePriorities, UserAuth } from "types"; +import { ICurrentUserResponse, IIssue, IState, ISubIssueResponse, Properties, TIssuePriorities, UserAuth } from "types"; // constant -import { CYCLE_DETAILS, MODULE_DETAILS, SUB_ISSUES } from "constants/fetch-keys"; +import { + CYCLE_DETAILS, + CYCLE_ISSUES_WITH_PARAMS, + MODULE_DETAILS, + MODULE_ISSUES_WITH_PARAMS, + PROJECT_ISSUES_LIST_WITH_PARAMS, + SUB_ISSUES, + VIEW_ISSUES, +} from "constants/fetch-keys"; type Props = { issue: IIssue; + projectId: string; index: number; expanded: boolean; handleToggleExpand: (issueId: string) => void; @@ -40,6 +49,7 @@ type Props = { export const SingleSpreadsheetIssue: React.FC = ({ issue, + projectId, index, expanded, handleToggleExpand, @@ -56,7 +66,9 @@ export const SingleSpreadsheetIssue: React.FC = ({ const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const { workspaceSlug, cycleId, moduleId, viewId } = router.query; + + const params = {}; const { setToastAlert } = useToast(); @@ -64,8 +76,53 @@ export const SingleSpreadsheetIssue: React.FC = ({ (formData: Partial, issue: IIssue) => { if (!workspaceSlug || !projectId) return; + const fetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) + : viewId + ? VIEW_ISSUES(viewId.toString(), params) + : PROJECT_ISSUES_LIST_WITH_PARAMS(projectId, params); + + if (issue.parent) + mutate( + SUB_ISSUES(issue.parent.toString()), + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + sub_issues: (prevData.sub_issues ?? []).map((i) => { + if (i.id === issue.id) { + return { + ...i, + ...formData, + }; + } + return i; + }), + }; + }, + false + ); + else + mutate( + fetchKey, + (prevData) => + (prevData ?? []).map((p) => { + if (p.id === issue.id) { + return { + ...p, + ...formData, + }; + } + return p; + }), + false + ); + issuesService - .patchIssue(workspaceSlug as string, projectId as string, issue.id as string, formData, user) + .patchIssue(workspaceSlug as string, projectId, issue.id as string, formData, user) .then(() => { if (issue.parent) { mutate(SUB_ISSUES(issue.parent as string)); @@ -286,6 +343,7 @@ export const SingleSpreadsheetIssue: React.FC = ({
= ({
= ({
) => void; -}; - -export const SpreadsheetColumns: React.FC = (props) => { - const { columnData, displayFilters, gridTemplateColumns, handleDisplayFiltersUpdate } = props; - - const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( - "spreadsheetViewSorting", - "" - ); - const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } = useLocalStorage( - "spreadsheetViewActiveSortingProperty", - "" - ); - - const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => { - handleDisplayFiltersUpdate({ order_by: order }); - setSelectedMenuItem(`${order}_${itemKey}`); - setActiveSortingProperty(order === "-created_at" ? "" : itemKey); - }; - - return ( -
- {columnData.map((col: any) => { - if (col.isActive) { - return ( -
- {col.propertyName === "title" ? ( -
- {col.colName} -
- ) : ( - - {activeSortingProperty === col.propertyName && ( -
- -
- )} - - {col.icon ? ( -
- } - width="xl" - > - { - handleOrderBy(col.ascendingOrder, col.propertyName); - }} - > -
-
- {col.propertyName === "assignee" || col.propertyName === "labels" ? ( - <> - - - - - A - - Z - - ) : col.propertyName === "due_date" || - col.propertyName === "created_on" || - col.propertyName === "updated_on" ? ( - <> - - - - - New - - Old - - ) : ( - <> - - - - - First - - Last - - )} -
- - -
-
- { - handleOrderBy(col.descendingOrder, col.propertyName); - }} - > -
-
- {col.propertyName === "assignee" || col.propertyName === "labels" ? ( - <> - - - - - Z - - A - - ) : col.propertyName === "due_date" ? ( - <> - - - - - Old - - New - - ) : ( - <> - - - - - Last - - First - - )} -
- - -
-
- {selectedMenuItem && - selectedMenuItem !== "" && - displayFilters?.order_by !== "-created_at" && - selectedMenuItem.includes(col.propertyName) && ( - { - handleOrderBy("-created_at", col.propertyName); - }} - > -
-
- - - - - Clear sorting -
-
-
- )} - - )} -
- ); - } - })} -
- ); -}; diff --git a/web/components/core/views/spreadsheet-view/spreadsheet-view.tsx b/web/components/core/views/spreadsheet-view/spreadsheet-view.tsx index dc5431a67d7..94c30e309b4 100644 --- a/web/components/core/views/spreadsheet-view/spreadsheet-view.tsx +++ b/web/components/core/views/spreadsheet-view/spreadsheet-view.tsx @@ -1,22 +1,54 @@ -import React, { useState } from "react"; +import React, { useCallback, useEffect, useRef, useState } from "react"; // next import { useRouter } from "next/router"; +import { KeyedMutator, mutate } from "swr"; + // components -import { SpreadsheetColumns, SpreadsheetIssues } from "components/core"; -import { CustomMenu, Spinner } from "components/ui"; +import { + ListInlineCreateIssueForm, + SpreadsheetAssigneeColumn, + SpreadsheetCreatedOnColumn, + SpreadsheetDueDateColumn, + SpreadsheetEstimateColumn, + SpreadsheetIssuesColumn, + SpreadsheetLabelColumn, + SpreadsheetPriorityColumn, + SpreadsheetStartDateColumn, + SpreadsheetStateColumn, + SpreadsheetUpdatedOnColumn, +} from "components/core"; +import { CustomMenu, Icon, Spinner } from "components/ui"; import { IssuePeekOverview } from "components/issues"; // hooks import useIssuesProperties from "hooks/use-issue-properties"; +import useLocalStorage from "hooks/use-local-storage"; +import { useWorkspaceView } from "hooks/use-workspace-view"; // types -import { ICurrentUserResponse, IIssue, Properties, UserAuth } from "types"; -// constants -import { SPREADSHEET_COLUMN } from "constants/spreadsheet"; +import { ICurrentUserResponse, IIssue, ISubIssueResponse, TIssueOrderByOptions, UserAuth } from "types"; +import { + CYCLE_DETAILS, + CYCLE_ISSUES_WITH_PARAMS, + MODULE_DETAILS, + MODULE_ISSUES_WITH_PARAMS, + PROJECT_ISSUES_LIST_WITH_PARAMS, + SUB_ISSUES, + VIEW_ISSUES, + WORKSPACE_VIEW_ISSUES, +} from "constants/fetch-keys"; +import issueService from "services/issue.service"; // icon -import { PlusIcon } from "@heroicons/react/24/outline"; +import { CheckIcon, ChevronDownIcon, PlusIcon } from "lucide-react"; type Props = { + spreadsheetIssues: IIssue[]; + mutateIssues: KeyedMutator< + | IIssue[] + | { + [key: string]: IIssue[]; + } + >; handleIssueAction: (issue: IIssue, action: "copy" | "delete" | "edit") => void; openIssuesListModal?: (() => void) | null; disableUserActions: boolean; @@ -25,6 +57,8 @@ type Props = { }; export const SpreadsheetView: React.FC = ({ + spreadsheetIssues, + mutateIssues, handleIssueAction, openIssuesListModal, disableUserActions, @@ -32,113 +66,503 @@ export const SpreadsheetView: React.FC = ({ userAuth, }) => { const [expandedIssues, setExpandedIssues] = useState([]); + const [currentProjectId, setCurrentProjectId] = useState(null); + + const [isInlineCreateIssueFormOpen, setIsInlineCreateIssueFormOpen] = useState(false); + + const [isScrolled, setIsScrolled] = useState(false); + + const containerRef = useRef(null); const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId, viewId, globalViewId } = router.query; const type = cycleId ? "cycle" : moduleId ? "module" : "issue"; const [properties] = useIssuesProperties(workspaceSlug as string, projectId as string); - const columnData = SPREADSHEET_COLUMN.map((column) => ({ - ...column, - isActive: properties - ? column.propertyName === "labels" - ? properties[column.propertyName as keyof Properties] - : column.propertyName === "title" - ? true - : properties[column.propertyName as keyof Properties] - : false, - })); - - const gridTemplateColumns = columnData - .filter((column) => column.isActive) - .map((column) => column.colSize) - .join(" "); - - return null; - - // return ( - // <> - // mutateIssues()} - // projectId={projectId?.toString() ?? ""} - // workspaceSlug={workspaceSlug?.toString() ?? ""} - // readOnly={disableUserActions} - // /> - //
- //
- // - //
- // {spreadsheetIssues ? ( - //
- // {spreadsheetIssues.map((issue: IIssue, index) => ( - // - // ))} - //
- // {type === "issue" ? ( - // - // ) : ( - // !disableUserActions && ( - // - // - // Add Issue - // - // } - // position="left" - // optionsClassName="left-5 !w-36" - // noBorder - // > - // { - // const e = new KeyboardEvent("keydown", { key: "c" }); - // document.dispatchEvent(e); - // }} - // > - // Create new - // - // {openIssuesListModal && ( - // Add an existing issue - // )} - // - // ) - // )} - //
- //
- // ) : ( - // - // )} - //
- // - // ); + const { storedValue: selectedMenuItem, setValue: setSelectedMenuItem } = useLocalStorage( + "spreadsheetViewSorting", + "" + ); + const { storedValue: activeSortingProperty, setValue: setActiveSortingProperty } = useLocalStorage( + "spreadsheetViewActiveSortingProperty", + "" + ); + + const workspaceIssuesPath = [ + { + params: { + sub_issue: false, + }, + path: "workspace-views/all-issues", + }, + { + params: { + assignees: user?.id ?? undefined, + sub_issue: false, + }, + path: "workspace-views/assigned", + }, + { + params: { + created_by: user?.id ?? undefined, + sub_issue: false, + }, + path: "workspace-views/created", + }, + { + params: { + subscriber: user?.id ?? undefined, + sub_issue: false, + }, + path: "workspace-views/subscribed", + }, + ]; + + const currentWorkspaceIssuePath = workspaceIssuesPath.find((path) => router.pathname.includes(path.path)); + + const { params: workspaceViewParams, filters: workspaceViewFilters, handleFilters } = useWorkspaceView(); + + const workspaceViewProperties = workspaceViewFilters.display_properties; + + const isWorkspaceView = globalViewId || currentWorkspaceIssuePath; + + const currentViewProperties = isWorkspaceView ? workspaceViewProperties : properties; + + const params = {}; + + const partialUpdateIssue = useCallback( + (formData: Partial, issue: IIssue) => { + if (!workspaceSlug || !issue) return; + + const fetchKey = cycleId + ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString(), params) + : moduleId + ? MODULE_ISSUES_WITH_PARAMS(moduleId.toString(), params) + : viewId + ? VIEW_ISSUES(viewId.toString(), params) + : globalViewId + ? WORKSPACE_VIEW_ISSUES(globalViewId.toString(), workspaceViewParams) + : currentWorkspaceIssuePath + ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), currentWorkspaceIssuePath?.params) + : PROJECT_ISSUES_LIST_WITH_PARAMS(issue.project_detail.id, params); + + if (issue.parent) + mutate( + SUB_ISSUES(issue.parent.toString()), + (prevData) => { + if (!prevData) return prevData; + + return { + ...prevData, + sub_issues: (prevData.sub_issues ?? []).map((i) => { + if (i.id === issue.id) { + return { + ...i, + ...formData, + }; + } + return i; + }), + }; + }, + false + ); + else + mutate( + fetchKey, + (prevData) => + (prevData ?? []).map((p) => { + if (p.id === issue.id) { + return { + ...p, + ...formData, + }; + } + return p; + }), + false + ); + + issueService + .patchIssue(workspaceSlug as string, issue.project_detail.id, issue.id as string, formData, user) + .then(() => { + if (issue.parent) { + mutate(SUB_ISSUES(issue.parent as string)); + } else { + mutate(fetchKey); + + if (cycleId) mutate(CYCLE_DETAILS(cycleId as string)); + if (moduleId) mutate(MODULE_DETAILS(moduleId as string)); + } + }) + .catch((error) => { + console.log(error); + }); + }, + [ + workspaceSlug, + cycleId, + moduleId, + viewId, + globalViewId, + workspaceViewParams, + currentWorkspaceIssuePath, + params, + user, + ] + ); + + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; + + const handleOrderBy = (order: TIssueOrderByOptions, itemKey: string) => { + if (globalViewId) handleFilters("display_filters", { order_by: order }); + else setDisplayFilters({ order_by: order }); + setSelectedMenuItem(`${order}_${itemKey}`); + setActiveSortingProperty(order === "-created_at" ? "" : itemKey); + }; + + const renderColumn = ( + header: string, + propertyName: string, + Component: React.ComponentType, + ascendingOrder: TIssueOrderByOptions, + descendingOrder: TIssueOrderByOptions + ) => ( +
+
+ {currentWorkspaceIssuePath ? ( + {header} + ) : ( + + {activeSortingProperty === propertyName && ( +
+ +
+ )} + + {header} +
+ } + width="xl" + > + { + handleOrderBy(ascendingOrder, propertyName); + }} + > +
+
+ {propertyName === "assignee" || propertyName === "labels" ? ( + <> + + + + + A + + Z + + ) : propertyName === "due_date" || propertyName === "created_on" || propertyName === "updated_on" ? ( + <> + + + + + New + + Old + + ) : ( + <> + + + + + First + + Last + + )} +
+ + +
+
+ { + handleOrderBy(descendingOrder, propertyName); + }} + > +
+
+ {propertyName === "assignee" || propertyName === "labels" ? ( + <> + + + + + Z + + A + + ) : propertyName === "due_date" ? ( + <> + + + + + Old + + New + + ) : ( + <> + + + + + Last + + First + + )} +
+ + +
+
+ {selectedMenuItem && + selectedMenuItem !== "" && + displayFilters?.order_by !== "-created_at" && + selectedMenuItem.includes(propertyName) && ( + { + handleOrderBy("-created_at", propertyName); + }} + > +
+
+ + + + + Clear sorting +
+
+
+ )} + + )} +
+
+ {spreadsheetIssues.map((issue: IIssue, index) => ( + + ))} +
+
+ ); + + const handleScroll = () => { + if (containerRef.current) { + const scrollLeft = containerRef.current.scrollLeft; + setIsScrolled(scrollLeft > 0); + } + }; + + useEffect(() => { + const currentContainerRef = containerRef.current; + + if (currentContainerRef) { + currentContainerRef.addEventListener("scroll", handleScroll); + } + + return () => { + if (currentContainerRef) { + currentContainerRef.removeEventListener("scroll", handleScroll); + } + }; + }, []); + + return ( + <> + mutateIssues()} + projectId={currentProjectId ?? ""} + workspaceSlug={workspaceSlug?.toString() ?? ""} + readOnly={disableUserActions} + /> +
+
+
+ {spreadsheetIssues ? ( + <> +
+
+
+ {currentViewProperties.key && ( + ID + )} + Issue +
+ + {spreadsheetIssues.map((issue: IIssue, index) => ( + + ))} +
+
+ {currentViewProperties.state && + renderColumn("State", "state", SpreadsheetStateColumn, "state__name", "-state__name")} + + {currentViewProperties.priority && + renderColumn("Priority", "priority", SpreadsheetPriorityColumn, "priority", "-priority")} + {currentViewProperties.assignee && + renderColumn( + "Assignees", + "assignee", + SpreadsheetAssigneeColumn, + "assignees__first_name", + "-assignees__first_name" + )} + {currentViewProperties.labels && + renderColumn("Label", "labels", SpreadsheetLabelColumn, "labels__name", "-labels__name")} + {currentViewProperties.start_date && + renderColumn("Start Date", "start_date", SpreadsheetStartDateColumn, "-start_date", "start_date")} + {currentViewProperties.due_date && + renderColumn("Due Date", "due_date", SpreadsheetDueDateColumn, "-target_date", "target_date")} + {currentViewProperties.estimate && + renderColumn("Estimate", "estimate", SpreadsheetEstimateColumn, "estimate_point", "-estimate_point")} + {currentViewProperties.created_on && + renderColumn("Created On", "created_on", SpreadsheetCreatedOnColumn, "-created_at", "created_at")} + {currentViewProperties.updated_on && + renderColumn("Updated On", "updated_on", SpreadsheetUpdatedOnColumn, "-updated_at", "updated_at")} + + ) : ( +
+ +
+ )} +
+ +
+
+ setIsInlineCreateIssueFormOpen(false)} + prePopulatedData={{ + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + }} + /> +
+ + {type === "issue" + ? !disableUserActions && + !isInlineCreateIssueFormOpen && ( + + ) + : !disableUserActions && + !isInlineCreateIssueFormOpen && ( + + + New Issue + + } + position="left" + verticalPosition="top" + optionsClassName="left-5 !w-36" + noBorder + > + setIsInlineCreateIssueFormOpen(true)}> + Create new + + {openIssuesListModal && ( + Add an existing issue + )} + + )} +
+
+
+ + ); }; diff --git a/web/components/core/views/spreadsheet-view/start-date-column/index.ts b/web/components/core/views/spreadsheet-view/start-date-column/index.ts new file mode 100644 index 00000000000..94f2294987e --- /dev/null +++ b/web/components/core/views/spreadsheet-view/start-date-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-start-date-column"; +export * from "./start-date-column"; diff --git a/web/components/core/views/spreadsheet-view/start-date-column/spreadsheet-start-date-column.tsx b/web/components/core/views/spreadsheet-view/start-date-column/spreadsheet-start-date-column.tsx new file mode 100644 index 00000000000..064506ca238 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/start-date-column/spreadsheet-start-date-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { StartDateColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetStartDateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/start-date-column/start-date-column.tsx b/web/components/core/views/spreadsheet-view/start-date-column/start-date-column.tsx new file mode 100644 index 00000000000..6774ce1b918 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/start-date-column/start-date-column.tsx @@ -0,0 +1,38 @@ +import React from "react"; + +// components +import { ViewStartDateSelect } from "components/issues"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const StartDateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => ( +
+ + {properties.due_date && ( + + )} + +
+); diff --git a/web/components/core/views/spreadsheet-view/state-column/index.ts b/web/components/core/views/spreadsheet-view/state-column/index.ts new file mode 100644 index 00000000000..f3cbef871df --- /dev/null +++ b/web/components/core/views/spreadsheet-view/state-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-state-column"; +export * from "./state-column"; diff --git a/web/components/core/views/spreadsheet-view/state-column/spreadsheet-state-column.tsx b/web/components/core/views/spreadsheet-view/state-column/spreadsheet-state-column.tsx new file mode 100644 index 00000000000..606f3e28a28 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/state-column/spreadsheet-state-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { StateColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetStateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/state-column/state-column.tsx b/web/components/core/views/spreadsheet-view/state-column/state-column.tsx new file mode 100644 index 00000000000..04c833b1dea --- /dev/null +++ b/web/components/core/views/spreadsheet-view/state-column/state-column.tsx @@ -0,0 +1,87 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +// components +import { StateSelect } from "components/states"; +// services +import trackEventServices from "services/track-event.service"; +// types +import { ICurrentUserResponse, IIssue, IState, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const StateColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => { + const router = useRouter(); + + const { workspaceSlug } = router.query; + + const handleStateChange = (data: string, states: IState[] | undefined) => { + const oldState = states?.find((s) => s.id === issue.state); + const newState = states?.find((s) => s.id === data); + + partialUpdateIssue( + { + state: data, + state_detail: newState, + }, + issue + ); + trackEventServices.trackIssuePartialPropertyUpdateEvent( + { + workspaceSlug, + workspaceId: issue.workspace, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + "ISSUE_PROPERTY_UPDATE_STATE", + user + ); + if (oldState?.group !== "completed" && newState?.group !== "completed") { + trackEventServices.trackIssueMarkedAsDoneEvent( + { + workspaceSlug: issue.workspace_detail.slug, + workspaceId: issue.workspace_detail.id, + projectId: issue.project_detail.id, + projectIdentifier: issue.project_detail.identifier, + projectName: issue.project_detail.name, + issueId: issue.id, + }, + user + ); + } + }; + + return ( +
+ + {properties.state && ( + + )} + +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/updated-on-column/index.ts b/web/components/core/views/spreadsheet-view/updated-on-column/index.ts new file mode 100644 index 00000000000..af1337a7f02 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/updated-on-column/index.ts @@ -0,0 +1,2 @@ +export * from "./spreadsheet-updated-on-column"; +export * from "./updated-on-column"; diff --git a/web/components/core/views/spreadsheet-view/updated-on-column/spreadsheet-updated-on-column.tsx b/web/components/core/views/spreadsheet-view/updated-on-column/spreadsheet-updated-on-column.tsx new file mode 100644 index 00000000000..bb29e460d2a --- /dev/null +++ b/web/components/core/views/spreadsheet-view/updated-on-column/spreadsheet-updated-on-column.tsx @@ -0,0 +1,62 @@ +import React from "react"; + +// components +import { UpdatedOnColumn } from "components/core"; +// hooks +import useSubIssue from "hooks/use-sub-issue"; +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + expandedIssues: string[]; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const SpreadsheetUpdatedOnColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + expandedIssues, + properties, + user, + isNotAllowed, +}) => { + const isExpanded = expandedIssues.indexOf(issue.id) > -1; + + const { subIssues, isLoading } = useSubIssue(issue.project_detail.id, issue.id, isExpanded); + + return ( +
+ + + {isExpanded && + !isLoading && + subIssues && + subIssues.length > 0 && + subIssues.map((subIssue: IIssue) => ( + + ))} +
+ ); +}; diff --git a/web/components/core/views/spreadsheet-view/updated-on-column/updated-on-column.tsx b/web/components/core/views/spreadsheet-view/updated-on-column/updated-on-column.tsx new file mode 100644 index 00000000000..087d589fc85 --- /dev/null +++ b/web/components/core/views/spreadsheet-view/updated-on-column/updated-on-column.tsx @@ -0,0 +1,34 @@ +import React from "react"; + +// types +import { ICurrentUserResponse, IIssue, Properties } from "types"; +// helper +import { renderLongDetailDateFormat } from "helpers/date-time.helper"; + +type Props = { + issue: IIssue; + projectId: string; + partialUpdateIssue: (formData: Partial, issue: IIssue) => void; + properties: Properties; + user: ICurrentUserResponse | undefined; + isNotAllowed: boolean; +}; + +export const UpdatedOnColumn: React.FC = ({ + issue, + projectId, + partialUpdateIssue, + properties, + user, + isNotAllowed, +}) => ( +
+ + {properties.updated_on && ( +
+ {renderLongDetailDateFormat(issue.updated_at)} +
+ )} +
+
+); diff --git a/web/components/cycles/single-cycle-list.tsx b/web/components/cycles/single-cycle-list.tsx index ec01da9e760..a4c21128aa8 100644 --- a/web/components/cycles/single-cycle-list.tsx +++ b/web/components/cycles/single-cycle-list.tsx @@ -149,6 +149,10 @@ export const SingleCycleList: React.FC = ({ color: group.color, })); + const completedIssues = cycle.completed_issues + cycle.cancelled_issues; + + const percentage = cycle.total_issues > 0 ? (completedIssues / cycle.total_issues) * 100 : 0; + return (
@@ -307,7 +311,7 @@ export const SingleCycleList: React.FC = ({ ) : cycleStatus === "completed" ? ( - {100} % + {Math.round(percentage)} % ) : ( diff --git a/web/components/emoji-icon-picker/index.tsx b/web/components/emoji-icon-picker/index.tsx index d1f7fe65166..4cfe90d9f37 100644 --- a/web/components/emoji-icon-picker/index.tsx +++ b/web/components/emoji-icon-picker/index.tsx @@ -4,6 +4,7 @@ import { Tab, Transition, Popover } from "@headlessui/react"; // react colors import { TwitterPicker } from "react-color"; // hooks +import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; import useOutsideClickDetector from "hooks/use-outside-click-detector"; // types import { Props } from "./types"; @@ -33,6 +34,7 @@ const EmojiIconPicker: React.FC = (props) => { const [activeColor, setActiveColor] = useState("rgb(var(--color-text-200))"); const [recentEmojis, setRecentEmojis] = useState([]); + const buttonRef = useRef(null); const emojiPickerRef = useRef(null); useEffect(() => { @@ -44,14 +46,22 @@ const EmojiIconPicker: React.FC = (props) => { }, [value, onChange]); useOutsideClickDetector(emojiPickerRef, () => setIsOpen(false)); + useDynamicDropdownPosition(isOpen, () => setIsOpen(false), buttonRef, emojiPickerRef); return ( - setIsOpen((prev) => !prev)} className="outline-none" disabled={disabled}> + setIsOpen((prev) => !prev)} + className="outline-none" + disabled={disabled} + > {label} = (props) => { leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - -
+ +
{tabOptions.map((tab) => ( diff --git a/web/components/gantt-chart/chart/index.tsx b/web/components/gantt-chart/chart/index.tsx index aa79ae19c8e..e21e803de1f 100644 --- a/web/components/gantt-chart/chart/index.tsx +++ b/web/components/gantt-chart/chart/index.tsx @@ -1,4 +1,6 @@ import { FC, useEffect, useState } from "react"; +// next +import { useRouter } from "next/router"; // icons import { ArrowsPointingInIcon, ArrowsPointingOutIcon } from "@heroicons/react/20/solid"; // components @@ -11,6 +13,8 @@ import { GanttSidebar } from "../sidebar"; import { MonthChartView } from "./month"; // import { QuarterChartView } from "./quarter"; // import { YearChartView } from "./year"; +// icons +import { PlusIcon } from "lucide-react"; // views import { // generateHourChart, @@ -25,6 +29,7 @@ import { getNumberOfDaysBetweenTwoDatesInYear, getMonthChartItemPositionWidthInMonth, } from "../views"; +import { GanttInlineCreateIssueForm } from "components/core/views/gantt-chart-view/inline-create-issue-form"; // types import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types"; // data @@ -64,12 +69,20 @@ export const ChartViewRoot: FC = ({ const [itemsContainerWidth, setItemsContainerWidth] = useState(0); const [fullScreenMode, setFullScreenMode] = useState(false); + const [isCreateIssueFormOpen, setIsCreateIssueFormOpen] = useState(false); + // blocks state management starts const [chartBlocks, setChartBlocks] = useState(null); const { currentView, currentViewData, renderView, dispatch, allViews, updateScrollLeft } = useChart(); + const router = useRouter(); + const { cycleId, moduleId } = router.query; + + const isCyclePage = router.pathname.split("/")[4] === "cycles" && !cycleId; + const isModulePage = router.pathname.split("/")[4] === "modules" && !moduleId; + const renderBlockStructure = (view: any, blocks: IGanttBlock[] | null) => blocks && blocks.length > 0 ? blocks.map((block: any) => ({ @@ -294,9 +307,12 @@ export const ChartViewRoot: FC = ({ >
-
+
+
{title}
+
Duration
+
= ({ SidebarBlockRender={SidebarBlockRender} enableReorder={enableReorder} /> + {chartBlocks && !(isCyclePage || isModulePage) && ( +
+ setIsCreateIssueFormOpen(false)} + onSuccess={() => { + const ganttSidebar = document.getElementById(`gantt-sidebar-${cycleId}`); + + const timeoutId = setTimeout(() => { + if (ganttSidebar) + ganttSidebar.scrollBy({ + top: ganttSidebar.scrollHeight, + left: 0, + behavior: "smooth", + }); + clearTimeout(timeoutId); + }, 10); + }} + prePopulatedData={{ + start_date: new Date(Date.now()).toISOString().split("T")[0], + target_date: new Date(Date.now() + 86400000).toISOString().split("T")[0], + ...(cycleId && { cycle: cycleId.toString() }), + ...(moduleId && { module: moduleId.toString() }), + }} + /> + + {!isCreateIssueFormOpen && ( + + )} +
+ )}
= ({ if (e.button !== 0) return; - e.preventDefault(); - e.stopPropagation(); - - setIsMoving(true); - const resizableDiv = resizableRef.current; const columnWidth = currentViewData.data.width; @@ -193,6 +188,8 @@ export const ChartDraggable: React.FC = ({ let initialMarginLeft = parseInt(resizableDiv.style.marginLeft); const handleMouseMove = (e: MouseEvent) => { + setIsMoving(true); + let delWidth = 0; delWidth = checkScrollEnd(e); @@ -295,7 +292,9 @@ export const ChartDraggable: React.FC = ({ )}
diff --git a/web/components/gantt-chart/sidebar.tsx b/web/components/gantt-chart/sidebar.tsx index 92e7a603d9f..2aec274d9e2 100644 --- a/web/components/gantt-chart/sidebar.tsx +++ b/web/components/gantt-chart/sidebar.tsx @@ -1,3 +1,4 @@ +import { useRouter } from "next/router"; // react-beautiful-dnd import { DragDropContext, Draggable, DropResult } from "react-beautiful-dnd"; import StrictModeDroppable from "components/dnd/StrictModeDroppable"; @@ -7,6 +8,8 @@ import { useChart } from "./hooks"; import { Loader } from "components/ui"; // icons import { EllipsisVerticalIcon } from "@heroicons/react/24/outline"; +// helpers +import { findTotalDaysInRange } from "helpers/date-time.helper"; // types import { IBlockUpdateData, IGanttBlock } from "./types"; @@ -18,13 +21,12 @@ type Props = { enableReorder: boolean; }; -export const GanttSidebar: React.FC = ({ - title, - blockUpdateHandler, - blocks, - SidebarBlockRender, - enableReorder, -}) => { +export const GanttSidebar: React.FC = (props) => { + const { title, blockUpdateHandler, blocks, SidebarBlockRender, enableReorder } = props; + + const router = useRouter(); + const { cycleId } = router.query; + const { activeBlock, dispatch } = useChart(); // update the active block on hover @@ -85,14 +87,21 @@ export const GanttSidebar: React.FC = ({ {(droppableProvided) => (
<> {blocks ? ( - blocks.length > 0 ? ( - blocks.map((block, index) => ( + blocks.map((block, index) => { + const duration = findTotalDaysInRange( + block.start_date ?? "", + block.target_date ?? "", + true + ); + + return ( = ({ )} -
- +
+
+ +
+
+ {duration} day{duration > 1 ? "s" : ""} +
)} - )) - ) : ( -
- No {title} found -
- ) + ); + }) ) : ( diff --git a/web/components/icons/state/backlog.tsx b/web/components/icons/state/backlog.tsx index b6378b82d12..369692c2011 100644 --- a/web/components/icons/state/backlog.tsx +++ b/web/components/icons/state/backlog.tsx @@ -15,10 +15,28 @@ export const StateGroupBacklogIcon: React.FC = ({ height={height} width={width} className={className} - viewBox="0 0 12 12" - fill="none" xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 323.15 323.03" > - + + + + + + + + ); diff --git a/web/components/icons/state/started.tsx b/web/components/icons/state/started.tsx index 7bc39f9f71a..f4796548baa 100644 --- a/web/components/icons/state/started.tsx +++ b/web/components/icons/state/started.tsx @@ -9,17 +9,38 @@ export const StateGroupStartedIcon: React.FC = ({ width = "20", height = "20", className, - color = "#f59e0b", + color = "#f39e1f", }) => ( - - + + + + + + + + + ); diff --git a/web/components/issues/activity.tsx b/web/components/issues/activity.tsx index fe322afe91d..e6f54f51246 100644 --- a/web/components/issues/activity.tsx +++ b/web/components/issues/activity.tsx @@ -7,9 +7,9 @@ import { useRouter } from "next/router"; import { ActivityIcon, ActivityMessage } from "components/core"; import { CommentCard } from "components/issues/comment"; // ui -import { Icon, Loader } from "components/ui"; +import { Icon, Loader, Tooltip } from "components/ui"; // helpers -import { timeAgo } from "helpers/date-time.helper"; +import { render24HourFormatTime, renderLongDateFormat, timeAgo } from "helpers/date-time.helper"; // types import { IIssueActivity, IIssueComment } from "types"; @@ -120,9 +120,15 @@ export const IssueActivitySection: React.FC = ({ )}{" "} {message}{" "} - - {timeAgo(activityItem.created_at)} - + + + {timeAgo(activityItem.created_at)} + +
diff --git a/web/components/issues/delete-draft-issue-modal.tsx b/web/components/issues/delete-draft-issue-modal.tsx index 8928fe51c9f..e7f748975ef 100644 --- a/web/components/issues/delete-draft-issue-modal.tsx +++ b/web/components/issues/delete-draft-issue-modal.tsx @@ -4,6 +4,8 @@ import { useRouter } from "next/router"; import { mutate } from "swr"; +import useUser from "hooks/use-user"; + // headless ui import { Dialog, Transition } from "@headlessui/react"; // services @@ -16,7 +18,7 @@ import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; // ui import { SecondaryButton, DangerButton } from "components/ui"; // types -import type { IIssue, ICurrentUserResponse } from "types"; +import type { IIssue } from "types"; // fetch-keys import { PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS } from "constants/fetch-keys"; @@ -24,12 +26,11 @@ type Props = { isOpen: boolean; handleClose: () => void; data: IIssue | null; - user?: ICurrentUserResponse; onSubmit?: () => Promise | void; }; export const DeleteDraftIssueModal: React.FC = (props) => { - const { isOpen, handleClose, data, user, onSubmit } = props; + const { isOpen, handleClose, data, onSubmit } = props; const [isDeleteLoading, setIsDeleteLoading] = useState(false); @@ -40,6 +41,8 @@ export const DeleteDraftIssueModal: React.FC = (props) => { const { setToastAlert } = useToast(); + const { user } = useUser(); + useEffect(() => { setIsDeleteLoading(false); }, [isOpen]); diff --git a/web/components/issues/draft-issue-form.tsx b/web/components/issues/draft-issue-form.tsx index aac5dede752..bf6d511e3da 100644 --- a/web/components/issues/draft-issue-form.tsx +++ b/web/components/issues/draft-issue-form.tsx @@ -8,6 +8,7 @@ import { Controller, useForm } from "react-hook-form"; import aiService from "services/ai.service"; // hooks import useToast from "hooks/use-toast"; +import useLocalStorage from "hooks/use-local-storage"; // components import { GptAssistantModal } from "components/core"; import { ParentIssuesListModal } from "components/issues"; @@ -60,12 +61,14 @@ interface IssueFormProps { action?: "createDraft" | "createNewIssue" | "updateDraft" | "convertToNewIssue" ) => Promise; data?: Partial | null; + isOpen: boolean; prePopulatedData?: Partial | null; projectId: string; setActiveProject: React.Dispatch>; createMore: boolean; setCreateMore: React.Dispatch>; handleClose: () => void; + handleDiscard: () => void; status: boolean; user: ICurrentUserResponse | undefined; fieldsToShow: ( @@ -88,6 +91,7 @@ export const DraftIssueForm: FC = (props) => { const { handleFormSubmit, data, + isOpen, prePopulatedData, projectId, setActiveProject, @@ -97,6 +101,7 @@ export const DraftIssueForm: FC = (props) => { status, user, fieldsToShow, + handleDiscard, } = props; const [stateModal, setStateModal] = useState(false); @@ -107,6 +112,8 @@ export const DraftIssueForm: FC = (props) => { const [gptAssistantModal, setGptAssistantModal] = useState(false); const [iAmFeelingLucky, setIAmFeelingLucky] = useState(false); + const { setValue: setLocalStorageValue } = useLocalStorage("draftedIssue", {}); + const editorRef = useRef(null); const router = useRouter(); @@ -131,6 +138,33 @@ export const DraftIssueForm: FC = (props) => { const issueName = watch("name"); + const payload: Partial = { + name: watch("name"), + description: watch("description"), + description_html: watch("description_html"), + state: watch("state"), + priority: watch("priority"), + assignees: watch("assignees"), + labels: watch("labels"), + start_date: watch("start_date"), + target_date: watch("target_date"), + project: watch("project"), + parent: watch("parent"), + cycle: watch("cycle"), + module: watch("module"), + }; + + useEffect(() => { + if (!isOpen || data) return; + + setLocalStorageValue( + JSON.stringify({ + ...payload, + }) + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [JSON.stringify(payload), isOpen, data]); + const onClose = () => { handleClose(); }; @@ -206,9 +240,7 @@ export const DraftIssueForm: FC = (props) => { setToastAlert({ type: "error", title: "Error!", - message: - error || - "You have reached the maximum number of requests of 50 requests per month per user.", + message: error || "You have reached the maximum number of requests of 50 requests per month per user.", }); else setToastAlert({ @@ -271,7 +303,7 @@ export const DraftIssueForm: FC = (props) => { )}
- handleCreateUpdateIssue(formData, "convertToNewIssue") + handleCreateUpdateIssue(formData, data ? "convertToNewIssue" : "createDraft") )} >
@@ -309,9 +341,7 @@ export const DraftIssueForm: FC = (props) => { {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} - - {selectedParentIssue.name.substring(0, 50)} - + {selectedParentIssue.name.substring(0, 50)} { @@ -386,9 +416,7 @@ export const DraftIssueForm: FC = (props) => { ref={editorRef} debouncedUpdatesEnabled={false} value={ - !value || - value === "" || - (typeof value === "object" && Object.keys(value).length === 0) + !value || value === "" || (typeof value === "object" && Object.keys(value).length === 0) ? watch("description_html") : value } @@ -447,11 +475,7 @@ export const DraftIssueForm: FC = (props) => { control={control} name="assignees" render={({ field: { value, onChange } }) => ( - + )} /> )} @@ -533,24 +557,15 @@ export const DraftIssueForm: FC = (props) => { {watch("parent") ? ( <> - setParentIssueListModalOpen(true)} - > + setParentIssueListModalOpen(true)}> Change parent issue - setValue("parent", null)} - > + setValue("parent", null)}> Remove parent issue ) : ( - setParentIssueListModalOpen(true)} - > + setParentIssueListModalOpen(true)}> Select Parent Issue )} @@ -569,7 +584,7 @@ export const DraftIssueForm: FC = (props) => { {}} size="md" />
- Discard + Discard diff --git a/web/components/issues/draft-issue-modal.tsx b/web/components/issues/draft-issue-modal.tsx index 3cd5b50cf0d..6ebb4fea819 100644 --- a/web/components/issues/draft-issue-modal.tsx +++ b/web/components/issues/draft-issue-modal.tsx @@ -93,6 +93,40 @@ export const CreateUpdateDraftIssueModal: React.FC = (props) = setActiveProject(null); }; + const onDiscard = () => { + clearDraftIssueLocalStorage(); + onClose(); + }; + + useEffect(() => { + setPreloadedData(prePopulateDataProps ?? {}); + + if (cycleId && !prePopulateDataProps?.cycle) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + cycle: cycleId.toString(), + })); + } + if (moduleId && !prePopulateDataProps?.module) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + module: moduleId.toString(), + })); + } + if ( + (router.asPath.includes("my-issues") || router.asPath.includes("assigned")) && + !prePopulateDataProps?.assignees + ) { + setPreloadedData((prevData) => ({ + ...(prevData ?? {}), + ...prePopulateDataProps, + assignees: prePopulateDataProps?.assignees ?? [user?.id ?? ""], + })); + } + }, [prePopulateDataProps, cycleId, moduleId, router.asPath, user?.id]); + useEffect(() => { setPreloadedData(prePopulateDataProps ?? {}); @@ -136,7 +170,7 @@ export const CreateUpdateDraftIssueModal: React.FC = (props) = if (prePopulateData && prePopulateData.project && !activeProject) return setActiveProject(prePopulateData.project); - if (prePopulateData && prePopulateData.project) return setActiveProject(prePopulateData.project); + if (prePopulateData && prePopulateData.project && !activeProject) return setActiveProject(prePopulateData.project); // if data is not present, set active project to the project // in the url. This has the least priority. @@ -158,14 +192,8 @@ export const CreateUpdateDraftIssueModal: React.FC = (props) = await issuesService .createDraftIssue(workspaceSlug as string, activeProject ?? "", payload, user) .then(async () => { - mutate(PROJECT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); mutate(PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS(activeProject ?? "", params)); - if (displayFilters.layout === "gantt_chart") - mutate(ganttFetchKey, { - start_target_date: true, - order_by: "sort_order", - }); if (groupedIssues) mutateMyIssues(); setToastAlert({ @@ -176,8 +204,6 @@ export const CreateUpdateDraftIssueModal: React.FC = (props) = if (payload.assignees_list?.some((assignee) => assignee === user?.id)) mutate(USER_ISSUE(workspaceSlug as string)); - - if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); }) .catch(() => { setToastAlert({ @@ -361,12 +387,14 @@ export const CreateUpdateDraftIssueModal: React.FC = (props) = > = (props) => { setToastAlert({ type: "error", title: "Error!", - message: - error || - "You have reached the maximum number of requests of 50 requests per month per user.", + message: error || "You have reached the maximum number of requests of 50 requests per month per user.", }); else setToastAlert({ @@ -308,9 +306,7 @@ export const IssueForm: FC = (props) => { {selectedParentIssue.project__identifier}-{selectedParentIssue.sequence_id} - - {selectedParentIssue.name.substring(0, 50)} - + {selectedParentIssue.name.substring(0, 50)} { @@ -385,9 +381,7 @@ export const IssueForm: FC = (props) => { ref={editorRef} debouncedUpdatesEnabled={false} value={ - !value || - value === "" || - (typeof value === "object" && Object.keys(value).length === 0) + !value || value === "" || (typeof value === "object" && Object.keys(value).length === 0) ? watch("description_html") : value } @@ -446,11 +440,7 @@ export const IssueForm: FC = (props) => { control={control} name="assignees" render={({ field: { value, onChange } }) => ( - + )} /> )} @@ -532,24 +522,15 @@ export const IssueForm: FC = (props) => { {watch("parent") ? ( <> - setParentIssueListModalOpen(true)} - > + setParentIssueListModalOpen(true)}> Change parent issue - setValue("parent", null)} - > + setValue("parent", null)}> Remove parent issue ) : ( - setParentIssueListModalOpen(true)} - > + setParentIssueListModalOpen(true)}> Select Parent Issue )} diff --git a/web/components/issues/issue-layouts/gantt/blocks.tsx b/web/components/issues/issue-layouts/gantt/blocks.tsx index ef4919780cb..3364565a3aa 100644 --- a/web/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/components/issues/issue-layouts/gantt/blocks.tsx @@ -5,7 +5,7 @@ import { Tooltip } from "components/ui"; // icons import { StateGroupIcon } from "components/icons"; // helpers -import { findTotalDaysInRange, renderShortDate } from "helpers/date-time.helper"; +import { renderShortDate } from "helpers/date-time.helper"; // types import { IIssue } from "types"; @@ -52,8 +52,6 @@ export const IssueGanttBlock = ({ data }: { data: IIssue }) => { export const IssueGanttSidebarBlock = ({ data }: { data: IIssue }) => { const router = useRouter(); - const duration = findTotalDaysInRange(data?.start_date ?? "", data?.target_date ?? "", true); - const openPeekOverview = () => { const { query } = router; @@ -72,12 +70,7 @@ export const IssueGanttSidebarBlock = ({ data }: { data: IIssue }) => {
{data?.project_detail?.identifier} {data?.sequence_id}
-
-
{data?.name}
- - {duration} day{duration > 1 ? "s" : ""} - -
+
{data?.name}
); }; diff --git a/web/components/issues/main-content.tsx b/web/components/issues/main-content.tsx index e5f2c651cd0..06f1ede4a5a 100644 --- a/web/components/issues/main-content.tsx +++ b/web/components/issues/main-content.tsx @@ -39,7 +39,7 @@ type Props = { export const IssueMainContent: React.FC = ({ issueDetails, submitChanges, uneditable = false }) => { const router = useRouter(); - const { workspaceSlug, projectId, issueId, archivedIssueId } = router.query; + const { workspaceSlug, projectId, issueId } = router.query; const { setToastAlert } = useToast(); @@ -170,7 +170,7 @@ export const IssueMainContent: React.FC = ({ issueDetails, submitChanges,
- +
diff --git a/web/components/issues/modal.tsx b/web/components/issues/modal.tsx index d483698d668..e57b6f22364 100644 --- a/web/components/issues/modal.tsx +++ b/web/components/issues/modal.tsx @@ -18,6 +18,7 @@ import useInboxView from "hooks/use-inbox-view"; import useProjects from "hooks/use-projects"; import useMyIssues from "hooks/my-issues/use-my-issues"; import useLocalStorage from "hooks/use-local-storage"; +import { useWorkspaceView } from "hooks/use-workspace-view"; // components import { IssueForm, ConfirmIssueDiscard } from "components/issues"; // types @@ -35,6 +36,7 @@ import { VIEW_ISSUES, INBOX_ISSUES, PROJECT_DRAFT_ISSUES_LIST_WITH_PARAMS, + WORKSPACE_VIEW_ISSUES, } from "constants/fetch-keys"; // constants import { INBOX_ISSUE_SOURCE } from "constants/inbox"; @@ -79,7 +81,7 @@ export const CreateUpdateIssueModal: React.FC = ({ const [prePopulateData, setPreloadedData] = useState>({}); const router = useRouter(); - const { workspaceSlug, projectId, cycleId, moduleId, viewId, inboxId } = router.query; + const { workspaceSlug, projectId, cycleId, moduleId, viewId, globalViewId, inboxId } = router.query; const { displayFilters, params } = useIssuesView(); const { ...viewGanttParams } = params; @@ -90,6 +92,8 @@ export const CreateUpdateIssueModal: React.FC = ({ const { groupedIssues, mutateMyIssues } = useMyIssues(workspaceSlug?.toString()); + const { params: globalViewParams } = useWorkspaceView(); + const { setValue: setValueInLocalStorage, clearValue: clearLocalStorageValue } = useLocalStorage( "draftedIssue", {} @@ -268,6 +272,38 @@ export const CreateUpdateIssueModal: React.FC = ({ }); }; + const workspaceIssuesPath = [ + { + params: { + sub_issue: false, + }, + path: "workspace-views/all-issues", + }, + { + params: { + assignees: user?.id ?? undefined, + sub_issue: false, + }, + path: "workspace-views/assigned", + }, + { + params: { + created_by: user?.id ?? undefined, + sub_issue: false, + }, + path: "workspace-views/created", + }, + { + params: { + subscriber: user?.id ?? undefined, + sub_issue: false, + }, + path: "workspace-views/subscribed", + }, + ]; + + const currentWorkspaceIssuePath = workspaceIssuesPath.find((path) => router.pathname.includes(path.path)); + const ganttFetchKey = cycleId ? CYCLE_ISSUES_WITH_PARAMS(cycleId.toString()) : moduleId @@ -305,6 +341,11 @@ export const CreateUpdateIssueModal: React.FC = ({ mutate(USER_ISSUE(workspaceSlug as string)); if (payload.parent && payload.parent !== "") mutate(SUB_ISSUES(payload.parent)); + + if (globalViewId) mutate(WORKSPACE_VIEW_ISSUES(globalViewId.toString(), globalViewParams)); + + if (currentWorkspaceIssuePath) + mutate(WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), currentWorkspaceIssuePath?.params)); }) .catch(() => { setToastAlert({ diff --git a/web/components/issues/my-issues/my-issues-view-options.tsx b/web/components/issues/my-issues/my-issues-view-options.tsx index f9aa62c2fe0..e251e61ac98 100644 --- a/web/components/issues/my-issues/my-issues-view-options.tsx +++ b/web/components/issues/my-issues/my-issues-view-options.tsx @@ -2,25 +2,20 @@ import React from "react"; import { useRouter } from "next/router"; -// headless ui -import { Popover, Transition } from "@headlessui/react"; // hooks import useMyIssuesFilters from "hooks/my-issues/use-my-issues-filter"; -import useEstimateOption from "hooks/use-estimate-option"; // components import { MyIssuesSelectFilters } from "components/issues"; // ui -import { CustomMenu, ToggleSwitch, Tooltip } from "components/ui"; +import { Tooltip } from "components/ui"; // icons -import { ChevronDownIcon } from "@heroicons/react/24/outline"; -import { FormatListBulletedOutlined, GridViewOutlined } from "@mui/icons-material"; +import { FormatListBulletedOutlined } from "@mui/icons-material"; +import { CreditCard } from "lucide-react"; // helpers import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; import { checkIfArraysHaveSameElements } from "helpers/array.helper"; // types -import { Properties, TIssueLayouts } from "types"; -// constants -import { GROUP_BY_OPTIONS, ORDER_BY_OPTIONS, FILTER_ISSUE_OPTIONS } from "constants/issue"; +import { TIssueLayouts } from "types"; const issueViewOptions: { type: TIssueLayouts; Icon: any }[] = [ { @@ -28,20 +23,22 @@ const issueViewOptions: { type: TIssueLayouts; Icon: any }[] = [ Icon: FormatListBulletedOutlined, }, { - type: "kanban", - Icon: GridViewOutlined, + type: "spreadsheet", + Icon: CreditCard, }, ]; export const MyIssuesViewOptions: React.FC = () => { const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, globalViewId } = router.query; - const { displayFilters, setDisplayFilters, properties, setProperty, filters, setFilters } = useMyIssuesFilters( - workspaceSlug?.toString() - ); + const { displayFilters, setDisplayFilters, filters, setFilters } = useMyIssuesFilters(workspaceSlug?.toString()); + + const workspaceViewPathName = ["workspace-views/all-issues"]; + + const isWorkspaceViewPath = workspaceViewPathName.some((pathname) => router.pathname.includes(pathname)); - const { isEstimateActive } = useEstimateOption(); + const showFilters = isWorkspaceViewPath || globalViewId; return (
@@ -54,220 +51,56 @@ export const MyIssuesViewOptions: React.FC = () => { > ))}
- { - const key = option.key as keyof typeof filters; + {showFilters && ( + { + const key = option.key as keyof typeof filters; - if (key === "start_date" || key === "target_date") { - const valueExists = checkIfArraysHaveSameElements(filters?.[key] ?? [], option.value); + if (key === "start_date" || key === "target_date") { + const valueExists = checkIfArraysHaveSameElements(filters?.[key] ?? [], option.value); - setFilters({ - [key]: valueExists ? null : option.value, - }); - } else { - const valueExists = filters[key]?.includes(option.value); - - if (valueExists) setFilters({ - [option.key]: ((filters[key] ?? []) as any[])?.filter((val) => val !== option.value), + [key]: valueExists ? null : option.value, }); - else - setFilters({ - [option.key]: [...((filters[key] ?? []) as any[]), option.value], - }); - } - }} - direction="left" - height="rg" - /> - - {({ open }) => ( - <> - - Display - - - - -
-
- {displayFilters?.layout !== "calendar" && displayFilters?.layout !== "spreadsheet" && ( - <> -
-

Group by

-
- option.key === displayFilters?.group_by)?.name ?? - "Select" - } - className="!w-full" - buttonClassName="w-full" - > - {GROUP_BY_OPTIONS.map((option) => { - if (displayFilters?.layout === "kanban" && option.key === null) return null; - if (option.key === "state" || option.key === "created_by" || option.key === "assignees") - return null; - - return ( - setDisplayFilters({ group_by: option.key })} - > - {option.name} - - ); - })} - -
-
-
-

Order by

-
- option.key === displayFilters?.order_by)?.name ?? - "Select" - } - className="!w-full" - buttonClassName="w-full" - > - {ORDER_BY_OPTIONS.map((option) => { - if (displayFilters?.group_by === "priority" && option.key === "priority") return null; - if (option.key === "sort_order") return null; - - return ( - { - setDisplayFilters({ order_by: option.key }); - }} - > - {option.name} - - ); - })} - -
-
- - )} -
-

Issue type

-
- option.key === displayFilters?.type)?.name ?? "Select" - } - className="!w-full" - buttonClassName="w-full" - > - {FILTER_ISSUE_OPTIONS.map((option) => ( - - setDisplayFilters({ - type: option.key, - }) - } - > - {option.name} - - ))} - -
-
- - {displayFilters?.layout !== "calendar" && displayFilters?.layout !== "spreadsheet" && ( - <> -
-

Show empty states

-
- - setDisplayFilters({ - show_empty_groups: !displayFilters?.show_empty_groups, - }) - } - /> -
-
- - )} -
- -
-

Display Properties

-
- {Object.keys(properties).map((key) => { - if (key === "estimate" && !isEstimateActive) return null; - - if ( - displayFilters?.layout === "spreadsheet" && - (key === "attachment_count" || key === "link" || key === "sub_issue_count") - ) - return null; - - if (displayFilters?.layout !== "spreadsheet" && (key === "created_on" || key === "updated_on")) - return null; - - return ( - - ); - })} -
-
-
-
-
- - )} -
+ } else { + const valueExists = filters[key]?.includes(option.value); + + if (valueExists) + setFilters({ + [option.key]: ((filters[key] ?? []) as any[])?.filter((val) => val !== option.value), + }); + else + setFilters({ + [option.key]: [...((filters[key] ?? []) as any[]), option.value], + }); + } + }} + direction="left" + height="rg" + /> + )}
); }; diff --git a/web/components/issues/sidebar-select/blocked.tsx b/web/components/issues/sidebar-select/blocked.tsx index c65d10437c4..94e2f7a7ddd 100644 --- a/web/components/issues/sidebar-select/blocked.tsx +++ b/web/components/issues/sidebar-select/blocked.tsx @@ -14,7 +14,7 @@ import { ExistingIssuesListModal } from "components/core"; import { XMarkIcon } from "@heroicons/react/24/outline"; import { BlockedIcon } from "components/icons"; // types -import { BlockeIssueDetail, IIssue, ISearchIssueResponse, UserAuth } from "types"; +import { BlockeIssueDetail, IIssue, ISearchIssueResponse } from "types"; type Props = { issueId?: string; @@ -36,6 +36,8 @@ export const SidebarBlockedSelect: React.FC = ({ issueId, submitChanges, setIsBlockedModalOpen(false); }; + const blockedByIssue = watch("related_issues")?.filter((i) => i.relation_type === "blocked_by") || []; + const onSubmit = async (data: ISearchIssueResponse[]) => { if (data.length === 0) { setToastAlert({ @@ -75,15 +77,13 @@ export const SidebarBlockedSelect: React.FC = ({ issueId, submitChanges, }) .then((response) => { submitChanges({ - related_issues: [...watch("related_issues")?.filter((i) => i.relation_type !== "blocked_by"), ...response], + related_issues: [...watch("related_issues"), ...response], }); }); handleClose(); }; - const blockedByIssue = watch("related_issues")?.filter((i) => i.relation_type === "blocked_by"); - return ( <> = (props) => { })), ], }) - .then((response) => { - submitChanges({ - related_issues: [...watch("related_issues"), ...(response ?? [])], - }); + .then(() => { + submitChanges(); }); handleClose(); diff --git a/web/components/issues/sidebar-select/relates-to.tsx b/web/components/issues/sidebar-select/relates-to.tsx index 2f72d4b98a9..455a388e389 100644 --- a/web/components/issues/sidebar-select/relates-to.tsx +++ b/web/components/issues/sidebar-select/relates-to.tsx @@ -75,10 +75,8 @@ export const SidebarRelatesSelect: React.FC = (props) => { })), ], }) - .then((response) => { - submitChanges({ - related_issues: [...watch("related_issues"), ...(response ?? [])], - }); + .then(() => { + submitChanges(); }); handleClose(); diff --git a/web/components/issues/sidebar.tsx b/web/components/issues/sidebar.tsx index c1bc6811f7e..9b4192175ce 100644 --- a/web/components/issues/sidebar.tsx +++ b/web/components/issues/sidebar.tsx @@ -53,7 +53,7 @@ import { copyTextToClipboard } from "helpers/string.helper"; // types import type { ICycle, IIssue, IIssueLink, linkDetails, IModule } from "types"; // fetch-keys -import { ISSUE_DETAILS } from "constants/fetch-keys"; +import { ISSUE_DETAILS, PROJECT_ISSUES_ACTIVITY } from "constants/fetch-keys"; import { ContrastIcon } from "components/icons"; type Props = { @@ -469,6 +469,7 @@ export const IssueDetailsSidebar: React.FC = ({ }, false ); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} @@ -489,6 +490,7 @@ export const IssueDetailsSidebar: React.FC = ({ }, false ); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} @@ -506,6 +508,7 @@ export const IssueDetailsSidebar: React.FC = ({ ...data, }; }); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} @@ -523,6 +526,7 @@ export const IssueDetailsSidebar: React.FC = ({ ...data, }; }); + mutate(PROJECT_ISSUES_ACTIVITY(issueId as string)); }} watch={watchIssue} disabled={memberRole.isGuest || memberRole.isViewer || uneditable} diff --git a/web/components/issues/sub-issues-list.tsx b/web/components/issues/sub-issues-list.tsx deleted file mode 100644 index fbb16d5c8d4..00000000000 --- a/web/components/issues/sub-issues-list.tsx +++ /dev/null @@ -1,243 +0,0 @@ -import { FC, useState } from "react"; - -import Link from "next/link"; -import { useRouter } from "next/router"; - -import useSWR, { mutate } from "swr"; - -// headless ui -import { Disclosure, Transition } from "@headlessui/react"; -// services -import issuesService from "services/issue.service"; -// contexts -import { useProjectMyMembership } from "contexts/project-member.context"; -// components -import { ExistingIssuesListModal } from "components/core"; -import { CreateUpdateIssueModal } from "components/issues"; -// ui -import { CustomMenu } from "components/ui"; -// icons -import { ChevronRightIcon, PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; -// types -import { ICurrentUserResponse, IIssue, ISearchIssueResponse, ISubIssueResponse } from "types"; -// fetch-keys -import { SUB_ISSUES } from "constants/fetch-keys"; - -type Props = { - parentIssue: IIssue; - user: ICurrentUserResponse | undefined; - disabled?: boolean; -}; - -export const SubIssuesList: FC = ({ parentIssue, user, disabled = false }) => { - // states - const [createIssueModal, setCreateIssueModal] = useState(false); - const [subIssuesListModal, setSubIssuesListModal] = useState(false); - const [preloadedData, setPreloadedData] = useState | null>(null); - - const router = useRouter(); - const { workspaceSlug } = router.query; - - const { memberRole } = useProjectMyMembership(); - - const { data: subIssuesResponse } = useSWR( - workspaceSlug && parentIssue ? SUB_ISSUES(parentIssue.id) : null, - workspaceSlug && parentIssue - ? () => issuesService.subIssues(workspaceSlug as string, parentIssue.project, parentIssue.id) - : null - ); - - const addAsSubIssue = async (data: ISearchIssueResponse[]) => { - if (!workspaceSlug || !parentIssue) return; - - const payload = { - sub_issue_ids: data.map((i) => i.id), - }; - - await issuesService - .addSubIssues(workspaceSlug as string, parentIssue.project, parentIssue.id, payload) - .finally(() => mutate(SUB_ISSUES(parentIssue.id))); - }; - - const handleSubIssueRemove = (issue: IIssue) => { - if (!workspaceSlug || !parentIssue) return; - - mutate( - SUB_ISSUES(parentIssue.id), - (prevData) => { - if (!prevData) return prevData; - - const stateDistribution = { ...prevData.state_distribution }; - - const issueGroup = issue.state_detail.group; - stateDistribution[issueGroup] = stateDistribution[issueGroup] - 1; - - return { - state_distribution: stateDistribution, - sub_issues: prevData.sub_issues.filter((i) => i.id !== issue.id), - }; - }, - false - ); - - issuesService - .patchIssue(workspaceSlug.toString(), issue.project, issue.id, { parent: null }, user) - .finally(() => mutate(SUB_ISSUES(parentIssue.id))); - }; - - const handleCreateIssueModal = () => { - setCreateIssueModal(true); - - setPreloadedData({ - parent: parentIssue.id, - }); - }; - - const completedSubIssue = subIssuesResponse?.state_distribution.completed ?? 0; - const cancelledSubIssue = subIssuesResponse?.state_distribution.cancelled ?? 0; - - const totalCompletedSubIssues = completedSubIssue + cancelledSubIssue; - - const totalSubIssues = subIssuesResponse ? subIssuesResponse.sub_issues.length : 0; - - const completionPercentage = (totalCompletedSubIssues / totalSubIssues) * 100; - - const isNotAllowed = memberRole.isGuest || memberRole.isViewer || disabled; - - return ( - <> - setCreateIssueModal(false)} - /> - setSubIssuesListModal(false)} - searchParams={{ sub_issue: true, issue_id: parentIssue?.id }} - handleOnSubmit={addAsSubIssue} - workspaceLevelToggle - /> - {subIssuesResponse && subIssuesResponse.sub_issues.length > 0 ? ( - - {({ open }) => ( - <> -
-
- - - Sub-issues {subIssuesResponse.sub_issues.length} - -
-
-
100 - ? 100 - : completionPercentage.toFixed(0) - }%`, - }} - /> -
- - {isNaN(completionPercentage) - ? 0 - : completionPercentage > 100 - ? 100 - : completionPercentage.toFixed(0)} - % Done - -
-
- - {open && !isNotAllowed ? ( -
- - - - setSubIssuesListModal(true)}> - Add an existing issue - - -
- ) : null} -
- - - {subIssuesResponse.sub_issues.map((issue) => ( - - -
- - - {issue.project_detail.identifier}-{issue.sequence_id} - - {issue.name} -
- - {!isNotAllowed && ( - - )} -
- - ))} -
-
- - )} - - ) : ( - !isNotAllowed && ( - - - Add sub-issue - - } - buttonClassName="whitespace-nowrap" - position="left" - noBorder - noChevron - > - Create new - setSubIssuesListModal(true)}>Add an existing issue - - ) - )} - - ); -}; diff --git a/web/components/issues/sub-issues/issue.tsx b/web/components/issues/sub-issues/issue.tsx index 2e3d8acdbb3..37431751aae 100644 --- a/web/components/issues/sub-issues/issue.tsx +++ b/web/components/issues/sub-issues/issue.tsx @@ -1,24 +1,21 @@ import React from "react"; // next imports -import Link from "next/link"; +import { useRouter } from "next/router"; +// swr +import { mutate } from "swr"; // lucide icons -import { - ChevronDown, - ChevronRight, - X, - Pencil, - Trash, - Link as LinkIcon, - Loader, -} from "lucide-react"; +import { ChevronDown, ChevronRight, X, Pencil, Trash, Link as LinkIcon, Loader } from "lucide-react"; // components +import { IssuePeekOverview } from "components/issues/peek-overview"; import { SubIssuesRootList } from "./issues-list"; import { IssueProperty } from "./properties"; // ui import { Tooltip, CustomMenu } from "components/ui"; - // types import { ICurrentUserResponse, IIssue } from "types"; +import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root"; +// fetch keys +import { SUB_ISSUES } from "constants/fetch-keys"; export interface ISubIssues { workspaceSlug: string; @@ -29,8 +26,8 @@ export interface ISubIssues { user: ICurrentUserResponse | undefined; editable: boolean; removeIssueFromSubIssues: (parentIssueId: string, issue: IIssue) => void; - issuesVisibility: string[]; - handleIssuesVisibility: (issueId: string) => void; + issuesLoader: ISubIssuesRootLoaders; + handleIssuesLoader: ({ key, issueId }: ISubIssuesRootLoadersHandler) => void; copyText: (text: string) => void; handleIssueCrudOperation: ( key: "create" | "existing" | "edit" | "delete", @@ -48,40 +45,53 @@ export const SubIssues: React.FC = ({ user, editable, removeIssueFromSubIssues, - issuesVisibility, - handleIssuesVisibility, + issuesLoader, + handleIssuesLoader, copyText, handleIssueCrudOperation, -}) => ( -
- {issue && ( -
-
- {issue?.sub_issues_count > 0 && ( - <> - {true ? ( -
handleIssuesVisibility(issue?.id)} - > - {issuesVisibility && issuesVisibility.includes(issue?.id) ? ( - - ) : ( - - )} -
- ) : ( - - )} - - )} -
+}) => { + const router = useRouter(); + const { query } = router; + const { peekIssue } = query as { peekIssue: string }; - - + const openPeekOverview = (issue_id: string) => { + router.push({ + pathname: router.pathname, + query: { ...query, peekIssue: issue_id }, + }); + }; + + return ( +
+ {issue && ( +
+
+ {issue?.sub_issues_count > 0 && ( + <> + {issuesLoader.sub_issues.includes(issue?.id) ? ( +
+ +
+ ) : ( +
handleIssuesLoader({ key: "visibility", issueId: issue?.id })} + > + {issuesLoader && issuesLoader.visibility.includes(issue?.id) ? ( + + ) : ( + + )} +
+ )} + + )} +
+ +
-
- -
+
+ +
-
- - {editable && ( - handleIssueCrudOperation("edit", parentIssue?.id, issue)} - > -
- - Edit issue -
-
- )} +
+ + {editable && ( + handleIssueCrudOperation("edit", parentIssue?.id, issue)}> +
+ + Edit issue +
+
+ )} + + {editable && ( + handleIssueCrudOperation("delete", parentIssue?.id, issue)}> +
+ + Delete issue +
+
+ )} - {editable && ( handleIssueCrudOperation("delete", parentIssue?.id, issue)} + onClick={() => copyText(`${workspaceSlug}/projects/${issue.project}/issues/${issue.id}`)} >
- - Delete issue + + Copy issue link
- )} +
+
- -
- - Copy issue link -
-
-
+ {editable && ( + <> + {issuesLoader.delete.includes(issue?.id) ? ( +
+ +
+ ) : ( +
{ + handleIssuesLoader({ key: "delete", issueId: issue?.id }); + removeIssueFromSubIssues(parentIssue?.id, issue); + }} + > + +
+ )} + + )}
+ )} - {editable && ( -
removeIssueFromSubIssues(parentIssue?.id, issue)} - > - -
- )} -
- )} + {issuesLoader.visibility.includes(issue?.id) && issue?.sub_issues_count > 0 && ( + + )} - {issuesVisibility.includes(issue?.id) && issue?.sub_issues_count > 0 && ( - - )} -
-); + {peekIssue && peekIssue === issue?.id && ( + parentIssue && parentIssue?.id && mutate(SUB_ISSUES(parentIssue?.id))} + projectId={issue?.project ?? ""} + workspaceSlug={workspaceSlug ?? ""} + readOnly={!editable} + /> + )} +
+ ); +}; diff --git a/web/components/issues/sub-issues/issues-list.tsx b/web/components/issues/sub-issues/issues-list.tsx index 01df9c34932..d4514bf3d94 100644 --- a/web/components/issues/sub-issues/issues-list.tsx +++ b/web/components/issues/sub-issues/issues-list.tsx @@ -5,6 +5,7 @@ import useSWR from "swr"; import { SubIssues } from "./issue"; // types import { ICurrentUserResponse, IIssue } from "types"; +import { ISubIssuesRootLoaders, ISubIssuesRootLoadersHandler } from "./root"; // services import issuesService from "services/issue.service"; // fetch keys @@ -18,8 +19,8 @@ export interface ISubIssuesRootList { user: ICurrentUserResponse | undefined; editable: boolean; removeIssueFromSubIssues: (parentIssueId: string, issue: IIssue) => void; - issuesVisibility: string[]; - handleIssuesVisibility: (issueId: string) => void; + issuesLoader: ISubIssuesRootLoaders; + handleIssuesLoader: ({ key, issueId }: ISubIssuesRootLoadersHandler) => void; copyText: (text: string) => void; handleIssueCrudOperation: ( key: "create" | "existing" | "edit" | "delete", @@ -36,8 +37,8 @@ export const SubIssuesRootList: React.FC = ({ user, editable, removeIssueFromSubIssues, - issuesVisibility, - handleIssuesVisibility, + issuesLoader, + handleIssuesLoader, copyText, handleIssueCrudOperation, }) => { @@ -48,6 +49,16 @@ export const SubIssuesRootList: React.FC = ({ : null ); + React.useEffect(() => { + if (isLoading) { + handleIssuesLoader({ key: "sub_issues", issueId: parentIssue?.id }); + } else { + if (issuesLoader.sub_issues.includes(parentIssue?.id)) { + handleIssuesLoader({ key: "sub_issues", issueId: parentIssue?.id }); + } + } + }, [isLoading]); + return (
{issues && @@ -64,8 +75,8 @@ export const SubIssuesRootList: React.FC = ({ user={user} editable={editable} removeIssueFromSubIssues={removeIssueFromSubIssues} - issuesVisibility={issuesVisibility} - handleIssuesVisibility={handleIssuesVisibility} + issuesLoader={issuesLoader} + handleIssuesLoader={handleIssuesLoader} copyText={copyText} handleIssueCrudOperation={handleIssueCrudOperation} /> diff --git a/web/components/issues/sub-issues/progressbar.tsx b/web/components/issues/sub-issues/progressbar.tsx index 368078a3d09..dee91263b2b 100644 --- a/web/components/issues/sub-issues/progressbar.tsx +++ b/web/components/issues/sub-issues/progressbar.tsx @@ -14,7 +14,7 @@ export const ProgressBar = ({ total = 0, done = 0 }: IProgressBar) => {
diff --git a/web/components/issues/sub-issues/properties.tsx b/web/components/issues/sub-issues/properties.tsx index 278992cd52e..9581234a983 100644 --- a/web/components/issues/sub-issues/properties.tsx +++ b/web/components/issues/sub-issues/properties.tsx @@ -86,12 +86,14 @@ export const IssueProperty: React.FC = ({ }; const handleAssigneeChange = (data: any) => { - const newData = issue.assignees ?? []; + let newData = issue.assignees ?? []; - if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); - else newData.push(data); + if (newData && newData.length > 0) { + if (newData.includes(data)) newData = newData.splice(newData.indexOf(data), 1); + else newData = [...newData, data]; + } else newData = [...newData, data]; - partialUpdateIssue({ assignees_list: data }); + partialUpdateIssue({ assignees_list: data, assignees: data }); trackEventServices.trackIssuePartialPropertyUpdateEvent( { @@ -151,7 +153,13 @@ export const IssueProperty: React.FC = ({ {properties.state && (
- +
)} @@ -181,6 +189,7 @@ export const IssueProperty: React.FC = ({
= ({ parentIssue, user, editable }) => { +export interface ISubIssuesRootLoaders { + visibility: string[]; + delete: string[]; + sub_issues: string[]; +} +export interface ISubIssuesRootLoadersHandler { + key: "visibility" | "delete" | "sub_issues"; + issueId: string; +} + +export const SubIssuesRoot: React.FC = ({ parentIssue, user }) => { const router = useRouter(); - const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string }; + const { workspaceSlug, projectId, peekIssue } = router.query as { + workspaceSlug: string; + projectId: string; + peekIssue: string; + }; const { memberRole } = useProjectMyMembership(); + const { setToastAlert } = useToast(); - const { data: issues } = useSWR( + const { data: issues, isLoading } = useSWR( workspaceSlug && projectId && parentIssue && parentIssue?.id ? SUB_ISSUES(parentIssue?.id) : null, workspaceSlug && projectId && parentIssue && parentIssue?.id ? () => issuesService.subIssues(workspaceSlug, projectId, parentIssue.id) : null ); - const [issuesVisibility, setIssuesVisibility] = React.useState([parentIssue?.id]); - const handleIssuesVisibility = (issueId: string) => { - if (issuesVisibility.includes(issueId)) { - setIssuesVisibility(issuesVisibility.filter((i: string) => i !== issueId)); - } else { - setIssuesVisibility([...issuesVisibility, issueId]); - } + const [issuesLoader, setIssuesLoader] = React.useState({ + visibility: [parentIssue?.id], + delete: [], + sub_issues: [], + }); + const handleIssuesLoader = ({ key, issueId }: ISubIssuesRootLoadersHandler) => { + setIssuesLoader((previousData: ISubIssuesRootLoaders) => ({ + ...previousData, + [key]: previousData[key].includes(issueId) + ? previousData[key].filter((i: string) => i !== issueId) + : [...previousData[key], issueId], + })); }; const [issueCrudOperation, setIssueCrudOperation] = React.useState<{ @@ -98,7 +117,6 @@ export const SubIssuesRoot: React.FC = ({ parentIssue, user, edi const payload = { sub_issue_ids: data.map((i) => i.id), }; - await issuesService.addSubIssues(workspaceSlug, projectId, issueId, payload).finally(() => { if (issueId) mutate(SUB_ISSUES(issueId)); }); @@ -106,19 +124,35 @@ export const SubIssuesRoot: React.FC = ({ parentIssue, user, edi const removeIssueFromSubIssues = async (parentIssueId: string, issue: IIssue) => { if (!workspaceSlug || !parentIssue || !issue?.id) return; - issuesService.patchIssue(workspaceSlug, projectId, issue.id, { parent: null }, user).finally(() => { - if (parentIssueId) mutate(SUB_ISSUES(parentIssueId)); - }); + issuesService + .patchIssue(workspaceSlug, projectId, issue.id, { parent: null }, user) + .then(async () => { + if (parentIssueId) await mutate(SUB_ISSUES(parentIssueId)); + handleIssuesLoader({ key: "delete", issueId: issue?.id }); + setToastAlert({ + type: "success", + title: `Issue removed!`, + message: `Issue removed successfully.`, + }); + }) + .catch(() => { + handleIssuesLoader({ key: "delete", issueId: issue?.id }); + setToastAlert({ + type: "warning", + title: `Error!`, + message: `Error, Please try again later.`, + }); + }); }; const copyText = (text: string) => { const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; copyTextToClipboard(`${originURL}/${text}`).then(() => { - // setToastAlert({ - // type: "success", - // title: "Link Copied!", - // message: "Issue link copied to clipboard.", - // }); + setToastAlert({ + type: "success", + title: "Link Copied!", + message: "Issue link copied to clipboard.", + }); }); }; @@ -130,139 +164,188 @@ export const SubIssuesRoot: React.FC = ({ parentIssue, user, edi return (
- {parentIssue && parentIssue?.sub_issues_count > 0 ? ( + {!issues && isLoading ? ( +
Loading...
+ ) : ( <> - {/* header */} -
-
handleIssuesVisibility(parentIssue?.id)} - > -
- {issuesVisibility.includes(parentIssue?.id) ? ( - - ) : ( - + {issues && issues?.sub_issues && issues?.sub_issues?.length > 0 ? ( + <> + {/* header */} +
+
handleIssuesLoader({ key: "visibility", issueId: parentIssue?.id })} + > +
+ {issuesLoader.visibility.includes(parentIssue?.id) ? ( + + ) : ( + + )} +
+
Sub-issues
+
({issues?.sub_issues?.length || 0})
+
+ +
+ +
+ + {isEditable && issuesLoader.visibility.includes(parentIssue?.id) && ( +
+
handleIssueCrudOperation("create", parentIssue?.id)} + > + Add sub-issue +
+
handleIssueCrudOperation("existing", parentIssue?.id)} + > + Add an existing issue +
+
)}
-
Sub-issues
-
({parentIssue?.sub_issues_count})
-
-
- -
- - {isEditable && issuesVisibility.includes(parentIssue?.id) && ( -
-
handleIssueCrudOperation("create", parentIssue?.id)} - > - Add sub-issue + {/* issues */} + {issuesLoader.visibility.includes(parentIssue?.id) && ( +
+
-
handleIssueCrudOperation("existing", parentIssue?.id)} + )} + +
+ + + Add sub-issue + + } + buttonClassName="whitespace-nowrap" + position="left" + noBorder + noChevron > - Add an existing issue + { + mutateSubIssues(parentIssue?.id); + handleIssueCrudOperation("create", parentIssue?.id); + }} + > + Create new + + { + mutateSubIssues(parentIssue?.id); + handleIssueCrudOperation("existing", parentIssue?.id); + }} + > + Add an existing issue + + +
+ + ) : ( + isEditable && ( +
+
No Sub-Issues yet
+
+ + + Add sub-issue + + } + buttonClassName="whitespace-nowrap" + position="left" + noBorder + noChevron + > + { + mutateSubIssues(parentIssue?.id); + handleIssueCrudOperation("create", parentIssue?.id); + }} + > + Create new + + { + mutateSubIssues(parentIssue?.id); + handleIssueCrudOperation("existing", parentIssue?.id); + }} + > + Add an existing issue + +
- )} -
- - {/* issues */} - {issuesVisibility.includes(parentIssue?.id) && ( -
- -
+ ) )} - - ) : ( - isEditable && ( -
-
No sub issues are available
+ {isEditable && issueCrudOperation?.create?.toggle && ( + { + mutateSubIssues(issueCrudOperation?.create?.issueId); + handleIssueCrudOperation("create", null); + }} + /> + )} + {isEditable && issueCrudOperation?.existing?.toggle && issueCrudOperation?.existing?.issueId && ( + handleIssueCrudOperation("existing", null)} + searchParams={{ sub_issue: true, issue_id: issueCrudOperation?.existing?.issueId }} + handleOnSubmit={addAsSubIssueFromExistingIssues} + workspaceLevelToggle + /> + )} + {isEditable && issueCrudOperation?.edit?.toggle && issueCrudOperation?.edit?.issueId && ( <> - - - Add sub-issue - - } - buttonClassName="whitespace-nowrap" - position="left" - noBorder - noChevron - > - handleIssueCrudOperation("create", parentIssue?.id)}> - Create new - - handleIssueCrudOperation("existing", parentIssue?.id)}> - Add an existing issue - - + { + mutateSubIssues(issueCrudOperation?.edit?.issueId); + handleIssueCrudOperation("edit", null, null); + }} + data={issueCrudOperation?.edit?.issue} + /> -
- ) - )} - - {isEditable && issueCrudOperation?.create?.toggle && ( - handleIssueCrudOperation("create", null)} - /> - )} - - {isEditable && issueCrudOperation?.existing?.toggle && issueCrudOperation?.existing?.issueId && ( - handleIssueCrudOperation("existing", null)} - searchParams={{ sub_issue: true, issue_id: issueCrudOperation?.existing?.issueId }} - handleOnSubmit={addAsSubIssueFromExistingIssues} - workspaceLevelToggle - /> - )} - - {isEditable && issueCrudOperation?.edit?.toggle && issueCrudOperation?.edit?.issueId && ( - { - mutateSubIssues(issueCrudOperation?.edit?.issueId); - handleIssueCrudOperation("edit", null, null); - }} - data={issueCrudOperation?.edit?.issue} - /> - )} - - {isEditable && issueCrudOperation?.delete?.toggle && issueCrudOperation?.delete?.issueId && ( - { - mutateSubIssues(issueCrudOperation?.delete?.issueId); - handleIssueCrudOperation("delete", null, null); - }} - data={issueCrudOperation?.delete?.issue} - user={user} - redirection={false} - /> + )} + {isEditable && issueCrudOperation?.delete?.toggle && issueCrudOperation?.delete?.issueId && ( + { + mutateSubIssues(issueCrudOperation?.delete?.issueId); + handleIssueCrudOperation("delete", null, null); + }} + data={issueCrudOperation?.delete?.issue} + user={user} + redirection={false} + /> + )} + )}
); diff --git a/web/components/issues/workspace-views/workpace-view-issues.tsx b/web/components/issues/workspace-views/workpace-view-issues.tsx new file mode 100644 index 00000000000..78a12f80706 --- /dev/null +++ b/web/components/issues/workspace-views/workpace-view-issues.tsx @@ -0,0 +1,232 @@ +import React, { useCallback, useState } from "react"; + +import useSWR from "swr"; + +import { useRouter } from "next/router"; + +// context +import { useProjectMyMembership } from "contexts/project-member.context"; +// service +import projectIssuesServices from "services/issues.service"; +// hooks +import useProjects from "hooks/use-projects"; +import useUser from "hooks/use-user"; +import { useWorkspaceView } from "hooks/use-workspace-view"; +import useWorkspaceMembers from "hooks/use-workspace-members"; +import useToast from "hooks/use-toast"; +// components +import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation"; +import { EmptyState, PrimaryButton } from "components/ui"; +import { SpreadsheetView, WorkspaceFiltersList } from "components/core"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +import { CreateUpdateWorkspaceViewModal } from "components/workspace/views/modal"; +// icon +import { PlusIcon } from "components/icons"; +// image +import emptyView from "public/empty-state/view.svg"; +// constants +import { WORKSPACE_LABELS } from "constants/fetch-keys"; +import { STATE_GROUP } from "constants/project"; +// types +import { IIssue, IWorkspaceIssueFilterOptions } from "types"; + +export const WorkspaceViewIssues = () => { + const router = useRouter(); + const { workspaceSlug, globalViewId } = router.query; + + const { setToastAlert } = useToast(); + + const { memberRole } = useProjectMyMembership(); + const { user } = useUser(); + const { isGuest, isViewer } = useWorkspaceMembers( + workspaceSlug?.toString(), + Boolean(workspaceSlug) + ); + const { filters, viewIssues, mutateViewIssues, handleFilters } = useWorkspaceView(); + + const [createViewModal, setCreateViewModal] = useState(null); + + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // update issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + const { projects: allProjects } = useProjects(); + const joinedProjects = allProjects?.filter((p) => p.is_member); + + const { data: workspaceLabels } = useSWR( + workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, + workspaceSlug ? () => projectIssuesServices.getWorkspaceLabels(workspaceSlug.toString()) : null + ); + + const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); + + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }, + [setEditIssueModal, setIssueToEdit] + ); + + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + + const nullFilters = + filters.filters && + Object.keys(filters.filters).filter( + (key) => + filters.filters[key as keyof IWorkspaceIssueFilterOptions] === null || + (filters.filters[key as keyof IWorkspaceIssueFilterOptions]?.length ?? 0) <= 0 + ); + + const areFiltersApplied = + filters.filters && + Object.keys(filters.filters).length > 0 && + nullFilters.length !== Object.keys(filters.filters).length; + + const isNotAllowed = isGuest || isViewer; + return ( + <> + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + onSubmit={async () => mutateViewIssues()} + /> + setEditIssueModal(false)} + data={issueToEdit} + onSubmit={async () => mutateViewIssues()} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + user={user} + onSubmit={async () => mutateViewIssues()} + /> + setCreateViewModal(null)} + preLoadedData={createViewModal} + /> +
+
+ setCreateViewModal(true)} /> + {false ? ( + router.push(`/${workspaceSlug}/workspace-views`), + }} + /> + ) : ( +
+ {areFiltersApplied && ( + <> +
+ handleFilters("filters", updatedFilter)} + labels={workspaceLabels} + members={workspaceMembers?.map((m) => m.member)} + stateGroup={STATE_GROUP} + project={joinedProjects} + clearAllFilters={() => + handleFilters("filters", { + assignees: null, + created_by: null, + labels: null, + priority: null, + state_group: null, + start_date: null, + target_date: null, + subscriber: null, + project: null, + }) + } + /> + { + if (globalViewId) { + handleFilters("filters", filters.filters, true); + setToastAlert({ + title: "View updated", + message: "Your view has been updated", + type: "success", + }); + } else + setCreateViewModal({ + query: filters.filters, + }); + }} + className="flex items-center gap-2 text-sm" + > + {!globalViewId && } + {globalViewId ? "Update" : "Save"} view + +
+ {
} + + )} + +
+ )} +
+
+ + ); +}; diff --git a/web/components/issues/workspace-views/workspace-all-issue.tsx b/web/components/issues/workspace-views/workspace-all-issue.tsx new file mode 100644 index 00000000000..4618c331d1d --- /dev/null +++ b/web/components/issues/workspace-views/workspace-all-issue.tsx @@ -0,0 +1,236 @@ +import { useCallback, useState } from "react"; +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// hook +import useUser from "hooks/use-user"; +import useWorkspaceMembers from "hooks/use-workspace-members"; +import useProjects from "hooks/use-projects"; +import { useWorkspaceView } from "hooks/use-workspace-view"; +// context +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import workspaceService from "services/workspace.service"; +import projectIssuesServices from "services/issues.service"; +// components +import { SpreadsheetView, WorkspaceFiltersList } from "components/core"; +import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +import { CreateUpdateWorkspaceViewModal } from "components/workspace/views/modal"; +// ui +import { PrimaryButton } from "components/ui"; +// icons +import { PlusIcon } from "@heroicons/react/24/outline"; +// fetch-keys +import { WORKSPACE_LABELS, WORKSPACE_VIEW_ISSUES } from "constants/fetch-keys"; +// constants +import { STATE_GROUP } from "constants/project"; +// types +import { IIssue, IWorkspaceIssueFilterOptions } from "types"; + +export const WorkspaceAllIssue = () => { + const router = useRouter(); + const { workspaceSlug, globalViewId } = router.query; + + const [createViewModal, setCreateViewModal] = useState(null); + + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // update issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + const { user } = useUser(); + const { memberRole } = useProjectMyMembership(); + + const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); + + const { data: workspaceLabels } = useSWR( + workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, + workspaceSlug ? () => projectIssuesServices.getWorkspaceLabels(workspaceSlug.toString()) : null + ); + + const { filters, handleFilters } = useWorkspaceView(); + + const params: any = { + assignees: filters?.filters?.assignees ? filters?.filters?.assignees.join(",") : undefined, + subscriber: filters?.filters?.subscriber ? filters?.filters?.subscriber.join(",") : undefined, + state_group: filters?.filters?.state_group + ? filters?.filters?.state_group.join(",") + : undefined, + priority: filters?.filters?.priority ? filters?.filters?.priority.join(",") : undefined, + labels: filters?.filters?.labels ? filters?.filters?.labels.join(",") : undefined, + created_by: filters?.filters?.created_by ? filters?.filters?.created_by.join(",") : undefined, + start_date: filters?.filters?.start_date ? filters?.filters?.start_date.join(",") : undefined, + target_date: filters?.filters?.target_date + ? filters?.filters?.target_date.join(",") + : undefined, + project: filters?.filters?.project ? filters?.filters?.project.join(",") : undefined, + sub_issue: false, + type: undefined, + }; + + const { data: viewIssues, mutate: mutateViewIssues } = useSWR( + workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null, + workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null + ); + + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }, + [setEditIssueModal, setIssueToEdit] + ); + + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + + const nullFilters = + filters.filters && + Object.keys(filters.filters).filter( + (key) => + filters.filters[key as keyof IWorkspaceIssueFilterOptions] === null || + (filters.filters[key as keyof IWorkspaceIssueFilterOptions]?.length ?? 0) <= 0 + ); + + const areFiltersApplied = + filters.filters && + Object.keys(filters.filters).length > 0 && + nullFilters.length !== Object.keys(filters.filters).length; + + const { projects: allProjects } = useProjects(); + const joinedProjects = allProjects?.filter((p) => p.is_member); + + return ( + <> + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + onSubmit={async () => { + mutateViewIssues(); + }} + /> + setEditIssueModal(false)} + data={issueToEdit} + onSubmit={async () => { + mutateViewIssues(); + }} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + user={user} + onSubmit={async () => { + mutateViewIssues(); + }} + /> + setCreateViewModal(null)} + preLoadedData={createViewModal} + /> +
+
+ setCreateViewModal(true)} /> +
+ {areFiltersApplied && ( + <> +
+ handleFilters("filters", updatedFilter)} + labels={workspaceLabels} + members={workspaceMembers?.map((m) => m.member)} + stateGroup={STATE_GROUP} + project={joinedProjects} + clearAllFilters={() => + handleFilters("filters", { + assignees: null, + created_by: null, + labels: null, + priority: null, + state_group: null, + start_date: null, + target_date: null, + subscriber: null, + project: null, + }) + } + /> + { + if (globalViewId) handleFilters("filters", filters.filters, true); + else + setCreateViewModal({ + query: filters.filters, + }); + }} + className="flex items-center gap-2 text-sm" + > + {!globalViewId && } + {globalViewId ? "Update" : "Save"} view + +
+ {
} + + )} + +
+
+
+ + ); +}; diff --git a/web/components/issues/workspace-views/workspace-assigned-issue.tsx b/web/components/issues/workspace-views/workspace-assigned-issue.tsx new file mode 100644 index 00000000000..4469804ac86 --- /dev/null +++ b/web/components/issues/workspace-views/workspace-assigned-issue.tsx @@ -0,0 +1,148 @@ +import React, { useCallback, useState } from "react"; +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// hook +import useUser from "hooks/use-user"; +// context +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import workspaceService from "services/workspace.service"; +// components +import { SpreadsheetView } from "components/core"; +import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +import { CreateUpdateWorkspaceViewModal } from "components/workspace/views/modal"; +// fetch-keys +import { WORKSPACE_VIEW_ISSUES } from "constants/fetch-keys"; +// types +import { IIssue } from "types"; + +export const WorkspaceAssignedIssue = () => { + const [createViewModal, setCreateViewModal] = useState(null); + + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // update issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { user } = useUser(); + + const { memberRole } = useProjectMyMembership(); + + const params: any = { + assignees: user?.id ?? undefined, + sub_issue: false, + }; + + const { data: viewIssues, mutate: mutateIssues } = useSWR( + workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null, + workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null + ); + + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }, + [setEditIssueModal, setIssueToEdit] + ); + + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + return ( + <> + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + onSubmit={async () => { + mutateIssues(); + }} + /> + setEditIssueModal(false)} + data={issueToEdit} + onSubmit={async () => { + mutateIssues(); + }} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + user={user} + onSubmit={async () => { + mutateIssues(); + }} + /> + setCreateViewModal(null)} + preLoadedData={createViewModal} + /> +
+
+ setCreateViewModal(true)} /> + +
+ +
+
+
+ + ); +}; diff --git a/web/components/issues/workspace-views/workspace-created-issues.tsx b/web/components/issues/workspace-views/workspace-created-issues.tsx new file mode 100644 index 00000000000..bcc83c38b4b --- /dev/null +++ b/web/components/issues/workspace-views/workspace-created-issues.tsx @@ -0,0 +1,147 @@ +import React, { useCallback, useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// hook +import useUser from "hooks/use-user"; +// context +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import workspaceService from "services/workspace.service"; +// components +import { SpreadsheetView } from "components/core"; +import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +import { CreateUpdateWorkspaceViewModal } from "components/workspace/views/modal"; +// fetch-keys +import { WORKSPACE_VIEW_ISSUES } from "constants/fetch-keys"; +// types +import { IIssue } from "types"; + +export const WorkspaceCreatedIssues = () => { + const [createViewModal, setCreateViewModal] = useState(null); + + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // update issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { user } = useUser(); + const { memberRole } = useProjectMyMembership(); + + const params: any = { + created_by: user?.id ?? undefined, + sub_issue: false, + }; + + const { data: viewIssues, mutate: mutateIssues } = useSWR( + workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null, + workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null + ); + + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }, + [setEditIssueModal, setIssueToEdit] + ); + + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + return ( + <> + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + onSubmit={async () => { + mutateIssues(); + }} + /> + setEditIssueModal(false)} + data={issueToEdit} + onSubmit={async () => { + mutateIssues(); + }} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + user={user} + onSubmit={async () => { + mutateIssues(); + }} + /> + setCreateViewModal(null)} + preLoadedData={createViewModal} + /> +
+
+ setCreateViewModal(true)} /> +
+ +
+
+
+ + ); +}; diff --git a/web/components/issues/workspace-views/workspace-issue-view-option.tsx b/web/components/issues/workspace-views/workspace-issue-view-option.tsx new file mode 100644 index 00000000000..25ddc338a11 --- /dev/null +++ b/web/components/issues/workspace-views/workspace-issue-view-option.tsx @@ -0,0 +1,116 @@ +import React from "react"; + +import { useRouter } from "next/router"; + +// hooks +import { useWorkspaceView } from "hooks/use-workspace-view"; +// components +import { GlobalSelectFilters } from "components/workspace/views/global-select-filters"; +// ui +import { Tooltip } from "components/ui"; +// icons +import { FormatListBulletedOutlined } from "@mui/icons-material"; +import { CreditCard } from "lucide-react"; +// helpers +import { replaceUnderscoreIfSnakeCase } from "helpers/string.helper"; +import { checkIfArraysHaveSameElements } from "helpers/array.helper"; +// types +import { TIssueViewOptions } from "types"; + +const issueViewOptions: { type: TIssueViewOptions; Icon: any }[] = [ + { + type: "list", + Icon: FormatListBulletedOutlined, + }, + { + type: "spreadsheet", + Icon: CreditCard, + }, +]; + +export const WorkspaceIssuesViewOptions: React.FC = () => { + const router = useRouter(); + const { workspaceSlug, globalViewId } = router.query; + + const { filters, handleFilters } = useWorkspaceView(); + + const isWorkspaceViewPath = router.pathname.includes("workspace-views/all-issues"); + + const showFilters = isWorkspaceViewPath || globalViewId; + + return ( +
+
+ {issueViewOptions.map((option) => ( + {replaceUnderscoreIfSnakeCase(option.type)} View + } + position="bottom" + > + + + ))} +
+ + {showFilters && ( + <> + { + const key = option.key as keyof typeof filters.filters; + + if (key === "start_date" || key === "target_date") { + const valueExists = checkIfArraysHaveSameElements( + filters.filters?.[key] ?? [], + option.value + ); + + handleFilters("filters", { + ...filters, + [key]: valueExists ? null : option.value, + }); + } else { + if (!filters?.filters?.[key]?.includes(option.value)) + handleFilters("filters", { + ...filters, + [key]: [...((filters?.filters?.[key] as any[]) ?? []), option.value], + }); + else { + handleFilters("filters", { + ...filters, + [key]: (filters?.filters?.[key] as any[])?.filter( + (item) => item !== option.value + ), + }); + } + } + }} + direction="left" + /> + + )} +
+ ); +}; diff --git a/web/components/issues/workspace-views/workspace-subscribed-issue.tsx b/web/components/issues/workspace-views/workspace-subscribed-issue.tsx new file mode 100644 index 00000000000..d9db2f347fe --- /dev/null +++ b/web/components/issues/workspace-views/workspace-subscribed-issue.tsx @@ -0,0 +1,148 @@ +import React, { useCallback, useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// hook +import useUser from "hooks/use-user"; +// context +import { useProjectMyMembership } from "contexts/project-member.context"; +// services +import workspaceService from "services/workspace.service"; +// components +import { SpreadsheetView } from "components/core"; +import { WorkspaceViewsNavigation } from "components/workspace/views/workpace-view-navigation"; +import { CreateUpdateIssueModal, DeleteIssueModal } from "components/issues"; +import { CreateUpdateWorkspaceViewModal } from "components/workspace/views/modal"; +// fetch-keys +import { WORKSPACE_VIEW_ISSUES } from "constants/fetch-keys"; +// types +import { IIssue } from "types"; + +export const WorkspaceSubscribedIssues = () => { + const [createViewModal, setCreateViewModal] = useState(null); + + // create issue modal + const [createIssueModal, setCreateIssueModal] = useState(false); + const [preloadedData, setPreloadedData] = useState< + (Partial & { actionType: "createIssue" | "edit" | "delete" }) | undefined + >(undefined); + + // update issue modal + const [editIssueModal, setEditIssueModal] = useState(false); + const [issueToEdit, setIssueToEdit] = useState< + (IIssue & { actionType: "edit" | "delete" }) | undefined + >(undefined); + + // delete issue modal + const [deleteIssueModal, setDeleteIssueModal] = useState(false); + const [issueToDelete, setIssueToDelete] = useState(null); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { user } = useUser(); + const { memberRole } = useProjectMyMembership(); + + const params: any = { + subscriber: user?.id ?? undefined, + sub_issue: false, + }; + + const { data: viewIssues, mutate: mutateIssues } = useSWR( + workspaceSlug ? WORKSPACE_VIEW_ISSUES(workspaceSlug.toString(), params) : null, + workspaceSlug ? () => workspaceService.getViewIssues(workspaceSlug.toString(), params) : null + ); + + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + + const handleEditIssue = useCallback( + (issue: IIssue) => { + setEditIssueModal(true); + setIssueToEdit({ + ...issue, + actionType: "edit", + cycle: issue.issue_cycle ? issue.issue_cycle.cycle : null, + module: issue.issue_module ? issue.issue_module.module : null, + }); + }, + [setEditIssueModal, setIssueToEdit] + ); + + const handleDeleteIssue = useCallback( + (issue: IIssue) => { + setDeleteIssueModal(true); + setIssueToDelete(issue); + }, + [setDeleteIssueModal, setIssueToDelete] + ); + + const handleIssueAction = useCallback( + (issue: IIssue, action: "copy" | "edit" | "delete" | "updateDraft") => { + if (action === "copy") makeIssueCopy(issue); + else if (action === "edit") handleEditIssue(issue); + else if (action === "delete") handleDeleteIssue(issue); + }, + [makeIssueCopy, handleEditIssue, handleDeleteIssue] + ); + return ( + <> + setCreateIssueModal(false)} + prePopulateData={{ + ...preloadedData, + }} + onSubmit={async () => { + mutateIssues(); + }} + /> + setEditIssueModal(false)} + data={issueToEdit} + onSubmit={async () => { + mutateIssues(); + }} + /> + setDeleteIssueModal(false)} + isOpen={deleteIssueModal} + data={issueToDelete} + user={user} + onSubmit={async () => { + mutateIssues(); + }} + /> + setCreateViewModal(null)} + preLoadedData={createViewModal} + /> +
+
+ setCreateViewModal(true)} /> + +
+ +
+
+
+ + ); +}; diff --git a/web/components/labels/single-label.tsx b/web/components/labels/single-label.tsx index be981e510d1..c163a37351b 100644 --- a/web/components/labels/single-label.tsx +++ b/web/components/labels/single-label.tsx @@ -1,11 +1,13 @@ -import React from "react"; +import React, { useRef, useState } from "react"; +//hook +import useOutsideClickDetector from "hooks/use-outside-click-detector"; // ui import { CustomMenu } from "components/ui"; // types import { IIssueLabels } from "types"; //icons -import { RectangleGroupIcon, PencilIcon } from "@heroicons/react/24/outline"; +import { PencilIcon } from "@heroicons/react/24/outline"; import { Component, X } from "lucide-react"; type Props = { @@ -20,9 +22,14 @@ export const SingleLabel: React.FC = ({ addLabelToGroup, editLabel, handleLabelDelete, -}) => ( -
-
+}) => { + const [isMenuActive, setIsMenuActive] = useState(false); + const actionSectionRef = useRef(null); + + useOutsideClickDetector(actionSectionRef, () => setIsMenuActive(false)); + + return ( +
= ({ />
{label.name}
-
-
- - -
- } +
+ setIsMenuActive(!isMenuActive)}> + +
+ } + > + addLabelToGroup(label)}> + + + Convert to group + + + editLabel(label)}> + + + Edit label + + + +
+
- -
-
-
-); + ); +}; diff --git a/web/components/onboarding/invite-members.tsx b/web/components/onboarding/invite-members.tsx index fee1cb252d0..8f1b1fa4103 100644 --- a/web/components/onboarding/invite-members.tsx +++ b/web/components/onboarding/invite-members.tsx @@ -1,15 +1,27 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useRef, useState } from "react"; +// headless ui +import { Listbox, Transition } from "@headlessui/react"; // react-hook-form -import { Controller, useFieldArray, useForm } from "react-hook-form"; +import { + Control, + Controller, + FieldArrayWithId, + UseFieldArrayRemove, + useFieldArray, + useForm, +} from "react-hook-form"; // services import workspaceService from "services/workspace.service"; // hooks import useToast from "hooks/use-toast"; // ui -import { CustomSelect, Input, PrimaryButton, SecondaryButton } from "components/ui"; +import { Input, PrimaryButton, SecondaryButton } from "components/ui"; +// hooks +import useDynamicDropdownPosition from "hooks/use-dynamic-dropdown"; // icons -import { PlusIcon, XMarkIcon } from "@heroicons/react/24/outline"; +import { ChevronDownIcon } from "@heroicons/react/20/solid"; +import { PlusIcon, XMarkIcon, CheckIcon } from "@heroicons/react/24/outline"; // types import { ICurrentUserResponse, IWorkspace, TOnboardingSteps } from "types"; // constants @@ -31,12 +43,136 @@ type FormValues = { emails: EmailRole[]; }; -export const InviteMembers: React.FC = ({ - finishOnboarding, - stepChange, - user, - workspace, -}) => { +type InviteMemberFormProps = { + index: number; + remove: UseFieldArrayRemove; + control: Control; + field: FieldArrayWithId; + fields: FieldArrayWithId[]; + errors: any; +}; + +const InviteMemberForm: React.FC = (props) => { + const { control, index, fields, remove, errors } = props; + + const buttonRef = useRef(null); + const dropdownRef = useRef(null); + + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + + useDynamicDropdownPosition( + isDropdownOpen, + () => setIsDropdownOpen(false), + buttonRef, + dropdownRef + ); + + return ( +
+
+ ( + <> + + {errors.emails?.[index]?.email && ( + + {errors.emails?.[index]?.email?.message} + + )} + + )} + /> +
+
+ ( + { + onChange(val); + setIsDropdownOpen(false); + }} + className="flex-shrink-0 text-left w-full" + > + setIsDropdownOpen((prev) => !prev)} + className="flex items-center px-2.5 py-2 text-xs justify-between gap-1 w-full rounded-md border border-custom-border-300 shadow-sm duration-300 focus:outline-none" + > + {ROLE[value]} + + + + +
+ {Object.entries(ROLE).map(([key, value]) => ( + + `cursor-pointer select-none truncate rounded px-1 py-1.5 ${ + active || selected ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } + > + {({ selected }) => ( +
+
{value}
+ {selected && } +
+ )} +
+ ))} +
+
+
+
+ )} + /> +
+ {fields.length > 1 && ( + + )} +
+ ); +}; + +export const InviteMembers: React.FC = (props) => { + const { finishOnboarding, stepChange, user, workspace } = props; + const { setToastAlert } = useToast(); const { @@ -109,66 +245,15 @@ export const InviteMembers: React.FC = ({
{fields.map((field, index) => ( -
-
- ( - <> - - {errors.emails?.[index]?.email && ( - - {errors.emails?.[index]?.email?.message} - - )} - - )} - /> -
-
- ( - {ROLE[value]}} - onChange={onChange} - width="w-full" - input - > - {Object.entries(ROLE).map(([key, value]) => ( - - {value} - - ))} - - )} - /> -
- {fields.length > 1 && ( - - )} -
+ ))}
- ); - })} + return ( + + ); + })}
diff --git a/web/components/profile/profile-issues-view.tsx b/web/components/profile/profile-issues-view.tsx index 8d1c5e37401..d4be6ef3da1 100644 --- a/web/components/profile/profile-issues-view.tsx +++ b/web/components/profile/profile-issues-view.tsx @@ -207,7 +207,8 @@ export const ProfileIssuesView = () => { const isMySubscribedIssues = (filters.subscriber && filters.subscriber.length > 0 && router.pathname.includes("my-issues")) ?? false; - const disableAddIssueOption = isSubscribedIssuesRoute || isMySubscribedIssues; + const disableAddIssueOption = + isSubscribedIssuesRoute || isMySubscribedIssues || user?.id !== userId; return ( <> diff --git a/web/components/project/card.tsx b/web/components/project/card.tsx index 0e8997da3b3..5e95395d6e6 100644 --- a/web/components/project/card.tsx +++ b/web/components/project/card.tsx @@ -151,7 +151,7 @@ export const ProjectCard: React.FC = observer((props) => { Select to Join ) : ( - Member + Joined )} {project.is_favorite && ( diff --git a/web/components/project/create-project-modal.tsx b/web/components/project/create-project-modal.tsx index d2ea87d6bb9..f07e8193bfd 100644 --- a/web/components/project/create-project-modal.tsx +++ b/web/components/project/create-project-modal.tsx @@ -365,7 +365,7 @@ export const CreateProjectModal: React.FC = (props) => { value={value} onChange={onChange} options={options} - buttonClassName="!px-2 shadow-md" + buttonClassName="border-[0.5px] !px-2 shadow-md" label={
{value ? ( diff --git a/web/components/project/label-select.tsx b/web/components/project/label-select.tsx index 56c1aef5e7a..53900bf7be8 100644 --- a/web/components/project/label-select.tsx +++ b/web/components/project/label-select.tsx @@ -24,6 +24,7 @@ import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; type Props = { value: string[]; + projectId: string; onChange: (data: any) => void; labelsDetails: any[]; className?: string; @@ -37,6 +38,7 @@ type Props = { export const LabelSelect: React.FC = ({ value, + projectId, onChange, labelsDetails, className = "", @@ -54,15 +56,15 @@ export const LabelSelect: React.FC = ({ const [labelModal, setLabelModal] = useState(false); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; const dropdownBtn = useRef(null); const dropdownOptions = useRef(null); const { data: issueLabels } = useSWR( - projectId && fetchStates ? PROJECT_ISSUE_LABELS(projectId.toString()) : null, + projectId && fetchStates ? PROJECT_ISSUE_LABELS(projectId) : null, workspaceSlug && projectId && fetchStates - ? () => issuesService.getIssueLabels(workspaceSlug as string, projectId as string) + ? () => issuesService.getIssueLabels(workspaceSlug as string, projectId) : null ); @@ -148,7 +150,7 @@ export const LabelSelect: React.FC = ({ setLabelModal(false)} - projectId={projectId.toString()} + projectId={projectId} user={user} /> )} diff --git a/web/components/project/member-select.tsx b/web/components/project/member-select.tsx index 64baa945d52..4fcb0426890 100644 --- a/web/components/project/member-select.tsx +++ b/web/components/project/member-select.tsx @@ -16,9 +16,10 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys"; type Props = { value: any; onChange: (val: string) => void; + isDisabled?: boolean; }; -export const MemberSelect: React.FC = ({ value, onChange }) => { +export const MemberSelect: React.FC = ({ value, onChange, isDisabled = false }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -79,6 +80,7 @@ export const MemberSelect: React.FC = ({ value, onChange }) => { position="right" width="w-full" onChange={onChange} + disabled={isDisabled} /> ); }; diff --git a/web/components/project/members-select.tsx b/web/components/project/members-select.tsx index 1ac216446c3..fdef943bb5d 100644 --- a/web/components/project/members-select.tsx +++ b/web/components/project/members-select.tsx @@ -18,6 +18,7 @@ import { IUser } from "types"; type Props = { value: string | string[]; + projectId: string; onChange: (data: any) => void; membersDetails: IUser[]; renderWorkspaceMembers?: boolean; @@ -30,6 +31,7 @@ type Props = { export const MembersSelect: React.FC = ({ value, + projectId, onChange, membersDetails, renderWorkspaceMembers = false, @@ -44,16 +46,12 @@ export const MembersSelect: React.FC = ({ const [fetchStates, setFetchStates] = useState(false); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; const dropdownBtn = useRef(null); const dropdownOptions = useRef(null); - const { members } = useProjectMembers( - workspaceSlug?.toString(), - projectId?.toString(), - fetchStates && !renderWorkspaceMembers - ); + const { members } = useProjectMembers(workspaceSlug?.toString(), projectId, fetchStates && !renderWorkspaceMembers); const { workspaceMembers } = useWorkspaceMembers( workspaceSlug?.toString() ?? "", diff --git a/web/components/states/state-select.tsx b/web/components/states/state-select.tsx index 02c2ea36513..a0e738c701a 100644 --- a/web/components/states/state-select.tsx +++ b/web/components/states/state-select.tsx @@ -25,6 +25,7 @@ import { getStatesList } from "helpers/state.helper"; type Props = { value: IState; onChange: (data: any, states: IState[] | undefined) => void; + projectId: string; className?: string; buttonClassName?: string; optionsClassName?: string; @@ -35,6 +36,7 @@ type Props = { export const StateSelect: React.FC = ({ value, onChange, + projectId, className = "", buttonClassName = "", optionsClassName = "", @@ -50,12 +52,12 @@ export const StateSelect: React.FC = ({ const [fetchStates, setFetchStates] = useState(false); const router = useRouter(); - const { workspaceSlug, projectId } = router.query; + const { workspaceSlug } = router.query; const { data: stateGroups } = useSWR( - workspaceSlug && projectId && fetchStates ? STATES_LIST(projectId as string) : null, + workspaceSlug && projectId && fetchStates ? STATES_LIST(projectId) : null, workspaceSlug && projectId && fetchStates - ? () => projectStateService.getStates(workspaceSlug as string, projectId as string) + ? () => projectStateService.getStates(workspaceSlug as string, projectId) : null ); @@ -87,72 +89,83 @@ export const StateSelect: React.FC = ({ useDynamicDropdownPosition(isOpen, () => setIsOpen(false), dropdownBtn, dropdownOptions); return ( -
- -
-
- person.name} - onChange={(event) => setQuery(event.target.value)} - /> - -
- setQuery('')} - > - - {filteredPeople.length === 0 && query !== '' ? ( -
- Nothing found. +
+ +
+ + setQuery(e.target.value)} + placeholder="Search" + displayValue={(assigned: any) => assigned?.name} + />
- ) : ( - filteredPeople.map((person) => ( - - `relative cursor-default select-none py-2 pl-10 pr-4 ${ - active ? 'bg-teal-600 text-white' : 'text-gray-900' - }` - } - value={person} - > - {({ selected, active }) => ( - <> - + {filteredOptions ? ( + filteredOptions.length > 0 ? ( + filteredOptions.map((option) => ( + + `flex items-center justify-between gap-2 cursor-pointer select-none truncate rounded px-1 py-1.5 ${ + active && !selected ? "bg-custom-background-80" : "" + } ${selected ? "text-custom-text-100" : "text-custom-text-200"}` + } > - {person.name} - - {selected ? ( - - - ) : null} - - )} - - )) - )} -
- -
- -
+ {({ selected }) => ( + <> + {option.content} + {selected && } + + )} + + )) + ) : ( + +

No matching results

+
+ ) + ) : ( +

Loading...

+ )} +
+ +
+ + ); + }} + ); }; diff --git a/web/components/tiptap/index.tsx b/web/components/tiptap/index.tsx index 84f691c35d5..44076234e60 100644 --- a/web/components/tiptap/index.tsx +++ b/web/components/tiptap/index.tsx @@ -89,7 +89,7 @@ const Tiptap = (props: ITipTapRichTextEditor) => { onClick={() => { editor?.chain().focus().run(); }} - className={`tiptap-editor-container cursor-text ${editorClassNames}`} + className={`tiptap-editor-container relative cursor-text ${editorClassNames}`} > {editor && }
diff --git a/web/components/tiptap/table-menu/index.tsx b/web/components/tiptap/table-menu/index.tsx index 94f9c0f8d87..daa8f695364 100644 --- a/web/components/tiptap/table-menu/index.tsx +++ b/web/components/tiptap/table-menu/index.tsx @@ -80,8 +80,6 @@ export const TableMenu = ({ editor }: { editor: any }) => { const range = selection.getRangeAt(0); const tableNode = findTableAncestor(range.startContainer); - let parent = tableNode?.parentElement; - if (tableNode) { const tableRect = tableNode.getBoundingClientRect(); const tableCenter = tableRect.left + tableRect.width / 2; @@ -90,18 +88,6 @@ export const TableMenu = ({ editor }: { editor: any }) => { const tableBottom = tableRect.bottom; setTableLocation({ bottom: tableBottom, left: menuLeft }); - - while (parent) { - if (!parent.classList.contains("disable-scroll")) - parent.classList.add("disable-scroll"); - parent = parent.parentElement; - } - } else { - const scrollDisabledContainers = document.querySelectorAll(".disable-scroll"); - - scrollDisabledContainers.forEach((container) => { - container.classList.remove("disable-scroll"); - }); } } }; @@ -115,13 +101,9 @@ export const TableMenu = ({ editor }: { editor: any }) => { return (
{items.map((item, index) => ( diff --git a/web/components/ui/dropdowns/custom-menu.tsx b/web/components/ui/dropdowns/custom-menu.tsx index c451d443254..f456804f046 100644 --- a/web/components/ui/dropdowns/custom-menu.tsx +++ b/web/components/ui/dropdowns/custom-menu.tsx @@ -19,6 +19,7 @@ export type CustomMenuProps = DropdownProps & { const CustomMenu = ({ buttonClassName = "", + customButtonClassName = "", children, className = "", customButton, @@ -40,7 +41,13 @@ const CustomMenu = ({ {({ open }) => ( <> {customButton ? ( - + {customButton} ) : ( diff --git a/web/components/ui/dropdowns/types.d.ts b/web/components/ui/dropdowns/types.d.ts index aace1858a32..b368a7ed8d3 100644 --- a/web/components/ui/dropdowns/types.d.ts +++ b/web/components/ui/dropdowns/types.d.ts @@ -1,5 +1,6 @@ export type DropdownProps = { buttonClassName?: string; + customButtonClassName?: string; className?: string; customButton?: JSX.Element; disabled?: boolean; diff --git a/web/components/ui/empty-state.tsx b/web/components/ui/empty-state.tsx index 098c3f15293..e39b10801f2 100644 --- a/web/components/ui/empty-state.tsx +++ b/web/components/ui/empty-state.tsx @@ -16,6 +16,7 @@ type Props = { }; secondaryButton?: React.ReactNode; isFullScreen?: boolean; + disabled?: boolean; }; export const EmptyState: React.FC = ({ @@ -25,6 +26,7 @@ export const EmptyState: React.FC = ({ primaryButton, secondaryButton, isFullScreen = true, + disabled = false, }) => (
= ({ {description &&

{description}

}
{primaryButton && ( - + {primaryButton.icon} {primaryButton.text} diff --git a/web/components/ui/toggle-switch.tsx b/web/components/ui/toggle-switch.tsx index e52ff26c944..5ad9377de11 100644 --- a/web/components/ui/toggle-switch.tsx +++ b/web/components/ui/toggle-switch.tsx @@ -21,7 +21,7 @@ export const ToggleSwitch: React.FC = (props) => { size === "sm" ? "h-4 w-6" : size === "md" ? "h-5 w-8" : "h-6 w-10" } flex-shrink-0 cursor-pointer rounded-full border border-custom-border-200 transition-colors duration-200 ease-in-out focus:outline-none ${ value ? "bg-custom-primary-100" : "bg-gray-700" - } ${className || ""}`} + } ${className || ""} ${disabled ? "cursor-not-allowed" : ""}`} > {label} = (props) => { : size === "md" ? "translate-x-4" : "translate-x-5") + " bg-white" - : "translate-x-1 bg-custom-background-90" - }`} + : "translate-x-0.5 bg-custom-background-90" + } ${disabled ? "cursor-not-allowed" : ""}`} /> ); diff --git a/web/components/views/delete-view-modal.tsx b/web/components/views/delete-view-modal.tsx index c65f7ba292f..61c627430f0 100644 --- a/web/components/views/delete-view-modal.tsx +++ b/web/components/views/delete-view-modal.tsx @@ -46,9 +46,8 @@ export const DeleteViewModal: React.FC = ({ isOpen, data, setIsOpen, user await viewsService .deleteView(workspaceSlug as string, projectId as string, data.id, user) .then(() => { - mutate( - VIEWS_LIST(projectId as string), - (views) => views?.filter((view) => view.id !== data.id) + mutate(VIEWS_LIST(projectId as string), (views) => + views?.filter((view) => view.id !== data.id) ); handleClose(); diff --git a/web/components/views/select-filters.tsx b/web/components/views/select-filters.tsx index 07a8ed337eb..0a3872afcdc 100644 --- a/web/components/views/select-filters.tsx +++ b/web/components/views/select-filters.tsx @@ -18,7 +18,7 @@ import { PriorityIcon, StateGroupIcon } from "components/icons"; import { getStatesList } from "helpers/state.helper"; import { checkIfArraysHaveSameElements } from "helpers/array.helper"; // types -import { IIssueFilterOptions, IQuery } from "types"; +import { IIssueFilterOptions } from "types"; // fetch-keys import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS, STATES_LIST } from "constants/fetch-keys"; // constants @@ -26,7 +26,7 @@ import { PRIORITIES } from "constants/project"; import { DATE_FILTER_OPTIONS } from "constants/filters"; type Props = { - filters: Partial | IQuery; + filters: Partial; onSelect: (option: any) => void; direction?: "left" | "right"; height?: "sm" | "md" | "rg" | "lg"; @@ -65,6 +65,185 @@ export const SelectFilters: React.FC = ({ filters, onSelect, direction = : null ); + const projectFilterOption = [ + { + id: "priority", + label: "Priority", + value: PRIORITIES, + hasChildren: true, + children: PRIORITIES.map((priority) => ({ + id: priority === null ? "null" : priority, + label: ( +
+ + {priority ?? "None"} +
+ ), + value: { + key: "priority", + value: priority === null ? "null" : priority, + }, + selected: filters?.priority?.includes(priority === null ? "null" : priority), + })), + }, + { + id: "state", + label: "State", + value: statesList, + hasChildren: true, + children: statesList?.map((state) => ({ + id: state.id, + label: ( +
+ + {state.name} +
+ ), + value: { + key: "state", + value: state.id, + }, + selected: filters?.state?.includes(state.id), + })), + }, + { + id: "assignees", + label: "Assignees", + value: members, + hasChildren: true, + children: members?.map((member) => ({ + id: member.member.id, + label: ( +
+ + {member.member.display_name} +
+ ), + value: { + key: "assignees", + value: member.member.id, + }, + selected: filters?.assignees?.includes(member.member.id), + })), + }, + { + id: "created_by", + label: "Created by", + value: members, + hasChildren: true, + children: members?.map((member) => ({ + id: member.member.id, + label: ( +
+ + {member.member.display_name} +
+ ), + value: { + key: "created_by", + value: member.member.id, + }, + selected: filters?.created_by?.includes(member.member.id), + })), + }, + { + id: "labels", + label: "Labels", + value: issueLabels, + hasChildren: true, + children: issueLabels?.map((label) => ({ + id: label.id, + label: ( +
+
+ {label.name} +
+ ), + value: { + key: "labels", + value: label.id, + }, + selected: filters?.labels?.includes(label.id), + })), + }, + { + id: "start_date", + label: "Start date", + value: DATE_FILTER_OPTIONS, + hasChildren: true, + children: [ + ...DATE_FILTER_OPTIONS.map((option) => ({ + id: option.name, + label: option.name, + value: { + key: "start_date", + value: option.value, + }, + selected: checkIfArraysHaveSameElements(filters?.start_date ?? [], option.value), + })), + { + id: "custom", + label: "Custom", + value: "custom", + element: ( + + ), + }, + ], + }, + { + id: "target_date", + label: "Due date", + value: DATE_FILTER_OPTIONS, + hasChildren: true, + children: [ + ...DATE_FILTER_OPTIONS.map((option) => ({ + id: option.name, + label: option.name, + value: { + key: "target_date", + value: option.value, + }, + selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], option.value), + })), + { + id: "custom", + label: "Custom", + value: "custom", + element: ( + + ), + }, + ], + }, + ]; return ( <> {isDateFilterModalOpen && ( @@ -82,185 +261,7 @@ export const SelectFilters: React.FC = ({ filters, onSelect, direction = onSelect={onSelect} direction={direction} height={height} - options={[ - { - id: "priority", - label: "Priority", - value: PRIORITIES, - hasChildren: true, - children: PRIORITIES.map((priority) => ({ - id: priority === null ? "null" : priority, - label: ( -
- - {priority ?? "None"} -
- ), - value: { - key: "priority", - value: priority === null ? "null" : priority, - }, - selected: filters?.priority?.includes(priority === null ? "null" : priority), - })), - }, - { - id: "state", - label: "State", - value: statesList, - hasChildren: true, - children: statesList?.map((state) => ({ - id: state.id, - label: ( -
- - {state.name} -
- ), - value: { - key: "state", - value: state.id, - }, - selected: filters?.state?.includes(state.id), - })), - }, - { - id: "assignees", - label: "Assignees", - value: members, - hasChildren: true, - children: members?.map((member) => ({ - id: member.member.id, - label: ( -
- - {member.member.display_name} -
- ), - value: { - key: "assignees", - value: member.member.id, - }, - selected: filters?.assignees?.includes(member.member.id), - })), - }, - { - id: "created_by", - label: "Created by", - value: members, - hasChildren: true, - children: members?.map((member) => ({ - id: member.member.id, - label: ( -
- - {member.member.display_name} -
- ), - value: { - key: "created_by", - value: member.member.id, - }, - selected: filters?.created_by?.includes(member.member.id), - })), - }, - { - id: "labels", - label: "Labels", - value: issueLabels, - hasChildren: true, - children: issueLabels?.map((label) => ({ - id: label.id, - label: ( -
-
- {label.name} -
- ), - value: { - key: "labels", - value: label.id, - }, - selected: filters?.labels?.includes(label.id), - })), - }, - { - id: "start_date", - label: "Start date", - value: DATE_FILTER_OPTIONS, - hasChildren: true, - children: [ - ...DATE_FILTER_OPTIONS.map((option) => ({ - id: option.name, - label: option.name, - value: { - key: "start_date", - value: option.value, - }, - selected: checkIfArraysHaveSameElements(filters?.start_date ?? [], option.value), - })), - { - id: "custom", - label: "Custom", - value: "custom", - element: ( - - ), - }, - ], - }, - { - id: "target_date", - label: "Due date", - value: DATE_FILTER_OPTIONS, - hasChildren: true, - children: [ - ...DATE_FILTER_OPTIONS.map((option) => ({ - id: option.name, - label: option.name, - value: { - key: "target_date", - value: option.value, - }, - selected: checkIfArraysHaveSameElements(filters?.target_date ?? [], option.value), - })), - { - id: "custom", - label: "Custom", - value: "custom", - element: ( - - ), - }, - ], - }, - ]} + options={projectFilterOption} /> ); diff --git a/web/components/views/single-view-item.tsx b/web/components/views/single-view-item.tsx index a6f81912ce9..70f07a416d2 100644 --- a/web/components/views/single-view-item.tsx +++ b/web/components/views/single-view-item.tsx @@ -5,9 +5,9 @@ import { useRouter } from "next/router"; // icons import { TrashIcon, StarIcon, PencilIcon } from "@heroicons/react/24/outline"; -import { StackedLayersIcon } from "components/icons"; +import { PhotoFilterOutlined } from "@mui/icons-material"; //components -import { CustomMenu, Tooltip } from "components/ui"; +import { CustomMenu } from "components/ui"; // services import viewsService from "services/views.service"; // types @@ -18,7 +18,6 @@ import { VIEWS_LIST } from "constants/fetch-keys"; import useToast from "hooks/use-toast"; // helpers import { truncateText } from "helpers/string.helper"; -import { renderShortDateWithYearFormat, render24HourFormatTime } from "helpers/date-time.helper"; type Props = { view: IView; @@ -82,94 +81,92 @@ export const SingleViewItem: React.FC = ({ view, handleEditView, handleDe }); }; + const viewRedirectionUrl = `/${workspaceSlug}/projects/${projectId}/views/${view.id}`; + return ( -
- - -
-
-
- -

{truncateText(view.name, 75)}

+
+ + +
+
+
+ +
+
+

+ {truncateText(view.name, 75)} +

+ {view?.description && ( +

{view.description}

+ )}
-
-
-

- {Object.keys(view.query_data) - .map((key: string) => - view.query_data[key as keyof typeof view.query_data] !== null - ? (view.query_data[key as keyof typeof view.query_data] as any).length - : 0 - ) - .reduce((curr, prev) => curr + prev, 0)}{" "} - filters -

- +
+
+

+ {Object.keys(view.query_data) + .map((key: string) => + view.query_data[key as keyof typeof view.query_data] !== null + ? (view.query_data[key as keyof typeof view.query_data] as any).length + : 0 + ) + .reduce((curr, prev) => curr + prev, 0)}{" "} + filters +

+ + {view.is_favorite ? ( + + ) : ( + + )} + + { + e.preventDefault(); + e.stopPropagation(); + handleEditView(); + }} + > + + + Edit View + + + { + e.preventDefault(); + e.stopPropagation(); + handleDeleteView(); + }} > -

- {render24HourFormatTime(view.updated_at)} -

- - {view.is_favorite ? ( - - ) : ( - - )} - - { - e.preventDefault(); - e.stopPropagation(); - handleEditView(); - }} - > - - - Edit View - - - { - e.preventDefault(); - e.stopPropagation(); - handleDeleteView(); - }} - > - - - Delete View - - - -
+ + + Delete View + + +
- {view?.description && ( -

- {view.description} -

- )}
diff --git a/web/components/workspace/help-section.tsx b/web/components/workspace/help-section.tsx index ae8cdd327e3..1919710c914 100644 --- a/web/components/workspace/help-section.tsx +++ b/web/components/workspace/help-section.tsx @@ -1,21 +1,24 @@ import React, { useRef, useState } from "react"; import Link from "next/link"; import { Transition } from "@headlessui/react"; +import { observer } from "mobx-react-lite"; + +// mobx store +import { useMobxStore } from "lib/mobx/store-provider"; // hooks import useOutsideClickDetector from "hooks/use-outside-click-detector"; // icons import { Bolt, HelpOutlineOutlined, WestOutlined } from "@mui/icons-material"; -import { ChatBubbleOvalLeftEllipsisIcon } from "@heroicons/react/24/outline"; -import { DocumentIcon, DiscordIcon, GithubIcon } from "components/icons"; -// mobx store -import { useMobxStore } from "lib/mobx/store-provider"; -import { observer } from "mobx-react-lite"; +import { DiscordIcon } from "components/icons"; +import { FileText, Github, MessagesSquare } from "lucide-react"; +// assets +import packageJson from "package.json"; const helpOptions = [ { name: "Documentation", href: "https://docs.plane.so/", - Icon: DocumentIcon, + Icon: FileText, }, { name: "Join our Discord", @@ -25,17 +28,19 @@ const helpOptions = [ { name: "Report a bug", href: "https://github.com/makeplane/plane/issues/new/choose", - Icon: GithubIcon, + Icon: Github, }, { name: "Chat with us", href: null, onClick: () => (window as any).$crisp.push(["do", "chat:show"]), - Icon: ChatBubbleOvalLeftEllipsisIcon, + Icon: MessagesSquare, }, ]; -export interface WorkspaceHelpSectionProps {} +export interface WorkspaceHelpSectionProps { + setSidebarActive: React.Dispatch>; +} export const WorkspaceHelpSection: React.FC = observer(() => { // store @@ -119,37 +124,44 @@ export const WorkspaceHelpSection: React.FC = observe leaveTo="transform opacity-0 scale-95" >
- {helpOptions.map(({ name, Icon, href, onClick }) => { - if (href) - return ( - - + {helpOptions.map(({ name, Icon, href, onClick }) => { + if (href) + return ( + + +
+ +
+ {name} +
+ + ); + else + return ( + - ); - })} +
+ +
+ {name} + + ); + })} +
+
Version: v{packageJson.version}
diff --git a/web/components/workspace/sidebar-menu.tsx b/web/components/workspace/sidebar-menu.tsx index cf44ce551e4..b7a75e30e7a 100644 --- a/web/components/workspace/sidebar-menu.tsx +++ b/web/components/workspace/sidebar-menu.tsx @@ -30,8 +30,8 @@ const workspaceLinks = (workspaceSlug: string) => [ }, { Icon: TaskAltOutlined, - name: "My Issues", - href: `/${workspaceSlug}/me/my-issues`, + name: "All Issues", + href: `/${workspaceSlug}/workspace-views/all-issues`, }, ]; diff --git a/web/components/workspace/sidebar-quick-action.tsx b/web/components/workspace/sidebar-quick-action.tsx index 8923abc141e..52b0276ae1f 100644 --- a/web/components/workspace/sidebar-quick-action.tsx +++ b/web/components/workspace/sidebar-quick-action.tsx @@ -44,28 +44,31 @@ export const WorkspaceSidebarQuickAction = () => { > {storedValue && Object.keys(JSON.parse(storedValue)).length > 0 && ( <> -
+
-
+
diff --git a/web/components/workspace/views/delete-workspace-view-modal.tsx b/web/components/workspace/views/delete-workspace-view-modal.tsx new file mode 100644 index 00000000000..6030f630f11 --- /dev/null +++ b/web/components/workspace/views/delete-workspace-view-modal.tsx @@ -0,0 +1,141 @@ +import React, { useState } from "react"; + +import { useRouter } from "next/router"; + +import { mutate } from "swr"; + +// headless ui +import { Dialog, Transition } from "@headlessui/react"; +// services +import workspaceService from "services/workspace.service"; +// hooks +import useToast from "hooks/use-toast"; +// ui +import { DangerButton, SecondaryButton } from "components/ui"; +// icons +import { ExclamationTriangleIcon } from "@heroicons/react/24/outline"; +// types +import { IWorkspaceView } from "types/workspace-views"; +// fetch-keys +import { WORKSPACE_VIEWS_LIST } from "constants/fetch-keys"; + +type Props = { + isOpen: boolean; + setIsOpen: React.Dispatch>; + data: IWorkspaceView | null; +}; + +export const DeleteWorkspaceViewModal: React.FC = ({ isOpen, data, setIsOpen }) => { + const [isDeleteLoading, setIsDeleteLoading] = useState(false); + + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { setToastAlert } = useToast(); + + const handleClose = () => { + setIsOpen(false); + setIsDeleteLoading(false); + }; + + const handleDeletion = async () => { + setIsDeleteLoading(true); + + if (!workspaceSlug || !data) return; + + await workspaceService + .deleteView(workspaceSlug as string, data.id) + .then(() => { + mutate(WORKSPACE_VIEWS_LIST(workspaceSlug as string), (views) => + views?.filter((view) => view.id !== data.id) + ); + + handleClose(); + + setToastAlert({ + type: "success", + title: "Success!", + message: "View deleted successfully.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "View could not be deleted. Please try again.", + }); + }) + .finally(() => { + setIsDeleteLoading(false); + }); + }; + + return ( + + + +
+ + +
+
+ + +
+
+
+
+
+ + Delete View + +
+

+ Are you sure you want to delete view-{" "} + + {data?.name} + + ? All of the data related to the view will be permanently removed. This + action cannot be undone. +

+
+
+
+
+
+ Cancel + + {isDeleteLoading ? "Deleting..." : "Delete"} + +
+
+
+
+
+
+
+ ); +}; diff --git a/web/components/workspace/views/form.tsx b/web/components/workspace/views/form.tsx new file mode 100644 index 00000000000..b16d613990c --- /dev/null +++ b/web/components/workspace/views/form.tsx @@ -0,0 +1,213 @@ +import { useEffect } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// react-hook-form +import { useForm } from "react-hook-form"; +// services +import issuesService from "services/issues.service"; + +// hooks +import useProjects from "hooks/use-projects"; +import useWorkspaceMembers from "hooks/use-workspace-members"; +// components +import { WorkspaceFiltersList } from "components/core"; +import { GlobalSelectFilters } from "components/workspace/views/global-select-filters"; + +// ui +import { Input, PrimaryButton, SecondaryButton, TextArea } from "components/ui"; +// helpers +import { checkIfArraysHaveSameElements } from "helpers/array.helper"; +// types +import { IQuery } from "types"; +import { IWorkspaceView } from "types/workspace-views"; +// fetch-keys +import { WORKSPACE_LABELS } from "constants/fetch-keys"; +import { STATE_GROUP } from "constants/project"; + +type Props = { + handleFormSubmit: (values: IWorkspaceView) => Promise; + handleClose: () => void; + status: boolean; + data?: IWorkspaceView | null; + preLoadedData?: Partial | null; +}; + +const defaultValues: Partial = { + name: "", + description: "", +}; + +export const WorkspaceViewForm: React.FC = ({ + handleFormSubmit, + handleClose, + status, + data, + preLoadedData, +}) => { + const router = useRouter(); + const { workspaceSlug } = router.query; + + const { + register, + formState: { errors, isSubmitting }, + handleSubmit, + reset, + watch, + setValue, + } = useForm({ + defaultValues, + }); + const filters = watch("query"); + + const { data: labelOptions } = useSWR( + workspaceSlug ? WORKSPACE_LABELS(workspaceSlug.toString()) : null, + workspaceSlug ? () => issuesService.getWorkspaceLabels(workspaceSlug.toString()) : null + ); + + const { workspaceMembers } = useWorkspaceMembers(workspaceSlug?.toString() ?? ""); + + const memberOptions = workspaceMembers?.map((m) => m.member); + + const { projects: allProjects } = useProjects(); + const joinedProjects = allProjects?.filter((p) => p.is_member); + + const handleCreateUpdateView = async (formData: IWorkspaceView) => { + await handleFormSubmit(formData); + + reset({ + ...defaultValues, + }); + }; + + const clearAllFilters = () => { + setValue("query", { + assignees: null, + created_by: null, + subscriber: null, + labels: null, + priority: null, + state_group: null, + start_date: null, + target_date: null, + project: null, + }); + }; + + useEffect(() => { + reset({ + ...defaultValues, + ...preLoadedData, + ...data, + }); + }, [data, preLoadedData, reset]); + + useEffect(() => { + if (status && data) { + setValue("query", data.query_data); + } + }, [data, status, setValue]); + + return ( + +
+

+ {status ? "Update" : "Create"} View +

+
+
+ +
+
+