From c9252c97137e08a3be6b84f8c21117740d1e02a2 Mon Sep 17 00:00:00 2001 From: Vamsi Kurama Date: Wed, 8 Mar 2023 01:07:00 +0530 Subject: [PATCH] dev: promote the develop to stage-release (#399) * chore: new link endpoints * chore: added created by info for link * chore: cannot have empty state group * feat: filtering for cycle and module issue and updated grouper function for grouping in modules and cycles (#342) * docs: github integration (#346) * fix: add pagination for github repositories endpoint (#345) * fix: remove bot accounts from list api (#344) * refactor: create new endpoints for date checking getting current upcoming and past cycles (#343) * refactor: create new endpoints for date checking getting current upcoming and past cycles * refactor: rename endpoint to match consistency * fix: remove project slug (#340) * refactor: update links to different endpoints (#338) * chore: cycle validation services and constants added * style: kanban board * chore: cycle type and services updated * chore: completed cycle dynamic importing and refactor * feat: cycle modal date validation * fix: build fix * style: redesigned sidebar, added new icons and spacing changes * style: changed app header color to white * feat: cover image selector for project create * style/projects_page * style: added dragging state design * fix: cycle form date * chore: draft cycle services and types * feat: draft tab and cycle sidebar update * style: projects list page * fix: image aspect ratio * style: assignee drop down label * style: new primary button design * style: assignee dropdown * style: assignee dropdown stlye fix * style: state dropdown redesign * style: dropdown ui consisteny * style: priority dropdown redesign * style: label dropdown redesign * style: issue dropdown re-order * style: state Icon * style: date dropdown redesign * fix: dropdown issue label * style: transsition * style: color fixed * chore: labels list file and function rename * style: redesigned create project modal style: changed image picker to pop-over instread of modal * fix: upload button on workspace settings page not working, UX of workspace settings image upload * feat: date range status function added * style: project settings pages * fix: merge conflicts * fix: mutation fix and date range helper fn added * style: workspace settings pages * style: dropdowns, feat: favorite projects in sidebar * feat: global component for combobox with new design * feat: custom context menu for issues in kanban board * refactor: global context menu component * chore: updated context menu component * chore: updated sidebar selects * style: kanban horizontal scrollbar added (#372) * style: new cycle list (#374) * feat: short date helper function * feat: linear progress indicator added * style: new cyce list and cycle card design * feat: short date function improve * feat: linear progress indicator improvement * style: cycle card and progress indicator * fix: helper date function and progress indicator fix * fix: build error --------- Co-authored-by: Aaryan Khandelwal * chore: updated project favorites endpoints (#375) * feat: favorite cycle and style: style improvements (#376) * style: consistent btn * style: caret direction for disclosure * fix: progress tooltip value rounded * chore: favorite cycle serivces * chore: favorite cycle type and constant * feat: favorite cycle feat added * refactor: favorite services and type * fix: build fix * refactor: sidebar projects menu (#377) * feat: add endpoint for draft cycles and add validation for creating draft cycles (#355) * feat: add endpoint for draft cycles and add validation for creating draft cycles * fix: key error in cycle create endpoint * feat: delete file assets from storage (#373) * chore: rename past cycle to completed cycle (#347) * fix: workspace member listing endpoint (#348) * fix: module issue viewset typo (#349) * feat: add project to favourites (#352) * feat: add project to favourites * feat: add project is_favourite attribute to list endpoints * refactor: updated destroy endpoint to send project_id * chore: nomenclature update * feat: add cover image to project (#353) * fix: cycle date filtering for current and upcoming cycle (#357) * fix: update filtering for completed cycles * fix: filter updated for upcoming cycles * fix: cycle and module issue filtering (#363) * feat: already exisiting url validation (#368) * feat: cycle favourites for user (#369) * feat: cycle favourites for user * chore: update nomenclature * chore: update on nomenclature * feat: add favorites for completed and current cycle endpoints * feat: module favourites for user (#370) * feat: added floating toolbar on text selection (#378) style: re-designed create-issue modal * dev: migrations added for ProjectFavorite, ModuleFavorite, CycleFavorite including a bunch of other attribs * chore: cycles loading, fix: cycles favorite mutation (#379) * style: cycle sidebar, fix: cycle card bug fix (#383) * style: new cycle sidebar * style: other information section * style: progress bar bg fix * fix: cycle card bug fix * style: progress chart * style: chart tooltip * style : module sidebar (#385) * style: new cycle sidebar * style: other information section * style: progress bar bg fix * fix: cycle card bug fix * style: progress chart * style: chart tooltip * style: module link tab added in sidebar stats * style: lead and member select * fix: text selection moving when typing in between (#384) * feat: added floating toolbar on text selection (#386) style: re-designed create-issue modal * style :module list (#387) * chore: module favorite type and services * style: module list * style: module list and card * fix: link fix * style: truncate (#388) * style: truncate * fix: truncate text added to cycle and module card * fix: custom menu link item (#390) * fix: ui fixes (#392) * fix: ui fixes * chore: kanban issue title length * style: ui fix (#393) * style: truncate * fix: truncate text added to cycle and module card * fix: progress percentage * feat: cycle card tooltip * fix: sidebar fix * fix: edit module mutation error (#394) * fix: issue details mutation (#389) * fix: ui improvement (#395) * fix: current cycle date updation * fix: sidebar overflow fix , date helper fn added * chore: update module dropdowns (#396) * fix: project member filter for bot accounts (#391) * fix: make api token only view once (#382) * dev: add back migration for project cover images (#381) * fix: rename db host name for docker setup (#380) * dev: promote to staging (#397) Signed-off-by: dependabot[bot] Co-authored-by: sriram veeraghanta Co-authored-by: pablohashescobar Co-authored-by: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Co-authored-by: Anmol Singh Bhatia Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com> Co-authored-by: Aaryan Khandelwal Co-authored-by: Narayana * Revert "dev: promote to staging (#397)" (#398) This reverts commit f7405ba1d61f5334cc47efcd96ec17fa66a0f188. --------- Signed-off-by: dependabot[bot] Co-authored-by: Aaryan Khandelwal Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Co-authored-by: pablohashescobar <118773738+pablohashescobar@users.noreply.github.com> Co-authored-by: sphynxux <122926002+sphynxux@users.noreply.github.com> Co-authored-by: Anmol Singh Bhatia Co-authored-by: Dakshesh Jain Co-authored-by: Dakshesh Jain <65905942+dakshesh14@users.noreply.github.com> Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Co-authored-by: sriram veeraghanta Co-authored-by: pablohashescobar Co-authored-by: Anmol Singh Bhatia Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Narayana --- apiserver/.env.example | 2 +- apiserver/back_migration.py | 40 +- apiserver/plane/api/serializers/__init__.py | 12 +- apiserver/plane/api/serializers/api_token.py | 8 +- apiserver/plane/api/serializers/cycle.py | 18 +- apiserver/plane/api/serializers/issue.py | 68 +- apiserver/plane/api/serializers/module.py | 72 +- apiserver/plane/api/serializers/project.py | 18 +- apiserver/plane/api/urls.py | 135 +++ apiserver/plane/api/views/__init__.py | 19 +- apiserver/plane/api/views/api_token.py | 6 +- apiserver/plane/api/views/asset.py | 21 +- apiserver/plane/api/views/cycle.py | 278 +++++- .../plane/api/views/integration/github.py | 6 +- apiserver/plane/api/views/issue.py | 29 +- apiserver/plane/api/views/module.py | 140 ++- apiserver/plane/api/views/project.py | 87 +- apiserver/plane/api/views/workspace.py | 20 +- .../db/migrations/0022_auto_20230307_0304.py | 101 ++ apiserver/plane/db/models/__init__.py | 5 +- apiserver/plane/db/models/cycle.py | 37 +- apiserver/plane/db/models/issue.py | 1 + apiserver/plane/db/models/module.py | 38 +- apiserver/plane/db/models/project.py | 22 +- apiserver/plane/utils/grouper.py | 32 +- .../command-palette/command-pallette.tsx | 4 +- .../components/core/board-view/all-boards.tsx | 75 +- .../core/board-view/board-header.tsx | 33 +- .../core/board-view/single-board.tsx | 40 +- .../core/board-view/single-issue.tsx | 248 +++-- .../core/existing-issues-list-modal.tsx | 14 +- .../components/core/image-picker-popover.tsx | 152 +++ apps/app/components/core/index.ts | 1 + apps/app/components/core/issues-view.tsx | 17 +- apps/app/components/core/link-modal.tsx | 2 +- .../components/core/list-view/all-lists.tsx | 3 + .../core/list-view/single-issue.tsx | 238 +++-- .../components/core/list-view/single-list.tsx | 3 + .../components/core/multi-level-select.tsx | 8 +- .../components/core/sidebar/links-list.tsx | 5 +- .../core/sidebar/progress-chart.tsx | 117 +-- .../core/sidebar/sidebar-progress-stats.tsx | 109 ++- .../core/sidebar/single-progress-stats.tsx | 8 +- .../cycles/completed-cycles-list.tsx | 92 ++ .../components/cycles/cycles-list-view.tsx | 72 -- apps/app/components/cycles/cycles-list.tsx | 82 ++ apps/app/components/cycles/form.tsx | 111 ++- apps/app/components/cycles/index.ts | 3 +- apps/app/components/cycles/modal.tsx | 38 +- apps/app/components/cycles/sidebar.tsx | 527 ++++++----- .../components/cycles/single-cycle-card.tsx | 382 ++++++-- .../components/emoji-icon-picker/index.tsx | 22 +- .../icons/assignment-clipboard-icon.tsx | 20 + .../components/icons/backlog-state-icon.tsx | 21 + .../components/icons/cancelled-state-icon.tsx | 78 ++ .../components/icons/completed-state-icon.tsx | 69 ++ apps/app/components/icons/contrast-icon.tsx | 25 + apps/app/components/icons/grid-view-icons.tsx | 24 + apps/app/components/icons/heartbeat-icon.tsx | 41 +- apps/app/components/icons/index.ts | 12 + .../components/icons/layer-diagonal-icon.tsx | 32 +- .../components/icons/people-group-icon.tsx | 24 + apps/app/components/icons/setting-icon.tsx | 35 +- .../components/icons/started-state-icon.tsx | 77 ++ .../app/components/icons/state-group-icon.tsx | 29 + apps/app/components/icons/tick-mark-icon.tsx | 24 + .../components/icons/unstarted-state-icon.tsx | 59 ++ .../components/issues/comment/add-comment.tsx | 2 +- .../components/issues/description-form.tsx | 69 +- apps/app/components/issues/form.tsx | 52 +- apps/app/components/issues/modal.tsx | 4 +- .../components/issues/my-issues-list-item.tsx | 4 +- .../app/components/issues/select/assignee.tsx | 137 +-- apps/app/components/issues/select/date.tsx | 70 ++ apps/app/components/issues/select/index.ts | 1 + apps/app/components/issues/select/label.tsx | 160 +++- .../app/components/issues/select/priority.tsx | 69 +- apps/app/components/issues/select/project.tsx | 104 +-- apps/app/components/issues/select/state.tsx | 135 +-- .../issues/sidebar-select/assignee.tsx | 157 ++-- .../issues/sidebar-select/cycle.tsx | 27 +- .../issues/sidebar-select/module.tsx | 27 +- .../issues/sidebar-select/priority.tsx | 65 +- .../issues/sidebar-select/state.tsx | 98 +- apps/app/components/issues/sidebar.tsx | 60 +- .../issues/view-select/assignee.tsx | 131 ++- .../issues/view-select/priority.tsx | 57 +- .../components/issues/view-select/state.tsx | 79 +- .../labels/create-update-label-inline.tsx | 2 +- .../components/labels/labels-list-modal.tsx | 4 +- .../components/labels/single-label-group.tsx | 52 +- apps/app/components/labels/single-label.tsx | 8 +- apps/app/components/modules/form.tsx | 16 +- apps/app/components/modules/modal.tsx | 16 +- .../components/modules/select/select-lead.tsx | 77 +- .../modules/select/select-members.tsx | 75 +- .../modules/select/select-status.tsx | 59 +- .../modules/sidebar-select/select-lead.tsx | 202 ++-- .../modules/sidebar-select/select-members.tsx | 163 ++-- apps/app/components/modules/sidebar.tsx | 557 ++++++----- .../components/modules/single-module-card.tsx | 283 ++++-- apps/app/components/popup/index.tsx | 25 +- apps/app/components/project/card.tsx | 122 --- .../project/create-project-modal.tsx | 232 ++--- ...-deletion.tsx => delete-project-modal.tsx} | 10 +- apps/app/components/project/index.ts | 4 +- .../components/project/join-project-modal.tsx | 12 +- .../project/send-project-invitation-modal.tsx | 107 +-- apps/app/components/project/sidebar-list.tsx | 333 +++---- .../project/single-integration-card.tsx | 14 +- .../project/single-project-card.tsx | 244 +++++ .../project/single-sidebar-project.tsx | 161 ++++ .../app/components/rich-text-editor/index.tsx | 59 +- .../toolbar/float-tool-tip.tsx | 46 + .../rich-text-editor/toolbar/link.tsx | 4 +- apps/app/components/sidebar/projects-list.tsx | 193 ---- .../components/sidebar/workspace-options.tsx | 74 -- .../components/states/create-state-modal.tsx | 34 +- .../states/create-update-state-inline.tsx | 9 +- apps/app/components/states/single-state.tsx | 53 +- apps/app/components/ui/avatar.tsx | 20 +- apps/app/components/ui/button/index.ts | 1 + .../components/ui/button/primary-button.tsx | 32 + apps/app/components/ui/context-menu.tsx | 99 ++ apps/app/components/ui/custom-listbox.tsx | 133 --- apps/app/components/ui/custom-menu.tsx | 94 +- .../components/ui/custom-search-select.tsx | 269 ++++++ apps/app/components/ui/custom-select.tsx | 64 +- apps/app/components/ui/datepicker.tsx | 8 +- apps/app/components/ui/empty-space.tsx | 2 +- apps/app/components/ui/index.ts | 6 +- apps/app/components/ui/labels-list.tsx | 32 + .../ui/linear-progress-indicator.tsx | 32 + apps/app/components/ui/progress-bar.tsx | 2 +- apps/app/components/ui/select.tsx | 60 -- apps/app/components/ui/tooltip.tsx | 16 +- .../app/components/workspace/help-section.tsx | 75 +- .../send-workspace-invitation-modal.tsx | 58 +- .../components/workspace/sidebar-dropdown.tsx | 70 +- .../app/components/workspace/sidebar-menu.tsx | 27 +- apps/app/constants/cycle.ts | 5 - apps/app/constants/fetch-keys.ts | 6 + apps/app/helpers/date-time.helper.ts | 57 ++ apps/app/hooks/use-project-members.tsx | 18 +- apps/app/hooks/use-projects.tsx | 6 +- apps/app/layouts/app-layout/app-header.tsx | 2 +- apps/app/layouts/app-layout/app-sidebar.tsx | 4 +- apps/app/layouts/app-layout/index.tsx | 147 +-- .../settings-layout/settings-sidebar.tsx | 41 - apps/app/layouts/settings-navbar.tsx | 85 ++ apps/app/next.config.js | 1 + .../projects/[projectId]/cycles/[cycleId].tsx | 6 + .../projects/[projectId]/cycles/index.tsx | 238 +++-- .../projects/[projectId]/issues/[issueId].tsx | 4 +- .../projects/[projectId]/modules/index.tsx | 20 +- .../projects/[projectId]/settings/control.tsx | 242 +++-- .../[projectId]/settings/features.tsx | 145 +-- .../projects/[projectId]/settings/index.tsx | 340 ++++--- .../[projectId]/settings/integrations.tsx | 23 +- .../projects/[projectId]/settings/labels.tsx | 44 +- .../projects/[projectId]/settings/members.tsx | 231 ++--- .../projects/[projectId]/settings/states.tsx | 23 +- .../pages/[workspaceSlug]/projects/index.tsx | 27 +- .../[workspaceSlug]/settings/billing.tsx | 2 +- .../pages/[workspaceSlug]/settings/index.tsx | 223 ++--- .../[workspaceSlug]/settings/integrations.tsx | 9 +- .../[workspaceSlug]/settings/members.tsx | 224 ++--- apps/app/pages/invitations.tsx | 5 +- apps/app/public/logos/github-square.png | Bin 0 -> 2352 bytes apps/app/services/cycles.service.ts | 66 +- apps/app/services/file.service.ts | 42 + apps/app/services/issues.service.ts | 35 + apps/app/services/modules.service.ts | 57 ++ apps/app/services/project.service.ts | 34 +- apps/app/styles/globals.css | 24 +- apps/app/tailwind.config.js | 1 + apps/app/types/cycles.d.ts | 22 +- apps/app/types/issues.d.ts | 1 + apps/app/types/modules.d.ts | 2 + apps/app/types/projects.d.ts | 14 + apps/app/types/workspace.d.ts | 2 +- apps/docs/src/components/Navigation.jsx | 1 + apps/docs/src/pages/integrations.mdx | 65 ++ yarn.lock | 880 +++++++++--------- 184 files changed, 8165 insertions(+), 5308 deletions(-) create mode 100644 apiserver/plane/db/migrations/0022_auto_20230307_0304.py create mode 100644 apps/app/components/core/image-picker-popover.tsx create mode 100644 apps/app/components/cycles/completed-cycles-list.tsx delete mode 100644 apps/app/components/cycles/cycles-list-view.tsx create mode 100644 apps/app/components/cycles/cycles-list.tsx create mode 100644 apps/app/components/icons/assignment-clipboard-icon.tsx create mode 100644 apps/app/components/icons/backlog-state-icon.tsx create mode 100644 apps/app/components/icons/cancelled-state-icon.tsx create mode 100644 apps/app/components/icons/completed-state-icon.tsx create mode 100644 apps/app/components/icons/contrast-icon.tsx create mode 100644 apps/app/components/icons/grid-view-icons.tsx create mode 100644 apps/app/components/icons/people-group-icon.tsx create mode 100644 apps/app/components/icons/started-state-icon.tsx create mode 100644 apps/app/components/icons/state-group-icon.tsx create mode 100644 apps/app/components/icons/tick-mark-icon.tsx create mode 100644 apps/app/components/icons/unstarted-state-icon.tsx create mode 100644 apps/app/components/issues/select/date.tsx delete mode 100644 apps/app/components/project/card.tsx rename apps/app/components/project/{confirm-project-deletion.tsx => delete-project-modal.tsx} (95%) create mode 100644 apps/app/components/project/single-project-card.tsx create mode 100644 apps/app/components/project/single-sidebar-project.tsx create mode 100644 apps/app/components/rich-text-editor/toolbar/float-tool-tip.tsx delete mode 100644 apps/app/components/sidebar/projects-list.tsx delete mode 100644 apps/app/components/sidebar/workspace-options.tsx create mode 100644 apps/app/components/ui/button/index.ts create mode 100644 apps/app/components/ui/button/primary-button.tsx create mode 100644 apps/app/components/ui/context-menu.tsx delete mode 100644 apps/app/components/ui/custom-listbox.tsx create mode 100644 apps/app/components/ui/custom-search-select.tsx create mode 100644 apps/app/components/ui/labels-list.tsx create mode 100644 apps/app/components/ui/linear-progress-indicator.tsx delete mode 100644 apps/app/components/ui/select.tsx delete mode 100644 apps/app/constants/cycle.ts delete mode 100644 apps/app/layouts/settings-layout/settings-sidebar.tsx create mode 100644 apps/app/layouts/settings-navbar.tsx create mode 100644 apps/app/public/logos/github-square.png create mode 100644 apps/docs/src/pages/integrations.mdx diff --git a/apiserver/.env.example b/apiserver/.env.example index 9a6904b55..3d502fadb 100644 --- a/apiserver/.env.example +++ b/apiserver/.env.example @@ -1,7 +1,7 @@ SECRET_KEY="<-- django secret -->" DJANGO_SETTINGS_MODULE="plane.settings.production" # Database -DATABASE_URL=postgres://plane:plane@plane-db-1:5432/plane +DATABASE_URL=postgres://plane:plane@db:5432/plane # Cache REDIS_URL=redis://redis:6379/ # SMPT diff --git a/apiserver/back_migration.py b/apiserver/back_migration.py index f716ea29f..93f07134f 100644 --- a/apiserver/back_migration.py +++ b/apiserver/back_migration.py @@ -3,7 +3,7 @@ import uuid import random from django.contrib.auth.hashers import make_password from plane.db.models import ProjectIdentifier -from plane.db.models import Issue, IssueComment, User +from plane.db.models import Issue, IssueComment, User, Project # Update description and description html values for old descriptions @@ -96,3 +96,41 @@ def updated_issue_sort_order(): except Exception as e: print(e) print("Failed") + + +def update_project_cover_images(): + try: + project_cover_images = [ + "https://images.unsplash.com/photo-1677432658720-3d84f9d657b4?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + "https://images.unsplash.com/photo-1661107564401-57497d8fe86f?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80", + "https://images.unsplash.com/photo-1677352241429-dc90cfc7a623?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80", + "https://images.unsplash.com/photo-1677196728306-eeafea692454?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1331&q=80", + "https://images.unsplash.com/photo-1660902179734-c94c944f7830?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1255&q=80", + "https://images.unsplash.com/photo-1672243775941-10d763d9adef?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + "https://images.unsplash.com/photo-1677040628614-53936ff66632?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + "https://images.unsplash.com/photo-1676920410907-8d5f8dd4b5ba?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80", + "https://images.unsplash.com/photo-1676846328604-ce831c481346?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1155&q=80", + "https://images.unsplash.com/photo-1676744843212-09b7e64c3a05?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + "https://images.unsplash.com/photo-1676798531090-1608bedeac7b?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + "https://images.unsplash.com/photo-1597088758740-56fd7ec8a3f0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1169&q=80", + "https://images.unsplash.com/photo-1676638392418-80aad7c87b96?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80", + "https://images.unsplash.com/photo-1649639194967-2fec0b4ea7bc?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + "https://images.unsplash.com/photo-1675883086902-b453b3f8146e?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80", + "https://images.unsplash.com/photo-1675887057159-40fca28fdc5d?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1173&q=80", + "https://images.unsplash.com/photo-1675373980203-f84c5a672aa5?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + "https://images.unsplash.com/photo-1675191475318-d2bf6bad1200?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80", + "https://images.unsplash.com/photo-1675456230532-2194d0c4bcc0?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1170&q=80", + "https://images.unsplash.com/photo-1675371788315-60fa0ef48267?ixlib=rb-4.0.3&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1332&q=80", + ] + + projects = Project.objects.all() + updated_projects = [] + for project in projects: + project.cover_image = project_cover_images[random.randint(0, 19)] + updated_projects.append(project) + + Project.objects.bulk_update(updated_projects, ["cover_image"], batch_size=100) + print("Success") + except Exception as e: + print(e) + print("Failed") diff --git a/apiserver/plane/api/serializers/__init__.py b/apiserver/plane/api/serializers/__init__.py index 183129939..9814ace37 100644 --- a/apiserver/plane/api/serializers/__init__.py +++ b/apiserver/plane/api/serializers/__init__.py @@ -17,11 +17,12 @@ from .project import ( ProjectMemberSerializer, ProjectMemberInviteSerializer, ProjectIdentifierSerializer, + ProjectFavoriteSerializer, ) from .state import StateSerializer from .shortcut import ShortCutSerializer from .view import ViewSerializer -from .cycle import CycleSerializer, CycleIssueSerializer +from .cycle import CycleSerializer, CycleIssueSerializer, CycleFavoriteSerializer from .asset import FileAssetSerializer from .issue import ( IssueCreateSerializer, @@ -36,9 +37,16 @@ from .issue import ( IssueSerializer, IssueFlatSerializer, IssueStateSerializer, + IssueLinkSerializer, ) -from .module import ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer +from .module import ( + ModuleWriteSerializer, + ModuleSerializer, + ModuleIssueSerializer, + ModuleLinkSerializer, + ModuleFavoriteSerializer, +) from .api_token import APITokenSerializer diff --git a/apiserver/plane/api/serializers/api_token.py b/apiserver/plane/api/serializers/api_token.py index 247b3f0e7..9c363f895 100644 --- a/apiserver/plane/api/serializers/api_token.py +++ b/apiserver/plane/api/serializers/api_token.py @@ -5,4 +5,10 @@ from plane.db.models import APIToken class APITokenSerializer(BaseSerializer): class Meta: model = APIToken - fields = "__all__" + fields = [ + "label", + "user", + "user_type", + "workspace", + "created_at", + ] diff --git a/apiserver/plane/api/serializers/cycle.py b/apiserver/plane/api/serializers/cycle.py index 09f35b669..d96a70d8c 100644 --- a/apiserver/plane/api/serializers/cycle.py +++ b/apiserver/plane/api/serializers/cycle.py @@ -5,12 +5,12 @@ from rest_framework import serializers from .base import BaseSerializer from .user import UserLiteSerializer from .issue import IssueStateSerializer -from plane.db.models import Cycle, CycleIssue +from plane.db.models import Cycle, CycleIssue, CycleFavorite class CycleSerializer(BaseSerializer): - owned_by = UserLiteSerializer(read_only=True) + is_favorite = serializers.BooleanField(read_only=True) class Meta: model = Cycle @@ -23,7 +23,6 @@ class CycleSerializer(BaseSerializer): class CycleIssueSerializer(BaseSerializer): - issue_detail = IssueStateSerializer(read_only=True, source="issue") sub_issues_count = serializers.IntegerField(read_only=True) @@ -35,3 +34,16 @@ class CycleIssueSerializer(BaseSerializer): "project", "cycle", ] + + +class CycleFavoriteSerializer(BaseSerializer): + cycle_detail = CycleSerializer(source="cycle", read_only=True) + + class Meta: + model = CycleFavorite + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "user", + ] diff --git a/apiserver/plane/api/serializers/issue.py b/apiserver/plane/api/serializers/issue.py index 6a3c06e22..e934f5cbd 100644 --- a/apiserver/plane/api/serializers/issue.py +++ b/apiserver/plane/api/serializers/issue.py @@ -28,11 +28,6 @@ from plane.db.models import ( ) -class IssueLinkCreateSerializer(serializers.Serializer): - url = serializers.CharField(required=True) - title = serializers.CharField(required=False) - - class IssueFlatSerializer(BaseSerializer): ## Contain only flat fields @@ -82,11 +77,6 @@ class IssueCreateSerializer(BaseSerializer): write_only=True, required=False, ) - links_list = serializers.ListField( - child=IssueLinkCreateSerializer(), - write_only=True, - required=False, - ) class Meta: model = Issue @@ -105,7 +95,6 @@ class IssueCreateSerializer(BaseSerializer): assignees = validated_data.pop("assignees_list", None) labels = validated_data.pop("labels_list", None) blocks = validated_data.pop("blocks_list", None) - links = validated_data.pop("links_list", None) project = self.context["project"] issue = Issue.objects.create(**validated_data, project=project) @@ -174,24 +163,6 @@ class IssueCreateSerializer(BaseSerializer): batch_size=10, ) - if links is not None: - IssueLink.objects.bulk_create( - [ - IssueLink( - issue=issue, - project=project, - workspace=project.workspace, - created_by=issue.created_by, - updated_by=issue.updated_by, - title=link.get("title", None), - url=link.get("url", None), - ) - for link in links - ], - batch_size=10, - ignore_conflicts=True, - ) - return issue def update(self, instance, validated_data): @@ -199,7 +170,6 @@ class IssueCreateSerializer(BaseSerializer): assignees = validated_data.pop("assignees_list", None) labels = validated_data.pop("labels_list", None) blocks = validated_data.pop("blocks_list", None) - links = validated_data.pop("links_list", None) if blockers is not None: IssueBlocker.objects.filter(block=instance).delete() @@ -269,25 +239,6 @@ class IssueCreateSerializer(BaseSerializer): batch_size=10, ) - if links is not None: - IssueLink.objects.filter(issue=instance).delete() - IssueLink.objects.bulk_create( - [ - IssueLink( - issue=instance, - project=instance.project, - workspace=instance.project.workspace, - created_by=instance.created_by, - updated_by=instance.updated_by, - title=link.get("title", None), - url=link.get("url", None), - ) - for link in links - ], - batch_size=10, - ignore_conflicts=True, - ) - return super().update(instance, validated_data) @@ -456,6 +407,25 @@ class IssueLinkSerializer(BaseSerializer): class Meta: model = IssueLink fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "created_by", + "updated_by", + "created_at", + "updated_at", + "issue", + ] + + # Validation if url already exists + def create(self, validated_data): + if IssueLink.objects.filter( + url=validated_data.get("url"), issue_id=validated_data.get("issue_id") + ).exists(): + raise serializers.ValidationError( + {"error": "URL already exists for this Issue"} + ) + return IssueLink.objects.create(**validated_data) # Issue Serializer with state details diff --git a/apiserver/plane/api/serializers/module.py b/apiserver/plane/api/serializers/module.py index 2aa5ec208..bb317a330 100644 --- a/apiserver/plane/api/serializers/module.py +++ b/apiserver/plane/api/serializers/module.py @@ -7,27 +7,15 @@ from .user import UserLiteSerializer from .project import ProjectSerializer from .issue import IssueStateSerializer -from plane.db.models import User, Module, ModuleMember, ModuleIssue, ModuleLink - - -class LinkCreateSerializer(serializers.Serializer): - - url = serializers.CharField(required=True) - title = serializers.CharField(required=False) +from plane.db.models import User, Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite class ModuleWriteSerializer(BaseSerializer): - members_list = serializers.ListField( child=serializers.PrimaryKeyRelatedField(queryset=User.objects.all()), write_only=True, required=False, ) - links_list = serializers.ListField( - child=LinkCreateSerializer(), - write_only=True, - required=False, - ) class Meta: model = Module @@ -42,9 +30,7 @@ class ModuleWriteSerializer(BaseSerializer): ] def create(self, validated_data): - members = validated_data.pop("members_list", None) - links = validated_data.pop("links_list", None) project = self.context["project"] @@ -67,30 +53,10 @@ class ModuleWriteSerializer(BaseSerializer): ignore_conflicts=True, ) - if links is not None: - ModuleLink.objects.bulk_create( - [ - ModuleLink( - module=module, - project=project, - workspace=project.workspace, - created_by=module.created_by, - updated_by=module.updated_by, - title=link.get("title", None), - url=link.get("url", None), - ) - for link in links - ], - batch_size=10, - ignore_conflicts=True, - ) - return module def update(self, instance, validated_data): - members = validated_data.pop("members_list", None) - links = validated_data.pop("links_list", None) if members is not None: ModuleMember.objects.filter(module=instance).delete() @@ -110,25 +76,6 @@ class ModuleWriteSerializer(BaseSerializer): ignore_conflicts=True, ) - if links is not None: - ModuleLink.objects.filter(module=instance).delete() - ModuleLink.objects.bulk_create( - [ - ModuleLink( - module=instance, - project=instance.project, - workspace=instance.project.workspace, - created_by=instance.created_by, - updated_by=instance.updated_by, - title=link.get("title", None), - url=link.get("url", None), - ) - for link in links - ], - batch_size=10, - ignore_conflicts=True, - ) - return super().update(instance, validated_data) @@ -147,7 +94,6 @@ class ModuleFlatSerializer(BaseSerializer): class ModuleIssueSerializer(BaseSerializer): - module_detail = ModuleFlatSerializer(read_only=True, source="module") issue_detail = IssueStateSerializer(read_only=True, source="issue") sub_issues_count = serializers.IntegerField(read_only=True) @@ -167,7 +113,6 @@ class ModuleIssueSerializer(BaseSerializer): class ModuleLinkSerializer(BaseSerializer): - created_by_detail = UserLiteSerializer(read_only=True, source="created_by") class Meta: @@ -180,16 +125,17 @@ class ModuleLinkSerializer(BaseSerializer): "updated_by", "created_at", "updated_at", + "module", ] class ModuleSerializer(BaseSerializer): - project_detail = ProjectSerializer(read_only=True, source="project") lead_detail = UserLiteSerializer(read_only=True, source="lead") members_detail = UserLiteSerializer(read_only=True, many=True, source="members") issue_module = ModuleIssueSerializer(read_only=True, many=True) link_module = ModuleLinkSerializer(read_only=True, many=True) + is_favorite = serializers.BooleanField(read_only=True) class Meta: model = Module @@ -202,3 +148,15 @@ class ModuleSerializer(BaseSerializer): "created_at", "updated_at", ] + +class ModuleFavoriteSerializer(BaseSerializer): + module_detail = ModuleFlatSerializer(source="module", read_only=True) + + class Meta: + model = ModuleFavorite + fields = "__all__" + read_only_fields = [ + "workspace", + "project", + "user", + ] diff --git a/apiserver/plane/api/serializers/project.py b/apiserver/plane/api/serializers/project.py index cdc9adf36..61d09b4a8 100644 --- a/apiserver/plane/api/serializers/project.py +++ b/apiserver/plane/api/serializers/project.py @@ -13,6 +13,7 @@ from plane.db.models import ( ProjectMember, ProjectMemberInvite, ProjectIdentifier, + ProjectFavorite, ) @@ -44,7 +45,6 @@ class ProjectSerializer(BaseSerializer): return project def update(self, instance, validated_data): - identifier = validated_data.get("identifier", "").strip().upper() # If identifier is not passed update the project and return @@ -73,10 +73,10 @@ class ProjectSerializer(BaseSerializer): class ProjectDetailSerializer(BaseSerializer): - workspace = WorkSpaceSerializer(read_only=True) default_assignee = UserLiteSerializer(read_only=True) project_lead = UserLiteSerializer(read_only=True) + is_favorite = serializers.BooleanField(read_only=True) class Meta: model = Project @@ -84,7 +84,6 @@ class ProjectDetailSerializer(BaseSerializer): class ProjectMemberSerializer(BaseSerializer): - workspace = WorkSpaceSerializer(read_only=True) project = ProjectSerializer(read_only=True) member = UserLiteSerializer(read_only=True) @@ -95,7 +94,6 @@ class ProjectMemberSerializer(BaseSerializer): class ProjectMemberInviteSerializer(BaseSerializer): - project = ProjectSerializer(read_only=True) workspace = WorkSpaceSerializer(read_only=True) @@ -108,3 +106,15 @@ class ProjectIdentifierSerializer(BaseSerializer): class Meta: model = ProjectIdentifier fields = "__all__" + + +class ProjectFavoriteSerializer(BaseSerializer): + project_detail = ProjectSerializer(source="project", read_only=True) + + class Meta: + model = ProjectFavorite + fields = "__all__" + read_only_fields = [ + "workspace", + "user", + ] diff --git a/apiserver/plane/api/urls.py b/apiserver/plane/api/urls.py index f267ff16a..e75c29c12 100644 --- a/apiserver/plane/api/urls.py +++ b/apiserver/plane/api/urls.py @@ -52,6 +52,7 @@ from plane.api.views import ( ProjectJoinEndpoint, UserProjectInvitationsViewset, ProjectIdentifierEndpoint, + ProjectFavoritesViewSet, ## End Projects # Issues IssueViewSet, @@ -65,6 +66,8 @@ from plane.api.views import ( IssuePropertyViewSet, LabelViewSet, SubIssuesEndpoint, + IssueLinkViewSet, + ModuleLinkViewSet, ## End Issues # States StateViewSet, @@ -78,10 +81,16 @@ from plane.api.views import ( # Cycles CycleViewSet, CycleIssueViewSet, + CycleDateCheckEndpoint, + CurrentUpcomingCyclesEndpoint, + CompletedCyclesEndpoint, + CycleFavoriteViewSet, + DraftCyclesEndpoint, ## End Cycles # Modules ModuleViewSet, ModuleIssueViewSet, + ModuleFavoriteViewSet, ## End Modules # Api Tokens ApiTokenEndpoint, @@ -372,6 +381,25 @@ urlpatterns = [ ProjectMemberUserEndpoint.as_view(), name="project-view", ), + path( + "workspaces//user-favorite-projects/", + ProjectFavoritesViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project", + ), + path( + "workspaces//user-favorite-projects//", + ProjectFavoritesViewSet.as_view( + { + "delete": "destroy", + } + ), + name="project", + ), # End Projects # States path( @@ -490,6 +518,45 @@ urlpatterns = [ ), name="project-cycle", ), + path( + "workspaces//projects//cycles/date-check/", + CycleDateCheckEndpoint.as_view(), + name="project-cycle", + ), + path( + "workspaces//projects//cycles/current-upcoming-cycles/", + CurrentUpcomingCyclesEndpoint.as_view(), + name="project-cycle-upcoming", + ), + path( + "workspaces//projects//cycles/completed-cycles/", + CompletedCyclesEndpoint.as_view(), + name="project-cycle-completed", + ), + path( + "workspaces//projects//cycles/draft-cycles/", + DraftCyclesEndpoint.as_view(), + name="project-cycle-draft", + ), + path( + "workspaces//projects//user-favorite-cycles/", + CycleFavoriteViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="user-favorite-cycle", + ), + path( + "workspaces//projects//user-favorite-cycles//", + CycleFavoriteViewSet.as_view( + { + "delete": "destroy", + } + ), + name="user-favorite-cycle", + ), ## End Cycles # Issue path( @@ -555,6 +622,28 @@ urlpatterns = [ SubIssuesEndpoint.as_view(), name="sub-issues", ), + path( + "workspaces//projects//issues//issue-links/", + IssueLinkViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-links", + ), + path( + "workspaces//projects//issues//issue-links//", + IssueLinkViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-links", + ), ## End Issues ## Issue Activity path( @@ -641,6 +730,11 @@ urlpatterns = [ FileAssetEndpoint.as_view(), name="File Assets", ), + path( + "workspaces//file-assets//", + FileAssetEndpoint.as_view(), + name="File Assets", + ), ## End File Assets ## Modules path( @@ -687,6 +781,47 @@ urlpatterns = [ ), name="project-module-issues", ), + path( + "workspaces//projects//modules//module-links/", + ModuleLinkViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="project-issue-module-links", + ), + path( + "workspaces//projects//modules//module-links//", + ModuleLinkViewSet.as_view( + { + "get": "retrieve", + "put": "update", + "patch": "partial_update", + "delete": "destroy", + } + ), + name="project-issue-module-links", + ), + path( + "workspaces//projects//user-favorite-modules/", + ModuleFavoriteViewSet.as_view( + { + "get": "list", + "post": "create", + } + ), + name="user-favorite-module", + ), + path( + "workspaces//projects//user-favorite-modules//", + ModuleFavoriteViewSet.as_view( + { + "delete": "destroy", + } + ), + name="user-favorite-module", + ), ## End Modules # API Tokens path("api-tokens/", ApiTokenEndpoint.as_view(), name="api-tokens"), diff --git a/apiserver/plane/api/views/__init__.py b/apiserver/plane/api/views/__init__.py index 275642c50..2556fc7d9 100644 --- a/apiserver/plane/api/views/__init__.py +++ b/apiserver/plane/api/views/__init__.py @@ -11,6 +11,7 @@ from .project import ( ProjectJoinEndpoint, ProjectUserViewsEndpoint, ProjectMemberUserEndpoint, + ProjectFavoritesViewSet, ) from .people import ( UserEndpoint, @@ -39,7 +40,15 @@ from .workspace import ( from .state import StateViewSet from .shortcut import ShortCutViewSet from .view import ViewViewSet -from .cycle import CycleViewSet, CycleIssueViewSet +from .cycle import ( + CycleViewSet, + CycleIssueViewSet, + CycleDateCheckEndpoint, + CurrentUpcomingCyclesEndpoint, + CompletedCyclesEndpoint, + CycleFavoriteViewSet, + DraftCyclesEndpoint, +) from .asset import FileAssetEndpoint from .issue import ( IssueViewSet, @@ -52,6 +61,7 @@ from .issue import ( BulkDeleteIssuesEndpoint, UserWorkSpaceIssues, SubIssuesEndpoint, + IssueLinkViewSet, ) from .auth_extended import ( @@ -70,7 +80,12 @@ from .authentication import ( MagicSignInGenerateEndpoint, ) -from .module import ModuleViewSet, ModuleIssueViewSet +from .module import ( + ModuleViewSet, + ModuleIssueViewSet, + ModuleLinkViewSet, + ModuleFavoriteViewSet, +) from .api_token import ApiTokenEndpoint diff --git a/apiserver/plane/api/views/api_token.py b/apiserver/plane/api/views/api_token.py index 2508b06ac..a94ffb45c 100644 --- a/apiserver/plane/api/views/api_token.py +++ b/apiserver/plane/api/views/api_token.py @@ -28,7 +28,11 @@ class ApiTokenEndpoint(BaseAPIView): ) serializer = APITokenSerializer(api_token) - return Response(serializer.data, status=status.HTTP_201_CREATED) + # Token will be only vissible while creating + return Response( + {"api_token": serializer.data, "token": api_token.token}, + status=status.HTTP_201_CREATED, + ) except Exception as e: capture_exception(e) diff --git a/apiserver/plane/api/views/asset.py b/apiserver/plane/api/views/asset.py index 657383553..e5af2c080 100644 --- a/apiserver/plane/api/views/asset.py +++ b/apiserver/plane/api/views/asset.py @@ -11,7 +11,6 @@ from plane.api.serializers import FileAssetSerializer class FileAssetEndpoint(BaseAPIView): - parser_classes = (MultiPartParser, FormParser) """ @@ -27,7 +26,6 @@ class FileAssetEndpoint(BaseAPIView): try: serializer = FileAssetSerializer(data=request.data) if serializer.is_valid(): - if request.user.last_workspace_id is None: return Response( {"error": "Workspace id is required"}, @@ -43,3 +41,22 @@ class FileAssetEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + def delete(self, request, slug, pk): + try: + file_asset = FileAsset.objects.get(pk=pk, workspace__slug=slug) + # Delete the file from storage + file_asset.asset.delete(save=False) + # Delete the file object + file_asset.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except FileAsset.DoesNotExist: + return Response( + {"error": "File Asset doesn't exist"}, status=status.HTTP_404_NOT_FOUND + ) + 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 2b18aab96..9a1f40a6d 100644 --- a/apiserver/plane/api/views/cycle.py +++ b/apiserver/plane/api/views/cycle.py @@ -2,8 +2,10 @@ import json # Django imports -from django.db.models import OuterRef, Func, F +from django.db import IntegrityError +from django.db.models import OuterRef, Func, F, Q, Exists, OuterRef from django.core import serializers +from django.utils import timezone # Third party imports from rest_framework.response import Response @@ -11,11 +13,16 @@ from rest_framework import status from sentry_sdk import capture_exception # Module imports -from . import BaseViewSet -from plane.api.serializers import CycleSerializer, CycleIssueSerializer +from . import BaseViewSet, BaseAPIView +from plane.api.serializers import ( + CycleSerializer, + CycleIssueSerializer, + CycleFavoriteSerializer, +) from plane.api.permissions import ProjectEntityPermission -from plane.db.models import Cycle, CycleIssue, Issue +from plane.db.models import Cycle, CycleIssue, Issue, CycleFavorite from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.grouper import group_results class CycleViewSet(BaseViewSet): @@ -43,6 +50,54 @@ class CycleViewSet(BaseViewSet): .distinct() ) + def list(self, request, slug, project_id): + try: + subquery = CycleFavorite.objects.filter( + user=self.request.user, + cycle_id=OuterRef("pk"), + project_id=project_id, + workspace__slug=slug, + ) + cycles = self.get_queryset().annotate(is_favorite=Exists(subquery)) + return Response(CycleSerializer(cycles, many=True).data) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def create(self, request, slug, project_id): + try: + if ( + request.data.get("start_date", None) is None + and request.data.get("end_date", None) is None + ) or ( + request.data.get("start_date", None) is not None + and request.data.get("end_date", None) is not None + ): + serializer = CycleSerializer(data=request.data) + if serializer.is_valid(): + serializer.save( + project_id=project_id, + owned_by=request.user, + ) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response( + { + "error": "Both start date and end date are either required or are to be null" + }, + 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, + ) + class CycleIssueViewSet(BaseViewSet): serializer_class = CycleIssueSerializer @@ -52,6 +107,11 @@ class CycleIssueViewSet(BaseViewSet): ProjectEntityPermission, ] + filterset_fields = [ + "issue__labels__id", + "issue__assignees__id", + ] + def perform_create(self, serializer): serializer.save( project_id=self.kwargs.get("project_id"), @@ -80,6 +140,31 @@ class CycleIssueViewSet(BaseViewSet): .distinct() ) + def list(self, request, slug, project_id, cycle_id): + try: + order_by = request.GET.get("order_by", "created_at") + queryset = self.get_queryset().order_by(f"issue__{order_by}") + group_by = request.GET.get("group_by", False) + + cycle_issues = CycleIssueSerializer(queryset, many=True).data + + if group_by: + return Response( + group_results(cycle_issues, f"issue_detail.{group_by}"), + status=status.HTTP_200_OK, + ) + + return Response( + cycle_issues, + 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, + ) + def create(self, request, slug, project_id, cycle_id): try: issues = request.data.get("issues", []) @@ -175,3 +260,188 @@ class CycleIssueViewSet(BaseViewSet): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class CycleDateCheckEndpoint(BaseAPIView): + def post(self, request, slug, project_id): + try: + start_date = request.data.get("start_date") + end_date = request.data.get("end_date") + + cycles = Cycle.objects.filter( + Q(start_date__lte=start_date, end_date__gte=start_date) + | Q(start_date__gte=end_date, end_date__lte=end_date), + workspace__slug=slug, + project_id=project_id, + ) + + if cycles.exists(): + return Response( + { + "error": "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates", + "cycles": CycleSerializer(cycles, many=True).data, + "status": False, + } + ) + else: + return Response({"status": True}, 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 CurrentUpcomingCyclesEndpoint(BaseAPIView): + def get(self, request, slug, project_id): + try: + subquery = CycleFavorite.objects.filter( + user=self.request.user, + cycle_id=OuterRef("pk"), + project_id=project_id, + workspace__slug=slug, + ) + current_cycle = Cycle.objects.filter( + workspace__slug=slug, + project_id=project_id, + start_date__lte=timezone.now(), + end_date__gte=timezone.now(), + ).annotate(is_favorite=Exists(subquery)) + + upcoming_cycle = Cycle.objects.filter( + workspace__slug=slug, + project_id=project_id, + start_date__gt=timezone.now(), + ).annotate(is_favorite=Exists(subquery)) + + return Response( + { + "current_cycle": CycleSerializer(current_cycle, many=True).data, + "upcoming_cycle": CycleSerializer(upcoming_cycle, many=True).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, + ) + + +class CompletedCyclesEndpoint(BaseAPIView): + def get(self, request, slug, project_id): + try: + subquery = CycleFavorite.objects.filter( + user=self.request.user, + cycle_id=OuterRef("pk"), + project_id=project_id, + workspace__slug=slug, + ) + completed_cycles = Cycle.objects.filter( + workspace__slug=slug, + project_id=project_id, + end_date__lt=timezone.now(), + ).annotate(is_favorite=Exists(subquery)) + + return Response( + { + "completed_cycles": CycleSerializer( + completed_cycles, many=True + ).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, + ) + + +class DraftCyclesEndpoint(BaseAPIView): + def get(self, request, slug, project_id): + try: + draft_cycles = Cycle.objects.filter( + workspace__slug=slug, + project_id=project_id, + end_date=None, + start_date=None, + ) + + return Response( + {"draft_cycles": CycleSerializer(draft_cycles, many=True).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, + ) + + +class CycleFavoriteViewSet(BaseViewSet): + serializer_class = CycleFavoriteSerializer + model = CycleFavorite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related("cycle", "cycle__owned_by") + ) + + def create(self, request, slug, project_id): + try: + serializer = CycleFavoriteSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(user=request.user, 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 as e: + if "already exists" in str(e): + return Response( + {"error": "The cycle is already added to favorites"}, + status=status.HTTP_410_GONE, + ) + else: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + 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 destroy(self, request, slug, project_id, cycle_id): + try: + cycle_favorite = CycleFavorite.objects.get( + project=project_id, + user=request.user, + workspace__slug=slug, + cycle_id=cycle_id, + ) + cycle_favorite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except CycleFavorite.DoesNotExist: + return Response( + {"error": "Cycle is not in favorites"}, + 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, + ) diff --git a/apiserver/plane/api/views/integration/github.py b/apiserver/plane/api/views/integration/github.py index df8f1d0c2..5660e9d90 100644 --- a/apiserver/plane/api/views/integration/github.py +++ b/apiserver/plane/api/views/integration/github.py @@ -25,11 +25,15 @@ from plane.utils.integrations.github import get_github_repos class GithubRepositoriesEndpoint(BaseAPIView): def get(self, request, slug, workspace_integration_id): try: + page = request.GET.get("page", 1) workspace_integration = WorkspaceIntegration.objects.get( workspace__slug=slug, pk=workspace_integration_id ) access_tokens_url = workspace_integration.metadata["access_tokens_url"] - repositories_url = workspace_integration.metadata["repositories_url"] + repositories_url = ( + workspace_integration.metadata["repositories_url"] + + f"?per_page=100&page={page}" + ) repositories = get_github_repos(access_tokens_url, repositories_url) return Response(repositories, status=status.HTTP_200_OK) except WorkspaceIntegration.DoesNotExist: diff --git a/apiserver/plane/api/views/issue.py b/apiserver/plane/api/views/issue.py index 68797c296..ca40606ec 100644 --- a/apiserver/plane/api/views/issue.py +++ b/apiserver/plane/api/views/issue.py @@ -23,6 +23,7 @@ from plane.api.serializers import ( IssueSerializer, LabelSerializer, IssueFlatSerializer, + IssueLinkSerializer, ) from plane.api.permissions import ( ProjectEntityPermission, @@ -185,7 +186,7 @@ class IssueViewSet(BaseViewSet): ) issues = IssueSerializer(issue_queryset, many=True).data - + ## Grouping the results group_by = request.GET.get("group_by", False) if group_by: @@ -690,3 +691,29 @@ class SubIssuesEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class IssueLinkViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + model = IssueLink + serializer_class = IssueLinkSerializer + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + issue_id=self.kwargs.get("issue_id"), + ) + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(issue_id=self.kwargs.get("issue_id")) + .filter(project__project_projectmember__member=self.request.user) + .distinct() + ) diff --git a/apiserver/plane/api/views/module.py b/apiserver/plane/api/views/module.py index a1cda9834..ce74cfdff 100644 --- a/apiserver/plane/api/views/module.py +++ b/apiserver/plane/api/views/module.py @@ -3,7 +3,7 @@ import json # Django Imports from django.db import IntegrityError -from django.db.models import Prefetch, F, OuterRef, Func +from django.db.models import Prefetch, F, OuterRef, Func, Exists from django.core import serializers # Third party imports @@ -17,6 +17,8 @@ from plane.api.serializers import ( ModuleWriteSerializer, ModuleSerializer, ModuleIssueSerializer, + ModuleLinkSerializer, + ModuleFavoriteSerializer, ) from plane.api.permissions import ProjectEntityPermission from plane.db.models import ( @@ -25,8 +27,10 @@ from plane.db.models import ( Project, Issue, ModuleLink, + ModuleFavorite, ) from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.grouper import group_results class ModuleViewSet(BaseViewSet): @@ -97,14 +101,31 @@ class ModuleViewSet(BaseViewSet): status=status.HTTP_400_BAD_REQUEST, ) + def list(self, request, slug, project_id): + try: + subquery = ModuleFavorite.objects.filter( + user=self.request.user, + module_id=OuterRef("pk"), + project_id=project_id, + workspace__slug=slug, + ) + modules = self.get_queryset().annotate(is_favorite=Exists(subquery)) + return Response(ModuleSerializer(modules, many=True).data) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + class ModuleIssueViewSet(BaseViewSet): serializer_class = ModuleIssueSerializer model = ModuleIssue filterset_fields = [ - "issue__id", - "workspace__id", + "issue__labels__id", + "issue__assignees__id", ] permission_classes = [ @@ -140,6 +161,31 @@ class ModuleIssueViewSet(BaseViewSet): .distinct() ) + def list(self, request, slug, project_id, module_id): + try: + order_by = request.GET.get("order_by", "issue__created_at") + queryset = self.get_queryset().order_by(f"issue__{order_by}") + group_by = request.GET.get("group_by", False) + + module_issues = ModuleIssueSerializer(queryset, many=True).data + + if group_by: + return Response( + group_results(module_issues, f"issue_detail.{group_by}"), + status=status.HTTP_200_OK, + ) + + return Response( + module_issues, + 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, + ) + def create(self, request, slug, project_id, module_id): try: issues = request.data.get("issues", []) @@ -232,3 +278,91 @@ class ModuleIssueViewSet(BaseViewSet): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class ModuleLinkViewSet(BaseViewSet): + permission_classes = [ + ProjectEntityPermission, + ] + + model = ModuleLink + serializer_class = ModuleLinkSerializer + + def perform_create(self, serializer): + serializer.save( + project_id=self.kwargs.get("project_id"), + module_id=self.kwargs.get("module_id"), + ) + + def get_queryset(self): + return ( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(project_id=self.kwargs.get("project_id")) + .filter(module_id=self.kwargs.get("module_id")) + .filter(project__project_projectmember__member=self.request.user) + .distinct() + ) + + +class ModuleFavoriteViewSet(BaseViewSet): + serializer_class = ModuleFavoriteSerializer + model = ModuleFavorite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related("module") + ) + + def create(self, request, slug, project_id): + try: + serializer = ModuleFavoriteSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(user=request.user, 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 as e: + if "already exists" in str(e): + return Response( + {"error": "The module is already added to favorites"}, + status=status.HTTP_410_GONE, + ) + else: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + 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 destroy(self, request, slug, project_id, module_id): + try: + module_favorite = ModuleFavorite.objects.get( + project=project_id, + user=request.user, + workspace__slug=slug, + module_id=module_id, + ) + module_favorite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except ModuleFavorite.DoesNotExist: + return Response( + {"error": "Module is not in favorites"}, + 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, + ) diff --git a/apiserver/plane/api/views/project.py b/apiserver/plane/api/views/project.py index e24477ecd..0e51dd156 100644 --- a/apiserver/plane/api/views/project.py +++ b/apiserver/plane/api/views/project.py @@ -5,7 +5,7 @@ from datetime import datetime # Django imports from django.core.exceptions import ValidationError from django.db import IntegrityError -from django.db.models import Q +from django.db.models import Q, Exists, OuterRef from django.core.validators import validate_email from django.conf import settings @@ -22,6 +22,7 @@ from plane.api.serializers import ( ProjectMemberSerializer, ProjectDetailSerializer, ProjectMemberInviteSerializer, + ProjectFavoriteSerializer, ) from plane.api.permissions import ProjectBasePermission @@ -35,6 +36,7 @@ from plane.db.models import ( WorkspaceMember, State, TeamMember, + ProjectFavorite, ) from plane.db.models import ( @@ -73,6 +75,22 @@ class ProjectViewSet(BaseViewSet): .distinct() ) + def list(self, request, slug): + try: + subquery = ProjectFavorite.objects.filter( + user=self.request.user, + project_id=OuterRef("pk"), + workspace__slug=self.kwargs.get("slug"), + ) + projects = self.get_queryset().annotate(is_favorite=Exists(subquery)) + return Response(ProjectDetailSerializer(projects, many=True).data) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + def create(self, request, slug): try: workspace = Workspace.objects.get(slug=slug) @@ -345,6 +363,7 @@ class ProjectMemberViewSet(BaseViewSet): .get_queryset() .filter(workspace__slug=self.kwargs.get("slug")) .filter(project_id=self.kwargs.get("project_id")) + .filter(member__is_bot=False) .select_related("project") .select_related("member") .select_related("workspace", "workspace__owner") @@ -659,3 +678,69 @@ class ProjectMemberUserEndpoint(BaseAPIView): {"error": "Something went wrong please try again later"}, status=status.HTTP_400_BAD_REQUEST, ) + + +class ProjectFavoritesViewSet(BaseViewSet): + serializer_class = ProjectFavoriteSerializer + model = ProjectFavorite + + def get_queryset(self): + return self.filter_queryset( + super() + .get_queryset() + .filter(workspace__slug=self.kwargs.get("slug")) + .filter(user=self.request.user) + .select_related( + "project", "project__project_lead", "project__default_assignee" + ) + .select_related("workspace", "workspace__owner") + ) + + def perform_create(self, serializer): + serializer.save(user=self.request.user) + + def create(self, request, slug): + try: + serializer = ProjectFavoriteSerializer(data=request.data) + if serializer.is_valid(): + serializer.save(user=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except IntegrityError as e: + print(str(e)) + if "already exists" in str(e): + return Response( + {"error": "The project is already added to favorites"}, + status=status.HTTP_410_GONE, + ) + else: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_410_GONE, + ) + except Exception as e: + capture_exception(e) + return Response( + {"error": "Something went wrong please try again later"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + def destroy(self, request, slug, project_id): + try: + project_favorite = ProjectFavorite.objects.get( + project=project_id, user=request.user, workspace__slug=slug + ) + project_favorite.delete() + return Response(status=status.HTTP_204_NO_CONTENT) + except ProjectFavorite.DoesNotExist: + return Response( + {"error": "Project is not in favorites"}, + 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, + ) diff --git a/apiserver/plane/api/views/workspace.py b/apiserver/plane/api/views/workspace.py index 4b06275aa..cce222605 100644 --- a/apiserver/plane/api/views/workspace.py +++ b/apiserver/plane/api/views/workspace.py @@ -43,7 +43,6 @@ from plane.bgtasks.workspace_invitation_task import workspace_invitation class WorkSpaceViewSet(BaseViewSet): - model = Workspace serializer_class = WorkSpaceSerializer permission_classes = [ @@ -101,7 +100,6 @@ class WorkSpaceViewSet(BaseViewSet): class UserWorkSpacesEndpoint(BaseAPIView): - search_fields = [ "name", ] @@ -111,7 +109,6 @@ class UserWorkSpacesEndpoint(BaseAPIView): def get(self, request): try: - member_count = ( WorkspaceMember.objects.filter(workspace=OuterRef("id")) .order_by() @@ -163,14 +160,12 @@ class WorkSpaceAvailabilityCheckEndpoint(BaseAPIView): class InviteWorkspaceEndpoint(BaseAPIView): - permission_classes = [ WorkSpaceAdminPermission, ] def post(self, request, slug): try: - emails = request.data.get("emails", False) # Check if email is provided if not emails or not len(emails): @@ -267,7 +262,6 @@ class JoinWorkspaceEndpoint(BaseAPIView): def post(self, request, slug, pk): try: - workspace_invite = WorkspaceMemberInvite.objects.get( pk=pk, workspace__slug=slug ) @@ -286,7 +280,6 @@ class JoinWorkspaceEndpoint(BaseAPIView): workspace_invite.save() if workspace_invite.accepted: - # Check if the user created account after invitation user = User.objects.filter(email=email).first() @@ -334,7 +327,6 @@ class JoinWorkspaceEndpoint(BaseAPIView): class WorkspaceInvitationsViewset(BaseViewSet): - serializer_class = WorkSpaceMemberInviteSerializer model = WorkspaceMemberInvite @@ -352,7 +344,6 @@ class WorkspaceInvitationsViewset(BaseViewSet): class UserWorkspaceInvitationsEndpoint(BaseViewSet): - serializer_class = WorkSpaceMemberInviteSerializer model = WorkspaceMemberInvite @@ -366,7 +357,6 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet): def create(self, request): try: - invitations = request.data.get("invitations") workspace_invitations = WorkspaceMemberInvite.objects.filter( pk__in=invitations @@ -397,7 +387,6 @@ class UserWorkspaceInvitationsEndpoint(BaseViewSet): class WorkSpaceMemberViewSet(BaseViewSet): - serializer_class = WorkSpaceMemberSerializer model = WorkspaceMember @@ -414,14 +403,13 @@ class WorkSpaceMemberViewSet(BaseViewSet): return self.filter_queryset( super() .get_queryset() - .filter(workspace__slug=self.kwargs.get("slug")) + .filter(workspace__slug=self.kwargs.get("slug"), member__is_bot=False) .select_related("workspace", "workspace__owner") .select_related("member") ) class TeamMemberViewSet(BaseViewSet): - serializer_class = TeamSerializer model = Team permission_classes = [ @@ -443,9 +431,7 @@ class TeamMemberViewSet(BaseViewSet): ) def create(self, request, slug): - try: - members = list( WorkspaceMember.objects.filter( workspace__slug=slug, member__id__in=request.data.get("members", []) @@ -456,7 +442,6 @@ class TeamMemberViewSet(BaseViewSet): ) if len(members) != len(request.data.get("members", [])): - users = list(set(request.data.get("members", [])).difference(members)) users = User.objects.filter(pk__in=users) @@ -493,7 +478,6 @@ class TeamMemberViewSet(BaseViewSet): class UserWorkspaceInvitationEndpoint(BaseViewSet): - model = WorkspaceMemberInvite serializer_class = WorkSpaceMemberInviteSerializer @@ -513,7 +497,6 @@ class UserWorkspaceInvitationEndpoint(BaseViewSet): class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): def get(self, request): try: - user = User.objects.get(pk=request.user.id) last_workspace_id = user.last_workspace_id @@ -577,7 +560,6 @@ class WorkspaceMemberUserEndpoint(BaseAPIView): class WorkspaceMemberUserViewsEndpoint(BaseAPIView): def post(self, request, slug): try: - workspace_member = WorkspaceMember.objects.get( workspace__slug=slug, member=request.user ) diff --git a/apiserver/plane/db/migrations/0022_auto_20230307_0304.py b/apiserver/plane/db/migrations/0022_auto_20230307_0304.py new file mode 100644 index 000000000..25a8eef61 --- /dev/null +++ b/apiserver/plane/db/migrations/0022_auto_20230307_0304.py @@ -0,0 +1,101 @@ +# Generated by Django 3.2.16 on 2023-03-06 21:34 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('db', '0021_auto_20230223_0104'), + ] + + operations = [ + migrations.RemoveField( + model_name='cycle', + name='status', + ), + migrations.RemoveField( + model_name='project', + name='slug', + ), + migrations.AddField( + model_name='issuelink', + name='metadata', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='modulelink', + name='metadata', + field=models.JSONField(default=dict), + ), + migrations.AddField( + model_name='project', + name='cover_image', + field=models.URLField(blank=True, null=True), + ), + migrations.CreateModel( + name='ProjectFavorite', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectfavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_projectfavorite', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='projectfavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_favorites', to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_projectfavorite', to='db.workspace')), + ], + options={ + 'verbose_name': 'Project Favorite', + 'verbose_name_plural': 'Project Favorites', + 'db_table': 'project_favorites', + 'ordering': ('-created_at',), + 'unique_together': {('project', 'user')}, + }, + ), + migrations.CreateModel( + name='ModuleFavorite', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulefavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('module', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='module_favorites', to='db.module')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_modulefavorite', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='modulefavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='module_favorites', to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_modulefavorite', to='db.workspace')), + ], + options={ + 'verbose_name': 'Module Favorite', + 'verbose_name_plural': 'Module Favorites', + 'db_table': 'module_favorites', + 'ordering': ('-created_at',), + 'unique_together': {('module', 'user')}, + }, + ), + migrations.CreateModel( + name='CycleFavorite', + fields=[ + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created At')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Last Modified At')), + ('id', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cyclefavorite_created_by', to=settings.AUTH_USER_MODEL, verbose_name='Created By')), + ('cycle', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cycle_favorites', to='db.cycle')), + ('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_cyclefavorite', to='db.project')), + ('updated_by', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cyclefavorite_updated_by', to=settings.AUTH_USER_MODEL, verbose_name='Last Modified By')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cycle_favorites', to=settings.AUTH_USER_MODEL)), + ('workspace', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workspace_cyclefavorite', to='db.workspace')), + ], + options={ + 'verbose_name': 'Cycle Favorite', + 'verbose_name_plural': 'Cycle Favorites', + 'db_table': 'cycle_favorites', + 'ordering': ('-created_at',), + 'unique_together': {('cycle', 'user')}, + }, + ), + ] diff --git a/apiserver/plane/db/models/__init__.py b/apiserver/plane/db/models/__init__.py index ce8cf950b..09b44b422 100644 --- a/apiserver/plane/db/models/__init__.py +++ b/apiserver/plane/db/models/__init__.py @@ -16,6 +16,7 @@ from .project import ( ProjectBaseModel, ProjectMemberInvite, ProjectIdentifier, + ProjectFavorite, ) from .issue import ( @@ -38,13 +39,13 @@ from .social_connection import SocialLoginConnection from .state import State -from .cycle import Cycle, CycleIssue +from .cycle import Cycle, CycleIssue, CycleFavorite from .shortcut import Shortcut from .view import View -from .module import Module, ModuleMember, ModuleIssue, ModuleLink +from .module import Module, ModuleMember, ModuleIssue, ModuleLink, ModuleFavorite from .api_token import APIToken diff --git a/apiserver/plane/db/models/cycle.py b/apiserver/plane/db/models/cycle.py index c06ea40f2..6ecd3d3b0 100644 --- a/apiserver/plane/db/models/cycle.py +++ b/apiserver/plane/db/models/cycle.py @@ -7,11 +7,6 @@ from . import ProjectBaseModel class Cycle(ProjectBaseModel): - STATUS_CHOICES = ( - ("draft", "Draft"), - ("started", "Started"), - ("completed", "Completed"), - ) name = models.CharField(max_length=255, verbose_name="Cycle Name") description = models.TextField(verbose_name="Cycle Description", blank=True) start_date = models.DateField(verbose_name="Start Date", blank=True, null=True) @@ -21,12 +16,6 @@ class Cycle(ProjectBaseModel): on_delete=models.CASCADE, related_name="owned_by_cycle", ) - status = models.CharField( - max_length=255, - verbose_name="Cycle Status", - choices=STATUS_CHOICES, - default="draft", - ) class Meta: verbose_name = "Cycle" @@ -59,3 +48,29 @@ class CycleIssue(ProjectBaseModel): def __str__(self): return f"{self.cycle}" + + +class CycleFavorite(ProjectBaseModel): + """_summary_ + CycleFavorite (model): To store all the cycle favorite of the user + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="cycle_favorites", + ) + cycle = models.ForeignKey( + "db.Cycle", on_delete=models.CASCADE, related_name="cycle_favorites" + ) + + class Meta: + unique_together = ["cycle", "user"] + verbose_name = "Cycle Favorite" + verbose_name_plural = "Cycle Favorites" + db_table = "cycle_favorites" + ordering = ("-created_at",) + + def __str__(self): + """Return user and the cycle""" + return f"{self.user.email} <{self.cycle.name}>" diff --git a/apiserver/plane/db/models/issue.py b/apiserver/plane/db/models/issue.py index aea41677e..fc9971000 100644 --- a/apiserver/plane/db/models/issue.py +++ b/apiserver/plane/db/models/issue.py @@ -174,6 +174,7 @@ class IssueLink(ProjectBaseModel): issue = models.ForeignKey( "db.Issue", on_delete=models.CASCADE, related_name="issue_link" ) + metadata = models.JSONField(default=dict) class Meta: verbose_name = "Issue Link" diff --git a/apiserver/plane/db/models/module.py b/apiserver/plane/db/models/module.py index c5dfef588..ec8c401ab 100644 --- a/apiserver/plane/db/models/module.py +++ b/apiserver/plane/db/models/module.py @@ -7,7 +7,6 @@ from . import ProjectBaseModel class Module(ProjectBaseModel): - name = models.CharField(max_length=255, verbose_name="Module Name") description = models.TextField(verbose_name="Module Description", blank=True) description_text = models.JSONField( @@ -41,7 +40,6 @@ class Module(ProjectBaseModel): through_fields=("module", "member"), ) - class Meta: unique_together = ["name", "project"] verbose_name = "Module" @@ -54,7 +52,6 @@ class Module(ProjectBaseModel): class ModuleMember(ProjectBaseModel): - module = models.ForeignKey("db.Module", on_delete=models.CASCADE) member = models.ForeignKey("db.User", on_delete=models.CASCADE) @@ -70,7 +67,6 @@ class ModuleMember(ProjectBaseModel): class ModuleIssue(ProjectBaseModel): - module = models.ForeignKey( "db.Module", on_delete=models.CASCADE, related_name="issue_module" ) @@ -89,10 +85,12 @@ class ModuleIssue(ProjectBaseModel): class ModuleLink(ProjectBaseModel): - title = models.CharField(max_length=255, null=True) url = models.URLField() - module = models.ForeignKey(Module, on_delete=models.CASCADE, related_name="link_module") + module = models.ForeignKey( + Module, on_delete=models.CASCADE, related_name="link_module" + ) + metadata = models.JSONField(default=dict) class Meta: verbose_name = "Module Link" @@ -101,4 +99,30 @@ class ModuleLink(ProjectBaseModel): ordering = ("-created_at",) def __str__(self): - return f"{self.module.name} {self.url}" \ No newline at end of file + return f"{self.module.name} {self.url}" + + +class ModuleFavorite(ProjectBaseModel): + """_summary_ + ModuleFavorite (model): To store all the module favorite of the user + """ + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="module_favorites", + ) + module = models.ForeignKey( + "db.Module", on_delete=models.CASCADE, related_name="module_favorites" + ) + + class Meta: + unique_together = ["module", "user"] + verbose_name = "Module Favorite" + verbose_name_plural = "Module Favorites" + db_table = "module_favorites" + ordering = ("-created_at",) + + def __str__(self): + """Return user and the module""" + return f"{self.user.email} <{self.module.name}>" diff --git a/apiserver/plane/db/models/project.py b/apiserver/plane/db/models/project.py index 4a180642b..6153d0926 100644 --- a/apiserver/plane/db/models/project.py +++ b/apiserver/plane/db/models/project.py @@ -46,7 +46,6 @@ class Project(BaseModel): max_length=5, verbose_name="Project Identifier", ) - slug = models.SlugField(max_length=100, blank=True) default_assignee = models.ForeignKey( settings.AUTH_USER_MODEL, on_delete=models.CASCADE, @@ -64,6 +63,7 @@ class Project(BaseModel): icon = models.CharField(max_length=255, null=True, blank=True) module_view = models.BooleanField(default=True) cycle_view = models.BooleanField(default=True) + cover_image = models.URLField(blank=True, null=True) def __str__(self): """Return name of the project""" @@ -77,7 +77,6 @@ class Project(BaseModel): ordering = ("-created_at",) def save(self, *args, **kwargs): - self.slug = slugify(self.name) self.identifier = self.identifier.strip().upper() return super().save(*args, **kwargs) @@ -157,3 +156,22 @@ class ProjectIdentifier(AuditModel): verbose_name_plural = "Project Identifiers" db_table = "project_identifiers" ordering = ("-created_at",) + + +class ProjectFavorite(ProjectBaseModel): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="project_favorites", + ) + + class Meta: + unique_together = ["project", "user"] + verbose_name = "Project Favorite" + verbose_name_plural = "Project Favorites" + db_table = "project_favorites" + ordering = ("-created_at",) + + def __str__(self): + """Return user of the project""" + return f"{self.user.email} <{self.project.name}>" diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index 51c1f61c2..798b652fa 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -1,12 +1,34 @@ -def group_results(results_data, group_by): +def resolve_keys(group_keys, value): + """resolve keys to a key which will be used for + grouping + + Args: + group_keys (string): key which will be used for grouping + value (obj): data value + + Returns: + string: the key which will be used for """ - Utility function to group data into a given attribute. - Function can group attributes of string and list type. + keys = group_keys.split(".") + for key in keys: + value = value.get(key, None) + return value + + +def group_results(results_data, group_by): + """group results data into certain group_by + + Args: + results_data (obj): complete results data + group_by (key): string + + Returns: + obj: grouped results """ response_dict = dict() for value in results_data: - group_attribute = value.get(group_by, None) + group_attribute = resolve_keys(group_by, value) if isinstance(group_attribute, list): if len(group_attribute): for attrib in group_attribute: @@ -28,4 +50,4 @@ def group_results(results_data, group_by): response_dict[str(group_attribute)] = [] response_dict[str(group_attribute)].append(value) - return response_dict \ No newline at end of file + return response_dict diff --git a/apps/app/components/command-palette/command-pallette.tsx b/apps/app/components/command-palette/command-pallette.tsx index 4110d0a27..1aeb5d831 100644 --- a/apps/app/components/command-palette/command-pallette.tsx +++ b/apps/app/components/command-palette/command-pallette.tsx @@ -204,7 +204,7 @@ export const CommandPalette: React.FC = () => {
-
+
{ leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - + { if (value?.url) router.push(value.url); diff --git a/apps/app/components/core/board-view/all-boards.tsx b/apps/app/components/core/board-view/all-boards.tsx index f5b03267c..b81c3238f 100644 --- a/apps/app/components/core/board-view/all-boards.tsx +++ b/apps/app/components/core/board-view/all-boards.tsx @@ -11,6 +11,7 @@ type Props = { states: IState[] | undefined; members: IProjectMember[] | undefined; addIssueToState: (groupTitle: string, stateId: string | null) => void; + makeIssueCopy: (issue: IIssue) => void; handleEditIssue: (issue: IIssue) => void; openIssuesListModal?: (() => void) | null; handleDeleteIssue: (issue: IIssue) => void; @@ -25,6 +26,7 @@ export const AllBoards: React.FC = ({ states, members, addIssueToState, + makeIssueCopy, handleEditIssue, openIssuesListModal, handleDeleteIssue, @@ -37,43 +39,46 @@ export const AllBoards: React.FC = ({ return ( <> {groupedByIssues ? ( -
-
-
-
- {Object.keys(groupedByIssues).map((singleGroup, index) => { - const stateId = - selectedGroup === "state_detail.name" - ? states?.find((s) => s.name === singleGroup)?.id ?? null - : null; +
+
+ {Object.keys(groupedByIssues).map((singleGroup, index) => { + const currentState = + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup) + : null; - const bgColor = - selectedGroup === "state_detail.name" - ? states?.find((s) => s.name === singleGroup)?.color - : "#000000"; + const stateId = + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup)?.id ?? null + : null; - return ( - addIssueToState(singleGroup, stateId)} - handleDeleteIssue={handleDeleteIssue} - openIssuesListModal={openIssuesListModal ?? null} - orderBy={orderBy} - handleTrashBox={handleTrashBox} - removeIssue={removeIssue} - userAuth={userAuth} - /> - ); - })} -
-
+ const bgColor = + selectedGroup === "state_detail.name" + ? states?.find((s) => s.name === singleGroup)?.color + : "#000000"; + + return ( + addIssueToState(singleGroup, stateId)} + handleDeleteIssue={handleDeleteIssue} + openIssuesListModal={openIssuesListModal ?? null} + orderBy={orderBy} + handleTrashBox={handleTrashBox} + removeIssue={removeIssue} + userAuth={userAuth} + /> + ); + })}
) : ( diff --git a/apps/app/components/core/board-view/board-header.tsx b/apps/app/components/core/board-view/board-header.tsx index cf394ae5e..84d421190 100644 --- a/apps/app/components/core/board-view/board-header.tsx +++ b/apps/app/components/core/board-view/board-header.tsx @@ -1,22 +1,17 @@ import React from "react"; -// react-beautiful-dnd -import { DraggableProvided } from "react-beautiful-dnd"; // icons -import { - ArrowsPointingInIcon, - ArrowsPointingOutIcon, - EllipsisHorizontalIcon, - PlusIcon, -} from "@heroicons/react/24/outline"; +import { ArrowsPointingInIcon, ArrowsPointingOutIcon, PlusIcon } from "@heroicons/react/24/outline"; // helpers import { addSpaceIfCamelCase } from "helpers/string.helper"; // types -import { IIssue, IProjectMember, NestedKeyOf } from "types"; +import { IIssue, IProjectMember, IState, NestedKeyOf } from "types"; +import { getStateGroupIcon } from "components/icons"; type Props = { groupedByIssues: { [key: string]: IIssue[]; }; + currentState?: IState | null; selectedGroup: NestedKeyOf | null; groupTitle: string; bgColor?: string; @@ -28,6 +23,7 @@ type Props = { export const BoardHeader: React.FC = ({ groupedByIssues, + currentState, selectedGroup, groupTitle, bgColor, @@ -54,22 +50,19 @@ export const BoardHeader: React.FC = ({ return (
+ {currentState && getStateGroupIcon(currentState.group, "20", "20", bgColor)}

= ({ ? assignees : addSpaceIfCamelCase(groupTitle)}

- {groupedByIssues[groupTitle].length} + + {groupedByIssues[groupTitle].length} +
) : ( - - Add issue - + customButton={ + } - className="mt-1" optionsPosition="left" noBorder > diff --git a/apps/app/components/core/board-view/single-issue.tsx b/apps/app/components/core/board-view/single-issue.tsx index d3c9be75f..bee7b7797 100644 --- a/apps/app/components/core/board-view/single-issue.tsx +++ b/apps/app/components/core/board-view/single-issue.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect } from "react"; +import React, { useCallback, useEffect, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -24,9 +24,16 @@ import { ViewStateSelect, } from "components/issues/view-select"; // ui -import { CustomMenu } from "components/ui"; +import { ContextMenu, CustomMenu, Tooltip } from "components/ui"; +// icons +import { + ClipboardDocumentCheckIcon, + LinkIcon, + PencilIcon, + TrashIcon, +} from "@heroicons/react/24/outline"; // helpers -import { copyTextToClipboard } from "helpers/string.helper"; +import { copyTextToClipboard, truncateText } from "helpers/string.helper"; // types import { CycleIssueResponse, @@ -47,6 +54,7 @@ type Props = { selectedGroup: NestedKeyOf | null; properties: Properties; editIssue: () => void; + makeIssueCopy: () => void; removeIssue?: (() => void) | null; handleDeleteIssue: (issue: IIssue) => void; orderBy: NestedKeyOf | null; @@ -62,12 +70,17 @@ export const SingleBoardIssue: React.FC = ({ selectedGroup, properties, editIssue, + makeIssueCopy, removeIssue, handleDeleteIssue, orderBy, handleTrashBox, userAuth, }) => { + // context menu + const [contextMenu, setContextMenu] = useState(false); + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); + const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; @@ -88,6 +101,7 @@ export const SingleBoardIssue: React.FC = ({ issue_detail: { ...p.issue_detail, ...formData, + assignees: formData.assignees_list ?? p.issue_detail.assignees_list, }, }; } @@ -109,6 +123,7 @@ export const SingleBoardIssue: React.FC = ({ issue_detail: { ...p.issue_detail, ...formData, + assignees: formData.assignees_list ?? p.issue_detail.assignees_list, }, }; } @@ -123,7 +138,8 @@ export const SingleBoardIssue: React.FC = ({ PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), (prevData) => (prevData ?? []).map((p) => { - if (p.id === issue.id) return { ...p, ...formData }; + if (p.id === issue.id) + return { ...p, ...formData, assignees: formData.assignees_list ?? p.assignees_list }; return p; }), @@ -146,10 +162,10 @@ export const SingleBoardIssue: React.FC = ({ [workspaceSlug, projectId, cycleId, moduleId, issue] ); - function getStyle( + const getStyle = ( style: DraggingStyle | NotDraggingStyle | undefined, snapshot: DraggableStateSnapshot - ) { + ) => { if (orderBy === "sort_order") return style; if (!snapshot.isDragging) return {}; if (!snapshot.isDropAnimating) { @@ -160,7 +176,7 @@ export const SingleBoardIssue: React.FC = ({ ...style, transitionDuration: `0.001s`, }; - } + }; const handleCopyText = () => { const originURL = @@ -183,107 +199,135 @@ export const SingleBoardIssue: React.FC = ({ const isNotAllowed = userAuth.isGuest || userAuth.isViewer; return ( -
-
- {!isNotAllowed && ( -
- {type && !isNotAllowed && ( - - Edit issue - {type !== "issue" && removeIssue && ( - - <>Remove from {type} + <> + + + Edit issue + + + Make a copy... + + handleDeleteIssue(issue)}> + Delete issue + + + Copy issue link + + +
{ + e.preventDefault(); + setContextMenu(true); + setContextMenuPosition({ x: e.pageX, y: e.pageY }); + }} + > +
+ {!isNotAllowed && ( +
+ {type && !isNotAllowed && ( + + Edit issue + {type !== "issue" && removeIssue && ( + + <>Remove from {type} + + )} + handleDeleteIssue(issue)}> + Delete issue - )} - handleDeleteIssue(issue)}> - Delete issue - - Copy issue link - + + Copy issue link + + + )} +
+ )} + + + {properties.key && ( +
+ {issue.project_detail.identifier}-{issue.sequence_id} +
+ )} +
+ {truncateText(issue.name, 100)} +
+
+ +
+ {properties.priority && ( + )} -
- )} - - - {properties.key && ( -
- {issue.project_detail.identifier}-{issue.sequence_id} + {properties.state && ( + + )} + {properties.due_date && ( + + )} + {properties.sub_issue_count && ( +
+ {issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
)} -
- {issue.name} -
-
- -
- {properties.priority && selectedGroup !== "priority" && ( - - )} - {properties.state && selectedGroup !== "state_detail.name" && ( - - )} - {properties.due_date && ( - - )} - {properties.sub_issue_count && ( -
- {issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} -
- )} - {properties.labels && ( -
- {issue.label_details.map((label) => ( - + {properties.labels && issue.label_details.length > 0 && ( +
+ {issue.label_details.map((label) => ( - {label.name} - - ))} -
- )} - {properties.assignee && ( - - )} + key={label.id} + className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs" + > + + {label.name} + + ))} +
+ )} + {properties.assignee && ( + + )} +
-
+ ); }; diff --git a/apps/app/components/core/existing-issues-list-modal.tsx b/apps/app/components/core/existing-issues-list-modal.tsx index 59faff3f6..e28d53906 100644 --- a/apps/app/components/core/existing-issues-list-modal.tsx +++ b/apps/app/components/core/existing-issues-list-modal.tsx @@ -129,7 +129,7 @@ export const ExistingIssuesListModal: React.FC = ({ {filteredIssues.length > 0 ? (
  • {query === "" && ( -

    +

    Select issues to add

    )} @@ -175,18 +175,6 @@ export const ExistingIssuesListModal: React.FC = ({
  • )} - - {query !== "" && filteredIssues.length === 0 && ( -
    -
    - )} )} /> diff --git a/apps/app/components/core/image-picker-popover.tsx b/apps/app/components/core/image-picker-popover.tsx new file mode 100644 index 000000000..7a8ca12bb --- /dev/null +++ b/apps/app/components/core/image-picker-popover.tsx @@ -0,0 +1,152 @@ +import React, { useEffect, useState, useRef } from "react"; + +// next +import Image from "next/image"; + +// swr +import useSWR from "swr"; + +// headless ui +import { Tab, Transition, Popover } from "@headlessui/react"; + +// services +import fileService from "services/file.service"; + +// components +import { Input, Spinner } from "components/ui"; +import { PrimaryButton } from "components/ui/button/primary-button"; +// hooks +import useOutsideClickDetector from "hooks/use-outside-click-detector"; + +const tabOptions = [ + { + key: "unsplash", + title: "Unsplash", + }, + { + key: "upload", + title: "Upload", + }, +]; + +type Props = { + label: string | React.ReactNode; + value: string | null; + onChange: (data: string) => void; +}; + +export const ImagePickerPopover: React.FC = ({ label, value, onChange }) => { + const ref = useRef(null); + + const [isOpen, setIsOpen] = useState(false); + const [searchParams, setSearchParams] = useState(""); + const [formData, setFormData] = useState({ + search: "", + }); + + const { data: images } = useSWR(`UNSPLASH_IMAGES_${searchParams}`, () => + fileService.getUnsplashImages(1, searchParams) + ); + + useOutsideClickDetector(ref, () => { + setIsOpen(false); + }); + + useEffect(() => { + if (!images || value !== null) return; + onChange(images[0].urls.regular); + }, [value, onChange, images]); + + return ( + + setIsOpen((prev) => !prev)} + > + {label} + + + +
    + + + {tabOptions.map((tab) => ( + + `rounded py-1 px-4 text-center text-sm outline-none transition-colors ${ + selected ? "bg-theme text-white" : "text-black" + }` + } + > + {tab.title} + + ))} + + + +
    { + e.preventDefault(); + setSearchParams(formData.search); + }} + className="flex gap-x-2 pt-7" + > + setFormData({ ...formData, search: e.target.value })} + placeholder="Search for images" + /> + + Search + +
    + {images ? ( +
    + {images.map((image) => ( +
    + {image.alt_description} { + setIsOpen(false); + onChange(image.urls.regular); + }} + /> +
    + ))} +
    + ) : ( +
    + +
    + )} +
    + +

    Coming Soon...

    +
    +
    +
    +
    +
    +
    +
    + ); +}; diff --git a/apps/app/components/core/index.ts b/apps/app/components/core/index.ts index 01a190d07..c2d2f8929 100644 --- a/apps/app/components/core/index.ts +++ b/apps/app/components/core/index.ts @@ -9,3 +9,4 @@ export * from "./issues-view"; export * from "./link-modal"; export * from "./not-authorized-view"; export * from "./multi-level-select"; +export * from "./image-picker-popover"; diff --git a/apps/app/components/core/issues-view.tsx b/apps/app/components/core/issues-view.tsx index 98526a2b0..779338321 100644 --- a/apps/app/components/core/issues-view.tsx +++ b/apps/app/components/core/issues-view.tsx @@ -269,6 +269,15 @@ export const IssuesView: React.FC = ({ [setCreateIssueModal, setPreloadedData, selectedGroup] ); + const makeIssueCopy = useCallback( + (issue: IIssue) => { + setCreateIssueModal(true); + + setPreloadedData({ ...issue, name: `${issue.name} (Copy)`, actionType: "createIssue" }); + }, + [setCreateIssueModal, setPreloadedData] + ); + const handleEditIssue = useCallback( (issue: IIssue) => { setEditIssueModal(true); @@ -370,14 +379,14 @@ export const IssuesView: React.FC = ({ {(provided, snapshot) => (
    - + Drop issue here to delete
    )} @@ -389,6 +398,7 @@ export const IssuesView: React.FC = ({ states={states} members={members} addIssueToState={addIssueToState} + makeIssueCopy={makeIssueCopy} handleEditIssue={handleEditIssue} handleDeleteIssue={handleDeleteIssue} openIssuesListModal={type !== "issue" ? openIssuesListModal : null} @@ -408,6 +418,7 @@ export const IssuesView: React.FC = ({ states={states} members={members} addIssueToState={addIssueToState} + makeIssueCopy={makeIssueCopy} handleEditIssue={handleEditIssue} openIssuesListModal={type !== "issue" ? openIssuesListModal : null} handleDeleteIssue={handleDeleteIssue} diff --git a/apps/app/components/core/link-modal.tsx b/apps/app/components/core/link-modal.tsx index 5700946f0..03741963a 100644 --- a/apps/app/components/core/link-modal.tsx +++ b/apps/app/components/core/link-modal.tsx @@ -16,7 +16,7 @@ import type { IIssueLink, ModuleLink } from "types"; type Props = { isOpen: boolean; handleClose: () => void; - onFormSubmit: (formData: IIssueLink | ModuleLink) => void; + onFormSubmit: (formData: IIssueLink | ModuleLink) => Promise; }; const defaultValues: ModuleLink = { diff --git a/apps/app/components/core/list-view/all-lists.tsx b/apps/app/components/core/list-view/all-lists.tsx index c2b6c498a..d7c93cb78 100644 --- a/apps/app/components/core/list-view/all-lists.tsx +++ b/apps/app/components/core/list-view/all-lists.tsx @@ -12,6 +12,7 @@ type Props = { states: IState[] | undefined; members: IProjectMember[] | undefined; addIssueToState: (groupTitle: string, stateId: string | null) => void; + makeIssueCopy: (issue: IIssue) => void; handleEditIssue: (issue: IIssue) => void; handleDeleteIssue: (issue: IIssue) => void; openIssuesListModal?: (() => void) | null; @@ -25,6 +26,7 @@ export const AllLists: React.FC = ({ states, members, addIssueToState, + makeIssueCopy, openIssuesListModal, handleEditIssue, handleDeleteIssue, @@ -50,6 +52,7 @@ export const AllLists: React.FC = ({ selectedGroup={selectedGroup} members={members} addIssueToState={() => addIssueToState(singleGroup, stateId)} + makeIssueCopy={makeIssueCopy} handleEditIssue={handleEditIssue} handleDeleteIssue={handleDeleteIssue} openIssuesListModal={type !== "issue" ? openIssuesListModal : null} diff --git a/apps/app/components/core/list-view/single-issue.tsx b/apps/app/components/core/list-view/single-issue.tsx index 0dea00020..8e76237e9 100644 --- a/apps/app/components/core/list-view/single-issue.tsx +++ b/apps/app/components/core/list-view/single-issue.tsx @@ -1,4 +1,4 @@ -import React, { useCallback } from "react"; +import React, { useCallback, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; @@ -18,7 +18,14 @@ import { } from "components/issues/view-select"; // ui -import { Tooltip, CustomMenu } from "components/ui"; +import { Tooltip, CustomMenu, ContextMenu } from "components/ui"; +// icons +import { + ClipboardDocumentCheckIcon, + LinkIcon, + PencilIcon, + TrashIcon, +} from "@heroicons/react/24/outline"; // helpers import { copyTextToClipboard } from "helpers/string.helper"; // types @@ -31,6 +38,7 @@ type Props = { issue: IIssue; properties: Properties; editIssue: () => void; + makeIssueCopy: () => void; removeIssue?: (() => void) | null; handleDeleteIssue: (issue: IIssue) => void; userAuth: UserAuth; @@ -41,13 +49,20 @@ export const SingleListIssue: React.FC = ({ issue, properties, editIssue, + makeIssueCopy, removeIssue, handleDeleteIssue, userAuth, }) => { + // context menu + const [contextMenu, setContextMenu] = useState(false); + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); + const router = useRouter(); const { workspaceSlug, projectId, cycleId, moduleId } = router.query; + const { setToastAlert } = useToast(); + const partialUpdateIssue = useCallback( (formData: Partial) => { if (!workspaceSlug || !projectId) return; @@ -63,6 +78,7 @@ export const SingleListIssue: React.FC = ({ issue_detail: { ...p.issue_detail, ...formData, + assignees: formData.assignees_list ?? p.issue_detail.assignees_list, }, }; } @@ -84,6 +100,7 @@ export const SingleListIssue: React.FC = ({ issue_detail: { ...p.issue_detail, ...formData, + assignees: formData.assignees_list ?? p.issue_detail.assignees_list, }, }; } @@ -98,7 +115,8 @@ export const SingleListIssue: React.FC = ({ PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string), (prevData) => (prevData ?? []).map((p) => { - if (p.id === issue.id) return { ...p, ...formData }; + if (p.id === issue.id) + return { ...p, ...formData, assignees: formData.assignees_list ?? p.assignees_list }; return p; }), @@ -134,104 +152,136 @@ export const SingleListIssue: React.FC = ({ }); }); }; + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; return ( -
    -
    - - - - {properties.key && ( - - - {issue.project_detail?.identifier}-{issue.sequence_id} + <> + + + Edit issue + + + Make a copy... + + handleDeleteIssue(issue)}> + Delete issue + + + Copy issue link + + +
    { + e.preventDefault(); + setContextMenu(true); + setContextMenuPosition({ x: e.pageX, y: e.pageY }); + }} + > + -
    - {properties.priority && ( - - )} - {properties.state && ( - - )} - {properties.due_date && ( - - )} - {properties.sub_issue_count && ( -
    - {issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} -
    - )} - {properties.labels && ( -
    - {issue.label_details.map((label) => ( - + + +
    +
    + {properties.priority && ( + + )} + {properties.state && ( + + )} + {properties.due_date && ( + + )} + {properties.sub_issue_count && ( +
    + {issue.sub_issues_count} {issue.sub_issues_count === 1 ? "sub-issue" : "sub-issues"} +
    + )} + {properties.labels && ( +
    + {issue.label_details.map((label) => ( - {label.name} - - ))} -
    - )} - {properties.assignee && ( - - )} - {type && !isNotAllowed && ( - - Edit issue - {type !== "issue" && removeIssue && ( - - <>Remove from {type} + key={label.id} + className="group flex items-center gap-1 rounded-2xl border px-2 py-0.5 text-xs" + > + + {label.name} + + ))} +
    + )} + {properties.assignee && ( + + )} + {type && !isNotAllowed && ( + + Edit issue + {type !== "issue" && removeIssue && ( + + <>Remove from {type} + + )} + handleDeleteIssue(issue)}> + Delete issue - )} - handleDeleteIssue(issue)}> - Delete issue - - Copy issue link - - )} + Copy issue link + + )} +
    -
    + ); }; diff --git a/apps/app/components/core/list-view/single-list.tsx b/apps/app/components/core/list-view/single-list.tsx index 9c3a7ac0f..478b27380 100644 --- a/apps/app/components/core/list-view/single-list.tsx +++ b/apps/app/components/core/list-view/single-list.tsx @@ -23,6 +23,7 @@ type Props = { selectedGroup: NestedKeyOf | null; members: IProjectMember[] | undefined; addIssueToState: () => void; + makeIssueCopy: (issue: IIssue) => void; handleEditIssue: (issue: IIssue) => void; handleDeleteIssue: (issue: IIssue) => void; openIssuesListModal?: (() => void) | null; @@ -37,6 +38,7 @@ export const SingleList: React.FC = ({ selectedGroup, members, addIssueToState, + makeIssueCopy, handleEditIssue, handleDeleteIssue, openIssuesListModal, @@ -113,6 +115,7 @@ export const SingleList: React.FC = ({ issue={issue} properties={properties} editIssue={() => handleEditIssue(issue)} + makeIssueCopy={() => makeIssueCopy(issue)} handleDeleteIssue={handleDeleteIssue} removeIssue={() => { removeIssue && removeIssue(issue.bridge); diff --git a/apps/app/components/core/multi-level-select.tsx b/apps/app/components/core/multi-level-select.tsx index 68a76ae91..36068dcc6 100644 --- a/apps/app/components/core/multi-level-select.tsx +++ b/apps/app/components/core/multi-level-select.tsx @@ -83,10 +83,10 @@ export const MultiLevelSelect: React.FC = (props) => { <> {openChildFor?.id === option.id && (
    {option.children?.map((child) => ( @@ -118,7 +118,7 @@ export const MultiLevelSelect: React.FC = (props) => { ))}
    = ({ links, handleDeleteLink, userAuth }
    {link.title}

    Added {timeAgo(link.created_at)} - {/*
    - by {link.created_by_detail.email} */} +
    + by {link.created_by_detail.email}

    diff --git a/apps/app/components/core/sidebar/progress-chart.tsx b/apps/app/components/core/sidebar/progress-chart.tsx index b0d5bb394..7ff6f3f1f 100644 --- a/apps/app/components/core/sidebar/progress-chart.tsx +++ b/apps/app/components/core/sidebar/progress-chart.tsx @@ -1,17 +1,10 @@ import React from "react"; -import { - XAxis, - YAxis, - Tooltip, - ResponsiveContainer, - AreaChart, - Area, - ReferenceLine, -} from "recharts"; +import { XAxis, YAxis, Tooltip, AreaChart, Area, ReferenceLine, TooltipProps} from "recharts"; //types import { IIssue } from "types"; +import { NameType, ValueType } from "recharts/types/component/DefaultTooltipContent"; // helper import { getDatesInRange, renderShortNumericDateFormat } from "helpers/date-time.helper"; @@ -43,53 +36,69 @@ const ProgressChart: React.FC = ({ issues, start, end }) => { }); return dateWiseData; }; + + const CustomTooltip = ({ active, payload }: TooltipProps) => { + if (active && payload && payload.length) { + console.log(payload[0].payload.currentDate); + return ( +
    +

    {payload[0].payload.currentDate}

    +
    + ); + } + return null; + }; const ChartData = getChartData(); return ( -
    -
    -
    - - Ideal -
    -
    - - Current -
    -
    -
    - - - - - - - - - -
    +
    + + + + + + + + + + + } /> + + +
    ); }; diff --git a/apps/app/components/core/sidebar/sidebar-progress-stats.tsx b/apps/app/components/core/sidebar/sidebar-progress-stats.tsx index 01417b625..8c9c7028b 100644 --- a/apps/app/components/core/sidebar/sidebar-progress-stats.tsx +++ b/apps/app/components/core/sidebar/sidebar-progress-stats.tsx @@ -13,19 +13,24 @@ import projectService from "services/project.service"; // hooks import useLocalStorage from "hooks/use-local-storage"; // components -import { SingleProgressStats } from "components/core"; +import { LinksList, SingleProgressStats } from "components/core"; // ui import { Avatar } from "components/ui"; // icons import User from "public/user.png"; +import { PlusIcon } from "@heroicons/react/24/outline"; // types -import { IIssue, IIssueLabels } from "types"; +import { IIssue, IIssueLabels, IModule, UserAuth } from "types"; // fetch-keys import { PROJECT_ISSUE_LABELS, PROJECT_MEMBERS } from "constants/fetch-keys"; // types type Props = { groupedIssues: any; issues: IIssue[]; + module?: IModule; + setModuleLinkModal?: any; + handleDeleteLink?: any; + userAuth?: UserAuth; }; const stateGroupColours: { @@ -38,7 +43,14 @@ const stateGroupColours: { completed: "#096e8d", }; -export const SidebarProgressStats: React.FC = ({ groupedIssues, issues }) => { +export const SidebarProgressStats: React.FC = ({ + groupedIssues, + issues, + module, + setModuleLinkModal, + handleDeleteLink, + userAuth, +}) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -60,14 +72,17 @@ export const SidebarProgressStats: React.FC = ({ groupedIssues, issues }) const currentValue = (tab: string | null) => { switch (tab) { + case "Links": + return 0; case "Assignees": - return 0; - case "Labels": return 1; - case "States": + case "Labels": return 2; + case "States": + return 3; + default: - return 0; + return 3; } }; return ( @@ -76,45 +91,91 @@ export const SidebarProgressStats: React.FC = ({ groupedIssues, issues }) onChange={(i) => { switch (i) { case 0: - return setTab("Assignees"); + return setTab("Links"); case 1: - return setTab("Labels"); + return setTab("Assignees"); case 2: + return setTab("Labels"); + case 3: return setTab("States"); default: - return setTab("Assignees"); + return setTab("States"); } }} > + {module ? ( + + `w-full rounded px-3 py-1 text-gray-900 ${ + selected ? " bg-theme text-white" : " hover:bg-hover-gray" + }` + } + > + Links + + ) : ( + "" + )} + - `w-1/2 rounded py-1 ${selected ? "bg-gray-300" : "hover:bg-gray-200"}` + `w-full rounded px-3 py-1 text-gray-900 ${ + selected ? " bg-theme text-white" : " hover:bg-hover-gray" + }` } > Assignees - `w-1/2 rounded py-1 ${selected ? "bg-gray-300 font-semibold" : "hover:bg-gray-200 "}` + `w-full rounded px-3 py-1 text-gray-900 ${ + selected ? " bg-theme text-white" : " hover:bg-hover-gray" + }` } > Labels - `w-1/2 rounded py-1 ${selected ? "bg-gray-300 font-semibold" : "hover:bg-gray-200 "}` + `w-full rounded px-3 py-1 text-gray-900 ${ + selected ? " bg-theme text-white" : " hover:bg-hover-gray" + }` } > States - - + + {module ? ( + + +
    + {userAuth && module.link_module && module.link_module.length > 0 ? ( + + ) : null} +
    +
    + ) : ( + "" + )} + + {members?.map((member, index) => { const totalArray = issues?.filter((i) => i.assignees?.includes(member.member.id)); const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); @@ -161,7 +222,7 @@ export const SidebarProgressStats: React.FC = ({ groupedIssues, issues }) "" )} - + {issueLabels?.map((issue, index) => { const totalArray = issues?.filter((i) => i.labels?.includes(issue.id)); const completeArray = totalArray?.filter((i) => i.state_detail.group === "completed"); @@ -170,15 +231,15 @@ export const SidebarProgressStats: React.FC = ({ groupedIssues, issues }) +
    {issue.name} - +
    } completed={completeArray.length} total={totalArray.length} @@ -187,20 +248,20 @@ export const SidebarProgressStats: React.FC = ({ groupedIssues, issues }) } })}
    - + {Object.keys(groupedIssues).map((group, index) => ( +
    {group} - +
    } completed={groupedIssues[group].length} total={issues.length} diff --git a/apps/app/components/core/sidebar/single-progress-stats.tsx b/apps/app/components/core/sidebar/single-progress-stats.tsx index bb56e1545..58e684f61 100644 --- a/apps/app/components/core/sidebar/single-progress-stats.tsx +++ b/apps/app/components/core/sidebar/single-progress-stats.tsx @@ -13,10 +13,10 @@ export const SingleProgressStats: React.FC = ({ completed, total, }) => ( -
    -
    {title}
    -
    -
    +
    +
    {title}
    +
    +
    diff --git a/apps/app/components/cycles/completed-cycles-list.tsx b/apps/app/components/cycles/completed-cycles-list.tsx new file mode 100644 index 000000000..a6589f0d2 --- /dev/null +++ b/apps/app/components/cycles/completed-cycles-list.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; + +import { useRouter } from "next/router"; + +import useSWR from "swr"; + +// services +import cyclesService from "services/cycles.service"; +// components +import { DeleteCycleModal, SingleCycleCard } from "components/cycles"; +// icons +import { CompletedCycleIcon } from "components/icons"; +// types +import { ICycle, SelectCycleType } from "types"; +// fetch-keys +import { CYCLE_COMPLETE_LIST } from "constants/fetch-keys"; +import { Loader } from "components/ui"; + +export interface CompletedCyclesListProps { + setCreateUpdateCycleModal: React.Dispatch>; + setSelectedCycle: React.Dispatch>; +} + +export const CompletedCyclesList: React.FC = ({ + setCreateUpdateCycleModal, + setSelectedCycle, +}) => { + const [cycleDeleteModal, setCycleDeleteModal] = useState(false); + const [selectedCycleForDelete, setSelectedCycleForDelete] = useState(); + + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { data: completedCycles } = useSWR( + workspaceSlug && projectId ? CYCLE_COMPLETE_LIST(projectId as string) : null, + workspaceSlug && projectId + ? () => cyclesService.getCompletedCycles(workspaceSlug as string, projectId as string) + : null + ); + + const handleDeleteCycle = (cycle: ICycle) => { + setSelectedCycleForDelete({ ...cycle, actionType: "delete" }); + setCycleDeleteModal(true); + }; + + const handleEditCycle = (cycle: ICycle) => { + setSelectedCycle({ ...cycle, actionType: "edit" }); + setCreateUpdateCycleModal(true); + }; + + return ( + <> + + {completedCycles ? ( + completedCycles.completed_cycles.length > 0 ? ( +
    + {completedCycles.completed_cycles.map((cycle) => ( + handleDeleteCycle(cycle)} + handleEditCycle={() => handleEditCycle(cycle)} + /> + ))} +
    + ) : ( +
    + +

    + No completed cycles yet. Create with{" "} +
    Q
    . +

    +
    + ) + ) : ( + + + + + + )} + + ); +}; diff --git a/apps/app/components/cycles/cycles-list-view.tsx b/apps/app/components/cycles/cycles-list-view.tsx deleted file mode 100644 index 8491190e8..000000000 --- a/apps/app/components/cycles/cycles-list-view.tsx +++ /dev/null @@ -1,72 +0,0 @@ -// react -import { useState } from "react"; -// components -import { DeleteCycleModal, SingleCycleCard } from "components/cycles"; -// types -import { ICycle, SelectCycleType } from "types"; -import { CompletedCycleIcon, CurrentCycleIcon, UpcomingCycleIcon } from "components/icons"; - -type TCycleStatsViewProps = { - cycles: ICycle[]; - setCreateUpdateCycleModal: React.Dispatch>; - setSelectedCycle: React.Dispatch>; - type: "current" | "upcoming" | "completed"; -}; - -export const CyclesListView: React.FC = ({ - cycles, - setCreateUpdateCycleModal, - setSelectedCycle, - type, -}) => { - const [cycleDeleteModal, setCycleDeleteModal] = useState(false); - const [selectedCycleForDelete, setSelectedCycleForDelete] = useState(); - - const handleDeleteCycle = (cycle: ICycle) => { - setSelectedCycleForDelete({ ...cycle, actionType: "delete" }); - setCycleDeleteModal(true); - }; - - const handleEditCycle = (cycle: ICycle) => { - setSelectedCycle({ ...cycle, actionType: "edit" }); - setCreateUpdateCycleModal(true); - }; - - return ( - <> - - {cycles.length > 0 ? ( - cycles.map((cycle) => ( - handleDeleteCycle(cycle)} - handleEditCycle={() => handleEditCycle(cycle)} - /> - )) - ) : ( -
    - {type === "upcoming" ? ( - - ) : type === "completed" ? ( - - ) : ( - - )} -

    - No {type} {type === "current" ? "cycle" : "cycles"} yet. Create with{" "} -
    Q
    . -

    -
    - )} - - ); -}; diff --git a/apps/app/components/cycles/cycles-list.tsx b/apps/app/components/cycles/cycles-list.tsx new file mode 100644 index 000000000..97701202c --- /dev/null +++ b/apps/app/components/cycles/cycles-list.tsx @@ -0,0 +1,82 @@ +import { useState } from "react"; + +// components +import { DeleteCycleModal, SingleCycleCard } from "components/cycles"; +// icons +import { CompletedCycleIcon, CurrentCycleIcon, UpcomingCycleIcon } from "components/icons"; +// types +import { ICycle, SelectCycleType } from "types"; +import { Loader } from "components/ui"; + +type TCycleStatsViewProps = { + cycles: ICycle[] | undefined; + setCreateUpdateCycleModal: React.Dispatch>; + setSelectedCycle: React.Dispatch>; + type: "current" | "upcoming" | "draft"; +}; + +export const CyclesList: React.FC = ({ + cycles, + setCreateUpdateCycleModal, + setSelectedCycle, + type, +}) => { + const [cycleDeleteModal, setCycleDeleteModal] = useState(false); + const [selectedCycleForDelete, setSelectedCycleForDelete] = useState(); + + const handleDeleteCycle = (cycle: ICycle) => { + setSelectedCycleForDelete({ ...cycle, actionType: "delete" }); + setCycleDeleteModal(true); + }; + + const handleEditCycle = (cycle: ICycle) => { + setSelectedCycle({ ...cycle, actionType: "edit" }); + setCreateUpdateCycleModal(true); + }; + + return ( + <> + + {cycles ? ( + cycles.length > 0 ? ( +
    + {cycles.map((cycle) => ( + handleDeleteCycle(cycle)} + handleEditCycle={() => handleEditCycle(cycle)} + /> + ))} +
    + ) : ( +
    + {type === "upcoming" ? ( + + ) : type === "draft" ? ( + + ) : ( + + )} +

    + No {type} {type === "current" ? "cycle" : "cycles"} yet. Create with{" "} +
    Q
    . +

    +
    + ) + ) : ( + + + + )} + + ); +}; diff --git a/apps/app/components/cycles/form.tsx b/apps/app/components/cycles/form.tsx index 58f57ba14..8ab61acc5 100644 --- a/apps/app/components/cycles/form.tsx +++ b/apps/app/components/cycles/form.tsx @@ -1,11 +1,18 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +// toast +import useToast from "hooks/use-toast"; // react-hook-form import { Controller, useForm } from "react-hook-form"; // ui import { Button, CustomDatePicker, CustomSelect, Input, TextArea } from "components/ui"; // types import { ICycle } from "types"; +// services +import cyclesService from "services/cycles.service"; +// helper +import { getDateRangeStatus } from "helpers/date-time.helper"; type Props = { handleFormSubmit: (values: Partial) => Promise; @@ -17,17 +24,24 @@ type Props = { const defaultValues: Partial = { name: "", description: "", - status: "draft", - start_date: "", - end_date: "", + start_date: null, + end_date: null, }; export const CycleForm: React.FC = ({ handleFormSubmit, handleClose, status, data }) => { + const router = useRouter(); + const { workspaceSlug, projectId } = router.query; + + const { setToastAlert } = useToast(); + + const [isDateValid, setIsDateValid] = useState(true); + const { register, formState: { errors, isSubmitting }, handleSubmit, control, + watch, reset, } = useForm({ defaultValues, @@ -41,6 +55,35 @@ export const CycleForm: React.FC = ({ handleFormSubmit, handleClose, stat }); }; + const cycleStatus = + data?.start_date && data?.end_date + ? getDateRangeStatus(data?.start_date, data?.end_date) : ""; + + const dateChecker = async (payload: any) => { + await cyclesService + .cycleDateCheck(workspaceSlug as string, projectId as string, payload) + .then((res) => { + if (res.status) { + setIsDateValid(true); + } else { + setIsDateValid(false); + setToastAlert({ + type: "error", + title: "Error!", + message: + "You have a cycle already on the given dates, if you want to create your draft cycle you can do that by removing dates", + }); + } + }) + .catch((err) => { + console.log(err); + }); + }; + + const checkEmptyDate = + (watch("start_date") === "" && watch("end_date") === "") || + (!watch("start_date") && !watch("end_date")); + useEffect(() => { reset({ ...defaultValues, @@ -84,30 +127,7 @@ export const CycleForm: React.FC = ({ handleFormSubmit, handleClose, stat register={register} />
    -
    -
    Status
    - ( - {field.value ?? "Select Status"}} - input - > - {[ - { label: "Draft", value: "draft" }, - { label: "Started", value: "started" }, - { label: "Completed", value: "completed" }, - ].map((item) => ( - - {item.label} - - ))} - - )} - /> -
    +
    Start Date
    @@ -115,12 +135,19 @@ export const CycleForm: React.FC = ({ handleFormSubmit, handleClose, stat ( { + onChange(val); + watch("end_date") && cycleStatus != "current" + ? dateChecker({ + start_date: val, + end_date: watch("end_date"), + }) + : ""; + }} error={errors.start_date ? true : false} /> )} @@ -136,12 +163,19 @@ export const CycleForm: React.FC = ({ handleFormSubmit, handleClose, stat ( { + onChange(val); + watch("start_date") && cycleStatus != "current" + ? dateChecker({ + start_date: watch("start_date"), + end_date: val, + }) + : ""; + }} error={errors.end_date ? true : false} /> )} @@ -158,7 +192,18 @@ export const CycleForm: React.FC = ({ handleFormSubmit, handleClose, stat - - -
    -
    -
    -
    -
    -
    - -

    Owned by

    -
    -
    - {cycle.owned_by && - (cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( -
    - {cycle.owned_by?.first_name} -
    - ) : ( -
    - {cycle.owned_by?.first_name && cycle.owned_by.first_name !== "" - ? cycle.owned_by.first_name.charAt(0) - : cycle.owned_by?.email.charAt(0)} -
    - ))} - {cycle.owned_by.first_name !== "" - ? cycle.owned_by.first_name - : cycle.owned_by.email} -
    +
    +
    +
    + + {capitalizeFirstLetter(cycleStatus)} +
    -
    -
    - -

    Progress

    +
    + + {({ open }) => ( + <> + + + {renderShortDate(new Date(`${cycle?.start_date}`))} + + + + + { + submitChanges({ + start_date: renderDateFormat(date), + }); + setStartDateRange(date); + }} + selectsStart + startDate={startDateRange} + endDate={endDateRange} + maxDate={endDateRange} + shouldCloseOnSelect + inline + /> + + + + )} + + + + + + {({ open }) => ( + <> + + + + {renderShortDate(new Date(`${cycle?.end_date}`))} + + + + + { + submitChanges({ + end_date: renderDateFormat(date), + }); + setEndDateRange(date); + }} + selectsEnd + startDate={startDateRange} + endDate={endDateRange} + // minDate={startDateRange} + + inline + /> + + + + )} + +
    +
    + +
    +
    +
    +

    {cycle.name}

    + + + + + Copy Link + + + setCycleDeleteModal(true)}> + + + Delete + + +
    -
    -
    + + + {cycle.description} + +
    + +
    +
    +
    + + Lead +
    + +
    + {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( + {cycle.owned_by.first_name} + ) : ( + + {cycle.owned_by.first_name.charAt(0)} + + )} + {cycle.owned_by.first_name} +
    +
    + +
    +
    + + Progress +
    + +
    + {groupedIssues.completed.length}/{cycleIssues?.length}
    - {groupedIssues.completed.length}/{cycleIssues?.length}
    -
    -
    - {isStartValid && isEndValid ? ( -
    - -
    - ) : ( - "" - )} - {issues.length > 0 ? ( - - ) : ( - "" - )} + +
    + + {({ open }) => ( +
    +
    +
    + Progress + {!open && cycleIssues && progressPercentage ? ( + + {progressPercentage ? `${progressPercentage}%` : ""} + + ) : ( + "" + )} +
    + + + +
    + + + {isStartValid && isEndValid ? ( +
    +
    +
    + + + + + Pending Issues -{" "} + {cycleIssues?.length - groupedIssues.completed.length}{" "} + +
    + +
    +
    + + Ideal +
    +
    + + Current +
    +
    +
    +
    + +
    +
    + ) : ( + "" + )} +
    +
    +
    + )} +
    +
    + +
    + + {({ open }) => ( +
    +
    +
    + Other Information +
    + + + +
    + + + {issues.length > 0 ? ( +
    + +
    + ) : ( + "" + )} +
    +
    +
    + )} +
    ) : ( diff --git a/apps/app/components/cycles/single-cycle-card.tsx b/apps/app/components/cycles/single-cycle-card.tsx index 1fa565ceb..edbaa35c4 100644 --- a/apps/app/components/cycles/single-cycle-card.tsx +++ b/apps/app/components/cycles/single-cycle-card.tsx @@ -4,26 +4,38 @@ import Link from "next/link"; import Image from "next/image"; import { useRouter } from "next/router"; -// swr -import useSWR from "swr"; +import useSWR, { mutate } from "swr"; + // services import cyclesService from "services/cycles.service"; // hooks import useToast from "hooks/use-toast"; // ui -import { Button, CustomMenu } from "components/ui"; +import { CustomMenu, LinearProgressIndicator, Tooltip } from "components/ui"; +import { Disclosure, Transition } from "@headlessui/react"; // icons import { CalendarDaysIcon } from "@heroicons/react/20/solid"; -import { UserIcon } from "@heroicons/react/24/outline"; -import { CyclesIcon } from "components/icons"; +import { ChevronDownIcon, PencilIcon, StarIcon } from "@heroicons/react/24/outline"; // helpers -import { renderShortNumericDateFormat } from "helpers/date-time.helper"; +import { getDateRangeStatus, renderShortDateWithYearFormat } from "helpers/date-time.helper"; import { groupBy } from "helpers/array.helper"; -import { copyTextToClipboard } from "helpers/string.helper"; +import { capitalizeFirstLetter, copyTextToClipboard, truncateText } from "helpers/string.helper"; // types -import { CycleIssueResponse, ICycle } from "types"; +import { + CompletedCyclesResponse, + CurrentAndUpcomingCyclesResponse, + CycleIssueResponse, + DraftCyclesResponse, + ICycle, +} from "types"; // fetch-keys -import { CYCLE_ISSUES } from "constants/fetch-keys"; +import { + CYCLE_COMPLETE_LIST, + CYCLE_CURRENT_AND_UPCOMING_LIST, + CYCLE_DRAFT_LIST, + CYCLE_ISSUES, + CYCLE_LIST, +} from "constants/fetch-keys"; type TSingleStatProps = { cycle: ICycle; @@ -34,11 +46,11 @@ type TSingleStatProps = { const stateGroupColours: { [key: string]: string; } = { - backlog: "#3f76ff", - unstarted: "#ff9e9e", - started: "#d687ff", - cancelled: "#ff5353", - completed: "#096e8d", + backlog: "#DEE2E6", + unstarted: "#26B5CE", + started: "#F7AE59", + cancelled: "#D687FF", + completed: "#09A953", }; export const SingleCycleCard: React.FC = (props) => { @@ -67,6 +79,130 @@ export const SingleCycleCard: React.FC = (props) => { ...groupBy(cycleIssues ?? [], "issue_detail.state_detail.group"), }; + const handleAddToFavorites = () => { + if (!workspaceSlug && !projectId && !cycle) return; + + cyclesService + .addCycleToFavorites(workspaceSlug as string, projectId as string, { + cycle: cycle.id, + }) + .then(() => { + const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date); + + if (cycleStatus === "current" || cycleStatus === "upcoming") + mutate( + CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string), + (prevData) => ({ + current_cycle: (prevData?.current_cycle ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? true : c.is_favorite, + })), + upcoming_cycle: (prevData?.upcoming_cycle ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? true : c.is_favorite, + })), + }), + false + ); + else if (cycleStatus === "completed") + mutate( + CYCLE_COMPLETE_LIST(projectId as string), + (prevData) => ({ + completed_cycles: (prevData?.completed_cycles ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? true : c.is_favorite, + })), + }), + false + ); + else + mutate( + CYCLE_DRAFT_LIST(projectId as string), + (prevData) => ({ + draft_cycles: (prevData?.draft_cycles ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? true : c.is_favorite, + })), + }), + false + ); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Successfully added the cycle to favorites.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the cycle to favorites. Please try again.", + }); + }); + }; + + const handleRemoveFromFavorites = () => { + if (!workspaceSlug || !cycle) return; + + cyclesService + .removeCycleFromFavorites(workspaceSlug as string, projectId as string, cycle.id) + .then(() => { + const cycleStatus = getDateRangeStatus(cycle.start_date, cycle.end_date); + + if (cycleStatus === "current" || cycleStatus === "upcoming") + mutate( + CYCLE_CURRENT_AND_UPCOMING_LIST(projectId as string), + (prevData) => ({ + current_cycle: (prevData?.current_cycle ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? false : c.is_favorite, + })), + upcoming_cycle: (prevData?.upcoming_cycle ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? false : c.is_favorite, + })), + }), + false + ); + else if (cycleStatus === "completed") + mutate( + CYCLE_COMPLETE_LIST(projectId as string), + (prevData) => ({ + completed_cycles: (prevData?.completed_cycles ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? false : c.is_favorite, + })), + }), + false + ); + else + mutate( + CYCLE_DRAFT_LIST(projectId as string), + (prevData) => ({ + draft_cycles: (prevData?.draft_cycles ?? []).map((c) => ({ + ...c, + is_favorite: c.id === cycle.id ? false : c.is_favorite, + })), + }), + false + ); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Successfully removed the cycle from favorites.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't remove the cycle from favorites. Please try again.", + }); + }); + }; + const handleCopyText = () => { const originURL = typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; @@ -82,100 +218,148 @@ export const SingleCycleCard: React.FC = (props) => { }); }; + const progressIndicatorData = Object.keys(groupedIssues).map((group, index) => ({ + id: index, + name: capitalizeFirstLetter(group), + value: + cycleIssues && cycleIssues.length > 0 + ? (groupedIssues[group].length / cycleIssues.length) * 100 + : 0, + color: stateGroupColours[group], + })); + return ( - <> -
    -
    -
    -
    - - -

    - {cycle.name} -

    -
    - - - Edit cycle +
    +
    +
    +
    + + + +

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

    +
    +
    + + {cycle.is_favorite ? ( + + ) : ( + + )} +
    + +
    +
    + + Start : + {renderShortDateWithYearFormat(startDate)} +
    +
    + + End : + {renderShortDateWithYearFormat(endDate)} +
    +
    +
    + +
    +
    +
    + {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( + {cycle.owned_by.first_name} + ) : ( + + {cycle.owned_by.first_name.charAt(0)} + + )} + {cycle.owned_by.first_name} +
    +
    + + + Delete cycle Copy cycle link
    -
    -
    - - Cycle dates -
    -
    - {renderShortNumericDateFormat(startDate)} - {renderShortNumericDateFormat(endDate)} -
    -
    - - Created by -
    -
    - {cycle.owned_by.avatar && cycle.owned_by.avatar !== "" ? ( - {cycle.owned_by.first_name} - ) : ( - - {cycle.owned_by.first_name.charAt(0)} - - )} - {cycle.owned_by.first_name} -
    -
    -
    -
    + + + {({ open }) => ( +
    - - Open Cycle - -
    -
    -
    -

    PROGRESS

    -
    - {Object.keys(groupedIssues).map((group) => ( -
    -
    - + Progress + + +
    -
    - - {groupedIssues[group].length}{" "} - - -{" "} - {cycleIssues && cycleIssues.length > 0 - ? `${Math.round( - (groupedIssues[group].length / cycleIssues.length) * 100 - )}%` - : "0%"} - - -
    +
    - ))} -
    -
    + + +
    +
    +
    + {Object.keys(groupedIssues).map((group) => ( +
    +
    + +
    {group}
    +
    +
    + + {groupedIssues[group].length}{" "} + + -{" "} + {cycleIssues && cycleIssues.length > 0 + ? `${Math.round( + (groupedIssues[group].length / cycleIssues.length) * 100 + )}%` + : "0%"} + + +
    +
    + ))} +
    +
    +
    +
    +
    +
    + )} +
    - +
    ); }; diff --git a/apps/app/components/emoji-icon-picker/index.tsx b/apps/app/components/emoji-icon-picker/index.tsx index 711be870d..69a2c45e7 100644 --- a/apps/app/components/emoji-icon-picker/index.tsx +++ b/apps/app/components/emoji-icon-picker/index.tsx @@ -44,7 +44,7 @@ const EmojiIconPicker: React.FC = ({ label, value, onChange }) => { return ( setIsOpen((prev) => !prev)} > {label} @@ -58,10 +58,10 @@ const EmojiIconPicker: React.FC = ({ label, value, onChange }) => { leaveFrom="transform opacity-100 scale-100" leaveTo="transform opacity-0 scale-95" > - +
    - + {tabOptions.map((tab) => ( = ({ label, value, onChange }) => { ))} - - + + {recentEmojis.length > 0 && ( -
    -

    Recent Emojis

    +
    +

    Recent Emojis

    {recentEmojis.map((emoji) => (
    )} -
    -

    All Emojis

    +
    +

    All Emojis

    {emojis.map((emoji) => (
    - = ({ value={value} onJSONChange={(jsonValue) => setValue("description", jsonValue)} onHTMLChange={(htmlValue) => setValue("description_html", htmlValue)} - placeholder="Enter Your Text..." + placeholder="Description" /> )} /> @@ -266,16 +263,16 @@ export const IssueForm: FC = ({ /> ( - + )} /> ( - + )} /> = ({ control={control} name="target_date" render={({ field: { value, onChange } }) => ( - + )} />
    - ( - - )} - /> = ({
    -
    +
    setCreateMore((prevData) => !prevData)} @@ -372,15 +358,15 @@ export const IssueForm: FC = ({ - + ? "Adding Issue..." + : "Add Issue"} +
    diff --git a/apps/app/components/issues/modal.tsx b/apps/app/components/issues/modal.tsx index 28689e6cd..c87ff2e66 100644 --- a/apps/app/components/issues/modal.tsx +++ b/apps/app/components/issues/modal.tsx @@ -86,7 +86,7 @@ export const CreateUpdateIssueModal: React.FC = ({ return () => { window.removeEventListener("keydown", handleKeyDown); }; - }, []); + }, [handleClose]); const addIssueToCycle = async (issueId: string, cycleId: string) => { if (!workspaceSlug || !projectId) return; @@ -231,7 +231,7 @@ export const CreateUpdateIssueModal: React.FC = ({
    -
    +
    = ({ )} - + {issue.name} @@ -135,7 +135,7 @@ export const MyIssuesListItem: React.FC = ({ /> )} {properties.sub_issue_count && ( -
    +
    {issue?.sub_issues_count} {issue?.sub_issues_count === 1 ? "sub-issue" : "sub-issues"}
    )} diff --git a/apps/app/components/issues/select/assignee.tsx b/apps/app/components/issues/select/assignee.tsx index 414ec2e9a..d47d4b178 100644 --- a/apps/app/components/issues/select/assignee.tsx +++ b/apps/app/components/issues/select/assignee.tsx @@ -1,122 +1,75 @@ -import { useState, FC, Fragment } from "react"; - import { useRouter } from "next/router"; import useSWR from "swr"; -// headless ui -import { Transition, Combobox } from "@headlessui/react"; // services import projectServices from "services/project.service"; // ui -import { AssigneesList, Avatar } from "components/ui"; -// fetch keys +import { AssigneesList, Avatar, CustomSearchSelect } from "components/ui"; +// icons +import { UserGroupIcon } from "@heroicons/react/24/outline"; +// fetch-keys import { PROJECT_MEMBERS } from "constants/fetch-keys"; -export type IssueAssigneeSelectProps = { +export type Props = { projectId: string; value: string[]; onChange: (value: string[]) => void; }; -export const IssueAssigneeSelect: FC = ({ - projectId, - value = [], - onChange, -}) => { - // states - const [query, setQuery] = useState(""); - +export const IssueAssigneeSelect: React.FC = ({ projectId, value = [], onChange }) => { const router = useRouter(); const { workspaceSlug } = router.query; // fetching project members - const { data: people } = useSWR( + const { data: members } = useSWR( workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, workspaceSlug && projectId ? () => projectServices.projectMembers(workspaceSlug as string, projectId as string) : null ); - const options = people?.map((person) => ({ - value: person.member.id, - display: - person.member.first_name && person.member.first_name !== "" - ? person.member.first_name - : person.member.email, - })); - - const filteredOptions = - query === "" - ? options - : options?.filter((option) => option.display.toLowerCase().includes(query.toLowerCase())); + const options = + members?.map((member) => ({ + value: member.member.id, + query: + (member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email) + + " " + + member.member.last_name ?? "", + content: ( +
    + + {member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email} +
    + ), + })) ?? []; return ( - onChange(val)} - className="relative flex-shrink-0" - multiple - > - {({ open }: any) => ( - <> - -
    - {value && Array.isArray(value) ? : null} + onChange={onChange} + options={options} + label={ +
    + {value && value.length > 0 && Array.isArray(value) ? ( +
    + + {value.length} Assignees
    - - - - - setQuery(event.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
    - {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `${active ? "bg-indigo-50" : ""} ${ - selected ? "bg-indigo-50 font-medium" : "" - } flex cursor-pointer select-none items-center gap-2 truncate px-2 py-1 text-gray-900` - } - value={option.value} - > - {people && ( - <> - p.member.id === option.value)?.member} - /> - {option.display} - - )} - - )) - ) : ( -

    No assignees found

    - ) - ) : ( -

    Loading...

    - )} -
    -
    -
    - - )} - + ) : ( +
    + + Assignee +
    + )} +
    + } + multiple + noChevron + /> ); }; diff --git a/apps/app/components/issues/select/date.tsx b/apps/app/components/issues/select/date.tsx new file mode 100644 index 000000000..703569ef0 --- /dev/null +++ b/apps/app/components/issues/select/date.tsx @@ -0,0 +1,70 @@ +import React from "react"; + +import { Popover, Transition } from "@headlessui/react"; +import { CalendarDaysIcon, XMarkIcon } from "@heroicons/react/24/outline"; +// react-datepicker +import DatePicker from "react-datepicker"; +// import "react-datepicker/dist/react-datepicker.css"; +import { renderDateFormat } from "helpers/date-time.helper"; + +type Props = { + value: string | null; + onChange: (val: string | null) => void; +}; + +export const IssueDateSelect: React.FC = ({ value, onChange }) => ( + + {({ open }) => ( + <> + + `flex cursor-pointer items-center rounded-md border text-xs shadow-sm duration-200 + ${ + open + ? "border-theme bg-theme/5 outline-none ring-1 ring-theme " + : "hover:bg-theme/5 " + }` + } + > + + {value ? ( + <> + {value} + + + ) : ( + <> + + Due Date + + )} + + + + + + { + if (!val) onChange(""); + else onChange(renderDateFormat(val)); + }} + dateFormat="dd-MM-yyyy" + inline + /> + + + + )} + +); diff --git a/apps/app/components/issues/select/index.ts b/apps/app/components/issues/select/index.ts index 4338b3162..a21a1cbbb 100644 --- a/apps/app/components/issues/select/index.ts +++ b/apps/app/components/issues/select/index.ts @@ -4,3 +4,4 @@ export * from "./parent"; export * from "./priority"; export * from "./project"; export * from "./state"; +export * from "./date"; diff --git a/apps/app/components/issues/select/label.tsx b/apps/app/components/issues/select/label.tsx index 2b810b30a..b09513415 100644 --- a/apps/app/components/issues/select/label.tsx +++ b/apps/app/components/issues/select/label.tsx @@ -7,13 +7,20 @@ import useSWR from "swr"; // headless ui import { Combobox, Transition } from "@headlessui/react"; // icons -import { PlusIcon, RectangleGroupIcon, TagIcon } from "@heroicons/react/24/outline"; +import { + CheckIcon, + MagnifyingGlassIcon, + PlusIcon, + RectangleGroupIcon, + TagIcon, +} from "@heroicons/react/24/outline"; // services import issuesServices from "services/issues.service"; // types import type { IIssueLabels } from "types"; // fetch-keys import { PROJECT_ISSUE_LABELS } from "constants/fetch-keys"; +import { IssueLabelsList } from "components/ui"; type Props = { setIsOpen: React.Dispatch>; @@ -52,36 +59,57 @@ export const IssueLabelSelect: React.FC = ({ setIsOpen, value, onChange, > {({ open }: any) => ( <> - Labels + `flex cursor-pointer items-center rounded-md border text-xs shadow-sm duration-200 + ${ + open + ? "border-theme bg-theme/5 outline-none ring-1 ring-theme " + : "hover:bg-theme/5 " + }` + } > - - - {Array.isArray(value) - ? value.map((v) => issueLabels?.find((l) => l.id === v)?.name).join(", ") || - "Labels" - : issueLabels?.find((l) => l.id === value)?.name || "Labels"} - + {value && value.length > 0 ? ( + + issueLabels?.find((l) => l.id === v)?.color) ?? []} + length={3} + showLength + /> + {value.length} Labels + + ) : ( + + + Label + + )} - setQuery(event.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
    +
    + + setQuery(event.target.value)} + placeholder="Search for label..." + displayValue={(assigned: any) => assigned?.name} + /> +
    +
    {issueLabels && filteredOptions ? ( filteredOptions.length > 0 ? ( filteredOptions.map((label) => { @@ -92,47 +120,75 @@ export const IssueLabelSelect: React.FC = ({ setIsOpen, value, onChange, return ( - `${active ? "bg-indigo-50" : ""} ${ - selected ? "bg-indigo-50 font-medium" : "" - } flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` + className={({ active }) => + `${ + active ? "bg-gray-200" : "" + } group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-gray-600` } value={label.id} > - - {label.name} + {({ selected }) => ( +
    +
    + + {label.name} +
    +
    + +
    +
    + )}
    ); } else return ( -
    -
    +
    +
    {label.name}
    {children.map((child) => ( - `${active ? "bg-indigo-50" : ""} ${ - selected ? "bg-indigo-50 font-medium" : "" - } flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` + className={({ active }) => + `${ + active ? "bg-gray-200" : "" + } group flex min-w-[14rem] cursor-pointer select-none items-center gap-2 truncate rounded px-1 py-1.5 text-gray-600` } value={child.id} > - - {child.name} + {({ selected }) => ( +
    +
    + + {child.name} +
    +
    + +
    +
    + )}
    ))}
    @@ -140,18 +196,20 @@ export const IssueLabelSelect: React.FC = ({ setIsOpen, value, onChange, ); }) ) : ( -

    No labels found

    +

    No labels found

    ) ) : ( -

    Loading...

    +

    Loading...

    )}
    diff --git a/apps/app/components/issues/select/priority.tsx b/apps/app/components/issues/select/priority.tsx index 1347e2765..d184ad0e5 100644 --- a/apps/app/components/issues/select/priority.tsx +++ b/apps/app/components/issues/select/priority.tsx @@ -1,7 +1,7 @@ import React from "react"; -// headless ui -import { Listbox, Transition } from "@headlessui/react"; +// ui +import { CustomSelect } from "components/ui"; // icons import { getPriorityIcon } from "components/icons/priority-icon"; // constants @@ -13,43 +13,30 @@ type Props = { }; export const IssuePrioritySelect: React.FC = ({ value, onChange }) => ( - - {({ open }) => ( - <> - - {getPriorityIcon(value)} -
    {value ?? "Priority"}
    -
    - - - -
    - {PRIORITIES.map((priority) => ( - - `${selected ? "bg-indigo-50 font-medium" : ""} ${ - active ? "bg-indigo-50" : "" - } relative cursor-pointer select-none p-2 text-gray-900` - } - value={priority} - > - - {getPriorityIcon(priority)} - {priority ?? "None"} - - - ))} -
    -
    -
    - - )} -
    + + + {getPriorityIcon(value, `${value ? "text-xs" : "text-xs text-gray-500"}`)} + + + {value ?? "Priority"} + +
    + } + onChange={onChange} + noChevron + > + {PRIORITIES.map((priority) => ( + +
    +
    + {getPriorityIcon(priority)} + {priority ?? "None"} +
    +
    +
    + ))} + ); diff --git a/apps/app/components/issues/select/project.tsx b/apps/app/components/issues/select/project.tsx index 39b6b470d..be35098e0 100644 --- a/apps/app/components/issues/select/project.tsx +++ b/apps/app/components/issues/select/project.tsx @@ -1,11 +1,9 @@ -import { FC, Fragment } from "react"; - import { useRouter } from "next/router"; import useSWR from "swr"; -// headless ui -import { Listbox, Transition } from "@headlessui/react"; +// ui +import { CustomSelect } from "components/ui"; // icons import { ClipboardDocumentListIcon } from "@heroicons/react/24/outline"; // services @@ -19,7 +17,7 @@ export interface IssueProjectSelectProps { setActiveProject: React.Dispatch>; } -export const IssueProjectSelect: FC = ({ +export const IssueProjectSelect: React.FC = ({ value, onChange, setActiveProject, @@ -34,71 +32,35 @@ export const IssueProjectSelect: FC = ({ ); return ( - <> - { - onChange(val); - setActiveProject(val); - }} - > - {({ open }) => ( - <> -
    - - - - {projects?.find((i) => i.id === value)?.identifier ?? "Project"} - - - - - -
    - {projects ? ( - projects.length > 0 ? ( - projects.map((project) => ( - - `${active ? "bg-indigo-50" : ""} ${ - selected ? "bg-indigo-50 font-medium" : "" - } cursor-pointer select-none p-2 text-gray-900` - } - value={project.id} - > - {({ selected }) => ( - <> - - {project.name} - - - )} - - )) - ) : ( -

    No projects found!

    - ) - ) : ( -
    Loading...
    - )} -
    -
    -
    -
    - - )} -
    - + + + + {projects?.find((i) => i.id === value)?.identifier ?? "Project"} + + + } + onChange={(val: string) => { + onChange(val); + setActiveProject(val); + }} + noChevron + > + {projects ? ( + projects.length > 0 ? ( + projects.map((project) => ( + + <>{project.name} + + )) + ) : ( +

    No projects found!

    + ) + ) : ( +
    Loading...
    + )} +
    ); }; diff --git a/apps/app/components/issues/select/state.tsx b/apps/app/components/issues/select/state.tsx index 269b0af73..eca350fe6 100644 --- a/apps/app/components/issues/select/state.tsx +++ b/apps/app/components/issues/select/state.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react"; +import React from "react"; import { useRouter } from "next/router"; @@ -6,10 +6,11 @@ import useSWR from "swr"; // services import stateService from "services/state.service"; -// headless ui -import { Squares2X2Icon, PlusIcon } from "@heroicons/react/24/outline"; +// ui +import { CustomSearchSelect } from "components/ui"; // icons -import { Combobox, Transition } from "@headlessui/react"; +import { PlusIcon, Squares2X2Icon } from "@heroicons/react/24/outline"; +import { getStateGroupIcon } from "components/icons"; // helpers import { getStatesList } from "helpers/state.helper"; // fetch keys @@ -24,8 +25,6 @@ type Props = { export const IssueStateSelect: React.FC = ({ setIsOpen, value, onChange, projectId }) => { // states - const [query, setQuery] = useState(""); - const router = useRouter(); const { workspaceSlug } = router.query; @@ -39,103 +38,41 @@ export const IssueStateSelect: React.FC = ({ setIsOpen, value, onChange, const options = states?.map((state) => ({ value: state.id, - display: state.name, - color: state.color, + query: state.name, + content: ( +
    + {getStateGroupIcon(state.group, "16", "16", state.color)} + {state.name} +
    + ), })); - const filteredOptions = - query === "" - ? options - : options?.filter((option) => option.display.toLowerCase().includes(query.toLowerCase())); + const selectedOption = states?.find((s) => s.id === value); return ( - onChange(val)} - className="relative flex-shrink-0" - > - {({ open }: any) => ( - <> - State - - - - {value && value !== "" ? ( - option.value === value)?.color, - }} - /> - ) : null} - {options?.find((option) => option.value === value)?.display || "State"} - - - - - - setQuery(event.target.value)} - placeholder="Search" - displayValue={(assigned: any) => assigned?.name} - /> -
    - {filteredOptions ? ( - filteredOptions.length > 0 ? ( - filteredOptions.map((option) => ( - - `${active ? "bg-indigo-50" : ""} ${ - selected ? "bg-indigo-50 font-medium" : "" - } flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` - } - value={option.value} - > - {states && ( - <> - - {option.display} - - )} - - )) - ) : ( -

    No states found

    - ) - ) : ( -

    Loading...

    - )} - -
    -
    -
    - - )} -
    + onChange={onChange} + options={options} + label={ +
    + + {selectedOption && + getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)} + {selectedOption?.name ?? "State"} +
    + } + footerOption={ + + } + noChevron + /> ); }; diff --git a/apps/app/components/issues/sidebar-select/assignee.tsx b/apps/app/components/issues/sidebar-select/assignee.tsx index 2d8088059..0d45143a3 100644 --- a/apps/app/components/issues/sidebar-select/assignee.tsx +++ b/apps/app/components/issues/sidebar-select/assignee.tsx @@ -1,41 +1,57 @@ import React from "react"; -import Image from "next/image"; import { useRouter } from "next/router"; import useSWR from "swr"; -// react-hook-form -import { Control, Controller } from "react-hook-form"; -// headless ui -import { Listbox, Transition } from "@headlessui/react"; // services -import { UserGroupIcon } from "@heroicons/react/24/outline"; -import workspaceService from "services/workspace.service"; -// hooks +import projectService from "services/project.service"; // ui -import { AssigneesList } from "components/ui/avatar"; -import { Spinner } from "components/ui"; +import { CustomSearchSelect } from "components/ui"; +import { AssigneesList, Avatar } from "components/ui/avatar"; +// icons +import { UserGroupIcon } from "@heroicons/react/24/outline"; // types -import { IIssue, UserAuth } from "types"; -// constants -import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; +import { UserAuth } from "types"; +// fetch-keys +import { PROJECT_MEMBERS } from "constants/fetch-keys"; type Props = { - control: Control; - submitChanges: (formData: Partial) => void; + value: string[]; + onChange: (val: string[]) => void; userAuth: UserAuth; }; -export const SidebarAssigneeSelect: React.FC = ({ control, submitChanges, userAuth }) => { +export const SidebarAssigneeSelect: React.FC = ({ value, onChange, userAuth }) => { const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; - const { data: people } = useSWR( - workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug as string) : null, - workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null + const { data: members } = useSWR( + workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, + workspaceSlug && projectId + ? () => projectService.projectMembers(workspaceSlug as string, projectId as string) + : null ); + const options = + members?.map((member) => ({ + value: member.member.id, + query: + (member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email) + + " " + + member.member.last_name ?? "", + content: ( +
    + + {member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email} +
    + ), + })) ?? []; + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; return ( @@ -45,93 +61,24 @@ export const SidebarAssigneeSelect: React.FC = ({ control, submitChanges,

    Assignees

    - ( - { - submitChanges({ assignees_list: value }); - }} - className="flex-shrink-0" - disabled={isNotAllowed} - > - {({ open }) => ( -
    - -
    - {value && Array.isArray(value) ? ( - - ) : null} -
    -
    - - - -
    - {people ? ( - people.length > 0 ? ( - people.map((option) => ( - - `${active || selected ? "bg-indigo-50" : ""} ${ - selected ? "font-medium" : "" - } flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` - } - value={option.member.id} - > - {option.member.avatar && option.member.avatar !== "" ? ( -
    - avatar -
    - ) : ( -
    - {option.member.first_name && option.member.first_name !== "" - ? option.member.first_name.charAt(0) - : option.member.email.charAt(0)} -
    - )} - {option.member.first_name && option.member.first_name !== "" - ? option.member.first_name - : option.member.email} -
    - )) - ) : ( -
    No assignees found
    - ) - ) : ( - - )} -
    -
    -
    + + {value && value.length > 0 && Array.isArray(value) ? ( +
    + + {value.length} Assignees
    + ) : ( + "No assignees" )} - - )} +
    + } + options={options} + onChange={onChange} + multiple + disabled={isNotAllowed} />
    diff --git a/apps/app/components/issues/sidebar-select/cycle.tsx b/apps/app/components/issues/sidebar-select/cycle.tsx index 127c78eba..2d1251454 100644 --- a/apps/app/components/issues/sidebar-select/cycle.tsx +++ b/apps/app/components/issues/sidebar-select/cycle.tsx @@ -65,26 +65,21 @@ export const SidebarCycleSelect: React.FC = ({
    - - {issueCycle ? issueCycle.cycle_detail.name : "None"} - - + {issueCycle ? issueCycle.cycle_detail.name : "None"} + } value={issueCycle?.cycle_detail.id} onChange={(value: any) => { - value === null + !value ? removeIssueFromCycle(issueCycle?.id ?? "", issueCycle?.cycle ?? "") : handleCycleChange(cycles?.find((c) => c.id === value) as ICycle); }} + width="w-full" disabled={isNotAllowed} > {cycles ? ( @@ -97,11 +92,7 @@ export const SidebarCycleSelect: React.FC = ({ ))} - - - None - - + None ) : (
    No cycles found
    diff --git a/apps/app/components/issues/sidebar-select/module.tsx b/apps/app/components/issues/sidebar-select/module.tsx index 90661a0df..977ae7649 100644 --- a/apps/app/components/issues/sidebar-select/module.tsx +++ b/apps/app/components/issues/sidebar-select/module.tsx @@ -64,26 +64,21 @@ export const SidebarModuleSelect: React.FC = ({
    m.id === issueModule?.module)?.name ?? "None"} + - - {modules?.find((m) => m.id === issueModule?.module)?.name ?? "None"} - - + {modules?.find((m) => m.id === issueModule?.module)?.name ?? "None"} + } value={issueModule?.module_detail?.id} onChange={(value: any) => { - value === null + !value ? removeIssueFromModule(issueModule?.id ?? "", issueModule?.module ?? "") : handleModuleChange(modules?.find((m) => m.id === value) as IModule); }} + width="w-full" disabled={isNotAllowed} > {modules ? ( @@ -96,11 +91,7 @@ export const SidebarModuleSelect: React.FC = ({ ))} - - - None - - + None ) : (
    No modules found
    diff --git a/apps/app/components/issues/sidebar-select/priority.tsx b/apps/app/components/issues/sidebar-select/priority.tsx index 7583f16f4..b3464a106 100644 --- a/apps/app/components/issues/sidebar-select/priority.tsx +++ b/apps/app/components/issues/sidebar-select/priority.tsx @@ -1,24 +1,22 @@ import React from "react"; -// react-hook-form -import { Control, Controller } from "react-hook-form"; // ui import { CustomSelect } from "components/ui"; // icons import { ChartBarIcon } from "@heroicons/react/24/outline"; import { getPriorityIcon } from "components/icons/priority-icon"; // types -import { IIssue, UserAuth } from "types"; +import { UserAuth } from "types"; // constants import { PRIORITIES } from "constants/project"; type Props = { - control: Control; - submitChanges: (formData: Partial) => void; + value: string | null; + onChange: (val: string) => void; userAuth: UserAuth; }; -export const SidebarPrioritySelect: React.FC = ({ control, submitChanges, userAuth }) => { +export const SidebarPrioritySelect: React.FC = ({ value, onChange, userAuth }) => { const isNotAllowed = userAuth.isGuest || userAuth.isViewer; return ( @@ -28,38 +26,31 @@ export const SidebarPrioritySelect: React.FC = ({ control, submitChanges,

    Priority

    - ( - - {getPriorityIcon(value && value !== "" ? value ?? "" : "None", "text-sm")} - {value && value !== "" ? value : "None"} - - } - value={value} - onChange={(value: any) => { - submitChanges({ priority: value }); - }} - disabled={isNotAllowed} + - {PRIORITIES.map((option) => ( - - <> - {getPriorityIcon(option, "text-sm")} - {option ?? "None"} - - - ))} - - )} - /> + {getPriorityIcon(value && value !== "" ? value ?? "" : "None", "text-sm")} + {value && value !== "" ? value : "None"} + + } + value={value} + onChange={onChange} + width="w-full" + disabled={isNotAllowed} + > + {PRIORITIES.map((option) => ( + + <> + {getPriorityIcon(option, "text-sm")} + {option ?? "None"} + + + ))} +
    ); diff --git a/apps/app/components/issues/sidebar-select/state.tsx b/apps/app/components/issues/sidebar-select/state.tsx index 0ed3a04bb..13ecd3c62 100644 --- a/apps/app/components/issues/sidebar-select/state.tsx +++ b/apps/app/components/issues/sidebar-select/state.tsx @@ -4,28 +4,28 @@ import { useRouter } from "next/router"; import useSWR from "swr"; -// react-hook-form -import { Control, Controller } from "react-hook-form"; // services import stateService from "services/state.service"; // ui import { Spinner, CustomSelect } from "components/ui"; // icons import { Squares2X2Icon } from "@heroicons/react/24/outline"; +import { getStateGroupIcon } from "components/icons"; // helpers import { getStatesList } from "helpers/state.helper"; +import { addSpaceIfCamelCase } from "helpers/string.helper"; // types -import { IIssue, UserAuth } from "types"; +import { UserAuth } from "types"; // constants import { STATE_LIST } from "constants/fetch-keys"; type Props = { - control: Control; - submitChanges: (formData: Partial) => void; + value: string; + onChange: (val: string) => void; userAuth: UserAuth; }; -export const SidebarStateSelect: React.FC = ({ control, submitChanges, userAuth }) => { +export const SidebarStateSelect: React.FC = ({ value, onChange, userAuth }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; @@ -37,6 +37,8 @@ export const SidebarStateSelect: React.FC = ({ control, submitChanges, us ); const states = getStatesList(stateGroups ?? {}); + const selectedState = states?.find((s) => s.id === value); + const isNotAllowed = userAuth.isGuest || userAuth.isViewer; return ( @@ -46,60 +48,40 @@ export const SidebarStateSelect: React.FC = ({ control, submitChanges, us

    State

    - ( - - {value ? ( - <> - option.id === value)?.color, - }} - /> - {states?.find((option) => option.id === value)?.name} - - ) : ( - "None" - )} - - } - value={value} - onChange={(value: any) => { - submitChanges({ state: value }); - }} - disabled={isNotAllowed} - > - {states ? ( - states.length > 0 ? ( - states.map((option) => ( - - <> - {option.color && ( - - )} - {option.name} - - - )) - ) : ( -
    No states found
    - ) - ) : ( - + + {getStateGroupIcon( + selectedState?.group ?? "backlog", + "16", + "16", + selectedState?.color ?? "" )} - + {addSpaceIfCamelCase(selectedState?.name ?? "")} +
    + } + value={value} + onChange={onChange} + width="w-full" + disabled={isNotAllowed} + > + {states ? ( + states.length > 0 ? ( + states.map((state) => ( + + <> + {getStateGroupIcon(state.group, "16", "16", state.color)} + {state.name} + + + )) + ) : ( +
    No states found
    + ) + ) : ( + )} - /> +
    ); diff --git a/apps/app/components/issues/sidebar.tsx b/apps/app/components/issues/sidebar.tsx index 0829d4226..5632dc015 100644 --- a/apps/app/components/issues/sidebar.tsx +++ b/apps/app/components/issues/sidebar.tsx @@ -149,16 +149,12 @@ export const IssueDetailsSidebar: React.FC = ({ const handleCreateLink = async (formData: IIssueLink) => { if (!workspaceSlug || !projectId || !issueDetail) return; - const previousLinks = issueDetail?.issue_link.map((l) => ({ title: l.title, url: l.url })); - - const payload: Partial = { - links_list: [...(previousLinks ?? []), formData], - }; + const payload = { metadata: {}, ...formData }; await issuesService - .patchIssue(workspaceSlug as string, projectId as string, issueDetail.id, payload) + .createIssueLink(workspaceSlug as string, projectId as string, issueDetail.id, payload) .then((res) => { - mutate(ISSUE_DETAILS(issueDetail.id as string)); + mutate(ISSUE_DETAILS(issueDetail.id)); }) .catch((err) => { console.log(err); @@ -171,17 +167,15 @@ export const IssueDetailsSidebar: React.FC = ({ const updatedLinks = issueDetail.issue_link.filter((l) => l.id !== linkId); mutate( - ISSUE_DETAILS(issueDetail.id as string), + ISSUE_DETAILS(issueDetail.id), (prevData) => ({ ...(prevData as IIssue), issue_link: updatedLinks }), false ); await issuesService - .patchIssue(workspaceSlug as string, projectId as string, issueDetail.id, { - links_list: updatedLinks, - }) + .deleteIssueLink(workspaceSlug as string, projectId as string, issueDetail.id, linkId) .then((res) => { - mutate(ISSUE_DETAILS(issueDetail.id as string)); + mutate(ISSUE_DETAILS(issueDetail.id)); }) .catch((err) => { console.log(err); @@ -223,7 +217,7 @@ export const IssueDetailsSidebar: React.FC = ({ isOpen={deleteIssueModal} data={issueDetail ?? null} /> -
    +

    {issueDetail?.project_detail?.identifier}-{issueDetail?.sequence_id} @@ -249,20 +243,38 @@ export const IssueDetailsSidebar: React.FC = ({

    - ( + submitChanges({ state: val })} + userAuth={userAuth} + /> + )} /> - ( + submitChanges({ assignees_list: val })} + userAuth={userAuth} + /> + )} /> - ( + submitChanges({ priority: val })} + userAuth={userAuth} + /> + )} />
    @@ -448,8 +460,8 @@ export const IssueDetailsSidebar: React.FC = ({ ); } else return ( -
    -
    +
    +
    {" "} {label.name}
    diff --git a/apps/app/components/issues/view-select/assignee.tsx b/apps/app/components/issues/view-select/assignee.tsx index cb482dfa5..6e9c900fd 100644 --- a/apps/app/components/issues/view-select/assignee.tsx +++ b/apps/app/components/issues/view-select/assignee.tsx @@ -4,12 +4,12 @@ import { useRouter } from "next/router"; import useSWR from "swr"; -// headless ui -import { Listbox, Transition } from "@headlessui/react"; // services import projectService from "services/project.service"; // ui -import { AssigneesList, Avatar, Tooltip } from "components/ui"; +import { AssigneesList, Avatar, CustomSearchSelect, Tooltip } from "components/ui"; +// icons +import { UserGroupIcon } from "@heroicons/react/24/outline"; // types import { IIssue } from "types"; // fetch-keys @@ -18,6 +18,7 @@ import { PROJECT_MEMBERS } from "constants/fetch-keys"; type Props = { issue: IIssue; partialUpdateIssue: (formData: Partial) => void; + position?: "left" | "right"; selfPositioned?: boolean; tooltipPosition?: "left" | "right"; isNotAllowed: boolean; @@ -26,6 +27,7 @@ type Props = { export const ViewAssigneeSelect: React.FC = ({ issue, partialUpdateIssue, + position = "left", selfPositioned = false, tooltipPosition = "right", isNotAllowed, @@ -40,9 +42,27 @@ export const ViewAssigneeSelect: React.FC = ({ : null ); + const options = + members?.map((member) => ({ + value: member.member.id, + query: + (member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email) + + " " + + member.member.last_name ?? "", + content: ( +
    + + {member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email} +
    + ), + })) ?? []; + return ( - { const newData = issue.assignees ?? []; @@ -50,69 +70,46 @@ export const ViewAssigneeSelect: React.FC = ({ if (newData.includes(data)) newData.splice(newData.indexOf(data), 1); else newData.push(data); - partialUpdateIssue({ assignees_list: newData }); + partialUpdateIssue({ assignees_list: data }); }} - className={`group ${!selfPositioned ? "relative" : ""} flex-shrink-0`} - disabled={isNotAllowed} - > - {({ open }) => ( -
    - - 0 - ? issue.assignee_details - .map((assignee) => - assignee?.first_name !== "" ? assignee?.first_name : assignee?.email - ) - .join(", ") - : "No Assignee" - } - > -
    - -
    -
    -
    - - 0 + ? issue.assignee_details + .map((assignee) => + assignee?.first_name !== "" ? assignee?.first_name : assignee?.email + ) + .join(", ") + : "No Assignee" + } + > +
    - - {members?.map((member) => ( - - `flex items-center gap-x-1 cursor-pointer select-none p-2 whitespace-nowrap ${ - active ? "bg-indigo-50" : "" - } ${ - selected || issue.assignees?.includes(member.member.id) - ? "bg-indigo-50 font-medium" - : "font-normal" - }` - } - value={member.member.id} - > - - {member.member.first_name && member.member.first_name !== "" - ? member.member.first_name - : member.member.email} - - ))} - - -
    - )} - + {issue.assignees && issue.assignees.length > 0 && Array.isArray(issue.assignees) ? ( +
    + + {issue.assignees.length} Assignees +
    + ) : ( +
    + + Assignee +
    + )} +
    + + } + multiple + noChevron + position={position} + disabled={isNotAllowed} + /> ); }; diff --git a/apps/app/components/issues/view-select/priority.tsx b/apps/app/components/issues/view-select/priority.tsx index 52a911f5e..6ca81037e 100644 --- a/apps/app/components/issues/view-select/priority.tsx +++ b/apps/app/components/issues/view-select/priority.tsx @@ -12,6 +12,7 @@ import { PRIORITIES } from "constants/project"; type Props = { issue: IIssue; partialUpdateIssue: (formData: Partial) => void; + position?: "left" | "right"; selfPositioned?: boolean; isNotAllowed: boolean; }; @@ -19,40 +20,44 @@ type Props = { export const ViewPrioritySelect: React.FC = ({ issue, partialUpdateIssue, + position = "left", selfPositioned = false, isNotAllowed, }) => ( - - {getPriorityIcon( - issue.priority && issue.priority !== "" ? issue.priority ?? "" : "None", - "text-sm" - )} - - - } value={issue.state} - onChange={(data: string) => { - partialUpdateIssue({ priority: data }); - }} + onChange={(data: string) => partialUpdateIssue({ priority: data })} maxHeight="md" - buttonClassName={`flex ${ - isNotAllowed ? "cursor-not-allowed" : "cursor-pointer" - } items-center gap-x-2 rounded px-2 py-0.5 capitalize shadow-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${ - issue.priority === "urgent" - ? "bg-red-100 text-red-600 hover:bg-red-100" - : issue.priority === "high" - ? "bg-orange-100 text-orange-500 hover:bg-orange-100" - : issue.priority === "medium" - ? "bg-yellow-100 text-yellow-500 hover:bg-yellow-100" - : issue.priority === "low" - ? "bg-green-100 text-green-500 hover:bg-green-100" - : "bg-gray-100" - } border-none`} + customButton={ + + } noChevron disabled={isNotAllowed} + position={position} selfPositioned={selfPositioned} > {PRIORITIES?.map((priority) => ( diff --git a/apps/app/components/issues/view-select/state.tsx b/apps/app/components/issues/view-select/state.tsx index 704bef426..b76a3c237 100644 --- a/apps/app/components/issues/view-select/state.tsx +++ b/apps/app/components/issues/view-select/state.tsx @@ -5,7 +5,9 @@ import useSWR from "swr"; // services import stateService from "services/state.service"; // ui -import { CustomSelect, Tooltip } from "components/ui"; +import { CustomSearchSelect, Tooltip } from "components/ui"; +// icons +import { getStateGroupIcon } from "components/icons"; // helpers import { addSpaceIfCamelCase } from "helpers/string.helper"; import { getStatesList } from "helpers/state.helper"; @@ -17,6 +19,7 @@ import { STATE_LIST } from "constants/fetch-keys"; type Props = { issue: IIssue; partialUpdateIssue: (formData: Partial) => void; + position?: "left" | "right"; selfPositioned?: boolean; isNotAllowed: boolean; }; @@ -24,6 +27,7 @@ type Props = { export const ViewStateSelect: React.FC = ({ issue, partialUpdateIssue, + position = "left", selfPositioned = false, isNotAllowed, }) => { @@ -38,50 +42,39 @@ export const ViewStateSelect: React.FC = ({ ); const states = getStatesList(stateGroups ?? {}); + const options = states?.map((state) => ({ + value: state.id, + query: state.name, + content: ( +
    + {getStateGroupIcon(state.group, "16", "16", state.color)} + {state.name} +
    + ), + })); + + const selectedOption = states?.find((s) => s.id === issue.state); + return ( - - s.id === issue.state)?.color, - }} - /> - s.id === issue.state)?.name ?? "" - )} - > - - {addSpaceIfCamelCase(states?.find((s) => s.id === issue.state)?.name ?? "")} - - - - } + { - partialUpdateIssue({ state: data }); - }} - maxHeight="md" - noChevron + onChange={(data: string) => partialUpdateIssue({ state: data })} + options={options} + label={ + +
    + {selectedOption && + getStateGroupIcon(selectedOption.group, "16", "16", selectedOption.color)} + {selectedOption?.name ?? "State"} +
    +
    + } + position={position} disabled={isNotAllowed} - selfPositioned={selfPositioned} - > - {states?.map((state) => ( - - <> - - {addSpaceIfCamelCase(state.name)} - - - ))} -
    + noChevron + /> ); }; diff --git a/apps/app/components/labels/create-update-label-inline.tsx b/apps/app/components/labels/create-update-label-inline.tsx index 6c61d6d5a..a3a9c6bad 100644 --- a/apps/app/components/labels/create-update-label-inline.tsx +++ b/apps/app/components/labels/create-update-label-inline.tsx @@ -109,7 +109,7 @@ export const CreateUpdateLabelInline: React.FC = ({ return (
    diff --git a/apps/app/components/labels/labels-list-modal.tsx b/apps/app/components/labels/labels-list-modal.tsx index 01f7e8d25..7c0d17742 100644 --- a/apps/app/components/labels/labels-list-modal.tsx +++ b/apps/app/components/labels/labels-list-modal.tsx @@ -92,7 +92,7 @@ export const LabelsListModal: React.FC = ({ isOpen, handleClose, parent } leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > - +
    = ({ isOpen, handleClose, parent } }} > = ({ }; return ( - + {({ open }) => ( <> -
    - -
    +
    +
    + + + +
    {label.name}
    +
    +
    + + addLabelToGroup(label)}> + Add more labels + + editLabel(label)}>Edit + handleLabelDelete(label.id)}> + Delete + + + - - - -
    {label.name}
    -
    - - - addLabelToGroup(label)}> - Add more labels - - editLabel(label)}>Edit - handleLabelDelete(label.id)}> - Delete - - + +
    = ({ leaveTo="transform opacity-0" > -
    +
    {labelChildren.map((child) => (
    -
    +
    {child.name}
    -
    +
    removeFromGroup(child)}> Remove from group diff --git a/apps/app/components/labels/single-label.tsx b/apps/app/components/labels/single-label.tsx index 9c311518e..0bef9ee35 100644 --- a/apps/app/components/labels/single-label.tsx +++ b/apps/app/components/labels/single-label.tsx @@ -18,16 +18,16 @@ export const SingleLabel: React.FC = ({ editLabel, handleLabelDelete, }) => ( -
    +
    -
    +
    -
    {label.name}
    +
    {label.name}
    addLabelToGroup(label)}> diff --git a/apps/app/components/modules/form.tsx b/apps/app/components/modules/form.tsx index 35a141451..936424e01 100644 --- a/apps/app/components/modules/form.tsx +++ b/apps/app/components/modules/form.tsx @@ -114,8 +114,20 @@ export const ModuleForm: React.FC = ({ handleFormSubmit, handleClose, sta
    - - + ( + + )} + /> + ( + + )} + />
    diff --git a/apps/app/components/modules/modal.tsx b/apps/app/components/modules/modal.tsx index 7acf81339..b611f415b 100644 --- a/apps/app/components/modules/modal.tsx +++ b/apps/app/components/modules/modal.tsx @@ -76,15 +76,12 @@ export const CreateUpdateModuleModal: React.FC = ({ isOpen, setIsOpen, da .then((res) => { mutate( MODULE_LIST(projectId as string), - (prevData) => { - const newData = prevData?.map((item) => { - if (item.id === res.id) { - return res; - } - return item; - }); - return newData; - }, + (prevData) => + prevData?.map((p) => { + if (p.id === res.id) return { ...p, ...payload }; + + return p; + }), false ); handleClose(); @@ -109,6 +106,7 @@ export const CreateUpdateModuleModal: React.FC = ({ isOpen, setIsOpen, da const payload: Partial = { ...formData, + members_list: formData.members, }; if (!data) await createModule(payload); diff --git a/apps/app/components/modules/select/select-lead.tsx b/apps/app/components/modules/select/select-lead.tsx index e89add487..0d0e1ace1 100644 --- a/apps/app/components/modules/select/select-lead.tsx +++ b/apps/app/components/modules/select/select-lead.tsx @@ -1,57 +1,78 @@ import React from "react"; import { useRouter } from "next/router"; +import Image from "next/image"; import useSWR from "swr"; -// react-hook-form -import { Controller, Control } from "react-hook-form"; // services import projectServices from "services/project.service"; // ui -import SearchListbox from "components/search-listbox"; +import { Avatar, CustomSearchSelect } from "components/ui"; // icons -import { UserIcon } from "@heroicons/react/24/outline"; -// types -import type { IModule } from "types"; +import User from "public/user.png"; // fetch-keys import { PROJECT_MEMBERS } from "constants/fetch-keys"; type Props = { - control: Control; + value: string | null; + onChange: () => void; }; -export const ModuleLeadSelect: React.FC = ({ control }) => { +export const ModuleLeadSelect: React.FC = ({ value, onChange }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; - const { data: people } = useSWR( + const { data: members } = useSWR( workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, workspaceSlug && projectId ? () => projectServices.projectMembers(workspaceSlug as string, projectId as string) : null ); + const options = + members?.map((member) => ({ + value: member.member.id, + query: + (member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email) + + " " + + member.member.last_name ?? "", + content: ( +
    + + {member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email} +
    + ), + })) ?? []; + + const selectedOption = members?.find((m) => m.member.id === value)?.member; + return ( - ( - ({ - value: person.member.id, - display: - person.member.first_name && person.member.first_name !== "" - ? person.member.first_name - : person.member.email, - }))} - value={value} - onChange={onChange} - icon={} - /> - )} + + {selectedOption ? ( + + ) : ( +
    + No user +
    + )} + {selectedOption + ? selectedOption?.first_name && selectedOption.first_name !== "" + ? selectedOption?.first_name + : selectedOption?.email + : "N/A"} +
    + } + onChange={onChange} + noChevron /> ); }; diff --git a/apps/app/components/modules/select/select-members.tsx b/apps/app/components/modules/select/select-members.tsx index 9ad8b532f..439753a91 100644 --- a/apps/app/components/modules/select/select-members.tsx +++ b/apps/app/components/modules/select/select-members.tsx @@ -4,55 +4,72 @@ import { useRouter } from "next/router"; import useSWR from "swr"; -// react-hook-form -import { Controller, Control } from "react-hook-form"; // services import projectServices from "services/project.service"; // ui -import SearchListbox from "components/search-listbox"; +import { AssigneesList, Avatar, CustomSearchSelect } from "components/ui"; // icons -import { UserIcon } from "@heroicons/react/24/outline"; -// types -import type { IModule } from "types"; +import { UserGroupIcon } from "@heroicons/react/24/outline"; // fetch-keys import { PROJECT_MEMBERS } from "constants/fetch-keys"; type Props = { - control: Control; + value: string[]; + onChange: () => void; }; -export const ModuleMembersSelect: React.FC = ({ control }) => { +export const ModuleMembersSelect: React.FC = ({ value, onChange }) => { const router = useRouter(); const { workspaceSlug, projectId } = router.query; - const { data: people } = useSWR( + const { data: members } = useSWR( workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, workspaceSlug && projectId ? () => projectServices.projectMembers(workspaceSlug as string, projectId as string) : null ); + const options = + members?.map((member) => ({ + value: member.member.id, + query: + (member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email) + + " " + + member.member.last_name ?? "", + content: ( +
    + + {member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email} +
    + ), + })) ?? []; return ( - ( - ({ - value: person.member.id, - display: - person.member.first_name && person.member.first_name !== "" - ? person.member.first_name - : person.member.email, - }))} - multiple={true} - value={value} - onChange={onChange} - icon={} - /> - )} + + {value && value.length > 0 && Array.isArray(value) ? ( +
    + + {value.length} Assignees +
    + ) : ( +
    + + Assignee +
    + )} +
    + } + options={options} + onChange={onChange} + height="md" + multiple + noChevron /> ); }; diff --git a/apps/app/components/modules/select/select-status.tsx b/apps/app/components/modules/select/select-status.tsx index 06e476210..f0723eb27 100644 --- a/apps/app/components/modules/select/select-status.tsx +++ b/apps/app/components/modules/select/select-status.tsx @@ -3,7 +3,7 @@ import React from "react"; // react hook form import { Controller, FieldError, Control } from "react-hook-form"; // ui -import { CustomListbox } from "components/ui"; +import { CustomSelect } from "components/ui"; // icons import { Squares2X2Icon } from "@heroicons/react/24/outline"; // types @@ -22,26 +22,43 @@ export const ModuleStatusSelect: React.FC = ({ control, error }) => ( rules={{ required: true }} name="status" render={({ field: { value, onChange } }) => ( -
    - ({ - value: status.value, - display: status.label, - color: status.color, - }))} - value={value} - optionsFontsize="sm" - onChange={onChange} - icon={} - /> - {error &&

    {error.message}

    } -
    + + + {value && ( + s.value === value)?.color, + }} + /> + )} + {MODULE_STATUS.find((s) => s.value === value)?.label ?? "Status"} +
    + } + onChange={onChange} + noChevron + > + {MODULE_STATUS.map((status) => ( + +
    + + {status.label} +
    +
    + ))} + )} /> ); diff --git a/apps/app/components/modules/sidebar-select/select-lead.tsx b/apps/app/components/modules/sidebar-select/select-lead.tsx index 84f230692..bf45f614e 100644 --- a/apps/app/components/modules/sidebar-select/select-lead.tsx +++ b/apps/app/components/modules/sidebar-select/select-lead.tsx @@ -5,160 +5,88 @@ import { useRouter } from "next/router"; import useSWR from "swr"; -// react-hook-form -import { Control, Controller } from "react-hook-form"; -// headless ui -import { Listbox, Transition } from "@headlessui/react"; // services -import workspaceService from "services/workspace.service"; +import projectService from "services/project.service"; +// ui +import { Avatar, CustomSearchSelect } from "components/ui"; // icons -import { UserIcon } from "@heroicons/react/24/outline"; +import { UserCircleIcon } from "@heroicons/react/24/outline"; import User from "public/user.png"; -// types -import { IModule, IUserLite } from "types"; // fetch-keys -import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; +import { PROJECT_MEMBERS } from "constants/fetch-keys"; type Props = { - control: Control, any>; - submitChanges: (formData: Partial) => void; - lead: IUserLite | null; + value: string | null | undefined; + onChange: (val: string) => void; }; -export const SidebarLeadSelect: React.FC = ({ control, submitChanges, lead }) => { +export const SidebarLeadSelect: React.FC = ({ value, onChange }) => { const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; - const { data: people } = useSWR( - workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug as string) : null, - workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null + const { data: members } = useSWR( + workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, + workspaceSlug && projectId + ? () => projectService.projectMembers(workspaceSlug as string, projectId as string) + : null ); + const options = + members?.map((member) => ({ + value: member.member.id, + query: + (member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email) + + " " + + member.member.last_name ?? "", + content: ( +
    + + {member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email} +
    + ), + })) ?? []; + + const selectedOption = members?.find((m) => m.member.id === value)?.member; + return ( -
    -
    - -

    Lead

    +
    +
    + + Lead
    - ( - { - submitChanges({ lead: value }); - }} - className="flex-shrink-0" - > - {({ open }) => ( -
    - - -
    - {lead ? ( - lead.avatar && lead.avatar !== "" ? ( -
    - {lead?.first_name} -
    - ) : ( -
    - {lead?.first_name && lead.first_name !== "" - ? lead.first_name.charAt(0) - : lead?.email.charAt(0)} -
    - ) - ) : ( -
    - No user -
    - )} - {lead - ? lead?.first_name && lead.first_name !== "" - ? lead?.first_name - : lead?.email - : "N/A"} -
    -
    -
    - - - -
    - {people ? ( - people.length > 0 ? ( - people.map((option) => ( - - `${ - active || selected ? "bg-indigo-50" : "" - } flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` - } - value={option.member.id} - > - {option.member.avatar && option.member.avatar !== "" ? ( -
    - avatar -
    - ) : ( -
    - {option.member.first_name && option.member.first_name !== "" - ? option.member.first_name.charAt(0) - : option.member.email.charAt(0)} -
    - )} - {option.member.first_name && option.member.first_name !== "" - ? option.member.first_name - : option.member.email} -
    - )) - ) : ( -
    No members found
    - ) - ) : ( -

    Loading...

    - )} -
    -
    -
    + + {selectedOption ? ( + + ) : ( +
    + No user
    )} - - )} + {selectedOption + ? selectedOption?.first_name && selectedOption.first_name !== "" + ? selectedOption?.first_name + : selectedOption?.email + : "N/A"} +
    + } + options={options} + height="md" + position="right" + onChange={onChange} />
    diff --git a/apps/app/components/modules/sidebar-select/select-members.tsx b/apps/app/components/modules/sidebar-select/select-members.tsx index d99dee135..ba00f3246 100644 --- a/apps/app/components/modules/sidebar-select/select-members.tsx +++ b/apps/app/components/modules/sidebar-select/select-members.tsx @@ -1,132 +1,79 @@ import React from "react"; -import Image from "next/image"; import { useRouter } from "next/router"; import useSWR from "swr"; -import { Control, Controller } from "react-hook-form"; // services -import { Listbox, Transition } from "@headlessui/react"; -import { UserGroupIcon } from "@heroicons/react/24/outline"; -import workspaceService from "services/workspace.service"; -// headless ui +import projectService from "services/project.service"; // ui -import { AssigneesList } from "components/ui"; -// types -import { IModule } from "types"; -// constants -import { WORKSPACE_MEMBERS } from "constants/fetch-keys"; +import { AssigneesList, Avatar, CustomSearchSelect } from "components/ui"; +// icons +import { UserGroupIcon } from "@heroicons/react/24/outline"; +// fetch-keys +import { PROJECT_MEMBERS } from "constants/fetch-keys"; type Props = { - control: Control, any>; - submitChanges: (formData: Partial) => void; + value: string[] | undefined; + onChange: (val: string[]) => void; }; -export const SidebarMembersSelect: React.FC = ({ control, submitChanges }) => { +export const SidebarMembersSelect: React.FC = ({ value, onChange }) => { const router = useRouter(); - const { workspaceSlug } = router.query; + const { workspaceSlug, projectId } = router.query; - const { data: people } = useSWR( - workspaceSlug ? WORKSPACE_MEMBERS(workspaceSlug as string) : null, - workspaceSlug ? () => workspaceService.workspaceMembers(workspaceSlug as string) : null + const { data: members } = useSWR( + workspaceSlug && projectId ? PROJECT_MEMBERS(projectId as string) : null, + workspaceSlug && projectId + ? () => projectService.projectMembers(workspaceSlug as string, projectId as string) + : null ); + const options = + members?.map((member) => ({ + value: member.member.id, + query: + (member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email) + + " " + + member.member.last_name ?? "", + content: ( +
    + + {member.member.first_name && member.member.first_name !== "" + ? member.member.first_name + : member.member.email} +
    + ), + })) ?? []; + return ( -
    -
    - -

    Members

    +
    +
    + + Members
    - ( - { - submitChanges({ members_list: value }); - }} - className="flex-shrink-0" - > - {({ open }) => ( -
    - - -
    - {value && Array.isArray(value) ? ( - - ) : null} -
    -
    -
    - - - -
    - {people ? ( - people.length > 0 ? ( - people.map((option) => ( - - `${ - active || selected ? "bg-indigo-50" : "" - } flex cursor-pointer select-none items-center gap-2 truncate p-2 text-gray-900` - } - value={option.member.id} - > - {option.member.avatar && option.member.avatar !== "" ? ( -
    - avatar -
    - ) : ( -
    - {option.member.first_name && option.member.first_name !== "" - ? option.member.first_name.charAt(0) - : option.member.email.charAt(0)} -
    - )} - {option.member.first_name && option.member.first_name !== "" - ? option.member.first_name - : option.member.email} -
    - )) - ) : ( -
    No members found
    - ) - ) : ( -

    Loading...

    - )} -
    -
    -
    + + {value && value.length > 0 && Array.isArray(value) ? ( +
    + + {value.length} Assignees
    + ) : ( + "No members" )} - - )} +
    + } + options={options} + onChange={onChange} + height="md" + position="right" + multiple />
    diff --git a/apps/app/components/modules/sidebar.tsx b/apps/app/components/modules/sidebar.tsx index 74d7c9483..07e102ce0 100644 --- a/apps/app/components/modules/sidebar.tsx +++ b/apps/app/components/modules/sidebar.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState } from "react"; -import Link from "next/link"; import { useRouter } from "next/router"; import { mutate } from "swr"; @@ -9,32 +8,32 @@ import { mutate } from "swr"; import { Controller, useForm } from "react-hook-form"; // icons import { + ArrowLongRightIcon, CalendarDaysIcon, ChartPieIcon, - LinkIcon, - PlusIcon, - Squares2X2Icon, + ChevronDownIcon, + DocumentDuplicateIcon, + DocumentIcon, TrashIcon, } from "@heroicons/react/24/outline"; -import { Popover, Transition } from "@headlessui/react"; +import { Disclosure, Popover, Transition } from "@headlessui/react"; import DatePicker from "react-datepicker"; - // services import modulesService from "services/modules.service"; // hooks import useToast from "hooks/use-toast"; // components -import { LinkModal, LinksList, SidebarProgressStats } from "components/core"; +import { LinkModal, SidebarProgressStats } from "components/core"; import { DeleteModuleModal, SidebarLeadSelect, SidebarMembersSelect } from "components/modules"; import ProgressChart from "components/core/sidebar/progress-chart"; // components // ui -import { CustomSelect, Loader, ProgressBar } from "components/ui"; +import { CustomMenu, CustomSelect, Loader, ProgressBar } from "components/ui"; // helpers -import { renderDateFormat, renderShortNumericDateFormat, timeAgo } from "helpers/date-time.helper"; -import { copyTextToClipboard } from "helpers/string.helper"; +import { renderDateFormat, renderShortDate, timeAgo } from "helpers/date-time.helper"; +import { capitalizeFirstLetter, copyTextToClipboard } from "helpers/string.helper"; import { groupBy } from "helpers/array.helper"; // types import { IIssue, IModule, ModuleIssueResponse, ModuleLink, UserAuth } from "types"; @@ -115,14 +114,10 @@ export const ModuleDetailsSidebar: React.FC = ({ const handleCreateLink = async (formData: ModuleLink) => { if (!workspaceSlug || !projectId || !moduleId) return; - const previousLinks = module?.link_module.map((l) => ({ title: l.title, url: l.url })); - - const payload: Partial = { - links_list: [...(previousLinks ?? []), formData], - }; + const payload = { metadata: {}, ...formData }; await modulesService - .patchModule(workspaceSlug as string, projectId as string, moduleId as string, payload) + .createModuleLink(workspaceSlug as string, projectId as string, moduleId as string, payload) .then((res) => { mutate(MODULE_DETAILS(moduleId as string)); }) @@ -135,11 +130,44 @@ export const ModuleDetailsSidebar: React.FC = ({ }); }; - const handleDeleteLink = (linkId: string) => { - if (!module) return; + const handleDeleteLink = async (linkId: string) => { + if (!workspaceSlug || !projectId || !module) return; const updatedLinks = module.link_module.filter((l) => l.id !== linkId); - submitChanges({ links_list: updatedLinks }); + + mutate( + MODULE_DETAILS(module.id), + (prevData) => ({ ...(prevData as IModule), link_module: updatedLinks }), + false + ); + + await modulesService + .deleteModuleLink(workspaceSlug as string, projectId as string, module.id, linkId) + .then((res) => { + mutate(MODULE_DETAILS(module.id)); + }) + .catch((err) => { + console.log(err); + }); + }; + + const handleCopyText = () => { + const originURL = + typeof window !== "undefined" && window.location.origin ? window.location.origin : ""; + + copyTextToClipboard(`${workspaceSlug}/projects/${projectId}/modules/${module?.id}`) + .then(() => { + setToastAlert({ + type: "success", + title: "Module link copied to clipboard", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Some error occurred", + }); + }); }; useEffect(() => { @@ -153,6 +181,10 @@ export const ModuleDetailsSidebar: React.FC = ({ const isStartValid = new Date(`${module?.start_date}`) <= new Date(); const isEndValid = new Date(`${module?.target_date}`) >= new Date(`${module?.start_date}`); + const progressPercentage = moduleIssues + ? Math.round((groupedIssues.completed.length / moduleIssues?.length) * 100) + : null; + return ( <> = ({
    {module ? ( <> -
    -
    - ( - +
    +
    + ( + + {capitalizeFirstLetter(`${watch("status")}`)} + + } + value={value} + onChange={(value: any) => { + submitChanges({ status: value }); + }} + > + {MODULE_STATUS.map((option) => ( + + {option.label} + + ))} + + )} + /> +
    +
    + + {({ open }) => ( + <> + - - {watch("status")} - - } - value={value} - onChange={(value: any) => { - submitChanges({ status: value }); - }} - > - {MODULE_STATUS.map((option) => ( - - {option.label} - - ))} - - )} - /> -
    -
    - - {({ open }) => ( - <> - - - - {renderShortNumericDateFormat(`${module?.start_date}`) - ? renderShortNumericDateFormat(`${module?.start_date}`) - : "N/A"} - - + + {renderShortDate(new Date(`${module.start_date}`))} + - - - { - submitChanges({ - start_date: renderDateFormat(date), - }); - setStartDateRange(date); - }} - selectsStart - startDate={startDateRange} - endDate={endDateRange} - inline - /> - - - - )} - - - {({ open }) => ( - <> - - - -{" "} - {renderShortNumericDateFormat(`${module?.target_date}`) - ? renderShortNumericDateFormat(`${module?.target_date}`) - : "N/A"} - - + + + { + submitChanges({ + start_date: renderDateFormat(date), + }); + setStartDateRange(date); + }} + selectsStart + startDate={startDateRange} + endDate={endDateRange} + maxDate={endDateRange} + shouldCloseOnSelect + inline + /> + + + + )} + + + + + + {({ open }) => ( + <> + + - - - { - submitChanges({ - target_date: renderDateFormat(date), - }); - setEndDateRange(date); - }} - selectsEnd - startDate={startDateRange} - endDate={endDateRange} - minDate={startDateRange} - inline - /> - - - - )} - + {renderShortDate(new Date(`${module?.target_date}`))} + + + + + { + submitChanges({ + target_date: renderDateFormat(date), + }); + setEndDateRange(date); + }} + selectsEnd + startDate={startDateRange} + endDate={endDateRange} + // minDate={startDateRange} + + inline + /> + + + + )} + +
    -
    -
    -

    {module.name}

    -
    - - -
    -
    -
    -
    - - -
    -
    - -

    Progress

    + +
    +
    +
    +

    {module.name}

    + + + + + Copy Link + + + setModuleDeleteModal(true)}> + + + Delete + + +
    -
    -
    + + + {module.description} + +
    + +
    + ( + { + submitChanges({ lead: value }); + }} + /> + )} + /> + ( + { + submitChanges({ members_list: val }); + }} + /> + )} + /> + +
    +
    + + Progress +
    + +
    + {groupedIssues.completed.length}/{moduleIssues?.length}
    - {groupedIssues.completed.length}/{moduleIssues?.length}
    -
    -
    -

    Links

    - -
    -
    - {module.link_module && module.link_module.length > 0 ? ( - - ) : null} -
    -
    -
    - {isStartValid && isEndValid ? ( - - ) : ( - "" - )} - {issues.length > 0 ? ( - - ) : ( - "" - )} + +
    + + {({ open }) => ( +
    +
    +
    + Progress + {!open && moduleIssues && progressPercentage ? ( + + {progressPercentage ? `${progressPercentage}%` : ""} + + ) : ( + "" + )} +
    + + + +
    + + + {isStartValid && isEndValid && moduleIssues ? ( +
    +
    +
    + + + + + Pending Issues -{" "} + {moduleIssues?.length - groupedIssues.completed.length}{" "} + +
    + +
    +
    + + Ideal +
    +
    + + Current +
    +
    +
    +
    + +
    +
    + ) : ( + "" + )} +
    +
    +
    + )} +
    +
    + +
    + + {({ open }) => ( +
    +
    +
    + Other Information +
    + + + +
    + + + {issues.length > 0 ? ( + <> +
    + +
    + + ) : ( + "" + )} +
    +
    +
    + )} +
    ) : ( diff --git a/apps/app/components/modules/single-module-card.tsx b/apps/app/components/modules/single-module-card.tsx index 8651f0a33..2a48baac5 100644 --- a/apps/app/components/modules/single-module-card.tsx +++ b/apps/app/components/modules/single-module-card.tsx @@ -3,28 +3,53 @@ import React, { useState } from "react"; import Link from "next/link"; import { useRouter } from "next/router"; import Image from "next/image"; +import useSWR, { mutate } from "swr"; +// toast +import useToast from "hooks/use-toast"; // components import { DeleteModuleModal } from "components/modules"; // ui -import { AssigneesList, Avatar, CustomMenu } from "components/ui"; +import { AssigneesList, Avatar, CustomMenu, Tooltip } from "components/ui"; // icons import User from "public/user.png"; -import { CalendarDaysIcon } from "@heroicons/react/24/outline"; +import { + CalendarDaysIcon, + StarIcon, + UserCircleIcon, + UserGroupIcon, +} from "@heroicons/react/24/outline"; // helpers -import { renderShortNumericDateFormat } from "helpers/date-time.helper"; +import { + renderShortDateWithYearFormat, + renderShortNumericDateFormat, +} from "helpers/date-time.helper"; +// services +import stateService from "services/state.service"; +import modulesService from "services/modules.service"; // types import { IModule } from "types"; -// common -import { MODULE_STATUS } from "constants/module"; -import useToast from "hooks/use-toast"; -import { copyTextToClipboard } from "helpers/string.helper"; +// fetch-key +import { MODULE_LIST, STATE_LIST } from "constants/fetch-keys"; +// helper +import { copyTextToClipboard, truncateText } from "helpers/string.helper"; +import { getStatesList } from "helpers/state.helper"; type Props = { module: IModule; handleEditModule: () => void; }; +const stateGroupColours: { + [key: string]: string; +} = { + backlog: "#3f76ff", + unstarted: "#ff9e9e", + started: "#d687ff", + cancelled: "#ff5353", + completed: "#096e8d", +}; + export const SingleModuleCard: React.FC = ({ module, handleEditModule }) => { const [moduleDeleteModal, setModuleDeleteModal] = useState(false); @@ -37,6 +62,80 @@ export const SingleModuleCard: React.FC = ({ module, handleEditModule }) setModuleDeleteModal(true); }; + const { data: stateGroups } = useSWR( + workspaceSlug && module.project_detail.id ? STATE_LIST(module.project_detail.id) : null, + workspaceSlug && module.project_detail.id + ? () => stateService.getStates(workspaceSlug as string, module.project_detail.id) + : null + ); + + const states = getStatesList(stateGroups ?? {}); + const selectedOption = states?.find( + (s) => s.name.replace(" ", "-").toLowerCase() === module.status?.replace(" ", "-").toLowerCase() + ); + + const handleAddToFavorites = () => { + if (!workspaceSlug && !projectId && !module) return; + + modulesService + .addModuleToFavorites(workspaceSlug as string, projectId as string, { + module: module.id, + }) + .then(() => { + mutate( + MODULE_LIST(projectId as string), + (prevData) => + (prevData ?? []).map((m) => ({ + ...m, + is_favorite: m.id === module.id ? true : m.is_favorite, + })), + false + ); + + setToastAlert({ + type: "success", + title: "Success!", + message: "Successfully added the module to favorites.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't add the module to favorites. Please try again.", + }); + }); + }; + + const handleRemoveFromFavorites = () => { + if (!workspaceSlug || !module) return; + + modulesService + .removeModuleFromFavorites(workspaceSlug as string, projectId as string, module.id) + .then(() => { + mutate( + MODULE_LIST(projectId as string), + (prevData) => + (prevData ?? []).map((m) => ({ + ...m, + is_favorite: m.id === module.id ? false : m.is_favorite, + })), + false + ); + setToastAlert({ + type: "success", + title: "Success!", + message: "Successfully removed the module from favorites.", + }); + }) + .catch(() => { + setToastAlert({ + type: "error", + title: "Error!", + message: "Couldn't remove the module from favorites. Please try again.", + }); + }); + }; const handleCopyText = () => { const originURL = @@ -53,6 +152,9 @@ export const SingleModuleCard: React.FC = ({ module, handleEditModule }) }); }; + const endDate = new Date(module.target_date ?? ""); + const startDate = new Date(module.start_date ?? ""); + return ( <> = ({ module, handleEditModule }) setIsOpen={setModuleDeleteModal} data={module} /> -
    -
    - - Edit module - Delete module - Copy module link - -
    - - - - {module.name} - -
    -
    -
    LEAD
    -
    - {module.lead_detail ? ( - - ) : ( -
    - N/A - N/A -
    - )} +
    +
    + +
    +
    + + + + {truncateText(module.name, 75)} + + + +
    +
    + + Start: + {renderShortDateWithYearFormat(startDate)} +
    +
    + + End: + {renderShortDateWithYearFormat(endDate)}
    -
    -
    MEMBERS
    -
    - {module.members_detail && module.members_detail.length > 0 ? ( - - ) : ( -
    - N/A - N/A -
    - )} +
    +
    + + Lead: +
    + {module.lead_detail ? ( +
    + + {module.lead_detail.first_name} +
    + ) : ( +
    + N/A + N/A +
    + )} +
    -
    -
    -
    END DATE
    -
    - - {module.target_date ? renderShortNumericDateFormat(module?.target_date) : "N/A"} -
    -
    -
    -
    STATUS
    -
    - s.value === module.status)?.color, - }} - /> - {module.status} +
    + + Members: +
    + {module.members && module.members.length > 0 ? ( + + ) : ( +
    + N/A + N/A +
    + )} +
    - - +
    +
    + {module?.status?.replace("-", " ")} +
    +
    + {module.is_favorite ? ( + + ) : ( + + )} + + + Edit module + + Delete module + + + Copy module link + + +
    +
    +
    +
    ); diff --git a/apps/app/components/popup/index.tsx b/apps/app/components/popup/index.tsx index 9c18eb916..48a4348c6 100644 --- a/apps/app/components/popup/index.tsx +++ b/apps/app/components/popup/index.tsx @@ -3,6 +3,8 @@ import React, { useRef, useState } from "react"; import Image from "next/image"; import { useRouter } from "next/router"; +import useSWR, { mutate } from "swr"; + // services import workspaceService from "services/workspace.service"; // hooks @@ -10,10 +12,11 @@ import useToast from "hooks/use-toast"; // ui import { Button, Loader } from "components/ui"; // icons -import GithubLogo from "public/logos/github-black.png"; -import useSWR, { mutate } from "swr"; -import { APP_INTEGRATIONS, WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; +import GithubLogo from "public/logos/github-square.png"; +// types import { IWorkspaceIntegrations } from "types"; +// fetch-keys +import { WORKSPACE_INTEGRATIONS } from "constants/fetch-keys"; const OAuthPopUp = ({ integration }: any) => { const [deletingIntegration, setDeletingIntegration] = useState(false); @@ -97,28 +100,28 @@ const OAuthPopUp = ({ integration }: any) => { ); return ( -
    +
    -
    +
    GithubLogo
    -

    +

    {integration.title} {workspaceIntegrations ? ( isInstalled ? ( - - Installed + + Installed ) : ( - - Not + + Not Installed ) ) : null}

    -

    +

    {workspaceIntegrations ? isInstalled ? "Activate GitHub integrations on individual projects to sync with specific repositories." diff --git a/apps/app/components/project/card.tsx b/apps/app/components/project/card.tsx deleted file mode 100644 index 8100379d4..000000000 --- a/apps/app/components/project/card.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import React from "react"; -import Link from "next/link"; -import { useRouter } from "next/router"; -// ui -// icons -import { - CalendarDaysIcon, - CheckIcon, - PencilIcon, - PlusIcon, - TrashIcon, - ClipboardDocumentListIcon, -} from "@heroicons/react/24/outline"; -// types -// ui -import { Button } from "components/ui"; -// hooks -import useProjectMembers from "hooks/use-project-members"; -// helpers -import { renderShortNumericDateFormat } from "helpers/date-time.helper"; -// types -import type { IProject } from "types"; - -export type ProjectCardProps = { - workspaceSlug: string; - project: IProject; - setToJoinProject: (id: string | null) => void; - setDeleteProject: (id: string | null) => void; -}; - -export const ProjectCard: React.FC = (props) => { - const { workspaceSlug, project, setToJoinProject, setDeleteProject } = props; - // router - const router = useRouter(); - // fetching project members information - const { members, isMember, canDelete, canEdit } = useProjectMembers(workspaceSlug, project.id); - - if (!members) { - return ( -

    -
    -
    - ); - } - - return ( - <> -
    -
    - - {isMember ? ( -
    - {canEdit && ( - - - - - - )} - {canDelete && ( - - )} -
    - ) : null} -
    -
    -

    {project.description}

    -
    -
    -
    - - {!isMember ? ( - - ) : ( -
    - - Member -
    - )} -
    -
    - - {renderShortNumericDateFormat(project.created_at)} -
    -
    -
    - - ); -}; diff --git a/apps/app/components/project/create-project-modal.tsx b/apps/app/components/project/create-project-modal.tsx index 08a085e49..747e6ee4f 100644 --- a/apps/app/components/project/create-project-modal.tsx +++ b/apps/app/components/project/create-project-modal.tsx @@ -1,5 +1,6 @@ import React, { useState, useEffect } from "react"; +import Image from "next/image"; import { useRouter } from "next/router"; import useSWR, { mutate } from "swr"; @@ -7,14 +8,19 @@ import useSWR, { mutate } from "swr"; import { useForm, Controller } from "react-hook-form"; import { Dialog, Transition } from "@headlessui/react"; + // services import projectServices from "services/project.service"; import workspaceService from "services/workspace.service"; // hooks import useToast from "hooks/use-toast"; // ui +import { PrimaryButton } from "components/ui/button/primary-button"; import { Button, Input, TextArea, CustomSelect } from "components/ui"; +// icons +import { XMarkIcon } from "@heroicons/react/24/outline"; // components +import { ImagePickerPopover } from "components/core"; import EmojiIconPicker from "components/emoji-icon-picker"; // helpers import { getRandomEmoji } from "helpers/common.helper"; @@ -36,6 +42,7 @@ const defaultValues: Partial = { description: "", network: 2, icon: getRandomEmoji(), + cover_image: null, }; const IsGuestCondition: React.FC<{ @@ -172,127 +179,144 @@ export const CreateProjectModal: React.FC = (props) => { leaveFrom="opacity-100 translate-y-0 sm:scale-100" leaveTo="opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95" > - -
    -
    - - Create Project - -
    -

    - Create a new project to start working on it. -

    + +
    + {watch("cover_image") !== null && ( + cover image + )} + +
    + +
    +
    +
    +

    Create Project

    +
    + { + setValue("cover_image", image); + }} + value={watch("cover_image")} + />
    -
    -
    -
    - - ( - - )} - /> -
    -
    - -
    -
    +
    +
    + +
    +
    -
    Network
    - ( - k === value.toString()) - ? NETWORK_CHOICES[ - value.toString() as keyof typeof NETWORK_CHOICES - ] - : "Select network" - } - input - > - {Object.keys(NETWORK_CHOICES).map((key) => ( - - {NETWORK_CHOICES[key as keyof typeof NETWORK_CHOICES]} - - ))} - - )} + { + setValue("icon", emoji); + }} + value={watch("icon")} />
    -
    -