From 666d35afb92786f3b3556c5ae04ba56402768a14 Mon Sep 17 00:00:00 2001 From: rahulramesha <71900764+rahulramesha@users.noreply.github.com> Date: Mon, 10 Jun 2024 20:15:03 +0530 Subject: [PATCH] feat: Issue pagination (#4109) * dev: separate order by of issue queryset to separate utilty function * dev: pagination for spreadhseet and gantt * dev: group pagination * dev: paginate single entities * dev: refactor pagination * dev: paginating issue apis * dev: grouped pagination for empty groups * dev: ungrouped list * dev: fix paginator for single groups * dev: fix paginating true list * dev: state__group pagination * fix: imports * dev: fix grouping on taget date and project_id * dev: remove unused imports * dev: add ruff in dependencies * make store changes for pagination * fix some build errors due to type changes * dev: add total pages key * chore: paginator changes * implement pagination for spreadsheet, list, kanban and calendar * fix: order by grouped pagination * dev: sub group paginator * dev: grouped paginator * dev: sub grouping paginator * restructure gantt layout charts * dev: fix pagination count * dev: date filtering for issues * dev: group by counts * implement new logic for pagination layouts * fix: label id and assignee id interchange * dev: fix priority ordering * fix group by bugs * dev: grouping for priority * fix reeordering while update * dev: fix order by for pagination * fix: total results for sub group pagination * dev: add comments and fix ordering * fix orderby priority for spreadsheet * fix subGroupCount * Fix logic for load more in Kanban * fix issue quick add * dev: fix issue creation * dev: add sorting * fix order by for modules and cycles * fix non render of Issues * fix subGroupKey generation when subGroupId is null * dev: fix cycle and module issue * dev: fix sub grouping * fix: imports * fix minor build errors * fix major build errors * fix priority order by * grouped pagination cursor logic changes * fix calendar pagination * active cycle issues pagination * dev: fix lint errors * fix Kanban subgroup dnd * fix empty subgroup kanbans * fix updation from an empty field with groupBy * fix issue count of groups * fix issue sorting on first page fetch * dev: remove pagination from list endpoint add ordering for sub grouping and handle error for empty issues * refactor module and cycle issues * fix quick add refactor * refactor gantt roots * fix empty states * fix filter params * fix group by module * minor UX changes * fix sub grouping in Kanban * remove unnecessary sorting logic in backend (Nikhil's changes) * dev: add error handling when using without on results * calendar layout loader improvement * list per page count logic change * spreadsheet loader improvement * Added loader for issues load more pagination * fix quick add in gantt * dev: add profile issue pagination * fix all issue and profile issues logic * remove empty state from calendar layout * use useEffect instead of swr to fetch issues to have quick switching between views cycles etc * dev: add aggregation for multi fields * fix priority sorting for workspace issues * fix move from draft for draft issues * fix pagination loader for spreadsheet * fetch project, module and cycle stats on update, create and delete of issues * increase horizontal margin * change load more pagination to on scroll pagination for active cycle issues * fix linting error * dev: fix ordering when order by m2m * dev: fix null paginations * dev: commenting * 0add comments to the issue stores methods * fix order by for array properties * fix: priority ordering * perform optimistic updates while adding or removing cycles or modules * fix build errors * dev: add default values when iterating through sub group * Move code from EE to CE repo * chore: folder structure updates * Move sortabla and radio input to packages/ui * chore: updated empty and loading screens * chore: delete an estimate point * chore: estimate point response change * chore: updated create estimate and handled the build error * chore: migration fixes * chore: updated create estimate * [WEB-1322] dev: conflict free pages collaboration (#4463) * chore: pages realtime * chore: empty binary response * chore: added a ypy package * feat: pages collaboration * chore: update fetching logic * chore: degrade ypy version * chore: replace useEffect fetch logic with useSWR * chore: move all the update logic to the page store * refactor: remove react-hook-form * chore: save description_html as well * chore: migrate old data logic * fix: added description_binary as field name * fix: code cleanup * refactor: create separate hook to handle page description * fix: build errors * chore: combine updates instead of using the whole document * chore: removed ypy package * chore: added conflict resolving logic to the client side * chore: add a save changes button * chore: add read-only validation * chore: remove saving state information * chore: added permission class * chore: removed the migration file * chore: corrected the model field * chore: rename pageStore to page * chore: update collaboration provider * chore: add try catch to handle error --------- Co-authored-by: NarayanBavisetti * chore: create estimate workflow update * chore: editing and deleting the existing estimate updates * chore: updating the new estinates in update modal * chore: ui changed * chore: response changes of get and post * chore: new field added in estimates * chore: individual endpoint for estimate points * chore: typo changes * chore: create estimate point * chore: integrated new endpoints * chore: update key value pair * chore: update sorting in the estimates * Add custom option in the estimate templates * chore: handled current project active estimate * chore: handle estimate update worklfow * chore: AIO docker images for preview deployments (#4605) * fix: adding single docker base file * action added * fix action * dockerfile.base modified * action fix * dockerfile * fix: base aio dockerfile * fix: dockerfile.base * fix: dockerfile base * fix: modified folder structure * fix: action * fix: dockerfile * fix: dockerfile.base * fix: supervisor file name changed * fix: base dockerfile updated * fix dockerfile base * fix: base dockerfile * fix: docker files * fix: base dockerfile * update base image * modified docker aio base * aio base modified to debian-12-slim * fixes * finalize the dockerfiles with volume exposure * modified the aio build and dockerfile * fix: codacy suggestions implemented * fix: codacy fix * update aio build action --------- Co-authored-by: sriram veeraghanta * chore: handled estimates switch * chore: handled estimate edit * chore: handled close button in estimate edit * chore: updated ceate estimare workflow * chore: updated switch estimate * fix minor bugs in base issues store * single column scroll pagination * UI changes for load more button * chore: UI and typos * chore: resolved build error * [WEB-1184] feat: issue bulk operations (#4530) * chore: bulk operations * chore: archive bulk issues * chore: bulk ops keys changed * chore: bulk delete and archive confirmation modals * style: list layout spacing * chore: create hoc for multi-select groups * chore: update multiple select components * chore: archive, target and start date error messsage * chore: edge case handling * chore: bulk ops in spreadsheet layout * chore: update UI * chore: scroll element into view * fix: shift + arrow navigation * chore: implement bulk ops in the gantt layout * fix: ui bugs * chore: move selection logic to store * fix: group selection * refactor: multiple select store * style: dropdowns UI * fix: bulk assignee and label update mutation * chore: removed migrations * refactor: entities grouping logic * fix performance issue is selection of bulk ops * fix: shift keyboard navigation * fix: group click action * chore: start and target date validation * chore: remove optimistic updates, check archivability in frontend * chore: code optimisation * chore: add store comments * refactor: component fragmentation * style: issue active state --------- Co-authored-by: NarayanBavisetti Co-authored-by: rahulramesha * fix a performance issue when there are too many groups * chore: updated delete dropdown and handled the repeated values while creating and updating the estimate point * [WEB-1424] chore: page and view logo implementation, and emoji/icon picker improvement (#4583) * chore: added logo_props * chore: logo props in cycles, views and modules * chore: emoji icon picker types updated * chore: info icon added to plane ui package * chore: icon color adjust helper function added * style: icon picker ui improvement and default color options updated * chore: update page logo action added in store * chore: emoji code to unicode helper function added * chore: common logo renderer component added * chore: app header project logo updated * chore: project logo updated across platform * chore: page logo picker added * chore: control link component improvement * chore: list item improvement * chore: emoji picker component updated * chore: space app and package logo prop type updated * chore: migration * chore: logo added to project view * chore: page logo picker added in create modal and breadcrumbs * chore: view logo picker added in create modal and updated breadcrumbs * fix: build error * chore: AIO docker images for preview deployments (#4605) * fix: adding single docker base file * action added * fix action * dockerfile.base modified * action fix * dockerfile * fix: base aio dockerfile * fix: dockerfile.base * fix: dockerfile base * fix: modified folder structure * fix: action * fix: dockerfile * fix: dockerfile.base * fix: supervisor file name changed * fix: base dockerfile updated * fix dockerfile base * fix: base dockerfile * fix: docker files * fix: base dockerfile * update base image * modified docker aio base * aio base modified to debian-12-slim * fixes * finalize the dockerfiles with volume exposure * modified the aio build and dockerfile * fix: codacy suggestions implemented * fix: codacy fix * update aio build action --------- Co-authored-by: sriram veeraghanta * fix: merge conflict * chore: lucide react added to planu ui package * chore: new emoji picker component added with lucid icon and code refactor * chore: logo component updated * chore: emoji picker updated for pages and views --------- Co-authored-by: NarayanBavisetti Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Co-authored-by: sriram veeraghanta * chore: handled inline errors in the estimate switch * fix module and cycle drag and drop * Fix issue count bug for accumulated actions * chore: handled active and availability vadilation * chore: handled create and update components in projecr estimates * chore: added migration * Add category specific values for custom template * chore: estimate dropdown handled in issues * chore: estimate alerts * fix bulk updates * chore: updated alerts * add optional chaining * Extract the list row actions * change color of load more to match new Issues * list group collapsible * fix: updated and handled the estimate points * fix: upgrader ee banner * Fix issues with sortable * Fix sortable spacing issue in create estimate modal * fix: updated the issue create sorting * chore: removed radio button from ui and updated in the estimates * chore: resolved import error in packaged ui * chore: handled props in create modal * chore: removed ee files * chore: changed default analytics * fix: pagination ordering for grouped and subgrouped * chore: removed the migration file * chore: estimate point value in graph * chore: estimate point key change * chore: squashed migration (#4634) * chore: squashed migration * chore: removed instance migraion * chore: key changes * chore: issue activity back migration * dev: replaced estimate key with estimate id and replaced estimate type from number to string in issue * chore: estimate point value field * chore: estimate point activity * chore: removed the unused function * chore: resolved merge conflicts * chore: deploy board keys changed * chore: yarn lock file change * chore: resolved frontend build --------- Co-authored-by: guru_sainath * [WEB-1516] refactor: space app routing and layouts (#4705) * dev: change layout * chore: replace workspace slug and project id with anchor * chore: migration fixes * chore: update filtering logic * chore: endpoint changes * chore: update endpoint * chore: changed url pratterns * chore: use client side for layout and page * chore: issue vote changes * chore: project deploy board response change * refactor: publish project store and components * fix: update layout options after fetching settings * chore: remove unnecessary types * style: peek overview * refactor: components folder structure * fix: redirect from old path * chore: make the whole issue block clickable * chore: removed the migration file * chore: add server side redirection for old routes * chore: is enabled key change * chore: update types * chore: removed the migration file --------- Co-authored-by: NarayanBavisetti * Merge develop into revamp-estimates-ce * chore: removed migration file and updated the estimate system order and removed ee banner * chore: initial radio select in create estimate * chore: space key changes * Fix sortable component as the sort order was broken. * fix: formatting and linting errors * fix Alignment for load more * add logic to approuter * fix approuter changes and fix build * chore: removed the linting issue --------- Co-authored-by: pablohashescobar Co-authored-by: Satish Gandham Co-authored-by: guru_sainath Co-authored-by: NarayanBavisetti Co-authored-by: Aaryan Khandelwal <65252264+aaryan610@users.noreply.github.com> Co-authored-by: Manish Gupta <59428681+mguptahub@users.noreply.github.com> Co-authored-by: sriram veeraghanta Co-authored-by: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Co-authored-by: Bavisetti Narayan <72156168+NarayanBavisetti@users.noreply.github.com> Co-authored-by: pushya22 <130810100+pushya22@users.noreply.github.com> --- apiserver/plane/app/serializers/estimate.py | 4 - apiserver/plane/app/urls/issue.py | 12 + apiserver/plane/app/views/__init__.py | 6 +- apiserver/plane/app/views/base.py | 8 +- apiserver/plane/app/views/cycle/issue.py | 220 +- apiserver/plane/app/views/dashboard/base.py | 78 +- apiserver/plane/app/views/exporter/base.py | 13 +- apiserver/plane/app/views/issue/archive.py | 351 ++-- apiserver/plane/app/views/issue/base.py | 393 ++-- .../plane/app/views/issue/bulk_operations.py | 288 +++ apiserver/plane/app/views/issue/draft.py | 315 +-- apiserver/plane/app/views/module/issue.py | 226 +- .../plane/app/views/notification/base.py | 26 +- apiserver/plane/app/views/project/base.py | 27 +- apiserver/plane/app/views/user/base.py | 1 + apiserver/plane/app/views/view/base.py | 272 +-- apiserver/plane/app/views/workspace/user.py | 277 ++- .../plane/bgtasks/project_invitation_task.py | 1 + apiserver/plane/space/views/issue.py | 280 ++- apiserver/plane/utils/exception_logger.py | 8 + apiserver/plane/utils/grouper.py | 399 ++-- apiserver/plane/utils/issue_filters.py | 38 +- apiserver/plane/utils/order_queryset.py | 84 + apiserver/plane/utils/paginator.py | 621 +++++- apiserver/requirements/base.txt | 3 +- packages/types/src/issues/base.d.ts | 27 +- packages/types/src/issues/issue.d.ts | 59 +- packages/types/src/users.d.ts | 2 + packages/types/src/view-props.d.ts | 28 +- packages/ui/src/icons/priority-icon.tsx | 6 +- packages/ui/src/sortable/sortable.stories.tsx | 2 +- space/types/project.d.ts | 24 - space/types/publish.d.ts | 24 + .../profile/[userId]/[profileViewId]/page.tsx | 20 + .../profile/[userId]/assigned/page.tsx | 12 - .../@header/profile/[userId]/created/page.tsx | 12 - .../profile/[userId]/mobile-header.tsx | 10 +- .../profile/[userId]/subscribed/page.tsx | 12 - .../[projectId]/cycles/[cycleId]/header.tsx | 17 +- .../cycles/[cycleId]/mobile-header.tsx | 6 +- .../[projectId]/draft-issues/header.tsx | 8 +- .../projects/[projectId]/issues/header.tsx | 16 +- .../[projectId]/issues/mobile-header.tsx | 6 +- .../[projectId]/modules/[moduleId]/header.tsx | 25 +- .../modules/[moduleId]/mobile-header.tsx | 6 +- .../[projectId]/views/[viewId]/header.tsx | 14 +- .../profile/[userId]/[profileViewId]/page.tsx | 30 + .../profile/[userId]/assigned/page.tsx | 15 - .../profile/[userId]/created/page.tsx | 16 - .../profile/[userId]/navbar.tsx | 2 +- .../profile/[userId]/subscribed/page.tsx | 16 - .../issues/[archivedIssueId]/page.tsx | 2 +- .../workspace-views/[globalViewId]/page.tsx | 4 +- .../modals/bulk-delete-issues-modal-item.tsx | 17 +- .../core/modals/bulk-delete-issues-modal.tsx | 119 +- web/components/core/render-if-visible-HOC.tsx | 13 +- .../cycles/active-cycle/cycle-stats.tsx | 140 +- .../cycles/gantt-chart/cycles-list-layout.tsx | 57 +- .../widgets/issue-panels/issue-list-item.tsx | 12 +- .../widgets/issue-panels/issues-list.tsx | 2 +- web/components/dropdowns/cycle/index.tsx | 4 +- web/components/dropdowns/estimate.tsx | 6 +- web/components/dropdowns/module/index.tsx | 4 +- web/components/dropdowns/priority.tsx | 10 +- web/components/dropdowns/state.tsx | 6 +- web/components/gantt-chart/blocks/block.tsx | 23 +- .../gantt-chart/blocks/blocks-list.tsx | 43 +- web/components/gantt-chart/chart/header.tsx | 10 +- .../gantt-chart/chart/main-content.tsx | 76 +- web/components/gantt-chart/chart/root.tsx | 41 +- web/components/gantt-chart/root.tsx | 17 +- .../gantt-chart/sidebar/cycles/sidebar.tsx | 20 +- .../gantt-chart/sidebar/issues/block.tsx | 2 + .../gantt-chart/sidebar/issues/sidebar.tsx | 95 +- .../gantt-chart/sidebar/modules/sidebar.tsx | 19 +- web/components/gantt-chart/sidebar/root.tsx | 29 +- web/components/gantt-chart/sidebar/utils.ts | 26 +- .../inbox/content/issue-properties.tsx | 2 +- .../create-edit-modal/issue-properties.tsx | 4 +- .../inbox/modals/select-duplicate.tsx | 152 +- .../bulk-operations/actions/archive.tsx | 67 + .../issues/bulk-operations/actions/delete.tsx | 45 + .../issues/bulk-operations/actions/index.ts | 3 + .../issues/bulk-operations/actions/root.tsx | 18 + .../bulk-operations/bulk-archive-modal.tsx | 83 + .../bulk-operations/bulk-delete-modal.tsx | 79 + .../bulk-operations/exrtra-properties.tsx | 1 + .../issues/issue-detail/main-content.tsx | 2 +- .../issues/issue-detail/parent/siblings.tsx | 2 +- .../issues/issue-detail/sidebar.tsx | 6 +- .../calendar/base-calendar-root.tsx | 78 +- .../issue-layouts/calendar/calendar.tsx | 153 +- .../issue-layouts/calendar/day-tile.tsx | 36 +- .../issue-layouts/calendar/issue-blocks.tsx | 71 +- .../calendar/quick-add-issue-form.tsx | 21 +- .../calendar/roots/cycle-root.tsx | 3 +- .../calendar/roots/module-root.tsx | 3 +- .../calendar/roots/project-root.tsx | 5 +- .../calendar/roots/project-view-root.tsx | 17 +- .../issue-layouts/calendar/week-days.tsx | 23 +- .../issue-layouts/empty-states/cycle.tsx | 58 +- .../empty-states/global-view.tsx | 66 +- .../issue-layouts/empty-states/index.ts | 7 - .../issue-layouts/empty-states/index.tsx | 37 + .../issue-layouts/empty-states/module.tsx | 57 +- .../empty-states/profile-view.tsx | 20 + .../display-filters-selection.tsx | 4 +- .../filters/header/layout-selection.tsx | 10 +- .../issue-layouts/gantt/base-gantt-root.tsx | 76 +- .../issues/issue-layouts/gantt/blocks.tsx | 2 + .../issues/issue-layouts/gantt/cycle-root.tsx | 13 - .../issues/issue-layouts/gantt/index.ts | 5 +- .../issue-layouts/gantt/module-root.tsx | 13 - .../issue-layouts/gantt/project-root.tsx | 8 - .../issue-layouts/gantt/project-view-root.tsx | 15 - .../gantt/quick-add-issue-form.tsx | 12 +- .../issues/issue-layouts/issue-layout-HOC.tsx | 54 + .../issue-layouts/kanban/base-kanban-root.tsx | 78 +- .../issues/issue-layouts/kanban/block.tsx | 10 +- .../issue-layouts/kanban/blocks-list.tsx | 14 +- .../issues/issue-layouts/kanban/default.tsx | 41 +- .../kanban/headers/group-by-card.tsx | 5 +- .../issue-layouts/kanban/kanban-group.tsx | 78 +- .../kanban/quick-add-issue-form.tsx | 21 +- .../issue-layouts/kanban/roots/cycle-root.tsx | 4 +- .../kanban/roots/draft-issue-root.tsx | 5 +- .../kanban/roots/module-root.tsx | 4 +- .../kanban/roots/profile-issues-root.tsx | 7 +- .../kanban/roots/project-root.tsx | 5 +- .../kanban/roots/project-view-root.tsx | 18 +- .../issues/issue-layouts/kanban/swimlanes.tsx | 187 +- .../issue-layouts/list/base-list-root.tsx | 76 +- .../issues/issue-layouts/list/block-root.tsx | 5 +- .../issues/issue-layouts/list/block.tsx | 6 +- .../issues/issue-layouts/list/blocks-list.tsx | 51 +- .../issues/issue-layouts/list/default.tsx | 175 +- .../list/headers/group-by-card.tsx | 10 +- .../issues/issue-layouts/list/list-group.tsx | 118 +- .../list/quick-add-issue-form.tsx | 12 +- .../list/roots/archived-issue-root.tsx | 2 - .../issue-layouts/list/roots/cycle-root.tsx | 3 +- .../list/roots/draft-issue-root.tsx | 3 +- .../issue-layouts/list/roots/module-root.tsx | 3 +- .../list/roots/profile-issues-root.tsx | 7 +- .../issue-layouts/list/roots/project-root.tsx | 3 +- .../list/roots/project-view-root.tsx | 16 +- .../properties/all-properties.tsx | 17 +- .../roots/all-issue-layout-root.tsx | 112 +- .../roots/archived-issue-layout-root.tsx | 34 +- .../issue-layouts/roots/cycle-layout-root.tsx | 106 +- .../roots/draft-issue-layout-root.tsx | 49 +- .../roots/module-layout-root.tsx | 107 +- .../roots/project-layout-root.tsx | 80 +- .../roots/project-view-layout-root.tsx | 73 +- .../spreadsheet/base-spreadsheet-root.tsx | 68 +- .../spreadsheet/columns/assignee-column.tsx | 2 +- .../spreadsheet/columns/cycle-column.tsx | 11 +- .../spreadsheet/columns/estimate-column.tsx | 2 +- .../spreadsheet/columns/module-column.tsx | 10 +- .../spreadsheet/columns/state-column.tsx | 2 +- .../spreadsheet/issue-column.tsx | 2 +- .../issue-layouts/spreadsheet/issue-row.tsx | 12 +- .../spreadsheet/quick-add-issue-form.tsx | 17 +- .../spreadsheet/roots/cycle-root.tsx | 4 +- .../spreadsheet/roots/module-root.tsx | 11 +- .../spreadsheet/roots/project-root.tsx | 3 +- .../spreadsheet/roots/project-view-root.tsx | 17 +- .../spreadsheet/spreadsheet-table.tsx | 30 +- .../spreadsheet/spreadsheet-view.tsx | 19 +- web/components/issues/issue-layouts/utils.tsx | 24 +- web/components/issues/issue-modal/form.tsx | 18 +- web/components/issues/issue-modal/modal.tsx | 11 +- .../issues/parent-issues-list-modal.tsx | 2 +- .../issues/peek-overview/issue-detail.tsx | 4 +- .../issues/peek-overview/properties.tsx | 2 +- web/components/issues/select/label.tsx | 2 +- .../issues/sub-issues/issue-list-item.tsx | 5 +- .../issues/sub-issues/properties.tsx | 7 +- .../labels/label-block/label-item-block.tsx | 10 +- .../gantt-chart/modules-list-layout.tsx | 41 +- .../profile/profile-issues-filter.tsx | 8 +- web/components/profile/profile-issues.tsx | 22 +- .../loader/layouts/calendar-layout-loader.tsx | 12 +- .../loader/layouts/kanban-layout-loader.tsx | 20 +- .../ui/loader/layouts/list-layout-loader.tsx | 26 +- .../layouts/spreadsheet-layout-loader.tsx | 42 +- web/components/ui/loader/utils.tsx | 29 - web/constants/errors.ts | 25 + web/constants/issue.ts | 70 +- web/constants/spreadsheet.ts | 13 +- web/helpers/issue.helper.ts | 23 +- web/hooks/store/use-issues.ts | 8 +- web/hooks/use-group-dragndrop.ts | 10 +- ...erver.tsx => use-intersection-observer.ts} | 18 +- web/hooks/use-issue-layout-store.ts | 17 + web/hooks/use-issues-actions.tsx | 444 ++-- web/hooks/use-multiple-select.ts | 4 +- web/services/cycle.service.ts | 14 +- web/services/issue/issue.service.ts | 52 +- web/services/issue/issue_draft.service.ts | 12 +- web/services/module.service.ts | 17 +- web/services/user.service.ts | 3 +- web/services/workspace.service.ts | 4 +- web/store/global-view.store.ts | 2 +- web/store/issue/archived/filter.store.ts | 37 +- web/store/issue/archived/issue.store.ts | 233 ++- web/store/issue/cycle/filter.store.ts | 42 +- web/store/issue/cycle/issue.store.ts | 647 +++--- web/store/issue/draft/filter.store.ts | 37 +- web/store/issue/draft/issue.store.ts | 271 ++- web/store/issue/helpers/base-issues-utils.ts | 183 ++ web/store/issue/helpers/base-issues.store.ts | 1819 +++++++++++++++++ .../helpers/issue-filter-helper.store.ts | 130 +- web/store/issue/helpers/issue-helper.store.ts | 401 ---- .../issue/issue-details/sub_issues.store.ts | 8 +- web/store/issue/issue.store.ts | 15 +- web/store/issue/issue_calendar_view.store.ts | 18 + web/store/issue/module/filter.store.ts | 42 +- web/store/issue/module/issue.store.ts | 533 ++--- web/store/issue/profile/filter.store.ts | 46 +- web/store/issue/profile/issue.store.ts | 320 ++- web/store/issue/project-views/filter.store.ts | 40 +- web/store/issue/project-views/issue.store.ts | 332 +-- web/store/issue/project/filter.store.ts | 39 +- web/store/issue/project/issue.store.ts | 367 ++-- web/store/issue/root.store.ts | 16 +- web/store/issue/workspace/filter.store.ts | 48 +- web/store/issue/workspace/issue.store.ts | 262 +-- web/store/label.store.ts | 4 +- web/store/project/project.store.ts | 12 +- web/store/router.store.ts | 11 + web/store/state.store.ts | 12 +- web/styles/globals.css | 5 +- yarn.lock | 44 +- 234 files changed, 9056 insertions(+), 6188 deletions(-) create mode 100644 apiserver/plane/app/views/issue/bulk_operations.py create mode 100644 apiserver/plane/utils/order_queryset.py create mode 100644 space/types/publish.d.ts create mode 100644 web/app/[workspaceSlug]/@header/profile/[userId]/[profileViewId]/page.tsx delete mode 100644 web/app/[workspaceSlug]/@header/profile/[userId]/assigned/page.tsx delete mode 100644 web/app/[workspaceSlug]/@header/profile/[userId]/created/page.tsx delete mode 100644 web/app/[workspaceSlug]/@header/profile/[userId]/subscribed/page.tsx create mode 100644 web/app/[workspaceSlug]/profile/[userId]/[profileViewId]/page.tsx delete mode 100644 web/app/[workspaceSlug]/profile/[userId]/assigned/page.tsx delete mode 100644 web/app/[workspaceSlug]/profile/[userId]/created/page.tsx delete mode 100644 web/app/[workspaceSlug]/profile/[userId]/subscribed/page.tsx create mode 100644 web/components/issues/bulk-operations/actions/archive.tsx create mode 100644 web/components/issues/bulk-operations/actions/delete.tsx create mode 100644 web/components/issues/bulk-operations/actions/index.ts create mode 100644 web/components/issues/bulk-operations/actions/root.tsx create mode 100644 web/components/issues/bulk-operations/bulk-archive-modal.tsx create mode 100644 web/components/issues/bulk-operations/bulk-delete-modal.tsx create mode 100644 web/components/issues/bulk-operations/exrtra-properties.tsx delete mode 100644 web/components/issues/issue-layouts/empty-states/index.ts create mode 100644 web/components/issues/issue-layouts/empty-states/index.tsx create mode 100644 web/components/issues/issue-layouts/empty-states/profile-view.tsx delete mode 100644 web/components/issues/issue-layouts/gantt/cycle-root.tsx delete mode 100644 web/components/issues/issue-layouts/gantt/module-root.tsx delete mode 100644 web/components/issues/issue-layouts/gantt/project-root.tsx delete mode 100644 web/components/issues/issue-layouts/gantt/project-view-root.tsx create mode 100644 web/components/issues/issue-layouts/issue-layout-HOC.tsx create mode 100644 web/constants/errors.ts rename web/hooks/{use-intersection-observer.tsx => use-intersection-observer.ts} (64%) create mode 100644 web/hooks/use-issue-layout-store.ts create mode 100644 web/store/issue/helpers/base-issues-utils.ts create mode 100644 web/store/issue/helpers/base-issues.store.ts delete mode 100644 web/store/issue/helpers/issue-helper.store.ts diff --git a/apiserver/plane/app/serializers/estimate.py b/apiserver/plane/app/serializers/estimate.py index e73b5ceef..8cb083ca5 100644 --- a/apiserver/plane/app/serializers/estimate.py +++ b/apiserver/plane/app/serializers/estimate.py @@ -2,10 +2,6 @@ from .base import BaseSerializer from plane.db.models import Estimate, EstimatePoint -from plane.app.serializers import ( - WorkspaceLiteSerializer, - ProjectLiteSerializer, -) from rest_framework import serializers diff --git a/apiserver/plane/app/urls/issue.py b/apiserver/plane/app/urls/issue.py index 0d3b9e063..b7a4eaa48 100644 --- a/apiserver/plane/app/urls/issue.py +++ b/apiserver/plane/app/urls/issue.py @@ -19,6 +19,8 @@ from plane.app.views import ( IssueUserDisplayPropertyEndpoint, IssueViewSet, LabelViewSet, + BulkIssueOperationsEndpoint, + BulkArchiveIssuesEndpoint, ) urlpatterns = [ @@ -81,6 +83,11 @@ urlpatterns = [ BulkDeleteIssuesEndpoint.as_view(), name="project-issues-bulk", ), + path( + "workspaces//projects//bulk-archive-issues/", + BulkArchiveIssuesEndpoint.as_view(), + name="bulk-archive-issues", + ), ## path( "workspaces//projects//issues//sub-issues/", @@ -298,4 +305,9 @@ urlpatterns = [ ), name="project-issue-draft", ), + path( + "workspaces//projects//bulk-operation-issues/", + BulkIssueOperationsEndpoint.as_view(), + name="bulk-operations-issues", + ), ] diff --git a/apiserver/plane/app/views/__init__.py b/apiserver/plane/app/views/__init__.py index 8da0268b9..8934e832c 100644 --- a/apiserver/plane/app/views/__init__.py +++ b/apiserver/plane/app/views/__init__.py @@ -113,9 +113,7 @@ from .issue.activity import ( IssueActivityEndpoint, ) -from .issue.archive import ( - IssueArchiveViewSet, -) +from .issue.archive import IssueArchiveViewSet, BulkArchiveIssuesEndpoint from .issue.attachment import ( IssueAttachmentEndpoint, @@ -154,6 +152,8 @@ from .issue.subscriber import ( ) +from .issue.bulk_operations import BulkIssueOperationsEndpoint + from .module.base import ( ModuleViewSet, ModuleLinkViewSet, diff --git a/apiserver/plane/app/views/base.py b/apiserver/plane/app/views/base.py index 8f21f5fe1..45488b64e 100644 --- a/apiserver/plane/app/views/base.py +++ b/apiserver/plane/app/views/base.py @@ -1,4 +1,6 @@ # Python imports +import traceback + import zoneinfo from django.conf import settings from django.core.exceptions import ObjectDoesNotExist, ValidationError @@ -76,7 +78,11 @@ class BaseViewSet(TimezoneMixin, ModelViewSet, BasePaginator): response = super().handle_exception(exc) return response except Exception as e: - print(e) if settings.DEBUG else print("Server Error") + ( + print(e, traceback.format_exc()) + if settings.DEBUG + else print("Server Error") + ) if isinstance(e, IntegrityError): return Response( {"error": "The payload is not valid"}, diff --git a/apiserver/plane/app/views/cycle/issue.py b/apiserver/plane/app/views/cycle/issue.py index fdc998f6d..1932ae169 100644 --- a/apiserver/plane/app/views/cycle/issue.py +++ b/apiserver/plane/app/views/cycle/issue.py @@ -2,43 +2,50 @@ import json # Django imports -from django.db.models import ( - Func, - F, - Q, - OuterRef, - Value, - UUIDField, -) from django.core import serializers +from django.db.models import ( + F, + Func, + OuterRef, + Q, +) from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models.functions import Coalesce # Third party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response +from plane.app.permissions import ( + ProjectEntityPermission, +) # Module imports from .. import BaseViewSet from plane.app.serializers import ( - IssueSerializer, CycleIssueSerializer, ) -from plane.app.permissions import ProjectEntityPermission +from plane.bgtasks.issue_activites_task import issue_activity from plane.db.models import ( Cycle, CycleIssue, Issue, - IssueLink, IssueAttachment, + IssueLink, +) +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, ) -from plane.bgtasks.issue_activites_task import issue_activity from plane.utils.issue_filters import issue_filters -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) + +# Module imports class CycleIssueViewSet(BaseViewSet): serializer_class = CycleIssueSerializer @@ -86,14 +93,9 @@ class CycleIssueViewSet(BaseViewSet): @method_decorator(gzip_page) def list(self, request, slug, project_id, cycle_id): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] - order_by = request.GET.get("order_by", "created_at") + order_by_param = request.GET.get("order_by", "created_at") filters = issue_filters(request.query_params, "GET") - queryset = ( + issue_queryset = ( Issue.issue_objects.filter(issue_cycle__cycle_id=cycle_id) .filter(project_id=project_id) .filter(workspace__slug=slug) @@ -105,7 +107,6 @@ class CycleIssueViewSet(BaseViewSet): "issue_module__module", "issue_cycle__cycle", ) - .order_by(order_by) .filter(**filters) .annotate(cycle_id=F("issue_cycle__cycle_id")) .annotate( @@ -130,73 +131,112 @@ class CycleIssueViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) - .order_by(order_by) ) - if self.fields: - issues = IssueSerializer( - queryset, many=True, fields=fields if fields else None - ).data - else: - issues = queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", - ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) + filters = issue_filters(request.query_params, "GET") - return Response(issues, status=status.HTTP_200_OK) + order_by_param = request.GET.get("order_by", "-created_at") + issue_queryset = issue_queryset.filter(**filters) + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) + + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + # Check group and sub group value paginate + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # group and sub group pagination + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + else: + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + ) def create(self, request, slug, project_id, cycle_id): issues = request.data.get("issues", []) diff --git a/apiserver/plane/app/views/dashboard/base.py b/apiserver/plane/app/views/dashboard/base.py index 9558348d9..4a1680db6 100644 --- a/apiserver/plane/app/views/dashboard/base.py +++ b/apiserver/plane/app/views/dashboard/base.py @@ -1,52 +1,53 @@ # Django imports -from django.db.models import ( - Q, - Case, - When, - Value, - CharField, - Count, - F, - Exists, - OuterRef, - Subquery, - JSONField, - Func, - Prefetch, - IntegerField, -) from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models import UUIDField +from django.db.models import ( + Case, + CharField, + Count, + Exists, + F, + Func, + IntegerField, + JSONField, + OuterRef, + Prefetch, + Q, + Subquery, + UUIDField, + Value, + When, +) from django.db.models.functions import Coalesce from django.utils import timezone +from rest_framework import status # Third Party imports from rest_framework.response import Response -from rest_framework import status + +from plane.app.serializers import ( + DashboardSerializer, + IssueActivitySerializer, + IssueSerializer, + WidgetSerializer, +) +from plane.db.models import ( + Dashboard, + DashboardWidget, + Issue, + IssueActivity, + IssueAttachment, + IssueLink, + IssueRelation, + Project, + ProjectMember, + User, + Widget, +) +from plane.utils.issue_filters import issue_filters # Module imports from .. import BaseAPIView -from plane.db.models import ( - Issue, - IssueActivity, - ProjectMember, - Widget, - DashboardWidget, - Dashboard, - Project, - IssueLink, - IssueAttachment, - IssueRelation, - User, -) -from plane.app.serializers import ( - IssueActivitySerializer, - IssueSerializer, - DashboardSerializer, - WidgetSerializer, -) -from plane.utils.issue_filters import issue_filters def dashboard_overview_stats(self, request, slug): @@ -569,6 +570,7 @@ def dashboard_recent_collaborators(self, request, slug): ) return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=project_members_with_activities, controller=lambda qs: self.get_results_controller(qs, slug), diff --git a/apiserver/plane/app/views/exporter/base.py b/apiserver/plane/app/views/exporter/base.py index 698d9eb99..dba61d728 100644 --- a/apiserver/plane/app/views/exporter/base.py +++ b/apiserver/plane/app/views/exporter/base.py @@ -1,14 +1,14 @@ # Third Party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import WorkSpaceAdminPermission +from plane.app.serializers import ExporterHistorySerializer +from plane.bgtasks.export_task import issue_export_task +from plane.db.models import ExporterHistory, Project, Workspace # Module imports from .. import BaseAPIView -from plane.app.permissions import WorkSpaceAdminPermission -from plane.bgtasks.export_task import issue_export_task -from plane.db.models import Project, ExporterHistory, Workspace - -from plane.app.serializers import ExporterHistorySerializer class ExportIssuesEndpoint(BaseAPIView): @@ -72,6 +72,7 @@ class ExportIssuesEndpoint(BaseAPIView): "cursor", False ): return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=exporter_history, on_results=lambda exporter_history: ExporterHistorySerializer( diff --git a/apiserver/plane/app/views/issue/archive.py b/apiserver/plane/app/views/issue/archive.py index cc3a343d2..584edd8f9 100644 --- a/apiserver/plane/app/views/issue/archive.py +++ b/apiserver/plane/app/views/issue/archive.py @@ -2,52 +2,54 @@ import json # Django imports -from django.utils import timezone -from django.db.models import ( - Prefetch, - OuterRef, - Func, - F, - Q, - Case, - Value, - CharField, - When, - Exists, - Max, - UUIDField, -) from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import ( + F, + Func, + OuterRef, + Q, + Prefetch, + Exists, +) +from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models.functions import Coalesce # Third Party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response -# Module imports -from .. import BaseViewSet -from plane.app.serializers import ( - IssueSerializer, - IssueFlatSerializer, - IssueDetailSerializer, -) from plane.app.permissions import ( ProjectEntityPermission, ) +from plane.app.serializers import ( + IssueFlatSerializer, + IssueSerializer, + IssueDetailSerializer +) +from plane.bgtasks.issue_activites_task import issue_activity from plane.db.models import ( Issue, - IssueLink, IssueAttachment, + IssueLink, IssueSubscriber, IssueReaction, ) -from plane.bgtasks.issue_activites_task import issue_activity +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) from plane.utils.issue_filters import issue_filters -from plane.utils.user_timezone_converter import user_timezone_converter +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) + +# Module imports +from .. import BaseViewSet, BaseAPIView + class IssueArchiveViewSet(BaseViewSet): permission_classes = [ @@ -92,33 +94,6 @@ class IssueArchiveViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) ) @method_decorator(gzip_page) @@ -126,125 +101,116 @@ class IssueArchiveViewSet(BaseViewSet): filters = issue_filters(request.query_params, "GET") show_sub_issues = request.GET.get("show_sub_issues", "true") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = self.get_queryset().filter(**filters) - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") - - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) - issue_queryset = ( issue_queryset if show_sub_issues == "true" else issue_queryset.filter(parent__isnull=True) ) - if self.expand or self.fields: - issues = IssueSerializer( - issue_queryset, - many=True, - fields=self.fields, - ).data - else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", - ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - return Response(issues, status=status.HTTP_200_OK) + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + # Check group and sub group value paginate + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # group and sub group pagination + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + else: + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + ) def retrieve(self, request, slug, project_id, pk=None): issue = ( @@ -351,3 +317,58 @@ class IssueArchiveViewSet(BaseViewSet): issue.save() return Response(status=status.HTTP_204_NO_CONTENT) + + +class BulkArchiveIssuesEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id): + issue_ids = request.data.get("issue_ids", []) + + if not len(issue_ids): + return Response( + {"error": "Issue IDs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + issues = Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issue_ids + ).select_related("state") + bulk_archive_issues = [] + for issue in issues: + if issue.state.group not in ["completed", "cancelled"]: + return Response( + { + "error_code": 4091, + "error_message": "INVALID_ARCHIVE_STATE_GROUP" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + issue_activity.delay( + type="issue.activity.updated", + requested_data=json.dumps( + { + "archived_at": str(timezone.now().date()), + "automation": False, + } + ), + actor_id=str(request.user.id), + issue_id=str(issue.id), + project_id=str(project_id), + current_instance=json.dumps( + IssueSerializer(issue).data, cls=DjangoJSONEncoder + ), + epoch=int(timezone.now().timestamp()), + notification=True, + origin=request.META.get("HTTP_ORIGIN"), + ) + issue.archived_at = timezone.now().date() + bulk_archive_issues.append(issue) + Issue.objects.bulk_update(bulk_archive_issues, ["archived_at"]) + + return Response( + {"archived_at": str(timezone.now().date())}, + status=status.HTTP_200_OK, + ) diff --git a/apiserver/plane/app/views/issue/base.py b/apiserver/plane/app/views/issue/base.py index fad85b79d..e7b12a528 100644 --- a/apiserver/plane/app/views/issue/base.py +++ b/apiserver/plane/app/views/issue/base.py @@ -1,34 +1,30 @@ # Python imports import json +# Django imports from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.core.serializers.json import DjangoJSONEncoder from django.db.models import ( - Case, - CharField, Exists, F, Func, - Max, OuterRef, Prefetch, Q, UUIDField, Value, - When, ) from django.db.models.functions import Coalesce - -# Django imports from django.utils import timezone from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from rest_framework import status # Third Party imports +from rest_framework import status from rest_framework.response import Response +# Module imports from plane.app.permissions import ( ProjectEntityPermission, ProjectLitePermission, @@ -49,11 +45,21 @@ from plane.db.models import ( IssueSubscriber, Project, ) +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) from plane.utils.issue_filters import issue_filters +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) +from .. import BaseAPIView, BaseViewSet from plane.utils.user_timezone_converter import user_timezone_converter # Module imports -from .. import BaseAPIView, BaseViewSet class IssueListEndpoint(BaseAPIView): @@ -105,110 +111,28 @@ class IssueListEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) ).distinct() filters = issue_filters(request.query_params, "GET") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - order_by_param = request.GET.get("order_by", "-created_at") - issue_queryset = queryset.filter(**filters) + # Issue queryset + issue_queryset, _ = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) if self.fields or self.expand: issues = IssueSerializer( @@ -304,33 +228,6 @@ class IssueViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) ).distinct() @method_decorator(gzip_page) @@ -340,116 +237,104 @@ class IssueViewSet(BaseViewSet): issue_queryset = self.get_queryset().filter(**filters) # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) else: - issue_queryset = issue_queryset.order_by(order_by_param) - - # Only use serializer when expand or fields else return by values - if self.expand or self.fields: - issues = IssueSerializer( - issue_queryset, - many=True, - fields=self.fields, - expand=self.expand, - ).data - else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) - return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) @@ -481,8 +366,13 @@ class IssueViewSet(BaseViewSet): origin=request.META.get("HTTP_ORIGIN"), ) issue = ( - self.get_queryset() - .filter(pk=serializer.data["id"]) + issue_queryset_grouper( + queryset=self.get_queryset().filter( + pk=serializer.data["id"] + ), + group_by=None, + sub_group_by=None, + ) .values( "id", "name", @@ -523,6 +413,33 @@ class IssueViewSet(BaseViewSet): issue = ( self.get_queryset() .filter(pk=pk) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) .prefetch_related( Prefetch( "issue_reactions", diff --git a/apiserver/plane/app/views/issue/bulk_operations.py b/apiserver/plane/app/views/issue/bulk_operations.py new file mode 100644 index 000000000..ea6637826 --- /dev/null +++ b/apiserver/plane/app/views/issue/bulk_operations.py @@ -0,0 +1,288 @@ +# Python imports +import json +from datetime import datetime + +# Django imports +from django.utils import timezone + +# Third Party imports +from rest_framework.response import Response +from rest_framework import status + +# Module imports +from .. import BaseAPIView +from plane.app.permissions import ( + ProjectEntityPermission, +) +from plane.db.models import ( + Project, + Issue, + IssueLabel, + IssueAssignee, +) +from plane.bgtasks.issue_activites_task import issue_activity + + +class BulkIssueOperationsEndpoint(BaseAPIView): + permission_classes = [ + ProjectEntityPermission, + ] + + def post(self, request, slug, project_id): + issue_ids = request.data.get("issue_ids", []) + if not len(issue_ids): + return Response( + {"error": "Issue IDs are required"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + # Get all the issues + issues = ( + Issue.objects.filter( + workspace__slug=slug, project_id=project_id, pk__in=issue_ids + ) + .select_related("state") + .prefetch_related("labels", "assignees") + ) + # Current epoch + epoch = int(timezone.now().timestamp()) + + # Project details + project = Project.objects.get(workspace__slug=slug, pk=project_id) + workspace_id = project.workspace_id + + # Initialize arrays + bulk_update_issues = [] + bulk_issue_activities = [] + bulk_update_issue_labels = [] + bulk_update_issue_assignees = [] + + properties = request.data.get("properties", {}) + + if properties.get("start_date", False) and properties.get("target_date", False): + if ( + datetime.strptime(properties.get("start_date"), "%Y-%m-%d").date() + > datetime.strptime(properties.get("target_date"), "%Y-%m-%d").date() + ): + return Response( + { + "error_code": 4100, + "error_message": "INVALID_ISSUE_DATES", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + for issue in issues: + + # Priority + if properties.get("priority", False): + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"priority": properties.get("priority")} + ), + "current_instance": json.dumps( + {"priority": (issue.priority)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.priority = properties.get("priority") + + # State + if properties.get("state_id", False): + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"state": properties.get("state")} + ), + "current_instance": json.dumps( + {"state": str(issue.state_id)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.state_id = properties.get("state_id") + + # Start date + if properties.get("start_date", False): + if ( + issue.target_date + and not properties.get("target_date", False) + and issue.target_date + <= datetime.strptime( + properties.get("start_date"), "%Y-%m-%d" + ).date() + ): + return Response( + { + "error_code": 4101, + "error_message": "INVALID_ISSUE_START_DATE", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"start_date": properties.get("start_date")} + ), + "current_instance": json.dumps( + {"start_date": str(issue.start_date)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.start_date = properties.get("start_date") + + # Target date + if properties.get("target_date", False): + if ( + issue.start_date + and not properties.get("start_date", False) + and issue.start_date + >= datetime.strptime( + properties.get("target_date"), "%Y-%m-%d" + ).date() + ): + return Response( + { + "error_code": 4102, + "error_message": "INVALID_ISSUE_TARGET_DATE", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"target_date": properties.get("target_date")} + ), + "current_instance": json.dumps( + {"target_date": str(issue.target_date)} + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + issue.target_date = properties.get("target_date") + + bulk_update_issues.append(issue) + + # Labels + if properties.get("label_ids", []): + for label_id in properties.get("label_ids", []): + bulk_update_issue_labels.append( + IssueLabel( + issue=issue, + label_id=label_id, + created_by=request.user, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + {"label_ids": properties.get("label_ids", [])} + ), + "current_instance": json.dumps( + { + "label_ids": [ + str(label.id) + for label in issue.labels.all() + ] + } + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + + # Assignees + if properties.get("assignee_ids", []): + for assignee_id in properties.get( + "assignee_ids", issue.assignees + ): + bulk_update_issue_assignees.append( + IssueAssignee( + issue=issue, + assignee_id=assignee_id, + created_by=request.user, + project_id=project_id, + workspace_id=workspace_id, + ) + ) + bulk_issue_activities.append( + { + "type": "issue.activity.updated", + "requested_data": json.dumps( + { + "assignee_ids": properties.get( + "assignee_ids", [] + ) + } + ), + "current_instance": json.dumps( + { + "assignee_ids": [ + str(assignee.id) + for assignee in issue.assignees.all() + ] + } + ), + "issue_id": str(issue.id), + "actor_id": str(request.user.id), + "project_id": str(project_id), + "epoch": epoch, + } + ) + + # Bulk update all the objects + Issue.objects.bulk_update( + bulk_update_issues, + [ + "priority", + "start_date", + "target_date", + "state", + ], + batch_size=100, + ) + + # Create new labels + IssueLabel.objects.bulk_create( + bulk_update_issue_labels, + ignore_conflicts=True, + batch_size=100, + ) + + # Create new assignees + IssueAssignee.objects.bulk_create( + bulk_update_issue_assignees, + ignore_conflicts=True, + batch_size=100, + ) + # update the issue activity + [ + issue_activity.delay(**activity) + for activity in bulk_issue_activities + ] + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/apiserver/plane/app/views/issue/draft.py b/apiserver/plane/app/views/issue/draft.py index 610c3c468..6944f40f7 100644 --- a/apiserver/plane/app/views/issue/draft.py +++ b/apiserver/plane/app/views/issue/draft.py @@ -6,18 +6,14 @@ from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField from django.core.serializers.json import DjangoJSONEncoder from django.db.models import ( - Case, - CharField, Exists, F, Func, - Max, OuterRef, Prefetch, Q, UUIDField, Value, - When, ) from django.db.models.functions import Coalesce from django.utils import timezone @@ -28,6 +24,7 @@ from django.views.decorators.gzip import gzip_page from rest_framework import status from rest_framework.response import Response +# Module imports from plane.app.permissions import ProjectEntityPermission from plane.app.serializers import ( IssueCreateSerializer, @@ -44,10 +41,17 @@ from plane.db.models import ( IssueSubscriber, Project, ) +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) from plane.utils.issue_filters import issue_filters -from plane.utils.user_timezone_converter import user_timezone_converter - -# Module imports +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) from .. import BaseViewSet @@ -88,153 +92,116 @@ class IssueDraftViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) ).distinct() @method_decorator(gzip_page) def list(self, request, slug, project_id): filters = issue_filters(request.query_params, "GET") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = self.get_queryset().filter(**filters) + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + # Check group and sub group value paginate + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # group and sub group pagination + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) else: - issue_queryset = issue_queryset.order_by(order_by_param) - - # Only use serializer when expand else return by values - if self.expand or self.fields: - issues = IssueSerializer( - issue_queryset, - many=True, - fields=self.fields, - expand=self.expand, - ).data - else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) - return Response(issues, status=status.HTTP_200_OK) def create(self, request, slug, project_id): project = Project.objects.get(pk=project_id) @@ -265,12 +232,45 @@ class IssueDraftViewSet(BaseViewSet): notification=True, origin=request.META.get("HTTP_ORIGIN"), ) + issue = ( - self.get_queryset().filter(pk=serializer.data["id"]).first() - ) - return Response( - IssueSerializer(issue).data, status=status.HTTP_201_CREATED + issue_queryset_grouper( + queryset=self.get_queryset().filter( + pk=serializer.data["id"] + ), + group_by=None, + sub_group_by=None, + ) + .values( + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "module_ids", + "label_ids", + "assignee_ids", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + ) + .first() ) + return Response(issue, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) def partial_update(self, request, slug, project_id, pk): @@ -309,6 +309,33 @@ class IssueDraftViewSet(BaseViewSet): issue = ( self.get_queryset() .filter(pk=pk) + .annotate( + label_ids=Coalesce( + ArrayAgg( + "labels__id", + distinct=True, + filter=~Q(labels__id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + assignee_ids=Coalesce( + ArrayAgg( + "assignees__id", + distinct=True, + filter=~Q(assignees__id__isnull=True) + & Q(assignees__member_project__is_active=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + module_ids=Coalesce( + ArrayAgg( + "issue_module__module_id", + distinct=True, + filter=~Q(issue_module__module_id__isnull=True), + ), + Value([], output_field=ArrayField(UUIDField())), + ), + ) .prefetch_related( Prefetch( "issue_reactions", diff --git a/apiserver/plane/app/views/module/issue.py b/apiserver/plane/app/views/module/issue.py index 879ab7e47..53665b943 100644 --- a/apiserver/plane/app/views/module/issue.py +++ b/apiserver/plane/app/views/module/issue.py @@ -1,37 +1,50 @@ # Python imports import json +from django.db.models import ( + F, + Func, + OuterRef, + Q, +) + # Django Imports from django.utils import timezone -from django.db.models import F, OuterRef, Func, Q from django.utils.decorators import method_decorator from django.views.decorators.gzip import gzip_page -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models import Value, UUIDField -from django.db.models.functions import Coalesce # Third party imports -from rest_framework.response import Response from rest_framework import status +from rest_framework.response import Response + +from plane.app.permissions import ( + ProjectEntityPermission, +) +from plane.app.serializers import ( + ModuleIssueSerializer, +) +from plane.bgtasks.issue_activites_task import issue_activity +from plane.db.models import ( + Issue, + IssueAttachment, + IssueLink, + ModuleIssue, + Project, +) +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) +from plane.utils.issue_filters import issue_filters +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) # Module imports from .. import BaseViewSet -from plane.app.serializers import ( - ModuleIssueSerializer, - IssueSerializer, -) -from plane.app.permissions import ProjectEntityPermission -from plane.db.models import ( - ModuleIssue, - Project, - Issue, - IssueLink, - IssueAttachment, -) -from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.issue_filters import issue_filters -from plane.utils.user_timezone_converter import user_timezone_converter class ModuleIssueViewSet(BaseViewSet): serializer_class = ModuleIssueSerializer @@ -80,82 +93,115 @@ class ModuleIssueViewSet(BaseViewSet): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) ).distinct() @method_decorator(gzip_page) def list(self, request, slug, project_id, module_id): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] filters = issue_filters(request.query_params, "GET") issue_queryset = self.get_queryset().filter(**filters) - if self.fields or self.expand: - issues = IssueSerializer( - issue_queryset, many=True, fields=fields if fields else None - ).data - else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", - ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) + order_by_param = request.GET.get("order_by", "created_at") - return Response(issues, status=status.HTTP_200_OK) + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) + + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + # Check group and sub group value paginate + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # group and sub group pagination + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + else: + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + ) # create multiple issues inside a module def create_module_issues(self, request, slug, project_id, module_id): diff --git a/apiserver/plane/app/views/notification/base.py b/apiserver/plane/app/views/notification/base.py index 8dae618db..5af5d0a9a 100644 --- a/apiserver/plane/app/views/notification/base.py +++ b/apiserver/plane/app/views/notification/base.py @@ -1,26 +1,27 @@ # Django imports -from django.db.models import Q, OuterRef, Exists +from django.db.models import Exists, OuterRef, Q from django.utils import timezone # Third party imports from rest_framework import status from rest_framework.response import Response -from plane.utils.paginator import BasePaginator -# Module imports -from ..base import BaseViewSet, BaseAPIView -from plane.db.models import ( - Notification, - IssueAssignee, - IssueSubscriber, - Issue, - WorkspaceMember, - UserNotificationPreference, -) from plane.app.serializers import ( NotificationSerializer, UserNotificationPreferenceSerializer, ) +from plane.db.models import ( + Issue, + IssueAssignee, + IssueSubscriber, + Notification, + UserNotificationPreference, + WorkspaceMember, +) +from plane.utils.paginator import BasePaginator + +# Module imports +from ..base import BaseAPIView, BaseViewSet class NotificationViewSet(BaseViewSet, BasePaginator): @@ -131,6 +132,7 @@ class NotificationViewSet(BaseViewSet, BasePaginator): "cursor", False ): return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=(notifications), on_results=lambda notifications: NotificationSerializer( diff --git a/apiserver/plane/app/views/project/base.py b/apiserver/plane/app/views/project/base.py index 7e3326e02..78a9b9547 100644 --- a/apiserver/plane/app/views/project/base.py +++ b/apiserver/plane/app/views/project/base.py @@ -1,26 +1,25 @@ # Python imports import boto3 +from django.conf import settings +from django.utils import timezone import json # Django imports from django.db import IntegrityError from django.db.models import ( - Prefetch, - Q, Exists, - OuterRef, F, Func, + OuterRef, + Prefetch, + Q, Subquery, ) -from django.conf import settings -from django.utils import timezone from django.core.serializers.json import DjangoJSONEncoder # Third Party imports from rest_framework.response import Response -from rest_framework import status -from rest_framework import serializers +from rest_framework import serializers, status from rest_framework.permissions import AllowAny # Module imports @@ -35,20 +34,19 @@ from plane.app.permissions import ( ProjectBasePermission, ProjectMemberPermission, ) - from plane.db.models import ( - Project, - ProjectMember, - Workspace, - State, UserFavorite, - ProjectIdentifier, - Module, Cycle, Inbox, DeployBoard, IssueProperty, Issue, + Module, + Project, + ProjectIdentifier, + ProjectMember, + State, + Workspace, ) from plane.utils.cache import cache_response from plane.bgtasks.webhook_task import model_activity @@ -168,6 +166,7 @@ class ProjectViewSet(BaseViewSet): "cursor", False ): return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=(projects), on_results=lambda projects: ProjectListSerializer( diff --git a/apiserver/plane/app/views/user/base.py b/apiserver/plane/app/views/user/base.py index de1559b0c..5d757ef57 100644 --- a/apiserver/plane/app/views/user/base.py +++ b/apiserver/plane/app/views/user/base.py @@ -250,6 +250,7 @@ class UserActivityEndpoint(BaseAPIView, BasePaginator): ).select_related("actor", "workspace", "issue", "project") return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=queryset, on_results=lambda issue_activities: IssueActivitySerializer( diff --git a/apiserver/plane/app/views/view/base.py b/apiserver/plane/app/views/view/base.py index 72c27d20a..d4bf258a5 100644 --- a/apiserver/plane/app/views/view/base.py +++ b/apiserver/plane/app/views/view/base.py @@ -1,47 +1,56 @@ # Django imports -from django.db.models import ( - Q, - OuterRef, - Func, - F, - Case, - Value, - CharField, - When, - Exists, - Max, -) -from django.utils.decorators import method_decorator -from django.views.decorators.gzip import gzip_page from django.contrib.postgres.aggregates import ArrayAgg from django.contrib.postgres.fields import ArrayField -from django.db.models import UUIDField +from django.db.models import ( + Exists, + F, + Func, + OuterRef, + Q, + UUIDField, + Value, +) from django.db.models.functions import Coalesce +from django.utils.decorators import method_decorator +from django.views.decorators.gzip import gzip_page +from rest_framework import status # Third party imports from rest_framework.response import Response -from rest_framework import status + +from plane.app.permissions import ( + ProjectEntityPermission, + WorkspaceEntityPermission, +) +from plane.app.serializers import ( + IssueViewSerializer, +) +from plane.db.models import ( + Issue, + IssueAttachment, + IssueLink, + IssueView, + Workspace, +) +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) +from plane.utils.issue_filters import issue_filters +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) # Module imports from .. import BaseViewSet -from plane.app.serializers import ( - IssueViewSerializer, - IssueSerializer, -) -from plane.app.permissions import ( - WorkspaceEntityPermission, - ProjectEntityPermission, -) + from plane.db.models import ( - Workspace, - IssueView, - Issue, UserFavorite, - IssueLink, - IssueAttachment, ) -from plane.utils.issue_filters import issue_filters -from plane.utils.user_timezone_converter import user_timezone_converter + class GlobalViewViewSet(BaseViewSet): serializer_class = IssueViewSerializer @@ -143,17 +152,6 @@ class GlobalViewIssuesViewSet(BaseViewSet): @method_decorator(gzip_page) def list(self, request, slug): filters = issue_filters(request.query_params, "GET") - - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( @@ -162,103 +160,107 @@ class GlobalViewIssuesViewSet(BaseViewSet): .annotate(cycle_id=F("issue_cycle__cycle_id")) ) - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), - ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) - else: - issue_queryset = issue_queryset.order_by(order_by_param) + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) - if self.fields: - issues = IssueSerializer( - issue_queryset, many=True, fields=self.fields - ).data + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + # Check group and sub group value paginate + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # group and sub group pagination + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=None, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=None, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=None, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) else: - issues = issue_queryset.values( - "id", - "name", - "state_id", - "sort_order", - "completed_at", - "estimate_point", - "priority", - "start_date", - "target_date", - "sequence_id", - "project_id", - "parent_id", - "cycle_id", - "module_ids", - "label_ids", - "assignee_ids", - "sub_issues_count", - "created_at", - "updated_at", - "created_by", - "updated_by", - "attachment_count", - "link_count", - "is_draft", - "archived_at", + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), ) - datetime_fields = ["created_at", "updated_at"] - issues = user_timezone_converter( - issues, datetime_fields, request.user.user_timezone - ) - return Response(issues, status=status.HTTP_200_OK) class IssueViewViewSet(BaseViewSet): diff --git a/apiserver/plane/app/views/workspace/user.py b/apiserver/plane/app/views/workspace/user.py index 94a22a1a7..addb8c5ac 100644 --- a/apiserver/plane/app/views/workspace/user.py +++ b/apiserver/plane/app/views/workspace/user.py @@ -1,61 +1,66 @@ # Python imports from datetime import date + from dateutil.relativedelta import relativedelta # Django imports -from django.utils import timezone from django.db.models import ( - OuterRef, - Func, - F, - Q, - Count, Case, - Value, - CharField, - When, - Max, + Count, + F, + Func, IntegerField, - UUIDField, + OuterRef, + Q, + Value, + When, ) -from django.db.models.functions import ExtractWeek, Cast from django.db.models.fields import DateField -from django.contrib.postgres.aggregates import ArrayAgg -from django.contrib.postgres.fields import ArrayField -from django.db.models.functions import Coalesce +from django.db.models.functions import Cast, ExtractWeek +from django.utils import timezone # Third party modules from rest_framework import status from rest_framework.response import Response -# Module imports -from plane.app.serializers import ( - WorkSpaceSerializer, - ProjectMemberSerializer, - IssueActivitySerializer, - IssueSerializer, - WorkspaceUserPropertiesSerializer, -) -from plane.app.views.base import BaseAPIView -from plane.db.models import ( - User, - Workspace, - ProjectMember, - IssueActivity, - Issue, - IssueLink, - IssueAttachment, - IssueSubscriber, - Project, - WorkspaceMember, - CycleIssue, - WorkspaceUserProperties, -) from plane.app.permissions import ( WorkspaceEntityPermission, WorkspaceViewerPermission, ) + +# Module imports +from plane.app.serializers import ( + IssueActivitySerializer, + ProjectMemberSerializer, + WorkSpaceSerializer, + WorkspaceUserPropertiesSerializer, +) +from plane.app.views.base import BaseAPIView +from plane.db.models import ( + CycleIssue, + Issue, + IssueActivity, + IssueAttachment, + IssueLink, + IssueSubscriber, + Project, + ProjectMember, + User, + Workspace, + WorkspaceMember, + WorkspaceUserProperties, +) +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) from plane.utils.issue_filters import issue_filters +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) class UserLastProjectWithWorkspaceEndpoint(BaseAPIView): @@ -99,22 +104,8 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): ] def get(self, request, slug, user_id): - fields = [ - field - for field in request.GET.get("fields", "").split(",") - if field - ] - filters = issue_filters(request.query_params, "GET") - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] + filters = issue_filters(request.query_params, "GET") order_by_param = request.GET.get("order_by", "-created_at") issue_queryset = ( @@ -152,100 +143,103 @@ class WorkspaceUserProfileIssuesEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) - .annotate( - label_ids=Coalesce( - ArrayAgg( - "labels__id", - distinct=True, - filter=~Q(labels__id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - assignee_ids=Coalesce( - ArrayAgg( - "assignees__id", - distinct=True, - filter=~Q(assignees__id__isnull=True) - & Q(assignees__member_project__is_active=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - module_ids=Coalesce( - ArrayAgg( - "issue_module__module_id", - distinct=True, - filter=~Q(issue_module__module_id__isnull=True), - ), - Value([], output_field=ArrayField(UUIDField())), - ), - ) .order_by("created_at") ).distinct() - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, + ) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) + + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issues = IssueSerializer( - issue_queryset, many=True, fields=fields if fields else None - ).data - return Response(issues, status=status.HTTP_200_OK) + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by + ), + ) class WorkspaceUserPropertiesEndpoint(BaseAPIView): @@ -397,6 +391,7 @@ class WorkspaceUserActivityEndpoint(BaseAPIView): queryset = queryset.filter(project__in=projects) return self.paginate( + order_by=request.GET.get("order_by", "-created_at"), request=request, queryset=queryset, on_results=lambda issue_activities: IssueActivitySerializer( diff --git a/apiserver/plane/bgtasks/project_invitation_task.py b/apiserver/plane/bgtasks/project_invitation_task.py index b60c49da1..84ef237ef 100644 --- a/apiserver/plane/bgtasks/project_invitation_task.py +++ b/apiserver/plane/bgtasks/project_invitation_task.py @@ -5,6 +5,7 @@ import logging from celery import shared_task # Django imports +# Third party imports from django.core.mail import EmailMultiAlternatives, get_connection from django.template.loader import render_to_string from django.utils.html import strip_tags diff --git a/apiserver/plane/space/views/issue.py b/apiserver/plane/space/views/issue.py index 71ba0f6a7..80f6ec9dc 100644 --- a/apiserver/plane/space/views/issue.py +++ b/apiserver/plane/space/views/issue.py @@ -1,46 +1,29 @@ # Python imports import json +from django.core.serializers.json import DjangoJSONEncoder +from django.db.models import Exists, F, Func, OuterRef, Q, Prefetch + # Django imports from django.utils import timezone -from django.db.models import ( - Prefetch, - OuterRef, - Func, - F, - Q, - Case, - Value, - CharField, - When, - Exists, - Max, - IntegerField, -) -from django.core.serializers.json import DjangoJSONEncoder - -# Third Party imports -from rest_framework.response import Response from rest_framework import status from rest_framework.permissions import AllowAny, IsAuthenticated -# Module imports -from .base import BaseViewSet, BaseAPIView -from plane.app.serializers import ( - IssueCommentSerializer, - IssueReactionSerializer, - CommentReactionSerializer, - IssueVoteSerializer, - IssuePublicSerializer, -) +# Third Party imports +from rest_framework.response import Response +from plane.app.serializers import ( + CommentReactionSerializer, + IssueCommentSerializer, + IssuePublicSerializer, + IssueReactionSerializer, + IssueVoteSerializer, +) from plane.db.models import ( Issue, IssueComment, - Label, IssueLink, IssueAttachment, - State, ProjectMember, IssueReaction, CommentReaction, @@ -49,8 +32,20 @@ from plane.db.models import ( ProjectPublicMember, ) from plane.bgtasks.issue_activites_task import issue_activity -from plane.utils.grouper import group_results +from plane.utils.grouper import ( + issue_group_values, + issue_on_results, + issue_queryset_grouper, +) from plane.utils.issue_filters import issue_filters +from plane.utils.order_queryset import order_issue_queryset +from plane.utils.paginator import ( + GroupedOffsetPaginator, + SubGroupedOffsetPaginator, +) + +# Module imports +from .base import BaseAPIView, BaseViewSet class IssueCommentPublicViewSet(BaseViewSet): @@ -535,17 +530,10 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): anchor=anchor, entity_name="project" ) - filters = issue_filters(request.query_params, "GET") + project_id = project_deploy_board.entity_identifier + slug = project_deploy_board.workspace.slug - # Custom ordering for priority and state - priority_order = ["urgent", "high", "medium", "low", "none"] - state_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] + filters = issue_filters(request.query_params, "GET") order_by_param = request.GET.get("order_by", "-created_at") @@ -576,7 +564,6 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): ) .filter(**filters) .annotate(cycle_id=F("issue_cycle__cycle_id")) - .annotate(module_id=F("issue_module__module_id")) .annotate( link_count=IssueLink.objects.filter(issue=OuterRef("id")) .order_by() @@ -591,113 +578,118 @@ class ProjectIssuesPublicEndpoint(BaseAPIView): .annotate(count=Func(F("id"), function="Count")) .values("count") ) + .annotate( + sub_issues_count=Issue.issue_objects.filter( + parent=OuterRef("id") + ) + .order_by() + .annotate(count=Func(F("id"), function="Count")) + .values("count") + ) + ).distinct() + + order_by_param = request.GET.get("order_by", "-created_at") + + issue_queryset = self.get_queryset().filter(**filters) + + # Issue queryset + issue_queryset, order_by_param = order_issue_queryset( + issue_queryset=issue_queryset, + order_by_param=order_by_param, ) - # Priority Ordering - if order_by_param == "priority" or order_by_param == "-priority": - priority_order = ( - priority_order - if order_by_param == "priority" - else priority_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - priority_order=Case( - *[ - When(priority=p, then=Value(i)) - for i, p in enumerate(priority_order) - ], - output_field=CharField(), - ) - ).order_by("priority_order") + # Group by + group_by = request.GET.get("group_by", False) + sub_group_by = request.GET.get("sub_group_by", False) - # State Ordering - elif order_by_param in [ - "state__name", - "state__group", - "-state__name", - "-state__group", - ]: - state_order = ( - state_order - if order_by_param in ["state__name", "state__group"] - else state_order[::-1] - ) - issue_queryset = issue_queryset.annotate( - state_order=Case( - *[ - When(state__group=state_group, then=Value(i)) - for i, state_group in enumerate(state_order) - ], - default=Value(len(state_order)), - output_field=CharField(), + # issue queryset + issue_queryset = issue_queryset_grouper( + queryset=issue_queryset, + group_by=group_by, + sub_group_by=sub_group_by, + ) + + if group_by: + # Check group and sub group value paginate + if sub_group_by: + if group_by == sub_group_by: + return Response( + { + "error": "Group by and sub group by cannot have same parameters" + }, + status=status.HTTP_400_BAD_REQUEST, + ) + else: + # group and sub group pagination + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=SubGroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + sub_group_by_fields=issue_group_values( + field=sub_group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + sub_group_by_field_name=sub_group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), + ) + # Group Paginate + else: + # Group paginate + return self.paginate( + request=request, + order_by=order_by_param, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, + issues=issues, + sub_group_by=sub_group_by, + ), + paginator_cls=GroupedOffsetPaginator, + group_by_fields=issue_group_values( + field=group_by, + slug=slug, + project_id=project_id, + filters=filters, + ), + group_by_field_name=group_by, + count_filter=Q( + Q(issue_inbox__status=1) + | Q(issue_inbox__status=-1) + | Q(issue_inbox__status=2) + | Q(issue_inbox__isnull=True), + archived_at__isnull=True, + is_draft=False, + ), ) - ).order_by("state_order") - # assignee and label ordering - elif order_by_param in [ - "labels__name", - "-labels__name", - "assignees__first_name", - "-assignees__first_name", - ]: - issue_queryset = issue_queryset.annotate( - max_values=Max( - order_by_param[1::] - if order_by_param.startswith("-") - else order_by_param - ) - ).order_by( - "-max_values" - if order_by_param.startswith("-") - else "max_values" - ) else: - issue_queryset = issue_queryset.order_by(order_by_param) - - issues = IssuePublicSerializer(issue_queryset, many=True).data - - state_group_order = [ - "backlog", - "unstarted", - "started", - "completed", - "cancelled", - ] - - states = ( - State.objects.filter( - ~Q(name="Triage"), - workspace_id=project_deploy_board.workspace_id, - project_id=project_deploy_board.project_id, - ) - .annotate( - custom_order=Case( - *[ - When(group=value, then=Value(index)) - for index, value in enumerate(state_group_order) - ], - default=Value(len(state_group_order)), - output_field=IntegerField(), + # List Paginate + return self.paginate( + order_by=order_by_param, + request=request, + queryset=issue_queryset, + on_results=lambda issues: issue_on_results( + group_by=group_by, issues=issues, sub_group_by=sub_group_by ), ) - .values("name", "group", "color", "id") - .order_by("custom_order", "sequence") - ) - - labels = Label.objects.filter( - workspace_id=project_deploy_board.workspace_id, - project_id=project_deploy_board.project_id, - ).values("id", "name", "color", "parent") - - ## Grouping the results - group_by = request.GET.get("group_by", False) - if group_by: - issues = group_results(issues, group_by) - - return Response( - { - "issues": issues, - "states": states, - "labels": labels, - }, - status=status.HTTP_200_OK, - ) diff --git a/apiserver/plane/utils/exception_logger.py b/apiserver/plane/utils/exception_logger.py index 0938f054b..4f4b5ae06 100644 --- a/apiserver/plane/utils/exception_logger.py +++ b/apiserver/plane/utils/exception_logger.py @@ -1,5 +1,9 @@ # Python imports import logging +import traceback + +# Django imports +from django.conf import settings # Third party imports from sentry_sdk import capture_exception @@ -11,6 +15,10 @@ def log_exception(e): logger = logging.getLogger("plane") logger.error(e) + # Log traceback if running in Debug + if settings.DEBUG: + logger.error(traceback.format_exc(e)) + # Capture in sentry if configured capture_exception(e) return diff --git a/apiserver/plane/utils/grouper.py b/apiserver/plane/utils/grouper.py index edc7adc15..ca989ed81 100644 --- a/apiserver/plane/utils/grouper.py +++ b/apiserver/plane/utils/grouper.py @@ -1,240 +1,191 @@ -def resolve_keys(group_keys, value): - """resolve keys to a key which will be used for - grouping +# Django imports +from django.contrib.postgres.aggregates import ArrayAgg +from django.contrib.postgres.fields import ArrayField +from django.db.models import Q, UUIDField, Value +from django.db.models.functions import Coalesce - Args: - group_keys (string): key which will be used for grouping - value (obj): data value - - Returns: - string: the key which will be used for - """ - keys = group_keys.split(".") - for key in keys: - value = value.get(key, None) - return value +# Module imports +from plane.db.models import ( + Cycle, + Issue, + Label, + Module, + Project, + ProjectMember, + State, + WorkspaceMember, +) -def group_results(results_data, group_by, sub_group_by=False): - """group results data into certain group_by +def issue_queryset_grouper(queryset, group_by, sub_group_by): - Args: - results_data (obj): complete results data - group_by (key): string + FIELD_MAPPER = { + "label_ids": "labels__id", + "assignee_ids": "assignees__id", + "module_ids": "issue_module__module_id", + } - Returns: - obj: grouped results - """ - if sub_group_by: - main_responsive_dict = dict() + annotations_map = { + "assignee_ids": ("assignees__id", ~Q(assignees__id__isnull=True)), + "label_ids": ("labels__id", ~Q(labels__id__isnull=True)), + "module_ids": ( + "issue_module__module_id", + ~Q(issue_module__module_id__isnull=True), + ), + } + default_annotations = { + key: Coalesce( + ArrayAgg( + field, + distinct=True, + filter=condition, + ), + Value([], output_field=ArrayField(UUIDField())), + ) + for key, (field, condition) in annotations_map.items() + if FIELD_MAPPER.get(key) != group_by + or FIELD_MAPPER.get(key) != sub_group_by + } - if sub_group_by == "priority": - main_responsive_dict = { - "urgent": {}, - "high": {}, - "medium": {}, - "low": {}, - "none": {}, - } + return queryset.annotate(**default_annotations) - for value in results_data: - main_group_attribute = resolve_keys(sub_group_by, value) - group_attribute = resolve_keys(group_by, value) - if isinstance(main_group_attribute, list) and not isinstance( - group_attribute, list - ): - if len(main_group_attribute): - for attrib in main_group_attribute: - if str(attrib) not in main_responsive_dict: - main_responsive_dict[str(attrib)] = {} - if ( - str(group_attribute) - in main_responsive_dict[str(attrib)] - ): - main_responsive_dict[str(attrib)][ - str(group_attribute) - ].append(value) - else: - main_responsive_dict[str(attrib)][ - str(group_attribute) - ] = [] - main_responsive_dict[str(attrib)][ - str(group_attribute) - ].append(value) - else: - if str(None) not in main_responsive_dict: - main_responsive_dict[str(None)] = {} - if str(group_attribute) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][ - str(group_attribute) - ].append(value) - else: - main_responsive_dict[str(None)][ - str(group_attribute) - ] = [] - main_responsive_dict[str(None)][ - str(group_attribute) - ].append(value) +def issue_on_results(issues, group_by, sub_group_by): - elif isinstance(group_attribute, list) and not isinstance( - main_group_attribute, list - ): - if str(main_group_attribute) not in main_responsive_dict: - main_responsive_dict[str(main_group_attribute)] = {} - if len(group_attribute): - for attrib in group_attribute: - if ( - str(attrib) - in main_responsive_dict[str(main_group_attribute)] - ): - main_responsive_dict[str(main_group_attribute)][ - str(attrib) - ].append(value) - else: - main_responsive_dict[str(main_group_attribute)][ - str(attrib) - ] = [] - main_responsive_dict[str(main_group_attribute)][ - str(attrib) - ].append(value) - else: - if ( - str(None) - in main_responsive_dict[str(main_group_attribute)] - ): - main_responsive_dict[str(main_group_attribute)][ - str(None) - ].append(value) - else: - main_responsive_dict[str(main_group_attribute)][ - str(None) - ] = [] - main_responsive_dict[str(main_group_attribute)][ - str(None) - ].append(value) + FIELD_MAPPER = { + "labels__id": "label_ids", + "assignees__id": "assignee_ids", + "issue_module__module_id": "module_ids", + } - elif isinstance(group_attribute, list) and isinstance( - main_group_attribute, list - ): - if len(main_group_attribute): - for main_attrib in main_group_attribute: - if str(main_attrib) not in main_responsive_dict: - main_responsive_dict[str(main_attrib)] = {} - if len(group_attribute): - for attrib in group_attribute: - if ( - str(attrib) - in main_responsive_dict[str(main_attrib)] - ): - main_responsive_dict[str(main_attrib)][ - str(attrib) - ].append(value) - else: - main_responsive_dict[str(main_attrib)][ - str(attrib) - ] = [] - main_responsive_dict[str(main_attrib)][ - str(attrib) - ].append(value) - else: - if ( - str(None) - in main_responsive_dict[str(main_attrib)] - ): - main_responsive_dict[str(main_attrib)][ - str(None) - ].append(value) - else: - main_responsive_dict[str(main_attrib)][ - str(None) - ] = [] - main_responsive_dict[str(main_attrib)][ - str(None) - ].append(value) - else: - if str(None) not in main_responsive_dict: - main_responsive_dict[str(None)] = {} - if len(group_attribute): - for attrib in group_attribute: - if str(attrib) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][ - str(attrib) - ].append(value) - else: - main_responsive_dict[str(None)][ - str(attrib) - ] = [] - main_responsive_dict[str(None)][ - str(attrib) - ].append(value) - else: - if str(None) in main_responsive_dict[str(None)]: - main_responsive_dict[str(None)][str(None)].append( - value - ) - else: - main_responsive_dict[str(None)][str(None)] = [] - main_responsive_dict[str(None)][str(None)].append( - value - ) - else: - main_group_attribute = resolve_keys(sub_group_by, value) - group_attribute = resolve_keys(group_by, value) + original_list = ["assignee_ids", "label_ids", "module_ids"] - if str(main_group_attribute) not in main_responsive_dict: - main_responsive_dict[str(main_group_attribute)] = {} + required_fields = [ + "id", + "name", + "state_id", + "sort_order", + "completed_at", + "estimate_point", + "priority", + "start_date", + "target_date", + "sequence_id", + "project_id", + "parent_id", + "cycle_id", + "sub_issues_count", + "created_at", + "updated_at", + "created_by", + "updated_by", + "attachment_count", + "link_count", + "is_draft", + "archived_at", + "state__group", + ] - if ( - str(group_attribute) - in main_responsive_dict[str(main_group_attribute)] - ): - main_responsive_dict[str(main_group_attribute)][ - str(group_attribute) - ].append(value) - else: - main_responsive_dict[str(main_group_attribute)][ - str(group_attribute) - ] = [] - main_responsive_dict[str(main_group_attribute)][ - str(group_attribute) - ].append(value) + if group_by in FIELD_MAPPER: + original_list.remove(FIELD_MAPPER[group_by]) + original_list.append(group_by) - return main_responsive_dict + if sub_group_by in FIELD_MAPPER: + original_list.remove(FIELD_MAPPER[sub_group_by]) + original_list.append(sub_group_by) - else: - response_dict = {} + required_fields.extend(original_list) + return issues.values(*required_fields) - if group_by == "priority": - response_dict = { - "urgent": [], - "high": [], - "medium": [], - "low": [], - "none": [], - } - for value in results_data: - group_attribute = resolve_keys(group_by, value) - if isinstance(group_attribute, list): - if len(group_attribute): - for attrib in group_attribute: - if str(attrib) in response_dict: - response_dict[str(attrib)].append(value) - else: - response_dict[str(attrib)] = [] - response_dict[str(attrib)].append(value) - else: - if str(None) in response_dict: - response_dict[str(None)].append(value) - else: - response_dict[str(None)] = [] - response_dict[str(None)].append(value) - else: - if str(group_attribute) in response_dict: - response_dict[str(group_attribute)].append(value) - else: - response_dict[str(group_attribute)] = [] - response_dict[str(group_attribute)].append(value) - - return response_dict +def issue_group_values(field, slug, project_id=None, filters=dict): + if field == "state_id": + queryset = State.objects.filter( + ~Q(name="Triage"), + workspace__slug=slug, + ).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + if field == "labels__id": + queryset = Label.objects.filter(workspace__slug=slug).values_list( + "id", flat=True + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + else: + return list(queryset) + ["None"] + if field == "assignees__id": + if project_id: + return ProjectMember.objects.filter( + workspace__slug=slug, + project_id=project_id, + is_active=True, + ).values_list("member_id", flat=True) + else: + return list( + WorkspaceMember.objects.filter( + workspace__slug=slug, is_active=True + ).values_list("member_id", flat=True) + ) + if field == "issue_module__module_id": + queryset = Module.objects.filter( + workspace__slug=slug, + ).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + else: + return list(queryset) + ["None"] + if field == "cycle_id": + queryset = Cycle.objects.filter( + workspace__slug=slug, + ).values_list("id", flat=True) + if project_id: + return list(queryset.filter(project_id=project_id)) + ["None"] + else: + return list(queryset) + ["None"] + if field == "project_id": + queryset = Project.objects.filter(workspace__slug=slug).values_list( + "id", flat=True + ) + return list(queryset) + if field == "priority": + return [ + "low", + "medium", + "high", + "urgent", + "none", + ] + if field == "state__group": + return [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", + ] + if field == "target_date": + queryset = ( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(**filters) + .values_list("target_date", flat=True) + .distinct() + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + if field == "start_date": + queryset = ( + Issue.issue_objects.filter(workspace__slug=slug) + .filter(**filters) + .values_list("start_date", flat=True) + .distinct() + ) + if project_id: + return list(queryset.filter(project_id=project_id)) + else: + return list(queryset) + return [] diff --git a/apiserver/plane/utils/issue_filters.py b/apiserver/plane/utils/issue_filters.py index 531ef93ec..d68850856 100644 --- a/apiserver/plane/utils/issue_filters.py +++ b/apiserver/plane/utils/issue_filters.py @@ -1,6 +1,7 @@ import re import uuid from datetime import timedelta + from django.utils import timezone # The date from pattern @@ -63,24 +64,27 @@ def date_filter(filter, date_term, queries): """ for query in queries: date_query = query.split(";") - if len(date_query) >= 2: - match = pattern.match(date_query[0]) - if match: - if len(date_query) == 3: - digit, term = date_query[0].split("_") - string_date_filter( - filter=filter, - duration=int(digit), - subsequent=date_query[1], - term=term, - date_filter=date_term, - offset=date_query[2], - ) - else: - if "after" in date_query: - filter[f"{date_term}__gte"] = date_query[0] + if date_query: + if len(date_query) >= 2: + match = pattern.match(date_query[0]) + if match: + if len(date_query) == 3: + digit, term = date_query[0].split("_") + string_date_filter( + filter=filter, + duration=int(digit), + subsequent=date_query[1], + term=term, + date_filter=date_term, + offset=date_query[2], + ) else: - filter[f"{date_term}__lte"] = date_query[0] + if "after" in date_query: + filter[f"{date_term}__gte"] = date_query[0] + else: + filter[f"{date_term}__lte"] = date_query[0] + else: + filter[f"{date_term}__contains"] = date_query[0] def filter_state(params, filter, method, prefix=""): diff --git a/apiserver/plane/utils/order_queryset.py b/apiserver/plane/utils/order_queryset.py new file mode 100644 index 000000000..aafa954dc --- /dev/null +++ b/apiserver/plane/utils/order_queryset.py @@ -0,0 +1,84 @@ +from django.db.models import ( + Case, + CharField, + Min, + Value, + When, +) + +# Custom ordering for priority and state +PRIORITY_ORDER = ["urgent", "high", "medium", "low", "none"] +STATE_ORDER = [ + "backlog", + "unstarted", + "started", + "completed", + "cancelled", +] + + +def order_issue_queryset(issue_queryset, order_by_param="-created_at"): + # Priority Ordering + if order_by_param == "priority" or order_by_param == "-priority": + issue_queryset = issue_queryset.annotate( + priority_order=Case( + *[ + When(priority=p, then=Value(i)) + for i, p in enumerate(PRIORITY_ORDER) + ], + output_field=CharField(), + ) + ).order_by("priority_order") + order_by_param = ( + "-priority_order" + if order_by_param.startswith("-") + else "priority_order" + ) + # State Ordering + elif order_by_param in [ + "state__group", + "-state__group", + ]: + state_order = ( + STATE_ORDER + if order_by_param in ["state__name", "state__group"] + else STATE_ORDER[::-1] + ) + issue_queryset = issue_queryset.annotate( + state_order=Case( + *[ + When(state__group=state_group, then=Value(i)) + for i, state_group in enumerate(state_order) + ], + default=Value(len(state_order)), + output_field=CharField(), + ) + ).order_by("state_order") + order_by_param = ( + "-state_order" if order_by_param.startswith("-") else "state_order" + ) + # assignee and label ordering + elif order_by_param in [ + "labels__name", + "assignees__first_name", + "issue_module__module__name", + "-labels__name", + "-assignees__first_name", + "-issue_module__module__name", + ]: + issue_queryset = issue_queryset.annotate( + min_values=Min( + order_by_param[1::] + if order_by_param.startswith("-") + else order_by_param + ) + ).order_by( + "-min_values" if order_by_param.startswith("-") else "min_values" + ) + order_by_param = ( + "-min_values" if order_by_param.startswith("-") else "min_values" + ) + else: + issue_queryset = issue_queryset.order_by(order_by_param) + order_by_param = order_by_param + return issue_queryset, order_by_param diff --git a/apiserver/plane/utils/paginator.py b/apiserver/plane/utils/paginator.py index 8cc853370..42038b384 100644 --- a/apiserver/plane/utils/paginator.py +++ b/apiserver/plane/utils/paginator.py @@ -1,33 +1,49 @@ -from rest_framework.response import Response -from rest_framework.exceptions import ParseError -from collections.abc import Sequence +# Python imports import math +from collections import defaultdict +from collections.abc import Sequence + +# Django imports +from django.db.models import Count, F, Window +from django.db.models.functions import RowNumber + +# Third party imports +from rest_framework.exceptions import ParseError +from rest_framework.response import Response + +# Module imports class Cursor: + # The cursor value def __init__(self, value, offset=0, is_prev=False, has_results=None): self.value = value self.offset = int(offset) self.is_prev = bool(is_prev) self.has_results = has_results + # Return the cursor value in string format def __str__(self): return f"{self.value}:{self.offset}:{int(self.is_prev)}" + # Return the cursor value def __eq__(self, other): return all( getattr(self, attr) == getattr(other, attr) for attr in ("value", "offset", "is_prev", "has_results") ) + # Return the representation of the cursor def __repr__(self): return f"{type(self).__name__,}: value={self.value} offset={self.offset}, is_prev={int(self.is_prev)}" + # Return if the cursor is true def __bool__(self): return bool(self.has_results) @classmethod def from_string(cls, value): + """Return the cursor value from string format""" try: bits = value.split(":") if len(bits) != 3: @@ -50,15 +66,19 @@ class CursorResult(Sequence): self.max_hits = max_hits def __len__(self): + # Return the length of the results return len(self.results) def __iter__(self): + # Return the iterator of the results return iter(self.results) def __getitem__(self, key): + # Return the results based on the key return self.results[key] def __repr__(self): + # Return the representation of the results return f"<{type(self).__name__}: results={len(self.results)}>" @@ -85,11 +105,14 @@ class OffsetPaginator: max_offset=None, on_results=None, ): + # Key tuple and remove `-` if descending order by self.key = ( order_by if order_by is None or isinstance(order_by, (list, tuple, set)) - else (order_by,) + else (order_by[1::] if order_by.startswith("-") else order_by,) ) + # Set desc to true when `-` exists in the order by + self.desc = True if order_by.startswith("-") else False self.queryset = queryset self.max_limit = max_limit self.max_offset = max_offset @@ -101,11 +124,101 @@ class OffsetPaginator: if cursor is None: cursor = Cursor(0, 0, 0) + # Get the min from limit and max limit limit = min(limit, self.max_limit) + # queryset queryset = self.queryset if self.key: - queryset = queryset.order_by(*self.key) + queryset = queryset.order_by( + ( + F(*self.key).desc(nulls_last=True) + if self.desc + else F(*self.key).asc(nulls_last=True) + ), + "-created_at", + ) + # The current page + page = cursor.offset + # The offset + offset = cursor.offset * cursor.value + stop = offset + (cursor.value or limit) + 1 + + if self.max_offset is not None and offset >= self.max_offset: + raise BadPaginationError("Pagination offset too large") + if offset < 0: + raise BadPaginationError("Pagination offset cannot be negative") + + results = queryset[offset:stop] + + if cursor.value != limit: + results = results[-(limit + 1) :] + + # Adjust cursors based on the results for pagination + next_cursor = Cursor(limit, page + 1, False, results.count() > limit) + # If the page is greater than 0, then set the previous cursor + prev_cursor = Cursor(limit, page - 1, True, page > 0) + + # Process the results + results = results[:limit] + + # Process the results + if self.on_results: + results = self.on_results(results) + + # Count the queryset + count = queryset.count() + + # Optionally, calculate the total count and max_hits if needed + max_hits = math.ceil(count / limit) + + # Return the cursor results + return CursorResult( + results=results, + next=next_cursor, + prev=prev_cursor, + hits=count, + max_hits=max_hits, + ) + + def process_results(self, results): + raise NotImplementedError + + +class GroupedOffsetPaginator(OffsetPaginator): + + # Field mappers + FIELD_MAPPER = { + "labels__id": "label_ids", + "assignees__id": "assignee_ids", + "modules__id": "module_ids", + } + + def __init__( + self, + queryset, + group_by_field_name, + group_by_fields, + count_filter, + *args, + **kwargs, + ): + # Initiate the parent class for all the parameters + super().__init__(queryset, *args, **kwargs) + self.group_by_field_name = group_by_field_name + self.group_by_fields = group_by_fields + self.count_filter = count_filter + + def get_result(self, limit=50, cursor=None): + # offset is page # + # value is page limit + if cursor is None: + cursor = Cursor(0, 0, 0) + + limit = min(limit, self.max_limit) + + # Adjust the initial offset and stop based on the cursor and limit + queryset = self.queryset page = cursor.offset offset = cursor.offset * cursor.value @@ -116,20 +229,73 @@ class OffsetPaginator: if offset < 0: raise BadPaginationError("Pagination offset cannot be negative") - results = list(queryset[offset:stop]) - if cursor.value != limit: - results = results[-(limit + 1) :] + # Compute the results + results = {} + # Create window for all the groups + queryset = queryset.annotate( + row_number=Window( + expression=RowNumber(), + partition_by=[F(self.group_by_field_name)], + order_by=( + ( + F(*self.key).desc( + nulls_last=True + ) # order by desc if desc is set + if self.desc + else F(*self.key).asc( + nulls_last=True + ) # Order by asc if set + ), + F("created_at").desc(), + ), + ) + ) + # Filter the results by row number + results = queryset.filter( + row_number__gt=offset, row_number__lt=stop + ).order_by( + ( + F(*self.key).desc(nulls_last=True) + if self.desc + else F(*self.key).asc(nulls_last=True) + ), + F("created_at").desc(), + ) - next_cursor = Cursor(limit, page + 1, False, len(results) > limit) - prev_cursor = Cursor(limit, page - 1, True, page > 0) - - results = list(results[:limit]) - if self.on_results: - results = self.on_results(results) + # Adjust cursors based on the grouped results for pagination + next_cursor = Cursor( + limit, + page + 1, + False, + queryset.filter(row_number__gte=stop).exists(), + ) + prev_cursor = Cursor( + limit, + page - 1, + True, + page > 0, + ) + # Count the queryset count = queryset.count() - max_hits = math.ceil(count / limit) + # Optionally, calculate the total count and max_hits if needed + # This might require adjustments based on specific use cases + if results: + max_hits = math.ceil( + queryset.values(self.group_by_field_name) + .annotate( + count=Count( + "id", + filter=self.count_filter, + distinct=True, + ) + ) + .order_by("-count")[0]["count"] + / limit + ) + else: + max_hits = 0 return CursorResult( results=results, next=next_cursor, @@ -138,6 +304,393 @@ class OffsetPaginator: max_hits=max_hits, ) + def __get_total_queryset(self): + # Get total queryset + return ( + self.queryset.values(self.group_by_field_name) + .annotate( + count=Count( + "id", + filter=self.count_filter, + distinct=True, + ) + ) + .order_by() + ) + + def __get_total_dict(self): + # Convert the total into dictionary of keys as group name and value as the total + total_group_dict = {} + for group in self.__get_total_queryset(): + total_group_dict[str(group.get(self.group_by_field_name))] = ( + total_group_dict.get( + str(group.get(self.group_by_field_name)), 0 + ) + + (1 if group.get("count") == 0 else group.get("count")) + ) + + return total_group_dict + + def __get_field_dict(self): + # Create a field dictionary + total_group_dict = self.__get_total_dict() + return { + str(field): { + "results": [], + "total_results": total_group_dict.get(str(field), 0), + } + for field in self.group_by_fields + } + + def __result_already_added(self, result, group): + # Check if the result is already added then add it + for existing_issue in group: + if existing_issue["id"] == result["id"]: + return True + return False + + def __query_multi_grouper(self, results): + # Grouping for m2m values + total_group_dict = self.__get_total_dict() + + # Preparing a dict to keep track of group IDs associated with each label ID + result_group_mapping = defaultdict(set) + # Preparing a dict to group result by group ID + grouped_by_field_name = defaultdict(list) + + # Iterate over results to fill the above dictionaries + for result in results: + result_id = result["id"] + group_id = result[self.group_by_field_name] + result_group_mapping[str(result_id)].add(str(group_id)) + + # Adding group_ids key to each issue and grouping by group_name + for result in results: + result_id = result["id"] + group_ids = list(result_group_mapping[str(result_id)]) + result[self.FIELD_MAPPER.get(self.group_by_field_name)] = ( + [] if "None" in group_ids else group_ids + ) + # If a result belongs to multiple groups, add it to each group + for group_id in group_ids: + if not self.__result_already_added( + result, grouped_by_field_name[group_id] + ): + grouped_by_field_name[group_id].append(result) + + # Convert grouped_by_field_name back to a list for each group + processed_results = { + str(group_id): { + "results": issues, + "total_results": total_group_dict.get(str(group_id)), + } + for group_id, issues in grouped_by_field_name.items() + } + + return processed_results + + def __query_grouper(self, results): + # Grouping for single values + processed_results = self.__get_field_dict() + for result in results: + ( + print(result["created_at"].date(), result["priority"]) + if str(result[self.group_by_field_name]) + == "c88dfd3b-e97e-4948-851b-a5fe1e36ffd0" + else None + ) + group_value = str(result.get(self.group_by_field_name)) + if group_value in processed_results: + processed_results[str(group_value)]["results"].append(result) + + return processed_results + + def process_results(self, results): + # Process results + if results: + if self.group_by_field_name in self.FIELD_MAPPER: + processed_results = self.__query_multi_grouper(results=results) + else: + processed_results = self.__query_grouper(results=results) + else: + processed_results = {} + return processed_results + + +class SubGroupedOffsetPaginator(OffsetPaginator): + FIELD_MAPPER = { + "labels__id": "label_ids", + "assignees__id": "assignee_ids", + "modules__id": "module_ids", + } + + def __init__( + self, + queryset, + group_by_field_name, + sub_group_by_field_name, + group_by_fields, + sub_group_by_fields, + count_filter, + *args, + **kwargs, + ): + super().__init__(queryset, *args, **kwargs) + self.group_by_field_name = group_by_field_name + self.group_by_fields = group_by_fields + self.sub_group_by_field_name = sub_group_by_field_name + self.sub_group_by_fields = sub_group_by_fields + self.count_filter = count_filter + + def get_result(self, limit=30, cursor=None): + # offset is page # + # value is page limit + if cursor is None: + cursor = Cursor(0, 0, 0) + + limit = min(limit, self.max_limit) + + # Adjust the initial offset and stop based on the cursor and limit + queryset = self.queryset + + page = cursor.offset + offset = cursor.offset * cursor.value + stop = offset + (cursor.value or limit) + 1 + + if self.max_offset is not None and offset >= self.max_offset: + raise BadPaginationError("Pagination offset too large") + if offset < 0: + raise BadPaginationError("Pagination offset cannot be negative") + + # Compute the results + results = {} + + # Create windows for group and sub group field name + queryset = queryset.annotate( + row_number=Window( + expression=RowNumber(), + partition_by=[ + F(self.group_by_field_name), + F(self.sub_group_by_field_name), + ], + order_by=( + ( + F(*self.key).desc(nulls_last=True) + if self.desc + else F(*self.key).asc(nulls_last=True) + ), + "-created_at", + ), + ) + ) + + # Filter the results + results = queryset.filter( + row_number__gt=offset, row_number__lt=stop + ).order_by( + ( + F(*self.key).desc(nulls_last=True) + if self.desc + else F(*self.key).asc(nulls_last=True) + ), + F("created_at").desc(), + ) + + # Adjust cursors based on the grouped results for pagination + next_cursor = Cursor( + limit, + page + 1, + False, + queryset.filter(row_number__gte=stop).exists(), + ) + prev_cursor = Cursor( + limit, + page - 1, + True, + page > 0, + ) + + # Count the queryset + count = queryset.count() + + # Optionally, calculate the total count and max_hits if needed + # This might require adjustments based on specific use cases + if results: + max_hits = math.ceil( + queryset.values(self.group_by_field_name) + .annotate( + count=Count( + "id", + filter=self.count_filter, + distinct=True, + ) + ) + .order_by("-count")[0]["count"] + / limit + ) + else: + max_hits = 0 + return CursorResult( + results=results, + next=next_cursor, + prev=prev_cursor, + hits=count, + max_hits=max_hits, + ) + + def __get_group_total_queryset(self): + # Get group totals + return ( + self.queryset.order_by(self.group_by_field_name) + .values(self.group_by_field_name) + .annotate( + count=Count( + "id", + filter=self.count_filter, + distinct=True, + ) + ) + .distinct() + ) + + def __get_subgroup_total_queryset(self): + # Get subgroup totals + return ( + self.queryset.values( + self.group_by_field_name, self.sub_group_by_field_name + ) + .annotate( + count=Count("id", filter=self.count_filter, distinct=True) + ) + .order_by() + .values( + self.group_by_field_name, self.sub_group_by_field_name, "count" + ) + ) + + def __get_total_dict(self): + # Use the above to convert to dictionary of 2D objects + total_group_dict = {} + total_sub_group_dict = {} + for group in self.__get_group_total_queryset(): + total_group_dict[str(group.get(self.group_by_field_name))] = ( + total_group_dict.get( + str(group.get(self.group_by_field_name)), 0 + ) + + (1 if group.get("count") == 0 else group.get("count")) + ) + + # Sub group total values + for item in self.__get_subgroup_total_queryset(): + group = str(item[self.group_by_field_name]) + subgroup = str(item[self.sub_group_by_field_name]) + count = item["count"] + + if group not in total_sub_group_dict: + total_sub_group_dict[str(group)] = {} + + if subgroup not in total_sub_group_dict[group]: + total_sub_group_dict[str(group)][str(subgroup)] = {} + + total_sub_group_dict[group][subgroup] = count + + return total_group_dict, total_sub_group_dict + + def __get_field_dict(self): + total_group_dict, total_sub_group_dict = self.__get_total_dict() + + return { + str(group): { + "results": { + str(sub_group): { + "results": [], + "total_results": total_sub_group_dict.get( + str(group) + ).get(str(sub_group), 0), + } + for sub_group in total_sub_group_dict.get(str(group), []) + }, + "total_results": total_group_dict.get(str(group), 0), + } + for group in self.group_by_fields + } + + def __query_multi_grouper(self, results): + # Multi grouper + processed_results = self.__get_field_dict() + # Preparing a dict to keep track of group IDs associated with each label ID + result_group_mapping = defaultdict(set) + result_sub_group_mapping = defaultdict(set) + + # Iterate over results to fill the above dictionaries + if self.group_by_field_name in self.FIELD_MAPPER: + for result in results: + result_id = result["id"] + group_id = result[self.group_by_field_name] + result_group_mapping[str(result_id)].add(str(group_id)) + + # Use the same calculation for the sub group + if self.sub_group_by_field_name in self.FIELD_MAPPER: + for result in results: + result_id = result["id"] + sub_group_id = result[self.sub_group_by_field_name] + result_sub_group_mapping[str(result_id)].add(str(sub_group_id)) + + # Iterate over results + for result in results: + # Get the group value + group_value = str(result.get(self.group_by_field_name)) + # Get the sub group value + sub_group_value = str(result.get(self.sub_group_by_field_name)) + if ( + group_value in processed_results + and sub_group_value + in processed_results[str(group_value)]["results"] + ): + if self.group_by_field_name in self.FIELD_MAPPER: + # for multi grouper + group_ids = list(result_group_mapping[str(result_id)]) + result[self.FIELD_MAPPER.get(self.group_by_field_name)] = ( + [] if "None" in group_ids else group_ids + ) + if self.sub_group_by_field_name in self.FIELD_MAPPER: + sub_group_ids = list(result_group_mapping[str(result_id)]) + # for multi groups + result[self.FIELD_MAPPER.get(self.group_by_field_name)] = ( + [] if "None" in sub_group_ids else sub_group_ids + ) + + processed_results[str(group_value)]["results"][ + str(sub_group_value) + ]["results"].append(result) + + return processed_results + + def __query_grouper(self, results): + # Single grouper + processed_results = self.__get_field_dict() + for result in results: + group_value = str(result.get(self.group_by_field_name)) + sub_group_value = str(result.get(self.sub_group_by_field_name)) + processed_results[group_value]["results"][sub_group_value][ + "results" + ].append(result) + + return processed_results + + def process_results(self, results): + if results: + if ( + self.group_by_field_name in self.FIELD_MAPPER + or self.sub_group_by_field_name in self.FIELD_MAPPER + ): + processed_results = self.__query_multi_grouper(results=results) + else: + processed_results = self.__query_grouper(results=results) + else: + processed_results = {} + return processed_results + class BasePaginator: """BasePaginator class can be inherited by any View to return a paginated view""" @@ -171,6 +724,11 @@ class BasePaginator: cursor_cls=Cursor, extra_stats=None, controller=None, + group_by_field_name=None, + group_by_fields=None, + sub_group_by_field_name=None, + sub_group_by_fields=None, + count_filter=None, **paginator_kwargs, ): """Paginate the request""" @@ -178,15 +736,27 @@ class BasePaginator: # Convert the cursor value to integer and float from string input_cursor = None - if request.GET.get(self.cursor_name): - try: - input_cursor = cursor_cls.from_string( - request.GET.get(self.cursor_name) - ) - except ValueError: - raise ParseError(detail="Invalid cursor parameter.") + try: + input_cursor = cursor_cls.from_string( + request.GET.get(self.cursor_name, f"{per_page}:0:0"), + ) + except ValueError: + raise ParseError(detail="Invalid cursor parameter.") if not paginator: + if group_by_field_name: + paginator_kwargs["group_by_field_name"] = group_by_field_name + paginator_kwargs["group_by_fields"] = group_by_fields + paginator_kwargs["count_filter"] = count_filter + + if sub_group_by_field_name: + paginator_kwargs["sub_group_by_field_name"] = ( + sub_group_by_field_name + ) + paginator_kwargs["sub_group_by_fields"] = ( + sub_group_by_fields + ) + paginator = paginator_cls(**paginator_kwargs) try: @@ -196,12 +766,14 @@ class BasePaginator: except BadPaginationError: raise ParseError(detail="Error in parsing") - # Serialize result according to the on_result function if on_results: results = on_results(cursor_result.results) else: results = cursor_result.results + if group_by_field_name: + results = paginator.process_results(results=results) + # Add Manipulation functions to the response if controller is not None: results = controller(results) @@ -211,6 +783,9 @@ class BasePaginator: # Return the response response = Response( { + "grouped_by": group_by_field_name, + "sub_grouped_by": sub_group_by_field_name, + "total_count": (cursor_result.hits), "next_cursor": str(cursor_result.next), "prev_cursor": str(cursor_result.prev), "next_page_results": cursor_result.next.has_results, diff --git a/apiserver/requirements/base.txt b/apiserver/requirements/base.txt index a6bd2ab50..028451874 100644 --- a/apiserver/requirements/base.txt +++ b/apiserver/requirements/base.txt @@ -60,4 +60,5 @@ zxcvbn==4.4.28 # timezone pytz==2024.1 # jwt -PyJWT==2.8.0 \ No newline at end of file +PyJWT==2.8.0 + diff --git a/packages/types/src/issues/base.d.ts b/packages/types/src/issues/base.d.ts index ae210d3b1..1ad8530cd 100644 --- a/packages/types/src/issues/base.d.ts +++ b/packages/types/src/issues/base.d.ts @@ -1,3 +1,6 @@ +import { StateGroup } from "components/states"; +import { TIssuePriorities } from "../issues"; + // issues export * from "./issue"; export * from "./issue_reaction"; @@ -7,16 +10,30 @@ export * from "./issue_relation"; export * from "./issue_sub_issues"; export * from "./activity/base"; -export type TLoader = "init-loader" | "mutation" | undefined; +export type TLoader = "init-loader" | "mutation" | "pagination" | undefined; export type TGroupedIssues = { [group_id: string]: string[]; }; export type TSubGroupedIssues = { - [sub_grouped_id: string]: { - [group_id: string]: string[]; - }; + [sub_grouped_id: string]: TGroupedIssues; }; -export type TUnGroupedIssues = string[]; +export type TIssues = TGroupedIssues | TSubGroupedIssues; + +export type TPaginationData = { + nextCursor: string; + prevCursor: string; + nextPageResults: boolean; +}; + +export type TIssuePaginationData = { + [group_id: string]: TPaginationData; +}; + +export type TGroupedIssueCount = { + [group_id: string]: number; +}; + +export type TUnGroupedIssues = string[]; \ No newline at end of file diff --git a/packages/types/src/issues/issue.d.ts b/packages/types/src/issues/issue.d.ts index 990b308e7..d86ab24d2 100644 --- a/packages/types/src/issues/issue.d.ts +++ b/packages/types/src/issues/issue.d.ts @@ -4,15 +4,15 @@ import { TIssueLink } from "./issue_link"; import { TIssueReaction } from "./issue_reaction"; // new issue structure types -export type TIssue = { + +export type TBaseIssue = { id: string; sequence_id: number; name: string; - description_html: string; sort_order: number; - state_id: string; - priority: TIssuePriorities; + state_id: string | null; + priority: TIssuePriorities | null; label_ids: string[]; assignee_ids: string[]; estimate_point: string | null; @@ -21,7 +21,7 @@ export type TIssue = { attachment_count: number; link_count: number; - project_id: string; + project_id: string | null; parent_id: string | null; cycle_id: string | null; module_ids: string[] | null; @@ -37,9 +37,14 @@ export type TIssue = { updated_by: string; is_draft: boolean; +}; + +export type TIssue = TBaseIssue & { + description_html?: string; is_subscribed?: boolean; parent?: partial; + issue_reactions?: TIssueReaction[]; issue_attachment?: TIssueAttachment[]; issue_link?: TIssueLink[]; @@ -51,3 +56,47 @@ export type TIssue = { export type TIssueMap = { [issue_id: string]: TIssue; }; + +type TIssueResponseResults = + | TBaseIssue[] + | { + [key: string]: { + results: + | TBaseIssue[] + | { + [key: string]: { + results: TBaseIssue[]; + total_results: number; + }; + }; + total_results: number; + }; + }; + +export type TIssuesResponse = { + grouped_by: string; + next_cursor: string; + prev_cursor: string; + next_page_results: boolean; + prev_page_results: boolean; + total_count: number; + count: number; + total_pages: number; + extra_stats: null; + results: TIssueResponseResults; +} + +export type TBulkIssueProperties = Pick< + TIssue, + | "state_id" + | "priority" + | "label_ids" + | "assignee_ids" + | "start_date" + | "target_date" +>; + +export type TBulkOperationsPayload = { + issue_ids: string[]; + properties: Partial; +}; diff --git a/packages/types/src/users.d.ts b/packages/types/src/users.d.ts index 3adec55d1..f167bef48 100644 --- a/packages/types/src/users.d.ts +++ b/packages/types/src/users.d.ts @@ -186,6 +186,8 @@ export interface IUserEmailNotificationSettings { issue_completed: boolean; } +export type TProfileViews = "assigned" | "created" | "subscribed"; + // export interface ICurrentUser { // id: readonly string; // avatar: string; diff --git a/packages/types/src/view-props.d.ts b/packages/types/src/view-props.d.ts index c2c98def3..82302dda1 100644 --- a/packages/types/src/view-props.d.ts +++ b/packages/types/src/view-props.d.ts @@ -1,3 +1,5 @@ +import { EIssueLayoutTypes } from "constants/issue"; + export type TIssueLayouts = | "list" | "kanban" @@ -13,9 +15,9 @@ export type TIssueGroupByOptions = | "state_detail.group" | "project" | "assignees" - | "mentions" | "cycle" | "module" + | "target_date" | null; export type TIssueOrderByOptions = @@ -32,10 +34,10 @@ export type TIssueOrderByOptions = | "-assignees__first_name" | "labels__name" | "-labels__name" - | "modules__name" - | "-modules__name" - | "cycle__name" - | "-cycle__name" + | "issue_module__module__name" + | "-issue_module__module__name" + | "issue_cycle__cycle__name" + | "-issue_cycle__cycle__name" | "target_date" | "-target_date" | "estimate_point" @@ -72,7 +74,9 @@ export type TIssueParams = | "order_by" | "type" | "sub_issue" - | "show_empty_groups"; + | "show_empty_groups" + | "cursor" + | "per_page"; export type TCalendarLayouts = "month" | "week"; @@ -82,9 +86,9 @@ export interface IIssueFilterOptions { created_by?: string[] | null; labels?: string[] | null; priority?: string[] | null; - project?: string[] | null; cycle?: string[] | null; module?: string[] | null; + project?: string[] | null; start_date?: string[] | null; state?: string[] | null; state_group?: string[] | null; @@ -99,7 +103,7 @@ export interface IIssueDisplayFilterOptions { }; group_by?: TIssueGroupByOptions; sub_group_by?: TIssueGroupByOptions; - layout?: TIssueLayouts; + layout?: EIssueLayoutTypes; order_by?: TIssueOrderByOptions; show_empty_groups?: boolean; sub_issue?: boolean; @@ -191,3 +195,11 @@ export interface IWorkspaceGlobalViewProps { display_filters: IWorkspaceIssueDisplayFilterOptions | undefined; display_properties: IIssueDisplayProperties; } + +export interface IssuePaginationOptions { + canGroup: boolean; + perPageCount: number; + before?: string; + after?: string; + groupedBy?: TIssueGroupByOptions; +} diff --git a/packages/ui/src/icons/priority-icon.tsx b/packages/ui/src/icons/priority-icon.tsx index 031b769f1..ffa74a374 100644 --- a/packages/ui/src/icons/priority-icon.tsx +++ b/packages/ui/src/icons/priority-icon.tsx @@ -7,7 +7,7 @@ type TIssuePriorities = "urgent" | "high" | "medium" | "low" | "none"; interface IPriorityIcon { className?: string; containerClassName?: string; - priority: TIssuePriorities; + priority: TIssuePriorities | undefined | null; size?: number; withContainer?: boolean; } @@ -31,7 +31,7 @@ export const PriorityIcon: React.FC = (props) => { low: SignalLow, none: Ban, }; - const Icon = icons[priority]; + const Icon = icons[priority ?? "none"]; if (!Icon) return null; @@ -41,7 +41,7 @@ export const PriorityIcon: React.FC = (props) => {
diff --git a/packages/ui/src/sortable/sortable.stories.tsx b/packages/ui/src/sortable/sortable.stories.tsx index 0428b1198..b701af95d 100644 --- a/packages/ui/src/sortable/sortable.stories.tsx +++ b/packages/ui/src/sortable/sortable.stories.tsx @@ -12,7 +12,7 @@ type Story = StoryObj; const data = [ { id: "1", name: "John Doe" }, - { id: "2", name: "Jane Doe 2" }, + { id: "2", name: "Satish" }, { id: "3", name: "Alice" }, { id: "4", name: "Bob" }, { id: "5", name: "Charlie" }, diff --git a/space/types/project.d.ts b/space/types/project.d.ts index 90c89ed80..c0ae02583 100644 --- a/space/types/project.d.ts +++ b/space/types/project.d.ts @@ -1,11 +1,5 @@ import { TLogoProps } from "@plane/types"; -export type TWorkspaceDetails = { - name: string; - slug: string; - id: string; -}; - export type TViewDetails = { list: boolean; gantt: boolean; @@ -22,21 +16,3 @@ export type TProjectDetails = { logo_props: TLogoProps; description: string; }; - -export type TProjectSettings = { - id: string; - anchor: string; - comments: boolean; - reactions: boolean; - votes: boolean; - inbox: unknown; - workspace: string; - workspace_detail: TWorkspaceDetails; - project: string; - project_details: TProjectDetails; - views: TViewDetails; - created_by: string; - updated_by: string; - created_at: string; - updated_at: string; -}; diff --git a/space/types/publish.d.ts b/space/types/publish.d.ts new file mode 100644 index 000000000..482cbafec --- /dev/null +++ b/space/types/publish.d.ts @@ -0,0 +1,24 @@ +import { IWorkspaceLite } from "@plane/types"; +import { TProjectDetails, TViewDetails } from "@/types/project"; + +export type TPublishEntityType = "project"; + +export type TPublishSettings = { + anchor: string | undefined; + is_comments_enabled: boolean; + created_at: string | undefined; + created_by: string | undefined; + entity_identifier: string | undefined; + entity_name: TPublishEntityType | undefined; + id: string | undefined; + inbox: unknown; + project: string | undefined; + project_details: TProjectDetails | undefined; + is_reactions_enabled: boolean; + updated_at: string | undefined; + updated_by: string | undefined; + view_props: TViewDetails | undefined; + is_votes_enabled: boolean; + workspace: string | undefined; + workspace_detail: IWorkspaceLite | undefined; +}; diff --git a/web/app/[workspaceSlug]/@header/profile/[userId]/[profileViewId]/page.tsx b/web/app/[workspaceSlug]/@header/profile/[userId]/[profileViewId]/page.tsx new file mode 100644 index 000000000..d04a0a661 --- /dev/null +++ b/web/app/[workspaceSlug]/@header/profile/[userId]/[profileViewId]/page.tsx @@ -0,0 +1,20 @@ +"use client"; + +import { useParams } from "next/navigation"; +// components +import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper"; +import UserProfileHeader from "../header"; +import ProfileIssuesMobileHeader from "../mobile-header"; + +const ProfileHeader = () => { + const { profileViewId } = useParams(); + + return ( + } + mobileHeader={} + /> + ); +}; + +export default ProfileHeader; diff --git a/web/app/[workspaceSlug]/@header/profile/[userId]/assigned/page.tsx b/web/app/[workspaceSlug]/@header/profile/[userId]/assigned/page.tsx deleted file mode 100644 index a6253fca3..000000000 --- a/web/app/[workspaceSlug]/@header/profile/[userId]/assigned/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -// components -import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper"; -import UserProfileHeader from "../header"; -import ProfileIssuesMobileHeader from "../mobile-header"; - -const ProfileAssignedHeader = () => ( - } mobileHeader={} /> -); - -export default ProfileAssignedHeader; diff --git a/web/app/[workspaceSlug]/@header/profile/[userId]/created/page.tsx b/web/app/[workspaceSlug]/@header/profile/[userId]/created/page.tsx deleted file mode 100644 index e46ef7b25..000000000 --- a/web/app/[workspaceSlug]/@header/profile/[userId]/created/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -// components -import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper"; -import UserProfileHeader from "../header"; -import ProfileIssuesMobileHeader from "../mobile-header"; - -const ProfileCreatedHeader = () => ( - } mobileHeader={} /> -); - -export default ProfileCreatedHeader; diff --git a/web/app/[workspaceSlug]/@header/profile/[userId]/mobile-header.tsx b/web/app/[workspaceSlug]/@header/profile/[userId]/mobile-header.tsx index 99f6e0c9c..1bbd54cd2 100644 --- a/web/app/[workspaceSlug]/@header/profile/[userId]/mobile-header.tsx +++ b/web/app/[workspaceSlug]/@header/profile/[userId]/mobile-header.tsx @@ -12,7 +12,13 @@ import { CustomMenu } from "@plane/ui"; // components import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; +import { + EIssueFilterType, + EIssueLayoutTypes, + EIssuesStoreType, + ISSUE_DISPLAY_FILTERS_BY_LAYOUT, + ISSUE_LAYOUTS, +} from "@/constants/issue"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks @@ -42,7 +48,7 @@ const ProfileIssuesMobileHeader = observer(() => { workspaceSlug.toString(), undefined, EIssueFilterType.DISPLAY_FILTERS, - { layout: layout }, + { layout: layout as EIssueLayoutTypes | undefined }, userId.toString() ); }, diff --git a/web/app/[workspaceSlug]/@header/profile/[userId]/subscribed/page.tsx b/web/app/[workspaceSlug]/@header/profile/[userId]/subscribed/page.tsx deleted file mode 100644 index 75caa98c9..000000000 --- a/web/app/[workspaceSlug]/@header/profile/[userId]/subscribed/page.tsx +++ /dev/null @@ -1,12 +0,0 @@ -"use client"; - -// components -import AppHeaderWrapper from "@/app/[workspaceSlug]/app-header-wrapper"; -import UserProfileHeader from "../header"; -import ProfileIssuesMobileHeader from "../mobile-header"; - -const ProfileSubscribedHeader = () => ( - } mobileHeader={} /> -); - -export default ProfileSubscribedHeader; diff --git a/web/app/[workspaceSlug]/@header/projects/[projectId]/cycles/[cycleId]/header.tsx b/web/app/[workspaceSlug]/@header/projects/[projectId]/cycles/[cycleId]/header.tsx index a71cf5b63..01a16ed9e 100644 --- a/web/app/[workspaceSlug]/@header/projects/[projectId]/cycles/[cycleId]/header.tsx +++ b/web/app/[workspaceSlug]/@header/projects/[projectId]/cycles/[cycleId]/header.tsx @@ -7,7 +7,7 @@ import { useParams, useRouter } from "next/navigation"; // icons import { ArrowRight, PanelRight } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, Button, ContrastIcon, CustomMenu, Tooltip } from "@plane/ui"; // components @@ -15,7 +15,7 @@ import { ProjectAnalyticsModal } from "@/components/analytics"; import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // helpers import { cn } from "@/helpers/common.helper"; @@ -70,7 +70,7 @@ const CycleIssuesHeader: React.FC = observer(() => { // store hooks const { issuesFilter: { issueFilters, updateFilters }, - issues: { issuesCount }, + issues: { getGroupIssueCount }, } = useIssues(EIssuesStoreType.CYCLE); const { currentProjectCycleIds, getCycleById } = useCycle(); const { toggleCreateIssueModal } = useCommandPalette(); @@ -96,7 +96,7 @@ const CycleIssuesHeader: React.FC = observer(() => { }; const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId) return; updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, cycleId); }, @@ -147,6 +147,7 @@ const CycleIssuesHeader: React.FC = observer(() => { currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; + const issuesCount = getGroupIssueCount(undefined, undefined, false); return ( <> @@ -231,7 +232,13 @@ const CycleIssuesHeader: React.FC = observer(() => {
handleLayoutChange(layout)} selectedLayout={activeLayout} /> diff --git a/web/app/[workspaceSlug]/@header/projects/[projectId]/cycles/[cycleId]/mobile-header.tsx b/web/app/[workspaceSlug]/@header/projects/[projectId]/cycles/[cycleId]/mobile-header.tsx index aba05f763..e496a6a27 100644 --- a/web/app/[workspaceSlug]/@header/projects/[projectId]/cycles/[cycleId]/mobile-header.tsx +++ b/web/app/[workspaceSlug]/@header/projects/[projectId]/cycles/[cycleId]/mobile-header.tsx @@ -5,14 +5,14 @@ import { useParams } from "next/navigation"; // icons import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { CustomMenu } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; +import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks @@ -37,7 +37,7 @@ const CycleIssuesMobileHeader = () => { const activeLayout = issueFilters?.displayFilters?.layout; const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId || !cycleId) return; updateFilters( workspaceSlug.toString(), diff --git a/web/app/[workspaceSlug]/@header/projects/[projectId]/draft-issues/header.tsx b/web/app/[workspaceSlug]/@header/projects/[projectId]/draft-issues/header.tsx index e115fdfef..749a3a949 100644 --- a/web/app/[workspaceSlug]/@header/projects/[projectId]/draft-issues/header.tsx +++ b/web/app/[workspaceSlug]/@header/projects/[projectId]/draft-issues/header.tsx @@ -4,14 +4,14 @@ import { FC, useCallback } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, LayersIcon, Tooltip } from "@plane/ui"; // components import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { EIssueFilterType, EIssuesStoreType, EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks @@ -56,7 +56,7 @@ const ProjectDraftIssueHeader: FC = observer(() => { ); const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId) return; updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); }, @@ -131,7 +131,7 @@ const ProjectDraftIssueHeader: FC = observer(() => {
handleLayoutChange(layout)} selectedLayout={activeLayout} /> diff --git a/web/app/[workspaceSlug]/@header/projects/[projectId]/issues/header.tsx b/web/app/[workspaceSlug]/@header/projects/[projectId]/issues/header.tsx index dbbdbbc5e..dcf9d4c22 100644 --- a/web/app/[workspaceSlug]/@header/projects/[projectId]/issues/header.tsx +++ b/web/app/[workspaceSlug]/@header/projects/[projectId]/issues/header.tsx @@ -6,7 +6,7 @@ import { useParams, useRouter } from "next/navigation"; // icons import { Briefcase, Circle, ExternalLink } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, Button, LayersIcon, Tooltip } from "@plane/ui"; // components @@ -14,7 +14,7 @@ import { ProjectAnalyticsModal } from "@/components/analytics"; import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { EIssueFilterType, EIssuesStoreType, EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // helpers import { SPACE_BASE_URL } from "@/helpers/common.helper"; @@ -44,7 +44,7 @@ const ProjectIssuesHeader: React.FC = observer(() => { } = useMember(); const { issuesFilter: { issueFilters, updateFilters }, - issues: { issuesCount }, + issues: { getGroupIssueCount }, } = useIssues(EIssuesStoreType.PROJECT); const { toggleCreateIssueModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); @@ -79,7 +79,7 @@ const ProjectIssuesHeader: React.FC = observer(() => { ); const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId) return; updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); }, @@ -108,6 +108,7 @@ const ProjectIssuesHeader: React.FC = observer(() => { currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; + const issuesCount = getGroupIssueCount(undefined, undefined, false); return ( <> @@ -176,7 +177,12 @@ const ProjectIssuesHeader: React.FC = observer(() => {
handleLayoutChange(layout)} selectedLayout={activeLayout} /> diff --git a/web/app/[workspaceSlug]/@header/projects/[projectId]/issues/mobile-header.tsx b/web/app/[workspaceSlug]/@header/projects/[projectId]/issues/mobile-header.tsx index 514910cbc..b602091d4 100644 --- a/web/app/[workspaceSlug]/@header/projects/[projectId]/issues/mobile-header.tsx +++ b/web/app/[workspaceSlug]/@header/projects/[projectId]/issues/mobile-header.tsx @@ -6,14 +6,14 @@ import { useParams } from "next/navigation"; // icons import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { CustomMenu } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues/issue-layouts"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; +import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks @@ -44,7 +44,7 @@ const ProjectIssuesMobileHeader = observer(() => { const activeLayout = issueFilters?.displayFilters?.layout; const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId) return; updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); }, diff --git a/web/app/[workspaceSlug]/@header/projects/[projectId]/modules/[moduleId]/header.tsx b/web/app/[workspaceSlug]/@header/projects/[projectId]/modules/[moduleId]/header.tsx index 146cb1ba2..990711d43 100644 --- a/web/app/[workspaceSlug]/@header/projects/[projectId]/modules/[moduleId]/header.tsx +++ b/web/app/[workspaceSlug]/@header/projects/[projectId]/modules/[moduleId]/header.tsx @@ -7,7 +7,7 @@ import { useParams, useRouter } from "next/navigation"; // icons import { ArrowRight, PanelRight } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, Button, CustomMenu, DiceIcon, Tooltip } from "@plane/ui"; // components @@ -15,7 +15,7 @@ import { ProjectAnalyticsModal } from "@/components/analytics"; import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // constants -import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { EIssuesStoreType, EIssueFilterType, EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // helpers import { cn } from "@/helpers/common.helper"; @@ -71,7 +71,7 @@ const ModuleIssuesHeader: React.FC = observer(() => { // store hooks const { issuesFilter: { issueFilters }, - issues: { issuesCount }, + issues: { getGroupIssueCount }, } = useIssues(EIssuesStoreType.MODULE); const { updateFilters } = useIssuesActions(EIssuesStoreType.MODULE); const { projectModuleIds, getModuleById } = useModule(); @@ -97,11 +97,11 @@ const ModuleIssuesHeader: React.FC = observer(() => { const activeLayout = issueFilters?.displayFilters?.layout; const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!projectId) return; updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, { layout: layout }); }, - [projectId, moduleId, updateFilters] + [projectId, updateFilters] ); const handleFiltersUpdate = useCallback( @@ -122,7 +122,7 @@ const ModuleIssuesHeader: React.FC = observer(() => { updateFilters(projectId.toString(), EIssueFilterType.FILTERS, { [key]: newValues }); }, - [projectId, moduleId, issueFilters, updateFilters] + [projectId, issueFilters, updateFilters] ); const handleDisplayFilters = useCallback( @@ -130,7 +130,7 @@ const ModuleIssuesHeader: React.FC = observer(() => { if (!projectId) return; updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_FILTERS, updatedDisplayFilter); }, - [projectId, moduleId, updateFilters] + [projectId, updateFilters] ); const handleDisplayProperties = useCallback( @@ -138,7 +138,7 @@ const ModuleIssuesHeader: React.FC = observer(() => { if (!projectId) return; updateFilters(projectId.toString(), EIssueFilterType.DISPLAY_PROPERTIES, property); }, - [projectId, moduleId, updateFilters] + [projectId, updateFilters] ); // derived values @@ -147,6 +147,7 @@ const ModuleIssuesHeader: React.FC = observer(() => { currentProjectRole && [EUserProjectRoles.ADMIN, EUserProjectRoles.MEMBER].includes(currentProjectRole); const isFiltersApplied = calculateTotalFilters(issueFilters?.filters ?? {}) !== 0; + const issuesCount = getGroupIssueCount(undefined, undefined, false); return ( <> @@ -231,7 +232,13 @@ const ModuleIssuesHeader: React.FC = observer(() => {
handleLayoutChange(layout)} selectedLayout={activeLayout} /> diff --git a/web/app/[workspaceSlug]/@header/projects/[projectId]/modules/[moduleId]/mobile-header.tsx b/web/app/[workspaceSlug]/@header/projects/[projectId]/modules/[moduleId]/mobile-header.tsx index a229914be..f76cbe868 100644 --- a/web/app/[workspaceSlug]/@header/projects/[projectId]/modules/[moduleId]/mobile-header.tsx +++ b/web/app/[workspaceSlug]/@header/projects/[projectId]/modules/[moduleId]/mobile-header.tsx @@ -6,14 +6,14 @@ import { useParams } from "next/navigation"; // icons import { Calendar, ChevronDown, Kanban, List } from "lucide-react"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { CustomMenu } from "@plane/ui"; // components import { ProjectAnalyticsModal } from "@/components/analytics"; import { DisplayFiltersSelection, FilterSelection, FiltersDropdown } from "@/components/issues"; // constants -import { EIssueFilterType, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; +import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, ISSUE_LAYOUTS } from "@/constants/issue"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks @@ -46,7 +46,7 @@ const ModuleIssuesMobileHeader = observer(() => { } = useMember(); const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId) return; updateFilters(workspaceSlug, projectId, EIssueFilterType.DISPLAY_FILTERS, { layout: layout }, moduleId); }, diff --git a/web/app/[workspaceSlug]/@header/projects/[projectId]/views/[viewId]/header.tsx b/web/app/[workspaceSlug]/@header/projects/[projectId]/views/[viewId]/header.tsx index 744f3ea37..18605325b 100644 --- a/web/app/[workspaceSlug]/@header/projects/[projectId]/views/[viewId]/header.tsx +++ b/web/app/[workspaceSlug]/@header/projects/[projectId]/views/[viewId]/header.tsx @@ -4,14 +4,14 @@ import { observer } from "mobx-react"; import Link from "next/link"; import { useParams } from "next/navigation"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // ui import { Breadcrumbs, Button, CustomMenu, PhotoFilterIcon } from "@plane/ui"; // components import { BreadcrumbLink, Logo } from "@/components/common"; import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "@/components/issues"; // constants -import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { EIssuesStoreType, EIssueFilterType, EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; @@ -52,7 +52,7 @@ const ProjectViewIssuesHeader: React.FC = observer(() => { const activeLayout = issueFilters?.displayFilters?.layout; const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !projectId || !viewId) return; updateFilters( workspaceSlug.toString(), @@ -202,7 +202,13 @@ const ProjectViewIssuesHeader: React.FC = observer(() => {
handleLayoutChange(layout)} selectedLayout={activeLayout} /> diff --git a/web/app/[workspaceSlug]/profile/[userId]/[profileViewId]/page.tsx b/web/app/[workspaceSlug]/profile/[userId]/[profileViewId]/page.tsx new file mode 100644 index 000000000..b6c1a22f0 --- /dev/null +++ b/web/app/[workspaceSlug]/profile/[userId]/[profileViewId]/page.tsx @@ -0,0 +1,30 @@ +"use client"; + +import React from "react"; +import { useParams } from "next/navigation"; +// components +import { PageHead } from "@/components/core"; +import { ProfileIssuesPage } from "@/components/profile/profile-issues"; + +const ProfilePageHeader = { + assigned: "Profile - Assigned", + created: "Profile - Created", + subscribed: "Profile - Subscribed", +}; + +const ProfileIssuesTypePage = () => { + const { profileViewId } = useParams() as { profileViewId: "assigned" | "subscribed" | "created" | undefined }; + + if (!profileViewId) return null; + + const header = ProfilePageHeader[profileViewId]; + + return ( + <> + + + + ); +}; + +export default ProfileIssuesTypePage; diff --git a/web/app/[workspaceSlug]/profile/[userId]/assigned/page.tsx b/web/app/[workspaceSlug]/profile/[userId]/assigned/page.tsx deleted file mode 100644 index 0d64e8eec..000000000 --- a/web/app/[workspaceSlug]/profile/[userId]/assigned/page.tsx +++ /dev/null @@ -1,15 +0,0 @@ -"use client"; - -import React from "react"; -// components -import { PageHead } from "@/components/core"; -import { ProfileIssuesPage } from "@/components/profile/profile-issues"; - -const ProfileAssignedIssuesPage = () => ( - <> - - - -); - -export default ProfileAssignedIssuesPage; diff --git a/web/app/[workspaceSlug]/profile/[userId]/created/page.tsx b/web/app/[workspaceSlug]/profile/[userId]/created/page.tsx deleted file mode 100644 index c8dab5e77..000000000 --- a/web/app/[workspaceSlug]/profile/[userId]/created/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -"use client"; - -// store -import { observer } from "mobx-react-lite"; -// components -import { PageHead } from "@/components/core"; -import { ProfileIssuesPage } from "@/components/profile/profile-issues"; - -const ProfileCreatedIssuesPage = () => ( - <> - - - -); - -export default observer(ProfileCreatedIssuesPage); diff --git a/web/app/[workspaceSlug]/profile/[userId]/navbar.tsx b/web/app/[workspaceSlug]/profile/[userId]/navbar.tsx index 25e9744fe..ad77fe8b4 100644 --- a/web/app/[workspaceSlug]/profile/[userId]/navbar.tsx +++ b/web/app/[workspaceSlug]/profile/[userId]/navbar.tsx @@ -17,7 +17,7 @@ export const ProfileNavbar: React.FC = (props) => { const { isAuthorized, showProfileIssuesFilter } = props; const { workspaceSlug, userId } = useParams(); - const pathname = usePathname(); +const pathname = usePathname(); const tabsList = isAuthorized ? [...PROFILE_VIEWER_TAB, ...PROFILE_ADMINS_TAB] : PROFILE_VIEWER_TAB; diff --git a/web/app/[workspaceSlug]/profile/[userId]/subscribed/page.tsx b/web/app/[workspaceSlug]/profile/[userId]/subscribed/page.tsx deleted file mode 100644 index 1fe152298..000000000 --- a/web/app/[workspaceSlug]/profile/[userId]/subscribed/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -"use client"; - -// store -import { observer } from "mobx-react-lite"; -// components -import { PageHead } from "@/components/core"; -import { ProfileIssuesPage } from "@/components/profile/profile-issues"; - -const ProfileSubscribedIssuesPage = () => ( - <> - - - -); - -export default observer(ProfileSubscribedIssuesPage); diff --git a/web/app/[workspaceSlug]/projects/[projectId]/archives/issues/[archivedIssueId]/page.tsx b/web/app/[workspaceSlug]/projects/[projectId]/archives/issues/[archivedIssueId]/page.tsx index a5bf78181..bc578bf5b 100644 --- a/web/app/[workspaceSlug]/projects/[projectId]/archives/issues/[archivedIssueId]/page.tsx +++ b/web/app/[workspaceSlug]/projects/[projectId]/archives/issues/[archivedIssueId]/page.tsx @@ -47,7 +47,7 @@ const ArchivedIssueDetailsPage = observer(() => { // derived values const issue = archivedIssueId ? getIssueById(archivedIssueId.toString()) : undefined; - const project = issue ? getProjectById(issue?.project_id) : undefined; + const project = issue ? getProjectById(issue?.project_id ?? "") : undefined; const pageTitle = project && issue ? `${project?.identifier}-${issue?.sequence_id} ${issue?.name}` : undefined; // auth const canRestoreIssue = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; diff --git a/web/app/[workspaceSlug]/workspace-views/[globalViewId]/page.tsx b/web/app/[workspaceSlug]/workspace-views/[globalViewId]/page.tsx index 5cb5e54fc..bbb6f0329 100644 --- a/web/app/[workspaceSlug]/workspace-views/[globalViewId]/page.tsx +++ b/web/app/[workspaceSlug]/workspace-views/[globalViewId]/page.tsx @@ -1,7 +1,6 @@ "use client"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // components import { PageHead } from "@/components/core"; import { AllIssueLayoutRoot } from "@/components/issues"; @@ -13,7 +12,8 @@ import { useGlobalView, useWorkspace } from "@/hooks/store"; const GlobalViewIssuesPage = observer(() => { // router - const { globalViewId } = useParams(); + //const { globalViewId } = useParams(); + const globalViewId = "assigned"; // store hooks const { currentWorkspace } = useWorkspace(); const { getViewDetailsById } = useGlobalView(); diff --git a/web/components/core/modals/bulk-delete-issues-modal-item.tsx b/web/components/core/modals/bulk-delete-issues-modal-item.tsx index 5d845e00d..994732464 100644 --- a/web/components/core/modals/bulk-delete-issues-modal-item.tsx +++ b/web/components/core/modals/bulk-delete-issues-modal-item.tsx @@ -1,13 +1,18 @@ import { observer } from "mobx-react"; import { Combobox } from "@headlessui/react"; // hooks -import { useProjectState } from "@/hooks/store"; +import { ISearchIssueResponse } from "@plane/types"; -export const BulkDeleteIssuesModalItem: React.FC = observer((props) => { - const { issue, delete_issue_ids, identifier } = props; - const { getStateById } = useProjectState(); +interface Props { + issue: ISearchIssueResponse; + canDeleteIssueIds: boolean; + identifier: string | undefined; +} - const color = getStateById(issue.state_id)?.color; +export const BulkDeleteIssuesModalItem: React.FC = observer((props: Props) => { + const { issue, canDeleteIssueIds, identifier } = props; + + const color = issue.state__color; return ( = observer((props) => { } >
- + = observer((props) => { const { isOpen, onClose } = props; @@ -47,13 +47,23 @@ export const BulkDeleteIssuesModal: React.FC = observer((props) => { } = useIssues(EIssuesStoreType.PROJECT); // states const [query, setQuery] = useState(""); - // fetching project issues. - const { data: issues } = useSWR( - workspaceSlug && projectId && isOpen ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null, - workspaceSlug && projectId && isOpen - ? () => issueService.getIssues(workspaceSlug as string, projectId as string) - : null - ); + const [issues, setIssues] = useState([]); + const [isSearching, setIsSearching] = useState(false); + + const debouncedSearchTerm: string = useDebounce(query, 500); + + useEffect(() => { + if (!isOpen || !workspaceSlug || !projectId) return; + + setIsSearching(true); + projectService + .projectIssuesSearch(workspaceSlug.toString(), projectId.toString(), { + search: debouncedSearchTerm, + workspace_search: false, + }) + .then((res: ISearchIssueResponse[]) => setIssues(res)) + .finally(() => setIsSearching(false)); + }, [debouncedSearchTerm, isOpen, projectId, workspaceSlug]); const { handleSubmit, @@ -107,14 +117,33 @@ export const BulkDeleteIssuesModal: React.FC = observer((props) => { const projectDetails = getProjectById(projectId as string); - const filteredIssues: TIssue[] = - query === "" - ? Object.values(issues ?? {}) - : Object.values(issues ?? {})?.filter( - (issue) => - issue.name.toLowerCase().includes(query.toLowerCase()) || - `${projectDetails?.identifier}-${issue.sequence_id}`.toLowerCase().includes(query.toLowerCase()) - ) ?? []; + const issueList = + issues.length > 0 ? ( +
  • + {query === "" && ( +

    Select issues to delete

    + )} +
      + {issues.map((issue) => ( + + ))} +
    +
  • + ) : ( +
    + +
    + ); return ( setQuery("")} appear> @@ -160,40 +189,20 @@ export const BulkDeleteIssuesModal: React.FC = observer((props) => { static className="max-h-80 scroll-py-2 divide-y divide-custom-border-200 overflow-y-auto" > - {filteredIssues.length > 0 ? ( -
  • - {query === "" && ( -

    - Select issues to delete -

    - )} -
      - {filteredIssues.map((issue) => ( - - ))} -
    -
  • + {isSearching ? ( + + + + + + ) : ( -
    - -
    + <>{issueList} )} - {filteredIssues.length > 0 && ( + {issues.length > 0 && (
    )} - {isOpen && ( + {isOpen && projectId && ( )} diff --git a/web/components/dropdowns/estimate.tsx b/web/components/dropdowns/estimate.tsx index 99e8106de..04e8b6d8f 100644 --- a/web/components/dropdowns/estimate.tsx +++ b/web/components/dropdowns/estimate.tsx @@ -25,8 +25,8 @@ type Props = TDropdownProps & { dropdownArrowClassName?: string; onChange: (val: string | undefined) => void; onClose?: () => void; - projectId: string; - value: string | undefined; + projectId: string | undefined; + value: string | undefined | null; }; type DropdownOptions = @@ -120,7 +120,7 @@ export const EstimateDropdown: React.FC = observer((props) => { const selectedEstimate = value && estimatePointById ? estimatePointById(value) : undefined; const onOpen = async () => { - if (!currentActiveEstimateId && workspaceSlug) await getProjectEstimates(workspaceSlug, projectId); + if (!currentActiveEstimateId && workspaceSlug && projectId) await getProjectEstimates(workspaceSlug, projectId); }; const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({ diff --git a/web/components/dropdowns/module/index.tsx b/web/components/dropdowns/module/index.tsx index 5261f6c17..d0de19788 100644 --- a/web/components/dropdowns/module/index.tsx +++ b/web/components/dropdowns/module/index.tsx @@ -24,7 +24,7 @@ type Props = TDropdownProps & { button?: ReactNode; dropdownArrow?: boolean; dropdownArrowClassName?: string; - projectId: string; + projectId: string | undefined; showCount?: boolean; onClose?: () => void; } & ( @@ -272,7 +272,7 @@ export const ModuleDropdown: React.FC = observer((props) => { )} - {isOpen && ( + {isOpen && projectId && ( void; onClose?: () => void; - value: TIssuePriorities | undefined; + value: TIssuePriorities | undefined | null; }; type ButtonProps = { @@ -304,7 +304,7 @@ export const PriorityDropdown: React.FC = (props) => { placement, showTooltip = false, tabIndex, - value, + value = "none", } = props; // states const [query, setQuery] = useState(""); @@ -363,8 +363,8 @@ export const PriorityDropdown: React.FC = (props) => { const ButtonToRender = BORDER_BUTTON_VARIANTS.includes(buttonVariant) ? BorderButton : BACKGROUND_BUTTON_VARIANTS.includes(buttonVariant) - ? BackgroundButton - : TransparentButton; + ? BackgroundButton + : TransparentButton; return ( = (props) => { onClick={handleOnClick} > void; onClose?: () => void; - projectId: string; + projectId: string | undefined; showDefaultState?: boolean; - value: string | undefined; + value: string | undefined | null; }; export const StateDropdown: React.FC = observer((props) => { @@ -96,7 +96,7 @@ export const StateDropdown: React.FC = observer((props) => { const selectedState = stateValue ? getStateById(stateValue) : undefined; const onOpen = async () => { - if (!statesList && workspaceSlug) { + if (!statesList && workspaceSlug && projectId) { setStateLoader(true); await fetchProjectStates(workspaceSlug, projectId); setStateLoader(false); diff --git a/web/components/gantt-chart/blocks/block.tsx b/web/components/gantt-chart/blocks/block.tsx index a33a0fd9f..805ea9876 100644 --- a/web/components/gantt-chart/blocks/block.tsx +++ b/web/components/gantt-chart/blocks/block.tsx @@ -10,11 +10,12 @@ import { BLOCK_HEIGHT } from "../constants"; // components import { ChartAddBlock, ChartDraggable } from "../helpers"; import { useGanttChart } from "../hooks"; -// types -import { IBlockUpdateData, IGanttBlock } from "../types"; +import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types"; type Props = { - block: IGanttBlock; + blockId: string; + getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; + showAllBlocks: boolean; blockToRender: (data: any) => React.ReactNode; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; enableBlockLeftResize: boolean; @@ -27,7 +28,9 @@ type Props = { export const GanttChartBlock: React.FC = observer((props) => { const { - block, + blockId, + getBlockById, + showAllBlocks, blockToRender, blockUpdateHandler, enableBlockLeftResize, @@ -38,9 +41,14 @@ export const GanttChartBlock: React.FC = observer((props) => { selectionHelpers, } = props; // store hooks - const { updateActiveBlockId, isBlockActive } = useGanttChart(); + const { currentViewData, updateActiveBlockId, isBlockActive } = useGanttChart(); const { getIsIssuePeeked } = useIssueDetail(); + const block = getBlockById(blockId, currentViewData); + + // hide the block if it doesn't have start and target dates and showAllBlocks is false + if (!block || (!showAllBlocks && !(block.start_date && block.target_date))) return null; + const isBlockVisibleOnChart = block.start_date && block.target_date; const handleChartBlockPosition = ( @@ -73,13 +81,14 @@ export const GanttChartBlock: React.FC = observer((props) => { }); }; + if (!block.data) return null; + const isBlockSelected = selectionHelpers.getIsEntitySelected(block.id); const isBlockFocused = selectionHelpers.getIsEntityActive(block.id); const isBlockHoveredOn = isBlockActive(block.id); return (
    = observer((props) => { "bg-custom-primary-100/10": isBlockSelected && isBlockHoveredOn, "border border-r-0 border-custom-border-400": isBlockFocused, })} - onMouseEnter={() => updateActiveBlockId(block.id)} + onMouseEnter={() => updateActiveBlockId(blockId)} onMouseLeave={() => updateActiveBlockId(null)} > {isBlockVisibleOnChart ? ( diff --git a/web/components/gantt-chart/blocks/blocks-list.tsx b/web/components/gantt-chart/blocks/blocks-list.tsx index 6fd22b254..8c94b07d0 100644 --- a/web/components/gantt-chart/blocks/blocks-list.tsx +++ b/web/components/gantt-chart/blocks/blocks-list.tsx @@ -4,13 +4,14 @@ import { TSelectionHelper } from "@/hooks/use-multiple-select"; // constants import { HEADER_HEIGHT } from "../constants"; // types -import { IBlockUpdateData, IGanttBlock } from "../types"; +import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types"; // components import { GanttChartBlock } from "./block"; export type GanttChartBlocksProps = { itemsContainerWidth: number; - blocks: IGanttBlock[] | null; + blockIds: string[]; + getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; blockToRender: (data: any) => React.ReactNode; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; enableBlockLeftResize: boolean; @@ -25,9 +26,10 @@ export type GanttChartBlocksProps = { export const GanttChartBlocksList: FC = (props) => { const { itemsContainerWidth, - blocks, + blockIds, blockToRender, blockUpdateHandler, + getBlockById, enableBlockLeftResize, enableBlockRightResize, enableBlockMove, @@ -45,25 +47,22 @@ export const GanttChartBlocksList: FC = (props) => { transform: `translateY(${HEADER_HEIGHT}px)`, }} > - {blocks?.map((block) => { - // hide the block if it doesn't have start and target dates and showAllBlocks is false - if (!showAllBlocks && !(block.start_date && block.target_date)) return; - - return ( - - ); - })} + {blockIds?.map((blockId) => ( + + ))}
    ); }; diff --git a/web/components/gantt-chart/chart/header.tsx b/web/components/gantt-chart/chart/header.tsx index fe4d0c885..8756e200f 100644 --- a/web/components/gantt-chart/chart/header.tsx +++ b/web/components/gantt-chart/chart/header.tsx @@ -6,11 +6,11 @@ import { VIEWS_LIST } from "@/components/gantt-chart/data"; import { cn } from "@/helpers/common.helper"; // types import { useGanttChart } from "../hooks/use-gantt-chart"; -import { IGanttBlock, TGanttViews } from "../types"; +import { TGanttViews } from "../types"; // constants type Props = { - blocks: IGanttBlock[] | null; + blockIds: string[]; fullScreenMode: boolean; handleChartView: (view: TGanttViews) => void; handleToday: () => void; @@ -19,14 +19,16 @@ type Props = { }; export const GanttChartHeader: React.FC = observer((props) => { - const { blocks, fullScreenMode, handleChartView, handleToday, loaderTitle, toggleFullScreenMode } = props; + const { blockIds, fullScreenMode, handleChartView, handleToday, loaderTitle, toggleFullScreenMode } = props; // chart hook const { currentView } = useGanttChart(); return (
    -
    {blocks ? `${blocks.length} ${loaderTitle}` : "Loading..."}
    +
    + {blockIds ? `${blockIds.length} ${loaderTitle}` : "Loading..."} +
    diff --git a/web/components/gantt-chart/chart/main-content.tsx b/web/components/gantt-chart/chart/main-content.tsx index fc62f1bbc..f2f421d44 100644 --- a/web/components/gantt-chart/chart/main-content.tsx +++ b/web/components/gantt-chart/chart/main-content.tsx @@ -6,6 +6,7 @@ import { observer } from "mobx-react"; import { MultipleSelectGroup } from "@/components/core"; import { BiWeekChartView, + ChartDataType, DayChartView, GanttChartBlocksList, GanttChartSidebar, @@ -27,17 +28,19 @@ import { GANTT_SELECT_GROUP } from "../constants"; import { useGanttChart } from "../hooks/use-gantt-chart"; type Props = { - blocks: IGanttBlock[] | null; + blockIds: string[]; + getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; + canLoadMoreBlocks?: boolean; + loadMoreBlocks?: () => void; blockToRender: (data: any) => React.ReactNode; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; bottomSpacing: boolean; - chartBlocks: IGanttBlock[] | null; enableBlockLeftResize: boolean; enableBlockMove: boolean; enableBlockRightResize: boolean; enableReorder: boolean; - enableAddBlock: boolean; enableSelection: boolean; + enableAddBlock: boolean; itemsContainerWidth: number; showAllBlocks: boolean; sidebarToRender: (props: any) => React.ReactNode; @@ -48,11 +51,12 @@ type Props = { export const GanttChartMainContent: React.FC = observer((props) => { const { - blocks, + blockIds, + getBlockById, + loadMoreBlocks, blockToRender, blockUpdateHandler, bottomSpacing, - chartBlocks, enableBlockLeftResize, enableBlockMove, enableBlockRightResize, @@ -63,6 +67,7 @@ export const GanttChartMainContent: React.FC = observer((props) => { showAllBlocks, sidebarToRender, title, + canLoadMoreBlocks, updateCurrentViewRenderPayload, quickAdd, } = props; @@ -116,7 +121,7 @@ export const GanttChartMainContent: React.FC = observer((props) => { block.id) ?? [], + [GANTT_SELECT_GROUP]: blockIds ?? [], }} disabled > @@ -135,33 +140,38 @@ export const GanttChartMainContent: React.FC = observer((props) => { onScroll={onScroll} > -
    - - {currentViewData && ( - - )} -
    + blockIds={blockIds} + getBlockById={getBlockById} + loadMoreBlocks={loadMoreBlocks} + canLoadMoreBlocks={canLoadMoreBlocks} + ganttContainerRef={ganttContainerRef} + blockUpdateHandler={blockUpdateHandler} + enableReorder={enableReorder} + enableSelection={enableSelection} + sidebarToRender={sidebarToRender} + title={title} + quickAdd={quickAdd} + selectionHelpers={helpers} + /> +
    + + {currentViewData && ( + + )} +
    diff --git a/web/components/gantt-chart/chart/root.tsx b/web/components/gantt-chart/chart/root.tsx index d961047e9..9eb1b7762 100644 --- a/web/components/gantt-chart/chart/root.tsx +++ b/web/components/gantt-chart/chart/root.tsx @@ -13,17 +13,13 @@ import { currentViewDataWithView } from "../data"; // constants import { useGanttChart } from "../hooks/use-gantt-chart"; import { ChartDataType, IBlockUpdateData, IGanttBlock, TGanttViews } from "../types"; -import { - generateMonthChart, - getNumberOfDaysBetweenTwoDatesInMonth, - getMonthChartItemPositionWidthInMonth, -} from "../views"; +import { generateMonthChart, getNumberOfDaysBetweenTwoDatesInMonth } from "../views"; type ChartViewRootProps = { border: boolean; title: string; loaderTitle: string; - blocks: IGanttBlock[] | null; + blockIds: string[]; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; blockToRender: (data: any) => React.ReactNode; sidebarToRender: (props: any) => React.ReactNode; @@ -35,6 +31,9 @@ type ChartViewRootProps = { enableSelection: boolean; bottomSpacing: boolean; showAllBlocks: boolean; + getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; + loadMoreBlocks?: () => void; + canLoadMoreBlocks?: boolean; quickAdd?: React.JSX.Element | undefined; }; @@ -42,11 +41,14 @@ export const ChartViewRoot: FC = observer((props) => { const { border, title, - blocks = null, + blockIds, + getBlockById, + loadMoreBlocks, loaderTitle, blockUpdateHandler, sidebarToRender, blockToRender, + canLoadMoreBlocks, enableBlockLeftResize, enableBlockRightResize, enableBlockMove, @@ -60,25 +62,10 @@ export const ChartViewRoot: FC = observer((props) => { // states const [itemsContainerWidth, setItemsContainerWidth] = useState(0); const [fullScreenMode, setFullScreenMode] = useState(false); - const [chartBlocks, setChartBlocks] = useState(null); // hooks const { currentView, currentViewData, renderView, updateCurrentView, updateCurrentViewData, updateRenderView } = useGanttChart(); - // rendering the block structure - const renderBlockStructure = (view: ChartDataType, blocks: IGanttBlock[] | null) => - blocks - ? blocks.map((block: IGanttBlock) => ({ - ...block, - position: getMonthChartItemPositionWidthInMonth(view, block), - })) - : []; - - useEffect(() => { - if (!currentViewData || !blocks) return; - setChartBlocks(() => renderBlockStructure(currentViewData, blocks)); - }, [currentViewData, blocks]); - const updateCurrentViewRenderPayload = (side: null | "left" | "right", view: TGanttViews) => { const selectedCurrentView: TGanttViews = view; const selectedCurrentViewData: ChartDataType | undefined = @@ -168,7 +155,7 @@ export const ChartViewRoot: FC = observer((props) => { })} > setFullScreenMode((prevData) => !prevData)} handleChartView={(key) => updateCurrentViewRenderPayload(null, key)} @@ -176,17 +163,19 @@ export const ChartViewRoot: FC = observer((props) => { loaderTitle={loaderTitle} /> void; blockToRender: (data: any) => React.ReactNode; sidebarToRender: (props: any) => React.ReactNode; quickAdd?: React.JSX.Element | undefined; + getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; + canLoadMoreBlocks?: boolean; + loadMoreBlocks?: () => void; enableBlockLeftResize?: boolean; enableBlockRightResize?: boolean; enableBlockMove?: boolean; @@ -27,11 +30,14 @@ export const GanttChartRoot: FC = (props) => { const { border = true, title, - blocks, + blockIds, loaderTitle = "blocks", blockUpdateHandler, sidebarToRender, blockToRender, + getBlockById, + loadMoreBlocks, + canLoadMoreBlocks, enableBlockLeftResize = false, enableBlockRightResize = false, enableBlockMove = false, @@ -48,7 +54,10 @@ export const GanttChartRoot: FC = (props) => { void; - blocks: IGanttBlock[] | null; + getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; + blockIds: string[]; enableReorder: boolean; }; export const CycleGanttSidebar: React.FC = (props) => { - const { blockUpdateHandler, blocks, enableReorder } = props; + const { blockUpdateHandler, blockIds, getBlockById, enableReorder } = props; const handleOnDrop = ( draggingBlockId: string | undefined, droppedBlockId: string | undefined, dropAtEndOfList: boolean ) => { - handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blocks, blockUpdateHandler); + handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blockIds, getBlockById, blockUpdateHandler); }; return (
    - {blocks ? ( - blocks.map((block, index) => ( + {blockIds ? ( + blockIds.map((blockId, index) => { + const block = getBlockById(blockId); + if (!block.start_date || !block.target_date) return null; + return ( @@ -48,7 +52,7 @@ export const CycleGanttSidebar: React.FC = (props) => { /> )} - )) + )}) ) : ( diff --git a/web/components/gantt-chart/sidebar/issues/block.tsx b/web/components/gantt-chart/sidebar/issues/block.tsx index df0fd64fb..8509df010 100644 --- a/web/components/gantt-chart/sidebar/issues/block.tsx +++ b/web/components/gantt-chart/sidebar/issues/block.tsx @@ -33,6 +33,8 @@ export const IssuesSidebarBlock = observer((props: Props) => { const duration = findTotalDaysInRange(block.start_date, block.target_date); + if (!block.data) return null; + const isIssueSelected = selectionHelpers?.getIsEntitySelected(block.id); const isIssueFocused = selectionHelpers?.getIsEntityActive(block.id); const isBlockHoveredOn = isBlockActive(block.id); diff --git a/web/components/gantt-chart/sidebar/issues/sidebar.tsx b/web/components/gantt-chart/sidebar/issues/sidebar.tsx index 82680d4c9..58efd3d1b 100644 --- a/web/components/gantt-chart/sidebar/issues/sidebar.tsx +++ b/web/components/gantt-chart/sidebar/issues/sidebar.tsx @@ -1,11 +1,13 @@ "use client"; -import { MutableRefObject } from "react"; +import { RefObject, MutableRefObject, useState } from "react"; +import { observer } from "mobx-react"; // ui import { Loader } from "@plane/ui"; // components import { IGanttBlock, IBlockUpdateData } from "@/components/gantt-chart/types"; -// hooks +//hooks +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; import { TSelectionHelper } from "@/hooks/use-multiple-select"; import { GanttDnDHOC } from "../gantt-dnd-HOC"; import { handleOrderChange } from "../utils"; @@ -14,54 +16,81 @@ import { IssuesSidebarBlock } from "./block"; type Props = { blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - blocks: IGanttBlock[] | null; + getBlockById: (id: string) => IGanttBlock; + canLoadMoreBlocks?: boolean; + loadMoreBlocks?: () => void; + ganttContainerRef: RefObject; + blockIds: string[]; enableReorder: boolean; enableSelection: boolean; showAllBlocks?: boolean; selectionHelpers?: TSelectionHelper; }; -export const IssueGanttSidebar: React.FC = (props) => { - const { blockUpdateHandler, blocks, enableReorder, enableSelection, showAllBlocks = false, selectionHelpers } = props; +export const IssueGanttSidebar: React.FC = observer((props) => { + const { + blockUpdateHandler, + blockIds, + getBlockById, + enableReorder, + enableSelection, + loadMoreBlocks, + canLoadMoreBlocks, + ganttContainerRef, + showAllBlocks = false, + selectionHelpers + } = props; + + const [intersectionElement, setIntersectionElement] = useState(null); + + useIntersectionObserver(ganttContainerRef, intersectionElement, loadMoreBlocks, "50% 0% 50% 0%"); const handleOnDrop = ( draggingBlockId: string | undefined, droppedBlockId: string | undefined, dropAtEndOfList: boolean ) => { - handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blocks, blockUpdateHandler); + handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blockIds, getBlockById, blockUpdateHandler); }; return (
    - {blocks ? ( - blocks.map((block, index) => { - const isBlockVisibleOnSidebar = block.start_date && block.target_date; + {blockIds ? ( + <> + {blockIds.map((blockId, index) => { + const block = getBlockById(blockId); + const isBlockVisibleOnSidebar = block?.start_date && block?.target_date; - // hide the block if it doesn't have start and target dates and showAllBlocks is false - if (!showAllBlocks && !isBlockVisibleOnSidebar) return; + // hide the block if it doesn't have start and target dates and showAllBlocks is false + if (!block || (!showAllBlocks && !isBlockVisibleOnSidebar)) return; - return ( - - {(isDragging: boolean, dragHandleRef: MutableRefObject) => ( - - )} - - ); - }) + return ( + + {(isDragging: boolean, dragHandleRef: MutableRefObject) => ( + + )} + + ); + })} + {canLoadMoreBlocks && ( +
    +
    +
    + )} + ) : ( @@ -72,4 +101,4 @@ export const IssueGanttSidebar: React.FC = (props) => { )}
    ); -}; +}); diff --git a/web/components/gantt-chart/sidebar/modules/sidebar.tsx b/web/components/gantt-chart/sidebar/modules/sidebar.tsx index 9673338fb..6141aad4f 100644 --- a/web/components/gantt-chart/sidebar/modules/sidebar.tsx +++ b/web/components/gantt-chart/sidebar/modules/sidebar.tsx @@ -4,7 +4,7 @@ import { MutableRefObject } from "react"; // ui import { Loader } from "@plane/ui"; // components -import { IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart"; +import { ChartDataType, IBlockUpdateData, IGanttBlock } from "components/gantt-chart"; import { GanttDnDHOC } from "../gantt-dnd-HOC"; import { handleOrderChange } from "../utils"; import { ModulesSidebarBlock } from "./block"; @@ -13,29 +13,32 @@ import { ModulesSidebarBlock } from "./block"; type Props = { title: string; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; - blocks: IGanttBlock[] | null; + getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; + blockIds: string[]; enableReorder: boolean; }; export const ModuleGanttSidebar: React.FC = (props) => { - const { blockUpdateHandler, blocks, enableReorder } = props; + const { blockUpdateHandler, blockIds, getBlockById, enableReorder } = props; const handleOnDrop = ( draggingBlockId: string | undefined, droppedBlockId: string | undefined, dropAtEndOfList: boolean ) => { - handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blocks, blockUpdateHandler); + handleOrderChange(draggingBlockId, droppedBlockId, dropAtEndOfList, blockIds, getBlockById, blockUpdateHandler); }; return (
    - {blocks ? ( - blocks.map((block, index) => ( + {blockIds ? ( + blockIds.map((blockId, index) => { + const block = getBlockById(blockId); + return ( @@ -48,7 +51,7 @@ export const ModuleGanttSidebar: React.FC = (props) => { /> )} - )) + )}) ) : ( diff --git a/web/components/gantt-chart/sidebar/root.tsx b/web/components/gantt-chart/sidebar/root.tsx index 8d227dd25..1c2a11a25 100644 --- a/web/components/gantt-chart/sidebar/root.tsx +++ b/web/components/gantt-chart/sidebar/root.tsx @@ -1,7 +1,8 @@ +import { RefObject } from "react"; import { observer } from "mobx-react"; // components import { MultipleSelectGroupAction } from "@/components/core"; -import { IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart"; +import { ChartDataType, IBlockUpdateData, IGanttBlock } from "@/components/gantt-chart"; // helpers import { cn } from "@/helpers/common.helper"; // hooks @@ -10,23 +11,31 @@ import { TSelectionHelper } from "@/hooks/use-multiple-select"; import { GANTT_SELECT_GROUP, HEADER_HEIGHT, SIDEBAR_WIDTH } from "../constants"; type Props = { - blocks: IGanttBlock[] | null; + blockIds: string[]; blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void; + canLoadMoreBlocks?: boolean; + loadMoreBlocks?: () => void; + ganttContainerRef: RefObject; enableReorder: boolean; enableSelection: boolean; sidebarToRender: (props: any) => React.ReactNode; title: string; + getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock; quickAdd?: React.JSX.Element | undefined; selectionHelpers: TSelectionHelper; }; export const GanttChartSidebar: React.FC = observer((props) => { const { - blocks, + blockIds, blockUpdateHandler, enableReorder, enableSelection, sidebarToRender, + getBlockById, + loadMoreBlocks, + canLoadMoreBlocks, + ganttContainerRef, title, quickAdd, selectionHelpers, @@ -74,7 +83,19 @@ export const GanttChartSidebar: React.FC = observer((props) => {
    - {sidebarToRender?.({ title, blockUpdateHandler, blocks, enableReorder, enableSelection, selectionHelpers })} + {sidebarToRender && + sidebarToRender({ + title, + blockUpdateHandler, + blockIds, + getBlockById, + enableReorder, + enableSelection, + canLoadMoreBlocks, + ganttContainerRef, + loadMoreBlocks, + selectionHelpers + })}
    {quickAdd ? quickAdd : null}
    diff --git a/web/components/gantt-chart/sidebar/utils.ts b/web/components/gantt-chart/sidebar/utils.ts index 765c0357f..58e8e05de 100644 --- a/web/components/gantt-chart/sidebar/utils.ts +++ b/web/components/gantt-chart/sidebar/utils.ts @@ -1,38 +1,38 @@ -import { IBlockUpdateData, IGanttBlock } from "../types"; +import { ChartDataType, IBlockUpdateData, IGanttBlock } from "../types"; export const handleOrderChange = ( draggingBlockId: string | undefined, droppedBlockId: string | undefined, dropAtEndOfList: boolean, - blocks: IGanttBlock[] | null, + blockIds: string[] | null, + getBlockById: (id: string, currentViewData?: ChartDataType | undefined) => IGanttBlock, blockUpdateHandler: (block: any, payload: IBlockUpdateData) => void ) => { - if (!blocks || !draggingBlockId || !droppedBlockId) return; + if (!blockIds || !draggingBlockId || !droppedBlockId) return; - const sourceBlockIndex = blocks.findIndex((block) => block.id === draggingBlockId); - const destinationBlockIndex = dropAtEndOfList - ? blocks.length - : blocks.findIndex((block) => block.id === droppedBlockId); + const sourceBlockIndex = blockIds.findIndex((id) => id === draggingBlockId); + const destinationBlockIndex = dropAtEndOfList ? blockIds.length : blockIds.findIndex((id) => id === droppedBlockId); // return if dropped outside the list if (sourceBlockIndex === -1 || destinationBlockIndex === -1 || sourceBlockIndex === destinationBlockIndex) return; - let updatedSortOrder = blocks[sourceBlockIndex].sort_order; + let updatedSortOrder = getBlockById(blockIds[sourceBlockIndex])?.sort_order; // update the sort order to the lowest if dropped at the top - if (destinationBlockIndex === 0) updatedSortOrder = blocks[0].sort_order - 1000; + if (destinationBlockIndex === 0) updatedSortOrder = getBlockById(blockIds[0])?.sort_order - 1000; // update the sort order to the highest if dropped at the bottom - else if (destinationBlockIndex === blocks.length) updatedSortOrder = blocks[blocks.length - 1].sort_order + 1000; + else if (destinationBlockIndex === blockIds.length) + updatedSortOrder = getBlockById(blockIds[blockIds.length - 1])?.sort_order + 1000; // update the sort order to the average of the two adjacent blocks if dropped in between else { - const destinationSortingOrder = blocks[destinationBlockIndex].sort_order; - const relativeDestinationSortingOrder = blocks[destinationBlockIndex - 1].sort_order; + const destinationSortingOrder = getBlockById(blockIds[destinationBlockIndex])?.sort_order; + const relativeDestinationSortingOrder = getBlockById(blockIds[destinationBlockIndex - 1])?.sort_order; updatedSortOrder = (destinationSortingOrder + relativeDestinationSortingOrder) / 2; } // call the block update handler with the updated sort order, new and old index - blockUpdateHandler(blocks[sourceBlockIndex].data, { + blockUpdateHandler(getBlockById(blockIds[sourceBlockIndex])?.data, { sort_order: { destinationIndex: destinationBlockIndex, newSortOrder: updatedSortOrder, diff --git a/web/components/inbox/content/issue-properties.tsx b/web/components/inbox/content/issue-properties.tsx index 52f626f06..d8f47cdb1 100644 --- a/web/components/inbox/content/issue-properties.tsx +++ b/web/components/inbox/content/issue-properties.tsx @@ -98,7 +98,7 @@ export const InboxIssueContentProperties: React.FC = observer((props) => Priority
    issue?.id && issueOperations.update(workspaceSlug, projectId, issue?.id, { priority: val }) } diff --git a/web/components/inbox/modals/create-edit-modal/issue-properties.tsx b/web/components/inbox/modals/create-edit-modal/issue-properties.tsx index c50195483..65f6d8ad2 100644 --- a/web/components/inbox/modals/create-edit-modal/issue-properties.tsx +++ b/web/components/inbox/modals/create-edit-modal/issue-properties.tsx @@ -49,7 +49,7 @@ export const InboxIssueProperties: FC = observer((props) {/* state */}
    handleData("state_id", stateId)} projectId={projectId} buttonVariant="border-with-text" @@ -59,7 +59,7 @@ export const InboxIssueProperties: FC = observer((props) {/* priority */}
    handleData("priority", priority)} buttonVariant="border-with-text" /> diff --git a/web/components/inbox/modals/select-duplicate.tsx b/web/components/inbox/modals/select-duplicate.tsx index 744f42a47..9bcefad49 100644 --- a/web/components/inbox/modals/select-duplicate.tsx +++ b/web/components/inbox/modals/select-duplicate.tsx @@ -1,22 +1,23 @@ "use client"; -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { useParams } from "next/navigation"; -import useSWR from "swr"; import { Search } from "lucide-react"; import { Combobox, Dialog, Transition } from "@headlessui/react"; -// hooks // icons // components +// types +import { ISearchIssueResponse } from "@plane/types"; // ui -import { TOAST_TYPE, setToast } from "@plane/ui"; +import { Loader, TOAST_TYPE, setToast } from "@plane/ui"; import { EmptyState } from "@/components/empty-state"; -// services // constants import { EmptyStateType } from "@/constants/empty-state"; -import { PROJECT_ISSUES_LIST } from "@/constants/fetch-keys"; -import { useProject, useProjectState } from "@/hooks/store"; -import { IssueService } from "@/services/issue"; +// hooks +import { useProject } from "@/hooks/store"; +import useDebounce from "@/hooks/use-debounce"; +// services +import { ProjectService } from "@/services/project"; type Props = { isOpen: boolean; @@ -25,7 +26,7 @@ type Props = { onSubmit: (issueId: string) => void; }; -const issueService = new IssueService(); +const projectService = new ProjectService(); export const SelectDuplicateInboxIssueModal: React.FC = (props) => { const { isOpen, onClose, onSubmit, value } = props; @@ -35,18 +36,27 @@ export const SelectDuplicateInboxIssueModal: React.FC = (props) => { const { workspaceSlug, projectId, issueId } = useParams(); // hooks - const { getProjectStates } = useProjectState(); const { getProjectById } = useProject(); - const { data: issues } = useSWR( - workspaceSlug && projectId ? PROJECT_ISSUES_LIST(workspaceSlug as string, projectId as string) : null, - workspaceSlug && projectId - ? () => - issueService - .getIssues(workspaceSlug as string, projectId as string) - .then((res) => Object.values(res ?? {}).filter((issue) => issue.id !== issueId)) - : null - ); + const [issues, setIssues] = useState([]); + const [isSearching, setIsSearching] = useState(false); + + const debouncedSearchTerm: string = useDebounce(query, 500); + + useEffect(() => { + if (!isOpen || !workspaceSlug || !projectId) return; + + setIsSearching(true); + projectService + .projectIssuesSearch(workspaceSlug.toString(), projectId.toString(), { + search: debouncedSearchTerm, + workspace_search: false, + }) + .then((res: ISearchIssueResponse[]) => setIssues(res)) + .finally(() => setIsSearching(false)); + }, [debouncedSearchTerm, isOpen, projectId, workspaceSlug]); + + const filteredIssues = issues.filter((issue) => issue.id !== issueId); const handleClose = () => { onClose(); @@ -62,7 +72,52 @@ export const SelectDuplicateInboxIssueModal: React.FC = (props) => { handleClose(); }; - const filteredIssues = (query === "" ? issues : issues?.filter((issue) => issue.name.includes(query))) ?? []; + const issueList = + filteredIssues.length > 0 ? ( +
  • + {query === "" &&

    Select issue

    } +
      + {filteredIssues.map((issue) => { + const stateColor = issue.state__color || ""; + + return ( + + `flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${ + active || selected ? "bg-custom-background-80 text-custom-text-100" : "" + } ` + } + > +
      + + + {getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id} + + {issue.name} +
      +
      + ); + })} +
    +
  • + ) : ( +
    + +
    + ); return ( setQuery("")} appear> @@ -110,56 +165,15 @@ export const SelectDuplicateInboxIssueModal: React.FC = (props) => { static className="max-h-80 scroll-py-2 divide-y divide-custom-border-200 overflow-y-auto" > - {filteredIssues.length > 0 ? ( -
  • - {query === "" && ( -

    Select issue

    - )} -
      - {filteredIssues.map((issue) => { - const stateColor = - getProjectStates(issue?.project_id)?.find((state) => state?.id == issue?.state_id) - ?.color || ""; - - return ( - - `flex w-full cursor-pointer select-none items-center gap-2 rounded-md px-3 py-2 text-custom-text-200 ${ - active || selected ? "bg-custom-background-80 text-custom-text-100" : "" - } ` - } - > -
      - - - {getProjectById(issue?.project_id)?.identifier}-{issue.sequence_id} - - {issue.name} -
      -
      - ); - })} -
    -
  • + {isSearching ? ( + + + + + + ) : ( -
    - -
    + <>{issueList} )} diff --git a/web/components/issues/bulk-operations/actions/archive.tsx b/web/components/issues/bulk-operations/actions/archive.tsx new file mode 100644 index 000000000..66b8eb4d3 --- /dev/null +++ b/web/components/issues/bulk-operations/actions/archive.tsx @@ -0,0 +1,67 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +// ui +import { ArchiveIcon, Tooltip } from "@plane/ui"; +// components +// constants +import { ARCHIVABLE_STATE_GROUPS } from "@/constants/state"; +// helpers +import { cn } from "@/helpers/common.helper"; +// hooks +import { useAppRouter, useIssueDetail, useProjectState } from "@/hooks/store"; +import { BulkArchiveConfirmationModal } from "../bulk-archive-modal"; + +type Props = { + handleClearSelection: () => void; + selectedEntityIds: string[]; +}; + +export const BulkArchiveIssues: React.FC = observer((props) => { + const { handleClearSelection, selectedEntityIds } = props; + // states + const [isBulkArchiveModalOpen, setIsBulkArchiveModalOpen] = useState(false); + // store hooks + const { projectId, workspaceSlug } = useAppRouter(); + const { + issue: { getIssueById }, + } = useIssueDetail(); + const { getStateById } = useProjectState(); + const canAllIssuesBeArchived = selectedEntityIds.every((issueId) => { + const issueDetails = getIssueById(issueId); + if (!issueDetails) return false; + const stateDetails = getStateById(issueDetails.state_id); + if (!stateDetails) return false; + return ARCHIVABLE_STATE_GROUPS.includes(stateDetails.group); + }); + + return ( + <> + {projectId && workspaceSlug && ( + setIsBulkArchiveModalOpen(false)} + issueIds={selectedEntityIds} + onSubmit={handleClearSelection} + projectId={projectId.toString()} + workspaceSlug={workspaceSlug.toString()} + /> + )} + + + + + ); +}); diff --git a/web/components/issues/bulk-operations/actions/delete.tsx b/web/components/issues/bulk-operations/actions/delete.tsx new file mode 100644 index 000000000..c716f6258 --- /dev/null +++ b/web/components/issues/bulk-operations/actions/delete.tsx @@ -0,0 +1,45 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +import { Trash2 } from "lucide-react"; +// ui +import { Tooltip } from "@plane/ui"; +// hooks +import { useAppRouter } from "@/hooks/store"; +import { BulkDeleteConfirmationModal } from "../bulk-delete-modal"; + +type Props = { + handleClearSelection: () => void; + selectedEntityIds: string[]; +}; + +export const BulkDeleteIssues: React.FC = observer((props) => { + const { handleClearSelection, selectedEntityIds } = props; + // states + const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); + // store hooks + const { projectId, workspaceSlug } = useAppRouter(); + + return ( + <> + {projectId && workspaceSlug && ( + setIsBulkDeleteModalOpen(false)} + issueIds={selectedEntityIds} + onSubmit={handleClearSelection} + projectId={projectId.toString()} + workspaceSlug={workspaceSlug.toString()} + /> + )} + + + + + ); +}); diff --git a/web/components/issues/bulk-operations/actions/index.ts b/web/components/issues/bulk-operations/actions/index.ts new file mode 100644 index 000000000..87fd75bc8 --- /dev/null +++ b/web/components/issues/bulk-operations/actions/index.ts @@ -0,0 +1,3 @@ +export * from "./archive"; +export * from "./delete"; +export * from "./root"; diff --git a/web/components/issues/bulk-operations/actions/root.tsx b/web/components/issues/bulk-operations/actions/root.tsx new file mode 100644 index 000000000..044fcc9c5 --- /dev/null +++ b/web/components/issues/bulk-operations/actions/root.tsx @@ -0,0 +1,18 @@ +import { BulkArchiveIssues } from "./archive"; +import { BulkDeleteIssues } from "./delete"; + +type Props = { + handleClearSelection: () => void; + selectedEntityIds: string[]; +}; + +export const BulkOperationsActionsRoot: React.FC = (props) => { + const { handleClearSelection, selectedEntityIds } = props; + + return ( +
    + + +
    + ); +}; diff --git a/web/components/issues/bulk-operations/bulk-archive-modal.tsx b/web/components/issues/bulk-operations/bulk-archive-modal.tsx new file mode 100644 index 000000000..250668ef4 --- /dev/null +++ b/web/components/issues/bulk-operations/bulk-archive-modal.tsx @@ -0,0 +1,83 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +// ui +import { TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { AlertModalCore, EModalPosition, EModalWidth } from "@/components/core"; +// constants +import { EErrorCodes, ERROR_DETAILS } from "@/constants/errors"; +// hooks +import { useIssues } from "@/hooks/store"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; + +type Props = { + handleClose: () => void; + isOpen: boolean; + issueIds: string[]; + onSubmit?: () => void; + projectId: string; + workspaceSlug: string; +}; + +export const BulkArchiveConfirmationModal: React.FC = observer((props) => { + const { handleClose, isOpen, issueIds, onSubmit, projectId, workspaceSlug } = props; + // states + const [isArchiving, setIsDeleting] = useState(false); + // store hooks + const storeType = useIssueStoreType(); + const { + issues: { archiveBulkIssues }, + } = useIssues(storeType); + + const handleSubmit = async () => { + setIsDeleting(true); + + archiveBulkIssues && + (await archiveBulkIssues(workspaceSlug, projectId, issueIds) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Issues archived successfully.", + }); + onSubmit?.(); + handleClose(); + }) + .catch((error) => { + const errorInfo = ERROR_DETAILS[error?.error_code as EErrorCodes] ?? undefined; + setToast({ + type: TOAST_TYPE.ERROR, + title: errorInfo?.title ?? "Error!", + message: errorInfo?.message ?? "Something went wrong. Please try again.", + }); + }) + .finally(() => setIsDeleting(false))); + }; + + const issueVariant = issueIds.length > 1 ? "issues" : "issue"; + + return ( + + Are you sure you want to archive {issueIds.length} {issueVariant}? Sub issues of selected {issueVariant} will + also be archived. Once archived {issueIds.length > 1 ? "they" : "it"} can be restored later via the archives + section. + + } + primaryButtonText={{ + loading: "Archiving", + default: `Archive ${issueVariant}`, + }} + hideIcon + /> + ); +}); diff --git a/web/components/issues/bulk-operations/bulk-delete-modal.tsx b/web/components/issues/bulk-operations/bulk-delete-modal.tsx new file mode 100644 index 000000000..064f2e838 --- /dev/null +++ b/web/components/issues/bulk-operations/bulk-delete-modal.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { observer } from "mobx-react"; +// ui +import { TOAST_TYPE, setToast } from "@plane/ui"; +// components +import { AlertModalCore, EModalPosition, EModalWidth } from "@/components/core"; +// constants +// hooks +import { useIssues } from "@/hooks/store"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; + +type Props = { + handleClose: () => void; + isOpen: boolean; + issueIds: string[]; + onSubmit?: () => void; + projectId: string; + workspaceSlug: string; +}; + +export const BulkDeleteConfirmationModal: React.FC = observer((props) => { + const { handleClose, isOpen, issueIds, onSubmit, projectId, workspaceSlug } = props; + // states + const [isDeleting, setIsDeleting] = useState(false); + // store hooks + const storeType = useIssueStoreType(); + const { + issues: { removeBulkIssues }, + } = useIssues(storeType); + + const handleSubmit = async () => { + setIsDeleting(true); + + await removeBulkIssues(workspaceSlug, projectId, issueIds) + .then(() => { + setToast({ + type: TOAST_TYPE.SUCCESS, + title: "Success!", + message: "Issues deleted successfully.", + }); + onSubmit?.(); + handleClose(); + }) + .catch(() => + setToast({ + type: TOAST_TYPE.ERROR, + title: "Error!", + message: "Something went wrong. Please try again.", + }) + ) + .finally(() => setIsDeleting(false)); + }; + + const issueVariant = issueIds.length > 1 ? "issues" : "issue"; + + return ( + + Are you sure you want to delete {issueIds.length} {issueVariant}? Sub issues of selected {issueVariant} will + also be deleted. All of the data related to the {issueVariant} will be permanently removed. This action cannot + be undone. + + } + primaryButtonText={{ + loading: "Deleting", + default: `Delete ${issueVariant}`, + }} + /> + ); +}); diff --git a/web/components/issues/bulk-operations/exrtra-properties.tsx b/web/components/issues/bulk-operations/exrtra-properties.tsx new file mode 100644 index 000000000..dc57b3702 --- /dev/null +++ b/web/components/issues/bulk-operations/exrtra-properties.tsx @@ -0,0 +1 @@ +export const BulkOperationsExtraProperties = () => null; diff --git a/web/components/issues/issue-detail/main-content.tsx b/web/components/issues/issue-detail/main-content.tsx index 13aaf3288..3f6e97b54 100644 --- a/web/components/issues/issue-detail/main-content.tsx +++ b/web/components/issues/issue-detail/main-content.tsx @@ -50,7 +50,7 @@ export const IssueMainContent: React.FC = observer((props) => { }, [isSubmitting, setShowAlert, setIsSubmitting]); const issue = issueId ? getIssueById(issueId) : undefined; - if (!issue) return <>; + if (!issue || !issue.project_id) return <>; const currentIssueState = projectStates?.find((s) => s.id === issue.state_id); diff --git a/web/components/issues/issue-detail/parent/siblings.tsx b/web/components/issues/issue-detail/parent/siblings.tsx index e23d8a595..f1991e805 100644 --- a/web/components/issues/issue-detail/parent/siblings.tsx +++ b/web/components/issues/issue-detail/parent/siblings.tsx @@ -27,7 +27,7 @@ export const IssueParentSiblings: FC = observer((props) => ? `ISSUE_PARENT_CHILD_ISSUES_${workspaceSlug}_${parentIssue.project_id}_${parentIssue.id}` : null, parentIssue && parentIssue.project_id - ? () => fetchSubIssues(workspaceSlug, parentIssue.project_id, parentIssue.id) + ? () => fetchSubIssues(workspaceSlug, parentIssue.project_id!, parentIssue.id) : null ); diff --git a/web/components/issues/issue-detail/sidebar.tsx b/web/components/issues/issue-detail/sidebar.tsx index 8e8e10c31..d92cbfbbe 100644 --- a/web/components/issues/issue-detail/sidebar.tsx +++ b/web/components/issues/issue-detail/sidebar.tsx @@ -205,7 +205,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { State
    issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })} projectId={projectId?.toString() ?? ""} disabled={!isEditable} @@ -234,7 +234,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { className="group w-3/5 flex-grow" buttonContainerClassName="w-full text-left" buttonClassName={`text-sm justify-between ${ - issue?.assignee_ids.length > 0 ? "" : "text-custom-text-400" + issue?.assignee_ids?.length > 0 ? "" : "text-custom-text-400" }`} hideIcon={issue.assignee_ids?.length === 0} dropdownArrow @@ -248,7 +248,7 @@ export const IssueDetailsSidebar: React.FC = observer((props) => { Priority
    issueOperations.update(workspaceSlug, projectId, issueId, { priority: val })} disabled={!isEditable} buttonVariant="border-with-text" diff --git a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx index 058992d62..af4392815 100644 --- a/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx +++ b/web/components/issues/issue-layouts/calendar/base-calendar-root.tsx @@ -1,18 +1,19 @@ "use client"; -import { FC } from "react"; +import { FC, useCallback, useEffect } from "react"; import { observer } from "mobx-react-lite"; import { useParams } from "next/navigation"; import { TGroupedIssues } from "@plane/types"; // components import { TOAST_TYPE, setToast } from "@plane/ui"; import { CalendarChart } from "@/components/issues"; -// hooks -import { EIssuesStoreType } from "@/constants/issue"; +//constants +import { EIssuesStoreType, EIssueGroupByToServerOptions } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; -import { useIssues, useUser } from "@/hooks/store"; +// hooks +import { useIssues, useUser, useCalendarView } from "@/hooks/store"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import { useIssuesActions } from "@/hooks/use-issues-actions"; -// ui // types import { IQuickActionProps } from "../list/list-view-types"; import { handleDragDrop } from "./utils"; @@ -25,25 +26,36 @@ type CalendarStoreType = interface IBaseCalendarRoot { QuickActions: FC; - storeType: CalendarStoreType; addIssuesToView?: (issueIds: string[]) => Promise; - viewId?: string; isCompletedCycle?: boolean; + viewId?: string | undefined; } export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { - const { QuickActions, storeType, addIssuesToView, viewId, isCompletedCycle = false } = props; + const { QuickActions, addIssuesToView, isCompletedCycle = false, viewId } = props; // router const { workspaceSlug, projectId } = useParams(); // hooks + const storeType = useIssueStoreType() as CalendarStoreType; const { membership: { currentProjectRole }, } = useUser(); const { issues, issuesFilter, issueMap } = useIssues(storeType); - const { updateIssue, removeIssue, removeIssueFromView, archiveIssue, restoreIssue, updateFilters } = - useIssuesActions(storeType); + const { + fetchIssues, + fetchNextIssues, + quickAddIssue, + updateIssue, + removeIssue, + removeIssueFromView, + archiveIssue, + restoreIssue, + updateFilters, + } = useIssuesActions(storeType); + + const issueCalendarView = useCalendarView(); const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; @@ -51,6 +63,26 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { const groupedIssueIds = (issues.groupedIssueIds ?? {}) as TGroupedIssues; + const layout = displayFilters?.calendar?.layout ?? "month"; + const { startDate, endDate } = issueCalendarView.getStartAndEndDate(layout) ?? {}; + + useEffect(() => { + startDate && + endDate && + layout && + fetchIssues( + "init-loader", + { + canGroup: true, + perPageCount: layout === "month" ? 4 : 30, + before: endDate, + after: startDate, + groupedBy: EIssueGroupByToServerOptions["target_date"], + }, + viewId + ); + }, [fetchIssues, storeType, startDate, endDate, layout, viewId]); + const handleDragAndDrop = async ( issueId: string | undefined, sourceDate: string | undefined, @@ -74,6 +106,23 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { }); }; + const loadMoreIssues = useCallback( + (dateString: string) => { + fetchNextIssues(dateString); + }, + [fetchNextIssues] + ); + + const getPaginationData = useCallback( + (groupId: string | undefined) => issues?.getPaginationData(groupId, undefined), + [issues?.getPaginationData] + ); + + const getGroupIssueCount = useCallback( + (groupId: string | undefined) => issues?.getGroupIssueCount(groupId, undefined, false), + [issues?.getGroupIssueCount] + ); + return ( <>
    @@ -83,6 +132,7 @@ export const BaseCalendarRoot = observer((props: IBaseCalendarRoot) => { groupedIssueIds={groupedIssueIds} layout={displayFilters?.calendar?.layout} showWeekends={displayFilters?.calendar?.show_weekends ?? false} + issueCalendarView={issueCalendarView} quickActions={({ issue, parentRef, customActionButton, placement }) => ( { placements={placement} /> )} + loadMoreIssues={loadMoreIssues} + getPaginationData={getPaginationData} + getGroupIssueCount={getGroupIssueCount} addIssuesToView={addIssuesToView} - quickAddCallback={issues.quickAddIssue} - viewId={viewId} + quickAddCallback={quickAddIssue} readOnly={!isEditingAllowed || isCompletedCycle} updateFilters={updateFilters} handleDragAndDrop={handleDragAndDrop} /> -
    +
    ); }); diff --git a/web/components/issues/issue-layouts/calendar/calendar.tsx b/web/components/issues/issue-layouts/calendar/calendar.tsx index 8561a435d..60f0316af 100644 --- a/web/components/issues/issue-layouts/calendar/calendar.tsx +++ b/web/components/issues/issue-layouts/calendar/calendar.tsx @@ -13,6 +13,7 @@ import type { TIssue, TIssueKanbanFilters, TIssueMap, + TPaginationData, } from "@plane/types"; // ui import { Spinner } from "@plane/ui"; @@ -20,20 +21,21 @@ import { Spinner } from "@plane/ui"; import { CalendarHeader, CalendarIssueBlocks, CalendarWeekDays, CalendarWeekHeader } from "@/components/issues"; // constants import { MONTHS_LIST } from "@/constants/calendar"; -import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; +import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // helpers import { cn } from "@/helpers/common.helper"; import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; // hooks import { useIssues, useUser } from "@/hooks/store"; -import { useCalendarView } from "@/hooks/store/use-calendar-view"; import useSize from "@/hooks/use-window-size"; // store import { ICycleIssuesFilter } from "@/store/issue/cycle"; +import { ICalendarStore } from "@/store/issue/issue_calendar_view.store"; import { IModuleIssuesFilter } from "@/store/issue/module"; import { IProjectIssuesFilter } from "@/store/issue/project"; import { IProjectViewIssuesFilter } from "@/store/issue/project-views"; +import { IssueLayoutHOC } from "../issue-layout-HOC"; import { TRenderQuickActions } from "../list/list-view-types"; import type { ICalendarWeek } from "./types"; @@ -43,20 +45,18 @@ type Props = { groupedIssueIds: TGroupedIssues; layout: "month" | "week" | undefined; showWeekends: boolean; + issueCalendarView: ICalendarStore; + loadMoreIssues: (dateString: string) => void; + getPaginationData: (groupId: string | undefined) => TPaginationData | undefined; + getGroupIssueCount: (groupId: string | undefined) => number | undefined; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; quickActions: TRenderQuickActions; handleDragAndDrop: ( issueId: string | undefined, sourceDate: string | undefined, destinationDate: string | undefined ) => Promise; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; addIssuesToView?: (issueIds: string[]) => Promise; - viewId?: string; readOnly?: boolean; updateFilters?: ( projectId: string, @@ -72,11 +72,14 @@ export const CalendarChart: React.FC = observer((props) => { groupedIssueIds, layout, showWeekends, + issueCalendarView, + loadMoreIssues, handleDragAndDrop, quickActions, quickAddCallback, addIssuesToView, - viewId, + getPaginationData, + getGroupIssueCount, updateFilters, readOnly = false, } = props; @@ -88,7 +91,7 @@ export const CalendarChart: React.FC = observer((props) => { const { issues: { viewFlags }, } = useIssues(EIssuesStoreType.PROJECT); - const issueCalendarView = useCalendarView(); + const { membership: { currentProjectRole }, } = useUser(); @@ -123,7 +126,7 @@ export const CalendarChart: React.FC = observer((props) => {
    ); - const issueIdList = groupedIssueIds ? groupedIssueIds[formattedDatePayload] : null; + const issueIdList = groupedIssueIds ? groupedIssueIds[formattedDatePayload] : []; return ( <> @@ -133,57 +136,90 @@ export const CalendarChart: React.FC = observer((props) => { issuesFilterStore={issuesFilterStore} updateFilters={updateFilters} /> -
    768, - })} - ref={scrollableContainerRef} - > - -
    - {layout === "month" && ( -
    - {allWeeksOfActiveMonth && - Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => ( - - ))} -
    - )} - {layout === "week" && ( - +
    768, + })} + ref={scrollableContainerRef} + > + +
    + {layout === "month" && ( +
    + {allWeeksOfActiveMonth && + Object.values(allWeeksOfActiveMonth).map((week: ICalendarWeek, weekIndex) => ( + + ))} +
    + )} + {layout === "week" && ( + + )} +
    + + {/* mobile view */} +
    +

    + {`${selectedDate.getDate()} ${ + MONTHS_LIST[selectedDate.getMonth() + 1].title + }, ${selectedDate.getFullYear()}`} +

    + - )} +
    + {/* mobile view */}
    @@ -197,20 +233,19 @@ export const CalendarChart: React.FC = observer((props) => { issues={issues} issueIdList={issueIdList} quickActions={quickActions} + loadMoreIssues={loadMoreIssues} + getPaginationData={getPaginationData} + getGroupIssueCount={getGroupIssueCount} enableQuickIssueCreate disableIssueCreation={!enableIssueCreation || !isEditingAllowed} quickAddCallback={quickAddCallback} addIssuesToView={addIssuesToView} - viewId={viewId} readOnly={readOnly} - isMonthLayout={false} - showAllIssues isDragDisabled isMobileView />
    -
    ); }); diff --git a/web/components/issues/issue-layouts/calendar/day-tile.tsx b/web/components/issues/issue-layouts/calendar/day-tile.tsx index 311d5cbf0..5a82d3628 100644 --- a/web/components/issues/issue-layouts/calendar/day-tile.tsx +++ b/web/components/issues/issue-layouts/calendar/day-tile.tsx @@ -6,7 +6,7 @@ import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element import { differenceInCalendarDays } from "date-fns"; import { observer } from "mobx-react-lite"; // types -import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; +import { TGroupedIssues, TIssue, TIssueMap, TPaginationData } from "@plane/types"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; // components @@ -29,22 +29,19 @@ type Props = { date: ICalendarDate; issues: TIssueMap | undefined; groupedIssueIds: TGroupedIssues; - quickActions: TRenderQuickActions; + loadMoreIssues: (dateString: string) => void; + getPaginationData: (groupId: string | undefined) => TPaginationData | undefined; + getGroupIssueCount: (groupId: string | undefined) => number | undefined; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; + quickActions: TRenderQuickActions; handleDragAndDrop: ( issueId: string | undefined, sourceDate: string | undefined, destinationDate: string | undefined ) => Promise; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; addIssuesToView?: (issueIds: string[]) => Promise; - viewId?: string; readOnly?: boolean; selectedDate: Date; setSelectedDate: (date: Date) => void; @@ -56,12 +53,14 @@ export const CalendarDayTile: React.FC = observer((props) => { date, issues, groupedIssueIds, + loadMoreIssues, + getPaginationData, + getGroupIssueCount, quickActions, enableQuickIssueCreate, disableIssueCreation, quickAddCallback, addIssuesToView, - viewId, readOnly = false, selectedDate, handleDragAndDrop, @@ -69,7 +68,6 @@ export const CalendarDayTile: React.FC = observer((props) => { } = props; const [isDraggingOver, setIsDraggingOver] = useState(false); - const [showAllIssues, setShowAllIssues] = useState(false); const calendarLayout = issuesFilterStore?.issueFilters?.displayFilters?.calendar?.layout ?? "month"; @@ -114,7 +112,6 @@ export const CalendarDayTile: React.FC = observer((props) => { } handleDragAndDrop(sourceData?.id, sourceData?.date, destinationData?.date); - setShowAllIssues(true); highlightIssueOnDrop(source?.element?.id, false); }, }) @@ -122,9 +119,7 @@ export const CalendarDayTile: React.FC = observer((props) => { }, [dayTileRef?.current, formattedDatePayload]); if (!formattedDatePayload) return null; - const issueIdList = groupedIssueIds ? groupedIssueIds[formattedDatePayload] : null; - - const totalIssues = issueIdList?.length ?? 0; + const issueIds = groupedIssueIds?.[formattedDatePayload]; const isToday = date.date.toDateString() === new Date().toDateString(); const isSelectedDate = date.date.toDateString() == selectedDate.toDateString(); @@ -171,18 +166,17 @@ export const CalendarDayTile: React.FC = observer((props) => {
    @@ -205,8 +199,6 @@ export const CalendarDayTile: React.FC = observer((props) => { > {date.date.getDate()}
    - - {totalIssues > 0 &&
    }
    diff --git a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx index c04241953..14bd852ba 100644 --- a/web/components/issues/issue-layouts/calendar/issue-blocks.tsx +++ b/web/components/issues/issue-layouts/calendar/issue-blocks.tsx @@ -1,33 +1,26 @@ -import { Dispatch, SetStateAction } from "react"; import { observer } from "mobx-react-lite"; -// types -import { TIssue, TIssueMap } from "@plane/types"; +import { TIssue, TIssueMap, TPaginationData } from "@plane/types"; // components import { CalendarQuickAddIssueForm, CalendarIssueBlockRoot } from "@/components/issues"; // helpers import { renderFormattedPayloadDate } from "@/helpers/date-time.helper"; +import { useIssuesStore } from "@/hooks/use-issue-layout-store"; import { TRenderQuickActions } from "../list/list-view-types"; // types type Props = { date: Date; issues: TIssueMap | undefined; - issueIdList: string[] | null; - showAllIssues: boolean; - setShowAllIssues?: Dispatch>; - isMonthLayout: boolean; + loadMoreIssues: (dateString: string) => void; + getPaginationData: (groupId: string | undefined) => TPaginationData | undefined; + getGroupIssueCount: (groupId: string | undefined) => number | undefined; + issueIdList: string[]; quickActions: TRenderQuickActions; isDragDisabled?: boolean; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; addIssuesToView?: (issueIds: string[]) => Promise; - viewId?: string; readOnly?: boolean; isMobileView?: boolean; }; @@ -37,28 +30,36 @@ export const CalendarIssueBlocks: React.FC = observer((props) => { date, issues, issueIdList, - showAllIssues, - setShowAllIssues, quickActions, + loadMoreIssues, isDragDisabled = false, enableQuickIssueCreate, disableIssueCreation, quickAddCallback, addIssuesToView, - viewId, readOnly, - isMonthLayout, isMobileView = false, } = props; - const formattedDatePayload = renderFormattedPayloadDate(date); - const totalIssues = issueIdList?.length ?? 0; + + const { + issues: { getGroupIssueCount, getPaginationData, getIssueLoader }, + } = useIssuesStore(); if (!formattedDatePayload) return null; + const dayIssueCount = getGroupIssueCount(formattedDatePayload, undefined, false); + const nextPageResults = getPaginationData(formattedDatePayload, undefined)?.nextPageResults; + const isPaginating = !!getIssueLoader(formattedDatePayload); + + const shouldLoadMore = + nextPageResults === undefined && dayIssueCount !== undefined + ? issueIdList?.length < dayIssueCount + : !!nextPageResults; + return ( <> - {issueIdList?.slice(0, showAllIssues || !isMonthLayout ? issueIdList.length : 4).map((issueId) => ( + {issueIdList?.map((issueId) => (
    = observer((props) => { />
    ))} - {totalIssues > 4 && isMonthLayout && ( -
    - + +{isPaginating && ( +
    +
    )} + {enableQuickIssueCreate && !disableIssueCreation && !readOnly && (
    = observer((props) => { }} quickAddCallback={quickAddCallback} addIssuesToView={addIssuesToView} - viewId={viewId} - onOpen={() => setShowAllIssues && setShowAllIssues(true)} />
    )} + + {shouldLoadMore && !isPaginating && ( +
    + +
    + )} ); }); diff --git a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx index c81c2ad76..b40a7050a 100644 --- a/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/calendar/quick-add-issue-form.tsx @@ -27,14 +27,8 @@ type Props = { groupId?: string; subGroupId?: string | null; prePopulatedData?: Partial; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; addIssuesToView?: (issueIds: string[]) => Promise; - viewId?: string; onOpen?: () => void; }; @@ -66,7 +60,7 @@ const Inputs = (props: any) => { }; export const CalendarQuickAddIssueForm: React.FC = observer((props) => { - const { formKey, prePopulatedData, quickAddCallback, addIssuesToView, viewId, onOpen } = props; + const { formKey, prePopulatedData, quickAddCallback, addIssuesToView, onOpen } = props; // router const { workspaceSlug, projectId, moduleId } = useParams(); @@ -133,14 +127,9 @@ export const CalendarQuickAddIssueForm: React.FC = observer((props) => { }); if (quickAddCallback) { - const quickAddPromise = quickAddCallback( - workspaceSlug.toString(), - projectId.toString(), - { - ...payload, - }, - viewId - ); + const quickAddPromise = quickAddCallback(projectId.toString(), { + ...payload, + }); setPromiseToast(quickAddPromise, { loading: "Adding issue...", success: { diff --git a/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx b/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx index e9dbcab9c..8543e85de 100644 --- a/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/cycle-root.tsx @@ -33,9 +33,8 @@ export const CycleCalendarLayout: React.FC = observer(() => { ); }); diff --git a/web/components/issues/issue-layouts/calendar/roots/module-root.tsx b/web/components/issues/issue-layouts/calendar/roots/module-root.tsx index 609cd606f..aaf746b34 100644 --- a/web/components/issues/issue-layouts/calendar/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/module-root.tsx @@ -28,9 +28,8 @@ export const ModuleCalendarLayout: React.FC = observer(() => { return ( ); }); diff --git a/web/components/issues/issue-layouts/calendar/roots/project-root.tsx b/web/components/issues/issue-layouts/calendar/roots/project-root.tsx index 5e1f1b545..cad7336de 100644 --- a/web/components/issues/issue-layouts/calendar/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/project-root.tsx @@ -1,10 +1,7 @@ import { observer } from "mobx-react"; // hooks import { ProjectIssueQuickActions } from "@/components/issues"; -import { EIssuesStoreType } from "@/constants/issue"; // components import { BaseCalendarRoot } from "../base-calendar-root"; -export const CalendarLayout: React.FC = observer(() => ( - -)); +export const CalendarLayout: React.FC = observer(() => ); diff --git a/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx b/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx index f54b5209e..9a26e8811 100644 --- a/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/calendar/roots/project-view-root.tsx @@ -1,22 +1,11 @@ import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // hooks import { ProjectIssueQuickActions } from "@/components/issues"; -import { EIssuesStoreType } from "@/constants/issue"; // components // types import { BaseCalendarRoot } from "../base-calendar-root"; // constants -export const ProjectViewCalendarLayout: React.FC = observer(() => { - // router - const { viewId } = useParams(); - - return ( - - ); -}); +export const ProjectViewCalendarLayout: React.FC = observer(() => ( + +)); diff --git a/web/components/issues/issue-layouts/calendar/week-days.tsx b/web/components/issues/issue-layouts/calendar/week-days.tsx index 9cd107bbd..2f9573402 100644 --- a/web/components/issues/issue-layouts/calendar/week-days.tsx +++ b/web/components/issues/issue-layouts/calendar/week-days.tsx @@ -1,5 +1,5 @@ import { observer } from "mobx-react-lite"; -import { TGroupedIssues, TIssue, TIssueMap } from "@plane/types"; +import { TGroupedIssues, TIssue, TIssueMap, TPaginationData } from "@plane/types"; // components import { CalendarDayTile } from "@/components/issues"; // helpers @@ -17,22 +17,19 @@ type Props = { issues: TIssueMap | undefined; groupedIssueIds: TGroupedIssues; week: ICalendarWeek | undefined; - quickActions: TRenderQuickActions; + quickActions: TRenderQuickActions + loadMoreIssues: (dateString: string) => void; + getPaginationData: (groupId: string | undefined) => TPaginationData | undefined; + getGroupIssueCount: (groupId: string | undefined) => number | undefined; enableQuickIssueCreate?: boolean; disableIssueCreation?: boolean; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; handleDragAndDrop: ( issueId: string | undefined, sourceDate: string | undefined, destinationDate: string | undefined ) => Promise; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; addIssuesToView?: (issueIds: string[]) => Promise; - viewId?: string; readOnly?: boolean; selectedDate: Date; setSelectedDate: (date: Date) => void; @@ -45,12 +42,14 @@ export const CalendarWeekDays: React.FC = observer((props) => { groupedIssueIds, handleDragAndDrop, week, + loadMoreIssues, + getPaginationData, + getGroupIssueCount, quickActions, enableQuickIssueCreate, disableIssueCreation, quickAddCallback, addIssuesToView, - viewId, readOnly = false, selectedDate, setSelectedDate, @@ -79,12 +78,14 @@ export const CalendarWeekDays: React.FC = observer((props) => { date={date} issues={issues} groupedIssueIds={groupedIssueIds} + loadMoreIssues={loadMoreIssues} + getPaginationData={getPaginationData} + getGroupIssueCount={getGroupIssueCount} quickActions={quickActions} enableQuickIssueCreate={enableQuickIssueCreate} disableIssueCreation={disableIssueCreation} quickAddCallback={quickAddCallback} addIssuesToView={addIssuesToView} - viewId={viewId} readOnly={readOnly} handleDragAndDrop={handleDragAndDrop} /> diff --git a/web/components/issues/issue-layouts/empty-states/cycle.tsx b/web/components/issues/issue-layouts/empty-states/cycle.tsx index 67f5c2eaa..89514ea49 100644 --- a/web/components/issues/issue-layouts/empty-states/cycle.tsx +++ b/web/components/issues/issue-layouts/empty-states/cycle.tsx @@ -2,9 +2,11 @@ import { useState } from "react"; import isEmpty from "lodash/isEmpty"; +import size from "lodash/size"; import { observer } from "mobx-react-lite"; +import { useParams } from "next/navigation"; // types -import { ISearchIssueResponse, TIssueLayouts } from "@plane/types"; +import { IIssueFilterOptions, ISearchIssueResponse } from "@plane/types"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; // components @@ -12,30 +14,23 @@ import { ExistingIssuesListModal } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; // constants import { EmptyStateType } from "@/constants/empty-state"; -import { EIssuesStoreType } from "@/constants/issue"; -// hooks +import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { useCommandPalette, useCycle, useEventTracker, useIssues } from "@/hooks/store"; -type Props = { - workspaceSlug: string | undefined; - projectId: string | undefined; - cycleId: string | undefined; - activeLayout: TIssueLayouts | undefined; - handleClearAllFilters: () => void; - isEmptyFilters?: boolean; -}; - -export const CycleEmptyState: React.FC = observer((props) => { - const { workspaceSlug, projectId, cycleId, activeLayout, handleClearAllFilters, isEmptyFilters = false } = props; +export const CycleEmptyState: React.FC = observer(() => { + // router + const { workspaceSlug, projectId, cycleId } = useParams(); // states const [cycleIssuesListModal, setCycleIssuesListModal] = useState(false); // store hooks const { getCycleById } = useCycle(); - const { issues } = useIssues(EIssuesStoreType.CYCLE); + const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE); const { toggleCreateIssueModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; + const userFilters = issuesFilter?.issueFilters?.filters; + const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; const handleAddIssuesToCycle = async (data: ISearchIssueResponse[]) => { if (!workspaceSlug || !projectId || !cycleId) return; @@ -43,7 +38,7 @@ export const CycleEmptyState: React.FC = observer((props) => { const issueIds = data.map((i) => i.id); await issues - .addIssueToCycle(workspaceSlug.toString(), projectId, cycleId.toString(), issueIds) + .addIssueToCycle(workspaceSlug.toString(), projectId.toString(), cycleId.toString(), issueIds) .then(() => setToast({ type: TOAST_TYPE.SUCCESS, @@ -59,9 +54,32 @@ export const CycleEmptyState: React.FC = observer((props) => { }) ); }; + const issueFilterCount = size( + Object.fromEntries( + Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0) + ) + ); const isCompletedCycleSnapshotAvailable = !isEmpty(cycleDetails?.progress_snapshot ?? {}); + const handleClearAllFilters = () => { + if (!workspaceSlug || !projectId || !cycleId) return; + const newFilters: IIssueFilterOptions = {}; + Object.keys(userFilters ?? {}).forEach((key) => { + newFilters[key as keyof IIssueFilterOptions] = null; + }); + issuesFilter.updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.FILTERS, + { + ...newFilters, + }, + cycleId.toString() + ); + }; + + const isEmptyFilters = issueFilterCount > 0; const isCompletedAndEmpty = isCompletedCycleSnapshotAvailable || cycleDetails?.status?.toLowerCase() === "completed"; const emptyStateType = isCompletedAndEmpty @@ -72,10 +90,10 @@ export const CycleEmptyState: React.FC = observer((props) => { const additionalPath = isCompletedAndEmpty ? undefined : activeLayout ?? "list"; return ( - <> +
    setCycleIssuesListModal(false)} searchParams={{ cycle: true }} @@ -98,6 +116,6 @@ export const CycleEmptyState: React.FC = observer((props) => { } />
    - +
    ); }); diff --git a/web/components/issues/issue-layouts/empty-states/global-view.tsx b/web/components/issues/issue-layouts/empty-states/global-view.tsx index bf03ee746..107770f83 100644 --- a/web/components/issues/issue-layouts/empty-states/global-view.tsx +++ b/web/components/issues/issue-layouts/empty-states/global-view.tsx @@ -1,50 +1,44 @@ import { observer } from "mobx-react"; -import { Plus, PlusIcon } from "lucide-react"; -// hooks -import { EmptyState } from "@/components/common"; -import { useCommandPalette, useEventTracker, useProject } from "@/hooks/store"; +import { useParams } from "next/navigation"; // components +import { EmptyState } from "@/components/empty-state"; +// constants +import { EMPTY_STATE_DETAILS, EmptyStateType } from "@/constants/empty-state"; +import { EIssuesStoreType } from "@/constants/issue"; +// hooks +import { useCommandPalette, useEventTracker, useProject } from "@/hooks/store"; // assets -import emptyIssue from "public/empty-state/issue.svg"; -import emptyProject from "public/empty-state/project.svg"; export const GlobalViewEmptyState: React.FC = observer(() => { + const { globalViewId } = useParams(); // store hooks + const { workspaceProjectIds } = useProject(); const { toggleCreateIssueModal, toggleCreateProjectModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); - const { workspaceProjectIds } = useProject(); + + const isDefaultView = ["all-issues", "assigned", "created", "subscribed"].includes(globalViewId?.toString() ?? ""); + const currentView = isDefaultView && globalViewId ? globalViewId : "custom-view"; + + const emptyStateType = + (workspaceProjectIds ?? []).length > 0 ? `workspace-${currentView}` : EmptyStateType.WORKSPACE_NO_PROJECTS; return ( -
    - {!workspaceProjectIds || workspaceProjectIds?.length === 0 ? ( - , - text: "New Project", - onClick: () => { + 0 + ? currentView !== "custom-view" && currentView !== "subscribed" + ? () => { + setTrackElement("All issues empty state"); + toggleCreateIssueModal(true, EIssuesStoreType.PROJECT); + } + : undefined + : () => { setTrackElement("All issues empty state"); toggleCreateProjectModal(true); - }, - }} - /> - ) : ( - , - onClick: () => { - setTrackElement("All issues empty state"); - toggleCreateIssueModal(true); - }, - }} - /> - )} -
    + } + } + /> ); }); diff --git a/web/components/issues/issue-layouts/empty-states/index.ts b/web/components/issues/issue-layouts/empty-states/index.ts deleted file mode 100644 index 1320076e7..000000000 --- a/web/components/issues/issue-layouts/empty-states/index.ts +++ /dev/null @@ -1,7 +0,0 @@ -export * from "./cycle"; -export * from "./global-view"; -export * from "./module"; -export * from "./project-view"; -export * from "./project-issues"; -export * from "./draft-issues"; -export * from "./archived-issues"; diff --git a/web/components/issues/issue-layouts/empty-states/index.tsx b/web/components/issues/issue-layouts/empty-states/index.tsx new file mode 100644 index 000000000..752e00bfd --- /dev/null +++ b/web/components/issues/issue-layouts/empty-states/index.tsx @@ -0,0 +1,37 @@ +import { EIssuesStoreType } from "@/constants/issue"; +// components +import { ProjectArchivedEmptyState } from "./archived-issues"; +import { CycleEmptyState } from "./cycle"; +import { ProjectDraftEmptyState } from "./draft-issues"; +import { GlobalViewEmptyState } from "./global-view"; +import { ModuleEmptyState } from "./module"; +import { ProfileViewEmptyState } from "./profile-view"; +import { ProjectEmptyState } from "./project-issues"; +import { ProjectViewEmptyState } from "./project-view"; + +interface Props { + storeType: EIssuesStoreType; +} + +export const IssueLayoutEmptyState = (props: Props) => { + switch (props.storeType) { + case EIssuesStoreType.PROJECT: + return ; + case EIssuesStoreType.PROJECT_VIEW: + return ; + case EIssuesStoreType.ARCHIVED: + return ; + case EIssuesStoreType.CYCLE: + return ; + case EIssuesStoreType.MODULE: + return ; + case EIssuesStoreType.DRAFT: + return ; + case EIssuesStoreType.GLOBAL: + return ; + case EIssuesStoreType.PROFILE: + return ; + default: + return null; + } +}; diff --git a/web/components/issues/issue-layouts/empty-states/module.tsx b/web/components/issues/issue-layouts/empty-states/module.tsx index ba5070abc..180a00929 100644 --- a/web/components/issues/issue-layouts/empty-states/module.tsx +++ b/web/components/issues/issue-layouts/empty-states/module.tsx @@ -1,9 +1,11 @@ "use client"; import { useState } from "react"; +import size from "lodash/size"; import { observer } from "mobx-react-lite"; +import { useParams } from "next/navigation"; // types -import { ISearchIssueResponse, TIssueLayouts } from "@plane/types"; +import { IIssueFilterOptions, ISearchIssueResponse } from "@plane/types"; // ui import { TOAST_TYPE, setToast } from "@plane/ui"; // components @@ -11,28 +13,23 @@ import { ExistingIssuesListModal } from "@/components/core"; import { EmptyState } from "@/components/empty-state"; // constants import { EmptyStateType } from "@/constants/empty-state"; -import { EIssuesStoreType } from "@/constants/issue"; +import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; // hooks import { useCommandPalette, useEventTracker, useIssues } from "@/hooks/store"; -type Props = { - workspaceSlug: string | undefined; - projectId: string | undefined; - moduleId: string | undefined; - activeLayout: TIssueLayouts | undefined; - handleClearAllFilters: () => void; - isEmptyFilters?: boolean; -}; - -export const ModuleEmptyState: React.FC = observer((props) => { - const { workspaceSlug, projectId, moduleId, activeLayout, handleClearAllFilters, isEmptyFilters = false } = props; +export const ModuleEmptyState: React.FC = observer(() => { + // router + const { workspaceSlug, projectId, moduleId } = useParams(); // states const [moduleIssuesListModal, setModuleIssuesListModal] = useState(false); // store hooks - const { issues } = useIssues(EIssuesStoreType.MODULE); + const { issues, issuesFilter } = useIssues(EIssuesStoreType.MODULE); const { toggleCreateIssueModal } = useCommandPalette(); const { setTrackElement } = useEventTracker(); + const userFilters = issuesFilter?.issueFilters?.filters; + const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout; + const handleAddIssuesToModule = async (data: ISearchIssueResponse[]) => { if (!workspaceSlug || !projectId || !moduleId) return; @@ -55,14 +52,38 @@ export const ModuleEmptyState: React.FC = observer((props) => { ); }; + const issueFilterCount = size( + Object.fromEntries( + Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0) + ) + ); + + const handleClearAllFilters = () => { + if (!workspaceSlug || !projectId || !moduleId) return; + const newFilters: IIssueFilterOptions = {}; + Object.keys(userFilters ?? {}).forEach((key) => { + newFilters[key as keyof IIssueFilterOptions] = null; + }); + issuesFilter.updateFilters( + workspaceSlug.toString(), + projectId.toString(), + EIssueFilterType.FILTERS, + { + ...newFilters, + }, + moduleId.toString() + ); + }; + + const isEmptyFilters = issueFilterCount > 0; const emptyStateType = isEmptyFilters ? EmptyStateType.PROJECT_EMPTY_FILTER : EmptyStateType.PROJECT_MODULE_ISSUES; const additionalPath = activeLayout ?? "list"; return ( - <> +
    setModuleIssuesListModal(false)} searchParams={{ module: moduleId != undefined ? moduleId.toString() : "" }} @@ -83,6 +104,6 @@ export const ModuleEmptyState: React.FC = observer((props) => { secondaryButtonOnClick={isEmptyFilters ? handleClearAllFilters : () => setModuleIssuesListModal(true)} />
    - +
    ); }); diff --git a/web/components/issues/issue-layouts/empty-states/profile-view.tsx b/web/components/issues/issue-layouts/empty-states/profile-view.tsx new file mode 100644 index 000000000..2ed5b8163 --- /dev/null +++ b/web/components/issues/issue-layouts/empty-states/profile-view.tsx @@ -0,0 +1,20 @@ +import { observer } from "mobx-react-lite"; +// components +import { EmptyState } from "@/components/empty-state"; +// constants +import { EMPTY_STATE_DETAILS } from "@/constants/empty-state"; +// hooks +import { useAppRouter } from "@/hooks/store"; + +// assets + +export const ProfileViewEmptyState: React.FC = observer(() => { + // store hooks + const { profileViewId } = useAppRouter(); + + if (!profileViewId) return null; + + const emptyStateType = `profile-${profileViewId}`; + + return ; +}); diff --git a/web/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx b/web/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx index 7fb95d95e..d196c09f6 100644 --- a/web/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx +++ b/web/components/issues/issue-layouts/filters/header/display-filters/display-filters-selection.tsx @@ -1,7 +1,7 @@ import React from "react"; +import isEmpty from "lodash/isEmpty"; import { observer } from "mobx-react-lite"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssueGroupByOptions } from "@plane/types"; - // components import { FilterDisplayProperties, @@ -96,7 +96,7 @@ export const DisplayFiltersSelection: React.FC = observer((props) => { )} {/* order by */} - {isDisplayFilterEnabled("order_by") && ( + {isDisplayFilterEnabled("order_by") && !isEmpty(layoutDisplayFiltersOptions?.display_filters?.order_by) && (
    void; - selectedLayout: TIssueLayouts | undefined; + layouts: EIssueLayoutTypes[]; + onChange: (layout: EIssueLayoutTypes) => void; + selectedLayout: EIssueLayoutTypes | undefined; }; export const LayoutSelection: React.FC = (props) => { diff --git a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx index 4709a0699..5a7e275f7 100644 --- a/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx +++ b/web/components/issues/issue-layouts/gantt/base-gantt-root.tsx @@ -1,48 +1,73 @@ -import React from "react"; +import React, { useCallback, useEffect } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import { TIssue, TUnGroupedIssues } from "@plane/types"; +import { TIssue } from "@plane/types"; // hooks -import { GanttChartRoot, IBlockUpdateData, IssueGanttSidebar } from "@/components/gantt-chart"; +import { ChartDataType, GanttChartRoot, IBlockUpdateData, IssueGanttSidebar } from "@/components/gantt-chart"; +import { getMonthChartItemPositionWidthInMonth } from "@/components/gantt-chart/views"; import { GanttQuickAddIssueForm, IssueGanttBlock } from "@/components/issues"; -import { EIssuesStoreType } from "@/constants/issue"; +//constants +import { ALL_ISSUES, EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; -import { renderIssueBlocksStructure } from "@/helpers/issue.helper"; +import { getIssueBlocksStructure } from "@/helpers/issue.helper"; +//hooks import { useIssues, useUser } from "@/hooks/store"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import { useIssuesActions } from "@/hooks/use-issues-actions"; -// components -// helpers -// types -// constants + +import { IssueLayoutHOC } from "../issue-layout-HOC"; + +interface IBaseGanttRoot { + viewId?: string | undefined; +} type GanttStoreType = | EIssuesStoreType.PROJECT | EIssuesStoreType.MODULE | EIssuesStoreType.CYCLE | EIssuesStoreType.PROJECT_VIEW; -interface IBaseGanttRoot { - viewId?: string; - storeType: GanttStoreType; -} export const BaseGanttRoot: React.FC = observer((props: IBaseGanttRoot) => { - const { viewId, storeType } = props; + const { viewId } = props; // router const { workspaceSlug } = useParams(); - const { issues, issuesFilter } = useIssues(storeType); - const { updateIssue } = useIssuesActions(storeType); + const storeType = useIssueStoreType() as GanttStoreType; + const { issues, issuesFilter, issueMap } = useIssues(storeType); + const { fetchIssues, fetchNextIssues, updateIssue, quickAddIssue } = useIssuesActions(storeType); // store hooks const { membership: { currentProjectRole }, } = useUser(); - const { issueMap } = useIssues(); const appliedDisplayFilters = issuesFilter.issueFilters?.displayFilters; - const issueIds = (issues.groupedIssueIds ?? []) as TUnGroupedIssues; + useEffect(() => { + fetchIssues("init-loader", { canGroup: false, perPageCount: 100 }, viewId); + }, [fetchIssues, storeType, viewId]); + + const issuesIds = (issues.groupedIssueIds?.[ALL_ISSUES] as string[]) ?? []; + const nextPageResults = issues.getPaginationData(undefined, undefined)?.nextPageResults; + const { enableIssueCreation } = issues?.viewFlags || {}; - const issuesArray = issueIds.map((id) => issueMap?.[id]); + const loadMoreIssues = useCallback(() => { + fetchNextIssues(); + }, [fetchNextIssues]); + + const getBlockById = useCallback( + (id: string, currentViewData?: ChartDataType | undefined) => { + const issue = issueMap[id]; + const block = getIssueBlocksStructure(issue); + if (currentViewData) { + return { + ...block, + position: getMonthChartItemPositionWidthInMonth(currentViewData, block), + }; + } + return block; + }, + [issueMap] + ); const updateIssueBlockStructure = async (issue: TIssue, data: IBlockUpdateData) => { if (!workspaceSlug) return; @@ -56,13 +81,14 @@ export const BaseGanttRoot: React.FC = observer((props: IBaseGan const isAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; return ( - <> +
    } sidebarToRender={(props) => } @@ -73,13 +99,13 @@ export const BaseGanttRoot: React.FC = observer((props: IBaseGan enableAddBlock={isAllowed} enableSelection={false} quickAdd={ - enableIssueCreation && isAllowed ? ( - - ) : undefined + enableIssueCreation && isAllowed ? : undefined } + loadMoreBlocks={loadMoreIssues} + canLoadMoreBlocks={nextPageResults} showAllBlocks />
    - +
    ); }); diff --git a/web/components/issues/issue-layouts/gantt/blocks.tsx b/web/components/issues/issue-layouts/gantt/blocks.tsx index 67db7df43..459807aab 100644 --- a/web/components/issues/issue-layouts/gantt/blocks.tsx +++ b/web/components/issues/issue-layouts/gantt/blocks.tsx @@ -32,6 +32,7 @@ export const IssueGanttBlock: React.FC = observer((props) => { workspaceSlug && issueDetails && !issueDetails.tempId && + issueDetails.project_id && !getIsIssuePeeked(issueDetails.id) && setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id }); const { isMobile } = usePlatformOS(); @@ -86,6 +87,7 @@ export const IssueGanttSidebarBlock: React.FC = observer((props) => { const handleIssuePeekOverview = () => workspaceSlug && issueDetails && + issueDetails.project_id && setPeekIssue({ workspaceSlug, projectId: issueDetails.project_id, issueId: issueDetails.id }); const { isMobile } = usePlatformOS(); diff --git a/web/components/issues/issue-layouts/gantt/cycle-root.tsx b/web/components/issues/issue-layouts/gantt/cycle-root.tsx deleted file mode 100644 index c0dab31b4..000000000 --- a/web/components/issues/issue-layouts/gantt/cycle-root.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// hooks -import { EIssuesStoreType } from "@/constants/issue"; -// components -import { BaseGanttRoot } from "./base-gantt-root"; - -export const CycleGanttLayout: React.FC = observer(() => { - // router - const { cycleId } = useParams(); - - return ; -}); diff --git a/web/components/issues/issue-layouts/gantt/index.ts b/web/components/issues/issue-layouts/gantt/index.ts index c598e624a..5e741d30d 100644 --- a/web/components/issues/issue-layouts/gantt/index.ts +++ b/web/components/issues/issue-layouts/gantt/index.ts @@ -1,6 +1,3 @@ export * from "./blocks"; -export * from "./cycle-root"; +export * from "./base-gantt-root"; export * from "./quick-add-issue-form"; -export * from "./module-root"; -export * from "./project-root"; -export * from "./project-view-root"; diff --git a/web/components/issues/issue-layouts/gantt/module-root.tsx b/web/components/issues/issue-layouts/gantt/module-root.tsx deleted file mode 100644 index 95fff2a4f..000000000 --- a/web/components/issues/issue-layouts/gantt/module-root.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// hooks -import { EIssuesStoreType } from "@/constants/issue"; -// components -import { BaseGanttRoot } from "./base-gantt-root"; - -export const ModuleGanttLayout: React.FC = observer(() => { - // router - const { moduleId } = useParams(); - - return ; -}); diff --git a/web/components/issues/issue-layouts/gantt/project-root.tsx b/web/components/issues/issue-layouts/gantt/project-root.tsx deleted file mode 100644 index 35e8e9b38..000000000 --- a/web/components/issues/issue-layouts/gantt/project-root.tsx +++ /dev/null @@ -1,8 +0,0 @@ -import React from "react"; -import { observer } from "mobx-react"; -// hooks -import { EIssuesStoreType } from "@/constants/issue"; -// components -import { BaseGanttRoot } from "./base-gantt-root"; - -export const GanttLayout: React.FC = observer(() => ); diff --git a/web/components/issues/issue-layouts/gantt/project-view-root.tsx b/web/components/issues/issue-layouts/gantt/project-view-root.tsx deleted file mode 100644 index 5cf263417..000000000 --- a/web/components/issues/issue-layouts/gantt/project-view-root.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; -// hooks -import { EIssuesStoreType } from "@/constants/issue"; -// components -import { BaseGanttRoot } from "./base-gantt-root"; -// constants -// types - -export const ProjectViewGanttLayout: React.FC = observer(() => { - // router - const { viewId } = useParams(); - - return ; -}); diff --git a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx index e56254111..9cb02b677 100644 --- a/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/gantt/quick-add-issue-form.tsx @@ -52,13 +52,7 @@ const Inputs: FC = (props) => { type IGanttQuickAddIssueForm = { prePopulatedData?: Partial; onSuccess?: (data: TIssue) => Promise | void; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; - viewId?: string; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; }; const defaultValues: Partial = { @@ -66,7 +60,7 @@ const defaultValues: Partial = { }; export const GanttQuickAddIssueForm: React.FC = observer((props) => { - const { prePopulatedData, quickAddCallback, viewId } = props; + const { prePopulatedData, quickAddCallback } = props; // router const { workspaceSlug, projectId } = useParams(); const pathname = usePathname(); @@ -113,7 +107,7 @@ export const GanttQuickAddIssueForm: React.FC = observe }); if (quickAddCallback) { - const quickAddPromise = quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId); + const quickAddPromise = quickAddCallback(projectId.toString(), { ...payload }); setPromiseToast(quickAddPromise, { loading: "Adding issue...", success: { diff --git a/web/components/issues/issue-layouts/issue-layout-HOC.tsx b/web/components/issues/issue-layouts/issue-layout-HOC.tsx new file mode 100644 index 000000000..be911f757 --- /dev/null +++ b/web/components/issues/issue-layouts/issue-layout-HOC.tsx @@ -0,0 +1,54 @@ +import { observer } from "mobx-react"; +import { + CalendarLayoutLoader, + GanttLayoutLoader, + KanbanLayoutLoader, + ListLayoutLoader, + SpreadsheetLayoutLoader, +} from "@/components/ui"; +import { EIssueLayoutTypes } from "@/constants/issue"; +import { useIssues } from "@/hooks/store"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; +import { IssueLayoutEmptyState } from "./empty-states"; + +const ActiveLoader = (props: { layout: EIssueLayoutTypes }) => { + const { layout } = props; + switch (layout) { + case EIssueLayoutTypes.LIST: + return ; + case EIssueLayoutTypes.KANBAN: + return ; + case EIssueLayoutTypes.SPREADSHEET: + return ; + case EIssueLayoutTypes.CALENDAR: + return ; + case EIssueLayoutTypes.GANTT: + return ; + default: + return null; + } +}; + +interface Props { + children: string | JSX.Element | JSX.Element[]; + layout: EIssueLayoutTypes; +} + +export const IssueLayoutHOC = observer((props: Props) => { + const { layout } = props; + + const storeType = useIssueStoreType(); + const { issues } = useIssues(storeType); + + const issueCount = issues.getGroupIssueCount(undefined, undefined, false); + + if (issues?.getIssueLoader() === "init-loader" || issueCount === undefined) { + return ; + } + + if (issues.getGroupIssueCount(undefined, undefined, false) === 0 && layout !== EIssueLayoutTypes.CALENDAR) { + return ; + } + + return <>{props.children}; +}); diff --git a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx index c2e3830d4..fdfdabaa4 100644 --- a/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx +++ b/web/components/issues/issue-layouts/kanban/base-kanban-root.tsx @@ -4,20 +4,23 @@ import { FC, useCallback, useEffect, useRef, useState } from "react"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; +import debounce from "lodash/debounce"; import { observer } from "mobx-react-lite"; import { useParams, usePathname } from "next/navigation"; -import { Spinner } from "@plane/ui"; import { DeleteIssueModal } from "@/components/issues"; +//constants import { ISSUE_DELETED } from "@/constants/event-tracker"; -import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; +import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; -// hooks +//hooks import { useEventTracker, useIssueDetail, useIssues, useKanbanView, useUser } from "@/hooks/store"; import { useGroupIssuesDragNDrop } from "@/hooks/use-group-dragndrop"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import { useIssuesActions } from "@/hooks/use-issues-actions"; // store // ui // types +import { IssueLayoutHOC } from "../issue-layout-HOC"; import { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types"; //components import { getSourceFromDropPayload } from "../utils"; @@ -33,28 +36,19 @@ export type KanbanStoreType = | EIssuesStoreType.PROFILE; export interface IBaseKanBanLayout { QuickActions: FC; - showLoader?: boolean; - viewId?: string; - storeType: KanbanStoreType; addIssuesToView?: (issueIds: string[]) => Promise; canEditPropertiesBasedOnProject?: (projectId: string) => boolean; isCompletedCycle?: boolean; + viewId?: string | undefined; } export const BaseKanBanRoot: React.FC = observer((props: IBaseKanBanLayout) => { - const { - QuickActions, - showLoader, - viewId, - storeType, - addIssuesToView, - canEditPropertiesBasedOnProject, - isCompletedCycle = false, - } = props; + const { QuickActions, addIssuesToView, canEditPropertiesBasedOnProject, isCompletedCycle = false, viewId } = props; // router const { workspaceSlug, projectId } = useParams(); const pathname = usePathname(); // store hooks + const storeType = useIssueStoreType() as KanbanStoreType; const { membership: { currentProjectRole }, } = useUser(); @@ -63,16 +57,23 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas const { issue: { getIssueById }, } = useIssueDetail(); - const { updateIssue, removeIssue, removeIssueFromView, archiveIssue, restoreIssue, updateFilters } = - useIssuesActions(storeType); + const { + fetchIssues, + fetchNextIssues, + quickAddIssue, + updateIssue, + removeIssue, + removeIssueFromView, + archiveIssue, + restoreIssue, + updateFilters, + } = useIssuesActions(storeType); const deleteAreaRef = useRef(null); const [isDragOverDelete, setIsDragOverDelete] = useState(false); const { isDragging } = useKanbanView(); - const issueIds = issues?.groupedIssueIds || []; - const displayFilters = issuesFilter?.issueFilters?.displayFilters; const displayProperties = issuesFilter?.issueFilters?.displayProperties; @@ -81,6 +82,27 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas const orderBy = displayFilters?.order_by; + useEffect(() => { + fetchIssues("init-loader", { canGroup: true, perPageCount: sub_group_by ? 10 : 30 }, viewId); + }, [fetchIssues, storeType, group_by, sub_group_by, viewId]); + + const fetchMoreIssues = useCallback( + (groupId?: string, subgroupId?: string) => { + if (issues?.getIssueLoader(groupId, subgroupId) !== "pagination") { + fetchNextIssues(groupId, subgroupId); + } + }, + [fetchNextIssues] + ); + + const debouncedFetchMoreIssues = debounce( + (groupId?: string, subgroupId?: string) => fetchMoreIssues(groupId, subgroupId), + 300, + { leading: true, trailing: false } + ); + + const groupedIssueIds = issues?.groupedIssueIds; + const userDisplayFilters = displayFilters || null; const KanBanView = sub_group_by ? KanBanSwimLanes : KanBan; @@ -200,7 +222,7 @@ export const BaseKanBanRoot: React.FC = observer((props: IBas const kanbanFilters = issuesFilter?.issueFilters?.kanbanFilters || { group_by: [], sub_group_by: [] }; return ( - <> + = observer((props: IBas onSubmit={handleDeleteIssue} /> - {showLoader && issues?.loader === "init-loader" && ( -
    - -
    - )} -
    = observer((props: IBas
    = observer((props: IBas kanbanFilters={kanbanFilters} enableQuickIssueCreate={enableQuickAdd} showEmptyGroup={userDisplayFilters?.show_empty_groups ?? true} - quickAddCallback={issues?.quickAddIssue} - viewId={viewId} + quickAddCallback={quickAddIssue} disableIssueCreation={!enableIssueCreation || !isEditingAllowed || isCompletedCycle} canEditProperties={canEditProperties} - storeType={storeType} addIssuesToView={addIssuesToView} scrollableContainerRef={scrollableContainerRef} handleOnDrop={handleOnDrop} + loadMoreIssues={debouncedFetchMoreIssues} />
    - + ); }); diff --git a/web/components/issues/issue-layouts/kanban/block.tsx b/web/components/issues/issue-layouts/kanban/block.tsx index a4e450851..c4c8c0f12 100644 --- a/web/components/issues/issue-layouts/kanban/block.tsx +++ b/web/components/issues/issue-layouts/kanban/block.tsx @@ -29,10 +29,9 @@ interface IssueBlockProps { subGroupId: string; issuesMap: IIssueMap; displayProperties: IIssueDisplayProperties | undefined; - isDragDisabled: boolean; draggableId: string; canDropOverIssue: boolean; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject; @@ -42,7 +41,7 @@ interface IssueDetailsBlockProps { cardRef: React.RefObject; issue: TIssue; displayProperties: IIssueDisplayProperties | undefined; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; isReadOnly: boolean; } @@ -110,7 +109,6 @@ export const KanbanIssueBlock: React.FC = observer((props) => { subGroupId, issuesMap, displayProperties, - isDragDisabled, canDropOverIssue, updateIssue, quickActions, @@ -139,9 +137,9 @@ export const KanbanIssueBlock: React.FC = observer((props) => { const [isDraggingOverBlock, setIsDraggingOverBlock] = useState(false); const [isCurrentBlockDragging, setIsCurrentBlockDragging] = useState(false); - const canEditIssueProperties = canEditProperties(issue?.project_id); + const canEditIssueProperties = canEditProperties(issue?.project_id ?? undefined); - const isDragAllowed = !isDragDisabled && !issue?.tempId && canEditIssueProperties; + const isDragAllowed = !issue?.tempId && canEditIssueProperties; useOutsideClickDetector(cardRef, () => { cardRef?.current?.classList?.remove(HIGHLIGHT_CLASS); diff --git a/web/components/issues/issue-layouts/kanban/blocks-list.tsx b/web/components/issues/issue-layouts/kanban/blocks-list.tsx index db2e55c8b..7eddea078 100644 --- a/web/components/issues/issue-layouts/kanban/blocks-list.tsx +++ b/web/components/issues/issue-layouts/kanban/blocks-list.tsx @@ -1,4 +1,5 @@ -import { MutableRefObject, memo } from "react"; +import { MutableRefObject } from "react"; +import { observer } from "mobx-react"; //types import { TIssue, IIssueDisplayProperties, IIssueMap } from "@plane/types"; import { KanbanIssueBlock } from "@/components/issues"; @@ -11,22 +12,20 @@ interface IssueBlocksListProps { issuesMap: IIssueMap; issueIds: string[]; displayProperties: IIssueDisplayProperties | undefined; - isDragDisabled: boolean; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; canEditProperties: (projectId: string | undefined) => boolean; canDropOverIssue: boolean; scrollableContainerRef?: MutableRefObject; } -const KanbanIssueBlocksListMemo: React.FC = (props) => { +export const KanbanIssueBlocksList: React.FC = observer((props) => { const { sub_group_id, groupId, issuesMap, issueIds, displayProperties, - isDragDisabled, canDropOverIssue, updateIssue, quickActions, @@ -56,7 +55,6 @@ const KanbanIssueBlocksListMemo: React.FC = (props) => { updateIssue={updateIssue} quickActions={quickActions} draggableId={draggableId} - isDragDisabled={isDragDisabled} canDropOverIssue={canDropOverIssue} canEditProperties={canEditProperties} scrollableContainerRef={scrollableContainerRef} @@ -67,6 +65,4 @@ const KanbanIssueBlocksListMemo: React.FC = (props) => { ) : null} ); -}; - -export const KanbanIssueBlocksList = memo(KanbanIssueBlocksListMemo); +}); diff --git a/web/components/issues/issue-layouts/kanban/default.tsx b/web/components/issues/issue-layouts/kanban/default.tsx index 995c86a78..a8a21a299 100644 --- a/web/components/issues/issue-layouts/kanban/default.tsx +++ b/web/components/issues/issue-layouts/kanban/default.tsx @@ -8,7 +8,6 @@ import { IIssueDisplayProperties, IIssueMap, TSubGroupedIssues, - TUnGroupedIssues, TIssueKanbanFilters, TIssueGroupByOptions, TIssueOrderByOptions, @@ -16,18 +15,23 @@ import { // constants // hooks import { useCycle, useKanbanView, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; // types // parent components import { TRenderQuickActions } from "../list/list-view-types"; import { getGroupByColumns, isWorkspaceLevel, GroupDropLocation } from "../utils"; // components -import { KanbanStoreType } from "./base-kanban-root"; import { HeaderGroupByCard } from "./headers/group-by-card"; import { KanbanGroup } from "./kanban-group"; export interface IKanBan { issuesMap: IIssueMap; - issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; displayProperties: IIssueDisplayProperties | undefined; sub_group_by: TIssueGroupByOptions | undefined; group_by: TIssueGroupByOptions | undefined; @@ -35,20 +39,14 @@ export interface IKanBan { isDropDisabled?: boolean; dropErrorMessage?: string | undefined; sub_group_id?: string; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: any; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; enableQuickIssueCreate?: boolean; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; - viewId?: string; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; disableIssueCreation?: boolean; - storeType: KanbanStoreType; addIssuesToView?: (issueIds: string[]) => Promise; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject; @@ -60,7 +58,8 @@ export interface IKanBan { export const KanBan: React.FC = observer((props) => { const { issuesMap, - issueIds, + groupedIssueIds, + getGroupIssueCount, displayProperties, sub_group_by, group_by, @@ -71,9 +70,8 @@ export const KanBan: React.FC = observer((props) => { handleKanbanFilters, enableQuickIssueCreate, quickAddCallback, - viewId, + loadMoreIssues, disableIssueCreation, - storeType, addIssuesToView, canEditProperties, scrollableContainerRef, @@ -85,6 +83,8 @@ export const KanBan: React.FC = observer((props) => { dropErrorMessage, } = props; + const storeType = useIssueStoreType(); + const member = useMember(); const project = useProject(); const label = useLabel(); @@ -125,7 +125,7 @@ export const KanBan: React.FC = observer((props) => { showIssues: true, }; if (!showEmptyGroup) { - if ((issueIds as TGroupedIssues)?.[_list.id]?.length > 0) groupVisibility.showGroup = true; + if ((getGroupIssueCount(_list.id, undefined, false) ?? 0) > 0) groupVisibility.showGroup = true; else groupVisibility.showGroup = false; } if (kanbanFilters?.group_by.includes(_list.id)) groupVisibility.showIssues = false; @@ -158,10 +158,9 @@ export const KanBan: React.FC = observer((props) => { column_id={subList.id} icon={subList.icon} title={subList.name} - count={(issueIds as TGroupedIssues)?.[subList.id]?.length || 0} + count={getGroupIssueCount(subList.id, undefined, false) ?? 0} issuePayload={subList.payload} disableIssueCreation={disableIssueCreation || isGroupByCreatedBy} - storeType={storeType} addIssuesToView={addIssuesToView} kanbanFilters={kanbanFilters} handleKanbanFilters={handleKanbanFilters} @@ -173,7 +172,7 @@ export const KanBan: React.FC = observer((props) => { = observer((props) => { quickActions={quickActions} enableQuickIssueCreate={enableQuickIssueCreate} quickAddCallback={quickAddCallback} - viewId={viewId} disableIssueCreation={disableIssueCreation} canEditProperties={canEditProperties} scrollableContainerRef={scrollableContainerRef} + loadMoreIssues={loadMoreIssues} handleOnDrop={handleOnDrop} /> )} @@ -198,4 +197,4 @@ export const KanBan: React.FC = observer((props) => { })}
    ); -}); +}); \ No newline at end of file diff --git a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx index feb0310fd..499880cf9 100644 --- a/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/kanban/headers/group-by-card.tsx @@ -14,8 +14,8 @@ import { CreateUpdateIssueModal } from "@/components/issues"; // constants // hooks import { useEventTracker } from "@/hooks/store"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; // types -import { KanbanStoreType } from "../base-kanban-root"; interface IHeaderGroupByCard { sub_group_by: TIssueGroupByOptions | undefined; @@ -28,7 +28,6 @@ interface IHeaderGroupByCard { handleKanbanFilters: any; issuePayload: Partial; disableIssueCreation?: boolean; - storeType: KanbanStoreType; addIssuesToView?: (issueIds: string[]) => Promise; } @@ -43,7 +42,6 @@ export const HeaderGroupByCard: FC = observer((props) => { handleKanbanFilters, issuePayload, disableIssueCreation, - storeType, addIssuesToView, } = props; const verticalAlignPosition = sub_group_by ? false : kanbanFilters?.group_by.includes(column_id); @@ -51,6 +49,7 @@ export const HeaderGroupByCard: FC = observer((props) => { const [isOpen, setIsOpen] = React.useState(false); const [openExistingIssueListModal, setOpenExistingIssueListModal] = React.useState(false); // hooks + const storeType = useIssueStoreType(); const { setTrackElement } = useEventTracker(); // router const { workspaceSlug, projectId, moduleId, cycleId } = useParams(); diff --git a/web/components/issues/issue-layouts/kanban/kanban-group.tsx b/web/components/issues/issue-layouts/kanban/kanban-group.tsx index 1c7501aa3..5d4507ccc 100644 --- a/web/components/issues/issue-layouts/kanban/kanban-group.tsx +++ b/web/components/issues/issue-layouts/kanban/kanban-group.tsx @@ -1,6 +1,6 @@ "use client"; -import { MutableRefObject, useEffect, useRef, useState } from "react"; +import { MutableRefObject, useCallback, useEffect, useRef, useState } from "react"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { autoScrollForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/element"; @@ -12,17 +12,18 @@ import { IIssueDisplayProperties, IIssueMap, TSubGroupedIssues, - TUnGroupedIssues, TIssueGroupByOptions, TIssueOrderByOptions, } from "@plane/types"; import { TOAST_TYPE, setToast } from "@plane/ui"; import { highlightIssueOnDrop } from "@/components/issues/issue-layouts/utils"; +import { KanbanIssueBlockLoader } from "@/components/ui"; // helpers import { cn } from "@/helpers/common.helper"; // hooks import { useProjectState } from "@/hooks/store"; -//components +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; +import { useIssuesStore } from "@/hooks/use-issue-layout-store"; import { GroupDragOverlay } from "../group-drag-overlay"; import { TRenderQuickActions } from "../list/list-view-types"; import { GroupDropLocation, getSourceFromDropPayload, getDestinationFromDropPayload, getIssueBlockId } from "../utils"; @@ -31,7 +32,7 @@ import { KanbanIssueBlocksList, KanBanQuickAddIssueForm } from "."; interface IKanbanGroup { groupId: string; issuesMap: IIssueMap; - issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; +groupedIssueIds: TGroupedIssues | TSubGroupedIssues; displayProperties: IIssueDisplayProperties | undefined; sub_group_by: TIssueGroupByOptions | undefined; group_by: TIssueGroupByOptions | undefined; @@ -39,16 +40,11 @@ interface IKanbanGroup { isDragDisabled: boolean; isDropDisabled: boolean; dropErrorMessage: string | undefined; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; enableQuickIssueCreate?: boolean; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; - viewId?: string; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; disableIssueCreation?: boolean; canEditProperties: (projectId: string | undefined) => boolean; groupByVisibilityToggle?: boolean; @@ -66,26 +62,38 @@ export const KanbanGroup = observer((props: IKanbanGroup) => { sub_group_by, issuesMap, displayProperties, - issueIds, - isDragDisabled, + groupedIssueIds, isDropDisabled, dropErrorMessage, updateIssue, quickActions, canEditProperties, + loadMoreIssues, enableQuickIssueCreate, disableIssueCreation, quickAddCallback, - viewId, scrollableContainerRef, handleOnDrop, } = props; // hooks const projectState = useProjectState(); + const { + issues: { getGroupIssueCount, getPaginationData, getIssueLoader }, + } = useIssuesStore(); + + const [intersectionElement, setIntersectionElement] = useState(null); + const columnRef = useRef(null); + + const containerRef = sub_group_by && scrollableContainerRef ? scrollableContainerRef : columnRef; + + const loadMoreIssuesInThisGroup = useCallback(() => { + loadMoreIssues(groupId, sub_group_id === "null"? undefined: sub_group_id) + }, [loadMoreIssues, groupId, sub_group_id]) + + useIntersectionObserver(containerRef, intersectionElement, loadMoreIssuesInThisGroup, `0% 100% 100% 100%`); const [isDraggingOverColumn, setIsDraggingOverColumn] = useState(false); - const columnRef = useRef(null); // Enable Kanban Columns as Drop Targets useEffect(() => { @@ -198,7 +206,35 @@ export const KanbanGroup = observer((props: IKanbanGroup) => { return preloadedData; }; - const canOverlayBeVisible = orderBy !== "sort_order" || isDropDisabled; + const isSubGroup = !!sub_group_id && sub_group_id !== "null"; + + const issueIds = isSubGroup + ? (groupedIssueIds as TSubGroupedIssues)?.[groupId]?.[sub_group_id] ?? [] + : (groupedIssueIds as TGroupedIssues)?.[groupId] ?? []; + + const groupIssueCount = getGroupIssueCount(groupId, sub_group_id, false); + + const nextPageResults = getPaginationData(groupId, sub_group_id)?.nextPageResults; + + const isPaginating = !!getIssueLoader(groupId, sub_group_id); + + const loadMore = isPaginating ? ( + + ) : ( +
    + {" "} + Load More ↓ +
    + ); + + const shouldLoadMore = + nextPageResults === undefined && groupIssueCount !== undefined + ? issueIds?.length < groupIssueCount + : !!nextPageResults; + const canOverlayBeVisible = orderBy !== "sort_order" || isDropDisabled; const shouldOverlayBeVisible = isDraggingOverColumn && canOverlayBeVisible; return ( @@ -223,16 +259,17 @@ export const KanbanGroup = observer((props: IKanbanGroup) => { sub_group_id={sub_group_id} groupId={groupId} issuesMap={issuesMap} - issueIds={(issueIds as TGroupedIssues)?.[groupId] || []} + issueIds={issueIds || []} displayProperties={displayProperties} - isDragDisabled={isDragDisabled} updateIssue={updateIssue} quickActions={quickActions} canEditProperties={canEditProperties} - scrollableContainerRef={sub_group_by ? scrollableContainerRef : columnRef} + scrollableContainerRef={scrollableContainerRef} canDropOverIssue={!canOverlayBeVisible} /> +{shouldLoadMore && (isSubGroup ? <>{loadMore} : )} + {enableQuickIssueCreate && !disableIssueCreation && (
    { ...(group_by && prePopulateQuickAddData(group_by, sub_group_by, groupId, sub_group_id)), }} quickAddCallback={quickAddCallback} - viewId={viewId} />
    )} diff --git a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx index 772c84c10..f0914fcdd 100644 --- a/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/kanban/quick-add-issue-form.tsx @@ -45,13 +45,7 @@ interface IKanBanQuickAddIssueForm { groupId?: string; subGroupId?: string | null; prePopulatedData?: Partial; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; - viewId?: string; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; } const defaultValues: Partial = { @@ -59,7 +53,7 @@ const defaultValues: Partial = { }; export const KanBanQuickAddIssueForm: React.FC = observer((props) => { - const { formKey, prePopulatedData, quickAddCallback, viewId } = props; + const { formKey, prePopulatedData, quickAddCallback } = props; // router const { workspaceSlug, projectId } = useParams(); const pathname = usePathname(); @@ -100,14 +94,9 @@ export const KanBanQuickAddIssueForm: React.FC = obser }); if (quickAddCallback) { - const quickAddPromise = quickAddCallback( - workspaceSlug.toString(), - projectId.toString(), - { - ...payload, - }, - viewId - ); + const quickAddPromise = quickAddCallback(projectId.toString(), { + ...payload, + }); setPromiseToast(quickAddPromise, { loading: "Adding issue...", success: { diff --git a/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx index 9ad7ea8fc..fc6b94f21 100644 --- a/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/cycle-root.tsx @@ -42,13 +42,11 @@ export const CycleKanBanLayout: React.FC = observer(() => { return ( ); }); diff --git a/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx b/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx index f13fda5da..008b38686 100644 --- a/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/draft-issue-root.tsx @@ -1,11 +1,8 @@ import { observer } from "mobx-react"; // components import { DraftIssueQuickActions } from "@/components/issues"; -import { EIssuesStoreType } from "@/constants/issue"; import { BaseKanBanRoot } from "../base-kanban-root"; export interface IKanBanLayout {} -export const DraftKanBanLayout: React.FC = observer(() => ( - -)); +export const DraftKanBanLayout: React.FC = observer(() => ); diff --git a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx index f454bee4e..1ef083c9e 100644 --- a/web/components/issues/issue-layouts/kanban/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/module-root.tsx @@ -20,14 +20,12 @@ export const ModuleKanBanLayout: React.FC = observer(() => { return ( { if (!workspaceSlug || !projectId || !moduleId) throw new Error(); return issues.addIssuesToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds); }} + viewId={moduleId?.toString()} /> ); }); diff --git a/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx b/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx index c7e4d3ad5..de9778377 100644 --- a/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/profile-issues-root.tsx @@ -1,9 +1,8 @@ import { observer } from "mobx-react"; // hooks import { ProjectIssueQuickActions } from "@/components/issues"; -import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; -import { useUser } from "@/hooks/store"; +import { useAppRouter, useUser } from "@/hooks/store"; // components // types // constants @@ -13,6 +12,7 @@ export const ProfileIssuesKanBanLayout: React.FC = observer(() => { const { membership: { currentWorkspaceAllProjectsRole }, } = useUser(); + const { profileViewId } = useAppRouter(); const canEditPropertiesBasedOnProject = (projectId: string) => { const currentProjectRole = currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId]; @@ -22,10 +22,9 @@ export const ProfileIssuesKanBanLayout: React.FC = observer(() => { return ( ); }); diff --git a/web/components/issues/issue-layouts/kanban/roots/project-root.tsx b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx index 8d7c35f38..76209419c 100644 --- a/web/components/issues/issue-layouts/kanban/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-root.tsx @@ -1,12 +1,9 @@ import { observer } from "mobx-react"; // mobx store import { ProjectIssueQuickActions } from "@/components/issues"; -import { EIssuesStoreType } from "@/constants/issue"; // components // types // constants import { BaseKanBanRoot } from "../base-kanban-root"; -export const KanBanLayout: React.FC = observer(() => ( - -)); +export const KanBanLayout: React.FC = observer(() => ); diff --git a/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx b/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx index 433dbf9f4..585c68c28 100644 --- a/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/kanban/roots/project-view-root.tsx @@ -1,24 +1,12 @@ import React from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // hooks -import { EIssuesStoreType } from "@/constants/issue"; // constant // types import { ProjectIssueQuickActions } from "../../quick-action-dropdowns"; // components import { BaseKanBanRoot } from "../base-kanban-root"; -export const ProjectViewKanBanLayout: React.FC = observer(() => { - // router - const { viewId } = useParams(); - - return ( - - ); -}); +export const ProjectViewKanBanLayout: React.FC = observer(() => ( + +)); diff --git a/web/components/issues/issue-layouts/kanban/swimlanes.tsx b/web/components/issues/issue-layouts/kanban/swimlanes.tsx index 08fc2b3c4..8f11dbdcb 100644 --- a/web/components/issues/issue-layouts/kanban/swimlanes.tsx +++ b/web/components/issues/issue-layouts/kanban/swimlanes.tsx @@ -8,16 +8,16 @@ import { IIssueDisplayProperties, IIssueMap, TSubGroupedIssues, - TUnGroupedIssues, TIssueKanbanFilters, TIssueGroupByOptions, TIssueOrderByOptions, } from "@plane/types"; -// components +// hooks import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; +// components import { TRenderQuickActions } from "../list/list-view-types"; import { getGroupByColumns, isWorkspaceLevel, GroupDropLocation } from "../utils"; -import { KanbanStoreType } from "./base-kanban-root"; import { KanBan } from "./default"; import { HeaderGroupByCard } from "./headers/group-by-card"; import { HeaderSubGroupByCard } from "./headers/sub-group-by-card"; @@ -25,149 +25,123 @@ import { HeaderSubGroupByCard } from "./headers/sub-group-by-card"; // constants interface ISubGroupSwimlaneHeader { - issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; sub_group_by: TIssueGroupByOptions | undefined; group_by: TIssueGroupByOptions | undefined; list: IGroupByColumn[]; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; - storeType: KanbanStoreType; showEmptyGroup: boolean; } -const getSubGroupHeaderIssuesCount = (issueIds: TSubGroupedIssues, groupById: string) => { - let headerCount = 0; - Object.keys(issueIds).map((groupState) => { - headerCount = headerCount + (issueIds?.[groupState]?.[groupById]?.length || 0); - }); - return headerCount; -}; - -const visibilitySubGroupByGroupCount = ( - issueIds: TSubGroupedIssues, - _list: IGroupByColumn, - showEmptyGroup: boolean -): boolean => { +const visibilitySubGroupByGroupCount = (subGroupIssueCount: number, showEmptyGroup: boolean): boolean => { let subGroupHeaderVisibility = true; if (showEmptyGroup) subGroupHeaderVisibility = true; else { - if (getSubGroupHeaderIssuesCount(issueIds, _list.id) > 0) subGroupHeaderVisibility = true; + if (subGroupIssueCount > 0) subGroupHeaderVisibility = true; else subGroupHeaderVisibility = false; } return subGroupHeaderVisibility; }; -const SubGroupSwimlaneHeader: React.FC = ({ - issueIds, - sub_group_by, - group_by, - storeType, - list, - kanbanFilters, - handleKanbanFilters, - showEmptyGroup, -}) => ( -
    - {list && - list.length > 0 && - list.map((_list: IGroupByColumn) => { - const subGroupByVisibilityToggle = visibilitySubGroupByGroupCount( - issueIds as TSubGroupedIssues, - _list, - showEmptyGroup - ); +const SubGroupSwimlaneHeader: React.FC = observer( + ({ getGroupIssueCount, sub_group_by, group_by, list, kanbanFilters, handleKanbanFilters, showEmptyGroup }) => ( +
    + {list && + list.length > 0 && + list.map((_list: IGroupByColumn) => { + const groupCount = getGroupIssueCount(_list?.id, undefined, false) ?? 0; - if (subGroupByVisibilityToggle === false) return <>; + const subGroupByVisibilityToggle = visibilitySubGroupByGroupCount(groupCount, showEmptyGroup); - return ( -
    - -
    - ); - })} -
    + if (subGroupByVisibilityToggle === false) return <>; + + return ( +
    + +
    + ); + })} +
    + ) ); interface ISubGroupSwimlane extends ISubGroupSwimlaneHeader { issuesMap: IIssueMap; - issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; showEmptyGroup: boolean; displayProperties: IIssueDisplayProperties | undefined; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise; disableIssueCreation?: boolean; - storeType: KanbanStoreType; enableQuickIssueCreate: boolean; orderBy: TIssueOrderByOptions | undefined; canEditProperties: (projectId: string | undefined) => boolean; addIssuesToView?: (issueIds: string[]) => Promise; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; - viewId?: string; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; scrollableContainerRef?: MutableRefObject; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; } + const SubGroupSwimlane: React.FC = observer((props) => { const { issuesMap, - issueIds, + groupedIssueIds, + getGroupIssueCount, sub_group_by, group_by, list, - storeType, updateIssue, quickActions, displayProperties, kanbanFilters, handleKanbanFilters, + loadMoreIssues, showEmptyGroup, enableQuickIssueCreate, canEditProperties, addIssuesToView, quickAddCallback, - viewId, scrollableContainerRef, handleOnDrop, orderBy, } = props; - const calculateIssueCount = (column_id: string) => { - let issueCount = 0; - const subGroupedIds = issueIds as TSubGroupedIssues; - subGroupedIds?.[column_id] && - Object.keys(subGroupedIds?.[column_id])?.forEach((_list: any) => { - issueCount += subGroupedIds?.[column_id]?.[_list]?.length || 0; - }); - return issueCount; - }; - - const visibilitySubGroupBy = (_list: IGroupByColumn): { showGroup: boolean; showIssues: boolean } => { + const visibilitySubGroupBy = ( + _list: IGroupByColumn, + subGroupCount: number + ): { showGroup: boolean; showIssues: boolean } => { const subGroupVisibility = { showGroup: true, showIssues: true, }; if (showEmptyGroup) subGroupVisibility.showGroup = true; else { - if (calculateIssueCount(_list.id) > 0) subGroupVisibility.showGroup = true; + if (subGroupCount > 0) subGroupVisibility.showGroup = true; else subGroupVisibility.showGroup = false; } if (kanbanFilters?.sub_group_by.includes(_list.id)) subGroupVisibility.showIssues = false; @@ -179,7 +153,8 @@ const SubGroupSwimlane: React.FC = observer((props) => { {list && list.length > 0 && list.map((_list: IGroupByColumn) => { - const subGroupByVisibilityToggle = visibilitySubGroupBy(_list); + const issueCount = getGroupIssueCount(undefined, _list.id, true) ?? 0; + const subGroupByVisibilityToggle = visibilitySubGroupBy(_list, issueCount); if (subGroupByVisibilityToggle.showGroup === false) return <>; return (
    @@ -189,7 +164,7 @@ const SubGroupSwimlane: React.FC = observer((props) => { column_id={_list.id} icon={_list.icon} title={_list.name || ""} - count={calculateIssueCount(_list.id)} + count={issueCount} kanbanFilters={kanbanFilters} handleKanbanFilters={handleKanbanFilters} /> @@ -200,12 +175,12 @@ const SubGroupSwimlane: React.FC = observer((props) => {
    = observer((props) => { canEditProperties={canEditProperties} addIssuesToView={addIssuesToView} quickAddCallback={quickAddCallback} - viewId={viewId} scrollableContainerRef={scrollableContainerRef} + loadMoreIssues={loadMoreIssues} handleOnDrop={handleOnDrop} orderBy={orderBy} isDropDisabled={_list.isDropDisabled} dropErrorMessage={_list.dropErrorMessage} subGroupIssueHeaderCount={(groupByListId: string) => - getSubGroupHeaderIssuesCount(issueIds as TSubGroupedIssues, groupByListId) + getGroupIssueCount(groupByListId, _list.id, true) ?? 0 } />
    @@ -236,27 +211,26 @@ const SubGroupSwimlane: React.FC = observer((props) => { export interface IKanBanSwimLanes { issuesMap: IIssueMap; - issueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues; + groupedIssueIds: TGroupedIssues | TSubGroupedIssues; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; displayProperties: IIssueDisplayProperties | undefined; sub_group_by: TIssueGroupByOptions | undefined; group_by: TIssueGroupByOptions | undefined; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; kanbanFilters: TIssueKanbanFilters; handleKanbanFilters: (toggle: "group_by" | "sub_group_by", value: string) => void; + loadMoreIssues: (groupId?: string, subGroupId?: string) => void; showEmptyGroup: boolean; handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise; disableIssueCreation?: boolean; - storeType: KanbanStoreType; addIssuesToView?: (issueIds: string[]) => Promise; enableQuickIssueCreate: boolean; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; - viewId?: string; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; canEditProperties: (projectId: string | undefined) => boolean; scrollableContainerRef?: MutableRefObject; orderBy: TIssueOrderByOptions | undefined; @@ -265,16 +239,17 @@ export interface IKanBanSwimLanes { export const KanBanSwimLanes: React.FC = observer((props) => { const { issuesMap, - issueIds, + groupedIssueIds, + getGroupIssueCount, displayProperties, sub_group_by, group_by, orderBy, updateIssue, - storeType, quickActions, kanbanFilters, handleKanbanFilters, + loadMoreIssues, showEmptyGroup, handleOnDrop, disableIssueCreation, @@ -282,10 +257,11 @@ export const KanBanSwimLanes: React.FC = observer((props) => { canEditProperties, addIssuesToView, quickAddCallback, - viewId, scrollableContainerRef, } = props; + const storeType = useIssueStoreType(); + const member = useMember(); const project = useProject(); const label = useLabel(); @@ -322,13 +298,12 @@ export const KanBanSwimLanes: React.FC = observer((props) => {
    @@ -337,7 +312,8 @@ export const KanBanSwimLanes: React.FC = observer((props) => { = observer((props) => { quickActions={quickActions} kanbanFilters={kanbanFilters} handleKanbanFilters={handleKanbanFilters} + loadMoreIssues={loadMoreIssues} showEmptyGroup={showEmptyGroup} handleOnDrop={handleOnDrop} disableIssueCreation={disableIssueCreation} @@ -353,9 +330,7 @@ export const KanBanSwimLanes: React.FC = observer((props) => { addIssuesToView={addIssuesToView} canEditProperties={canEditProperties} quickAddCallback={quickAddCallback} - viewId={viewId} scrollableContainerRef={scrollableContainerRef} - storeType={storeType} /> )}
    diff --git a/web/components/issues/issue-layouts/list/base-list-root.tsx b/web/components/issues/issue-layouts/list/base-list-root.tsx index 73385a094..dc6aa1d2d 100644 --- a/web/components/issues/issue-layouts/list/base-list-root.tsx +++ b/web/components/issues/issue-layouts/list/base-list-root.tsx @@ -1,13 +1,18 @@ -import { FC, useCallback } from "react"; +import { FC, useCallback, useEffect } from "react"; import { observer } from "mobx-react-lite"; +// types +import { GroupByColumnTypes, TGroupedIssues } from "@plane/types"; // constants -import { EIssuesStoreType } from "@/constants/issue"; +import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // hooks import { useIssues, useUser } from "@/hooks/store"; +// hooks import { useGroupIssuesDragNDrop } from "@/hooks/use-group-dragndrop"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import { useIssuesActions } from "@/hooks/use-issues-actions"; // components +import { IssueLayoutHOC } from "../issue-layout-HOC"; import { List } from "./default"; // types import { IQuickActionProps, TRenderQuickActions } from "./list-view-types"; @@ -22,32 +27,54 @@ type ListStoreType = | EIssuesStoreType.ARCHIVED; interface IBaseListRoot { QuickActions: FC; - viewId?: string; - storeType: ListStoreType; addIssuesToView?: (issueIds: string[]) => Promise; canEditPropertiesBasedOnProject?: (projectId: string) => boolean; + viewId?: string | undefined; isCompletedCycle?: boolean; } export const BaseListRoot = observer((props: IBaseListRoot) => { const { QuickActions, viewId, - storeType, addIssuesToView, canEditPropertiesBasedOnProject, isCompletedCycle = false, } = props; - // store hooks + // router + const storeType = useIssueStoreType() as ListStoreType; + //stores const { issuesFilter, issues } = useIssues(storeType); - const { updateIssue, removeIssue, removeIssueFromView, archiveIssue, restoreIssue } = useIssuesActions(storeType); + const { + fetchIssues, + fetchNextIssues, + quickAddIssue, + updateIssue, + removeIssue, + removeIssueFromView, + archiveIssue, + restoreIssue, + } = useIssuesActions(storeType); + // mobx store const { membership: { currentProjectRole }, } = useUser(); const { issueMap } = useIssues(); - // derived values - const issueIds = issues?.groupedIssueIds || []; - // auth - const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + + const displayFilters = issuesFilter?.issueFilters?.displayFilters; + const displayProperties = issuesFilter?.issueFilters?.displayProperties; + const orderBy = displayFilters?.order_by || undefined; + + const group_by = (displayFilters?.group_by || null) as GroupByColumnTypes | null; + const showEmptyGroup = displayFilters?.show_empty_groups ?? false; + + useEffect(() => { + fetchIssues("init-loader", { canGroup: true, perPageCount: group_by ? 50 : 100 }, viewId); + }, [fetchIssues, storeType, group_by, viewId]); + + + const groupedIssueIds = issues?.groupedIssueIds as TGroupedIssues | undefined; +// auth +const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {}; const canEditProperties = useCallback( @@ -60,13 +87,6 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { [canEditPropertiesBasedOnProject, enableInlineEditing, isEditingAllowed] ); - const displayFilters = issuesFilter?.issueFilters?.displayFilters; - const displayProperties = issuesFilter?.issueFilters?.displayProperties; - - const group_by = displayFilters?.group_by || null; - const orderBy = displayFilters?.order_by || undefined; - const showEmptyGroup = displayFilters?.show_empty_groups ?? false; - const handleOnDrop = useGroupIssuesDragNDrop(storeType, orderBy, group_by); const renderQuickActions: TRenderQuickActions = useCallback( @@ -86,8 +106,16 @@ export const BaseListRoot = observer((props: IBaseListRoot) => { [isEditingAllowed, isCompletedCycle, removeIssue, updateIssue, removeIssueFromView, archiveIssue, restoreIssue] ); + const loadMoreIssues = useCallback( + (groupId?: string) => { + fetchNextIssues(groupId); + }, + [fetchNextIssues] + ); + return ( -
    + +
    { orderBy={orderBy} updateIssue={updateIssue} quickActions={renderQuickActions} - issueIds={issueIds} + groupedIssueIds={groupedIssueIds ?? {}} + loadMoreIssues={loadMoreIssues} showEmptyGroup={showEmptyGroup} - viewId={viewId} - quickAddCallback={issues?.quickAddIssue} + quickAddCallback={quickAddIssue} enableIssueQuickAdd={!!enableQuickAdd} canEditProperties={canEditProperties} disableIssueCreation={!enableIssueCreation || !isEditingAllowed} - storeType={storeType} addIssuesToView={addIssuesToView} isCompletedCycle={isCompletedCycle} handleOnDrop={handleOnDrop} /> -
    +
    + ); }); diff --git a/web/components/issues/issue-layouts/list/block-root.tsx b/web/components/issues/issue-layouts/list/block-root.tsx index 28b5113a0..f2c73530d 100644 --- a/web/components/issues/issue-layouts/list/block-root.tsx +++ b/web/components/issues/issue-layouts/list/block-root.tsx @@ -5,6 +5,7 @@ import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item"; import { observer } from "mobx-react"; +// types import { IIssueDisplayProperties, TIssue, TIssueMap } from "@plane/types"; // components import { DropIndicator } from "@plane/ui"; @@ -22,7 +23,7 @@ type Props = { issueIds: string[]; issueId: string; issuesMap: TIssueMap; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; canEditProperties: (projectId: string | undefined) => boolean; displayProperties: IIssueDisplayProperties | undefined; @@ -145,7 +146,7 @@ export const IssueBlockRoot: FC = observer((props) => { {isExpanded && - subIssues?.map((subIssueId: string) => ( + subIssues?.map((subIssueId) => ( ) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; canEditProperties: (projectId: string | undefined) => boolean; @@ -99,7 +99,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => { if (!issue) return null; - const canEditIssueProperties = canEditProperties(issue.project_id); + const canEditIssueProperties = canEditProperties(issue.project_id ?? undefined); const projectIdentifier = getProjectIdentifierById(issue.project_id); const isIssueSelected = selectionHelpers.getIsEntitySelected(issue.id); const isIssueActive = selectionHelpers.getIsEntityActive(issue.id); @@ -115,7 +115,7 @@ export const IssueBlock = observer((props: IssueBlockProps) => { handleIssuePeekOverview(issue); } else { setExpanded((prevState) => { - if (!prevState && workspaceSlug && issue) + if (!prevState && workspaceSlug && issue && issue.project_id) subIssuesStore.fetchSubIssues(workspaceSlug.toString(), issue.project_id, issue.id); return !prevState; }); diff --git a/web/components/issues/issue-layouts/list/blocks-list.tsx b/web/components/issues/issue-layouts/list/blocks-list.tsx index a1d2d0096..1816dc758 100644 --- a/web/components/issues/issue-layouts/list/blocks-list.tsx +++ b/web/components/issues/issue-layouts/list/blocks-list.tsx @@ -1,17 +1,18 @@ import { FC, MutableRefObject } from "react"; // components -import { TIssue, IIssueDisplayProperties, TIssueMap, TUnGroupedIssues } from "@plane/types"; +import { TIssue, IIssueDisplayProperties, TIssueMap, TGroupedIssues } from "@plane/types"; import { IssueBlockRoot } from "@/components/issues/issue-layouts/list"; +// hooks import { TSelectionHelper } from "@/hooks/use-multiple-select"; // types import { TRenderQuickActions } from "./list-view-types"; interface Props { - issueIds: TUnGroupedIssues; + issueIds: TGroupedIssues | any; issuesMap: TIssueMap; groupId: string; canEditProperties: (projectId: string | undefined) => boolean; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; containerRef: MutableRefObject; @@ -36,27 +37,29 @@ export const IssueBlocksList: FC = (props) => { } = props; return ( -
    - {issueIds?.map((issueId, index) => ( - - ))} +
    + {issueIds && + issueIds.length > 0 && + issueIds.map((issueId: string, index: number) => ( + + ))}
    ); }; diff --git a/web/components/issues/issue-layouts/list/default.tsx b/web/components/issues/issue-layouts/list/default.tsx index 321f0640f..0c7fe81ff 100644 --- a/web/components/issues/issue-layouts/list/default.tsx +++ b/web/components/issues/issue-layouts/list/default.tsx @@ -9,50 +9,45 @@ import { TIssue, IIssueDisplayProperties, TIssueMap, - TUnGroupedIssues, - IGroupByColumn, - TIssueOrderByOptions, TIssueGroupByOptions, + TIssueOrderByOptions, + IGroupByColumn, } from "@plane/types"; // components import { MultipleSelectGroup } from "@/components/core"; import { IssueBulkOperationsRoot } from "@/components/issues"; +// constants +import { ALL_ISSUES } from "@/constants/issue"; // hooks -import { EIssuesStoreType } from "@/constants/issue"; import { useCycle, useLabel, useMember, useModule, useProject, useProjectState } from "@/hooks/store"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; // utils import { getGroupByColumns, isWorkspaceLevel, GroupDropLocation } from "../utils"; import { ListGroup } from "./list-group"; import { TRenderQuickActions } from "./list-view-types"; -export interface IGroupByList { - issueIds: TGroupedIssues | TUnGroupedIssues | any; +export interface IList { + groupedIssueIds: TGroupedIssues; issuesMap: TIssueMap; group_by: TIssueGroupByOptions | null; orderBy: TIssueOrderByOptions | undefined; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; enableIssueQuickAdd: boolean; showEmptyGroup?: boolean; canEditProperties: (projectId: string | undefined) => boolean; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; disableIssueCreation?: boolean; - storeType: EIssuesStoreType; handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise; addIssuesToView?: (issueIds: string[]) => Promise; - viewId?: string; isCompletedCycle?: boolean; + loadMoreIssues: (groupId?: string) => void; } -const GroupByList: React.FC = observer((props) => { +export const List: React.FC = observer((props) => { const { - issueIds, + groupedIssueIds, issuesMap, group_by, orderBy, @@ -63,13 +58,14 @@ const GroupByList: React.FC = observer((props) => { showEmptyGroup, canEditProperties, quickAddCallback, - viewId, disableIssueCreation, - storeType, handleOnDrop, addIssuesToView, isCompletedCycle = false, + loadMoreIssues, } = props; + + const storeType = useIssueStoreType(); // store hooks const member = useMember(); const project = useProject(); @@ -107,12 +103,6 @@ const GroupByList: React.FC = observer((props) => { if (!groups) return null; - const validateEmptyIssueGroups = (issues: TIssue[]) => { - const issuesCount = issues?.length || 0; - if (!showEmptyGroup && issuesCount <= 0) return false; - return true; - }; - const getGroupIndex = (groupId: string | undefined) => groups.findIndex(({ id }) => id === groupId); const is_list = group_by === null ? true : false; @@ -126,9 +116,11 @@ const GroupByList: React.FC = observer((props) => { let entities: Record = {}; if (is_list) { - entities = Object.assign(orderedGroups, { [groupIds[0]]: issueIds }); + entities = Object.assign(orderedGroups, { [groupIds[0]]: groupedIssueIds[ALL_ISSUES] }); + } else if(Array.isArray(groupedIssueIds[groupIds[0]])){ + entities = Object.assign(orderedGroups, { ...groupedIssueIds }); } else { - entities = Object.assign(orderedGroups, { ...issueIds }); + entities = orderedGroups; } return ( @@ -139,37 +131,35 @@ const GroupByList: React.FC = observer((props) => { <>
    - {groups.map( - (group: IGroupByColumn) => - validateEmptyIssueGroups(is_list ? issueIds : issueIds?.[group.id]) && ( - - ) - )} + {groups.map((group: IGroupByColumn) => ( + + ))}
    + )} @@ -178,76 +168,3 @@ const GroupByList: React.FC = observer((props) => {
    ); }); - -GroupByList.displayName = "GroupByList"; - -export interface IList { - issueIds: TGroupedIssues | TUnGroupedIssues | any; - issuesMap: TIssueMap; - group_by: TIssueGroupByOptions | null; - orderBy: TIssueOrderByOptions | undefined; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; - quickActions: TRenderQuickActions; - displayProperties: IIssueDisplayProperties | undefined; - showEmptyGroup: boolean; - enableIssueQuickAdd: boolean; - canEditProperties: (projectId: string | undefined) => boolean; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; - viewId?: string; - disableIssueCreation?: boolean; - storeType: EIssuesStoreType; - handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise; - addIssuesToView?: (issueIds: string[]) => Promise; - isCompletedCycle?: boolean; -} - -export const List: React.FC = (props) => { - const { - issueIds, - issuesMap, - group_by, - orderBy, - updateIssue, - quickActions, - quickAddCallback, - viewId, - displayProperties, - showEmptyGroup, - enableIssueQuickAdd, - canEditProperties, - disableIssueCreation, - storeType, - handleOnDrop, - addIssuesToView, - isCompletedCycle = false, - } = props; - - return ( -
    - -
    - ); -}; diff --git a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx index 39decd11f..a8eb0182b 100644 --- a/web/components/issues/issue-layouts/list/headers/group-by-card.tsx +++ b/web/components/issues/issue-layouts/list/headers/group-by-card.tsx @@ -12,11 +12,11 @@ import { CustomMenu, TOAST_TYPE, setToast } from "@plane/ui"; import { ExistingIssuesListModal, MultipleSelectGroupAction } from "@/components/core"; import { CreateUpdateIssueModal } from "@/components/issues"; // constants -import { EIssuesStoreType } from "@/constants/issue"; // helpers import { cn } from "@/helpers/common.helper"; // hooks import { useEventTracker } from "@/hooks/store"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import { TSelectionHelper } from "@/hooks/use-multiple-select"; interface IHeaderGroupByCard { @@ -26,8 +26,8 @@ interface IHeaderGroupByCard { count: number; issuePayload: Partial; canEditProperties: (projectId: string | undefined) => boolean; + toggleListGroup: () => void; disableIssueCreation?: boolean; - storeType: EIssuesStoreType; addIssuesToView?: (issueIds: string[]) => Promise; selectionHelpers: TSelectionHelper; } @@ -41,9 +41,9 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => { issuePayload, canEditProperties, disableIssueCreation, - storeType, addIssuesToView, selectionHelpers, + toggleListGroup, } = props; // states const [isOpen, setIsOpen] = useState(false); @@ -53,6 +53,7 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => { const pathname = usePathname(); // hooks const { setTrackElement } = useEventTracker(); + const storeType = useIssueStoreType(); // derived values const isDraftIssue = pathname.includes("draft-issue"); const renderExistingIssueModal = moduleId || cycleId; @@ -104,7 +105,8 @@ export const HeaderGroupByCard = observer((props: IHeaderGroupByCard) => { {icon ?? }
    -
    +
    {title}
    {count || 0}
    diff --git a/web/components/issues/issue-layouts/list/list-group.tsx b/web/components/issues/issue-layouts/list/list-group.tsx index b58c0f4da..bd1bcc81f 100644 --- a/web/components/issues/issue-layouts/list/list-group.tsx +++ b/web/components/issues/issue-layouts/list/list-group.tsx @@ -3,24 +3,27 @@ import { MutableRefObject, useEffect, useRef, useState } from "react"; import { combine } from "@atlaskit/pragmatic-drag-and-drop/combine"; import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter"; +import { isNil } from "lodash"; import { observer } from "mobx-react"; import { cn } from "@plane/editor-core"; -// plane +// plane packages import { IGroupByColumn, - IIssueDisplayProperties, - TGroupedIssues, - TIssue, - TIssueGroupByOptions, TIssueMap, + TIssueGroupByOptions, TIssueOrderByOptions, - TUnGroupedIssues, + TIssue, + IIssueDisplayProperties, } from "@plane/types"; -import { TOAST_TYPE, setToast } from "@plane/ui"; +import { setToast, TOAST_TYPE } from "@plane/ui"; +// components +import { ListLoaderItemRow } from "@/components/ui"; // constants -import { DRAG_ALLOWED_GROUPS, EIssuesStoreType } from "@/constants/issue"; +import { DRAG_ALLOWED_GROUPS } from "@/constants/issue"; // hooks import { useProjectState } from "@/hooks/store"; +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; +import { useIssuesStore } from "@/hooks/use-issue-layout-store"; import { TSelectionHelper } from "@/hooks/use-multiple-select"; // components import { GroupDragOverlay } from "../group-drag-overlay"; @@ -36,64 +39,99 @@ import { HeaderGroupByCard } from "./headers/group-by-card"; import { TRenderQuickActions } from "./list-view-types"; import { ListQuickAddIssueForm } from "./quick-add-issue-form"; -type Props = { - issueIds: TGroupedIssues | TUnGroupedIssues | any; +interface Props { + groupIssueIds: string[] | undefined; group: IGroupByColumn; issuesMap: TIssueMap; group_by: TIssueGroupByOptions | null; orderBy: TIssueOrderByOptions | undefined; getGroupIndex: (groupId: string | undefined) => number; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; quickActions: TRenderQuickActions; displayProperties: IIssueDisplayProperties | undefined; enableIssueQuickAdd: boolean; canEditProperties: (projectId: string | undefined) => boolean; - storeType: EIssuesStoreType; containerRef: MutableRefObject; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; + quickAddCallback?: ((projectId: string | null | undefined, data: TIssue) => Promise) | undefined; handleOnDrop: (source: GroupDropLocation, destination: GroupDropLocation) => Promise; disableIssueCreation?: boolean; addIssuesToView?: (issueIds: string[]) => Promise; - viewId?: string; isCompletedCycle?: boolean; + showEmptyGroup?: boolean; + loadMoreIssues: (groupId?: string) => void; selectionHelpers: TSelectionHelper; }; export const ListGroup = observer((props: Props) => { const { + groupIssueIds, group, - issueIds, + issuesMap, group_by, orderBy, - issuesMap, getGroupIndex, - disableIssueCreation, - addIssuesToView, updateIssue, quickActions, displayProperties, - canEditProperties, - quickAddCallback, - containerRef, - viewId, - handleOnDrop, enableIssueQuickAdd, + canEditProperties, + containerRef, + quickAddCallback, + handleOnDrop, + disableIssueCreation, + addIssuesToView, isCompletedCycle, - storeType, + showEmptyGroup, + loadMoreIssues, selectionHelpers, } = props; const [isDraggingOverColumn, setIsDraggingOverColumn] = useState(false); const [dragColumnOrientation, setDragColumnOrientation] = useState<"justify-start" | "justify-end">("justify-start"); + const [isExpanded, setIsExpanded] = useState(true); const groupRef = useRef(null); const projectState = useProjectState(); + const { + issues: { getGroupIssueCount, getPaginationData, getIssueLoader }, + } = useIssuesStore(); + + const [intersectionElement, setIntersectionElement] = useState(null); + + useIntersectionObserver(containerRef, intersectionElement, loadMoreIssues, `50% 0% 50% 0%`); + + const groupIssueCount = getGroupIssueCount(group.id, undefined, false); + const nextPageResults = getPaginationData(group.id, undefined)?.nextPageResults; + const isPaginating = !!getIssueLoader(group.id); + + const shouldLoadMore = + nextPageResults === undefined && groupIssueCount !== undefined && groupIssueIds + ? groupIssueIds.length < groupIssueCount + : !!nextPageResults; + + const loadMore = isPaginating ? ( + + ) : ( +
    loadMoreIssues(group.id)} + > + Load More ↓ +
    + ); + + const validateEmptyIssueGroups = (issueCount: number = 0) => { + if (!showEmptyGroup && issueCount <= 0) return false; + return true; + }; + + const toggleListGroup = () => { + setIsExpanded((prevState) => !prevState); + }; + const prePopulateQuickAddData = (groupByKey: string | null, value: any) => { const defaultState = projectState.projectStates?.find((state) => state.default); let preloadedData: object = { state_id: defaultState?.id }; @@ -179,15 +217,13 @@ export const ListGroup = observer((props: Props) => { ); }, [groupRef?.current, group, orderBy, getGroupIndex, setDragColumnOrientation, setIsDraggingOverColumn]); - const is_list = group_by === null ? true : false; const isDragAllowed = !!group_by && DRAG_ALLOWED_GROUPS.includes(group_by); const canOverlayBeVisible = orderBy !== "sort_order" || !!group.isDropDisabled; - const issueCount: number = is_list ? issueIds?.length ?? 0 : issueIds?.[group.id]?.length ?? 0; - const isGroupByCreatedBy = group_by === "created_by"; + const shouldExpand = (!!groupIssueCount && isExpanded) || !group_by; - return ( + return groupIssueIds && !isNil(groupIssueCount) && validateEmptyIssueGroups(groupIssueCount) ? (
    { groupID={group.id} icon={group.icon} title={group.name || ""} - count={issueCount} + count={groupIssueCount} issuePayload={group.payload} canEditProperties={canEditProperties} disableIssueCreation={disableIssueCreation || isGroupByCreatedBy || isCompletedCycle} - storeType={storeType} addIssuesToView={addIssuesToView} selectionHelpers={selectionHelpers} + toggleListGroup={toggleListGroup} />
    - - {!!issueCount && ( + {shouldExpand && (
    { orderBy={orderBy} isDraggingOverColumn={isDraggingOverColumn} /> - {issueIds && ( + {groupIssueIds && ( { /> )} + {shouldLoadMore && (group_by ? <>{loadMore} : )} + {enableIssueQuickAdd && !disableIssueCreation && !isGroupByCreatedBy && !isCompletedCycle && (
    )}
    )}
    - ); + ) : null; }); diff --git a/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx index dee54046f..5bc85837a 100644 --- a/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/list/quick-add-issue-form.tsx @@ -49,13 +49,7 @@ const Inputs: FC = (props) => { interface IListQuickAddIssueForm { prePopulatedData?: Partial; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; - viewId?: string; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; } const defaultValues: Partial = { @@ -63,7 +57,7 @@ const defaultValues: Partial = { }; export const ListQuickAddIssueForm: FC = observer((props) => { - const { prePopulatedData, quickAddCallback, viewId } = props; + const { prePopulatedData, quickAddCallback } = props; // router const { workspaceSlug, projectId } = useParams(); const pathname = usePathname(); @@ -104,7 +98,7 @@ export const ListQuickAddIssueForm: FC = observer((props }); if (quickAddCallback) { - const quickAddPromise = quickAddCallback(workspaceSlug.toString(), projectId.toString(), { ...payload }, viewId); + const quickAddPromise = quickAddCallback(projectId.toString(), { ...payload }); setPromiseToast(quickAddPromise, { loading: "Adding issue...", success: { diff --git a/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx b/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx index 75339f1d7..e7c7670ae 100644 --- a/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/archived-issue-root.tsx @@ -2,7 +2,6 @@ import { FC } from "react"; import { observer } from "mobx-react"; // hooks import { ArchivedIssueQuickActions } from "@/components/issues"; -import { EIssuesStoreType } from "@/constants/issue"; // components // types // constants @@ -14,7 +13,6 @@ export const ArchivedIssueListLayout: FC = observer(() => { return ( ); diff --git a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx index befe8d3b3..bd5f7bad9 100644 --- a/web/components/issues/issue-layouts/list/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/cycle-root.tsx @@ -42,11 +42,10 @@ export const CycleListLayout: React.FC = observer(() => { return ( ); }); diff --git a/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx b/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx index eb6ff1d23..bb037020e 100644 --- a/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/draft-issue-root.tsx @@ -3,7 +3,6 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // hooks import { DraftIssueQuickActions } from "@/components/issues"; -import { EIssuesStoreType } from "@/constants/issue"; // components // types // constants @@ -14,5 +13,5 @@ export const DraftIssueListLayout: FC = observer(() => { if (!workspaceSlug || !projectId) return null; - return ; + return ; }); diff --git a/web/components/issues/issue-layouts/list/roots/module-root.tsx b/web/components/issues/issue-layouts/list/roots/module-root.tsx index 010dcd798..e33b2544b 100644 --- a/web/components/issues/issue-layouts/list/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/module-root.tsx @@ -20,12 +20,11 @@ export const ModuleListLayout: React.FC = observer(() => { return ( { if (!workspaceSlug || !projectId || !moduleId) throw new Error(); return issues.addIssuesToModule(workspaceSlug.toString(), projectId.toString(), moduleId.toString(), issueIds); }} + viewId={moduleId?.toString()} /> ); }); diff --git a/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx b/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx index a5a4bc6fe..f4249a723 100644 --- a/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/profile-issues-root.tsx @@ -2,9 +2,8 @@ import { FC } from "react"; import { observer } from "mobx-react"; // hooks import { ProjectIssueQuickActions } from "@/components/issues"; -import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; -import { useUser } from "@/hooks/store"; +import { useAppRouter, useUser } from "@/hooks/store"; // components // types // constants @@ -15,6 +14,8 @@ export const ProfileIssuesListLayout: FC = observer(() => { membership: { currentWorkspaceAllProjectsRole }, } = useUser(); + const { profileViewId } = useAppRouter(); + const canEditPropertiesBasedOnProject = (projectId: string) => { const currentProjectRole = currentWorkspaceAllProjectsRole && currentWorkspaceAllProjectsRole[projectId]; @@ -24,8 +25,8 @@ export const ProfileIssuesListLayout: FC = observer(() => { return ( ); }); diff --git a/web/components/issues/issue-layouts/list/roots/project-root.tsx b/web/components/issues/issue-layouts/list/roots/project-root.tsx index 0dc66e5c3..4e7ad97c9 100644 --- a/web/components/issues/issue-layouts/list/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-root.tsx @@ -3,7 +3,6 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // hooks import { ProjectIssueQuickActions } from "@/components/issues"; -import { EIssuesStoreType } from "@/constants/issue"; // components // types // constants @@ -14,5 +13,5 @@ export const ListLayout: FC = observer(() => { if (!workspaceSlug || !projectId) return null; - return ; + return ; }); diff --git a/web/components/issues/issue-layouts/list/roots/project-view-root.tsx b/web/components/issues/issue-layouts/list/roots/project-view-root.tsx index 966b05184..73e7fd6ab 100644 --- a/web/components/issues/issue-layouts/list/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/list/roots/project-view-root.tsx @@ -1,24 +1,10 @@ import React from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // store -import { EIssuesStoreType } from "@/constants/issue"; // constants // types import { ProjectIssueQuickActions } from "../../quick-action-dropdowns"; // components import { BaseListRoot } from "../base-list-root"; -export const ProjectViewListLayout: React.FC = observer(() => { - const { workspaceSlug, projectId, viewId } = useParams(); - - if (!workspaceSlug || !projectId) return null; - - return ( - - ); -}); +export const ProjectViewListLayout: React.FC = observer(() => ); diff --git a/web/components/issues/issue-layouts/properties/all-properties.tsx b/web/components/issues/issue-layouts/properties/all-properties.tsx index ba71780b0..eb46a112a 100644 --- a/web/components/issues/issue-layouts/properties/all-properties.tsx +++ b/web/components/issues/issue-layouts/properties/all-properties.tsx @@ -22,13 +22,13 @@ import { } from "@/components/dropdowns"; // constants import { ISSUE_UPDATED } from "@/constants/event-tracker"; -import { EIssuesStoreType } from "@/constants/issue"; // helpers import { cn } from "@/helpers/common.helper"; import { getDate, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper"; // hooks import { useEventTracker, useLabel, useIssues, useProjectState, useProject, useProjectEstimates } from "@/hooks/store"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import { usePlatformOS } from "@/hooks/use-platform-os"; // local components import { IssuePropertyLabels } from "../properties/labels"; @@ -36,7 +36,9 @@ import { WithDisplayPropertiesHOC } from "../properties/with-display-properties- export interface IIssueProperties { issue: TIssue; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: + | ((projectId: string | null, issueId: string, data: Partial) => Promise) + | undefined; displayProperties: IIssueDisplayProperties | undefined; isReadOnly: boolean; className: string; @@ -49,20 +51,23 @@ export const IssueProperties: React.FC = observer((props) => { const { getProjectById } = useProject(); const { labelMap } = useLabel(); const { captureIssueEvent } = useEventTracker(); + const storeType = useIssueStoreType(); const { issues: { changeModulesInIssue }, - } = useIssues(EIssuesStoreType.MODULE); + } = useIssues(storeType); const { issues: { addCycleToIssue, removeCycleFromIssue }, - } = useIssues(EIssuesStoreType.CYCLE); + } = useIssues(storeType); const { areEstimateEnabledByProjectId } = useProjectEstimates(); const { getStateById } = useProjectState(); const { isMobile } = usePlatformOS(); const projectDetails = getProjectById(issue.project_id); + // router const router = useRouter(); const { workspaceSlug, projectId } = useParams(); const pathname = usePathname(); + const currentLayout = `${activeLayout} layout`; // derived values const stateDetails = getStateById(issue.state_id); @@ -250,7 +255,7 @@ export const IssueProperties: React.FC = observer((props) => { // }); }; - if (!displayProperties) return null; + if (!displayProperties || !issue.project_id) return null; const defaultLabelOptions = issue?.label_ids?.map((id) => labelMap[id]) || []; @@ -287,7 +292,7 @@ export const IssueProperties: React.FC = observer((props) => {
    { @@ -34,24 +41,16 @@ export const AllIssueLayoutRoot: React.FC = observer(() => { //swr hook for fetching issue properties useWorkspaceIssueProperties(workspaceSlug); // store - const { toggleCreateProjectModal, toggleCreateIssueModal } = useCommandPalette(); const { issuesFilter: { filters, fetchFilters, updateFilters }, - issues: { loader, groupedIssueIds, fetchIssues }, + issues: { clear, getIssueLoader, getPaginationData, groupedIssueIds, fetchIssues, fetchNextIssues }, } = useIssues(EIssuesStoreType.GLOBAL); const { updateIssue, removeIssue, archiveIssue } = useIssuesActions(EIssuesStoreType.GLOBAL); - const { dataViewId, issueIds } = groupedIssueIds; const { membership: { currentWorkspaceAllProjectsRole }, } = useUser(); const { fetchAllGlobalViews } = useGlobalView(); - const { workspaceProjectIds } = useProject(); - const { setTrackElement } = useEventTracker(); - - const isDefaultView = ["all-issues", "assigned", "created", "subscribed"].includes(groupedIssueIds.dataViewId); - const currentView = isDefaultView ? groupedIssueIds.dataViewId : "custom-view"; - // filter init from the query params const routerFilterParams = () => { @@ -83,6 +82,10 @@ export const AllIssueLayoutRoot: React.FC = observer(() => { } }; + const fetchNextPages = useCallback(() => { + if (workspaceSlug && globalViewId) fetchNextIssues(workspaceSlug.toString(), globalViewId.toString()); + }, [fetchNextIssues, workspaceSlug, globalViewId]); + useSWR( workspaceSlug ? `WORKSPACE_GLOBAL_VIEWS_${workspaceSlug}` : null, async () => { @@ -97,9 +100,17 @@ export const AllIssueLayoutRoot: React.FC = observer(() => { workspaceSlug && globalViewId ? `WORKSPACE_GLOBAL_VIEW_ISSUES_${workspaceSlug}_${globalViewId}` : null, async () => { if (workspaceSlug && globalViewId) { - await fetchAllGlobalViews(workspaceSlug.toString()); + clear(); await fetchFilters(workspaceSlug.toString(), globalViewId.toString()); - await fetchIssues(workspaceSlug.toString(), globalViewId.toString(), issueIds ? "mutation" : "init-loader"); + await fetchIssues( + workspaceSlug.toString(), + globalViewId.toString(), + groupedIssueIds ? "mutation" : "init-loader", + { + canGroup: false, + perPageCount: 100, + } + ); routerFilterParams(); } }, @@ -144,59 +155,38 @@ export const AllIssueLayoutRoot: React.FC = observer(() => { handleUpdate={async (data) => updateIssue && updateIssue(issue.project_id, issue.id, data)} handleArchive={async () => archiveIssue && archiveIssue(issue.project_id, issue.id)} portalElement={portalElement} - readOnly={!canEditProperties(issue.project_id)} + readOnly={!canEditProperties(issue.project_id ?? undefined)} placements={placement} /> ), [canEditProperties, removeIssue, updateIssue, archiveIssue] ); - if (loader === "init-loader" || !globalViewId || globalViewId !== dataViewId || !issueIds) { + if (getIssueLoader() === "init-loader" || !globalViewId || !groupedIssueIds) { return ; } - const emptyStateType = - (workspaceProjectIds ?? []).length > 0 ? `workspace-${currentView}` : EmptyStateType.WORKSPACE_NO_PROJECTS; + const issueIds = groupedIssueIds[ALL_ISSUES]; + const nextPageResults = getPaginationData(ALL_ISSUES, undefined)?.nextPageResults; return ( -
    -
    - - {issueIds.length === 0 ? ( - 0 - ? currentView !== "custom-view" && currentView !== "subscribed" - ? () => { - setTrackElement("All issues empty state"); - toggleCreateIssueModal(true, EIssuesStoreType.PROJECT); - } - : undefined - : () => { - setTrackElement("All issues empty state"); - toggleCreateProjectModal(true); - } - } - /> - ) : ( - - - {/* peek overview */} - - - )} -
    -
    + + + + {/* peek overview */} + + + ); }); diff --git a/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx index c319c6c0a..63ce3fe66 100644 --- a/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/archived-issue-layout-root.tsx @@ -7,55 +7,39 @@ import useSWR from "swr"; import { ArchivedIssueListLayout, ArchivedIssueAppliedFiltersRoot, - ProjectArchivedEmptyState, IssuePeekOverview, } from "@/components/issues"; -import { ListLayoutLoader } from "@/components/ui"; import { EIssuesStoreType } from "@/constants/issue"; // ui import { useIssues } from "@/hooks/store"; +import { IssuesStoreContext } from "@/hooks/use-issue-layout-store"; export const ArchivedIssueLayoutRoot: React.FC = observer(() => { // router const { workspaceSlug, projectId } = useParams(); // hooks - const { issues, issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED); + const { issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED); useSWR( workspaceSlug && projectId ? `ARCHIVED_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null, async () => { if (workspaceSlug && projectId) { await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); - await issues?.fetchIssues( - workspaceSlug.toString(), - projectId.toString(), - issues?.groupedIssueIds ? "mutation" : "init-loader" - ); } }, { revalidateIfStale: false, revalidateOnFocus: false } ); - if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) { - return ; - } - if (!workspaceSlug || !projectId) return <>; return ( - <> + - {issues?.groupedIssueIds?.length === 0 ? ( -
    - + +
    +
    - ) : ( - -
    - -
    - -
    - )} - + +
    + ); }); diff --git a/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx b/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx index 75503c625..5ef308fcc 100644 --- a/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/cycle-layout-root.tsx @@ -1,33 +1,47 @@ -import React, { Fragment, useState } from "react"; +import React, { useState } from "react"; import isEmpty from "lodash/isEmpty"; -import size from "lodash/size"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; -import { IIssueFilterOptions } from "@plane/types"; // hooks // components import { TransferIssues, TransferIssuesModal } from "@/components/cycles"; import { CycleAppliedFiltersRoot, CycleCalendarLayout, - CycleEmptyState, - CycleGanttLayout, + BaseGanttRoot, CycleKanBanLayout, CycleListLayout, CycleSpreadsheetLayout, IssuePeekOverview, } from "@/components/issues"; -import { ActiveLoader } from "@/components/ui"; // constants -import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; +import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; +// hooks import { useCycle, useIssues } from "@/hooks/store"; -// types +import { IssuesStoreContext } from "@/hooks/use-issue-layout-store"; + +const CycleIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undefined; cycleId: string }) => { + switch (props.activeLayout) { + case EIssueLayoutTypes.LIST: + return ; + case EIssueLayoutTypes.KANBAN: + return ; + case EIssueLayoutTypes.CALENDAR: + return ; + case EIssueLayoutTypes.GANTT: + return ; + case EIssueLayoutTypes.SPREADSHEET: + return ; + default: + return null; + } +}; export const CycleLayoutRoot: React.FC = observer(() => { const { workspaceSlug, projectId, cycleId } = useParams(); // store hooks - const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE); + const { issuesFilter } = useIssues(EIssuesStoreType.CYCLE); const { getCycleById } = useCycle(); // state const [transferIssuesModal, setTransferIssuesModal] = useState(false); @@ -39,12 +53,6 @@ export const CycleLayoutRoot: React.FC = observer(() => { async () => { if (workspaceSlug && projectId && cycleId) { await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), cycleId.toString()); - await issues?.fetchIssues( - workspaceSlug.toString(), - projectId.toString(), - issues?.groupedIssueIds ? "mutation" : "init-loader", - cycleId.toString() - ); } }, { revalidateIfStale: false, revalidateOnFocus: false } @@ -55,39 +63,10 @@ export const CycleLayoutRoot: React.FC = observer(() => { const cycleDetails = cycleId ? getCycleById(cycleId.toString()) : undefined; const cycleStatus = cycleDetails?.status?.toLocaleLowerCase() ?? "draft"; - const userFilters = issuesFilter?.issueFilters?.filters; - - const issueFilterCount = size( - Object.fromEntries( - Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0) - ) - ); - - const handleClearAllFilters = () => { - if (!workspaceSlug || !projectId || !cycleId) return; - const newFilters: IIssueFilterOptions = {}; - Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = []; - }); - issuesFilter.updateFilters( - workspaceSlug.toString(), - projectId.toString(), - EIssueFilterType.FILTERS, - { - ...newFilters, - }, - cycleId.toString() - ); - }; - if (!workspaceSlug || !projectId || !cycleId) return <>; - if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) { - return <>{activeLayout && }; - } - return ( - <> + setTransferIssuesModal(false)} isOpen={transferIssuesModal} />
    {cycleStatus === "completed" && ( @@ -98,37 +77,12 @@ export const CycleLayoutRoot: React.FC = observer(() => { )} - {issues?.groupedIssueIds?.length === 0 ? ( -
    - 0} - /> -
    - ) : ( - -
    - {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : activeLayout === "calendar" ? ( - - ) : activeLayout === "gantt_chart" ? ( - - ) : activeLayout === "spreadsheet" ? ( - - ) : null} -
    - {/* peek overview */} - -
    - )} +
    + +
    + {/* peek overview */} +
    - +
    ); }); diff --git a/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx b/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx index a37187da3..a4b78be80 100644 --- a/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/draft-issue-layout-root.tsx @@ -2,35 +2,39 @@ import React from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; -// hooks import { IssuePeekOverview } from "@/components/issues/peek-overview"; -import { ActiveLoader } from "@/components/ui"; -import { EIssuesStoreType } from "@/constants/issue"; +import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; +// hooks import { useIssues } from "@/hooks/store"; +import { IssuesStoreContext } from "@/hooks/use-issue-layout-store"; // components -import { ProjectDraftEmptyState } from "../empty-states"; import { DraftIssueAppliedFiltersRoot } from "../filters/applied-filters/roots/draft-issue"; import { DraftKanBanLayout } from "../kanban/roots/draft-issue-root"; import { DraftIssueListLayout } from "../list/roots/draft-issue-root"; // ui // constants +const DraftIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undefined }) => { + switch (props.activeLayout) { + case EIssueLayoutTypes.LIST: + return ; + case EIssueLayoutTypes.KANBAN: + return ; + default: + return null; + } +}; export const DraftIssueLayoutRoot: React.FC = observer(() => { // router const { workspaceSlug, projectId } = useParams(); // hooks - const { issues, issuesFilter } = useIssues(EIssuesStoreType.DRAFT); + const { issuesFilter } = useIssues(EIssuesStoreType.DRAFT); useSWR( workspaceSlug && projectId ? `DRAFT_ISSUES_${workspaceSlug.toString()}_${projectId.toString()}` : null, async () => { if (workspaceSlug && projectId) { await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); - await issues?.fetchIssues( - workspaceSlug.toString(), - projectId.toString(), - issues?.groupedIssueIds ? "mutation" : "init-loader" - ); } }, { revalidateIfStale: false, revalidateOnFocus: false } @@ -40,29 +44,16 @@ export const DraftIssueLayoutRoot: React.FC = observer(() => { if (!workspaceSlug || !projectId) return <>; - if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) { - return <>{activeLayout && }; - } - return ( -
    - - - {issues?.groupedIssueIds?.length === 0 ? ( -
    - -
    - ) : ( + +
    +
    - {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : null} + {/* issue peek overview */}
    - )} -
    +
    + ); }); diff --git a/web/components/issues/issue-layouts/roots/module-layout-root.tsx b/web/components/issues/issue-layouts/roots/module-layout-root.tsx index df348ba0b..f61d06a66 100644 --- a/web/components/issues/issue-layouts/roots/module-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/module-layout-root.tsx @@ -1,32 +1,46 @@ -import React, { Fragment } from "react"; -import size from "lodash/size"; +import React from "react" import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; -import { IIssueFilterOptions } from "@plane/types"; // mobx store // components import { IssuePeekOverview, ModuleAppliedFiltersRoot, ModuleCalendarLayout, - ModuleEmptyState, - ModuleGanttLayout, + BaseGanttRoot, ModuleKanBanLayout, ModuleListLayout, ModuleSpreadsheetLayout, } from "@/components/issues"; -import { ActiveLoader } from "@/components/ui"; // constants -import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; +import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; import { useIssues } from "@/hooks/store"; +import { IssuesStoreContext } from "@/hooks/use-issue-layout-store"; // types +const ModuleIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undefined; moduleId: string }) => { + switch (props.activeLayout) { + case EIssueLayoutTypes.LIST: + return ; + case EIssueLayoutTypes.KANBAN: + return ; + case EIssueLayoutTypes.CALENDAR: + return ; + case EIssueLayoutTypes.GANTT: + return ; + case EIssueLayoutTypes.SPREADSHEET: + return ; + default: + return null; + } +}; + export const ModuleLayoutRoot: React.FC = observer(() => { // router const { workspaceSlug, projectId, moduleId } = useParams(); // hooks - const { issues, issuesFilter } = useIssues(EIssuesStoreType.MODULE); + const { issuesFilter } = useIssues(EIssuesStoreType.MODULE); useSWR( workspaceSlug && projectId && moduleId @@ -35,84 +49,25 @@ export const ModuleLayoutRoot: React.FC = observer(() => { async () => { if (workspaceSlug && projectId && moduleId) { await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), moduleId.toString()); - await issues?.fetchIssues( - workspaceSlug.toString(), - projectId.toString(), - issues?.groupedIssueIds ? "mutation" : "init-loader", - moduleId.toString() - ); } }, { revalidateIfStale: false, revalidateOnFocus: false } ); - const userFilters = issuesFilter?.issueFilters?.filters; - - const issueFilterCount = size( - Object.fromEntries( - Object.entries(userFilters ?? {}).filter(([, value]) => value && Array.isArray(value) && value.length > 0) - ) - ); - - const handleClearAllFilters = () => { - if (!workspaceSlug || !projectId || !moduleId) return; - const newFilters: IIssueFilterOptions = {}; - Object.keys(userFilters ?? {}).forEach((key) => { - newFilters[key as keyof IIssueFilterOptions] = []; - }); - issuesFilter.updateFilters( - workspaceSlug.toString(), - projectId.toString(), - EIssueFilterType.FILTERS, - { - ...newFilters, - }, - moduleId.toString() - ); - }; - if (!workspaceSlug || !projectId || !moduleId) return <>; const activeLayout = issuesFilter?.issueFilters?.displayFilters?.layout || undefined; - if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) { - return <>{activeLayout && }; - } - return ( -
    - - - {issues?.groupedIssueIds?.length === 0 ? ( -
    - 0} - /> + +
    + +
    +
    - ) : ( - -
    - {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : activeLayout === "calendar" ? ( - - ) : activeLayout === "gantt_chart" ? ( - - ) : activeLayout === "spreadsheet" ? ( - - ) : null} -
    - {/* peek overview */} - -
    - )} -
    + {/* peek overview */} + +
    + ); }); diff --git a/web/components/issues/issue-layouts/roots/project-layout-root.tsx b/web/components/issues/issue-layouts/roots/project-layout-root.tsx index a1d506bab..24009ff8d 100644 --- a/web/components/issues/issue-layouts/roots/project-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/project-layout-root.tsx @@ -9,18 +9,34 @@ import { Spinner } from "@plane/ui"; import { ListLayout, CalendarLayout, - GanttLayout, + BaseGanttRoot, KanBanLayout, ProjectAppliedFiltersRoot, ProjectSpreadsheetLayout, - ProjectEmptyState, IssuePeekOverview, } from "@/components/issues"; -import { ActiveLoader } from "@/components/ui"; // constants -import { EIssuesStoreType } from "@/constants/issue"; +import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; // hooks import { useIssues } from "@/hooks/store"; +import { IssuesStoreContext } from "@/hooks/use-issue-layout-store"; + +const ProjectIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undefined }) => { + switch (props.activeLayout) { + case EIssueLayoutTypes.LIST: + return ; + case EIssueLayoutTypes.KANBAN: + return ; + case EIssueLayoutTypes.CALENDAR: + return ; + case EIssueLayoutTypes.GANTT: + return ; + case EIssueLayoutTypes.SPREADSHEET: + return ; + default: + return null; + } +}; export const ProjectLayoutRoot: FC = observer(() => { // router @@ -33,11 +49,6 @@ export const ProjectLayoutRoot: FC = observer(() => { async () => { if (workspaceSlug && projectId) { await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString()); - await issues?.fetchIssues( - workspaceSlug.toString(), - projectId.toString(), - issues?.groupedIssueIds ? "mutation" : "init-loader" - ); } }, { revalidateIfStale: false, revalidateOnFocus: false } @@ -47,42 +58,23 @@ export const ProjectLayoutRoot: FC = observer(() => { if (!workspaceSlug || !projectId) return <>; - if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) { - return <>{activeLayout && }; - } - return ( -
    - + +
    + +
    + {/* mutation loader */} + {issues?.getIssueLoader() === "mutation" && ( +
    + +
    + )} + +
    - {issues?.groupedIssueIds?.length === 0 ? ( - - ) : ( - -
    - {/* mutation loader */} - {issues?.loader === "mutation" && ( -
    - -
    - )} - {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : activeLayout === "calendar" ? ( - - ) : activeLayout === "gantt_chart" ? ( - - ) : activeLayout === "spreadsheet" ? ( - - ) : null} -
    - - {/* peek overview */} - -
    - )} -
    + {/* peek overview */} + +
    + ); }); diff --git a/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx b/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx index 29399cbfd..11412632d 100644 --- a/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx +++ b/web/components/issues/issue-layouts/roots/project-view-layout-root.tsx @@ -1,4 +1,4 @@ -import React, { Fragment } from "react"; +import React from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; @@ -8,35 +8,45 @@ import { IssuePeekOverview, ProjectViewAppliedFiltersRoot, ProjectViewCalendarLayout, - ProjectViewEmptyState, - ProjectViewGanttLayout, + BaseGanttRoot, ProjectViewKanBanLayout, ProjectViewListLayout, ProjectViewSpreadsheetLayout, } from "@/components/issues"; -import { ActiveLoader } from "@/components/ui"; // constants -import { EIssuesStoreType } from "@/constants/issue"; +import { EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; import { useIssues } from "@/hooks/store"; +import { IssuesStoreContext } from "@/hooks/use-issue-layout-store"; // types +const ProjectViewIssueLayout = (props: { activeLayout: EIssueLayoutTypes | undefined }) => { + switch (props.activeLayout) { + case EIssueLayoutTypes.LIST: + return ; + case EIssueLayoutTypes.KANBAN: + return ; + case EIssueLayoutTypes.CALENDAR: + return ; + case EIssueLayoutTypes.GANTT: + return ; + case EIssueLayoutTypes.SPREADSHEET: + return ; + default: + return null; + } +}; + export const ProjectViewLayoutRoot: React.FC = observer(() => { // router const { workspaceSlug, projectId, viewId } = useParams(); // hooks - const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW); + const { issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW); useSWR( workspaceSlug && projectId && viewId ? `PROJECT_VIEW_ISSUES_${workspaceSlug}_${projectId}_${viewId}` : null, async () => { if (workspaceSlug && projectId && viewId) { await issuesFilter?.fetchFilters(workspaceSlug.toString(), projectId.toString(), viewId.toString()); - await issues?.fetchIssues( - workspaceSlug.toString(), - projectId.toString(), - issues?.groupedIssueIds ? "mutation" : "init-loader", - viewId.toString() - ); } }, { revalidateIfStale: false, revalidateOnFocus: false } @@ -46,38 +56,17 @@ export const ProjectViewLayoutRoot: React.FC = observer(() => { if (!workspaceSlug || !projectId || !viewId) return <>; - if (issues?.loader === "init-loader" || !issues?.groupedIssueIds) { - return <>{activeLayout && }; - } - return ( -
    - - - {issues?.groupedIssueIds?.length === 0 ? ( -
    - + +
    + +
    +
    - ) : ( - -
    - {activeLayout === "list" ? ( - - ) : activeLayout === "kanban" ? ( - - ) : activeLayout === "calendar" ? ( - - ) : activeLayout === "gantt_chart" ? ( - - ) : activeLayout === "spreadsheet" ? ( - - ) : null} -
    - {/* peek overview */} - -
    - )} -
    + {/* peek overview */} + +
    + ); }); diff --git a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx index deb9c677a..8e3bdcf95 100644 --- a/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/base-spreadsheet-root.tsx @@ -1,13 +1,18 @@ -import { FC, useCallback } from "react"; +import { FC, useCallback, useEffect } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; -import { IIssueDisplayFilterOptions, TUnGroupedIssues } from "@plane/types"; +import { IIssueDisplayFilterOptions } from "@plane/types"; // hooks -import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; +import { ALL_ISSUES, EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; +// hooks import { useIssues, useUser } from "@/hooks/store"; +import { useIssueStoreType } from "@/hooks/use-issue-layout-store"; import { useIssuesActions } from "@/hooks/use-issues-actions"; // views +// stores +// components +import { IssueLayoutHOC } from "../issue-layout-HOC"; // types // constants import { IQuickActionProps, TRenderQuickActions } from "../list/list-view-types"; @@ -19,29 +24,42 @@ export type SpreadsheetStoreType = | EIssuesStoreType.CYCLE | EIssuesStoreType.PROJECT_VIEW; interface IBaseSpreadsheetRoot { - viewId?: string; QuickActions: FC; - storeType: SpreadsheetStoreType; canEditPropertiesBasedOnProject?: (projectId: string) => boolean; isCompletedCycle?: boolean; + viewId?: string | undefined; } export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { - const { viewId, QuickActions, storeType, canEditPropertiesBasedOnProject, isCompletedCycle = false } = props; + const { QuickActions, canEditPropertiesBasedOnProject, isCompletedCycle = false, viewId } = props; // router const { projectId } = useParams(); // store hooks + const storeType = useIssueStoreType() as SpreadsheetStoreType; const { membership: { currentProjectRole }, } = useUser(); const { issues, issuesFilter } = useIssues(storeType); - const { updateIssue, removeIssue, removeIssueFromView, archiveIssue, restoreIssue, updateFilters } = - useIssuesActions(storeType); + const { + fetchIssues, + fetchNextIssues, + quickAddIssue, + updateIssue, + removeIssue, + removeIssueFromView, + archiveIssue, + restoreIssue, + updateFilters, + } = useIssuesActions(storeType); // derived values const { enableInlineEditing, enableQuickAdd, enableIssueCreation } = issues?.viewFlags || {}; // user role validation const isEditingAllowed = !!currentProjectRole && currentProjectRole >= EUserProjectRoles.MEMBER; + useEffect(() => { + fetchIssues("init-loader", { canGroup: false, perPageCount: 100 }, viewId); + }, [fetchIssues, storeType, viewId]); + const canEditProperties = useCallback( (projectId: string | undefined) => { const isEditingAllowedBasedOnProject = @@ -52,7 +70,8 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { [canEditPropertiesBasedOnProject, enableInlineEditing, isEditingAllowed] ); - const issueIds = (issues.groupedIssueIds ?? []) as TUnGroupedIssues; + const issueIds = issues.groupedIssueIds?.[ALL_ISSUES] ?? []; + const nextPageResults = issues.getPaginationData(ALL_ISSUES, undefined)?.nextPageResults; const handleDisplayFiltersUpdate = useCallback( (updatedDisplayFilter: Partial) => { @@ -84,19 +103,24 @@ export const BaseSpreadsheetRoot = observer((props: IBaseSpreadsheetRoot) => { [isEditingAllowed, isCompletedCycle, removeIssue, updateIssue, removeIssueFromView, archiveIssue, restoreIssue] ); + if (!Array.isArray(issueIds)) return null; + return ( - + + + ); }); diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx index d0fb5812d..e0381ffbb 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/assignee-column.tsx @@ -29,7 +29,7 @@ export const SpreadsheetAssigneeColumn: React.FC = observer((props: Props } ); }} - projectId={issue?.project_id} + projectId={issue?.project_id ?? undefined} disabled={disabled} multiple placeholder="Assignees" diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx index e0727c844..8e18c2d5c 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/cycle-column.tsx @@ -5,10 +5,9 @@ import { useParams, usePathname } from "next/navigation"; import { TIssue } from "@plane/types"; // components import { CycleDropdown } from "@/components/dropdowns"; -// constants -import { EIssuesStoreType } from "@/constants/issue"; // hooks -import { useEventTracker, useIssues } from "@/hooks/store"; +import { useEventTracker } from "@/hooks/store"; +import { useIssuesStore } from "@/hooks/use-issue-layout-store"; type Props = { issue: TIssue; @@ -25,11 +24,11 @@ export const SpreadsheetCycleColumn: React.FC = observer((props) => { const { captureIssueEvent } = useEventTracker(); const { issues: { addCycleToIssue, removeCycleFromIssue }, - } = useIssues(EIssuesStoreType.CYCLE); + } = useIssuesStore(); const handleCycle = useCallback( async (cycleId: string | null) => { - if (!workspaceSlug || !issue || issue.cycle_id === cycleId) return; + if (!workspaceSlug || !issue || !issue.project_id || issue.cycle_id === cycleId) return; if (cycleId) await addCycleToIssue(workspaceSlug.toString(), issue.project_id, cycleId, issue.id); else await removeCycleFromIssue(workspaceSlug.toString(), issue.project_id, issue.id); captureIssueEvent({ @@ -49,7 +48,7 @@ export const SpreadsheetCycleColumn: React.FC = observer((props) => { return (
    = observer((props: Props onChange(issue, { estimate_point: data }, { changed_property: "estimate_point", change_details: data }) } placeholder="Estimate" - projectId={issue.project_id} + projectId={issue.project_id ?? undefined} disabled={disabled} buttonVariant="transparent-with-text" buttonClassName="text-left rounded-none group-[.selected-issue-row]:bg-custom-primary-100/5 group-[.selected-issue-row]:hover:bg-custom-primary-100/10" diff --git a/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx b/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx index ba724f201..14fba6140 100644 --- a/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/columns/module-column.tsx @@ -7,9 +7,9 @@ import { TIssue } from "@plane/types"; // components import { ModuleDropdown } from "@/components/dropdowns"; // constants -import { EIssuesStoreType } from "@/constants/issue"; // hooks -import { useEventTracker, useIssues } from "@/hooks/store"; +import { useEventTracker } from "@/hooks/store"; +import { useIssuesStore } from "@/hooks/use-issue-layout-store"; type Props = { issue: TIssue; @@ -26,11 +26,11 @@ export const SpreadsheetModuleColumn: React.FC = observer((props) => { const { captureIssueEvent } = useEventTracker(); const { issues: { changeModulesInIssue }, - } = useIssues(EIssuesStoreType.MODULE); + } = useIssuesStore(); const handleModule = useCallback( async (moduleIds: string[] | null) => { - if (!workspaceSlug || !issue || !issue.module_ids || !moduleIds) return; + if (!workspaceSlug || !issue || !issue.project_id || !issue.module_ids || !moduleIds) return; const updatedModuleIds = xor(issue.module_ids, moduleIds); const modulesToAdd: string[] = []; @@ -58,7 +58,7 @@ export const SpreadsheetModuleColumn: React.FC = observer((props) => { return (
    = observer((props) => { return (
    onChange(issue, { state_id: data }, { changed_property: "state", change_details: data })} disabled={disabled} diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx index 9ba38c501..41dc80f64 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-column.tsx @@ -15,7 +15,7 @@ type Props = { issueDetail: TIssue; disableUserActions: boolean; property: keyof IIssueDisplayProperties; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; isEstimateEnabled: boolean; }; diff --git a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx index 41ffd7601..9efd48048 100644 --- a/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/issue-row.tsx @@ -30,7 +30,9 @@ interface Props { isEstimateEnabled: boolean; quickActions: TRenderQuickActions; canEditProperties: (projectId: string | undefined) => boolean; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: + | ((projectId: string | null, issueId: string, data: Partial) => Promise) + | undefined; portalElement: React.MutableRefObject; nestingLevel: number; issueId: string; @@ -130,7 +132,9 @@ interface IssueRowDetailsProps { isEstimateEnabled: boolean; quickActions: TRenderQuickActions; canEditProperties: (projectId: string | undefined) => boolean; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: + | ((projectId: string | null, issueId: string, data: Partial) => Promise) + | undefined; portalElement: React.MutableRefObject; nestingLevel: number; issueId: string; @@ -212,14 +216,14 @@ const IssueRowDetails = observer((props: IssueRowDetailsProps) => { handleIssuePeekOverview(issueDetail); } else { setExpanded((prevState) => { - if (!prevState && workspaceSlug && issueDetail) + if (!prevState && workspaceSlug && issueDetail && issueDetail.project_id) subIssuesStore.fetchSubIssues(workspaceSlug.toString(), issueDetail.project_id, issueDetail.id); return !prevState; }); } }; - const disableUserActions = !canEditProperties(issueDetail.project_id); + const disableUserActions = !canEditProperties(issueDetail.project_id ?? undefined); const subIssuesCount = issueDetail?.sub_issues_count ?? 0; const isIssueSelected = selectionHelpers.getIsEntitySelected(issueDetail.id); diff --git a/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx b/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx index f1a553f6e..701d8aa53 100644 --- a/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/quick-add-issue-form.tsx @@ -23,13 +23,7 @@ type Props = { groupId?: string; subGroupId?: string | null; prePopulatedData?: Partial; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; - viewId?: string; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; }; const defaultValues: Partial = { @@ -60,7 +54,7 @@ const Inputs = (props: any) => { }; export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => { - const { formKey, prePopulatedData, quickAddCallback, viewId } = props; + const { formKey, prePopulatedData, quickAddCallback } = props; // store hooks const { currentWorkspace } = useWorkspace(); const { currentProjectDetails } = useProject(); @@ -162,12 +156,7 @@ export const SpreadsheetQuickAddIssueForm: React.FC = observer((props) => }); if (quickAddCallback) { - const quickAddPromise = quickAddCallback( - currentWorkspace.slug, - currentProjectDetails.id, - { ...payload } as TIssue, - viewId - ); + const quickAddPromise = quickAddCallback(currentProjectDetails.id, { ...payload } as TIssue); setPromiseToast(quickAddPromise, { loading: "Adding issue...", success: { diff --git a/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx b/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx index fc3cf6994..ff2b8c8ac 100644 --- a/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/roots/cycle-root.tsx @@ -2,7 +2,6 @@ import React, { useCallback } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // constants -import { EIssuesStoreType } from "@/constants/issue"; import { EUserProjectRoles } from "@/constants/project"; // hooks import { useCycle, useUser } from "@/hooks/store"; @@ -32,11 +31,10 @@ export const CycleSpreadsheetLayout: React.FC = observer(() => { return ( ); }); diff --git a/web/components/issues/issue-layouts/spreadsheet/roots/module-root.tsx b/web/components/issues/issue-layouts/spreadsheet/roots/module-root.tsx index 9506a6ac6..8c37ea8eb 100644 --- a/web/components/issues/issue-layouts/spreadsheet/roots/module-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/roots/module-root.tsx @@ -2,7 +2,6 @@ import React from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // mobx store -import { EIssuesStoreType } from "@/constants/issue"; // components import { ModuleIssueQuickActions } from "../../quick-action-dropdowns"; import { BaseSpreadsheetRoot } from "../base-spreadsheet-root"; @@ -10,13 +9,5 @@ import { BaseSpreadsheetRoot } from "../base-spreadsheet-root"; export const ModuleSpreadsheetLayout: React.FC = observer(() => { const { moduleId } = useParams(); - if (!moduleId) return null; - - return ( - - ); + return ; }); diff --git a/web/components/issues/issue-layouts/spreadsheet/roots/project-root.tsx b/web/components/issues/issue-layouts/spreadsheet/roots/project-root.tsx index 12e3e8b3b..34c52c01e 100644 --- a/web/components/issues/issue-layouts/spreadsheet/roots/project-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/roots/project-root.tsx @@ -1,10 +1,9 @@ import React from "react"; import { observer } from "mobx-react"; // mobx store -import { EIssuesStoreType } from "@/constants/issue"; import { ProjectIssueQuickActions } from "../../quick-action-dropdowns"; import { BaseSpreadsheetRoot } from "../base-spreadsheet-root"; export const ProjectSpreadsheetLayout: React.FC = observer(() => ( - + )); diff --git a/web/components/issues/issue-layouts/spreadsheet/roots/project-view-root.tsx b/web/components/issues/issue-layouts/spreadsheet/roots/project-view-root.tsx index 454445bc8..ce7cebfbb 100644 --- a/web/components/issues/issue-layouts/spreadsheet/roots/project-view-root.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/roots/project-view-root.tsx @@ -1,23 +1,12 @@ import React from "react"; import { observer } from "mobx-react"; -import { useParams } from "next/navigation"; // mobx store -import { EIssuesStoreType } from "@/constants/issue"; // components import { ProjectIssueQuickActions } from "../../quick-action-dropdowns"; import { BaseSpreadsheetRoot } from "../base-spreadsheet-root"; // types // constants -export const ProjectViewSpreadsheetLayout: React.FC = observer(() => { - // router - const { viewId } = useParams(); - - return ( - - ); -}); +export const ProjectViewSpreadsheetLayout: React.FC = observer(() => ( + +)); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx index 67bc99182..a639957f0 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-table.tsx @@ -1,11 +1,15 @@ -import { MutableRefObject, useCallback, useEffect, useRef } from "react"; +import { MutableRefObject, useCallback, useEffect, useRef, useState } from "react"; import { observer } from "mobx-react-lite"; +// types import { IIssueDisplayFilterOptions, IIssueDisplayProperties, TIssue } from "@plane/types"; -//types +import { SpreadsheetIssueRowLoader } from "@/components/ui/loader"; +//hooks +import { useIntersectionObserver } from "@/hooks/use-intersection-observer"; import { TSelectionHelper } from "@/hooks/use-multiple-select"; import { useTableKeyboardNavigation } from "@/hooks/use-table-keyboard-navigation"; -//components +// components import { TRenderQuickActions } from "../list/list-view-types"; +import { getDisplayPropertiesCount } from "../utils"; import { SpreadsheetIssueRow } from "./issue-row"; import { SpreadsheetHeader } from "./spreadsheet-header"; @@ -16,10 +20,12 @@ type Props = { issueIds: string[]; isEstimateEnabled: boolean; quickActions: TRenderQuickActions; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; canEditProperties: (projectId: string | undefined) => boolean; portalElement: React.MutableRefObject; containerRef: MutableRefObject; + canLoadMoreIssues: boolean; + loadMoreIssues: () => void; spreadsheetColumnsList: (keyof IIssueDisplayProperties)[]; selectionHelpers: TSelectionHelper; }; @@ -35,13 +41,16 @@ export const SpreadsheetTable = observer((props: Props) => { quickActions, updateIssue, canEditProperties, + canLoadMoreIssues, containerRef, + loadMoreIssues, spreadsheetColumnsList, selectionHelpers, } = props; // states const isScrolled = useRef(false); + const [intersectionElement, setIntersectionElement] = useState(null); const handleScroll = useCallback(() => { if (!containerRef.current) return; @@ -76,10 +85,16 @@ export const SpreadsheetTable = observer((props: Props) => { }; }, [handleScroll, containerRef]); + useIntersectionObserver(containerRef, intersectionElement, loadMoreIssues, `50% 0% 50% 0%`); + const handleKeyBoardNavigation = useTableKeyboardNavigation(); + const ignoreFieldsForCounting: (keyof IIssueDisplayProperties)[] = ["key"]; + if (!isEstimateEnabled) ignoreFieldsForCounting.push("estimate"); + const displayPropertiesCount = getDisplayPropertiesCount(displayProperties, ignoreFieldsForCounting); + return ( - +
    { /> ))} + {canLoadMoreIssues && ( + + + + )}
    ); }); diff --git a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx index 169d4f94c..c029d7576 100644 --- a/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx +++ b/web/components/issues/issue-layouts/spreadsheet/spreadsheet-view.tsx @@ -20,16 +20,12 @@ type Props = { handleDisplayFilterUpdate: (data: Partial) => void; issueIds: string[] | undefined; quickActions: TRenderQuickActions; - updateIssue: ((projectId: string, issueId: string, data: Partial) => Promise) | undefined; + updateIssue: ((projectId: string | null, issueId: string, data: Partial) => Promise) | undefined; openIssuesListModal?: (() => void) | null; - quickAddCallback?: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string - ) => Promise; - viewId?: string; + quickAddCallback?: (projectId: string | null | undefined, data: TIssue) => Promise; canEditProperties: (projectId: string | undefined) => boolean; + canLoadMoreIssues: boolean; + loadMoreIssues: () => void; enableQuickCreateIssue?: boolean; disableIssueCreation?: boolean; isWorkspaceLevel?: boolean; @@ -44,10 +40,11 @@ export const SpreadsheetView: React.FC = observer((props) => { quickActions, updateIssue, quickAddCallback, - viewId, canEditProperties, enableQuickCreateIssue, disableIssueCreation, + canLoadMoreIssues, + loadMoreIssues, isWorkspaceLevel = false, } = props; // refs @@ -97,6 +94,8 @@ export const SpreadsheetView: React.FC = observer((props) => { updateIssue={updateIssue} canEditProperties={canEditProperties} containerRef={containerRef} + canLoadMoreIssues={canLoadMoreIssues} + loadMoreIssues={loadMoreIssues} spreadsheetColumnsList={spreadsheetColumnsList} selectionHelpers={helpers} /> @@ -104,7 +103,7 @@ export const SpreadsheetView: React.FC = observer((props) => {
    {enableQuickCreateIssue && !disableIssueCreation && ( - + )}
    diff --git a/web/components/issues/issue-layouts/utils.tsx b/web/components/issues/issue-layouts/utils.tsx index a488cac88..e655d7678 100644 --- a/web/components/issues/issue-layouts/utils.tsx +++ b/web/components/issues/issue-layouts/utils.tsx @@ -13,6 +13,7 @@ import { GroupByColumnTypes, IGroupByColumn, TCycleGroups, + IIssueDisplayProperties, IPragmaticDropPayload, TIssue, TIssueGroupByOptions, @@ -29,7 +30,7 @@ import { ISSUE_PRIORITIES, EIssuesStoreType } from "@/constants/issue"; import { STATE_GROUPS } from "@/constants/state"; // stores import { ICycleStore } from "@/store/cycle.store"; -import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/issue-helper.store"; +import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/base-issues.store"; import { ILabelStore } from "@/store/label.store"; import { IMemberRootStore } from "@/store/member"; import { IModuleStore } from "@/store/module.store"; @@ -88,7 +89,7 @@ export const getGroupByColumns = ( case "created_by": return getCreatedByColumns(member) as any; default: - if (includeNone) return [{ id: `null`, name: `All Issues`, payload: {}, icon: undefined }]; + if (includeNone) return [{ id: `All Issues`, name: `All Issues`, payload: {}, icon: undefined }]; } }; @@ -282,6 +283,21 @@ const getCreatedByColumns = (member: IMemberRootStore) => { }); }; +export const getDisplayPropertiesCount = ( + displayProperties: IIssueDisplayProperties, + ignoreFields?: (keyof IIssueDisplayProperties)[] +) => { + const propertyKeys = Object.keys(displayProperties) as (keyof IIssueDisplayProperties)[]; + + let count = 0; + + for (const propertyKey of propertyKeys) { + if (ignoreFields && ignoreFields.includes(propertyKey)) continue; + if (displayProperties[propertyKey]) count++; + } + + return count; +} /** * This Method finds the DOM element with elementId, scrolls to it and highlights the issue block * @param elementId @@ -537,8 +553,8 @@ export const handleGroupDragDrop = async ( updatedIssue = { ...updatedIssue, [subGroupKey]: subGroupValue }; } - if (updatedIssue) { - return await updateIssueOnDrop(sourceIssue.project_id, sourceIssue.id, updatedIssue, issueUpdates); + if (updatedIssue && sourceIssue?.project_id) { + return await updateIssueOnDrop(sourceIssue?.project_id, sourceIssue.id, updatedIssue, issueUpdates); } }; diff --git a/web/components/issues/issue-modal/form.tsx b/web/components/issues/issue-modal/form.tsx index 9b0f1deab..1efcc9fb4 100644 --- a/web/components/issues/issue-modal/form.tsx +++ b/web/components/issues/issue-modal/form.tsx @@ -422,7 +422,7 @@ export const IssueFormRoot: FC = observer((props) => { {errors?.name?.message}
    - {data?.description_html === undefined ? ( + {data?.description_html === undefined || !projectId ? (
    @@ -449,7 +449,7 @@ export const IssueFormRoot: FC = observer((props) => { control={control} render={({ field: { value, onChange } }) => ( = observer((props) => { )} )} - {config?.has_openai_configured && ( + {config?.has_openai_configured && projectId && ( = observer((props) => { onChange(stateId); handleFormChange(); }} - projectId={projectId} + projectId={projectId?? undefined} buttonVariant="border-with-text" tabIndex={getTabIndex("state_id")} /> @@ -558,7 +558,7 @@ export const IssueFormRoot: FC = observer((props) => { render={({ field: { value, onChange } }) => (
    { onChange(assigneeIds); @@ -585,7 +585,7 @@ export const IssueFormRoot: FC = observer((props) => { onChange(labelIds); handleFormChange(); }} - projectId={projectId} + projectId={projectId ?? undefined} tabIndex={getTabIndex("label_ids")} />
    @@ -630,7 +630,7 @@ export const IssueFormRoot: FC = observer((props) => { render={({ field: { value, onChange } }) => (
    { onChange(cycleId); handleFormChange(); @@ -651,7 +651,7 @@ export const IssueFormRoot: FC = observer((props) => { render={({ field: { value, onChange } }) => (
    { onChange(moduleIds); @@ -748,7 +748,7 @@ export const IssueFormRoot: FC = observer((props) => { handleFormChange(); setSelectedParentIssue(issue); }} - projectId={projectId} + projectId={projectId?? undefined} issueId={isDraft ? undefined : data?.id} /> )} diff --git a/web/components/issues/issue-modal/modal.tsx b/web/components/issues/issue-modal/modal.tsx index 201b086e8..bfd20cb98 100644 --- a/web/components/issues/issue-modal/modal.tsx +++ b/web/components/issues/issue-modal/modal.tsx @@ -61,8 +61,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop const { workspaceProjectIds } = useProject(); const { fetchCycleDetails } = useCycle(); const { fetchModuleDetails } = useModule(); - const { issues: moduleIssues } = useIssues(EIssuesStoreType.MODULE); - const { issues: cycleIssues } = useIssues(EIssuesStoreType.CYCLE); + const { issues } = useIssues(storeType); const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT); const { fetchIssue } = useIssueDetail(); // pathname @@ -114,16 +113,16 @@ export const CreateUpdateIssueModal: React.FC = observer((prop }, [data, projectId, isOpen, activeProjectId]); const addIssueToCycle = async (issue: TIssue, cycleId: string) => { - if (!workspaceSlug || !activeProjectId) return; + if (!workspaceSlug || !issue.project_id) return; - await cycleIssues.addIssueToCycle(workspaceSlug, issue.project_id, cycleId, [issue.id]); - fetchCycleDetails(workspaceSlug, activeProjectId, cycleId); + await issues.addIssueToCycle(workspaceSlug, issue.project_id, cycleId, [issue.id]); + fetchCycleDetails(workspaceSlug, issue.project_id, cycleId); }; const addIssueToModule = async (issue: TIssue, moduleIds: string[]) => { if (!workspaceSlug || !activeProjectId) return; - await moduleIssues.changeModulesInIssue(workspaceSlug, activeProjectId, issue.id, moduleIds, []); + await issues.changeModulesInIssue(workspaceSlug, activeProjectId, issue.id, moduleIds, []); moduleIds.forEach((moduleId) => fetchModuleDetails(workspaceSlug, activeProjectId, moduleId)); }; diff --git a/web/components/issues/parent-issues-list-modal.tsx b/web/components/issues/parent-issues-list-modal.tsx index 7551b534f..80eb1b13e 100644 --- a/web/components/issues/parent-issues-list-modal.tsx +++ b/web/components/issues/parent-issues-list-modal.tsx @@ -23,7 +23,7 @@ type Props = { handleClose: () => void; value?: any; onChange: (issue: ISearchIssueResponse) => void; - projectId: string; + projectId: string | undefined; issueId?: string; }; diff --git a/web/components/issues/peek-overview/issue-detail.tsx b/web/components/issues/peek-overview/issue-detail.tsx index 3fc3d7c2e..53b9e6045 100644 --- a/web/components/issues/peek-overview/issue-detail.tsx +++ b/web/components/issues/peek-overview/issue-detail.tsx @@ -44,9 +44,9 @@ export const PeekOverviewIssueDetails: FC = observer( }, [isSubmitting, setShowAlert, setIsSubmitting]); const issue = issueId ? getIssueById(issueId) : undefined; - if (!issue) return <>; + if (!issue || !issue.project_id) return <>; - const projectDetails = getProjectById(issue?.project_id); + const projectDetails = getProjectById(issue.project_id); const issueDescription = issue.description_html !== undefined || issue.description_html !== null diff --git a/web/components/issues/peek-overview/properties.tsx b/web/components/issues/peek-overview/properties.tsx index 761f57010..0acf5bdd1 100644 --- a/web/components/issues/peek-overview/properties.tsx +++ b/web/components/issues/peek-overview/properties.tsx @@ -81,7 +81,7 @@ export const PeekOverviewProperties: FC = observer((pro State
    issueOperations.update(workspaceSlug, projectId, issueId, { state_id: val })} projectId={projectId} disabled={disabled} diff --git a/web/components/issues/select/label.tsx b/web/components/issues/select/label.tsx index bfe2282f6..6b8fd4011 100644 --- a/web/components/issues/select/label.tsx +++ b/web/components/issues/select/label.tsx @@ -17,7 +17,7 @@ type Props = { setIsOpen: React.Dispatch>; value: string[]; onChange: (value: string[]) => void; - projectId: string; + projectId: string | undefined; label?: JSX.Element; disabled?: boolean; tabIndex?: number; diff --git a/web/components/issues/sub-issues/issue-list-item.tsx b/web/components/issues/sub-issues/issue-list-item.tsx index 1f9b3551f..5700a68d8 100644 --- a/web/components/issues/sub-issues/issue-list-item.tsx +++ b/web/components/issues/sub-issues/issue-list-item.tsx @@ -204,7 +204,8 @@ export const IssueListItem: React.FC = observer((props) => {
    { - subIssueOperations.removeSubIssue(workspaceSlug, issue.project_id, parentIssueId, issue.id); + issue.project_id && + subIssueOperations.removeSubIssue(workspaceSlug, issue.project_id, parentIssueId, issue.id); }} > @@ -216,7 +217,7 @@ export const IssueListItem: React.FC = observer((props) => { )} {/* should not expand the current issue if it is also the root issue*/} - {subIssueHelpers.issue_visibility.includes(issueId) && subIssueCount > 0 && !isCurrentIssueRoot && ( + {subIssueHelpers.issue_visibility.includes(issueId) && issue.project_id && subIssueCount > 0 && !isCurrentIssueRoot && ( = (props) => {
    + issue.project_id && subIssueOperations.updateSubIssue( workspaceSlug, issue.project_id, @@ -51,6 +52,7 @@ export const IssueProperty: React.FC = (props) => { + issue.project_id && subIssueOperations.updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, { priority: val, }) @@ -64,8 +66,9 @@ export const IssueProperty: React.FC = (props) => {
    + issue.project_id && subIssueOperations.updateSubIssue(workspaceSlug, issue.project_id, parentIssueId, issueId, { assignee_ids: val, }) diff --git a/web/components/labels/label-block/label-item-block.tsx b/web/components/labels/label-block/label-item-block.tsx index 16c06878e..225b011bd 100644 --- a/web/components/labels/label-block/label-item-block.tsx +++ b/web/components/labels/label-block/label-item-block.tsx @@ -2,17 +2,17 @@ import { MutableRefObject, useRef, useState } from "react"; import { LucideIcon, X } from "lucide-react"; +// types import { IIssueLabel } from "@plane/types"; -//ui +// ui import { CustomMenu, DragHandle } from "@plane/ui"; -//types +// helpers import { cn } from "@/helpers/common.helper"; +// hooks import useOutsideClickDetector from "@/hooks/use-outside-click-detector"; -//hooks -//components +// components import { LabelName } from "./label-name"; -//types export interface ICustomMenuItem { CustomIcon: LucideIcon; onClick: (label: IIssueLabel) => void; diff --git a/web/components/modules/gantt-chart/modules-list-layout.tsx b/web/components/modules/gantt-chart/modules-list-layout.tsx index 0d55cf3a5..1c4c88dcb 100644 --- a/web/components/modules/gantt-chart/modules-list-layout.tsx +++ b/web/components/modules/gantt-chart/modules-list-layout.tsx @@ -1,9 +1,11 @@ +import { useCallback } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import { IModule } from "@plane/types"; // mobx store // components -import { GanttChartRoot, IBlockUpdateData, ModuleGanttSidebar } from "@/components/gantt-chart"; +import { ChartDataType, GanttChartRoot, IBlockUpdateData, ModuleGanttSidebar } from "@/components/gantt-chart"; +import { getMonthChartItemPositionWidthInMonth } from "@/components/gantt-chart/views"; import { ModuleGanttBlock } from "@/components/modules"; import { getDate } from "@/helpers/date-time.helper"; import { useModule, useModuleFilter, useProject } from "@/hooks/store"; @@ -14,7 +16,7 @@ export const ModulesListGanttChartView: React.FC = observer(() => { const { workspaceSlug, projectId } = useParams(); // store const { currentProjectDetails } = useProject(); - const { getFilteredModuleIds, moduleMap, updateModuleDetails } = useModule(); + const { getFilteredModuleIds, getModuleById, updateModuleDetails } = useModule(); const { currentProjectDisplayFilters: displayFilters } = useModuleFilter(); // derived values const filteredModuleIds = projectId ? getFilteredModuleIds(projectId.toString()) : undefined; @@ -28,26 +30,39 @@ export const ModulesListGanttChartView: React.FC = observer(() => { await updateModuleDetails(workspaceSlug.toString(), module.project_id, module.id, payload); }; - const blockFormat = (blocks: string[]) => - blocks?.map((blockId) => { - const block = moduleMap[blockId]; - return { - data: block, - id: block.id, - sort_order: block.sort_order, - start_date: getDate(block.start_date), - target_date: getDate(block.target_date), + const getBlockById = useCallback( + (id: string, currentViewData?: ChartDataType | undefined) => { + const projectModule = getModuleById(id); + + const block = { + data: projectModule, + id: projectModule?.id ?? "", + sort_order: projectModule?.sort_order ?? 0, + start_date: getDate(projectModule?.start_date), + target_date: getDate(projectModule?.target_date), }; - }); + if (currentViewData) { + return { + ...block, + position: getMonthChartItemPositionWidthInMonth(currentViewData, block), + }; + } + return block; + }, + [getModuleById] + ); const isAllowed = currentProjectDetails?.member_role === 20 || currentProjectDetails?.member_role === 15; + if (!filteredModuleIds) return null; + return (
    } blockUpdateHandler={(block, payload) => handleModuleUpdate(block, payload)} blockToRender={(data: IModule) => } diff --git a/web/components/profile/profile-issues-filter.tsx b/web/components/profile/profile-issues-filter.tsx index c6e3dec9f..cbacf33c6 100644 --- a/web/components/profile/profile-issues-filter.tsx +++ b/web/components/profile/profile-issues-filter.tsx @@ -2,11 +2,11 @@ import { useCallback } from "react"; import { observer } from "mobx-react"; import { useParams } from "next/navigation"; // types -import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, TIssueLayouts } from "@plane/types"; +import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions } from "@plane/types"; // components import { DisplayFiltersSelection, FilterSelection, FiltersDropdown, LayoutSelection } from "@/components/issues"; // constants -import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { EIssuesStoreType, EIssueFilterType, ISSUE_DISPLAY_FILTERS_BY_LAYOUT, EIssueLayoutTypes } from "@/constants/issue"; // helpers import { calculateTotalFilters } from "@/helpers/filter.helper"; // hooks @@ -27,7 +27,7 @@ export const ProfileIssuesFilter = observer(() => { const activeLayout = issueFilters?.displayFilters?.layout; const handleLayoutChange = useCallback( - (layout: TIssueLayouts) => { + (layout: EIssueLayoutTypes) => { if (!workspaceSlug || !userId) return; updateFilters( workspaceSlug.toString(), @@ -100,7 +100,7 @@ export const ProfileIssuesFilter = observer(() => { return (
    handleLayoutChange(layout)} selectedLayout={activeLayout} /> diff --git a/web/components/profile/profile-issues.tsx b/web/components/profile/profile-issues.tsx index ccca34bf1..f0b7ac48b 100644 --- a/web/components/profile/profile-issues.tsx +++ b/web/components/profile/profile-issues.tsx @@ -3,15 +3,13 @@ import { observer } from "mobx-react"; import { useParams } from "next/navigation"; import useSWR from "swr"; // components -import { EmptyState } from "@/components/empty-state"; import { IssuePeekOverview, ProfileIssuesAppliedFiltersRoot } from "@/components/issues"; import { ProfileIssuesKanBanLayout } from "@/components/issues/issue-layouts/kanban/roots/profile-issues-root"; import { ProfileIssuesListLayout } from "@/components/issues/issue-layouts/list/roots/profile-issues-root"; -import { KanbanLayoutLoader, ListLayoutLoader } from "@/components/ui"; // hooks -import { EMPTY_STATE_DETAILS } from "@/constants/empty-state"; import { EIssuesStoreType } from "@/constants/issue"; import { useIssues } from "@/hooks/store"; +import { IssuesStoreContext } from "../../hooks/use-issue-layout-store"; // constants interface IProfileIssuesPage { @@ -27,7 +25,7 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => { }; // store hooks const { - issues: { loader, groupedIssueIds, fetchIssues, setViewId }, + issues: { setViewId }, issuesFilter: { issueFilters, fetchFilters }, } = useIssues(EIssuesStoreType.PROFILE); @@ -36,11 +34,10 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => { }, [type, setViewId]); useSWR( - workspaceSlug && userId ? `CURRENT_WORKSPACE_PROFILE_ISSUES_${workspaceSlug}_${userId}_${type}` : null, + workspaceSlug && userId ? `CURRENT_WORKSPACE_PROFILE_ISSUES_${workspaceSlug}_${userId}` : null, async () => { if (workspaceSlug && userId) { await fetchFilters(workspaceSlug, userId); - await fetchIssues(workspaceSlug, undefined, groupedIssueIds ? "mutation" : "init-loader", userId, type); } }, { revalidateIfStale: false, revalidateOnFocus: false } @@ -48,17 +45,8 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => { const activeLayout = issueFilters?.displayFilters?.layout || undefined; - const emptyStateType = `profile-${type}`; - - if (!groupedIssueIds || loader === "init-loader") - return <>{activeLayout === "list" ? : }; - - if (groupedIssueIds.length === 0) { - return ; - } - return ( - <> +
    {activeLayout === "list" ? ( @@ -69,6 +57,6 @@ export const ProfileIssuesPage = observer((props: IProfileIssuesPage) => {
    {/* peek overview */} - +
    ); }); diff --git a/web/components/ui/loader/layouts/calendar-layout-loader.tsx b/web/components/ui/loader/layouts/calendar-layout-loader.tsx index 6bb75d3c6..1e2e13bd4 100644 --- a/web/components/ui/loader/layouts/calendar-layout-loader.tsx +++ b/web/components/ui/loader/layouts/calendar-layout-loader.tsx @@ -18,17 +18,7 @@ const CalendarDay = () => { export const CalendarLayoutLoader = () => (
    -
    -
    - - -
    -
    - - -
    -
    - + {[...Array(5)].map((_, index) => ( ))} diff --git a/web/components/ui/loader/layouts/kanban-layout-loader.tsx b/web/components/ui/loader/layouts/kanban-layout-loader.tsx index b949c1b73..85041bc5f 100644 --- a/web/components/ui/loader/layouts/kanban-layout-loader.tsx +++ b/web/components/ui/loader/layouts/kanban-layout-loader.tsx @@ -1,16 +1,24 @@ +import { forwardRef } from "react"; + +export const KanbanIssueBlockLoader = forwardRef((props, ref) => ( + +)); + +KanbanIssueBlockLoader.displayName = "KanbanIssueBlockLoader"; + export const KanbanLayoutLoader = ({ cardsInEachColumn = [2, 3, 2, 4, 3] }: { cardsInEachColumn?: number[] }) => (
    {cardsInEachColumn.map((cardsInColumn, columnIndex) => ( -
    +
    -
    - - +
    + +
    - +
    {Array.from({ length: cardsInColumn }, (_, cardIndex) => ( - + ))}
    ))} diff --git a/web/components/ui/loader/layouts/list-layout-loader.tsx b/web/components/ui/loader/layouts/list-layout-loader.tsx index 7285886a3..8c471ef47 100644 --- a/web/components/ui/loader/layouts/list-layout-loader.tsx +++ b/web/components/ui/loader/layouts/list-layout-loader.tsx @@ -1,44 +1,46 @@ -import { Fragment } from "react"; +import { Fragment, forwardRef } from "react"; import { getRandomInt, getRandomLength } from "../utils"; -const ListItemRow = () => ( -
    +export const ListLoaderItemRow = forwardRef((props, ref) => ( +
    - - + +
    {[...Array(6)].map((_, index) => ( {getRandomInt(1, 2) % 2 === 0 ? ( - + ) : ( - + )} ))}
    -); +)); + +ListLoaderItemRow.displayName = "ListLoaderItemRow"; const ListSection = ({ itemCount }: { itemCount: number }) => (
    - - + +
    {[...Array(itemCount)].map((_, index) => ( - + ))}
    ); export const ListLayoutLoader = () => ( -
    +
    {[6, 5, 2].map((itemCount, index) => ( ))} diff --git a/web/components/ui/loader/layouts/spreadsheet-layout-loader.tsx b/web/components/ui/loader/layouts/spreadsheet-layout-loader.tsx index 3b9e9bc65..fbc17c252 100644 --- a/web/components/ui/loader/layouts/spreadsheet-layout-loader.tsx +++ b/web/components/ui/loader/layouts/spreadsheet-layout-loader.tsx @@ -1,36 +1,42 @@ import { getRandomLength } from "../utils"; +export const SpreadsheetIssueRowLoader = (props: { columnCount: number }) => ( + + +
    + + +
    + + {[...Array(props.columnCount)].map((_, colIndex) => ( + +
    + +
    + + ))} + +); + export const SpreadsheetLayoutLoader = () => ( -
    +
    - {[...Array(16)].map((_, rowIndex) => ( - - - {[...Array(10)].map((_, colIndex) => ( - - ))} - + ))}
    + {[...Array(10)].map((_, index) => ( ))}
    -
    - - -
    -
    -
    - -
    -
    diff --git a/web/components/ui/loader/utils.tsx b/web/components/ui/loader/utils.tsx index 312df038e..3637626ed 100644 --- a/web/components/ui/loader/utils.tsx +++ b/web/components/ui/loader/utils.tsx @@ -1,35 +1,6 @@ -import { - CalendarLayoutLoader, - GanttLayoutLoader, - KanbanLayoutLoader, - ListLayoutLoader, - SpreadsheetLayoutLoader, -} from "./layouts"; - export const getRandomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; export const getRandomLength = (lengthArray: string[]) => { const randomIndex = Math.floor(Math.random() * lengthArray.length); return `${lengthArray[randomIndex]}`; }; - -interface Props { - layout: string; -} -export const ActiveLoader: React.FC = (props) => { - const { layout } = props; - switch (layout) { - case "list": - return ; - case "kanban": - return ; - case "spreadsheet": - return ; - case "calendar": - return ; - case "gantt_chart": - return ; - default: - return ; - } -}; diff --git a/web/constants/errors.ts b/web/constants/errors.ts new file mode 100644 index 000000000..b682c2ee1 --- /dev/null +++ b/web/constants/errors.ts @@ -0,0 +1,25 @@ +export enum EErrorCodes { + "INVALID_ARCHIVE_STATE_GROUP" = 4091, + "INVALID_ISSUE_START_DATE" = 4101, + "INVALID_ISSUE_TARGET_DATE" = 4102, +} + +export const ERROR_DETAILS: { + [key in EErrorCodes]: { + title: string; + message: string; + }; +} = { + [EErrorCodes.INVALID_ARCHIVE_STATE_GROUP]: { + title: "Unable to archive issues", + message: "Only issues belonging to Completed or Canceled state groups can be archived.", + }, + [EErrorCodes.INVALID_ISSUE_START_DATE]: { + title: "Unable to update issues", + message: "Start date selected succeeds the due date for some issues. Ensure start date to be before the due date.", + }, + [EErrorCodes.INVALID_ISSUE_TARGET_DATE]: { + title: "Unable to update issues", + message: "Due date selected precedes the start date for some issues. Ensure due date to be after the start date.", + }, +}; diff --git a/web/constants/issue.ts b/web/constants/issue.ts index 78b027cf6..95a761e54 100644 --- a/web/constants/issue.ts +++ b/web/constants/issue.ts @@ -6,12 +6,14 @@ import { IIssueDisplayProperties, TIssueExtraOptions, TIssueGroupByOptions, - TIssueLayouts, TIssueOrderByOptions, TIssuePriorities, TIssueTypeFilters, } from "@plane/types"; + +export const ALL_ISSUES = "All Issues"; + export const DRAG_ALLOWED_GROUPS: TIssueGroupByOptions[] = [ "state", "priority", @@ -33,6 +35,14 @@ export enum EIssuesStoreType { DEFAULT = "DEFAULT", } +export enum EIssueLayoutTypes { + LIST = "list", + KANBAN = "kanban", + CALENDAR = "calendar", + GANTT = "gantt_chart", + SPREADSHEET = "spreadsheet", +} + export type TCreateModalStoreTypes = | EIssuesStoreType.PROJECT | EIssuesStoreType.PROJECT_VIEW @@ -88,7 +98,7 @@ export const ISSUE_ORDER_BY_OPTIONS: { { key: "-updated_at", title: "Last Updated" }, { key: "start_date", title: "Start Date" }, { key: "target_date", title: "Due Date" }, - { key: "-priority", title: "Priority" }, + { key: "priority", title: "Priority" }, ]; export const ISSUE_FILTER_OPTIONS: { @@ -129,15 +139,15 @@ export const ISSUE_EXTRA_OPTIONS: { ]; export const ISSUE_LAYOUTS: { - key: TIssueLayouts; + key: EIssueLayoutTypes; title: string; icon: any; }[] = [ - { key: "list", title: "List Layout", icon: List }, - { key: "kanban", title: "Kanban Layout", icon: Kanban }, - { key: "calendar", title: "Calendar Layout", icon: Calendar }, - { key: "spreadsheet", title: "Spreadsheet Layout", icon: Sheet }, - { key: "gantt_chart", title: "Gantt Chart Layout", icon: GanttChartSquare }, + { key: EIssueLayoutTypes.LIST, title: "List Layout", icon: List }, + { key: EIssueLayoutTypes.KANBAN, title: "Kanban Layout", icon: Kanban }, + { key: EIssueLayoutTypes.CALENDAR, title: "Calendar Layout", icon: Calendar }, + { key: EIssueLayoutTypes.SPREADSHEET, title: "Spreadsheet Layout", icon: Sheet }, + { key: EIssueLayoutTypes.GANTT, title: "Gantt Chart Layout", icon: GanttChartSquare }, ]; export interface ILayoutDisplayFiltersOptions { @@ -164,7 +174,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { display_properties: true, display_filters: { group_by: ["state_detail.group", "priority", "project", "labels", null], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -177,7 +187,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { display_properties: true, display_filters: { group_by: ["state_detail.group", "priority", "project", "labels"], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -212,7 +222,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { "created_by", null, ], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -227,7 +237,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { display_properties: true, display_filters: { group_by: ["state_detail.group", "cycle", "module", "priority", "project", "labels", null], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -240,7 +250,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { display_properties: true, display_filters: { group_by: ["state_detail.group", "cycle", "module", "priority", "project", "labels"], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -264,6 +274,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { ], display_properties: true, display_filters: { + order_by: [], type: [null, "active", "backlog"], }, extra_options: { @@ -310,7 +321,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { display_properties: true, display_filters: { group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by", null], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -335,7 +346,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { display_filters: { group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by"], sub_group_by: ["state", "priority", "cycle", "module", "labels", "assignees", "created_by", null], - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority", "target_date"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority", "target_date"], type: [null, "active", "backlog"], }, extra_options: { @@ -369,7 +380,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { ], display_properties: true, display_filters: { - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -392,7 +403,7 @@ export const ISSUE_DISPLAY_FILTERS_BY_LAYOUT: { ], display_properties: false, display_filters: { - order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "-priority"], + order_by: ["sort_order", "-created_at", "-updated_at", "start_date", "priority"], type: [null, "active", "backlog"], }, extra_options: { @@ -432,3 +443,28 @@ export const groupReactionEmojis = (reactions: any) => { return groupedEmojis; }; +export enum EIssueGroupByToServerOptions { + "state" = "state_id", + "priority" = "priority", + "labels" = "labels__id", + "state_detail.group" = "state__group", + "assignees" = "assignees__id", + "cycle" = "cycle_id", + "module" = "issue_module__module_id", + "target_date" = "target_date", + "project" = "project_id", + "created_by" = "created_by", +} + +export enum EServerGroupByToFilterOptions { + "state_id" = "state", + "priority" = "priority", + "labels__id" = "labels", + "state__group" = "state_group", + "assignees__id" = "assignees", + "cycle_id" = "cycle", + "issue_module__module_id" = "module", + "target_date" = "target_date", + "project_id" = "project", + "created_by" = "created_by", +} diff --git a/web/constants/spreadsheet.ts b/web/constants/spreadsheet.ts index 852732d40..9ba69cbf2 100644 --- a/web/constants/spreadsheet.ts +++ b/web/constants/spreadsheet.ts @@ -17,7 +17,6 @@ import { IIssueDisplayProperties, TIssue, TIssueOrderByOptions } from "@plane/ty // ui import { LayersIcon, DoubleCircleIcon, DiceIcon, ContrastIcon } from "@plane/ui"; import { ISvgIcons } from "@plane/ui/src/icons/type"; -// components import { SpreadsheetAssigneeColumn, SpreadsheetAttachmentColumn, @@ -98,27 +97,27 @@ export const SPREADSHEET_PROPERTY_DETAILS: { }, modules: { title: "Modules", - ascendingOrderKey: "modules__name", + ascendingOrderKey: "issue_module__module__name", ascendingOrderTitle: "A", - descendingOrderKey: "-modules__name", + descendingOrderKey: "-issue_module__module__name", descendingOrderTitle: "Z", icon: DiceIcon, Column: SpreadsheetModuleColumn, }, cycle: { title: "Cycle", - ascendingOrderKey: "cycle__name", + ascendingOrderKey: "issue_cycle__cycle__name", ascendingOrderTitle: "A", - descendingOrderKey: "-cycle__name", + descendingOrderKey: "-issue_cycle__cycle__name", descendingOrderTitle: "Z", icon: ContrastIcon, Column: SpreadsheetCycleColumn, }, priority: { title: "Priority", - ascendingOrderKey: "priority", + ascendingOrderKey: "-priority", ascendingOrderTitle: "None", - descendingOrderKey: "-priority", + descendingOrderKey: "priority", descendingOrderTitle: "Urgent", icon: Signal, Column: SpreadsheetPriorityColumn, diff --git a/web/helpers/issue.helper.ts b/web/helpers/issue.helper.ts index d01f996c7..ae12ec84f 100644 --- a/web/helpers/issue.helper.ts +++ b/web/helpers/issue.helper.ts @@ -5,7 +5,6 @@ import { TGroupedIssues, TIssue, TIssueGroupByOptions, - TIssueLayouts, TIssueOrderByOptions, TIssueParams, TStateGroups, @@ -14,7 +13,7 @@ import { } from "@plane/types"; import { IGanttBlock } from "@/components/gantt-chart"; // constants -import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; +import { EIssueLayoutTypes, ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue"; import { STATE_GROUPS } from "@/constants/state"; // helpers import { orderArrayBy } from "@/helpers/array.helper"; @@ -93,7 +92,7 @@ export const handleIssuesMutation: THandleIssuesMutation = ( }; export const handleIssueQueryParamsByLayout = ( - layout: TIssueLayouts | undefined, + layout: EIssueLayoutTypes | undefined, viewType: "my_issues" | "issues" | "profile_issues" | "archived_issues" | "draft_issues" ): TIssueParams[] | null => { const queryParams: TIssueParams[] = []; @@ -169,14 +168,14 @@ export const shouldHighlightIssueDueDate = ( // if the issue is overdue, highlight the due date return targetDateDistance <= 0; }; -export const renderIssueBlocksStructure = (blocks: TIssue[]): IGanttBlock[] => - blocks?.map((block) => ({ +export const getIssueBlocksStructure = (block: TIssue): IGanttBlock => { + return { data: block, - id: block.id, - sort_order: block.sort_order, - start_date: getDate(block.start_date), - target_date: getDate(block.target_date), - })); + id: block?.id, + sort_order: block?.sort_order, + start_date: getDate(block?.start_date), + target_date: getDate(block?.target_date), + };}; export function getChangedIssuefields(formData: Partial, dirtyFields: { [key: string]: boolean | undefined }) { const changedFields: Partial = {}; @@ -216,8 +215,8 @@ export const getDescriptionPlaceholder = (isFocused: boolean, description: strin }; export const issueCountBasedOnFilters = ( - issueIds: TUnGroupedIssues | TGroupedIssues | TSubGroupedIssues, - layout: TIssueLayouts, + issueIds: TGroupedIssues | TUnGroupedIssues | TSubGroupedIssues, + layout: EIssueLayoutTypes, groupBy: string | undefined, subGroupBy: string | undefined ): number => { diff --git a/web/hooks/store/use-issues.ts b/web/hooks/store/use-issues.ts index ff32880fd..b535837ba 100644 --- a/web/hooks/store/use-issues.ts +++ b/web/hooks/store/use-issues.ts @@ -53,8 +53,8 @@ export type TStoreIssues = { issuesFilter: IDraftIssuesFilter; }; [EIssuesStoreType.DEFAULT]: defaultIssueStore & { - issues: undefined; - issuesFilter: undefined; + issues: IProjectIssues; + issuesFilter: IProjectIssuesFilter; }; }; @@ -109,8 +109,8 @@ export const useIssues = (storeType?: T): TStoreIssu }) as TStoreIssues[T]; default: return merge(defaultStore, { - issues: undefined, - issuesFilter: undefined, + issues: context.issue.projectIssues, + issuesFilter: context.issue.projectIssuesFilter, }) as TStoreIssues[T]; } }; diff --git a/web/hooks/use-group-dragndrop.ts b/web/hooks/use-group-dragndrop.ts index 974c2ed47..621a9dc7c 100644 --- a/web/hooks/use-group-dragndrop.ts +++ b/web/hooks/use-group-dragndrop.ts @@ -5,7 +5,7 @@ import { TIssue, TIssueGroupByOptions, TIssueOrderByOptions } from "@plane/types import { TOAST_TYPE, setToast } from "@plane/ui"; import { GroupDropLocation, handleGroupDragDrop } from "@/components/issues/issue-layouts/utils"; import { EIssuesStoreType } from "@/constants/issue"; -import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/issue-helper.store"; +import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/base-issues.store"; import { useIssueDetail, useIssues } from "./store"; import { useIssuesActions } from "./use-issues-actions"; @@ -31,14 +31,8 @@ export const useGroupIssuesDragNDrop = ( } = useIssueDetail(); const { updateIssue } = useIssuesActions(storeType); const { - issues: { getIssueIds }, + issues: { getIssueIds, addCycleToIssue, removeCycleFromIssue, changeModulesInIssue }, } = useIssues(storeType); - const { - issues: { addCycleToIssue, removeCycleFromIssue }, - } = useIssues(EIssuesStoreType.CYCLE); - const { - issues: { changeModulesInIssue }, - } = useIssues(EIssuesStoreType.MODULE); /** * update Issue on Drop, checks if modules or cycles are changed and then calls appropriate functions diff --git a/web/hooks/use-intersection-observer.tsx b/web/hooks/use-intersection-observer.ts similarity index 64% rename from web/hooks/use-intersection-observer.tsx rename to web/hooks/use-intersection-observer.ts index 5ed560511..63ab31f37 100644 --- a/web/hooks/use-intersection-observer.tsx +++ b/web/hooks/use-intersection-observer.ts @@ -1,16 +1,16 @@ import { RefObject, useEffect } from "react"; export type UseIntersectionObserverProps = { - containerRef: RefObject; - elementRef: RefObject; + containerRef: RefObject | undefined; + elementRef: HTMLElement | null; callback: () => void; rootMargin?: string; }; export const useIntersectionObserver = ( - containerRef: RefObject, - elementRef: HTMLDivElement | null, - callback: () => void, + containerRef: RefObject, + elementRef: HTMLElement | null, + callback: (() => void) | undefined, rootMargin?: string ) => { useEffect(() => { @@ -18,11 +18,11 @@ export const useIntersectionObserver = ( const observer = new IntersectionObserver( (entries) => { if (entries[entries.length - 1].isIntersecting) { - callback(); + callback && callback(); } }, { - root: containerRef.current, + root: containerRef?.current, rootMargin, } ); @@ -34,8 +34,8 @@ export const useIntersectionObserver = ( } }; } - // while removing the current from the refs, the observer is not not working as expected - // fix this eslint warning with caution + // When i am passing callback as a dependency, it is causing infinite loop, + // Please make sure you fix this eslint lint disable error with caution // eslint-disable-next-line react-hooks/exhaustive-deps }, [rootMargin, callback, elementRef, containerRef.current]); }; diff --git a/web/hooks/use-issue-layout-store.ts b/web/hooks/use-issue-layout-store.ts new file mode 100644 index 000000000..6a9f4237f --- /dev/null +++ b/web/hooks/use-issue-layout-store.ts @@ -0,0 +1,17 @@ +import { EIssuesStoreType } from "@/constants/issue"; +import { createContext, useContext } from "react"; +import { useIssues } from "./store"; + +export const IssuesStoreContext = createContext(EIssuesStoreType.PROJECT); + +export const useIssueStoreType = () => { + const storeType = useContext(IssuesStoreContext); + + return storeType; +}; + +export const useIssuesStore = () => { + const storeType = useContext(IssuesStoreContext); + + return useIssues(storeType); +}; diff --git a/web/hooks/use-issues-actions.tsx b/web/hooks/use-issues-actions.tsx index 581df33d3..62c776e6f 100644 --- a/web/hooks/use-issues-actions.tsx +++ b/web/hooks/use-issues-actions.tsx @@ -4,21 +4,30 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, + IssuePaginationOptions, TIssue, TIssueKanbanFilters, + TIssuesResponse, TLoader, + TProfileViews, } from "@plane/types"; import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { useAppRouter, useIssues } from "./store"; interface IssueActions { - fetchIssues?: (projectId: string, loadType: TLoader) => Promise; - removeIssue: (projectId: string, issueId: string) => Promise; - createIssue?: (projectId: string, data: Partial) => Promise; - updateIssue?: (projectId: string, issueId: string, data: Partial) => Promise; - removeIssueFromView?: (projectId: string, issueId: string) => Promise; - archiveIssue?: (projectId: string, issueId: string) => Promise; - restoreIssue?: (projectId: string, issueId: string) => Promise; + fetchIssues: ( + loadType: TLoader, + options: IssuePaginationOptions, + viewId?: string + ) => Promise; + fetchNextIssues: (groupId?: string, subGroupId?: string) => Promise; + removeIssue: (projectId: string | undefined | null, issueId: string) => Promise; + createIssue?: (projectId: string | undefined | null, data: Partial) => Promise; + quickAddIssue?: (projectId: string | undefined | null, data: TIssue) => Promise; + updateIssue?: (projectId: string | undefined | null, issueId: string, data: Partial) => Promise; + removeIssueFromView?: (projectId: string | undefined | null, issueId: string) => Promise; + archiveIssue?: (projectId: string | undefined | null, issueId: string) => Promise; + restoreIssue?: (projectId: string | undefined | null, issueId: string) => Promise; updateFilters: ( projectId: string, filterType: EIssueFilterType, @@ -30,25 +39,25 @@ export const useIssuesActions = (storeType: EIssuesStoreType): IssueActions => { const projectIssueActions = useProjectIssueActions(); const cycleIssueActions = useCycleIssueActions(); const moduleIssueActions = useModuleIssueActions(); - const profileIssueActions = useProfileIssueActions(); const projectViewIssueActions = useProjectViewIssueActions(); + const globalIssueActions = useGlobalIssueActions(); + const profileIssueActions = useProfileIssueActions(); const draftIssueActions = useDraftIssueActions(); const archivedIssueActions = useArchivedIssueActions(); - const globalIssueActions = useGlobalIssueActions(); switch (storeType) { case EIssuesStoreType.PROJECT_VIEW: return projectViewIssueActions; case EIssuesStoreType.PROFILE: return profileIssueActions; - case EIssuesStoreType.CYCLE: - return cycleIssueActions; - case EIssuesStoreType.MODULE: - return moduleIssueActions; case EIssuesStoreType.ARCHIVED: return archivedIssueActions; case EIssuesStoreType.DRAFT: return draftIssueActions; + case EIssuesStoreType.CYCLE: + return cycleIssueActions; + case EIssuesStoreType.MODULE: + return moduleIssueActions; case EIssuesStoreType.GLOBAL: return globalIssueActions; case EIssuesStoreType.PROJECT: @@ -60,39 +69,54 @@ export const useIssuesActions = (storeType: EIssuesStoreType): IssueActions => { const useProjectIssueActions = () => { const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT); - const { workspaceSlug } = useAppRouter(); + const { workspaceSlug, projectId } = useAppRouter(); const fetchIssues = useCallback( - async (projectId: string, loadType: TLoader) => { - if (!workspaceSlug) return; - return await issues.fetchIssues(workspaceSlug, projectId, loadType); + async (loadType: TLoader, options: IssuePaginationOptions) => { + if (!workspaceSlug || !projectId) return; + return issues.fetchIssues(workspaceSlug.toString(), projectId.toString(), loadType, options); }, - [issues.fetchIssues, workspaceSlug] + [issues.fetchIssues, workspaceSlug, projectId] ); + const fetchNextIssues = useCallback( + async (groupId?: string, subGroupId?: string) => { + if (!workspaceSlug || !projectId) return; + return issues.fetchNextIssues(workspaceSlug.toString(), projectId.toString(), groupId, subGroupId); + }, + [issues.fetchIssues, workspaceSlug, projectId] + ); + const createIssue = useCallback( - async (projectId: string, data: Partial) => { - if (!workspaceSlug) return; + async (projectId: string | undefined | null, data: Partial) => { + if (!workspaceSlug || !projectId) return; return await issues.createIssue(workspaceSlug, projectId, data); }, [issues.createIssue, workspaceSlug] ); + const quickAddIssue = useCallback( + async (projectId: string | undefined | null, data: TIssue) => { + if (!workspaceSlug || !projectId) return; + return await issues.quickAddIssue(workspaceSlug, projectId, data); + }, + [issues.quickAddIssue, workspaceSlug] + ); const updateIssue = useCallback( - async (projectId: string, issueId: string, data: Partial) => { - if (!workspaceSlug) return; + async (projectId: string | undefined | null, issueId: string, data: Partial) => { + if (!workspaceSlug || !projectId) return; return await issues.updateIssue(workspaceSlug, projectId, issueId, data); }, [issues.updateIssue, workspaceSlug] ); const removeIssue = useCallback( - async (projectId: string, issueId: string) => { - if (!workspaceSlug) return; + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; return await issues.removeIssue(workspaceSlug, projectId, issueId); }, [issues.removeIssue, workspaceSlug] ); const archiveIssue = useCallback( - async (projectId: string, issueId: string) => { - if (!workspaceSlug) return; + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; return await issues.archiveIssue(workspaceSlug, projectId, issueId); }, [issues.archiveIssue, workspaceSlug] @@ -113,61 +137,84 @@ const useProjectIssueActions = () => { return useMemo( () => ({ fetchIssues, + fetchNextIssues, createIssue, + quickAddIssue, updateIssue, removeIssue, archiveIssue, updateFilters, }), - [fetchIssues, createIssue, updateIssue, removeIssue, archiveIssue, updateFilters] + [fetchIssues, fetchNextIssues, createIssue, quickAddIssue, updateIssue, removeIssue, archiveIssue, updateFilters] ); }; const useCycleIssueActions = () => { const { issues, issuesFilter } = useIssues(EIssuesStoreType.CYCLE); - const { workspaceSlug, cycleId } = useAppRouter(); + const { workspaceSlug, projectId, cycleId } = useAppRouter(); const fetchIssues = useCallback( - async (projectId: string, loadType: TLoader) => { - if (!cycleId || !workspaceSlug) return; - return await issues.fetchIssues(workspaceSlug, projectId, loadType, cycleId); + async (loadType: TLoader, options: IssuePaginationOptions, cycleId?: string) => { + if (!workspaceSlug || !projectId || !cycleId) return; + return issues.fetchIssues(workspaceSlug.toString(), projectId.toString(), loadType, options, cycleId.toString()); }, - [issues.fetchIssues, cycleId, workspaceSlug] + [issues.fetchIssues, workspaceSlug, projectId] ); + const fetchNextIssues = useCallback( + async (groupId?: string, subGroupId?: string) => { + if (!workspaceSlug || !projectId || !cycleId) return; + return issues.fetchNextIssues( + workspaceSlug.toString(), + projectId.toString(), + cycleId.toString(), + groupId, + subGroupId + ); + }, + [issues.fetchIssues, workspaceSlug, projectId, cycleId] + ); + const createIssue = useCallback( - async (projectId: string, data: Partial) => { - if (!cycleId || !workspaceSlug) return; + async (projectId: string | undefined | null, data: Partial) => { + if (!cycleId || !workspaceSlug || !projectId) return; return await issues.createIssue(workspaceSlug, projectId, data, cycleId); }, [issues.createIssue, cycleId, workspaceSlug] ); - const updateIssue = useCallback( - async (projectId: string, issueId: string, data: Partial) => { - if (!cycleId || !workspaceSlug) return; - return await issues.updateIssue(workspaceSlug, projectId, issueId, data, cycleId); + const quickAddIssue = useCallback( + async (projectId: string | undefined | null, data: TIssue) => { + if (!cycleId || !workspaceSlug || !projectId) return; + return await issues.quickAddIssue(workspaceSlug, projectId, data, cycleId); }, - [issues.updateIssue, cycleId, workspaceSlug] + [issues.quickAddIssue, workspaceSlug, cycleId] + ); + const updateIssue = useCallback( + async (projectId: string | undefined | null, issueId: string, data: Partial) => { + if (!workspaceSlug || !projectId) return; + return await issues.updateIssue(workspaceSlug, projectId, issueId, data); + }, + [issues.updateIssue, workspaceSlug] ); const removeIssue = useCallback( - async (projectId: string, issueId: string) => { - if (!cycleId || !workspaceSlug) return; - return await issues.removeIssue(workspaceSlug, projectId, issueId, cycleId); + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; + return await issues.removeIssue(workspaceSlug, projectId, issueId); }, - [issues.removeIssue, cycleId, workspaceSlug] + [issues.removeIssue, workspaceSlug] ); const removeIssueFromView = useCallback( - async (projectId: string, issueId: string) => { - if (!cycleId || !workspaceSlug) return; + async (projectId: string | undefined | null, issueId: string) => { + if (!cycleId || !workspaceSlug || !projectId) return; return await issues.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); }, [issues.removeIssueFromCycle, cycleId, workspaceSlug] ); const archiveIssue = useCallback( - async (projectId: string, issueId: string) => { - if (!cycleId || !workspaceSlug) return; - return await issues.archiveIssue(workspaceSlug, projectId, issueId, cycleId); + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; + return await issues.archiveIssue(workspaceSlug, projectId, issueId); }, - [issues.archiveIssue, cycleId, workspaceSlug] + [issues.archiveIssue, workspaceSlug] ); const updateFilters = useCallback( @@ -185,61 +232,94 @@ const useCycleIssueActions = () => { return useMemo( () => ({ fetchIssues, + fetchNextIssues, createIssue, + quickAddIssue, updateIssue, removeIssue, removeIssueFromView, archiveIssue, updateFilters, }), - [fetchIssues, createIssue, updateIssue, removeIssue, removeIssueFromView, archiveIssue, updateFilters] + [ + fetchIssues, + fetchNextIssues, + createIssue, + quickAddIssue, + updateIssue, + removeIssue, + removeIssueFromView, + archiveIssue, + updateFilters, + ] ); }; const useModuleIssueActions = () => { const { issues, issuesFilter } = useIssues(EIssuesStoreType.MODULE); - const { workspaceSlug, moduleId } = useAppRouter(); + const { workspaceSlug, projectId, moduleId } = useAppRouter(); const fetchIssues = useCallback( - async (projectId: string, loadType: TLoader) => { - if (!moduleId || !workspaceSlug) return; - return await issues.fetchIssues(workspaceSlug, projectId, loadType, moduleId); + async (loadType: TLoader, options: IssuePaginationOptions, moduleId?: string) => { + if (!workspaceSlug || !projectId || !moduleId) return; + return issues.fetchIssues(workspaceSlug.toString(), projectId.toString(), loadType, options, moduleId.toString()); }, - [issues.fetchIssues, moduleId, workspaceSlug] + [issues.fetchIssues, workspaceSlug, projectId] ); + const fetchNextIssues = useCallback( + async (groupId?: string, subGroupId?: string) => { + if (!workspaceSlug || !projectId || !moduleId) return; + return issues.fetchNextIssues( + workspaceSlug.toString(), + projectId.toString(), + moduleId.toString(), + groupId, + subGroupId + ); + }, + [issues.fetchIssues, workspaceSlug, projectId, moduleId] + ); + const createIssue = useCallback( - async (projectId: string, data: Partial) => { - if (!moduleId || !workspaceSlug) return; + async (projectId: string | undefined | null, data: Partial) => { + if (!moduleId || !workspaceSlug || !projectId) return; return await issues.createIssue(workspaceSlug, projectId, data, moduleId); }, [issues.createIssue, moduleId, workspaceSlug] ); - const updateIssue = useCallback( - async (projectId: string, issueId: string, data: Partial) => { - if (!moduleId || !workspaceSlug) return; - return await issues.updateIssue(workspaceSlug, projectId, issueId, data, moduleId); + const quickAddIssue = useCallback( + async (projectId: string | undefined | null, data: TIssue) => { + if (!moduleId || !workspaceSlug || !projectId) return; + return await issues.quickAddIssue(workspaceSlug, projectId, data, moduleId); }, - [issues.updateIssue, moduleId, workspaceSlug] + [issues.quickAddIssue, workspaceSlug, moduleId] + ); + const updateIssue = useCallback( + async (projectId: string | undefined | null, issueId: string, data: Partial) => { + if (!workspaceSlug || !projectId) return; + return await issues.updateIssue(workspaceSlug, projectId, issueId, data); + }, + [issues.updateIssue, workspaceSlug] ); const removeIssue = useCallback( - async (projectId: string, issueId: string) => { - if (!moduleId || !workspaceSlug) return; - return await issues.removeIssue(workspaceSlug, projectId, issueId, moduleId); + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; + return await issues.removeIssue(workspaceSlug, projectId, issueId); }, - [issues.removeIssue, moduleId, workspaceSlug] + [issues.removeIssue, workspaceSlug] ); const removeIssueFromView = useCallback( - async (projectId: string, issueId: string) => { - if (!moduleId || !workspaceSlug) return; + async (projectId: string | undefined | null, issueId: string) => { + if (!moduleId || !workspaceSlug || !projectId) return; return await issues.removeIssuesFromModule(workspaceSlug, projectId, moduleId, [issueId]); }, [issues.removeIssuesFromModule, moduleId, workspaceSlug] ); const archiveIssue = useCallback( - async (projectId: string, issueId: string) => { - if (!moduleId || !workspaceSlug) return; - return await issues.archiveIssue(workspaceSlug, projectId, issueId, moduleId); + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; + return await issues.archiveIssue(workspaceSlug, projectId, issueId); }, [issues.archiveIssue, moduleId, workspaceSlug] ); @@ -259,7 +339,9 @@ const useModuleIssueActions = () => { return useMemo( () => ({ fetchIssues, + fetchNextIssues, createIssue, + quickAddIssue, updateIssue, removeIssue, removeIssueFromView, @@ -276,39 +358,53 @@ const useProfileIssueActions = () => { const { workspaceSlug, userId } = useAppRouter(); const fetchIssues = useCallback( - async (projectId: string, loadType: TLoader) => { - if (!userId || !workspaceSlug) return; - return await issues.fetchIssues(workspaceSlug, projectId, loadType, userId); + async (loadType: TLoader, options: IssuePaginationOptions, viewId?: string) => { + if (!workspaceSlug || !userId || !viewId) return; + return issues.fetchIssues( + workspaceSlug.toString(), + userId.toString(), + loadType, + options, + viewId as TProfileViews + ); }, - [issues.fetchIssues, userId, workspaceSlug] + [issues.fetchIssues, workspaceSlug, userId] ); - const createIssue = useCallback( - async (projectId: string, data: Partial) => { - if (!userId || !workspaceSlug) return; - return await issues.createIssue(workspaceSlug, projectId, data, userId); + const fetchNextIssues = useCallback( + async (groupId?: string, subGroupId?: string) => { + if (!workspaceSlug || !userId) return; + return issues.fetchNextIssues(workspaceSlug.toString(), userId.toString(), groupId, subGroupId); }, - [issues.createIssue, userId, workspaceSlug] + [issues.fetchIssues, workspaceSlug, userId] + ); + + const createIssue = useCallback( + async (projectId: string | undefined | null, data: Partial) => { + if (!workspaceSlug || !projectId) return; + return await issues.createIssue(workspaceSlug, projectId, data); + }, + [issues.createIssue, workspaceSlug] ); const updateIssue = useCallback( - async (projectId: string, issueId: string, data: Partial) => { - if (!userId || !workspaceSlug) return; - return await issues.updateIssue(workspaceSlug, projectId, issueId, data, userId); + async (projectId: string | undefined | null, issueId: string, data: Partial) => { + if (!workspaceSlug || !projectId) return; + return await issues.updateIssue(workspaceSlug, projectId, issueId, data); }, - [issues.updateIssue, userId, workspaceSlug] + [issues.updateIssue, workspaceSlug] ); const removeIssue = useCallback( - async (projectId: string, issueId: string) => { - if (!userId || !workspaceSlug) return; - return await issues.removeIssue(workspaceSlug, projectId, issueId, userId); + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; + return await issues.removeIssue(workspaceSlug, projectId, issueId); }, - [issues.removeIssue, userId, workspaceSlug] + [issues.removeIssue, workspaceSlug] ); const archiveIssue = useCallback( - async (projectId: string, issueId: string) => { - if (!userId || !workspaceSlug) return; - return await issues.archiveIssue(workspaceSlug, projectId, issueId, userId); + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; + return await issues.archiveIssue(workspaceSlug, projectId, issueId); }, - [issues.archiveIssue, userId, workspaceSlug] + [issues.archiveIssue, workspaceSlug] ); const updateFilters = useCallback( @@ -326,6 +422,7 @@ const useProfileIssueActions = () => { return useMemo( () => ({ fetchIssues, + fetchNextIssues, createIssue, updateIssue, removeIssue, @@ -339,42 +436,57 @@ const useProfileIssueActions = () => { const useProjectViewIssueActions = () => { const { issues, issuesFilter } = useIssues(EIssuesStoreType.PROJECT_VIEW); - const { workspaceSlug, viewId } = useAppRouter(); + const { workspaceSlug, projectId, viewId } = useAppRouter(); const fetchIssues = useCallback( - async (projectId: string, loadType: TLoader) => { - if (!viewId || !workspaceSlug) return; - return await issues.fetchIssues(workspaceSlug, projectId, loadType, viewId); + async (loadType: TLoader, options: IssuePaginationOptions) => { + if (!workspaceSlug || !projectId) return; + return issues.fetchIssues(workspaceSlug.toString(), projectId.toString(), loadType, options); }, - [issues.fetchIssues, viewId, workspaceSlug] + [issues.fetchIssues, workspaceSlug, projectId] ); - const createIssue = useCallback( - async (projectId: string, data: Partial) => { - if (!viewId || !workspaceSlug) return; - return await issues.createIssue(workspaceSlug, projectId, data, viewId); + const fetchNextIssues = useCallback( + async (groupId?: string, subGroupId?: string) => { + if (!workspaceSlug || !projectId) return; + return issues.fetchNextIssues(workspaceSlug.toString(), projectId.toString(), groupId, subGroupId); }, - [issues.createIssue, viewId, workspaceSlug] + [issues.fetchIssues, workspaceSlug, projectId] + ); + + const createIssue = useCallback( + async (projectId: string | undefined | null, data: Partial) => { + if (!workspaceSlug || !projectId) return; + return await issues.createIssue(workspaceSlug, projectId, data); + }, + [issues.createIssue, workspaceSlug] + ); + const quickAddIssue = useCallback( + async (projectId: string | undefined | null, data: TIssue) => { + if (!workspaceSlug || !projectId) return; + return await issues.quickAddIssue(workspaceSlug, projectId, data); + }, + [issues.quickAddIssue, workspaceSlug] ); const updateIssue = useCallback( - async (projectId: string, issueId: string, data: Partial) => { - if (!viewId || !workspaceSlug) return; - return await issues.updateIssue(workspaceSlug, projectId, issueId, data, viewId); + async (projectId: string | undefined | null, issueId: string, data: Partial) => { + if (!workspaceSlug || !projectId) return; + return await issues.updateIssue(workspaceSlug, projectId, issueId, data); }, - [issues.updateIssue, viewId, workspaceSlug] + [issues.updateIssue, workspaceSlug] ); const removeIssue = useCallback( - async (projectId: string, issueId: string) => { - if (!viewId || !workspaceSlug) return; - return await issues.removeIssue(workspaceSlug, projectId, issueId, viewId); + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; + return await issues.removeIssue(workspaceSlug, projectId, issueId); }, - [issues.removeIssue, viewId, workspaceSlug] + [issues.removeIssue, workspaceSlug] ); const archiveIssue = useCallback( - async (projectId: string, issueId: string) => { - if (!viewId || !workspaceSlug) return; - return await issues.archiveIssue(workspaceSlug, projectId, issueId, viewId); + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; + return await issues.archiveIssue(workspaceSlug, projectId, issueId); }, - [issues.archiveIssue, viewId, workspaceSlug] + [issues.archiveIssue, workspaceSlug] ); const updateFilters = useCallback( @@ -392,45 +504,55 @@ const useProjectViewIssueActions = () => { return useMemo( () => ({ fetchIssues, + fetchNextIssues, createIssue, + quickAddIssue, updateIssue, removeIssue, archiveIssue, updateFilters, }), - [fetchIssues, createIssue, updateIssue, removeIssue, archiveIssue, updateFilters] + [fetchIssues, fetchNextIssues, createIssue, quickAddIssue, updateIssue, removeIssue, archiveIssue, updateFilters] ); }; const useDraftIssueActions = () => { const { issues, issuesFilter } = useIssues(EIssuesStoreType.DRAFT); - const { workspaceSlug } = useAppRouter(); + const { workspaceSlug, projectId } = useAppRouter(); const fetchIssues = useCallback( - async (projectId: string, loadType: TLoader) => { - if (!workspaceSlug) return; - return await issues.fetchIssues(workspaceSlug, projectId, loadType); + async (loadType: TLoader, options: IssuePaginationOptions) => { + if (!workspaceSlug || !projectId) return; + return issues.fetchIssues(workspaceSlug.toString(), projectId.toString(), loadType, options); }, - [issues.fetchIssues, workspaceSlug] + [issues.fetchIssues, workspaceSlug, projectId] ); + const fetchNextIssues = useCallback( + async (groupId?: string, subGroupId?: string) => { + if (!workspaceSlug || !projectId) return; + return issues.fetchNextIssues(workspaceSlug.toString(), projectId.toString(), groupId, subGroupId); + }, + [issues.fetchIssues, workspaceSlug, projectId] + ); + const createIssue = useCallback( - async (projectId: string, data: Partial) => { - if (!workspaceSlug) return; + async (projectId: string | undefined | null, data: Partial) => { + if (!workspaceSlug || !projectId) return; return await issues.createIssue(workspaceSlug, projectId, data); }, [issues.createIssue, workspaceSlug] ); const updateIssue = useCallback( - async (projectId: string, issueId: string, data: Partial) => { - if (!workspaceSlug) return; + async (projectId: string | undefined | null, issueId: string, data: Partial) => { + if (!workspaceSlug || !projectId) return; return await issues.updateIssue(workspaceSlug, projectId, issueId, data); }, [issues.updateIssue, workspaceSlug] ); const removeIssue = useCallback( - async (projectId: string, issueId: string) => { - if (!workspaceSlug) return; + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; return await issues.removeIssue(workspaceSlug, projectId, issueId); }, [issues.removeIssue, workspaceSlug] @@ -451,6 +573,7 @@ const useDraftIssueActions = () => { return useMemo( () => ({ fetchIssues, + fetchNextIssues, createIssue, updateIssue, removeIssue, @@ -463,25 +586,33 @@ const useDraftIssueActions = () => { const useArchivedIssueActions = () => { const { issues, issuesFilter } = useIssues(EIssuesStoreType.ARCHIVED); - const { workspaceSlug } = useAppRouter(); + const { workspaceSlug, projectId } = useAppRouter(); const fetchIssues = useCallback( - async (projectId: string, loadType: TLoader) => { - if (!workspaceSlug) return; - return await issues.fetchIssues(workspaceSlug, projectId, loadType); + async (loadType: TLoader, options: IssuePaginationOptions) => { + if (!workspaceSlug || !projectId) return; + return issues.fetchIssues(workspaceSlug.toString(), projectId.toString(), loadType, options); }, - [issues.fetchIssues] + [issues.fetchIssues, workspaceSlug, projectId] ); + const fetchNextIssues = useCallback( + async (groupId?: string, subGroupId?: string) => { + if (!workspaceSlug || !projectId) return; + return issues.fetchNextIssues(workspaceSlug.toString(), projectId.toString(), groupId, subGroupId); + }, + [issues.fetchIssues, workspaceSlug, projectId] + ); + const removeIssue = useCallback( - async (projectId: string, issueId: string) => { - if (!workspaceSlug) return; + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; return await issues.removeIssue(workspaceSlug, projectId, issueId); }, [issues.removeIssue] ); const restoreIssue = useCallback( - async (projectId: string, issueId: string) => { - if (!workspaceSlug) return; + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; return await issues.restoreIssue(workspaceSlug, projectId, issueId); }, [issues.restoreIssue] @@ -502,11 +633,12 @@ const useArchivedIssueActions = () => { return useMemo( () => ({ fetchIssues, + fetchNextIssues, removeIssue, restoreIssue, updateFilters, }), - [fetchIssues, removeIssue, restoreIssue, updateFilters] + [fetchIssues, fetchNextIssues, removeIssue, restoreIssue, updateFilters] ); }; @@ -514,27 +646,41 @@ const useGlobalIssueActions = () => { const { issues, issuesFilter } = useIssues(EIssuesStoreType.GLOBAL); const { workspaceSlug, globalViewId } = useAppRouter(); + const fetchIssues = useCallback( + async (loadType: TLoader, options: IssuePaginationOptions) => { + if (!workspaceSlug || !globalViewId) return; + return issues.fetchIssues(workspaceSlug.toString(), globalViewId.toString(), loadType, options); + }, + [issues.fetchIssues, workspaceSlug, globalViewId] + ); + const fetchNextIssues = useCallback( + async (groupId?: string, subGroupId?: string) => { + if (!workspaceSlug || !globalViewId) return; + return issues.fetchNextIssues(workspaceSlug.toString(), globalViewId.toString(), groupId, subGroupId); + }, + [issues.fetchIssues, workspaceSlug, globalViewId] + ); const createIssue = useCallback( - async (projectId: string, data: Partial) => { - if (!globalViewId || !workspaceSlug) return; - return await issues.createIssue(workspaceSlug, projectId, data, globalViewId); + async (projectId: string | undefined | null, data: Partial) => { + if (!workspaceSlug || !projectId) return; + return await issues.createIssue(workspaceSlug, projectId, data); }, - [issues.createIssue, globalViewId, workspaceSlug] + [issues.createIssue, workspaceSlug] ); const updateIssue = useCallback( - async (projectId: string, issueId: string, data: Partial) => { - if (!globalViewId || !workspaceSlug) return; - return await issues.updateIssue(workspaceSlug, projectId, issueId, data, globalViewId); + async (projectId: string | undefined | null, issueId: string, data: Partial) => { + if (!workspaceSlug || !projectId) return; + return await issues.updateIssue(workspaceSlug, projectId, issueId, data); }, - [issues.updateIssue, globalViewId, workspaceSlug] + [issues.updateIssue, workspaceSlug] ); const removeIssue = useCallback( - async (projectId: string, issueId: string) => { - if (!globalViewId || !workspaceSlug) return; - return await issues.removeIssue(workspaceSlug, projectId, issueId, globalViewId); + async (projectId: string | undefined | null, issueId: string) => { + if (!workspaceSlug || !projectId) return; + return await issues.removeIssue(workspaceSlug, projectId, issueId); }, - [issues.removeIssue, globalViewId, workspaceSlug] + [issues.removeIssue, workspaceSlug] ); const updateFilters = useCallback( @@ -551,6 +697,8 @@ const useGlobalIssueActions = () => { return useMemo( () => ({ + fetchIssues, + fetchNextIssues, createIssue, updateIssue, removeIssue, diff --git a/web/hooks/use-multiple-select.ts b/web/hooks/use-multiple-select.ts index 4bfdbc041..f6bb3df9a 100644 --- a/web/hooks/use-multiple-select.ts +++ b/web/hooks/use-multiple-select.ts @@ -58,8 +58,8 @@ export const useMultipleSelect = (props: Props) => { const entitiesList: TEntityDetails[] = useMemo( () => groups - .map((groupID) => - entities[groupID].map((entityID) => ({ + ?.map((groupID) => + entities?.[groupID]?.map((entityID) => ({ entityID, groupID, })) diff --git a/web/services/cycle.service.ts b/web/services/cycle.service.ts index 5a256614f..6681926fd 100644 --- a/web/services/cycle.service.ts +++ b/web/services/cycle.service.ts @@ -2,7 +2,7 @@ import { API_BASE_URL } from "@/helpers/common.helper"; import { APIService } from "@/services/api.service"; // types -import type { CycleDateCheckData, ICycle, TIssue } from "@plane/types"; +import type { CycleDateCheckData, ICycle, TIssue, TIssuesResponse } from "@plane/types"; // helpers export class CycleService extends APIService { @@ -46,20 +46,12 @@ export class CycleService extends APIService { }); } - async getCycleIssues(workspaceSlug: string, projectId: string, cycleId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-issues/`) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - - async getCycleIssuesWithParams( + async getCycleIssues( workspaceSlug: string, projectId: string, cycleId: string, queries?: any - ): Promise { + ): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/cycles/${cycleId}/cycle-issues/`, { params: queries, }) diff --git a/web/services/issue/issue.service.ts b/web/services/issue/issue.service.ts index 1f6d95fd6..c9628840c 100644 --- a/web/services/issue/issue.service.ts +++ b/web/services/issue/issue.service.ts @@ -1,5 +1,5 @@ // types -import type { TIssue, IIssueDisplayProperties, TIssueLink, TIssueSubIssues, TIssueActivity } from "@plane/types"; +import type { TIssue, IIssueDisplayProperties, TIssueLink, TIssueSubIssues, TIssueActivity, TIssuesResponse, TBulkOperationsPayload } from "@plane/types"; // helpers import { API_BASE_URL } from "@/helpers/common.helper"; // services @@ -10,7 +10,7 @@ export class IssueService extends APIService { super(API_BASE_URL); } - async createIssue(workspaceSlug: string, projectId: string, data: any): Promise { + async createIssue(workspaceSlug: string, projectId: string, data: Partial): Promise { return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/`, data) .then((response) => response?.data) .catch((error) => { @@ -18,7 +18,7 @@ export class IssueService extends APIService { }); } - async getIssues(workspaceSlug: string, projectId: string, queries?: any): Promise { + async getIssues(workspaceSlug: string, projectId: string, queries?: any): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/`, { params: queries, }) @@ -162,14 +162,6 @@ export class IssueService extends APIService { }); } - async bulkDeleteIssues(workspaceSlug: string, projectId: string, data: any): Promise { - return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-delete-issues/`, data) - .then((response) => response?.data) - .catch((error) => { - throw error?.response?.data; - }); - } - async subIssues(workspaceSlug: string, projectId: string, issueId: string): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issues/${issueId}/sub-issues/`) .then((response) => response?.data) @@ -238,4 +230,42 @@ export class IssueService extends APIService { throw error?.response?.data; }); } + + async bulkOperations(workspaceSlug: string, projectId: string, data: TBulkOperationsPayload): Promise { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-operation-issues/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async bulkDeleteIssues( + workspaceSlug: string, + projectId: string, + data: { + issue_ids: string[]; + } + ): Promise { + return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-delete-issues/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } + + async bulkArchiveIssues( + workspaceSlug: string, + projectId: string, + data: { + issue_ids: string[]; + } + ): Promise<{ + archived_at: string; + }> { + return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/bulk-archive-issues/`, data) + .then((response) => response?.data) + .catch((error) => { + throw error?.response?.data; + }); + } } diff --git a/web/services/issue/issue_draft.service.ts b/web/services/issue/issue_draft.service.ts index b34ccc847..63cb2dda5 100644 --- a/web/services/issue/issue_draft.service.ts +++ b/web/services/issue/issue_draft.service.ts @@ -1,14 +1,14 @@ import { API_BASE_URL } from "@/helpers/common.helper"; import { APIService } from "@/services/api.service"; // helpers -import { TIssue } from "@plane/types"; +import { TIssue, TIssuesResponse } from "@plane/types"; export class IssueDraftService extends APIService { constructor() { super(API_BASE_URL); } - async getDraftIssues(workspaceSlug: string, projectId: string, query?: any): Promise { + async getDraftIssues(workspaceSlug: string, projectId: string, query?: any): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/`, { params: { ...query }, }) @@ -18,7 +18,7 @@ export class IssueDraftService extends APIService { }); } - async createDraftIssue(workspaceSlug: string, projectId: string, data: any): Promise { + async createDraftIssue(workspaceSlug: string, projectId: string, data: any): Promise { return this.post(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/`, data) .then((response) => response?.data) .catch((error) => { @@ -26,7 +26,7 @@ export class IssueDraftService extends APIService { }); } - async updateDraftIssue(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise { + async updateDraftIssue(workspaceSlug: string, projectId: string, issueId: string, data: any): Promise { return this.patch(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/${issueId}/`, data) .then((response) => response?.data) .catch((error) => { @@ -34,7 +34,7 @@ export class IssueDraftService extends APIService { }); } - async deleteDraftIssue(workspaceSlug: string, projectId: string, issueId: string): Promise { + async deleteDraftIssue(workspaceSlug: string, projectId: string, issueId: string): Promise { return this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/${issueId}/`) .then((response) => response?.data) .catch((error) => { @@ -42,7 +42,7 @@ export class IssueDraftService extends APIService { }); } - async getDraftIssueById(workspaceSlug: string, projectId: string, issueId: string, queries?: any): Promise { + async getDraftIssueById(workspaceSlug: string, projectId: string, issueId: string, queries?: any): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/${issueId}/`, { params: queries, }) diff --git a/web/services/module.service.ts b/web/services/module.service.ts index eb5758873..d78645379 100644 --- a/web/services/module.service.ts +++ b/web/services/module.service.ts @@ -1,5 +1,5 @@ // types -import type { IModule, TIssue, ILinkDetails, ModuleLink } from "@plane/types"; +import type { IModule, ILinkDetails, ModuleLink, TIssuesResponse } from "@plane/types"; // services import { API_BASE_URL } from "@/helpers/common.helper"; import { APIService } from "@/services/api.service"; @@ -70,7 +70,12 @@ export class ModuleService extends APIService { }); } - async getModuleIssues(workspaceSlug: string, projectId: string, moduleId: string, queries?: any): Promise { + async getModuleIssues( + workspaceSlug: string, + projectId: string, + moduleId: string, + queries?: any + ): Promise { return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/`, { params: queries, }) @@ -111,14 +116,14 @@ export class ModuleService extends APIService { projectId: string, moduleId: string, issueIds: string[] - ): Promise { + ): Promise { const promiseDataUrls: any = []; issueIds.forEach((issueId) => { promiseDataUrls.push( this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`) ); }); - return await Promise.all(promiseDataUrls) + await Promise.all(promiseDataUrls) .then((response) => response) .catch((error) => { throw error?.response?.data; @@ -130,14 +135,14 @@ export class ModuleService extends APIService { projectId: string, issueId: string, moduleIds: string[] - ): Promise { + ): Promise { const promiseDataUrls: any = []; moduleIds.forEach((moduleId) => { promiseDataUrls.push( this.delete(`/api/workspaces/${workspaceSlug}/projects/${projectId}/modules/${moduleId}/issues/${issueId}/`) ); }); - return await Promise.all(promiseDataUrls) + await Promise.all(promiseDataUrls) .then((response) => response) .catch((error) => { throw error?.response?.data; diff --git a/web/services/user.service.ts b/web/services/user.service.ts index fa8a06542..adbae133c 100644 --- a/web/services/user.service.ts +++ b/web/services/user.service.ts @@ -8,6 +8,7 @@ import type { IUserProfileProjectSegregation, IUserSettings, IUserEmailNotificationSettings, + TIssuesResponse, TUserProfile, } from "@plane/types"; import { API_BASE_URL } from "@/helpers/common.helper"; @@ -205,7 +206,7 @@ export class UserService extends APIService { }); } - async getUserProfileIssues(workspaceSlug: string, userId: string, params: any): Promise { + async getUserProfileIssues(workspaceSlug: string, userId: string, params: any): Promise { return this.get(`/api/workspaces/${workspaceSlug}/user-issues/${userId}/`, { params, }) diff --git a/web/services/workspace.service.ts b/web/services/workspace.service.ts index dd22df41e..4e8ee4bdf 100644 --- a/web/services/workspace.service.ts +++ b/web/services/workspace.service.ts @@ -14,8 +14,8 @@ import { IWorkspaceBulkInviteFormData, IWorkspaceViewProps, IUserProjectsRole, - TIssue, IWorkspaceView, + TIssuesResponse, } from "@plane/types"; export class WorkspaceService extends APIService { @@ -257,7 +257,7 @@ export class WorkspaceService extends APIService { }); } - async getViewIssues(workspaceSlug: string, params: any): Promise { + async getViewIssues(workspaceSlug: string, params: any): Promise { return this.get(`/api/workspaces/${workspaceSlug}/issues/`, { params, }) diff --git a/web/store/global-view.store.ts b/web/store/global-view.store.ts index 742d7af32..d5a7f6204 100644 --- a/web/store/global-view.store.ts +++ b/web/store/global-view.store.ts @@ -182,7 +182,7 @@ export class GlobalViewStore implements IGlobalViewStore { viewId ); } - this.rootStore.issue.workspaceIssues.fetchIssues(workspaceSlug, viewId, "mutation"); + this.rootStore.issue.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation"); } return currentView; } catch { diff --git a/web/store/issue/archived/filter.store.ts b/web/store/issue/archived/filter.store.ts index dc14b01d7..16a790d05 100644 --- a/web/store/issue/archived/filter.store.ts +++ b/web/store/issue/archived/filter.store.ts @@ -14,20 +14,24 @@ import { TIssueKanbanFilters, IIssueFilters, TIssueParams, + IssuePaginationOptions, } from "@plane/types"; -import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; +import { IBaseIssueFilterStore, IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers // types import { IIssueRootStore } from "../root.store"; +import { computedFn } from "mobx-utils"; // constants // services -export interface IArchivedIssuesFilter { - // observables - filters: Record; // Record defines projectId as key and IIssueFilters as value - // computed - issueFilters: IIssueFilters | undefined; - appliedFilters: Partial> | undefined; +export interface IArchivedIssuesFilter extends IBaseIssueFilterStore { + //helper actions + getFilterParams: ( + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => Partial>; // action fetchFilters: (workspaceSlug: string, projectId: string) => Promise; updateFilters: ( @@ -92,6 +96,20 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc return filteredRouteParams; } + getFilterParams = computedFn( + ( + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => { + const filterParams = this.appliedFilters; + + const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId); + return paginationParams; + } + ); + fetchFilters = async (workspaceSlug: string, projectId: string) => { try { const _filters = this.handleIssuesLocalFilters.get( @@ -150,7 +168,7 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc }); const appliedFilters = _filters.filters || {}; const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); - this.rootIssueStore.archivedIssues.fetchIssues( + this.rootIssueStore.archivedIssues.fetchIssuesWithExistingPagination( workspaceSlug, projectId, isEmpty(filteredFilters) ? "init-loader" : "mutation" @@ -192,8 +210,7 @@ export class ArchivedIssuesFilter extends IssueFilterHelperStore implements IArc }); }); - if (this.requiresServerUpdate(updatedDisplayFilters)) - this.rootIssueStore.archivedIssues.fetchIssues(workspaceSlug, projectId, "mutation"); + this.rootIssueStore.archivedIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation"); this.handleIssuesLocalFilters.set(EIssuesStoreType.ARCHIVED, type, workspaceSlug, projectId, undefined, { display_filters: _filters.displayFilters, diff --git a/web/store/issue/archived/issue.store.ts b/web/store/issue/archived/issue.store.ts index e17c3d001..9c23f547c 100644 --- a/web/store/issue/archived/issue.store.ts +++ b/web/store/issue/archived/issue.store.ts @@ -1,36 +1,45 @@ -import pull from "lodash/pull"; -import set from "lodash/set"; -import { action, observable, makeObservable, computed, runInAction } from "mobx"; +import { action, makeObservable, runInAction } from "mobx"; // base class -import { IssueArchiveService } from "@/services/issue"; -import { TIssue, TLoader, TGroupedIssues, TSubGroupedIssues, TUnGroupedIssues, ViewFlags } from "@plane/types"; -import { IssueHelperStore } from "../helpers/issue-helper.store"; +import { TLoader, IssuePaginationOptions, TIssuesResponse, ViewFlags, TBulkOperationsPayload } from "@plane/types"; // services // types import { IIssueRootStore } from "../root.store"; +import { IArchivedIssuesFilter } from "./filter.store"; +import { BaseIssuesStore, IBaseIssuesStore } from "../helpers/base-issues.store"; -export interface IArchivedIssues { +export interface IArchivedIssues extends IBaseIssuesStore { // observable - loader: TLoader; - issues: { [project_id: string]: string[] }; viewFlags: ViewFlags; - // computed - groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; // actions - getIssueIds: (groupId?: string) => string[] | undefined; - fetchIssues: (workspaceSlug: string, projectId: string, loadType: TLoader) => Promise; - removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + fetchIssues: ( + workspaceSlug: string, + projectId: string, + loadType: TLoader, + option: IssuePaginationOptions + ) => Promise; + fetchIssuesWithExistingPagination: ( + workspaceSlug: string, + projectId: string, + loadType: TLoader + ) => Promise; + fetchNextIssues: ( + workspaceSlug: string, + projectId: string, + groupId?: string, + subGroupId?: string + ) => Promise; + restoreIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise; + + archiveBulkIssues: undefined; quickAddIssue: undefined; } -export class ArchivedIssues extends IssueHelperStore implements IArchivedIssues { - loader: TLoader = "init-loader"; - issues: { [project_id: string]: string[] } = {}; - // root store - rootIssueStore: IIssueRootStore; - // services - archivedIssueService; +export class ArchivedIssues extends BaseIssuesStore implements IArchivedIssues { + // filter store + issueFilterStore: IArchivedIssuesFilter; //viewData viewFlags = { @@ -39,119 +48,138 @@ export class ArchivedIssues extends IssueHelperStore implements IArchivedIssues enableInlineEditing: true, }; - constructor(_rootStore: IIssueRootStore) { - super(_rootStore); + constructor(_rootStore: IIssueRootStore, issueFilterStore: IArchivedIssuesFilter) { + super(_rootStore, issueFilterStore, true); makeObservable(this, { - // observable - loader: observable.ref, - issues: observable, - // computed - groupedIssueIds: computed, // action fetchIssues: action, - removeIssue: action, + fetchNextIssues: action, + fetchIssuesWithExistingPagination: action, + restoreIssue: action, }); - // root store - this.rootIssueStore = _rootStore; - // services - this.archivedIssueService = new IssueArchiveService(); + // filter store + this.issueFilterStore = issueFilterStore; } - get groupedIssueIds() { - const projectId = this.rootIssueStore.projectId; - if (!projectId) return undefined; - - const displayFilters = this.rootIssueStore?.archivedIssuesFilter?.issueFilters?.displayFilters; - if (!displayFilters) return undefined; - - const groupBy = displayFilters?.group_by; - const orderBy = displayFilters?.order_by; - const layout = displayFilters?.layout; - - const archivedIssueIds = this.issues[projectId]; - if (!archivedIssueIds) return undefined; - - const _issues = this.rootIssueStore.issues.getIssuesByIds(archivedIssueIds, "archived"); - if (!_issues) return []; - - let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined = undefined; - - if (layout === "list" && orderBy) { - if (groupBy) issues = this.groupedIssues(groupBy, orderBy, _issues); - else issues = this.unGroupedIssues(orderBy, _issues); - } - - return issues; - } - - getIssueIds = (groupId?: string) => { - const groupedIssueIds = this.groupedIssueIds; - - const displayFilters = this.rootStore?.projectIssuesFilter?.issueFilters?.displayFilters; - if (!displayFilters || !groupedIssueIds) return undefined; - - const subGroupBy = displayFilters?.sub_group_by; - const groupBy = displayFilters?.group_by; - - if (!groupBy && !subGroupBy) { - return groupedIssueIds as string[]; - } - - if (groupBy && groupId) { - return (groupedIssueIds as TGroupedIssues)?.[groupId] as string[]; - } - - return undefined; + /** + * Fetches the project details + * @param workspaceSlug + * @param projectId + */ + fetchParentStats = async (workspaceSlug: string, projectId?: string) => { + projectId && this.rootIssueStore.rootStore.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId); }; - fetchIssues = async (workspaceSlug: string, projectId: string, loadType: TLoader = "init-loader") => { + /** + * This method is called to fetch the first issues of pagination + * @param workspaceSlug + * @param projectId + * @param loadType + * @param options + * @returns + */ + fetchIssues = async ( + workspaceSlug: string, + projectId: string, + loadType: TLoader = "init-loader", + options: IssuePaginationOptions + ) => { try { - this.loader = loadType; - - const params = this.rootIssueStore?.archivedIssuesFilter?.appliedFilters; - const response = await this.archivedIssueService.getArchivedIssues(workspaceSlug, projectId, params); - + // set loader and clear store runInAction(() => { - set( - this.issues, - [projectId], - response.map((issue: TIssue) => issue.id) - ); - this.loader = undefined; + this.setLoader(loadType); }); + this.clear(); - this.rootIssueStore.issues.addIssue(response); + // get params from pagination options + const params = this.issueFilterStore?.getFilterParams(options, undefined, undefined, undefined); + // call the fetch issues API with the params + const response = await this.issueArchiveService.getArchivedIssues(workspaceSlug, projectId, params); + // after fetching issues, call the base method to process the response further + this.onfetchIssues(response, options, workspaceSlug, projectId); return response; } catch (error) { - console.error(error); - this.loader = undefined; + // set loader to undefined if errored out + this.setLoader(undefined); throw error; } }; - removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { + /** + * This method is called subsequent pages of pagination + * if groupId/subgroupId is provided, only that specific group's next page is fetched + * else all the groups' next page is fetched + * @param workspaceSlug + * @param projectId + * @param groupId + * @param subGroupId + * @returns + */ + fetchNextIssues = async (workspaceSlug: string, projectId: string, groupId?: string, subGroupId?: string) => { + const cursorObject = this.getPaginationData(groupId, subGroupId); + // if there are no pagination options and the next page results do not exist the return + if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return; try { - await this.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); + // set Loader + this.setLoader("pagination", groupId, subGroupId); - runInAction(() => { - pull(this.issues[projectId], issueId); - }); + // get params from stored pagination options + const params = this.issueFilterStore?.getFilterParams( + this.paginationOptions, + cursorObject?.nextCursor, + groupId, + subGroupId + ); + // call the fetch issues API with the params for next page in issues + const response = await this.issueArchiveService.getArchivedIssues(workspaceSlug, projectId, params); + + // after the next page of issues are fetched, call the base method to process the response + this.onfetchNexIssues(response, groupId, subGroupId); + return response; } catch (error) { + // set Loader as undefined if errored out + this.setLoader(undefined, groupId, subGroupId); throw error; } }; + /** + * This Method exists to fetch the first page of the issues with the existing stored pagination + * This is useful for refetching when filters, groupBy, orderBy etc changes + * @param workspaceSlug + * @param projectId + * @param loadType + * @returns + */ + fetchIssuesWithExistingPagination = async ( + workspaceSlug: string, + projectId: string, + loadType: TLoader = "mutation" + ) => { + if (!this.paginationOptions) return; + return await this.fetchIssues(workspaceSlug, projectId, loadType, this.paginationOptions); + }; + + /** + * Restored the current issue from the archived issue + * @param workspaceSlug + * @param projectId + * @param issueId + * @returns + */ restoreIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { try { - const response = await this.archivedIssueService.restoreIssue(workspaceSlug, projectId, issueId); + // call API to restore the issue + const response = await this.issueArchiveService.restoreIssue(workspaceSlug, projectId, issueId); + // update the store and remove from the archived issues list once restored runInAction(() => { - this.rootStore.issues.updateIssue(issueId, { + this.rootIssueStore.issues.updateIssue(issueId, { archived_at: null, }); - pull(this.issues[projectId], issueId); + this.removeIssueFromList(issueId); }); return response; @@ -160,5 +188,6 @@ export class ArchivedIssues extends IssueHelperStore implements IArchivedIssues } }; - quickAddIssue: undefined; + archiveBulkIssues = undefined; + quickAddIssue = undefined; } diff --git a/web/store/issue/cycle/filter.store.ts b/web/store/issue/cycle/filter.store.ts index 65bfe28d9..8557aa36d 100644 --- a/web/store/issue/cycle/filter.store.ts +++ b/web/store/issue/cycle/filter.store.ts @@ -14,20 +14,24 @@ import { TIssueKanbanFilters, IIssueFilters, TIssueParams, + IssuePaginationOptions, } from "@plane/types"; -import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; +import { IBaseIssueFilterStore, IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers // types import { IIssueRootStore } from "../root.store"; +import { computedFn } from "mobx-utils"; // constants // services -export interface ICycleIssuesFilter { - // observables - filters: Record; // Record defines cycleId as key and IIssueFilters as value - // computed - issueFilters: IIssueFilters | undefined; - appliedFilters: Partial> | undefined; +export interface ICycleIssuesFilter extends IBaseIssueFilterStore { + //helper actions + getFilterParams: ( + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => Partial>; // action fetchFilters: (workspaceSlug: string, projectId: string, cycleId: string) => Promise; updateFilters: ( @@ -95,6 +99,20 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI return filteredRouteParams; } + getFilterParams = computedFn( + ( + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => { + const filterParams = this.appliedFilters; + + const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId); + return paginationParams; + } + ); + fetchFilters = async (workspaceSlug: string, projectId: string, cycleId: string) => { try { const _filters = await this.issueFilterService.fetchCycleIssueFilters(workspaceSlug, projectId, cycleId); @@ -161,7 +179,7 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI const appliedFilters = _filters.filters || {}; const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); - this.rootIssueStore.cycleIssues.fetchIssues( + this.rootIssueStore.cycleIssues.fetchIssuesWithExistingPagination( workspaceSlug, projectId, isEmpty(filteredFilters) ? "init-loader" : "mutation", @@ -204,8 +222,12 @@ export class CycleIssuesFilter extends IssueFilterHelperStore implements ICycleI }); }); - if (this.requiresServerUpdate(updatedDisplayFilters)) - this.rootIssueStore.cycleIssues.fetchIssues(workspaceSlug, projectId, "mutation", cycleId); + this.rootIssueStore.cycleIssues.fetchIssuesWithExistingPagination( + workspaceSlug, + projectId, + "mutation", + cycleId + ); await this.issueFilterService.patchCycleIssueFilters(workspaceSlug, projectId, cycleId, { display_filters: _filters.displayFilters, diff --git a/web/store/issue/cycle/issue.store.ts b/web/store/issue/cycle/issue.store.ts index 39ebc8059..156ef311e 100644 --- a/web/store/issue/cycle/issue.store.ts +++ b/web/store/issue/cycle/issue.store.ts @@ -1,69 +1,82 @@ +import { action, observable, makeObservable, runInAction } from "mobx"; +// base class +// types import concat from "lodash/concat"; -import pull from "lodash/pull"; +import get from "lodash/get"; import set from "lodash/set"; import uniq from "lodash/uniq"; import update from "lodash/update"; -import { action, observable, makeObservable, computed, runInAction } from "mobx"; // types -import { TIssue, TSubGroupedIssues, TGroupedIssues, TLoader, TUnGroupedIssues, ViewFlags } from "@plane/types"; -// helpers -import { issueCountBasedOnFilters } from "@/helpers/issue.helper"; -// services -import { CycleService } from "@/services/cycle.service"; -import { IssueService } from "@/services/issue"; -// types -import { IssueHelperStore } from "../helpers/issue-helper.store"; +import { TIssue, TLoader, IssuePaginationOptions, TIssuesResponse, ViewFlags, TBulkOperationsPayload } from "@plane/types"; import { IIssueRootStore } from "../root.store"; +import { BaseIssuesStore, IBaseIssuesStore } from "../helpers/base-issues.store"; +import { ICycleIssuesFilter } from "./filter.store"; +import { computedFn } from "mobx-utils"; +import { ALL_ISSUES } from "@/constants/issue"; export const ACTIVE_CYCLE_ISSUES = "ACTIVE_CYCLE_ISSUES"; -export interface ICycleIssues { - // observable - loader: TLoader; - issues: { [cycle_id: string]: string[] }; +export interface ActiveCycleIssueDetails { + issueIds: string[]; + issueCount: number; + nextCursor: string; + nextPageResults: boolean; + perPageCount: number; +} + +export interface ICycleIssues extends IBaseIssuesStore { viewFlags: ViewFlags; - // computed - issuesCount: number; - groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; + activeCycleIds: Record; + //action helpers + getActiveCycleById: (cycleId: string) => ActiveCycleIssueDetails | undefined; // actions getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; fetchIssues: ( workspaceSlug: string, projectId: string, loadType: TLoader, + options: IssuePaginationOptions, cycleId: string - ) => Promise; - createIssue: ( + ) => Promise; + fetchIssuesWithExistingPagination: ( workspaceSlug: string, projectId: string, - data: Partial, + loadType: TLoader, cycleId: string - ) => Promise; - updateIssue: ( + ) => Promise; + fetchNextIssues: ( workspaceSlug: string, projectId: string, - issueId: string, - data: Partial, + cycleId: string, + groupId?: string, + subGroupId?: string + ) => Promise; + + fetchActiveCycleIssues: ( + workspaceSlug: string, + projectId: string, + perPageCount: number, cycleId: string - ) => Promise; - removeIssue: (workspaceSlug: string, projectId: string, issueId: string, cycleId: string) => Promise; - archiveIssue: (workspaceSlug: string, projectId: string, issueId: string, cycleId: string) => Promise; + ) => Promise; + fetchNextActiveCycleIssues: ( + workspaceSlug: string, + projectId: string, + cycleId: string + ) => Promise; + + createIssue: (workspaceSlug: string, projectId: string, data: Partial, cycleId: string) => Promise; + updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; + archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; quickAddIssue: ( workspaceSlug: string, projectId: string, data: TIssue, - cycleId?: string | undefined - ) => Promise; - addIssueToCycle: ( - workspaceSlug: string, - projectId: string, - cycleId: string, - issueIds: string[], - fetchAddedIssues?: boolean - ) => Promise; - removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; - addCycleToIssue: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; - removeCycleFromIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + cycleId: string + ) => Promise; + removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise; + transferIssuesFromCycle: ( workspaceSlug: string, projectId: string, @@ -72,375 +85,179 @@ export interface ICycleIssues { new_cycle_id: string; } ) => Promise; - fetchActiveCycleIssues: (workspaceSlug: string, projectId: string, cycleId: string) => Promise; } -export class CycleIssues extends IssueHelperStore implements ICycleIssues { - loader: TLoader = "init-loader"; - issues: { [cycle_id: string]: string[] } = {}; +export class CycleIssues extends BaseIssuesStore implements ICycleIssues { + activeCycleIds: Record = {}; viewFlags = { enableQuickAdd: true, enableIssueCreation: true, enableInlineEditing: true, }; - // root store - rootIssueStore: IIssueRootStore; - // service - cycleService; - issueService; + // filter store + issueFilterStore; - constructor(_rootStore: IIssueRootStore) { - super(_rootStore); + constructor(_rootStore: IIssueRootStore, issueFilterStore: ICycleIssuesFilter) { + super(_rootStore, issueFilterStore); makeObservable(this, { // observable - loader: observable.ref, - issues: observable, - // computed - issuesCount: computed, - groupedIssueIds: computed, + activeCycleIds: observable, // action fetchIssues: action, - createIssue: action, - updateIssue: action, - removeIssue: action, - archiveIssue: action, - quickAddIssue: action, - addIssueToCycle: action, - removeIssueFromCycle: action, - addCycleToIssue: action, + fetchNextIssues: action, + fetchIssuesWithExistingPagination: action, + transferIssuesFromCycle: action, fetchActiveCycleIssues: action, + + quickAddIssue: action, }); - - this.rootIssueStore = _rootStore; - this.issueService = new IssueService(); - this.cycleService = new CycleService(); + // filter store + this.issueFilterStore = issueFilterStore; } - get issuesCount() { - let issuesCount = 0; + getActiveCycleById = computedFn((cycleId: string) => this.activeCycleIds[cycleId]); - const displayFilters = this.rootStore?.cycleIssuesFilter?.issueFilters?.displayFilters; - const groupedIssueIds = this.groupedIssueIds; - if (!displayFilters || !groupedIssueIds) return issuesCount; - - const layout = displayFilters?.layout || undefined; - const groupBy = displayFilters?.group_by || undefined; - const subGroupBy = displayFilters?.sub_group_by || undefined; - - if (!layout) return issuesCount; - issuesCount = issueCountBasedOnFilters(groupedIssueIds, layout, groupBy, subGroupBy); - return issuesCount; - } - - get groupedIssueIds() { - const cycleId = this.rootIssueStore?.cycleId; - if (!cycleId) return undefined; - - const displayFilters = this.rootIssueStore?.cycleIssuesFilter?.issueFilters?.displayFilters; - if (!displayFilters) return undefined; - - const subGroupBy = displayFilters?.sub_group_by; - const groupBy = displayFilters?.group_by; - const orderBy = displayFilters?.order_by; - const layout = displayFilters?.layout; - - const cycleIssueIds = this.issues[cycleId]; - if (!cycleIssueIds) return; - - const currentIssues = this.rootIssueStore.issues.getIssuesByIds(cycleIssueIds, "un-archived"); - if (!currentIssues) return []; - - let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues = []; - - if (layout === "list" && orderBy) { - if (groupBy) issues = this.groupedIssues(groupBy, orderBy, currentIssues); - else issues = this.unGroupedIssues(orderBy, currentIssues); - } else if (layout === "kanban" && groupBy && orderBy) { - if (subGroupBy) issues = this.subGroupedIssues(subGroupBy, groupBy, orderBy, currentIssues); - else issues = this.groupedIssues(groupBy, orderBy, currentIssues); - } else if (layout === "calendar") issues = this.groupedIssues("target_date", "target_date", currentIssues, true); - else if (layout === "spreadsheet") issues = this.unGroupedIssues(orderBy ?? "-created_at", currentIssues); - else if (layout === "gantt_chart") issues = this.unGroupedIssues(orderBy ?? "sort_order", currentIssues); - - return issues; - } - - getIssueIds = (groupId?: string, subGroupId?: string) => { - const groupedIssueIds = this.groupedIssueIds; - - const displayFilters = this.rootIssueStore?.cycleIssuesFilter?.issueFilters?.displayFilters; - if (!displayFilters || !groupedIssueIds) return undefined; - - const subGroupBy = displayFilters?.sub_group_by; - const groupBy = displayFilters?.group_by; - - if (!groupBy && !subGroupBy) { - return groupedIssueIds as string[]; - } - - if (groupBy && subGroupBy && groupId && subGroupId) { - return (groupedIssueIds as TSubGroupedIssues)?.[subGroupId]?.[groupId] as string[]; - } - - if (groupBy && groupId) { - return (groupedIssueIds as TGroupedIssues)?.[groupId] as string[]; - } - - return undefined; + /** + * Fetches the cycle details + * @param workspaceSlug + * @param projectId + * @param id is the cycle Id + */ + fetchParentStats = (workspaceSlug: string, projectId?: string | undefined, id?: string | undefined) => { + const cycleId = id ?? this.cycleId; + projectId && cycleId && this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); }; + /** + * This method is called to fetch the first issues of pagination + * @param workspaceSlug + * @param projectId + * @param loadType + * @param options + * @param cycleId + * @returns + */ fetchIssues = async ( workspaceSlug: string, projectId: string, - loadType: TLoader = "init-loader", + loadType: TLoader, + options: IssuePaginationOptions, cycleId: string ) => { try { - this.loader = loadType; - - const params = this.rootIssueStore?.cycleIssuesFilter?.appliedFilters; - const response = await this.cycleService.getCycleIssuesWithParams(workspaceSlug, projectId, cycleId, params); - this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); - + // set loader and clear store runInAction(() => { - set( - this.issues, - [cycleId], - response.map((issue) => issue.id) - ); - this.loader = undefined; + this.setLoader(loadType); }); + this.clear(); - this.rootIssueStore.issues.addIssue(response); + // get params from pagination options + const params = this.issueFilterStore?.getFilterParams(options, undefined, undefined, undefined); + // call the fetch issues API with the params + const response = await this.cycleService.getCycleIssues(workspaceSlug, projectId, cycleId, params); + // after fetching issues, call the base method to process the response further + this.onfetchIssues(response, options, workspaceSlug, projectId, cycleId); return response; } catch (error) { - this.loader = undefined; + // set loader to undefined once errored out + this.setLoader(undefined); throw error; } }; - createIssue = async (workspaceSlug: string, projectId: string, data: Partial, cycleId: string) => { - try { - const response = await this.rootIssueStore.projectIssues.createIssue(workspaceSlug, projectId, data); - await this.addIssueToCycle(workspaceSlug, projectId, cycleId, [response.id], false); - this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); - - return response; - } catch (error) { - throw error; - } - }; - - updateIssue = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - data: Partial, - cycleId: string - ) => { - try { - await this.rootIssueStore.projectIssues.updateIssue(workspaceSlug, projectId, issueId, data); - this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); - } catch (error) { - this.fetchIssues(workspaceSlug, projectId, "mutation", cycleId); - throw error; - } - }; - - removeIssue = async (workspaceSlug: string, projectId: string, issueId: string, cycleId: string) => { - try { - await this.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); - this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); - - const issueIndex = this.issues[cycleId].findIndex((_issueId) => _issueId === issueId); - if (issueIndex >= 0) - runInAction(() => { - this.issues[cycleId].splice(issueIndex, 1); - }); - } catch (error) { - throw error; - } - }; - - archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string, cycleId: string) => { - try { - await this.rootIssueStore.projectIssues.archiveIssue(workspaceSlug, projectId, issueId); - this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); - - runInAction(() => { - pull(this.issues[cycleId], issueId); - }); - } catch (error) { - throw error; - } - }; - - quickAddIssue = async ( - workspaceSlug: string, - projectId: string, - data: TIssue, - cycleId: string | undefined = undefined - ) => { - try { - if (!cycleId) throw new Error("Cycle Id is required"); - - runInAction(() => { - this.issues[cycleId].push(data.id); - this.rootIssueStore.issues.addIssue([data]); - }); - - const response = await this.createIssue(workspaceSlug, projectId, data, cycleId); - - const quickAddIssueIndex = this.issues[cycleId].findIndex((_issueId) => _issueId === data.id); - if (quickAddIssueIndex >= 0) { - runInAction(() => { - this.issues[cycleId].splice(quickAddIssueIndex, 1); - this.rootIssueStore.issues.removeIssue(data.id); - }); - } - - const currentModuleIds = - data.module_ids && data.module_ids.length > 0 ? data.module_ids.filter((moduleId) => moduleId != "None") : []; - - if (currentModuleIds.length > 0) { - await this.rootStore.moduleIssues.changeModulesInIssue( - workspaceSlug, - projectId, - response.id, - currentModuleIds, - [] - ); - } - - this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); - - return response; - } catch (error) { - if (cycleId) this.fetchIssues(workspaceSlug, projectId, "mutation", cycleId); - throw error; - } - }; - - addIssueToCycle = async ( + /** + * This method is called subsequent pages of pagination + * if groupId/subgroupId is provided, only that specific group's next page is fetched + * else all the groups' next page is fetched + * @param workspaceSlug + * @param projectId + * @param cycleId + * @param groupId + * @param subGroupId + * @returns + */ + fetchNextIssues = async ( workspaceSlug: string, projectId: string, cycleId: string, - issueIds: string[], - fetchAddedIssues = true + groupId?: string, + subGroupId?: string ) => { + const cursorObject = this.getPaginationData(groupId, subGroupId); + // if there are no pagination options and the next page results do not exist the return + if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return; try { - await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, { - issues: issueIds, - }); + // set Loader + this.setLoader("pagination", groupId, subGroupId); - if (fetchAddedIssues) await this.rootIssueStore.issues.getIssues(workspaceSlug, projectId, issueIds); + // get params from stored pagination options + const params = this.issueFilterStore?.getFilterParams( + this.paginationOptions, + cursorObject?.nextCursor, + groupId, + subGroupId + ); + // call the fetch issues API with the params for next page in issues + const response = await this.cycleService.getCycleIssues(workspaceSlug, projectId, cycleId, params); - // add the new issue ids to the cycle issues map - runInAction(() => { - update(this.issues, cycleId, (cycleIssueIds = []) => uniq(concat(cycleIssueIds, issueIds))); - }); - issueIds.forEach((issueId) => { - const issueCycleId = this.rootIssueStore.issues.getIssueById(issueId)?.cycle_id; - // remove issue from previous cycle if it exists - if (issueCycleId && issueCycleId !== cycleId) { - runInAction(() => { - pull(this.issues[issueCycleId], issueId); - }); - } - // update the root issue map with the new cycle id - this.rootStore.issues.updateIssue(issueId, { cycle_id: cycleId }); - }); + // after the next page of issues are fetched, call the base method to process the response + this.onfetchNexIssues(response, groupId, subGroupId); + return response; + } catch (error) { + // set Loader as undefined if errored out + this.setLoader(undefined, groupId, subGroupId); + throw error; + } + }; - this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); + /** + * This Method exists to fetch the first page of the issues with the existing stored pagination + * This is useful for refetching when filters, groupBy, orderBy etc changes + * @param workspaceSlug + * @param projectId + * @param loadType + * @param cycleId + * @returns + */ + fetchIssuesWithExistingPagination = async ( + workspaceSlug: string, + projectId: string, + loadType: TLoader, + cycleId: string + ) => { + if (!this.paginationOptions) return; + return await this.fetchIssues(workspaceSlug, projectId, loadType, this.paginationOptions, cycleId); + }; + + /** + * Override inherited create issue, to also add issue to cycle + * @param workspaceSlug + * @param projectId + * @param data + * @param cycleId + * @returns + */ + override createIssue = async (workspaceSlug: string, projectId: string, data: Partial, cycleId: string) => { + try { + const response = await super.createIssue(workspaceSlug, projectId, data, cycleId, false); + await this.addIssueToCycle(workspaceSlug, projectId, cycleId, [response.id], false); + + return response; } catch (error) { throw error; } }; /** - * Remove a cycle from issue + * This method is used to transfer issues from completed cycles to a new cycle * @param workspaceSlug * @param projectId - * @param issueId + * @param cycleId + * @param payload contains new cycle Id * @returns */ - removeCycleFromIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { - const issueCycleId = this.rootIssueStore.issues.getIssueById(issueId)?.cycle_id; - if (!issueCycleId) return; - try { - // perform optimistic update, update store - runInAction(() => { - pull(this.issues[issueCycleId], issueId); - }); - this.rootStore.issues.updateIssue(issueId, { cycle_id: null }); - - // make API call - await this.issueService.removeIssueFromCycle(workspaceSlug, projectId, issueCycleId, issueId); - this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, issueCycleId); - } catch (error) { - // revert back changes if fails - runInAction(() => { - update(this.issues, issueCycleId, (cycleIssueIds = []) => uniq(concat(cycleIssueIds, [issueId]))); - }); - this.rootStore.issues.updateIssue(issueId, { cycle_id: issueCycleId }); - throw error; - } - }; - - removeIssueFromCycle = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => { - try { - runInAction(() => { - pull(this.issues[cycleId], issueId); - }); - - this.rootStore.issues.updateIssue(issueId, { cycle_id: null }); - - await this.issueService.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); - this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); - } catch (error) { - throw error; - } - }; - - addCycleToIssue = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => { - const issueCycleId = this.rootIssueStore.issues.getIssueById(issueId)?.cycle_id; - try { - // add the new issue ids to the cycle issues map - runInAction(() => { - update(this.issues, cycleId, (cycleIssueIds = []) => uniq(concat(cycleIssueIds, [issueId]))); - }); - // remove issue from previous cycle if it exists - if (issueCycleId && issueCycleId !== cycleId) { - runInAction(() => { - pull(this.issues[issueCycleId], issueId); - }); - } - // update the root issue map with the new cycle id - this.rootStore.issues.updateIssue(issueId, { cycle_id: cycleId }); - - await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, { - issues: [issueId], - }); - - this.rootIssueStore.rootStore.cycle.fetchCycleDetails(workspaceSlug, projectId, cycleId); - } catch (error) { - // remove the new issue ids from the cycle issues map - runInAction(() => { - pull(this.issues[cycleId], issueId); - }); - // add issue back to the previous cycle if it exists - if (issueCycleId) - runInAction(() => { - update(this.issues, issueCycleId, (cycleIssueIds = []) => uniq(concat(cycleIssueIds, [issueId]))); - }); - // update the root issue map with the original cycle id - this.rootStore.issues.updateIssue(issueId, { cycle_id: issueCycleId ?? null }); - throw error; - } - }; - transferIssuesFromCycle = async ( workspaceSlug: string, projectId: string, @@ -450,13 +267,16 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { } ) => { try { + // call API call to transfer issues const response = await this.cycleService.transferIssues( workspaceSlug as string, projectId as string, cycleId as string, payload ); - await this.fetchIssues(workspaceSlug, projectId, "mutation", cycleId); + // call fetch issues + this.paginationOptions && + (await this.fetchIssues(workspaceSlug, projectId, "mutation", this.paginationOptions, cycleId)); return response; } catch (error) { @@ -464,22 +284,121 @@ export class CycleIssues extends IssueHelperStore implements ICycleIssues { } }; - fetchActiveCycleIssues = async (workspaceSlug: string, projectId: string, cycleId: string) => { + /** + * This is Pagination for active cycle issues + * This method is called to fetch the first page of issues pagination + * @param workspaceSlug + * @param projectId + * @param perPageCount + * @param cycleId + * @returns + */ + fetchActiveCycleIssues = async (workspaceSlug: string, projectId: string, perPageCount: number, cycleId: string) => { try { - const params = { priority: `urgent,high` }; - const response = await this.cycleService.getCycleIssuesWithParams(workspaceSlug, projectId, cycleId, params); + // set loader + set(this.activeCycleIds, [cycleId], undefined); - runInAction(() => { - set(this.issues, [ACTIVE_CYCLE_ISSUES], Object.keys(response)); - this.loader = undefined; + // set params for urgent and high + const params = { priority: `urgent,high`, cursor: `${perPageCount}:0:0`, per_page: perPageCount }; + // call the fetch issues API + const response = await this.cycleService.getCycleIssues(workspaceSlug, projectId, cycleId, params); + + // Process issue response + const { issueList, groupedIssues } = this.processIssueResponse(response); + + // add issues to the main Issue Map + this.rootIssueStore.issues.addIssue(issueList); + const activeIssueIds = groupedIssues[ALL_ISSUES] as string[]; + + // store the processed data in the current store + set(this.activeCycleIds, [cycleId], { + issueIds: activeIssueIds, + issueCount: response.total_count, + nextCursor: response.next_cursor, + nextPageResults: response.next_page_results, + perPageCount: perPageCount, }); - this.rootIssueStore.issues.addIssue(Object.values(response)); - return response; } catch (error) { - this.loader = undefined; throw error; } }; + + /** + * This is Pagination for active cycle issues + * This method is called subsequent pages of pagination + * @param workspaceSlug + * @param projectId + * @param cycleId + * @returns + */ + fetchNextActiveCycleIssues = async (workspaceSlug: string, projectId: string, cycleId: string) => { + try { + //get the previous pagination data for the cycle id + const activeCycle = get(this.activeCycleIds, [cycleId]); + + // if there is no active cycle and the next pages does not exist return + if (!activeCycle || !activeCycle.nextPageResults) return; + + // create params + const params = { priority: `urgent,high`, cursor: activeCycle.nextCursor, per_page: activeCycle.perPageCount }; + // fetch API response + const response = await this.cycleService.getCycleIssues(workspaceSlug, projectId, cycleId, params); + + // Process the response + const { issueList, groupedIssues } = this.processIssueResponse(response); + + // add issues to main issue Map + this.rootIssueStore.issues.addIssue(issueList); + + const activeIssueIds = groupedIssues[ALL_ISSUES] as string[]; + + // store the processed data for subsequent pages + set(this.activeCycleIds, [cycleId, "issueCount"], response.total_count); + set(this.activeCycleIds, [cycleId, "nextCursor"], response.next_cursor); + set(this.activeCycleIds, [cycleId, "nextPageResults"], response.next_page_results); + set(this.activeCycleIds, [cycleId, "issueCount"], response.total_count); + update(this.activeCycleIds, [cycleId, "issueIds"], (issueIds: string[] = []) => { + return this.issuesSortWithOrderBy(uniq(concat(issueIds, activeIssueIds)), this.orderBy); + }); + + return response; + } catch (error) { + throw error; + } + }; + + /** + * This Method overrides the base quickAdd issue + * @param workspaceSlug + * @param projectId + * @param data + * @param cycleId + * @returns + */ + quickAddIssue = async (workspaceSlug: string, projectId: string, data: TIssue, cycleId: string) => { + try { + // add temporary issue to store list + this.addIssue(data); + + // call overridden create issue + const response = await this.createIssue(workspaceSlug, projectId, data, cycleId); + + // remove temp Issue from store list + runInAction(() => { + this.removeIssueFromList(data.id); + this.rootIssueStore.issues.removeIssue(data.id); + }); + + if (data.module_ids && data.module_ids.length > 0) { + await this.changeModulesInIssue(workspaceSlug, projectId, response.id, data.module_ids, []); + } + return response; + } catch (error) { + throw error; + } + }; + + archiveBulkIssues = this.bulkArchiveIssues; } diff --git a/web/store/issue/draft/filter.store.ts b/web/store/issue/draft/filter.store.ts index 8e7e16b85..0bd8da8f3 100644 --- a/web/store/issue/draft/filter.store.ts +++ b/web/store/issue/draft/filter.store.ts @@ -14,20 +14,24 @@ import { TIssueKanbanFilters, IIssueFilters, TIssueParams, + IssuePaginationOptions, } from "@plane/types"; -import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; +import { IBaseIssueFilterStore, IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers // types import { IIssueRootStore } from "../root.store"; +import { computedFn } from "mobx-utils"; // constants // services -export interface IDraftIssuesFilter { - // observables - filters: Record; // Record defines projectId as key and IIssueFilters as value - // computed - issueFilters: IIssueFilters | undefined; - appliedFilters: Partial> | undefined; +export interface IDraftIssuesFilter extends IBaseIssueFilterStore { + //helper actions + getFilterParams: ( + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => Partial>; // action fetchFilters: (workspaceSlug: string, projectId: string) => Promise; updateFilters: ( @@ -92,6 +96,20 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI return filteredRouteParams; } + getFilterParams = computedFn( + ( + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => { + const filterParams = this.appliedFilters; + + const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId); + return paginationParams; + } + ); + fetchFilters = async (workspaceSlug: string, projectId: string) => { try { const _filters = this.handleIssuesLocalFilters.get(EIssuesStoreType.DRAFT, workspaceSlug, projectId, undefined); @@ -145,7 +163,7 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI }); const appliedFilters = _filters.filters || {}; const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); - this.rootIssueStore.draftIssues.fetchIssues( + this.rootIssueStore.draftIssues.fetchIssuesWithExistingPagination( workspaceSlug, projectId, isEmpty(filteredFilters) ? "init-loader" : "mutation" @@ -187,8 +205,7 @@ export class DraftIssuesFilter extends IssueFilterHelperStore implements IDraftI }); }); - if (this.requiresServerUpdate(updatedDisplayFilters)) - this.rootIssueStore.draftIssues.fetchIssues(workspaceSlug, projectId, "mutation"); + this.rootIssueStore.draftIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation"); this.handleIssuesLocalFilters.set(EIssuesStoreType.DRAFT, type, workspaceSlug, projectId, undefined, { display_filters: _filters.displayFilters, diff --git a/web/store/issue/draft/issue.store.ts b/web/store/issue/draft/issue.store.ts index c7d94e85c..429511382 100644 --- a/web/store/issue/draft/issue.store.ts +++ b/web/store/issue/draft/issue.store.ts @@ -1,203 +1,168 @@ -import concat from "lodash/concat"; -import pull from "lodash/pull"; -import set from "lodash/set"; -import uniq from "lodash/uniq"; -import update from "lodash/update"; -import { action, observable, makeObservable, computed, runInAction } from "mobx"; +import { action, makeObservable, runInAction } from "mobx"; // base class // services -import { IssueDraftService } from "@/services/issue/issue_draft.service"; // types -import { TIssue, TLoader, TGroupedIssues, TSubGroupedIssues, TUnGroupedIssues, ViewFlags } from "@plane/types"; -import { IssueHelperStore } from "../helpers/issue-helper.store"; +import { TIssue, TLoader, ViewFlags, IssuePaginationOptions, TIssuesResponse, TBulkOperationsPayload } from "@plane/types"; import { IIssueRootStore } from "../root.store"; +import { IDraftIssuesFilter } from "./filter.store"; +import { BaseIssuesStore, IBaseIssuesStore } from "../helpers/base-issues.store"; -export interface IDraftIssues { +export interface IDraftIssues extends IBaseIssuesStore { // observable - loader: TLoader; - issues: { [project_id: string]: string[] }; + viewFlags: ViewFlags; - // computed - groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; // actions - getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; - fetchIssues: (workspaceSlug: string, projectId: string, loadType: TLoader) => Promise; + fetchIssues: ( + workspaceSlug: string, + projectId: string, + loadType: TLoader, + option: IssuePaginationOptions + ) => Promise; + fetchIssuesWithExistingPagination: ( + workspaceSlug: string, + projectId: string, + loadType: TLoader + ) => Promise; + + fetchNextIssues: ( + workspaceSlug: string, + projectId: string, + groupId?: string, + subGroupId?: string + ) => Promise; createIssue: (workspaceSlug: string, projectId: string, data: Partial) => Promise; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; - removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise; + + archiveBulkIssues: undefined; quickAddIssue: undefined; } -export class DraftIssues extends IssueHelperStore implements IDraftIssues { - loader: TLoader = "init-loader"; - issues: { [project_id: string]: string[] } = {}; +export class DraftIssues extends BaseIssuesStore implements IDraftIssues { viewFlags = { enableQuickAdd: false, enableIssueCreation: true, enableInlineEditing: true, }; - // root store - rootIssueStore: IIssueRootStore; - // service - issueDraftService; + // filter store + issueFilterStore: IDraftIssuesFilter; - constructor(_rootStore: IIssueRootStore) { - super(_rootStore); + constructor(_rootStore: IIssueRootStore, issueFilterStore: IDraftIssuesFilter) { + super(_rootStore, issueFilterStore); makeObservable(this, { - // observable - loader: observable.ref, - issues: observable, - // computed - groupedIssueIds: computed, // action fetchIssues: action, - createIssue: action, - updateIssue: action, - removeIssue: action, + fetchNextIssues: action, + fetchIssuesWithExistingPagination: action, }); - // root store - this.rootIssueStore = _rootStore; - this.issueDraftService = new IssueDraftService(); + // filter store + this.issueFilterStore = issueFilterStore; } - get getIssues() { - const projectId = this.rootIssueStore.projectId; - if (!projectId || !this.issues || !this.issues[projectId]) return undefined; - - return this.issues[projectId]; - } - - get groupedIssueIds() { - const projectId = this.rootIssueStore.projectId; - if (!projectId) return undefined; - - const displayFilters = this.rootIssueStore?.draftIssuesFilter?.issueFilters?.displayFilters; - if (!displayFilters) return undefined; - - const subGroupBy = displayFilters?.sub_group_by; - const groupBy = displayFilters?.group_by; - const orderBy = displayFilters?.order_by; - const layout = displayFilters?.layout; - - const draftIssueIds = this.issues[projectId]; - if (!draftIssueIds) return undefined; - - const _issues = this.rootIssueStore.issues.getIssuesByIds(draftIssueIds, "un-archived"); - if (!_issues) return []; - - let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined = undefined; - - if (layout === "list" && orderBy) { - if (groupBy) issues = this.groupedIssues(groupBy, orderBy, _issues); - else issues = this.unGroupedIssues(orderBy, _issues); - } else if (layout === "kanban" && groupBy && orderBy) { - if (subGroupBy) issues = this.subGroupedIssues(subGroupBy, groupBy, orderBy, _issues); - else issues = this.groupedIssues(groupBy, orderBy, _issues); - } - - return issues; - } - - getIssueIds = (groupId?: string, subGroupId?: string) => { - const groupedIssueIds = this.groupedIssueIds; - - const displayFilters = this.rootIssueStore?.draftIssuesFilter?.issueFilters?.displayFilters; - if (!displayFilters || !groupedIssueIds) return undefined; - - const subGroupBy = displayFilters?.sub_group_by; - const groupBy = displayFilters?.group_by; - - if (!groupBy && !subGroupBy) { - return groupedIssueIds as string[]; - } - - if (groupBy && subGroupBy && groupId && subGroupId) { - return (groupedIssueIds as TSubGroupedIssues)?.[subGroupId]?.[groupId] as string[]; - } - - if (groupBy && groupId) { - return (groupedIssueIds as TGroupedIssues)?.[groupId] as string[]; - } - - return undefined; + /** + * Fetches the project details + * @param workspaceSlug + * @param projectId + */ + fetchParentStats = async (workspaceSlug: string, projectId?: string) => { + projectId && this.rootIssueStore.rootStore.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId); }; - fetchIssues = async (workspaceSlug: string, projectId: string, loadType: TLoader = "init-loader") => { + /** + * This method is called to fetch the first issues of pagination + * @param workspaceSlug + * @param projectId + * @param loadType + * @param options + * @returns + */ + fetchIssues = async ( + workspaceSlug: string, + projectId: string, + loadType: TLoader = "init-loader", + options: IssuePaginationOptions + ) => { try { - this.loader = loadType; + // set loader and clear store + runInAction(() => { + this.setLoader(loadType); + }); + this.clear(); - const params = this.rootIssueStore?.draftIssuesFilter?.appliedFilters; + // get params from pagination options + const params = this.issueFilterStore?.getFilterParams(options, undefined, undefined, undefined); + // call the fetch issues API with the params const response = await this.issueDraftService.getDraftIssues(workspaceSlug, projectId, params); - runInAction(() => { - set( - this.issues, - [projectId], - response.map((issue) => issue.id) - ); - this.loader = undefined; - }); - - this.rootIssueStore.issues.addIssue(response); - + // after fetching issues, call the base method to process the response further + this.onfetchIssues(response, options, workspaceSlug, projectId); return response; } catch (error) { - console.error(error); - this.loader = undefined; + // set loader to undefined if errored out + this.setLoader(undefined); throw error; } }; - createIssue = async (workspaceSlug: string, projectId: string, data: Partial) => { + /** + * This method is called subsequent pages of pagination + * if groupId/subgroupId is provided, only that specific group's next page is fetched + * else all the groups' next page is fetched + * @param workspaceSlug + * @param projectId + * @param groupId + * @param subGroupId + * @returns + */ + fetchNextIssues = async (workspaceSlug: string, projectId: string, groupId?: string, subGroupId?: string) => { + const cursorObject = this.getPaginationData(groupId, subGroupId); + // if there are no pagination options and the next page results do not exist the return + if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return; try { - const response = await this.issueDraftService.createDraftIssue(workspaceSlug, projectId, data); + // set Loader + this.setLoader("pagination", groupId, subGroupId); - runInAction(() => { - update(this.issues, [projectId], (issueIds = []) => uniq(concat(issueIds, response.id))); - }); - - this.rootStore.issues.addIssue([response]); + // get params from stored pagination options + const params = this.issueFilterStore?.getFilterParams( + this.paginationOptions, + cursorObject?.nextCursor, + groupId, + subGroupId + ); + // call the fetch issues API with the params for next page in issues + const response = await this.issueDraftService.getDraftIssues(workspaceSlug, projectId, params); + // after the next page of issues are fetched, call the base method to process the response + this.onfetchNexIssues(response, groupId, subGroupId); return response; } catch (error) { + // set Loader as undefined if errored out + this.setLoader(undefined, groupId, subGroupId); throw error; } }; - updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { - try { - this.rootStore.issues.updateIssue(issueId, data); - - if (data.hasOwnProperty("is_draft") && data?.is_draft === false) { - runInAction(() => { - update(this.issues, [projectId], (issueIds = []) => { - if (issueIds.includes(issueId)) pull(issueIds, issueId); - return issueIds; - }); - }); - } - - await this.issueDraftService.updateDraftIssue(workspaceSlug, projectId, issueId, data); - } catch (error) { - this.fetchIssues(workspaceSlug, projectId, "mutation"); - throw error; - } + /** + * This Method exists to fetch the first page of the issues with the existing stored pagination + * This is useful for refetching when filters, groupBy, orderBy etc changes + * @param workspaceSlug + * @param projectId + * @param loadType + * @returns + */ + fetchIssuesWithExistingPagination = async ( + workspaceSlug: string, + projectId: string, + loadType: TLoader = "mutation" + ) => { + if (!this.paginationOptions) return; + return await this.fetchIssues(workspaceSlug, projectId, loadType, this.paginationOptions); }; - removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { - try { - await this.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); + createIssue = this.createDraftIssue; + updateIssue = this.updateDraftIssue; - runInAction(() => { - update(this.issues, [projectId], (issueIds = []) => { - if (issueIds.includes(issueId)) pull(issueIds, issueId); - return issueIds; - }); - }); - } catch (error) { - throw error; - } - }; - - quickAddIssue: undefined; + archiveBulkIssues = undefined; + quickAddIssue = undefined; } diff --git a/web/store/issue/helpers/base-issues-utils.ts b/web/store/issue/helpers/base-issues-utils.ts new file mode 100644 index 000000000..408d787c6 --- /dev/null +++ b/web/store/issue/helpers/base-issues-utils.ts @@ -0,0 +1,183 @@ +import isEmpty from "lodash/isEmpty"; +import { EIssueGroupedAction } from "./base-issues.store"; +import uniq from "lodash/uniq"; +import { TIssue } from "@plane/types"; +import { ALL_ISSUES } from "@/constants/issue"; + +/** + * returns, + * A compound key, if both groupId & subGroupId are defined + * groupId, only if groupId is defined + * ALL_ISSUES, if both groupId & subGroupId are not defined + * @param groupId + * @param subGroupId + * @returns + */ +export const getGroupKey = (groupId?: string, subGroupId?: string) => { + if (groupId && subGroupId && subGroupId !== "null") return `${groupId}_${subGroupId}`; + + if (groupId) return groupId; + + return ALL_ISSUES; +}; + +/** + * This method returns the issue key actions for based on the difference in issue properties of grouped values + * @param addArray Array of groupIds at which the issue needs to be added + * @param deleteArray Array of groupIds at which the issue needs to be deleted + * @returns an array of objects that contains the issue Path at which it needs to be updated and the action that needs to be performed at the path as well + */ +export const getGroupIssueKeyActions = ( + addArray: string[], + deleteArray: string[] +): { path: string[]; action: EIssueGroupedAction }[] => { + const issueKeyActions = []; + + // Add all the groupIds as IssueKey and action as Add + for (const addKey of addArray) { + issueKeyActions.push({ path: [addKey], action: EIssueGroupedAction.ADD }); + } + + // Add all the groupIds as IssueKey and action as Delete + for (const deleteKey of deleteArray) { + issueKeyActions.push({ path: [deleteKey], action: EIssueGroupedAction.DELETE }); + } + + return issueKeyActions; +}; + +/** + * This method returns the issue key actions for based on the difference in issue properties of grouped and subGrouped values + * @param groupActionsArray Addition and Deletion arrays of groupIds at which the issue needs to be added and deleted + * @param subGroupActionsArray Addition and Deletion arrays of subGroupIds at which the issue needs to be added and deleted + * @param previousIssueGroupProperties previous value of the issue property that on which grouping is dependent on + * @param currentIssueGroupProperties current value of the issue property that on which grouping is dependent on + * @param previousIssueSubGroupProperties previous value of the issue property that on which subGrouping is dependent on + * @param currentIssueSubGroupProperties current value of the issue property that on which subGrouping is dependent on + * @returns an array of objects that contains the issue Path at which it needs to be updated and the action that needs to be performed at the path as well + */ +export const getSubGroupIssueKeyActions = ( + groupActionsArray: { + [EIssueGroupedAction.ADD]: string[]; + [EIssueGroupedAction.DELETE]: string[]; + }, + subGroupActionsArray: { + [EIssueGroupedAction.ADD]: string[]; + [EIssueGroupedAction.DELETE]: string[]; + }, + previousIssueGroupProperties: string[], + currentIssueGroupProperties: string[], + previousIssueSubGroupProperties: string[], + currentIssueSubGroupProperties: string[] +): { path: string[]; action: EIssueGroupedAction }[] => { + const issueKeyActions: { [key: string]: { path: string[]; action: EIssueGroupedAction } } = {}; + + // For every groupId path for issue Id List, that needs to be added, + // It needs to be added at all the current Issue Properties that on which subGrouping depends on + for (const addKey of groupActionsArray[EIssueGroupedAction.ADD]) { + for (const subGroupProperty of currentIssueSubGroupProperties) { + issueKeyActions[getGroupKey(addKey, subGroupProperty)] = { + path: [addKey, subGroupProperty], + action: EIssueGroupedAction.ADD, + }; + } + } + + // For every groupId path for issue Id List, that needs to be deleted, + // It needs to be deleted at all the previous Issue Properties that on which subGrouping depends on + for (const deleteKey of groupActionsArray[EIssueGroupedAction.DELETE]) { + for (const subGroupProperty of previousIssueSubGroupProperties) { + issueKeyActions[getGroupKey(deleteKey, subGroupProperty)] = { + path: [deleteKey, subGroupProperty], + action: EIssueGroupedAction.DELETE, + }; + } + } + + // For every subGroupId path for issue Id List, that needs to be added, + // It needs to be added at all the current Issue Properties that on which grouping depends on + for (const addKey of subGroupActionsArray[EIssueGroupedAction.ADD]) { + for (const groupProperty of currentIssueGroupProperties) { + issueKeyActions[getGroupKey(groupProperty, addKey)] = { + path: [groupProperty, addKey], + action: EIssueGroupedAction.ADD, + }; + } + } + + // For every subGroupId path for issue Id List, that needs to be deleted, + // It needs to be deleted at all the previous Issue Properties that on which grouping depends on + for (const deleteKey of subGroupActionsArray[EIssueGroupedAction.DELETE]) { + for (const groupProperty of previousIssueGroupProperties) { + issueKeyActions[getGroupKey(groupProperty, deleteKey)] = { + path: [groupProperty, deleteKey], + action: EIssueGroupedAction.DELETE, + }; + } + } + + return Object.values(issueKeyActions); +}; + +/** + * This Method is used to get the difference between two arrays + * @param current + * @param previous + * @param action + * @returns returns two arrays, ADD and DELETE. + * Whatever is newly added to current is added to ADD array + * Whatever is removed from previous is added to DELETE array + */ +export const getDifference = ( + current: string[], + previous: string[], + action?: EIssueGroupedAction.ADD | EIssueGroupedAction.DELETE +): { [EIssueGroupedAction.ADD]: string[]; [EIssueGroupedAction.DELETE]: string[] } => { + const ADD = []; + const DELETE = []; + + // For all the current issues values that are not in the previous array, Add them to the ADD array + if (isEmpty(current)) ADD.push("None"); + else { + for (const currentValue of current) { + if (previous.includes(currentValue)) continue; + ADD.push(currentValue); + } + } + + // For all the previous issues values that are not in the current array, Add them to the ADD array + if (isEmpty(previous)) DELETE.push("None"); + else { + for (const previousValue of previous) { + if (current.includes(previousValue)) continue; + DELETE.push(previousValue); + } + } + + // if there are no action provided, return the arrays + if (!action) return { [EIssueGroupedAction.ADD]: ADD, [EIssueGroupedAction.DELETE]: DELETE }; + + // If there is an action provided, return the values of both arrays under that array + if (action === EIssueGroupedAction.ADD) + return { [EIssueGroupedAction.ADD]: uniq([...ADD, ...DELETE]), [EIssueGroupedAction.DELETE]: [] }; + else return { [EIssueGroupedAction.DELETE]: uniq([...ADD, ...DELETE]), [EIssueGroupedAction.ADD]: [] }; +}; + +/** + * This Method is mainly used to filter out empty values in the beginning + * @param key key of the value that is to be checked if empty + * @param object any object in which the key's value is to be checked + * @returns 1 if empty, 0 if not empty + */ +export const getSortOrderToFilterEmptyValues = (key: string, object: any) => { + const value = object?.[key]; + + if (typeof value !== "number" && isEmpty(value)) return 1; + + return 0; +}; + +// get IssueIds from Issue data List +export const getIssueIds = (issues: TIssue[]) => { + return issues.map((issue) => issue?.id); +}; diff --git a/web/store/issue/helpers/base-issues.store.ts b/web/store/issue/helpers/base-issues.store.ts new file mode 100644 index 000000000..608ddf8b9 --- /dev/null +++ b/web/store/issue/helpers/base-issues.store.ts @@ -0,0 +1,1819 @@ +import { action, computed, makeObservable, observable, runInAction } from "mobx"; +import { computedFn } from "mobx-utils"; +import update from "lodash/update"; +import uniq from "lodash/uniq"; +import concat from "lodash/concat"; +import pull from "lodash/pull"; +import orderBy from "lodash/orderBy"; +import clone from "lodash/clone"; +import indexOf from "lodash/indexOf"; +import set from "lodash/set"; +import get from "lodash/get"; +import isEqual from "lodash/isEqual"; +import isNil from "lodash/isNil"; +// types +import { + TIssue, + TIssueGroupByOptions, + TIssueOrderByOptions, + TGroupedIssues, + TSubGroupedIssues, + TLoader, + IssuePaginationOptions, + TIssuesResponse, + TIssues, + TIssuePaginationData, + TGroupedIssueCount, + TPaginationData, + TBulkOperationsPayload, +} from "@plane/types"; +import { IIssueRootStore } from "../root.store"; +import { IBaseIssueFilterStore } from "./issue-filter-helper.store"; +// constants +import { ALL_ISSUES, EIssueLayoutTypes, ISSUE_PRIORITIES } from "@/constants/issue"; +// helpers +// services +import { IssueArchiveService, IssueDraftService, IssueService } from "@/services/issue"; +import { ModuleService } from "@/services/module.service"; +import { CycleService } from "@/services/cycle.service"; +import { + getDifference, + getGroupIssueKeyActions, + getGroupKey, + getIssueIds, + getSortOrderToFilterEmptyValues, + getSubGroupIssueKeyActions, +} from "./base-issues-utils"; +import { convertToISODateString } from "@/helpers/date-time.helper"; + +export type TIssueDisplayFilterOptions = Exclude | "target_date"; + +export enum EIssueGroupedAction { + ADD = "ADD", + DELETE = "DELETE", + REORDER = "REORDER", +} +export interface IBaseIssuesStore { + // observable + loader: Record; + + groupedIssueIds: TGroupedIssues | TSubGroupedIssues | undefined; // object to store Issue Ids based on group or subgroup + groupedIssueCount: TGroupedIssueCount; // map of groupId/subgroup and issue count of that particular group/subgroup + issuePaginationData: TIssuePaginationData; // map of groupId/subgroup and pagination Data of that particular group/subgroup + + //actions + removeIssue(workspaceSlug: string, projectId: string, issueId: string): Promise; + // helper methods + getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; + issuesSortWithOrderBy(issueIds: string[], key: Partial): string[]; + getPaginationData(groupId: string | undefined, subGroupId: string | undefined): TPaginationData | undefined; + getIssueLoader(groupId?: string, subGroupId?: string): TLoader; + getGroupIssueCount: ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ) => number | undefined; + + addIssueToCycle: ( + workspaceSlug: string, + projectId: string, + cycleId: string, + issueIds: string[], + fetchAddedIssues?: boolean + ) => Promise; + removeIssueFromCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; + addCycleToIssue: (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => Promise; + removeCycleFromIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + + addIssuesToModule: ( + workspaceSlug: string, + projectId: string, + moduleId: string, + issueIds: string[], + fetchAddedIssues?: boolean + ) => Promise; + removeIssuesFromModule: ( + workspaceSlug: string, + projectId: string, + moduleId: string, + issueIds: string[] + ) => Promise; + changeModulesInIssue( + workspaceSlug: string, + projectId: string, + issueId: string, + addModuleIds: string[], + removeModuleIds: string[] + ): Promise; +} + +// This constant maps the group by keys to the respective issue property that the key relies on +const ISSUE_GROUP_BY_KEY: Record = { + project: "project_id", + state: "state_id", + "state_detail.group": "state_id" as keyof TIssue, // state_detail.group is only being used for state_group display, + priority: "priority", + labels: "label_ids", + created_by: "created_by", + assignees: "assignee_ids", + target_date: "target_date", + cycle: "cycle_id", + module: "module_ids", +}; + +export const ISSUE_FILTER_DEFAULT_DATA: Record = { + project: "project_id", + cycle: "cycle_id", + module: "module_ids", + state: "state_id", + "state_detail.group": "state_group" as keyof TIssue, // state_detail.group is only being used for state_group display, + priority: "priority", + labels: "label_ids", + created_by: "created_by", + assignees: "assignee_ids", + target_date: "target_date", +}; + +// This constant maps the order by keys to the respective issue property that the key relies on +const ISSUE_ORDERBY_KEY: Record = { + created_at: "created_at", + "-created_at": "created_at", + updated_at: "updated_at", + "-updated_at": "updated_at", + priority: "priority", + "-priority": "priority", + sort_order: "sort_order", + state__name: "state_id", + "-state__name": "state_id", + assignees__first_name: "assignee_ids", + "-assignees__first_name": "assignee_ids", + labels__name: "label_ids", + "-labels__name": "label_ids", + issue_module__module__name: "module_ids", + "-issue_module__module__name": "module_ids", + issue_cycle__cycle__name: "cycle_id", + "-issue_cycle__cycle__name": "cycle_id", + target_date: "target_date", + "-target_date": "target_date", + estimate_point: "estimate_point", + "-estimate_point": "estimate_point", + start_date: "start_date", + "-start_date": "start_date", + link_count: "link_count", + "-link_count": "link_count", + attachment_count: "attachment_count", + "-attachment_count": "attachment_count", + sub_issues_count: "sub_issues_count", + "-sub_issues_count": "sub_issues_count", +}; + +export abstract class BaseIssuesStore implements IBaseIssuesStore { + loader: Record = {}; + groupedIssueIds: TIssues | undefined = undefined; + issuePaginationData: TIssuePaginationData = {}; + + groupedIssueCount: TGroupedIssueCount = {}; + // + paginationOptions: IssuePaginationOptions | undefined = undefined; + + isArchived: boolean; + + // services + issueService; + issueArchiveService; + issueDraftService; + moduleService; + cycleService; + // root store + rootIssueStore; + issueFilterStore; + + constructor(_rootStore: IIssueRootStore, issueFilterStore: IBaseIssueFilterStore, isArchived = false) { + makeObservable(this, { + // observable + loader: observable, + groupedIssueIds: observable, + issuePaginationData: observable, + groupedIssueCount: observable, + + paginationOptions: observable, + // computed + moduleId: computed, + cycleId: computed, + orderBy: computed, + groupBy: computed, + subGroupBy: computed, + orderByKey: computed, + issueGroupKey: computed, + issueSubGroupKey: computed, + // action + storePreviousPaginationValues: action.bound, + + onfetchIssues: action.bound, + onfetchNexIssues: action.bound, + clear: action.bound, + setLoader: action.bound, + addIssue: action.bound, + removeIssueFromList: action.bound, + + createIssue: action, + updateIssue: action, + createDraftIssue: action, + updateDraftIssue: action, + issueQuickAdd: action.bound, + removeIssue: action.bound, + archiveIssue: action.bound, + removeBulkIssues: action.bound, + bulkArchiveIssues: action.bound, + bulkUpdateProperties: action.bound, + + addIssueToCycle: action.bound, + removeIssueFromCycle: action.bound, + addCycleToIssue: action.bound, + removeCycleFromIssue: action.bound, + + addIssuesToModule: action.bound, + removeIssuesFromModule: action.bound, + changeModulesInIssue: action.bound, + }); + this.rootIssueStore = _rootStore; + this.issueFilterStore = issueFilterStore; + + this.isArchived = isArchived; + + this.issueService = new IssueService(); + this.issueArchiveService = new IssueArchiveService(); + this.issueDraftService = new IssueDraftService(); + this.moduleService = new ModuleService(); + this.cycleService = new CycleService(); + } + + // Abstract class to be implemented to fetch parent stats such as project, module or cycle details + abstract fetchParentStats: (workspaceSlug: string, projectId?: string, id?: string) => void; + + // current Module Id from url + get moduleId() { + return this.rootIssueStore.moduleId; + } + + // current Cycle Id from url + get cycleId() { + return this.rootIssueStore.cycleId; + } + + // current Order by value + get orderBy() { + const displayFilters = this.issueFilterStore?.issueFilters?.displayFilters; + if (!displayFilters) return; + + return displayFilters?.order_by; + } + + // current Group by value + get groupBy() { + const displayFilters = this.issueFilterStore?.issueFilters?.displayFilters; + if (!displayFilters || !displayFilters?.layout) return; + + const layout = displayFilters?.layout; + + return layout === EIssueLayoutTypes.CALENDAR + ? "target_date" + : [EIssueLayoutTypes.LIST, EIssueLayoutTypes.KANBAN]?.includes(layout) + ? displayFilters?.group_by + : undefined; + } + + // current Sub group by value + get subGroupBy() { + const displayFilters = this.issueFilterStore?.issueFilters?.displayFilters; + if (!displayFilters || displayFilters.group_by === displayFilters.sub_group_by) return; + + return displayFilters?.layout === "kanban" ? displayFilters?.sub_group_by : undefined; + } + + getIssueIds = (groupId?: string, subGroupId?: string) => { + const groupedIssueIds = this.groupedIssueIds; + + const displayFilters = this.issueFilterStore?.issueFilters?.displayFilters; + if (!displayFilters || !groupedIssueIds) return undefined; + + const subGroupBy = displayFilters?.sub_group_by; + const groupBy = displayFilters?.group_by; + + if (!groupBy && !subGroupBy && Array.isArray(groupedIssueIds)) { + return groupedIssueIds as string[]; + } + + if (groupBy && groupId && groupedIssueIds?.[groupId] && Array.isArray(groupedIssueIds[groupId])) { + return groupedIssueIds[groupId] as string[]; + } + + if (groupBy && subGroupBy && groupId && subGroupId) { + return (groupedIssueIds as TSubGroupedIssues)?.[subGroupId]?.[groupId] as string[]; + } + + return undefined; + }; + + // The Issue Property corresponding to the order by value + get orderByKey() { + const orderBy = this.orderBy; + if (!orderBy) return; + + return ISSUE_ORDERBY_KEY[orderBy]; + } + + // The Issue Property corresponding to the group by value + get issueGroupKey() { + const groupBy = this.groupBy; + + if (!groupBy) return; + + return ISSUE_GROUP_BY_KEY[groupBy]; + } + + // The Issue Property corresponding to the sub group by value + get issueSubGroupKey() { + const subGroupBy = this.subGroupBy; + + if (!subGroupBy) return; + + return ISSUE_GROUP_BY_KEY[subGroupBy]; + } + + /** + * Store the pagination data required for next subsequent issue pagination calls + * @param prevCursor cursor value of previous page + * @param nextCursor cursor value of next page + * @param nextPageResults boolean to indicate if the next page results exist i.e, have we reached end of pages + * @param groupId groupId and subGroupId to add the pagination data for the particular group/subgroup + * @param subGroupId + */ + setPaginationData( + prevCursor: string, + nextCursor: string, + nextPageResults: boolean, + groupId?: string, + subGroupId?: string + ) { + const cursorObject = { + prevCursor, + nextCursor, + nextPageResults, + }; + + set(this.issuePaginationData, [getGroupKey(groupId, subGroupId)], cursorObject); + } + + /** + * Sets the loader value of the particular groupId/subGroupId, or to ALL_ISSUES if both are undefined + * @param loaderValue + * @param groupId + * @param subGroupId + */ + setLoader(loaderValue: TLoader, groupId?: string, subGroupId?: string) { + runInAction(() => { + set(this.loader, getGroupKey(groupId, subGroupId), loaderValue); + }); + } + + /** + * gets the Loader value of particular group/subgroup/ALL_ISSUES + */ + getIssueLoader = (groupId?: string, subGroupId?: string) => { + return get(this.loader, getGroupKey(groupId, subGroupId)); + }; + + /** + * gets the pagination data of particular group/subgroup/ALL_ISSUES + */ + getPaginationData = computedFn( + (groupId: string | undefined, subGroupId: string | undefined): TPaginationData | undefined => { + return get(this.issuePaginationData, [getGroupKey(groupId, subGroupId)]); + } + ); + + /** + * gets the issue count of particular group/subgroup/ALL_ISSUES + * + * if isSubGroupCumulative is true, sum up all the issueCount of the subGroupId, across all the groupIds + */ + getGroupIssueCount = computedFn( + ( + groupId: string | undefined, + subGroupId: string | undefined, + isSubGroupCumulative: boolean + ): number | undefined => { + if (isSubGroupCumulative && subGroupId) { + const groupIssuesKeys = Object.keys(this.groupedIssueCount); + let subGroupCumulativeCount = 0; + + for (const groupKey of groupIssuesKeys) { + if (groupKey.includes(subGroupId)) subGroupCumulativeCount += this.groupedIssueCount[groupKey]; + } + + return subGroupCumulativeCount; + } + + return get(this.groupedIssueCount, [getGroupKey(groupId, subGroupId)]); + } + ); + + /** + * This Method is called after fetching the first paginated issues + * + * This method updates the appropriate issue list based on if groupByKey or subGroupByKey are defined + * If both groupByKey and subGroupByKey are not defined, then the issue list are added to another group called ALL_ISSUES + * @param issuesResponse Paginated Response received from the API + * @param options Pagination options + * @param workspaceSlug + * @param projectId + * @param id Id can be anything from cycleId, moduleId, viewId or userId based on the store + */ + onfetchIssues( + issuesResponse: TIssuesResponse, + options: IssuePaginationOptions, + workspaceSlug: string, + projectId?: string, + id?: string + ) { + // Process the Issue Response to get the following data from it + const { issueList, groupedIssues, groupedIssueCount } = this.processIssueResponse(issuesResponse); + + // The Issue list is added to the main Issue Map + this.rootIssueStore.issues.addIssue(issueList); + + // Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts + runInAction(() => { + this.updateGroupedIssueIds(groupedIssues, groupedIssueCount); + this.loader[getGroupKey()] = undefined; + }); + + // fetch parent stats if required, to be handled in the Implemented class + this.fetchParentStats(workspaceSlug, projectId, id); + + // store Pagination options for next subsequent calls and data like next cursor etc + this.storePreviousPaginationValues(issuesResponse, options); + } + + /** + * This Method is called on the subsequent pagination calls after the first initial call + * + * This method updates the appropriate issue list based on if groupId or subgroupIds are Passed + * @param issuesResponse Paginated Response received from the API + * @param groupId + * @param subGroupId + */ + onfetchNexIssues(issuesResponse: TIssuesResponse, groupId?: string, subGroupId?: string) { + // Process the Issue Response to get the following data from it + const { issueList, groupedIssues, groupedIssueCount } = this.processIssueResponse(issuesResponse); + + // The Issue list is added to the main Issue Map + this.rootIssueStore.issues.addIssue(issueList); + + // Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts + runInAction(() => { + this.updateGroupedIssueIds(groupedIssues, groupedIssueCount, groupId, subGroupId); + this.loader[getGroupKey(groupId, subGroupId)] = undefined; + }); + + // store Pagination data like next cursor etc + this.storePreviousPaginationValues(issuesResponse, undefined, groupId, subGroupId); + } + + /** + * Method to create Issue. This method updates the store and calls the API to create an issue + * @param workspaceSlug + * @param projectId + * @param data Default Issue Data + * @param id optional id like moduleId and cycleId, not used here but required in overridden the Module or cycle issues methods + * @param shouldUpdateList If false, then it would not update the Issue Id list but only makes an API call and adds to the main Issue Map + * @returns + */ + async createIssue( + workspaceSlug: string, + projectId: string, + data: Partial, + id?: string, + shouldUpdateList = true + ) { + try { + // perform an API call + const response = await this.issueService.createIssue(workspaceSlug, projectId, data); + + // add Issue to Store + this.addIssue(response, shouldUpdateList); + + // If shouldUpdateList is true, call fetchParentStats + shouldUpdateList && this.fetchParentStats(workspaceSlug, projectId); + + return response; + } catch (error) { + throw error; + } + } + + /** + * Updates the Issue, by calling the API and also updating the store + * @param workspaceSlug + * @param projectId + * @param issueId + * @param data Partial Issue Data to be updated + * @param shouldSync If False then only issue is to be updated in the store not call API to update + * @returns + */ + async updateIssue( + workspaceSlug: string, + projectId: string, + issueId: string, + data: Partial, + shouldSync = true + ) { + // Store Before state of the issue + const issueBeforeUpdate = clone(this.rootIssueStore.issues.getIssueById(issueId)); + try { + // Update the Respective Stores + this.rootIssueStore.issues.updateIssue(issueId, data); + this.updateIssueList({ ...issueBeforeUpdate, ...data } as TIssue, issueBeforeUpdate); + + // Check if should Sync + if (!shouldSync) return; + + // call API to update the issue + await this.issueService.patchIssue(workspaceSlug, projectId, issueId, data); + + // call fetch Parent Stats + this.fetchParentStats(workspaceSlug, projectId); + } catch (error) { + // If errored out update store again to revert the change + this.rootIssueStore.issues.updateIssue(issueId, issueBeforeUpdate ?? {}); + this.updateIssueList(issueBeforeUpdate, { ...issueBeforeUpdate, ...data } as TIssue); + throw error; + } + } + + /** + * Similar to Create Issue but for creating Draft issues + * @param workspaceSlug + * @param projectId + * @param data draft issue data + * @returns + */ + async createDraftIssue(workspaceSlug: string, projectId: string, data: Partial) { + try { + // call API to create a Draft issue + const response = await this.issueDraftService.createDraftIssue(workspaceSlug, projectId, data); + + // call Fetch parent stats + this.fetchParentStats(workspaceSlug, projectId); + + // Add issue to store + this.addIssue(response); + + return response; + } catch (error) { + throw error; + } + } + + /** + * Similar to update issue but for draft issues. + * @param workspaceSlug + * @param projectId + * @param issueId + * @param data Partial Issue Data to be updated + */ + async updateDraftIssue(workspaceSlug: string, projectId: string, issueId: string, data: Partial) { + // Store Before state of the issue + const issueBeforeUpdate = clone(this.rootIssueStore.issues.getIssueById(issueId)); + try { + // Update the Respective Stores + this.rootIssueStore.issues.updateIssue(issueId, data); + this.updateIssueList({ ...issueBeforeUpdate, ...data } as TIssue, issueBeforeUpdate); + + // call API to update the issue + await this.issueDraftService.updateDraftIssue(workspaceSlug, projectId, issueId, data); + + // call Fetch parent stats + this.fetchParentStats(workspaceSlug, projectId); + + // If the issue is updated to not a draft issue anymore remove from the store list + if (!isNil(data.is_draft) && !data.is_draft) this.removeIssueFromList(issueId); + } catch (error) { + // If errored out update store again to revert the change + this.rootIssueStore.issues.updateIssue(issueId, issueBeforeUpdate ?? {}); + this.updateIssueList(issueBeforeUpdate, { ...issueBeforeUpdate, ...data } as TIssue); + throw error; + } + } + + /** + * This method is called to delete an issue + * @param workspaceSlug + * @param projectId + * @param issueId + */ + async removeIssue(workspaceSlug: string, projectId: string, issueId: string) { + try { + // Male API call + await this.issueService.deleteIssue(workspaceSlug, projectId, issueId); + + // Remove from Respective issue Id list + runInAction(() => { + this.removeIssueFromList(issueId); + }); + + // call fetch Parent stats + this.fetchParentStats(workspaceSlug, projectId); + + // Remove issue from main issue Map store + this.rootIssueStore.issues.removeIssue(issueId); + } catch (error) { + throw error; + } + } + + /** + * This method is called to Archive an issue + * @param workspaceSlug + * @param projectId + * @param issueId + */ + async archiveIssue(workspaceSlug: string, projectId: string, issueId: string) { + try { + // Male API call + const response = await this.issueArchiveService.archiveIssue(workspaceSlug, projectId, issueId); + + // call fetch Parent stats + this.fetchParentStats(workspaceSlug, projectId); + + runInAction(() => { + // Update the Archived at of the issue from store + this.rootIssueStore.issues.updateIssue(issueId, { + archived_at: response.archived_at, + }); + // Since Archived remove the issue Id from the current store + this.removeIssueFromList(issueId); + }); + } catch (error) { + throw error; + } + } + + /** + * Method to perform Quick add of issues + * @param workspaceSlug + * @param projectId + * @param data + * @returns + */ + async issueQuickAdd(workspaceSlug: string, projectId: string, data: TIssue) { + try { + // Add issue to store with a temporary Id + this.addIssue(data); + + // call Create issue method + const response = await this.createIssue(workspaceSlug, projectId, data); + + runInAction(() => { + this.removeIssueFromList(data.id); + this.rootIssueStore.issues.removeIssue(data.id); + }); + + if (data.cycle_id && data.cycle_id !== "" && !this.cycleId) { + await this.addCycleToIssue(workspaceSlug, projectId, data.cycle_id, response.id); + } + + if (data.module_ids && data.module_ids.length > 0 && !this.moduleId) { + await this.changeModulesInIssue(workspaceSlug, projectId, response.id, data.module_ids, []); + } + + return response; + } catch (error) { + throw error; + } + } + + /** + * This is a method to delete issues in bulk + * @param workspaceSlug + * @param projectId + * @param issueIds + * @returns + */ + async removeBulkIssues(workspaceSlug: string, projectId: string, issueIds: string[]) { + try { + // Make API call to bulk delete issues + const response = await this.issueService.bulkDeleteIssues(workspaceSlug, projectId, { issue_ids: issueIds }); + + // call fetch parent stats + this.fetchParentStats(workspaceSlug, projectId); + + // Remove issues from the store + runInAction(() => { + issueIds.forEach((issueId) => { + this.removeIssueFromList(issueId); + this.rootIssueStore.issues.removeIssue(issueId); + }); + }); + return response; + } catch (error) { + throw error; + } + } + + /** + * Bulk Archive issues + * @param workspaceSlug + * @param projectId + * @param issueIds + */ + bulkArchiveIssues = async (workspaceSlug: string, projectId: string, issueIds: string[]) => { + try { + const response = await this.issueService.bulkArchiveIssues(workspaceSlug, projectId, { issue_ids: issueIds }); + + runInAction(() => { + issueIds.forEach((issueId) => { + this.updateIssue(workspaceSlug, projectId, issueId, { + archived_at: response.archived_at, + }); + this.removeIssueFromList(issueId); + }); + }); + } catch (error) { + throw error; + } + }; + + /** + * @description bulk update properties of selected issues + * @param {TBulkOperationsPayload} data + */ + bulkUpdateProperties = async (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => { + const issueIds = data.issue_ids; + try { + // make request to update issue properties + await this.issueService.bulkOperations(workspaceSlug, projectId, data); + // update issues in the store + runInAction(() => { + issueIds.forEach((issueId) => { + const issueBeforeUpdate = clone(this.rootIssueStore.issues.getIssueById(issueId)); + if (!issueBeforeUpdate) throw new Error("Issue not found"); + Object.keys(data.properties).forEach((key) => { + const property = key as keyof TBulkOperationsPayload["properties"]; + const propertyValue = data.properties[property]; + // update root issue map properties + if (Array.isArray(propertyValue)) { + // if property value is array, append it to the existing values + const existingValue = issueBeforeUpdate[property]; + // convert existing value to an array + const newExistingValue = Array.isArray(existingValue) ? existingValue : []; + this.rootIssueStore.issues.updateIssue(issueId, { + [property]: uniq([newExistingValue, ...propertyValue]), + }); + } else { + // if property value is not an array, simply update the value + this.rootIssueStore.issues.updateIssue(issueId, { + [property]: propertyValue, + }); + } + }); + const issueDetails = this.rootIssueStore.issues.getIssueById(issueId); + this.updateIssueList(issueDetails, issueBeforeUpdate); + }); + }); + } catch (error) { + throw error; + } + }; + + /** + * This method is used to add issues to a particular Cycle + * @param workspaceSlug + * @param projectId + * @param cycleId + * @param issueIds + * @param fetchAddedIssues If True we make an additional call to fetch all the issues from their Ids, Since the addIssueToCycle API does not return them + */ + async addIssueToCycle( + workspaceSlug: string, + projectId: string, + cycleId: string, + issueIds: string[], + fetchAddedIssues = true + ) { + try { + // Perform an APi call to add issue to cycle + await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, { + issues: issueIds, + }); + + // if cycle Id is the current Cycle Id then call fetch parent stats + if (this.cycleId === cycleId) this.fetchParentStats(workspaceSlug, projectId); + + // if true, fetch the issue data for all the issueIds + if (fetchAddedIssues) await this.rootIssueStore.issues.getIssues(workspaceSlug, projectId, issueIds); + + // Update issueIds from current store + runInAction(() => { + // If cycle Id is the current cycle Id, then, add issue to list of issueIds + if (this.cycleId === cycleId) issueIds.forEach((issueId) => this.addIssueToList(issueId)); + // If cycle Id is not the current cycle Id, then, remove issue to list of issueIds + else if (this.cycleId) issueIds.forEach((issueId) => this.removeIssueFromList(issueId)); + }); + + // For Each issue update cycle Id by calling current store's update Issue, without making an API call + issueIds.forEach((issueId) => { + this.updateIssue(workspaceSlug, projectId, issueId, { cycle_id: cycleId }, false); + }); + } catch (error) { + throw error; + } + } + + /** + * This method is used to remove issue from a cycle + * @param workspaceSlug + * @param projectId + * @param cycleId + * @param issueId + */ + async removeIssueFromCycle(workspaceSlug: string, projectId: string, cycleId: string, issueId: string) { + try { + // Perform an APi call to remove issue from cycle + await this.issueService.removeIssueFromCycle(workspaceSlug, projectId, cycleId, issueId); + + // if cycle Id is the current Cycle Id then call fetch parent stats + if (this.cycleId === cycleId) this.fetchParentStats(workspaceSlug, projectId); + + runInAction(() => { + // If cycle Id is the current cycle Id, then, remove issue from list of issueIds + this.cycleId === cycleId && this.removeIssueFromList(issueId); + }); + + // update Issue cycle Id to null by calling current store's update Issue, without making an API call + this.updateIssue(workspaceSlug, projectId, issueId, { cycle_id: null }, false); + } catch (error) { + throw error; + } + } + + addCycleToIssue = async (workspaceSlug: string, projectId: string, cycleId: string, issueId: string) => { + const issueCycleId = this.rootIssueStore.issues.getIssueById(issueId)?.cycle_id; + try { + // Update issueIds from current store + runInAction(() => { + // If cycle Id is the current cycle Id, then, add issue to list of issueIds + if (this.cycleId === cycleId) this.addIssueToList(issueId); + // For Each issue update cycle Id by calling current store's update Issue, without making an API call + this.updateIssue(workspaceSlug, projectId, issueId, { cycle_id: cycleId }, false); + }); + + await this.issueService.addIssueToCycle(workspaceSlug, projectId, cycleId, { + issues: [issueId], + }); + + // if cycle Id is the current Cycle Id then call fetch parent stats + if (this.cycleId === cycleId) this.fetchParentStats(workspaceSlug, projectId); + } catch (error) { + // remove the new issue ids from the cycle issues map + runInAction(() => { + // If cycle Id is the current cycle Id, then, remove issue to list of issueIds + if (this.cycleId === cycleId) this.removeIssueFromList(issueId); + // For Each issue update cycle Id to previous value by calling current store's update Issue, without making an API call + this.updateIssue(workspaceSlug, projectId, issueId, { cycle_id: issueCycleId }, false); + }); + + throw error; + } + }; + + /* + * Remove a cycle from issue + * @param workspaceSlug + * @param projectId + * @param issueId + * @returns + */ + removeCycleFromIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { + const issueCycleId = this.rootIssueStore.issues.getIssueById(issueId)?.cycle_id; + if (!issueCycleId) return; + try { + // perform optimistic update, update store + // Update issueIds from current store + runInAction(() => { + // If cycle Id is the current cycle Id, then, add issue to list of issueIds + if (this.cycleId === issueCycleId) this.removeIssueFromList(issueId); + // For Each issue update cycle Id by calling current store's update Issue, without making an API call + this.updateIssue(workspaceSlug, projectId, issueId, { cycle_id: null }, false); + }); + + // make API call + await this.issueService.removeIssueFromCycle(workspaceSlug, projectId, issueCycleId, issueId); + // if cycle Id is the current Cycle Id then call fetch parent stats + if (this.cycleId === issueCycleId) this.fetchParentStats(workspaceSlug, projectId); + } catch (error) { + // revert back changes if fails + // Update issueIds from current store + runInAction(() => { + // If cycle Id is the current cycle Id, then, add issue to list of issueIds + if (this.cycleId === issueCycleId) this.addIssueToList(issueId); + // For Each issue update cycle Id by calling current store's update Issue, without making an API call + this.updateIssue(workspaceSlug, projectId, issueId, { cycle_id: issueCycleId }, false); + }); + + throw error; + } + }; + + /** + * This method is used to add issues to a module + * @param workspaceSlug + * @param projectId + * @param moduleId + * @param issueIds + * @param fetchAddedIssues If True we make an additional call to fetch all the issues from their Ids, Since the addIssuesToModule API does not return them + */ + async addIssuesToModule( + workspaceSlug: string, + projectId: string, + moduleId: string, + issueIds: string[], + fetchAddedIssues = true + ) { + try { + // Perform an APi call to add issue to module + await this.moduleService.addIssuesToModule(workspaceSlug, projectId, moduleId, { + issues: issueIds, + }); + + // if true, fetch the issue data for all the issueIds + if (fetchAddedIssues) await this.rootIssueStore.issues.getIssues(workspaceSlug, projectId, issueIds); + + // if module Id is the current Module Id then call fetch parent stats + if (this.moduleId === moduleId) this.fetchParentStats(workspaceSlug, projectId); + + runInAction(() => { + // if module Id is the current Module Id, then, add issue to list of issueIds + this.moduleId === moduleId && issueIds.forEach((issueId) => this.addIssueToList(issueId)); + }); + + // For Each issue update module Ids by calling current store's update Issue, without making an API call + issueIds.forEach((issueId) => { + const issueModuleIds = get(this.rootIssueStore.issues.issuesMap, [issueId, "module_ids"]) ?? []; + const updatedIssueModuleIds = uniq(concat(issueModuleIds, [moduleId])); + this.updateIssue(workspaceSlug, projectId, issueId, { module_ids: updatedIssueModuleIds }, false); + }); + } catch (error) { + throw error; + } + } + + /** + * This method is used to remove issue from a module + * @param workspaceSlug + * @param projectId + * @param moduleId + * @param issueIds + * @returns + */ + async removeIssuesFromModule(workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) { + try { + // Perform an APi call to remove issue to module + const response = await this.moduleService.removeIssuesFromModuleBulk( + workspaceSlug, + projectId, + moduleId, + issueIds + ); + + // if module Id is the current Module Id then call fetch parent stats + if (this.moduleId === moduleId) this.fetchParentStats(workspaceSlug, projectId); + + runInAction(() => { + // if module Id is the current Module Id, then remove issue from list of issueIds + this.moduleId === moduleId && issueIds.forEach((issueId) => this.removeIssueFromList(issueId)); + }); + + // For Each issue update module Ids by calling current store's update Issue, without making an API call + runInAction(() => { + issueIds.forEach((issueId) => { + const issueModuleIds = get(this.rootIssueStore.issues.issuesMap, [issueId, "module_ids"]) ?? []; + const updatedIssueModuleIds = pull(issueModuleIds, moduleId); + this.updateIssue(workspaceSlug, projectId, issueId, { module_ids: updatedIssueModuleIds }, false); + }); + }); + + return response; + } catch (error) { + throw error; + } + } + + /* + * change modules array in issue + * @param workspaceSlug + * @param projectId + * @param issueId + * @param addModuleIds array of modules to be added + * @param removeModuleIds array of modules to be removed + */ + async changeModulesInIssue( + workspaceSlug: string, + projectId: string, + issueId: string, + addModuleIds: string[], + removeModuleIds: string[] + ) { + // keep a copy of the original module ids + const originalModuleIds = get(this.rootIssueStore.issues.issuesMap, [issueId, "module_ids"]) ?? []; + try { + runInAction(() => { + // get current Module Ids of the issue + let currentModuleIds = [...originalModuleIds]; + // remove the new issue id to the module issues + removeModuleIds.forEach((moduleId) => { + // If module Id is equal to current module Id, them remove Issue from List + this.moduleId === moduleId && this.removeIssueFromList(issueId); + currentModuleIds = pull(currentModuleIds, moduleId); + }); + + // If current Module Id is included in the modules list, then add Issue to List + if (addModuleIds.includes(this.moduleId ?? "")) this.addIssueToList(issueId); + currentModuleIds = uniq(concat([...currentModuleIds], addModuleIds)); + + // For current Issue, update module Ids by calling current store's update Issue, without making an API call + this.updateIssue(workspaceSlug, projectId, issueId, { module_ids: currentModuleIds }, false); + }); + + //Perform API call + await this.moduleService.addModulesToIssue(workspaceSlug, projectId, issueId, { + modules: addModuleIds, + removed_modules: removeModuleIds, + }); + + if (addModuleIds.includes(this.moduleId || "") || removeModuleIds.includes(this.moduleId || "")) { + this.fetchParentStats(workspaceSlug, projectId); + } + } catch (error) { + // revert the issue back to its original module ids + runInAction(() => { + // If current Module Id is included in the add modules list, then remove Issue from List + if (addModuleIds.includes(this.moduleId ?? "")) this.removeIssueFromList(issueId); + // If current Module Id is included in the removed modules list, then add Issue to List + if (removeModuleIds.includes(this.moduleId ?? "")) this.addIssueToList(issueId); + + // For current Issue, update module Ids by calling current store's update Issue, without making an API call + this.updateIssue(workspaceSlug, projectId, issueId, { module_ids: originalModuleIds }, false); + }); + + throw error; + } + } + + /** + * Add issue to the store + * @param issue + * @param shouldUpdateList indicates if the issue Id to be added to the list + */ + addIssue(issue: TIssue, shouldUpdateList = true) { + runInAction(() => { + this.rootIssueStore.issues.addIssue([issue]); + }); + + // if true, add issue id to the list + if (shouldUpdateList) this.updateIssueList(issue, undefined, EIssueGroupedAction.ADD); + } + + /** + * Method called to clear out the current store + */ + clear() { + runInAction(() => { + this.groupedIssueIds = undefined; + this.issuePaginationData = {}; + this.groupedIssueCount = {}; + this.paginationOptions = undefined; + }); + } + + /** + * Method called to add issue id to list. + * This will only work if the issue already exists in the main issue map + * @param issueId + */ + addIssueToList(issueId: string) { + const issue = this.rootIssueStore.issues.getIssueById(issueId); + this.updateIssueList(issue, undefined, EIssueGroupedAction.ADD); + } + + /** + * Method called to remove issue id from list. + * This will only work if the issue already exists in the main issue map + * @param issueId + */ + removeIssueFromList(issueId: string) { + const issue = this.rootIssueStore.issues.getIssueById(issueId); + this.updateIssueList(issue, undefined, EIssueGroupedAction.DELETE); + } + + /** + * Method called to update the issue list, + * If an action is passed, this method would add/remove the issue from list according to the action + * If there is no action, this method compares before and after states of the issue to decide, where to remove the issue id from and where to add it to + * if only issue is passed down then, the method determines where to add the issue Id and updates the list + * @param issue current issue state + * @param issueBeforeUpdate issue state before the update + * @param action specific action can be provided to force the method to that action + * @returns + */ + updateIssueList( + issue?: TIssue, + issueBeforeUpdate?: TIssue, + action?: EIssueGroupedAction.ADD | EIssueGroupedAction.DELETE + ) { + if (!issue) return; + // get issueUpdates from another method by passing down the three arguments + // issueUpdates is nothing but an array of objects that contain the path of the issueId list that need updating and also the action that needs to be performed at the path + const issueUpdates = this.getUpdateDetails(issue, issueBeforeUpdate, action); + const accumulatedUpdatesForCount = {}; + runInAction(() => { + // The issueUpdates + for (const issueUpdate of issueUpdates) { + //if update is add, add it at a particular path + if (issueUpdate.action === EIssueGroupedAction.ADD) { + // add issue Id at the path + update(this, ["groupedIssueIds", ...issueUpdate.path], (issueIds: string[] = []) => { + return this.issuesSortWithOrderBy(uniq(concat(issueIds, issue.id)), this.orderBy); + }); + } + + //if update is delete, remove it at a particular path + if (issueUpdate.action === EIssueGroupedAction.DELETE) { + // remove issue Id from the path + update(this, ["groupedIssueIds", ...issueUpdate.path], (issueIds: string[] = []) => { + return pull(issueIds, issue.id); + }); + } + + // accumulate the updates so that we don't end up updating the count twice for the same issue + this.accumulateIssueUpdates(accumulatedUpdatesForCount, issueUpdate.path, issueUpdate.action); + + //if update is reorder, reorder it at a particular path + if (issueUpdate.action === EIssueGroupedAction.REORDER) { + // re-order/re-sort the issue Ids at the path + update(this, ["groupedIssueIds", ...issueUpdate.path], (issueIds: string[] = []) => { + return this.issuesSortWithOrderBy(issueIds, this.orderBy); + }); + } + } + + // update the respective counts from the accumulation object + this.updateIssueCount(accumulatedUpdatesForCount); + }); + } + + /** + * This method processes the issueResponse to provide data that can be used to update the store + * @param issueResponse + * @returns issueList, list of issue Data + * @returns groupedIssues, grouped issue Ids + * @returns groupedIssueCount, object containing issue counts of individual groups + */ + processIssueResponse(issueResponse: TIssuesResponse): { + issueList: TIssue[]; + groupedIssues: TIssues; + groupedIssueCount: TGroupedIssueCount; + } { + const issueResult = issueResponse?.results; + + // if undefined return empty objects + if (!issueResult) + return { + issueList: [], + groupedIssues: {}, + groupedIssueCount: {}, + }; + + //if is an array then it's an ungrouped response. return values with groupId as ALL_ISSUES + if (Array.isArray(issueResult)) { + return { + issueList: issueResult, + groupedIssues: { + [ALL_ISSUES]: issueResult.map((issue) => issue.id), + }, + groupedIssueCount: { + [ALL_ISSUES]: issueResponse.total_count, + }, + }; + } + + const issueList: TIssue[] = []; + const groupedIssues: TGroupedIssues | TSubGroupedIssues = {}; + const groupedIssueCount: TGroupedIssueCount = {}; + + // update total issue count to ALL_ISSUES + set(groupedIssueCount, [ALL_ISSUES], issueResponse.total_count); + + // loop through all the groupIds from issue Result + for (const groupId in issueResult) { + const groupIssuesObject = issueResult[groupId]; + const groupIssueResult = groupIssuesObject?.results; + + // if groupIssueResult is undefined then continue the loop + if (!groupIssueResult) continue; + + // set grouped Issue count of the current groupId + set(groupedIssueCount, [groupId], groupIssuesObject.total_results); + + // if groupIssueResult, the it is not subGrouped + if (Array.isArray(groupIssueResult)) { + // add the result to issueList + issueList.push(...groupIssueResult); + // set the issue Ids to the groupId path + set( + groupedIssues, + [groupId], + groupIssueResult.map((issue) => issue.id) + ); + continue; + } + + // loop through all the subGroupIds from issue Result + for (const subGroupId in groupIssueResult) { + const subGroupIssuesObject = groupIssueResult[subGroupId]; + const subGroupIssueResult = subGroupIssuesObject?.results; + + // if subGroupIssueResult is undefined then continue the loop + if (!subGroupIssueResult) continue; + + // set sub grouped Issue count of the current groupId + set(groupedIssueCount, [getGroupKey(groupId, subGroupId)], subGroupIssuesObject.total_results); + + if (Array.isArray(subGroupIssueResult)) { + // add the result to issueList + issueList.push(...subGroupIssueResult); + // set the issue Ids to the [groupId, subGroupId] path + set( + groupedIssues, + [groupId, subGroupId], + subGroupIssueResult.map((issue) => issue.id) + ); + + continue; + } + } + } + + return { issueList, groupedIssues, groupedIssueCount }; + } + + /** + * This method is used to update the grouped issue Ids to it's respected lists and also to update group Issue Counts + * @param groupedIssues Object that contains list of issueIds with respect to their groups/subgroups + * @param groupedIssueCount Object the contains the issue count of each groups + * @param groupId groupId string + * @param subGroupId subGroupId string + * @returns updates the store with the values + */ + updateGroupedIssueIds( + groupedIssues: TIssues, + groupedIssueCount: TGroupedIssueCount, + groupId?: string, + subGroupId?: string + ) { + // if groupId exists and groupedIssues has ALL_ISSUES as a group, + // then it's an individual group/subgroup pagination + if (groupId && groupedIssues[ALL_ISSUES] && Array.isArray(groupedIssues[ALL_ISSUES])) { + const issueGroup = groupedIssues[ALL_ISSUES]; + const issueGroupCount = groupedIssueCount[ALL_ISSUES]; + const issuesPath = [groupId]; + // issuesPath is the path for the issue List in the Grouped Issue List + // issuePath is either [groupId] for grouped pagination or [groupId, subGroupId] for subGrouped pagination + if (subGroupId) issuesPath.push(subGroupId); + + // update the issue Count of the particular group/subGroup + set(this.groupedIssueCount, [getGroupKey(groupId, subGroupId)], issueGroupCount); + + // update the issue list in the issuePath + this.updateIssueGroup(issueGroup, issuesPath); + return; + } + + // if not in the above condition the it's a complete grouped pagination not individual group/subgroup pagination + // update total issue count as ALL_ISSUES count in `groupedIssueCount` object + set(this.groupedIssueCount, [ALL_ISSUES], groupedIssueCount[ALL_ISSUES]); + + // loop through the groups of groupedIssues. + for (const groupId in groupedIssues) { + const issueGroup = groupedIssues[groupId]; + const issueGroupCount = groupedIssueCount[groupId]; + + // update the groupId's issue count + set(this.groupedIssueCount, [groupId], issueGroupCount); + + // This updates the group issue list in the store, if the issueGroup is a string + const storeUpdated = this.updateIssueGroup(issueGroup, [groupId]); + // if issueGroup is indeed a string, continue + if (storeUpdated) continue; + + // if issueGroup is not a string, loop through the sub group Issues + for (const subGroupId in issueGroup) { + const issueSubGroup = (issueGroup as TGroupedIssues)[subGroupId]; + const issueSubGroupCount = groupedIssueCount[getGroupKey(groupId, subGroupId)]; + + // update the subGroupId's issue count + set(this.groupedIssueCount, [getGroupKey(groupId, subGroupId)], issueSubGroupCount); + // This updates the subgroup issue list in the store + this.updateIssueGroup(issueSubGroup, [groupId, subGroupId]); + } + } + } + + /** + * This Method is used to update the issue Id list at the particular issuePath + * @param groupedIssueIds could be an issue Id List for grouped issues or an object that contains a issue Id list in case of subGrouped + * @param issuePath array of string, to identify the path of the issueList to be updated with the above issue Id list + * @returns a boolean that indicates if the groupedIssueIds is indeed a array Id list, in which case the issue Id list is added to the store at issuePath + */ + updateIssueGroup(groupedIssueIds: TGroupedIssues | string[], issuePath: string[]): boolean { + if (!groupedIssueIds) return true; + + // if groupedIssueIds is an array, update the `groupedIssueIds` store at the issuePath + if (groupedIssueIds && Array.isArray(groupedIssueIds)) { + update(this, ["groupedIssueIds", ...issuePath], (issueIds: string[] = []) => { + return this.issuesSortWithOrderBy(uniq(concat(issueIds, groupedIssueIds as string[])), this.orderBy); + }); + // return true to indicate the store has been updated + return true; + } + + // return false to indicate the store has been updated and the groupedIssueIds is likely Object for subGrouped Issues + return false; + } + + /** + * For Every issue update, accumulate it so that when an single issue is added to two groups, it doesn't increment the total count twice + * @param accumulator + * @param path + * @param action + * @returns + */ + accumulateIssueUpdates( + accumulator: { [key: string]: EIssueGroupedAction }, + path: string[], + action: EIssueGroupedAction + ) { + const [groupId, subGroupId] = path; + + if (action !== EIssueGroupedAction.ADD && action !== EIssueGroupedAction.DELETE) return; + + // if both groupId && subGroupId exists update the subgroup key + if (subGroupId && groupId) { + const groupKey = getGroupKey(groupId, subGroupId); + this.updateUpdateAccumulator(accumulator, groupKey, action); + } + + // after above, if groupId exists update the group key + if (groupId) { + this.updateUpdateAccumulator(accumulator, groupId, action); + } + + // if groupId is not ALL_ISSUES then update the All_ISSUES key + // (if groupId is equal to ALL_ISSUES, it would have updated in the previous condition) + if (groupId !== ALL_ISSUES) { + this.updateUpdateAccumulator(accumulator, ALL_ISSUES, action); + } + } + + /** + * This method's job is just to check and update the accumulator key + * @param accumulator accumulator object + * @param key object key like, subgroupKey, Group Key or ALL_ISSUES + * @param action + * @returns + */ + updateUpdateAccumulator( + accumulator: { [key: string]: EIssueGroupedAction }, + key: string, + action: EIssueGroupedAction + ) { + // if the key for accumulator is undefined, they update it with the action + if (!accumulator[key]) { + accumulator[key] = action; + return; + } + + // if the key for accumulator is not the current action, + // Meaning if the key already has an action ADD and the current one is REMOVE, + // The the key is deleted as both the actions cancel each other out + if (accumulator[key] !== action) { + delete accumulator[key]; + } + } + + /** + * This method is used to update the count of the issues at the path with the increment + * @param path issuePath, corresponding key is to be incremented + * @param increment + */ + updateIssueCount(accumulatedUpdatesForCount: { [key: string]: EIssueGroupedAction }) { + const updateKeys = Object.keys(accumulatedUpdatesForCount); + for (const updateKey of updateKeys) { + const update = accumulatedUpdatesForCount[updateKey]; + if (!update) continue; + + const increment = update === EIssueGroupedAction.ADD ? 1 : -1; + // get current count at the key + const issueCount = get(this.groupedIssueCount, updateKey) ?? 0; + // update the count at the key + set(this.groupedIssueCount, updateKey, issueCount + increment); + } + } + + /** + * This method is used to get update Details that would be used to update the issue Ids at the path + * @param issue current state of issue + * @param issueBeforeUpdate state of the issue before the update + * @param action optional action, to force the method to return back that action + * @returns an array of object that contains the path at which issue to be updated and the action to be performed at the path + */ + getUpdateDetails = ( + issue: Partial, + issueBeforeUpdate?: Partial, + action?: EIssueGroupedAction.ADD | EIssueGroupedAction.DELETE + ): { path: string[]; action: EIssueGroupedAction }[] => { + // check the before and after states to return if there needs to be a re-sorting of issueId list if the issue property that orderBy depends on has changed + const orderByUpdates = this.getOrderByUpdateDetails(issue, issueBeforeUpdate); + // if unGrouped, then return the path as ALL_ISSUES along with orderByUpdates + if (!this.issueGroupKey) return action ? [{ path: [ALL_ISSUES], action }, ...orderByUpdates] : orderByUpdates; + + // if grouped, the get the Difference between the two issue properties (this.issueGroupKey) on which groupBy is performed + const groupActionsArray = getDifference( + this.getArrayStringArray(issue[this.issueGroupKey], this.groupBy), + this.getArrayStringArray(issueBeforeUpdate?.[this.issueGroupKey], this.groupBy), + action + ); + + // if not subGrouped, then use the differences to construct an updateDetails Array + if (!this.issueSubGroupKey) + return [ + ...getGroupIssueKeyActions( + groupActionsArray[EIssueGroupedAction.ADD], + groupActionsArray[EIssueGroupedAction.DELETE] + ), + ...orderByUpdates, + ]; + + // if subGrouped, the get the Difference between the two issue properties (this.issueGroupKey) on which subGroupBy is performed + const subGroupActionsArray = getDifference( + this.getArrayStringArray(issue[this.issueSubGroupKey], this.subGroupBy), + this.getArrayStringArray(issueBeforeUpdate?.[this.issueSubGroupKey], this.subGroupBy), + action + ); + + // Use the differences to construct an updateDetails Array + return [ + ...getSubGroupIssueKeyActions( + groupActionsArray, + subGroupActionsArray, + this.getArrayStringArray(issueBeforeUpdate?.[this.issueGroupKey] ?? issue[this.issueGroupKey], this.groupBy), + this.getArrayStringArray(issue[this.issueGroupKey], this.groupBy), + this.getArrayStringArray( + issueBeforeUpdate?.[this.issueSubGroupKey] ?? issue[this.issueSubGroupKey], + this.subGroupBy + ), + this.getArrayStringArray(issue[this.issueSubGroupKey], this.subGroupBy) + ), + ...orderByUpdates, + ]; + }; + + /** + * This method is used to get update Details that would be used to re-order/re-sort the issue Ids at the path + * @param issue current state of issue + * @param issueBeforeUpdate state of the issue before the update + * @returns an array of object that contains the path at which issue to be re-sorted/re-ordered + */ + getOrderByUpdateDetails( + issue: Partial | undefined, + issueBeforeUpdate: Partial | undefined + ): { path: string[]; action: EIssueGroupedAction.REORDER }[] { + // if before and after states of the issue prop on which orderBy depends on then return and empty Array + if ( + !issue || + !issueBeforeUpdate || + !this.orderByKey || + isEqual(issue[this.orderByKey], issueBeforeUpdate[this.orderByKey]) + ) + return []; + + // if they are not equal and issues are not grouped then, provide path as ALL_ISSUES + if (!this.issueGroupKey) return [{ path: [ALL_ISSUES], action: EIssueGroupedAction.REORDER }]; + + // if they are grouped then identify the paths based on props on which group by is dependent on + const issueKeyActions: { path: string[]; action: EIssueGroupedAction.REORDER }[] = []; + const groupByValues = this.getArrayStringArray(issue[this.issueGroupKey]); + + // if issues are not subGrouped then, provide path from groupByValues + if (!this.issueSubGroupKey) { + for (const groupKey of groupByValues) { + issueKeyActions.push({ path: [groupKey], action: EIssueGroupedAction.REORDER }); + } + + return issueKeyActions; + } + + // if they are grouped then identify the paths based on props on which sub group by is dependent on + const subGroupByValues = this.getArrayStringArray(issue[this.issueSubGroupKey]); + + // if issues are subGrouped then, provide path from subGroupByValues + for (const groupKey of groupByValues) { + for (const subGroupKey of subGroupByValues) { + issueKeyActions.push({ path: [groupKey, subGroupKey], action: EIssueGroupedAction.REORDER }); + } + } + + return issueKeyActions; + } + + /** + * get the groupByKey issue property on which actions are to be decided in the form of array + * @param value + * @param groupByKey + * @returns an array of issue property values + */ + getArrayStringArray = ( + value: string | string[] | undefined | null, + groupByKey?: TIssueGroupByOptions | undefined + ): string[] => { + // if value is not defined, return empty array + if (!value) return []; + // if array return the array + if (Array.isArray(value)) return value; + + // if the groupKey is state group then return the group based on state_id + if (groupByKey === "state_detail.group") { + return [this.rootIssueStore.rootStore.state.stateMap?.[value]?.group]; + } + + return [value]; + }; + + /** + * This Method is used to get data of the issue based on the ids of the data for states, labels adn assignees + * @param dataType what type of data is being sent + * @param dataIds id/ids of the data that is to be populated + * @param order ascending or descending for arrays of data + * @returns string | string[] of sortable fields to be used for sorting + */ + populateIssueDataForSorting( + dataType: "state_id" | "label_ids" | "assignee_ids" | "module_ids" | "cycle_id", + dataIds: string | string[] | null | undefined, + order?: "asc" | "desc" + ) { + if (!dataIds) return; + + const dataValues: string[] = []; + const isDataIdsArray = Array.isArray(dataIds); + const dataIdsArray = isDataIdsArray ? dataIds : [dataIds]; + + switch (dataType) { + case "state_id": + const stateMap = this.rootIssueStore?.stateMap; + if (!stateMap) break; + for (const dataId of dataIdsArray) { + const state = stateMap[dataId]; + if (state && state.name) dataValues.push(state.name.toLocaleLowerCase()); + } + break; + case "label_ids": + const labelMap = this.rootIssueStore?.labelMap; + if (!labelMap) break; + for (const dataId of dataIdsArray) { + const label = labelMap[dataId]; + if (label && label.name) dataValues.push(label.name.toLocaleLowerCase()); + } + break; + case "assignee_ids": + const memberMap = this.rootIssueStore?.memberMap; + if (!memberMap) break; + for (const dataId of dataIdsArray) { + const member = memberMap[dataId]; + if (member && member.first_name) dataValues.push(member.first_name.toLocaleLowerCase()); + } + break; + case "module_ids": + const moduleMap = this.rootIssueStore?.moduleMap; + if (!moduleMap) break; + for (const dataId of dataIdsArray) { + const currentModule = moduleMap[dataId]; + if (currentModule && currentModule.name) dataValues.push(currentModule.name.toLocaleLowerCase()); + } + break; + case "cycle_id": + const cycleMap = this.rootIssueStore?.cycleMap; + if (!cycleMap) break; + for (const dataId of dataIdsArray) { + const cycle = cycleMap[dataId]; + if (cycle && cycle.name) dataValues.push(cycle.name.toLocaleLowerCase()); + } + break; + } + + return isDataIdsArray ? (order ? orderBy(dataValues, undefined, [order])[0] : dataValues) : dataValues[0]; + } + + issuesSortWithOrderBy = (issueIds: string[], key: TIssueOrderByOptions | undefined): string[] => { + const issues = this.rootIssueStore.issues.getIssuesByIds(issueIds, this.isArchived ? "archived" : "un-archived"); + const array = orderBy(issues, (issue) => convertToISODateString(issue["created_at"]), ["desc"]); + + switch (key) { + case "sort_order": + return getIssueIds(orderBy(array, "sort_order")); + case "state__name": + return getIssueIds( + orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue?.["state_id"])) + ); + case "-state__name": + return getIssueIds( + orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue?.["state_id"]), ["desc"]) + ); + // dates + case "created_at": + return getIssueIds(orderBy(array, (issue) => convertToISODateString(issue["created_at"]))); + case "-created_at": + return getIssueIds(orderBy(array, (issue) => convertToISODateString(issue["created_at"]), ["desc"])); + case "updated_at": + return getIssueIds(orderBy(array, (issue) => convertToISODateString(issue["updated_at"]))); + case "-updated_at": + return getIssueIds(orderBy(array, (issue) => convertToISODateString(issue["updated_at"]), ["desc"])); + case "start_date": + return getIssueIds(orderBy(array, [getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"])); //preferring sorting based on empty values to always keep the empty values below + case "-start_date": + return getIssueIds( + orderBy( + array, + [getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ) + ); + + case "target_date": + return getIssueIds(orderBy(array, [getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"])); //preferring sorting based on empty values to always keep the empty values below + case "-target_date": + return getIssueIds( + orderBy( + array, + [getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ) + ); + + // custom + case "priority": { + const sortArray = ISSUE_PRIORITIES.map((i) => i.key); + return getIssueIds(orderBy(array, (currentIssue: TIssue) => indexOf(sortArray, currentIssue?.priority))); + } + case "-priority": { + const sortArray = ISSUE_PRIORITIES.map((i) => i.key); + return getIssueIds( + orderBy(array, (currentIssue: TIssue) => indexOf(sortArray, currentIssue?.priority), ["desc"]) + ); + } + + // number + case "attachment_count": + return getIssueIds(orderBy(array, "attachment_count")); + case "-attachment_count": + return getIssueIds(orderBy(array, "attachment_count", ["desc"])); + + case "estimate_point": + return getIssueIds( + orderBy(array, [getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"]) + ); //preferring sorting based on empty values to always keep the empty values below + case "-estimate_point": + return getIssueIds( + orderBy( + array, + [getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"], //preferring sorting based on empty values to always keep the empty values below + ["asc", "desc"] + ) + ); + + case "link_count": + return getIssueIds(orderBy(array, "link_count")); + case "-link_count": + return getIssueIds(orderBy(array, "link_count", ["desc"])); + + case "sub_issues_count": + return getIssueIds(orderBy(array, "sub_issues_count")); + case "-sub_issues_count": + return getIssueIds(orderBy(array, "sub_issues_count", ["desc"])); + + // Array + case "labels__name": + return getIssueIds( + orderBy(array, [ + getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("label_ids", issue?.["label_ids"], "asc"), + ]) + ); + case "-labels__name": + return getIssueIds( + orderBy( + array, + [ + getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("label_ids", issue?.["label_ids"], "asc"), + ], + ["asc", "desc"] + ) + ); + + case "issue_module__module__name": + return getIssueIds( + orderBy(array, [ + getSortOrderToFilterEmptyValues.bind(null, "module_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("module_ids", issue?.["module_ids"], "asc"), + ]) + ); + case "-issue_module__module__name": + return getIssueIds( + orderBy( + array, + [ + getSortOrderToFilterEmptyValues.bind(null, "module_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("module_ids", issue?.["module_ids"], "asc"), + ], + ["asc", "desc"] + ) + ); + + case "issue_cycle__cycle__name": + return getIssueIds( + orderBy(array, [ + getSortOrderToFilterEmptyValues.bind(null, "cycle_id"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("cycle_id", issue?.["cycle_id"], "asc"), + ]) + ); + case "-issue_cycle__cycle__name": + return getIssueIds( + orderBy( + array, + [ + getSortOrderToFilterEmptyValues.bind(null, "cycle_id"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("cycle_id", issue?.["cycle_id"], "asc"), + ], + ["asc", "desc"] + ) + ); + + case "assignees__first_name": + return getIssueIds( + orderBy(array, [ + getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("assignee_ids", issue?.["assignee_ids"], "asc"), + ]) + ); + case "-assignees__first_name": + return getIssueIds( + orderBy( + array, + [ + getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below + (issue) => this.populateIssueDataForSorting("assignee_ids", issue?.["assignee_ids"], "asc"), + ], + ["asc", "desc"] + ) + ); + + default: + return getIssueIds(array); + } + }; + + /** + * This Method is called to store the pagination options and paginated data from response + * @param issuesResponse issue list response + * @param options pagination options to be stored for next page call + * @param groupId + * @param subGroupId + */ + storePreviousPaginationValues = ( + issuesResponse: TIssuesResponse, + options?: IssuePaginationOptions, + groupId?: string, + subGroupId?: string + ) => { + if (options) this.paginationOptions = options; + + this.setPaginationData( + issuesResponse.prev_cursor, + issuesResponse.next_cursor, + issuesResponse.next_page_results, + groupId, + subGroupId + ); + }; +} diff --git a/web/store/issue/helpers/issue-filter-helper.store.ts b/web/store/issue/helpers/issue-filter-helper.store.ts index 8b31dfd30..41d014d39 100644 --- a/web/store/issue/helpers/issue-filter-helper.store.ts +++ b/web/store/issue/helpers/issue-filter-helper.store.ts @@ -1,19 +1,26 @@ import isEmpty from "lodash/isEmpty"; // types -// constants -import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; -// lib -import { storage } from "@/lib/local-storage"; import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOptions, IIssueFilters, IIssueFiltersResponse, + IssuePaginationOptions, TIssueKanbanFilters, TIssueParams, TStaticViewTypes, } from "@plane/types"; +// constants +import { + EIssueFilterType, + EIssuesStoreType, + EIssueGroupByToServerOptions, + EServerGroupByToFilterOptions, + EIssueLayoutTypes, +} from "@/constants/issue"; +// lib +import { storage } from "@/lib/local-storage"; interface ILocalStoreIssueFilters { key: EIssuesStoreType; @@ -23,6 +30,14 @@ interface ILocalStoreIssueFilters { filters: IIssueFilters; } +export interface IBaseIssueFilterStore { + // observables + filters: Record; + //computed + appliedFilters: Partial> | undefined; + issueFilters: IIssueFilters | undefined; +} + export interface IIssueFilterHelperStore { computedIssueFilters(filters: IIssueFilters): IIssueFilters; computedFilteredParams( @@ -78,9 +93,14 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { module: filters?.module || undefined, start_date: filters?.start_date || undefined, target_date: filters?.target_date || undefined, - project: filters.project || undefined, - subscriber: filters.subscriber || undefined, + project: filters?.project || undefined, + subscriber: filters?.subscriber || undefined, // display filters + group_by: displayFilters?.group_by ? EIssueGroupByToServerOptions[displayFilters.group_by] : undefined, + sub_group_by: displayFilters?.sub_group_by + ? EIssueGroupByToServerOptions[displayFilters.sub_group_by] + : undefined, + order_by: displayFilters?.order_by || undefined, type: displayFilters?.type || undefined, sub_issue: displayFilters?.sub_issue ?? true, }; @@ -89,8 +109,11 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { Object.keys(computedFilters).forEach((key) => { const _key = key as TIssueParams; const _value: string | boolean | string[] | undefined = computedFilters[_key]; - if (_value != undefined && acceptableParamsByLayout.includes(_key)) - issueFiltersParams[_key] = Array.isArray(_value) ? _value.join(",") : _value; + const nonEmptyArrayValue = Array.isArray(_value) && _value.length === 0 ? undefined : _value; + if (nonEmptyArrayValue != undefined && acceptableParamsByLayout.includes(_key)) + issueFiltersParams[_key] = Array.isArray(nonEmptyArrayValue) + ? nonEmptyArrayValue.join(",") + : nonEmptyArrayValue; }); return issueFiltersParams; @@ -166,7 +189,7 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { show_weekends: filters?.calendar?.show_weekends || false, layout: filters?.calendar?.layout || "month", }, - layout: filters?.layout || "list", + layout: filters?.layout || EIssueLayoutTypes.LIST, order_by: filters?.order_by || "sort_order", group_by: filters?.group_by || null, sub_group_by: filters?.sub_group_by || null, @@ -199,20 +222,6 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { cycle: displayProperties?.cycle ?? true, }); - /** - * This Method returns true if the display properties changed requires a server side update - * @param displayFilters - * @returns - */ - requiresServerUpdate = (displayFilters: IIssueDisplayFilterOptions) => { - const SERVER_DISPLAY_FILTERS = ["sub_issue", "type"]; - const displayFilterKeys = Object.keys(displayFilters); - - return SERVER_DISPLAY_FILTERS.some((serverDisplayfilter: string) => - displayFilterKeys.includes(serverDisplayfilter) - ); - }; - handleIssuesLocalFilters = { fetchFiltersFromStorage: () => { const _filters = storage.get("issue_local_filters"); @@ -275,4 +284,79 @@ export class IssueFilterHelperStore implements IIssueFilterHelperStore { storage.set("issue_local_filters", JSON.stringify(storageFilters)); }, }; + + /** + * This Method returns true if the display properties changed requires a server side update + * @param displayFilters + * @returns + */ + requiresServerUpdate = (displayFilters: IIssueDisplayFilterOptions) => { + const NON_SERVER_DISPLAY_FILTERS = ["order_by", "sub_issue", "type"]; + const displayFilterKeys = Object.keys(displayFilters); + + return NON_SERVER_DISPLAY_FILTERS.some((serverDisplayfilter: string) => + displayFilterKeys.includes(serverDisplayfilter) + ); + }; + + /** + * This Method is used to construct the url params along with paginated values + * @param filterParams params generated from filters + * @param options pagination options + * @param cursor cursor if exists + * @param groupId groupId if to fetch By group + * @param subGroupId groupId if to fetch By sub group + * @returns + */ + getPaginationParams( + filterParams: Partial> | undefined, + options: IssuePaginationOptions, + cursor: string | undefined, + groupId?: string, + subGroupId?: string + ) { + // if cursor exists, use the cursor. If it doesn't exist construct the cursor based on per page count + const pageCursor = cursor ? cursor : groupId ? `${options.perPageCount}:1:0` : `${options.perPageCount}:0:0`; + + // pagination params + const paginationParams: Partial> = { + ...filterParams, + cursor: pageCursor, + per_page: options.perPageCount.toString(), + }; + + // If group by is specifically sent through options, like that for calendar layout, use that to group + if (options.groupedBy) { + paginationParams.group_by = options.groupedBy; + } + + // If before and after dates are sent from option to filter by then, add them to filter the options + if (options.after && options.before) { + paginationParams["target_date"] = `${options.after};after,${options.before};before`; + } + + // If groupId is passed down, add a filter param for that group Id + if (groupId) { + const groupBy = paginationParams["group_by"] as EIssueGroupByToServerOptions | undefined; + delete paginationParams["group_by"]; + + if (groupBy) { + const groupByFilterOption = EServerGroupByToFilterOptions[groupBy]; + paginationParams[groupByFilterOption] = groupId; + } + } + + // If subGroupId is passed down, add a filter param for that subGroup Id + if (subGroupId) { + const subGroupBy = paginationParams["sub_group_by"] as EIssueGroupByToServerOptions | undefined; + delete paginationParams["sub_group_by"]; + + if (subGroupBy) { + const subGroupByFilterOption = EServerGroupByToFilterOptions[subGroupBy]; + paginationParams[subGroupByFilterOption] = subGroupId; + } + } + + return paginationParams; + } } diff --git a/web/store/issue/helpers/issue-helper.store.ts b/web/store/issue/helpers/issue-helper.store.ts deleted file mode 100644 index e308679ca..000000000 --- a/web/store/issue/helpers/issue-helper.store.ts +++ /dev/null @@ -1,401 +0,0 @@ -import get from "lodash/get"; -import indexOf from "lodash/indexOf"; -import isEmpty from "lodash/isEmpty"; -import orderBy from "lodash/orderBy"; -import values from "lodash/values"; -// constants -import { ISSUE_PRIORITIES } from "@/constants/issue"; -import { STATE_GROUPS } from "@/constants/state"; -// helpers -import { convertToISODateString, renderFormattedPayloadDate } from "@/helpers/date-time.helper"; -// types -import { TIssue, TIssueMap, TIssueGroupByOptions, TIssueOrderByOptions } from "@plane/types"; -// store -import { IIssueRootStore } from "../root.store"; - -export type TIssueDisplayFilterOptions = Exclude | "target_date"; - -export type TIssueHelperStore = { - // helper methods - groupedIssues( - groupBy: TIssueDisplayFilterOptions, - orderBy: TIssueOrderByOptions, - issues: TIssueMap, - isCalendarIssues?: boolean - ): { [group_id: string]: string[] }; - subGroupedIssues( - subGroupBy: TIssueDisplayFilterOptions, - groupBy: TIssueDisplayFilterOptions, - orderBy: TIssueOrderByOptions, - issues: TIssueMap - ): { [sub_group_id: string]: { [group_id: string]: string[] } }; - unGroupedIssues(orderBy: TIssueOrderByOptions, issues: TIssueMap): string[]; - issueDisplayFiltersDefaultData(groupBy: string | null): string[]; - issuesSortWithOrderBy(issueObject: TIssueMap, key: Partial): TIssue[]; - getGroupArray(value: boolean | number | string | string[] | null, isDate?: boolean): string[]; -}; - -export const ISSUE_FILTER_DEFAULT_DATA: Record = { - project: "project_id", - cycle: "cycle_id", - module: "module_ids", - state: "state_id", - "state_detail.group": "state_group" as keyof TIssue, // state_detail.group is only being used for state_group display, - priority: "priority", - labels: "label_ids", - created_by: "created_by", - assignees: "assignee_ids", - mentions: "assignee_ids", - target_date: "target_date", -}; - -export class IssueHelperStore implements TIssueHelperStore { - // root store - rootStore; - - constructor(_rootStore: IIssueRootStore) { - this.rootStore = _rootStore; - } - - groupedIssues = ( - groupBy: TIssueDisplayFilterOptions, - orderBy: TIssueOrderByOptions, - issues: TIssueMap, - isCalendarIssues: boolean = false - ) => { - const currentIssues: { [group_id: string]: string[] } = {}; - if (!groupBy) return currentIssues; - - this.issueDisplayFiltersDefaultData(groupBy).forEach((group) => { - currentIssues[group] = []; - }); - - const projectIssues = this.issuesSortWithOrderBy(issues, orderBy); - - for (const issue in projectIssues) { - const currentIssue = projectIssues[issue]; - let groupArray = []; - - if (groupBy === "state_detail.group") { - // if groupBy state_detail.group is coming from the project level the we are using stateDetails from root store else we are looping through the stateMap - const state_group = (this.rootStore?.stateMap || {})?.[currentIssue?.state_id]?.group || "None"; - groupArray = [state_group]; - } else { - const groupValue = get(currentIssue, ISSUE_FILTER_DEFAULT_DATA[groupBy]); - groupArray = groupValue !== undefined ? this.getGroupArray(groupValue, isCalendarIssues) : ["None"]; - } - - for (const group of groupArray) { - if (group && currentIssues[group]) currentIssues[group].push(currentIssue.id); - else if (group) currentIssues[group] = [currentIssue.id]; - } - } - - return currentIssues; - }; - - subGroupedIssues = ( - subGroupBy: TIssueDisplayFilterOptions, - groupBy: TIssueDisplayFilterOptions, - orderBy: TIssueOrderByOptions, - issues: TIssueMap - ) => { - const currentIssues: { [sub_group_id: string]: { [group_id: string]: string[] } } = {}; - if (!subGroupBy || !groupBy) return currentIssues; - - this.issueDisplayFiltersDefaultData(subGroupBy).forEach((sub_group) => { - const groupByIssues: { [group_id: string]: string[] } = {}; - this.issueDisplayFiltersDefaultData(groupBy).forEach((group) => { - groupByIssues[group] = []; - }); - currentIssues[sub_group] = groupByIssues; - }); - - const projectIssues = this.issuesSortWithOrderBy(issues, orderBy); - - for (const issue in projectIssues) { - const currentIssue = projectIssues[issue]; - let subGroupArray = []; - let groupArray = []; - if (subGroupBy === "state_detail.group" || groupBy === "state_detail.group") { - const state_group = (this.rootStore?.stateMap || {})?.[currentIssue?.state_id]?.group || "None"; - - subGroupArray = [state_group]; - groupArray = [state_group]; - } else { - const subGroupValue = get(currentIssue, ISSUE_FILTER_DEFAULT_DATA[subGroupBy]); - const groupValue = get(currentIssue, ISSUE_FILTER_DEFAULT_DATA[groupBy]); - - subGroupArray = subGroupValue != undefined ? this.getGroupArray(subGroupValue) : ["None"]; - groupArray = groupValue != undefined ? this.getGroupArray(groupValue) : ["None"]; - } - - for (const subGroup of subGroupArray) { - for (const group of groupArray) { - if (subGroup && group && currentIssues?.[subGroup]?.[group]) - currentIssues[subGroup][group].push(currentIssue.id); - else if (subGroup && group && currentIssues[subGroup]) currentIssues[subGroup][group] = [currentIssue.id]; - else if (subGroup && group) currentIssues[subGroup] = { [group]: [currentIssue.id] }; - } - } - } - - return currentIssues; - }; - - unGroupedIssues = (orderBy: TIssueOrderByOptions, issues: TIssueMap) => - this.issuesSortWithOrderBy(issues, orderBy).map((issue) => issue.id); - - issueDisplayFiltersDefaultData = (groupBy: string | null): string[] => { - switch (groupBy) { - case "state": - return Object.keys(this.rootStore?.stateMap || {}); - case "state_detail.group": - return Object.keys(STATE_GROUPS); - case "priority": - return ISSUE_PRIORITIES.map((i) => i.key); - case "labels": - return Object.keys(this.rootStore?.labelMap || {}); - case "created_by": - return Object.keys(this.rootStore?.workSpaceMemberRolesMap || {}); - case "assignees": - return Object.keys(this.rootStore?.workSpaceMemberRolesMap || {}); - case "project": - return Object.keys(this.rootStore?.projectMap || {}); - case "cycle": - return Object.keys(this.rootStore?.cycleMap || {}); - case "module": - return Object.keys(this.rootStore?.moduleMap || {}); - default: - return []; - } - }; - - /** - * This Method is used to get data of the issue based on the ids of the data for states, labels adn assignees - * @param dataType what type of data is being sent - * @param dataIds id/ids of the data that is to be populated - * @param order ascending or descending for arrays of data - * @returns string | string[] of sortable fields to be used for sorting - */ - populateIssueDataForSorting( - dataType: "state_id" | "label_ids" | "assignee_ids" | "module_ids" | "cycle_id", - dataIds: string | string[] | null | undefined, - order?: "asc" | "desc" - ) { - if (!dataIds) return; - - const dataValues: string[] = []; - const isDataIdsArray = Array.isArray(dataIds); - const dataIdsArray = isDataIdsArray ? dataIds : [dataIds]; - - switch (dataType) { - case "state_id": - const stateMap = this.rootStore?.stateMap; - if (!stateMap) break; - for (const dataId of dataIdsArray) { - const state = stateMap[dataId]; - if (state && state.name) dataValues.push(state.name.toLocaleLowerCase()); - } - break; - case "label_ids": - const labelMap = this.rootStore?.labelMap; - if (!labelMap) break; - for (const dataId of dataIdsArray) { - const label = labelMap[dataId]; - if (label && label.name) dataValues.push(label.name.toLocaleLowerCase()); - } - break; - case "assignee_ids": - const memberMap = this.rootStore?.memberMap; - if (!memberMap) break; - for (const dataId of dataIdsArray) { - const member = memberMap[dataId]; - if (member && member.first_name) dataValues.push(member.first_name.toLocaleLowerCase()); - } - break; - case "module_ids": - const moduleMap = this.rootStore?.moduleMap; - if (!moduleMap) break; - for (const dataId of dataIdsArray) { - const currentModule = moduleMap[dataId]; - if (currentModule && currentModule.name) dataValues.push(currentModule.name.toLocaleLowerCase()); - } - break; - case "cycle_id": - const cycleMap = this.rootStore?.cycleMap; - if (!cycleMap) break; - for (const dataId of dataIdsArray) { - const cycle = cycleMap[dataId]; - if (cycle && cycle.name) dataValues.push(cycle.name.toLocaleLowerCase()); - } - break; - } - - return isDataIdsArray ? (order ? orderBy(dataValues, undefined, [order]) : dataValues) : dataValues[0]; - } - - /** - * This Method is mainly used to filter out empty values in the beginning - * @param key key of the value that is to be checked if empty - * @param object any object in which the key's value is to be checked - * @returns 1 if empty, 0 if not empty - */ - getSortOrderToFilterEmptyValues(key: string, object: any) { - const value = object?.[key]; - - if (typeof value !== "number" && isEmpty(value)) return 1; - - return 0; - } - - issuesSortWithOrderBy = (issueObject: TIssueMap, key: Partial): TIssue[] => { - let array = values(issueObject); - array = orderBy(array, "created_at"); - - switch (key) { - case "sort_order": - return orderBy(array, "sort_order"); - case "state__name": - return orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue["state_id"])); - case "-state__name": - return orderBy(array, (issue) => this.populateIssueDataForSorting("state_id", issue["state_id"]), ["desc"]); - // dates - case "created_at": - return orderBy(array, (issue) => convertToISODateString(issue["created_at"])); - case "-created_at": - return orderBy(array, (issue) => convertToISODateString(issue["created_at"]), ["desc"]); - case "updated_at": - return orderBy(array, (issue) => convertToISODateString(issue["updated_at"])); - case "-updated_at": - return orderBy(array, (issue) => convertToISODateString(issue["updated_at"]), ["desc"]); - case "start_date": - return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"]); //preferring sorting based on empty values to always keep the empty values below - case "-start_date": - return orderBy( - array, - [this.getSortOrderToFilterEmptyValues.bind(null, "start_date"), "start_date"], //preferring sorting based on empty values to always keep the empty values below - ["asc", "desc"] - ); - - case "target_date": - return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"]); //preferring sorting based on empty values to always keep the empty values below - case "-target_date": - return orderBy( - array, - [this.getSortOrderToFilterEmptyValues.bind(null, "target_date"), "target_date"], //preferring sorting based on empty values to always keep the empty values below - ["asc", "desc"] - ); - - // custom - case "priority": { - const sortArray = ISSUE_PRIORITIES.map((i) => i.key); - return orderBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority), ["desc"]); - } - case "-priority": { - const sortArray = ISSUE_PRIORITIES.map((i) => i.key); - return orderBy(array, (_issue: TIssue) => indexOf(sortArray, _issue.priority)); - } - - // number - case "attachment_count": - return orderBy(array, "attachment_count"); - case "-attachment_count": - return orderBy(array, "attachment_count", ["desc"]); - - case "estimate_point": - return orderBy(array, [this.getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"]); //preferring sorting based on empty values to always keep the empty values below - case "-estimate_point": - return orderBy( - array, - [this.getSortOrderToFilterEmptyValues.bind(null, "estimate_point"), "estimate_point"], //preferring sorting based on empty values to always keep the empty values below - ["asc", "desc"] - ); - - case "link_count": - return orderBy(array, "link_count"); - case "-link_count": - return orderBy(array, "link_count", ["desc"]); - - case "sub_issues_count": - return orderBy(array, "sub_issues_count"); - case "-sub_issues_count": - return orderBy(array, "sub_issues_count", ["desc"]); - - // Array - case "labels__name": - return orderBy(array, [ - this.getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below - (issue) => this.populateIssueDataForSorting("label_ids", issue["label_ids"], "asc"), - ]); - case "-labels__name": - return orderBy( - array, - [ - this.getSortOrderToFilterEmptyValues.bind(null, "label_ids"), //preferring sorting based on empty values to always keep the empty values below - (issue) => this.populateIssueDataForSorting("label_ids", issue["label_ids"], "desc"), - ], - ["asc", "desc"] - ); - - case "modules__name": - return orderBy(array, [ - this.getSortOrderToFilterEmptyValues.bind(null, "module_ids"), //preferring sorting based on empty values to always keep the empty values below - (issue) => this.populateIssueDataForSorting("module_ids", issue["module_ids"], "asc"), - ]); - case "-modules__name": - return orderBy( - array, - [ - this.getSortOrderToFilterEmptyValues.bind(null, "module_ids"), //preferring sorting based on empty values to always keep the empty values below - (issue) => this.populateIssueDataForSorting("module_ids", issue["module_ids"], "desc"), - ], - ["asc", "desc"] - ); - - case "cycle__name": - return orderBy(array, [ - this.getSortOrderToFilterEmptyValues.bind(null, "cycle_id"), //preferring sorting based on empty values to always keep the empty values below - (issue) => this.populateIssueDataForSorting("cycle_id", issue["cycle_id"], "asc"), - ]); - case "-cycle__name": - return orderBy( - array, - [ - this.getSortOrderToFilterEmptyValues.bind(null, "cycle_id"), //preferring sorting based on empty values to always keep the empty values below - (issue) => this.populateIssueDataForSorting("cycle_id", issue["cycle_id"], "desc"), - ], - ["asc", "desc"] - ); - - case "assignees__first_name": - return orderBy(array, [ - this.getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below - (issue) => this.populateIssueDataForSorting("assignee_ids", issue["assignee_ids"], "asc"), - ]); - case "-assignees__first_name": - return orderBy( - array, - [ - this.getSortOrderToFilterEmptyValues.bind(null, "assignee_ids"), //preferring sorting based on empty values to always keep the empty values below - (issue) => this.populateIssueDataForSorting("assignee_ids", issue["assignee_ids"], "desc"), - ], - ["asc", "desc"] - ); - - default: - return array; - } - }; - - getGroupArray(value: boolean | number | string | string[] | null, isDate: boolean = false): string[] { - if (!value || value === null || value === undefined) return ["None"]; - if (Array.isArray(value)) - if (value && value.length) return value; - else return ["None"]; - else if (typeof value === "boolean") return [value ? "True" : "False"]; - else if (typeof value === "number") return [value.toString()]; - else if (isDate) return [renderFormattedPayloadDate(value) || "None"]; - else return [value || "None"]; - } -} diff --git a/web/store/issue/issue-details/sub_issues.store.ts b/web/store/issue/issue-details/sub_issues.store.ts index b43e4d487..0bbdffe49 100644 --- a/web/store/issue/issue-details/sub_issues.store.ts +++ b/web/store/issue/issue-details/sub_issues.store.ts @@ -122,7 +122,9 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { // fetch other issues states and members when sub-issues are from different project if (subIssues && subIssues.length > 0) { - const otherProjectIds = uniq(subIssues.map((issue) => issue.project_id).filter((id) => id !== projectId)); + const otherProjectIds = uniq( + subIssues.map((issue) => issue.project_id).filter((id) => !!id && id !== projectId) + ) as string[]; this.fetchOtherProjectProperties(workspaceSlug, otherProjectIds); } @@ -152,7 +154,9 @@ export class IssueSubIssuesStore implements IIssueSubIssuesStore { // fetch other issues states and members when sub-issues are from different project if (subIssues && subIssues.length > 0) { - const otherProjectIds = uniq(subIssues.map((issue) => issue.project_id).filter((id) => id !== projectId)); + const otherProjectIds = uniq( + subIssues.map((issue) => issue.project_id).filter((id) => !!id && id !== projectId) + ) as string[]; this.fetchOtherProjectProperties(workspaceSlug, otherProjectIds); } diff --git a/web/store/issue/issue.store.ts b/web/store/issue/issue.store.ts index e6418c11d..6b5154d57 100644 --- a/web/store/issue/issue.store.ts +++ b/web/store/issue/issue.store.ts @@ -19,7 +19,7 @@ export type IIssueStore = { removeIssue(issueId: string): void; // helper methods getIssueById(issueId: string): undefined | TIssue; - getIssuesByIds(issueIds: string[], type: "archived" | "un-archived"): undefined | Record; // Record defines issue_id as key and TIssue as value + getIssuesByIds(issueIds: string[], type: "archived" | "un-archived"): TIssue[]; // Record defines issue_id as key and TIssue as value }; export class IssueStore implements IIssueStore { @@ -114,15 +114,16 @@ export class IssueStore implements IIssueStore { * @returns {Record | undefined} */ getIssuesByIds = computedFn((issueIds: string[], type: "archived" | "un-archived") => { - if (!issueIds || issueIds.length <= 0 || isEmpty(this.issuesMap)) return undefined; - const filteredIssues: { [key: string]: TIssue } = {}; - Object.values(this.issuesMap).forEach((issue) => { + if (!issueIds || issueIds.length <= 0 || isEmpty(this.issuesMap)) return []; + const filteredIssues: TIssue[] = []; + Object.values(issueIds).forEach((issueId) => { // if type is archived then check archived_at is not null // if type is un-archived then check archived_at is null - if ((type === "archived" && issue.archived_at) || (type === "un-archived" && !issue.archived_at)) { - if (issueIds.includes(issue.id)) filteredIssues[issue.id] = issue; + const issue = this.issuesMap[issueId]; + if ((issue && type === "archived" && issue.archived_at) || (type === "un-archived" && !issue?.archived_at)) { + filteredIssues.push(issue); } }); - return isEmpty(filteredIssues) ? undefined : filteredIssues; + return filteredIssues; }); } diff --git a/web/store/issue/issue_calendar_view.store.ts b/web/store/issue/issue_calendar_view.store.ts index 08c7efb4f..ddf8cae67 100644 --- a/web/store/issue/issue_calendar_view.store.ts +++ b/web/store/issue/issue_calendar_view.store.ts @@ -5,6 +5,7 @@ import { ICalendarPayload, ICalendarWeek } from "@/components/issues"; import { generateCalendarData } from "@/helpers/calendar.helper"; // types import { getWeekNumberOfDate } from "@/helpers/date-time.helper"; +import { computedFn } from "mobx-utils"; export interface ICalendarStore { calendarFilters: { @@ -25,6 +26,7 @@ export interface ICalendarStore { | undefined; activeWeekNumber: number; allDaysOfActiveWeek: ICalendarWeek | undefined; + getStartAndEndDate: (layout: "week" | "month") => { startDate: string; endDate: string } | undefined; } export class CalendarStore implements ICalendarStore { @@ -82,6 +84,22 @@ export class CalendarStore implements ICalendarStore { ]; } + getStartAndEndDate = computedFn((layout: "week" | "month") => { + switch (layout) { + case "week": + if (!this.allDaysOfActiveWeek) return; + const dates = Object.keys(this.allDaysOfActiveWeek); + return { startDate: dates[0], endDate: dates[dates.length - 1] }; + case "month": + if (!this.allWeeksOfActiveMonth) return; + const weeks = Object.keys(this.allWeeksOfActiveMonth); + const firstWeekDates = Object.keys(this.allWeeksOfActiveMonth[weeks[0]]); + const lastWeekDates = Object.keys(this.allWeeksOfActiveMonth[weeks[weeks.length - 1]]); + + return { startDate: firstWeekDates[0], endDate: lastWeekDates[lastWeekDates.length - 1] }; + } + }); + updateCalendarFilters = (filters: Partial<{ activeMonthDate: Date; activeWeekDate: Date }>) => { this.updateCalendarPayload(filters.activeMonthDate || filters.activeWeekDate || new Date()); diff --git a/web/store/issue/module/filter.store.ts b/web/store/issue/module/filter.store.ts index 9a3891002..297320f1e 100644 --- a/web/store/issue/module/filter.store.ts +++ b/web/store/issue/module/filter.store.ts @@ -14,20 +14,24 @@ import { TIssueKanbanFilters, IIssueFilters, TIssueParams, + IssuePaginationOptions, } from "@plane/types"; -import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; +import { IBaseIssueFilterStore, IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers // types import { IIssueRootStore } from "../root.store"; +import { computedFn } from "mobx-utils"; // constants // services -export interface IModuleIssuesFilter { - // observables - filters: Record; // Record defines moduleId as key and IIssueFilters as value - // computed - issueFilters: IIssueFilters | undefined; - appliedFilters: Partial> | undefined; +export interface IModuleIssuesFilter extends IBaseIssueFilterStore { + //helper actions + getFilterParams: ( + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => Partial>; // action fetchFilters: (workspaceSlug: string, projectId: string, moduleId: string) => Promise; updateFilters: ( @@ -95,6 +99,20 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul return filteredRouteParams; } + getFilterParams = computedFn( + ( + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => { + const filterParams = this.appliedFilters; + + const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId); + return paginationParams; + } + ); + fetchFilters = async (workspaceSlug: string, projectId: string, moduleId: string) => { try { const _filters = await this.issueFilterService.fetchModuleIssueFilters(workspaceSlug, projectId, moduleId); @@ -160,7 +178,7 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul }); const appliedFilters = _filters.filters || {}; const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); - this.rootIssueStore.moduleIssues.fetchIssues( + this.rootIssueStore.moduleIssues.fetchIssuesWithExistingPagination( workspaceSlug, projectId, isEmpty(filteredFilters) ? "init-loader" : "mutation", @@ -203,8 +221,12 @@ export class ModuleIssuesFilter extends IssueFilterHelperStore implements IModul }); }); - if (this.requiresServerUpdate(updatedDisplayFilters)) - this.rootIssueStore.moduleIssues.fetchIssues(workspaceSlug, projectId, "mutation", moduleId); + this.rootIssueStore.moduleIssues.fetchIssuesWithExistingPagination( + workspaceSlug, + projectId, + "mutation", + moduleId + ); await this.issueFilterService.patchModuleIssueFilters(workspaceSlug, projectId, moduleId, { display_filters: _filters.displayFilters, diff --git a/web/store/issue/module/issue.store.ts b/web/store/issue/module/issue.store.ts index 741f5ea19..e9f24fa9f 100644 --- a/web/store/issue/module/issue.store.ts +++ b/web/store/issue/module/issue.store.ts @@ -1,388 +1,210 @@ -import concat from "lodash/concat"; -import pull from "lodash/pull"; -import set from "lodash/set"; -import uniq from "lodash/uniq"; -import update from "lodash/update"; -import { action, observable, makeObservable, computed, runInAction } from "mobx"; -// types -import { TIssue, TLoader, TGroupedIssues, TSubGroupedIssues, TUnGroupedIssues, ViewFlags } from "@plane/types"; -// helpers -import { issueCountBasedOnFilters } from "@/helpers/issue.helper"; +import { action, makeObservable, runInAction } from "mobx"; +// base class +import { BaseIssuesStore, IBaseIssuesStore } from "../helpers/base-issues.store"; // services -import { IssueService } from "@/services/issue"; -import { ModuleService } from "@/services/module.service"; -// helpers -import { IssueHelperStore } from "../helpers/issue-helper.store"; +// types +import { + TIssue, + TLoader, + ViewFlags, + IssuePaginationOptions, + TIssuesResponse, + TBulkOperationsPayload, +} from "@plane/types"; // store import { IIssueRootStore } from "../root.store"; +import { IModuleIssuesFilter } from "./filter.store"; -export interface IModuleIssues { - // observable - loader: TLoader; - issues: { [module_id: string]: string[] }; +export interface IModuleIssues extends IBaseIssuesStore { viewFlags: ViewFlags; - // computed - issuesCount: number; - groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; // actions getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; fetchIssues: ( workspaceSlug: string, projectId: string, loadType: TLoader, + options: IssuePaginationOptions, moduleId: string - ) => Promise; - createIssue: ( + ) => Promise; + fetchIssuesWithExistingPagination: ( workspaceSlug: string, projectId: string, - data: Partial, + loadType: TLoader, moduleId: string - ) => Promise; - updateIssue: ( + ) => Promise; + fetchNextIssues: ( workspaceSlug: string, projectId: string, - issueId: string, - data: Partial, - moduleId: string - ) => Promise; - removeIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleId: string) => Promise; - archiveIssue: (workspaceSlug: string, projectId: string, issueId: string, moduleId: string) => Promise; + moduleId: string, + groupId?: string, + subGroupId?: string + ) => Promise; + + createIssue: (workspaceSlug: string, projectId: string, data: Partial, moduleId: string) => Promise; + updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; + archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; quickAddIssue: ( workspaceSlug: string, projectId: string, data: TIssue, - moduleId?: string | undefined + moduleId: string ) => Promise; - addIssuesToModule: ( - workspaceSlug: string, - projectId: string, - moduleId: string, - issueIds: string[], - fetchAddedIssues?: boolean - ) => Promise; - removeIssuesFromModule: ( - workspaceSlug: string, - projectId: string, - moduleId: string, - issueIds: string[] - ) => Promise; - changeModulesInIssue: ( - workspaceSlug: string, - projectId: string, - issueId: string, - addModuleIds: string[], - removeModuleIds: string[] - ) => Promise; + removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise; } -export class ModuleIssues extends IssueHelperStore implements IModuleIssues { - loader: TLoader = "init-loader"; - issues: { [module_id: string]: string[] } = {}; +export class ModuleIssues extends BaseIssuesStore implements IModuleIssues { viewFlags = { enableQuickAdd: true, enableIssueCreation: true, enableInlineEditing: true, }; - // root store - rootIssueStore: IIssueRootStore; - // service - moduleService; - issueService; + // filter store + issueFilterStore: IModuleIssuesFilter; - constructor(_rootStore: IIssueRootStore) { - super(_rootStore); + constructor(_rootStore: IIssueRootStore, issueFilterStore: IModuleIssuesFilter) { + super(_rootStore, issueFilterStore); makeObservable(this, { - // observable - loader: observable.ref, - issues: observable, - // computed - issuesCount: computed, - groupedIssueIds: computed, // action fetchIssues: action, - createIssue: action, - updateIssue: action, - removeIssue: action, - archiveIssue: action, + fetchNextIssues: action, + fetchIssuesWithExistingPagination: action, + quickAddIssue: action, - addIssuesToModule: action, - removeIssuesFromModule: action, - changeModulesInIssue: action, }); - - this.rootIssueStore = _rootStore; - this.issueService = new IssueService(); - this.moduleService = new ModuleService(); + // filter store + this.issueFilterStore = issueFilterStore; } - get issuesCount() { - let issuesCount = 0; - - const displayFilters = this.rootIssueStore?.moduleIssuesFilter?.issueFilters?.displayFilters; - const groupedIssueIds = this.groupedIssueIds; - if (!displayFilters || !groupedIssueIds) return issuesCount; - - const layout = displayFilters?.layout || undefined; - const groupBy = displayFilters?.group_by || undefined; - const subGroupBy = displayFilters?.sub_group_by || undefined; - - if (!layout) return issuesCount; - issuesCount = issueCountBasedOnFilters(groupedIssueIds, layout, groupBy, subGroupBy); - return issuesCount; - } - - get groupedIssueIds() { - const moduleId = this.rootIssueStore?.moduleId; - if (!moduleId) return undefined; - - const displayFilters = this.rootIssueStore?.moduleIssuesFilter?.issueFilters?.displayFilters; - if (!displayFilters) return undefined; - - const subGroupBy = displayFilters?.sub_group_by; - const groupBy = displayFilters?.group_by; - const orderBy = displayFilters?.order_by; - const layout = displayFilters?.layout; - - const moduleIssueIds = this.issues[moduleId]; - if (!moduleIssueIds) return; - - const currentIssues = this.rootIssueStore.issues.getIssuesByIds(moduleIssueIds, "un-archived"); - if (!currentIssues) return []; - - let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues = []; - - if (layout === "list" && orderBy) { - if (groupBy) issues = this.groupedIssues(groupBy, orderBy, currentIssues); - else issues = this.unGroupedIssues(orderBy, currentIssues); - } else if (layout === "kanban" && groupBy && orderBy) { - if (subGroupBy) issues = this.subGroupedIssues(subGroupBy, groupBy, orderBy, currentIssues); - else issues = this.groupedIssues(groupBy, orderBy, currentIssues); - } else if (layout === "calendar") issues = this.groupedIssues("target_date", "target_date", currentIssues, true); - else if (layout === "spreadsheet") issues = this.unGroupedIssues(orderBy ?? "-created_at", currentIssues); - else if (layout === "gantt_chart") issues = this.unGroupedIssues(orderBy ?? "sort_order", currentIssues); - - return issues; - } - - getIssueIds = (groupId?: string, subGroupId?: string) => { - const groupedIssueIds = this.groupedIssueIds; - - const displayFilters = this.rootIssueStore?.moduleIssuesFilter?.issueFilters?.displayFilters; - if (!displayFilters || !groupedIssueIds) return undefined; - - const subGroupBy = displayFilters?.sub_group_by; - const groupBy = displayFilters?.group_by; - - if (!groupBy && !subGroupBy) { - return groupedIssueIds as string[]; - } - - if (groupBy && subGroupBy && groupId && subGroupId) { - return (groupedIssueIds as TSubGroupedIssues)?.[subGroupId]?.[groupId] as string[]; - } - - if (groupBy && groupId) { - return (groupedIssueIds as TGroupedIssues)?.[groupId] as string[]; - } - - return undefined; + /** + * Fetches the module details + * @param workspaceSlug + * @param projectId + * @param id is the module Id + */ + fetchParentStats = (workspaceSlug: string, projectId?: string | undefined, id?: string | undefined) => { + const moduleId = id ?? this.moduleId; + projectId && + moduleId && + this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); }; + /** + * This method is called to fetch the first issues of pagination + * @param workspaceSlug + * @param projectId + * @param loadType + * @param options + * @param moduleId + * @returns + */ fetchIssues = async ( workspaceSlug: string, projectId: string, - loadType: TLoader = "init-loader", + loadType: TLoader, + options: IssuePaginationOptions, moduleId: string ) => { try { - this.loader = loadType; + // set loader and clear store + runInAction(() => { + this.setLoader(loadType); + }); + this.clear(); - const params = this.rootIssueStore?.moduleIssuesFilter?.appliedFilters; + // get params from pagination options + const params = this.issueFilterStore?.getFilterParams(options, undefined, undefined, undefined); + // call the fetch issues API with the params const response = await this.moduleService.getModuleIssues(workspaceSlug, projectId, moduleId, params); - this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); - - runInAction(() => { - set( - this.issues, - [moduleId], - response.map((issue) => issue.id) - ); - this.loader = undefined; - }); - - this.rootIssueStore.issues.addIssue(response); + // after fetching issues, call the base method to process the response further + this.onfetchIssues(response, options, workspaceSlug, projectId, moduleId); return response; } catch (error) { - console.error(error); - this.loader = undefined; + // set loader to undefined once errored out + this.setLoader(undefined); throw error; } }; - createIssue = async (workspaceSlug: string, projectId: string, data: Partial, moduleId: string) => { - try { - const response = await this.rootIssueStore.projectIssues.createIssue(workspaceSlug, projectId, data); - await this.addIssuesToModule(workspaceSlug, projectId, moduleId, [response.id], false); - this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); - - return response; - } catch (error) { - throw error; - } - }; - - updateIssue = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - data: Partial, - moduleId: string - ) => { - try { - await this.rootIssueStore.projectIssues.updateIssue(workspaceSlug, projectId, issueId, data); - this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); - } catch (error) { - this.fetchIssues(workspaceSlug, projectId, "mutation", moduleId); - throw error; - } - }; - - removeIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleId: string) => { - try { - await this.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); - this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); - - const issueIndex = this.issues[moduleId].findIndex((_issueId) => _issueId === issueId); - if (issueIndex >= 0) - runInAction(() => { - this.issues[moduleId].splice(issueIndex, 1); - }); - } catch (error) { - throw error; - } - }; - - archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string, moduleId: string) => { - try { - await this.rootIssueStore.projectIssues.archiveIssue(workspaceSlug, projectId, issueId); - this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); - - runInAction(() => { - pull(this.issues[moduleId], issueId); - }); - } catch (error) { - throw error; - } - }; - - quickAddIssue = async ( - workspaceSlug: string, - projectId: string, - data: TIssue, - moduleId: string | undefined = undefined - ) => { - try { - if (!moduleId) throw new Error("Module Id is required"); - - runInAction(() => { - this.issues[moduleId].push(data.id); - this.rootIssueStore.issues.addIssue([data]); - }); - - const response = await this.createIssue(workspaceSlug, projectId, data, moduleId); - - const quickAddIssueIndex = this.issues[moduleId].findIndex((_issueId) => _issueId === data.id); - if (quickAddIssueIndex >= 0) { - runInAction(() => { - this.issues[moduleId].splice(quickAddIssueIndex, 1); - this.rootIssueStore.issues.removeIssue(data.id); - }); - } - - const currentCycleId = data.cycle_id !== "" && data.cycle_id === "None" ? undefined : data.cycle_id; - if (currentCycleId) { - await this.rootStore.cycleIssues.addCycleToIssue(workspaceSlug, projectId, currentCycleId, response.id); - } - - this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); - - return response; - } catch (error) { - throw error; - } - }; - - addIssuesToModule = async ( + /** + * This method is called subsequent pages of pagination + * if groupId/subgroupId is provided, only that specific group's next page is fetched + * else all the groups' next page is fetched + * @param workspaceSlug + * @param projectId + * @param moduleId + * @param groupId + * @param subGroupId + * @returns + */ + fetchNextIssues = async ( workspaceSlug: string, projectId: string, moduleId: string, - issueIds: string[], - fetchAddedIssues = true + groupId?: string, + subGroupId?: string ) => { + const cursorObject = this.getPaginationData(groupId, subGroupId); + // if there are no pagination options and the next page results do not exist the return + if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return; try { - // add the new issue ids to the module issues map - runInAction(() => { - update(this.issues, moduleId, (moduleIssueIds = []) => { - if (!moduleIssueIds) return [...issueIds]; - else return uniq(concat(moduleIssueIds, issueIds)); - }); - }); - // update the root issue map with the new module ids - issueIds.forEach((issueId) => { - update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => { - if (issueModuleIds.includes(moduleId)) return issueModuleIds; - else return uniq(concat(issueModuleIds, [moduleId])); - }); - }); + // set Loader + this.setLoader("pagination", groupId, subGroupId); - await this.moduleService.addIssuesToModule(workspaceSlug, projectId, moduleId, { - issues: issueIds, - }); + // get params from stored pagination options + const params = this.issueFilterStore?.getFilterParams( + this.paginationOptions, + cursorObject?.nextCursor, + groupId, + subGroupId + ); + // call the fetch issues API with the params for next page in issues + const response = await this.moduleService.getModuleIssues(workspaceSlug, projectId, moduleId, params); - if (fetchAddedIssues) await this.rootIssueStore.issues.getIssues(workspaceSlug, projectId, issueIds); - - this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); + // after the next page of issues are fetched, call the base method to process the response + this.onfetchNexIssues(response, groupId, subGroupId); + return response; } catch (error) { - issueIds.forEach((issueId) => { - runInAction(() => { - // remove the new issue ids from the module issues map - pull(this.issues[moduleId], issueId); - // remove the new module ids from the root issue map - update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => - pull(issueModuleIds, moduleId) - ); - }); - }); + // set Loader as undefined if errored out + this.setLoader(undefined, groupId, subGroupId); throw error; } }; - removeIssuesFromModule = async (workspaceSlug: string, projectId: string, moduleId: string, issueIds: string[]) => { + /** + * This Method exists to fetch the first page of the issues with the existing stored pagination + * This is useful for refetching when filters, groupBy, orderBy etc changes + * @param workspaceSlug + * @param projectId + * @param loadType + * @param moduleId + * @returns + */ + fetchIssuesWithExistingPagination = async ( + workspaceSlug: string, + projectId: string, + loadType: TLoader, + moduleId: string + ) => { + if (!this.paginationOptions) return; + return await this.fetchIssues(workspaceSlug, projectId, loadType, this.paginationOptions, moduleId); + }; + + /** + * Override inherited create issue, to also add issue to module + * @param workspaceSlug + * @param projectId + * @param data + * @param moduleId + * @returns + */ + override createIssue = async (workspaceSlug: string, projectId: string, data: Partial, moduleId: string) => { try { - runInAction(() => { - issueIds.forEach((issueId) => { - pull(this.issues[moduleId], issueId); - }); - }); - - runInAction(() => { - issueIds.forEach((issueId) => { - update(this.rootStore.issues.issuesMap, [issueId, "module_ids"], (issueModuleIds = []) => { - if (issueModuleIds.includes(moduleId)) return pull(issueModuleIds, moduleId); - else return uniq(concat(issueModuleIds, [moduleId])); - }); - }); - }); - - const response = await this.moduleService.removeIssuesFromModuleBulk( - workspaceSlug, - projectId, - moduleId, - issueIds - ); - this.rootIssueStore.rootStore.module.fetchModuleDetails(workspaceSlug, projectId, moduleId); + const response = await super.createIssue(workspaceSlug, projectId, data, moduleId, false); + await this.addIssuesToModule(workspaceSlug, projectId, moduleId, [response.id], false); return response; } catch (error) { @@ -391,72 +213,35 @@ export class ModuleIssues extends IssueHelperStore implements IModuleIssues { }; /** - * change modules array in issue + * This Method overrides the base quickAdd issue * @param workspaceSlug * @param projectId - * @param issueId - * @param addModuleIds array of modules to be added - * @param removeModuleIds array of modules to be removed + * @param data + * @param moduleId + * @returns */ - changeModulesInIssue = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - addModuleIds: string[], - removeModuleIds: string[] - ) => { - // keep a copy of the original module ids - const originalModuleIds = this.rootStore.issues.issuesMap[issueId]?.module_ids - ? [...this.rootStore.issues.issuesMap[issueId].module_ids!] - : []; + quickAddIssue = async (workspaceSlug: string, projectId: string, data: TIssue, moduleId: string) => { try { + // add temporary issue to store list + this.addIssue(data); + + // call overridden create issue + const response = await this.createIssue(workspaceSlug, projectId, data, moduleId); + + // remove temp Issue from store list runInAction(() => { - // remove the new issue id to the module issues map - removeModuleIds.forEach((moduleId) => { - update(this.issues, moduleId, (moduleIssueIds = []) => { - if (moduleIssueIds.includes(issueId)) return pull(moduleIssueIds, issueId); - else return moduleIssueIds; - }); - }); - // add the new issue id to the module issues map - addModuleIds.forEach((moduleId) => { - update(this.issues, moduleId, (moduleIssueIds = []) => { - if (moduleIssueIds.includes(issueId)) return moduleIssueIds; - else return uniq(concat(moduleIssueIds, [issueId])); - }); - }); + this.removeIssueFromList(data.id); + this.rootIssueStore.issues.removeIssue(data.id); }); - if (originalModuleIds) { - // update the root issue map with the new module ids - let currentModuleIds = concat([...originalModuleIds], addModuleIds); - currentModuleIds = pull(currentModuleIds, ...removeModuleIds); - this.rootStore.issues.updateIssue(issueId, { module_ids: uniq(currentModuleIds) }); + + if (data.cycle_id && data.cycle_id !== "") { + await this.addCycleToIssue(workspaceSlug, projectId, data.cycle_id, response.id); } - - //Perform API call - await this.moduleService.addModulesToIssue(workspaceSlug, projectId, issueId, { - modules: addModuleIds, - removed_modules: removeModuleIds, - }); + return response; } catch (error) { - // revert the issue back to its original module ids - set(this.rootStore.issues.issuesMap, [issueId, "module_ids"], originalModuleIds); - // add the removed issue id to the module issues map - addModuleIds.forEach((moduleId) => { - update(this.issues, moduleId, (moduleIssueIds = []) => { - if (moduleIssueIds.includes(issueId)) return pull(moduleIssueIds, issueId); - else return moduleIssueIds; - }); - }); - // remove the added issue id to the module issues map - removeModuleIds.forEach((moduleId) => { - update(this.issues, moduleId, (moduleIssueIds = []) => { - if (moduleIssueIds.includes(issueId)) return moduleIssueIds; - else return uniq(concat(moduleIssueIds, [issueId])); - }); - }); - throw error; } }; + + archiveBulkIssues = this.bulkArchiveIssues; } diff --git a/web/store/issue/profile/filter.store.ts b/web/store/issue/profile/filter.store.ts index 6e0147fb4..a13933fd7 100644 --- a/web/store/issue/profile/filter.store.ts +++ b/web/store/issue/profile/filter.store.ts @@ -14,21 +14,26 @@ import { TIssueKanbanFilters, IIssueFilters, TIssueParams, + IssuePaginationOptions, } from "@plane/types"; -import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; +import { IBaseIssueFilterStore, IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers // types import { IIssueRootStore } from "../root.store"; +import { computedFn } from "mobx-utils"; // constants // services -export interface IProfileIssuesFilter { +export interface IProfileIssuesFilter extends IBaseIssueFilterStore { // observables userId: string; - filters: Record; // Record defines userId as key and IIssueFilters as value - // computed - issueFilters: IIssueFilters | undefined; - appliedFilters: Partial> | undefined; + //helper actions + getFilterParams: ( + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => Partial>; // action fetchFilters: (workspaceSlug: string, userId: string) => Promise; updateFilters: ( @@ -96,6 +101,20 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf return filteredRouteParams; } + getFilterParams = computedFn( + ( + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => { + const filterParams = this.appliedFilters; + + const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId); + return paginationParams; + } + ); + fetchFilters = async (workspaceSlug: string, userId: string) => { try { this.userId = userId; @@ -150,12 +169,10 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf const appliedFilters = _filters.filters || {}; const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); - this.rootIssueStore.profileIssues.fetchIssues( + this.rootIssueStore.profileIssues.fetchIssuesWithExistingPagination( workspaceSlug, - undefined, - isEmpty(filteredFilters) ? "init-loader" : "mutation", userId, - this.rootIssueStore.profileIssues.currentView + isEmpty(filteredFilters) ? "init-loader" : "mutation" ); this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, { @@ -195,14 +212,7 @@ export class ProfileIssuesFilter extends IssueFilterHelperStore implements IProf }); }); - if (this.requiresServerUpdate(updatedDisplayFilters)) - this.rootIssueStore.profileIssues.fetchIssues( - workspaceSlug, - undefined, - "mutation", - userId, - this.rootIssueStore.profileIssues.currentView - ); + this.rootIssueStore.profileIssues.fetchIssuesWithExistingPagination(workspaceSlug, userId, "mutation"); this.handleIssuesLocalFilters.set(EIssuesStoreType.PROFILE, type, workspaceSlug, userId, undefined, { display_filters: _filters.displayFilters, diff --git a/web/store/issue/profile/issue.store.ts b/web/store/issue/profile/issue.store.ts index 9a754b4f5..91a49ea32 100644 --- a/web/store/issue/profile/issue.store.ts +++ b/web/store/issue/profile/issue.store.ts @@ -1,148 +1,76 @@ -import pull from "lodash/pull"; -import set from "lodash/set"; import { action, observable, makeObservable, computed, runInAction } from "mobx"; // base class import { UserService } from "@/services/user.service"; -import { TIssue, TLoader, TGroupedIssues, TSubGroupedIssues, TUnGroupedIssues, ViewFlags } from "@plane/types"; -import { IssueHelperStore } from "../helpers/issue-helper.store"; +import { TIssue, TLoader, IssuePaginationOptions, TIssuesResponse, ViewFlags, TBulkOperationsPayload } from "@plane/types"; + // services // types import { IIssueRootStore } from "../root.store"; +import { IProfileIssuesFilter } from "./filter.store"; +import { BaseIssuesStore, IBaseIssuesStore } from "../helpers/base-issues.store"; -interface IProfileIssueTabTypes { - [key: string]: string[]; -} - -export interface IProfileIssues { +export interface IProfileIssues extends IBaseIssuesStore { // observable - loader: TLoader; currentView: "assigned" | "created" | "subscribed"; - issues: { [userId: string]: IProfileIssueTabTypes }; - // computed - groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; viewFlags: ViewFlags; // actions setViewId: (viewId: "assigned" | "created" | "subscribed") => void; - getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; + // action fetchIssues: ( workspaceSlug: string, - projectId: string | undefined, - loadType: TLoader, userId: string, - view?: "assigned" | "created" | "subscribed" - ) => Promise; - createIssue: ( + loadType: TLoader, + option: IssuePaginationOptions, + view: "assigned" | "created" | "subscribed" + ) => Promise; + fetchIssuesWithExistingPagination: ( workspaceSlug: string, - projectId: string, - data: Partial, - userId: string - ) => Promise; - updateIssue: ( + userId: string, + loadType: TLoader + ) => Promise; + fetchNextIssues: ( workspaceSlug: string, - projectId: string, - issueId: string, - data: Partial, - userId: string - ) => Promise; - removeIssue: (workspaceSlug: string, projectId: string, issueId: string, userId: string) => Promise; - archiveIssue: (workspaceSlug: string, projectId: string, issueId: string, userId: string) => Promise; + userId: string, + groupId?: string, + subGroupId?: string + ) => Promise; + + createIssue: (workspaceSlug: string, projectId: string, data: Partial) => Promise; + updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; + archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise; + quickAddIssue: undefined; } -export class ProfileIssues extends IssueHelperStore implements IProfileIssues { - loader: TLoader = "init-loader"; +export class ProfileIssues extends BaseIssuesStore implements IProfileIssues { currentView: "assigned" | "created" | "subscribed" = "assigned"; - issues: { [userId: string]: IProfileIssueTabTypes } = {}; - quickAddIssue = undefined; - // root store - rootIssueStore: IIssueRootStore; + // filter store + issueFilterStore: IProfileIssuesFilter; // services userService; - constructor(_rootStore: IIssueRootStore) { - super(_rootStore); + constructor(_rootStore: IIssueRootStore, issueFilterStore: IProfileIssuesFilter) { + super(_rootStore, issueFilterStore); makeObservable(this, { // observable - loader: observable.ref, currentView: observable.ref, - issues: observable, // computed - groupedIssueIds: computed, viewFlags: computed, // action setViewId: action.bound, fetchIssues: action, - createIssue: action, - updateIssue: action, - removeIssue: action, - archiveIssue: action, + fetchNextIssues: action, + fetchIssuesWithExistingPagination: action, }); - // root store - this.rootIssueStore = _rootStore; + // filter store + this.issueFilterStore = issueFilterStore; // services this.userService = new UserService(); } - get groupedIssueIds() { - const userId = this.rootIssueStore.userId; - const workspaceSlug = this.rootIssueStore.workspaceSlug; - const currentView = this.currentView; - if (!userId || !currentView || !workspaceSlug) return undefined; - - const uniqueViewId = `${workspaceSlug}_${currentView}`; - - const displayFilters = this.rootIssueStore?.profileIssuesFilter?.issueFilters?.displayFilters; - if (!displayFilters) return undefined; - - const subGroupBy = displayFilters?.sub_group_by; - const groupBy = displayFilters?.group_by; - const orderBy = displayFilters?.order_by; - const layout = displayFilters?.layout; - - const userIssueIds = this.issues[userId]?.[uniqueViewId]; - - if (!userIssueIds) return; - - const _issues = this.rootStore.issues.getIssuesByIds(userIssueIds, "un-archived"); - if (!_issues) return []; - - let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined = undefined; - - if (layout === "list" && orderBy) { - if (groupBy) issues = this.groupedIssues(groupBy, orderBy, _issues); - else issues = this.unGroupedIssues(orderBy, _issues); - } else if (layout === "kanban" && groupBy && orderBy) { - if (subGroupBy) issues = this.subGroupedIssues(subGroupBy, groupBy, orderBy, _issues); - else issues = this.groupedIssues(groupBy, orderBy, _issues); - } - - return issues; - } - - getIssueIds = (groupId?: string, subGroupId?: string) => { - const groupedIssueIds = this.groupedIssueIds; - - const displayFilters = this.rootStore?.projectIssuesFilter?.issueFilters?.displayFilters; - if (!displayFilters || !groupedIssueIds) return undefined; - - const subGroupBy = displayFilters?.sub_group_by; - const groupBy = displayFilters?.group_by; - - if (!groupBy && !subGroupBy) { - return groupedIssueIds as string[]; - } - - if (groupBy && subGroupBy && groupId && subGroupId) { - return (groupedIssueIds as TSubGroupedIssues)?.[subGroupId]?.[groupId] as string[]; - } - - if (groupBy && groupId) { - return (groupedIssueIds as TGroupedIssues)?.[groupId] as string[]; - } - - return undefined; - }; - get viewFlags() { if (this.currentView === "subscribed") return { @@ -161,22 +89,85 @@ export class ProfileIssues extends IssueHelperStore implements IProfileIssues { this.currentView = viewId; } + fetchParentStats = () => {}; + + /** + * This method is called to fetch the first issues of pagination + * @param workspaceSlug + * @param userId + * @param loadType + * @param options + * @param view + * @returns + */ fetchIssues = async ( workspaceSlug: string, - projectId: string | undefined, - loadType: TLoader = "init-loader", userId: string, - view?: "assigned" | "created" | "subscribed" + loadType: TLoader, + options: IssuePaginationOptions, + view: "assigned" | "created" | "subscribed" ) => { try { - this.loader = loadType; - if (view) this.currentView = view; + // set loader and clear store + runInAction(() => { + this.setLoader(loadType); + }); + this.clear(); - if (!this.currentView) throw new Error("current tab view is required"); + // set ViewId + this.setViewId(view); - const uniqueViewId = `${workspaceSlug}_${view}`; + // get params from pagination options + let params = this.issueFilterStore?.getFilterParams(options, undefined, undefined, undefined); + params = { + ...params, + assignees: undefined, + created_by: undefined, + subscriber: undefined, + }; + // modify params based on view + if (this.currentView === "assigned") params = { ...params, assignees: userId }; + else if (this.currentView === "created") params = { ...params, created_by: userId }; + else if (this.currentView === "subscribed") params = { ...params, subscriber: userId }; - let params: any = this.rootIssueStore?.profileIssuesFilter?.appliedFilters; + // call the fetch issues API with the params + const response = await this.userService.getUserProfileIssues(workspaceSlug, userId, params); + + // after fetching issues, call the base method to process the response further + this.onfetchIssues(response, options, workspaceSlug); + return response; + } catch (error) { + // set loader to undefined if errored out + this.setLoader(undefined); + throw error; + } + }; + + /** + * This method is called subsequent pages of pagination + * if groupId/subgroupId is provided, only that specific group's next page is fetched + * else all the groups' next page is fetched + * @param workspaceSlug + * @param userId + * @param groupId + * @param subGroupId + * @returns + */ + fetchNextIssues = async (workspaceSlug: string, userId: string, groupId?: string, subGroupId?: string) => { + const cursorObject = this.getPaginationData(groupId, subGroupId); + // if there are no pagination options and the next page results do not exist the return + if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return; + try { + // set Loader + this.setLoader("pagination", groupId, subGroupId); + + // get params from stored pagination options + let params = this.issueFilterStore?.getFilterParams( + this.paginationOptions, + cursorObject?.nextCursor, + groupId, + subGroupId + ); params = { ...params, assignees: undefined, @@ -187,93 +178,32 @@ export class ProfileIssues extends IssueHelperStore implements IProfileIssues { else if (this.currentView === "created") params = { ...params, created_by: userId }; else if (this.currentView === "subscribed") params = { ...params, subscriber: userId }; + // call the fetch issues API with the params for next page in issues const response = await this.userService.getUserProfileIssues(workspaceSlug, userId, params); - runInAction(() => { - set( - this.issues, - [userId, uniqueViewId], - response.map((issue) => issue.id) - ); - this.loader = undefined; - }); - - this.rootIssueStore.issues.addIssue(response); - + // after the next page of issues are fetched, call the base method to process the response + this.onfetchNexIssues(response, groupId, subGroupId); return response; } catch (error) { - this.loader = undefined; + // set Loader as undefined if errored out + this.setLoader(undefined, groupId, subGroupId); throw error; } }; - createIssue = async (workspaceSlug: string, projectId: string, data: Partial, userId: string) => { - try { - const response = await this.rootIssueStore.projectIssues.createIssue(workspaceSlug, projectId, data); - - const uniqueViewId = `${workspaceSlug}_${this.currentView}`; - - runInAction(() => { - this.issues[userId][uniqueViewId].push(response.id); - }); - - this.rootStore.issues.addIssue([response]); - - return response; - } catch (error) { - throw error; - } + /** + * This Method exists to fetch the first page of the issues with the existing stored pagination + * This is useful for refetching when filters, groupBy, orderBy etc changes + * @param workspaceSlug + * @param userId + * @param loadType + * @returns + */ + fetchIssuesWithExistingPagination = async (workspaceSlug: string, userId: string, loadType: TLoader) => { + if (!this.paginationOptions || !this.currentView) return; + return await this.fetchIssues(workspaceSlug, userId, loadType, this.paginationOptions, this.currentView); }; - updateIssue = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - data: Partial, - userId: string - ) => { - try { - this.rootStore.issues.updateIssue(issueId, data); - await this.rootIssueStore.projectIssues.updateIssue(workspaceSlug, projectId, data.id as keyof TIssue, data); - } catch (error) { - if (this.currentView) this.fetchIssues(workspaceSlug, undefined, "mutation", userId, this.currentView); - throw error; - } - }; - - removeIssue = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - userId: string | undefined = undefined - ) => { - if (!userId) return; - try { - await this.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); - - const uniqueViewId = `${workspaceSlug}_${this.currentView}`; - - const issueIndex = this.issues[userId][uniqueViewId].findIndex((_issueId) => _issueId === issueId); - if (issueIndex >= 0) - runInAction(() => { - this.issues[userId][uniqueViewId].splice(issueIndex, 1); - }); - } catch (error) { - throw error; - } - }; - - archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string, userId: string) => { - try { - await this.rootIssueStore.projectIssues.archiveIssue(workspaceSlug, projectId, issueId); - - const uniqueViewId = `${workspaceSlug}_${this.currentView}`; - - runInAction(() => { - pull(this.issues[userId][uniqueViewId], issueId); - }); - } catch (error) { - throw error; - } - }; + archiveBulkIssues = this.bulkArchiveIssues; + quickAddIssue = undefined; } diff --git a/web/store/issue/project-views/filter.store.ts b/web/store/issue/project-views/filter.store.ts index 6fbe94443..da0cb4a92 100644 --- a/web/store/issue/project-views/filter.store.ts +++ b/web/store/issue/project-views/filter.store.ts @@ -14,20 +14,24 @@ import { TIssueKanbanFilters, IIssueFilters, TIssueParams, + IssuePaginationOptions, } from "@plane/types"; -import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; +import { IBaseIssueFilterStore, IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers // types import { IIssueRootStore } from "../root.store"; +import { computedFn } from "mobx-utils"; // constants // services -export interface IProjectViewIssuesFilter { - // observables - filters: Record; // Record defines viewId as key and IIssueFilters as value - // computed - issueFilters: IIssueFilters | undefined; - appliedFilters: Partial> | undefined; +export interface IProjectViewIssuesFilter extends IBaseIssueFilterStore { + //helper actions + getFilterParams: ( + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => Partial>; // action fetchFilters: (workspaceSlug: string, projectId: string, viewId: string) => Promise; updateFilters: ( @@ -93,6 +97,20 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I return filteredRouteParams; } + getFilterParams = computedFn( + ( + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => { + const filterParams = this.appliedFilters; + + const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId); + return paginationParams; + } + ); + fetchFilters = async (workspaceSlug: string, projectId: string, viewId: string) => { try { const _filters = await this.issueFilterService.getViewDetails(workspaceSlug, projectId, viewId); @@ -159,11 +177,10 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I const appliedFilters = _filters.filters || {}; const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); - this.rootIssueStore.projectViewIssues.fetchIssues( + this.rootIssueStore.projectViewIssues.fetchIssuesWithExistingPagination( workspaceSlug, projectId, - isEmpty(filteredFilters) ? "init-loader" : "mutation", - viewId + isEmpty(filteredFilters) ? "init-loader" : "mutation" ); break; case EIssueFilterType.DISPLAY_FILTERS: @@ -199,8 +216,7 @@ export class ProjectViewIssuesFilter extends IssueFilterHelperStore implements I }); }); - if (this.requiresServerUpdate(updatedDisplayFilters)) - this.rootIssueStore.projectViewIssues.fetchIssues(workspaceSlug, projectId, "mutation", viewId); + this.rootIssueStore.projectViewIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation"); await this.issueFilterService.patchView(workspaceSlug, projectId, viewId, { display_filters: _filters.displayFilters, diff --git a/web/store/issue/project-views/issue.store.ts b/web/store/issue/project-views/issue.store.ts index 1c7decd26..02af914df 100644 --- a/web/store/issue/project-views/issue.store.ts +++ b/web/store/issue/project-views/issue.store.ts @@ -1,277 +1,159 @@ -import pull from "lodash/pull"; -import set from "lodash/set"; -import { action, observable, makeObservable, computed, runInAction } from "mobx"; -import { TIssue, TLoader, TGroupedIssues, TSubGroupedIssues, TUnGroupedIssues, ViewFlags } from "@plane/types"; +import { action, makeObservable, runInAction } from "mobx"; // base class -import { IssueService } from "@/services/issue/issue.service"; -import { IssueHelperStore } from "../helpers/issue-helper.store"; +import { + TIssue, + TLoader, + ViewFlags, + IssuePaginationOptions, + TIssuesResponse, + TBulkOperationsPayload, +} from "@plane/types"; // services // types import { IIssueRootStore } from "../root.store"; +import { BaseIssuesStore, IBaseIssuesStore } from "../helpers/base-issues.store"; +import { IProjectViewIssuesFilter } from "./filter.store"; -export interface IProjectViewIssues { - // observable - loader: TLoader; - issues: { [view_id: string]: string[] }; +export interface IProjectViewIssues extends IBaseIssuesStore { viewFlags: ViewFlags; - // computed - groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; // actions - getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; fetchIssues: ( workspaceSlug: string, projectId: string, loadType: TLoader, - viewId: string - ) => Promise; - createIssue: ( + options: IssuePaginationOptions + ) => Promise; + fetchIssuesWithExistingPagination: ( workspaceSlug: string, projectId: string, - data: Partial, - viewId: string - ) => Promise; - updateIssue: ( + loadType: TLoader + ) => Promise; + fetchNextIssues: ( workspaceSlug: string, projectId: string, - issueId: string, - data: Partial, - viewId: string - ) => Promise; - removeIssue: (workspaceSlug: string, projectId: string, issueId: string, viewId: string) => Promise; - archiveIssue: (workspaceSlug: string, projectId: string, issueId: string, viewId: string) => Promise; - quickAddIssue: ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId?: string | undefined - ) => Promise; + groupId?: string, + subGroupId?: string + ) => Promise; + + createIssue: (workspaceSlug: string, projectId: string, data: Partial) => Promise; + updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; + archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + quickAddIssue: (workspaceSlug: string, projectId: string, data: TIssue) => Promise; + removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise; } -export class ProjectViewIssues extends IssueHelperStore implements IProjectViewIssues { - loader: TLoader = "init-loader"; - issues: { [view_id: string]: string[] } = {}; +export class ProjectViewIssues extends BaseIssuesStore implements IProjectViewIssues { viewFlags = { enableQuickAdd: true, enableIssueCreation: true, enableInlineEditing: true, }; - // root store - rootIssueStore: IIssueRootStore; - // services - issueService; + //filter store + issueFilterStore: IProjectViewIssuesFilter; - constructor(_rootStore: IIssueRootStore) { - super(_rootStore); + constructor(_rootStore: IIssueRootStore, issueFilterStore: IProjectViewIssuesFilter) { + super(_rootStore, issueFilterStore); makeObservable(this, { - // observable - loader: observable.ref, - issues: observable, - // computed - groupedIssueIds: computed, // action fetchIssues: action, - createIssue: action, - updateIssue: action, - removeIssue: action, - archiveIssue: action, - quickAddIssue: action, + fetchNextIssues: action, + fetchIssuesWithExistingPagination: action, }); - // root store - this.rootIssueStore = _rootStore; - // services - this.issueService = new IssueService(); + //filter store + this.issueFilterStore = issueFilterStore; } - get groupedIssueIds() { - const viewId = this.rootStore?.viewId; - if (!viewId) return undefined; + fetchParentStats = async () => {}; - const displayFilters = this.rootIssueStore?.projectViewIssuesFilter?.issueFilters?.displayFilters; - if (!displayFilters) return undefined; - - const subGroupBy = displayFilters?.sub_group_by; - const groupBy = displayFilters?.group_by; - const orderBy = displayFilters?.order_by; - const layout = displayFilters?.layout; - - const viewIssueIds = this.issues[viewId]; - if (!viewIssueIds) return; - - const currentIssues = this.rootStore.issues.getIssuesByIds(viewIssueIds, "un-archived"); - if (!currentIssues) return []; - - let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues = []; - - if (layout === "list" && orderBy) { - if (groupBy) issues = this.groupedIssues(groupBy, orderBy, currentIssues); - else issues = this.unGroupedIssues(orderBy, currentIssues); - } else if (layout === "kanban" && groupBy && orderBy) { - if (subGroupBy) issues = this.subGroupedIssues(subGroupBy, groupBy, orderBy, currentIssues); - else issues = this.groupedIssues(groupBy, orderBy, currentIssues); - } else if (layout === "calendar") issues = this.groupedIssues("target_date", "target_date", currentIssues, true); - else if (layout === "spreadsheet") issues = this.unGroupedIssues(orderBy ?? "-created_at", currentIssues); - else if (layout === "gantt_chart") issues = this.unGroupedIssues(orderBy ?? "sort_order", currentIssues); - - return issues; - } - - getIssueIds = (groupId?: string, subGroupId?: string) => { - const groupedIssueIds = this.groupedIssueIds; - - const displayFilters = this.rootIssueStore?.projectViewIssuesFilter?.issueFilters?.displayFilters; - if (!displayFilters || !groupedIssueIds) return undefined; - - const subGroupBy = displayFilters?.sub_group_by; - const groupBy = displayFilters?.group_by; - - if (!groupBy && !subGroupBy) { - return groupedIssueIds as string[]; - } - - if (groupBy && subGroupBy && groupId && subGroupId) { - return (groupedIssueIds as TSubGroupedIssues)?.[subGroupId]?.[groupId] as string[]; - } - - if (groupBy && groupId) { - return (groupedIssueIds as TGroupedIssues)?.[groupId] as string[]; - } - - return undefined; - }; - - fetchIssues = async (workspaceSlug: string, projectId: string, loadType: TLoader = "init-loader", viewId: string) => { + /** + * This method is called to fetch the first issues of pagination + * @param workspaceSlug + * @param projectId + * @param loadType + * @param options + * @returns + */ + fetchIssues = async ( + workspaceSlug: string, + projectId: string, + loadType: TLoader, + options: IssuePaginationOptions + ) => { try { - this.loader = loadType; + // set loader and clear store + runInAction(() => { + this.setLoader(loadType); + }); + this.clear(); - const params = this.rootIssueStore?.projectViewIssuesFilter?.appliedFilters; + // get params from pagination options + const params = this.issueFilterStore?.getFilterParams(options, undefined, undefined, undefined); + // call the fetch issues API with the params const response = await this.issueService.getIssues(workspaceSlug, projectId, params); - runInAction(() => { - set( - this.issues, - [viewId], - response.map((issue) => issue.id) - ); - this.loader = undefined; - }); - - this.rootIssueStore.issues.addIssue(response); - + // after fetching issues, call the base method to process the response further + this.onfetchIssues(response, options, workspaceSlug, projectId); return response; } catch (error) { - console.error(error); - this.loader = undefined; + // set loader to undefined if errored out + this.setLoader(undefined); throw error; } }; - createIssue = async (workspaceSlug: string, projectId: string, data: Partial, viewId: string) => { + /** + * This method is called subsequent pages of pagination + * if groupId/subgroupId is provided, only that specific group's next page is fetched + * else all the groups' next page is fetched + * @param workspaceSlug + * @param projectId + * @param groupId + * @param subGroupId + * @returns + */ + fetchNextIssues = async (workspaceSlug: string, projectId: string, groupId?: string, subGroupId?: string) => { + const cursorObject = this.getPaginationData(groupId, subGroupId); + // if there are no pagination options and the next page results do not exist the return + if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return; try { - const response = await this.rootIssueStore.projectIssues.createIssue(workspaceSlug, projectId, data); + // set Loader + this.setLoader("pagination", groupId, subGroupId); - runInAction(() => { - this.issues[viewId].push(response.id); - }); + // get params from stored pagination options + let params = this.issueFilterStore?.getFilterParams( + this.paginationOptions, + cursorObject?.nextCursor, + groupId, + subGroupId + ); + // call the fetch issues API with the params for next page in issues + const response = await this.issueService.getIssues(workspaceSlug, projectId, params); + // after the next page of issues are fetched, call the base method to process the response + this.onfetchNexIssues(response, groupId, subGroupId); return response; } catch (error) { - this.fetchIssues(workspaceSlug, projectId, "mutation", viewId); + // set Loader as undefined if errored out + this.setLoader(undefined, groupId, subGroupId); throw error; } }; - updateIssue = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - data: Partial, - viewId: string - ) => { - try { - await this.rootIssueStore.projectIssues.updateIssue(workspaceSlug, projectId, issueId, data); - } catch (error) { - this.fetchIssues(workspaceSlug, projectId, "mutation", viewId); - throw error; - } + /** + * This Method exists to fetch the first page of the issues with the existing stored pagination + * This is useful for refetching when filters, groupBy, orderBy etc changes + * @param workspaceSlug + * @param projectId + * @param loadType + * @returns + */ + fetchIssuesWithExistingPagination = async (workspaceSlug: string, projectId: string, loadType: TLoader) => { + if (!this.paginationOptions) return; + return await this.fetchIssues(workspaceSlug, projectId, loadType, this.paginationOptions); }; - removeIssue = async (workspaceSlug: string, projectId: string, issueId: string, viewId: string) => { - try { - await this.rootIssueStore.projectIssues.removeIssue(workspaceSlug, projectId, issueId); - - const issueIndex = this.issues[viewId].findIndex((_issueId) => _issueId === issueId); - if (issueIndex >= 0) - runInAction(() => { - this.issues[viewId].splice(issueIndex, 1); - }); - } catch (error) { - this.fetchIssues(workspaceSlug, projectId, "mutation", viewId); - throw error; - } - }; - - archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string, viewId: string) => { - try { - await this.rootIssueStore.projectIssues.archiveIssue(workspaceSlug, projectId, issueId); - - runInAction(() => { - pull(this.issues[viewId], issueId); - }); - } catch (error) { - this.fetchIssues(workspaceSlug, projectId, "mutation", viewId); - throw error; - } - }; - - quickAddIssue = async ( - workspaceSlug: string, - projectId: string, - data: TIssue, - viewId: string | undefined = undefined - ) => { - try { - if (!viewId) throw new Error("View Id is required"); - - runInAction(() => { - this.issues[viewId].push(data.id); - this.rootIssueStore.issues.addIssue([data]); - }); - - const response = await this.createIssue(workspaceSlug, projectId, data, viewId); - - const quickAddIssueIndex = this.issues[viewId].findIndex((_issueId) => _issueId === data.id); - if (quickAddIssueIndex >= 0) { - runInAction(() => { - this.issues[viewId].splice(quickAddIssueIndex, 1); - this.rootIssueStore.issues.removeIssue(data.id); - }); - } - - const currentCycleId = data.cycle_id !== "" && data.cycle_id === "None" ? undefined : data.cycle_id; - const currentModuleIds = - data.module_ids && data.module_ids.length > 0 ? data.module_ids.filter((moduleId) => moduleId != "None") : []; - - const multipleIssuePromises = []; - if (currentCycleId) { - multipleIssuePromises.push( - this.rootStore.cycleIssues.addCycleToIssue(workspaceSlug, projectId, currentCycleId, response.id) - ); - } - - if (currentModuleIds.length > 0) { - multipleIssuePromises.push( - this.rootStore.moduleIssues.changeModulesInIssue(workspaceSlug, projectId, response.id, currentModuleIds, []) - ); - } - - if (multipleIssuePromises && multipleIssuePromises.length > 0) { - await Promise.all(multipleIssuePromises); - } - - return response; - } catch (error) { - if (viewId) this.fetchIssues(workspaceSlug, projectId, "mutation", viewId); - throw error; - } - }; + archiveBulkIssues = this.bulkArchiveIssues; + quickAddIssue = this.issueQuickAdd; } diff --git a/web/store/issue/project/filter.store.ts b/web/store/issue/project/filter.store.ts index 66ce98344..2b8cfd974 100644 --- a/web/store/issue/project/filter.store.ts +++ b/web/store/issue/project/filter.store.ts @@ -4,7 +4,6 @@ import pickBy from "lodash/pickBy"; import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // base class -import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; import { handleIssueQueryParamsByLayout } from "@/helpers/issue.helper"; import { IssueFiltersService } from "@/services/issue_filter.service"; import { @@ -14,20 +13,25 @@ import { TIssueKanbanFilters, IIssueFilters, TIssueParams, + IssuePaginationOptions, } from "@plane/types"; -import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; +import { IBaseIssueFilterStore, IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers // types import { IIssueRootStore } from "../root.store"; // constants +import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; +import { computedFn } from "mobx-utils"; // services -export interface IProjectIssuesFilter { - // observables - filters: Record; // Record defines projectId as key and IIssueFilters as value - // computed - issueFilters: IIssueFilters | undefined; - appliedFilters: Partial> | undefined; +export interface IProjectIssuesFilter extends IBaseIssueFilterStore { + //helper actions + getFilterParams: ( + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => Partial>; // action fetchFilters: (workspaceSlug: string, projectId: string) => Promise; updateFilters: ( @@ -66,9 +70,11 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj get issueFilters() { const projectId = this.rootIssueStore.projectId; + console.log("projectId", projectId); if (!projectId) return undefined; const displayFilters = this.filters[projectId] || undefined; + console.log("displayFilters", displayFilters); if (isEmpty(displayFilters)) return undefined; return this.computedIssueFilters(displayFilters); @@ -90,6 +96,19 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj return filteredRouteParams; } + getFilterParams = computedFn( + ( + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => { + const filterParams = this.appliedFilters; + const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId); + return paginationParams; + } + ); + fetchFilters = async (workspaceSlug: string, projectId: string) => { try { const _filters = await this.issueFilterService.fetchProjectIssueFilters(workspaceSlug, projectId); @@ -155,7 +174,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj const appliedFilters = _filters.filters || {}; const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); - this.rootIssueStore.projectIssues.fetchIssues( + this.rootIssueStore.projectIssues.fetchIssuesWithExistingPagination( workspaceSlug, projectId, isEmpty(filteredFilters) ? "init-loader" : "mutation" @@ -198,7 +217,7 @@ export class ProjectIssuesFilter extends IssueFilterHelperStore implements IProj }); if (this.requiresServerUpdate(updatedDisplayFilters)) - this.rootIssueStore.projectIssues.fetchIssues(workspaceSlug, projectId, "mutation"); + this.rootIssueStore.projectIssues.fetchIssuesWithExistingPagination(workspaceSlug, projectId, "mutation"); await this.issueFilterService.patchProjectIssueFilters(workspaceSlug, projectId, { display_filters: _filters.displayFilters, diff --git a/web/store/issue/project/issue.store.ts b/web/store/issue/project/issue.store.ts index 4d4930b29..212add8f5 100644 --- a/web/store/issue/project/issue.store.ts +++ b/web/store/issue/project/issue.store.ts @@ -1,301 +1,166 @@ -import concat from "lodash/concat"; -import pull from "lodash/pull"; -import set from "lodash/set"; -import update from "lodash/update"; -import { action, makeObservable, observable, runInAction, computed } from "mobx"; +import { action, makeObservable, runInAction, } from "mobx"; // types -import { TIssue, TGroupedIssues, TSubGroupedIssues, TLoader, TUnGroupedIssues, ViewFlags } from "@plane/types"; +import { TIssue, TLoader, ViewFlags, IssuePaginationOptions, TIssuesResponse, TBulkOperationsPayload } from "@plane/types"; // helpers -import { issueCountBasedOnFilters } from "@/helpers/issue.helper"; // base class -import { IssueService, IssueArchiveService } from "@/services/issue"; -import { IssueHelperStore } from "../helpers/issue-helper.store"; +import { BaseIssuesStore, IBaseIssuesStore } from "../helpers/base-issues.store"; // services import { IIssueRootStore } from "../root.store"; +import { IProjectIssuesFilter } from "./filter.store"; -export interface IProjectIssues { - // observable - loader: TLoader; - issues: Record; // Record of project_id as key and issue_ids as value +export interface IProjectIssues extends IBaseIssuesStore { viewFlags: ViewFlags; - // computed - issuesCount: number; - groupedIssueIds: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues | undefined; - getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined; // action - fetchIssues: (workspaceSlug: string, projectId: string, loadType: TLoader) => Promise; + fetchIssues: ( + workspaceSlug: string, + projectId: string, + loadType: TLoader, + option: IssuePaginationOptions + ) => Promise; + fetchIssuesWithExistingPagination: ( + workspaceSlug: string, + projectId: string, + loadType: TLoader + ) => Promise; + fetchNextIssues: ( + workspaceSlug: string, + projectId: string, + groupId?: string, + subGroupId?: string + ) => Promise; + createIssue: (workspaceSlug: string, projectId: string, data: Partial) => Promise; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; - removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; - quickAddIssue: (workspaceSlug: string, projectId: string, data: TIssue) => Promise; + quickAddIssue: (workspaceSlug: string, projectId: string, data: TIssue) => Promise; removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise; } -export class ProjectIssues extends IssueHelperStore implements IProjectIssues { - // observable - loader: TLoader = "init-loader"; - issues: Record = {}; +export class ProjectIssues extends BaseIssuesStore implements IProjectIssues { viewFlags = { enableQuickAdd: true, enableIssueCreation: true, enableInlineEditing: true, }; - // root store - rootIssueStore: IIssueRootStore; - // services - issueService; - issueArchiveService; - constructor(_rootStore: IIssueRootStore) { - super(_rootStore); + // filter store + issueFilterStore: IProjectIssuesFilter; + + constructor(_rootStore: IIssueRootStore, issueFilterStore: IProjectIssuesFilter) { + super(_rootStore, issueFilterStore); makeObservable(this, { - // observable - loader: observable.ref, - issues: observable, - // computed - issuesCount: computed, - groupedIssueIds: computed, - // action fetchIssues: action, - createIssue: action, - updateIssue: action, - removeIssue: action, - archiveIssue: action, - removeBulkIssues: action, + fetchNextIssues: action, + fetchIssuesWithExistingPagination: action, + quickAddIssue: action, }); - // root store - this.rootIssueStore = _rootStore; - // services - this.issueService = new IssueService(); - this.issueArchiveService = new IssueArchiveService(); + // filter store + this.issueFilterStore = issueFilterStore; } - get issuesCount() { - let issuesCount = 0; - - const displayFilters = this.rootStore?.projectIssuesFilter?.issueFilters?.displayFilters; - const groupedIssueIds = this.groupedIssueIds; - if (!displayFilters || !groupedIssueIds) return issuesCount; - - const layout = displayFilters?.layout || undefined; - const groupBy = displayFilters?.group_by || undefined; - const subGroupBy = displayFilters?.sub_group_by || undefined; - - if (!layout) return issuesCount; - issuesCount = issueCountBasedOnFilters(groupedIssueIds, layout, groupBy, subGroupBy); - return issuesCount; - } - - get groupedIssueIds() { - const projectId = this.rootStore?.projectId; - if (!projectId) return undefined; - - const displayFilters = this.rootStore?.projectIssuesFilter?.issueFilters?.displayFilters; - if (!displayFilters) return undefined; - - const subGroupBy = displayFilters?.sub_group_by; - const groupBy = displayFilters?.group_by; - const orderBy = displayFilters?.order_by; - const layout = displayFilters?.layout; - - const projectIssueIds = this.issues[projectId]; - if (!projectIssueIds) return; - - const currentIssues = this.rootStore.issues.getIssuesByIds(projectIssueIds, "un-archived"); - if (!currentIssues) return []; - - let issues: TGroupedIssues | TSubGroupedIssues | TUnGroupedIssues = []; - - if (layout === "list" && orderBy) { - if (groupBy) issues = this.groupedIssues(groupBy, orderBy, currentIssues); - else issues = this.unGroupedIssues(orderBy, currentIssues); - } else if (layout === "kanban" && groupBy && orderBy) { - if (subGroupBy) issues = this.subGroupedIssues(subGroupBy, groupBy, orderBy, currentIssues); - else issues = this.groupedIssues(groupBy, orderBy, currentIssues); - } else if (layout === "calendar") issues = this.groupedIssues("target_date", "target_date", currentIssues, true); - else if (layout === "spreadsheet") issues = this.unGroupedIssues(orderBy ?? "-created_at", currentIssues); - else if (layout === "gantt_chart") issues = this.unGroupedIssues(orderBy ?? "sort_order", currentIssues); - - return issues; - } - - getIssueIds = (groupId?: string, subGroupId?: string) => { - const groupedIssueIds = this.groupedIssueIds; - - const displayFilters = this.rootStore?.projectIssuesFilter?.issueFilters?.displayFilters; - if (!displayFilters || !groupedIssueIds) return undefined; - - const subGroupBy = displayFilters?.sub_group_by; - const groupBy = displayFilters?.group_by; - - if (!groupBy && !subGroupBy) { - return groupedIssueIds as string[]; - } - - if (groupBy && subGroupBy && groupId && subGroupId) { - return (groupedIssueIds as TSubGroupedIssues)?.[subGroupId]?.[groupId] as string[]; - } - - if (groupBy && groupId) { - return (groupedIssueIds as TGroupedIssues)?.[groupId] as string[]; - } - - return undefined; + /** + * Fetches the project details + * @param workspaceSlug + * @param projectId + */ + fetchParentStats = async (workspaceSlug: string, projectId?: string) => { + projectId && this.rootIssueStore.rootStore.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId); }; - fetchIssues = async (workspaceSlug: string, projectId: string, loadType: TLoader = "init-loader") => { + /** + * This method is called to fetch the first issues of pagination + * @param workspaceSlug + * @param projectId + * @param loadType + * @param options + * @returns + */ + fetchIssues = async ( + workspaceSlug: string, + projectId: string, + loadType: TLoader = "init-loader", + options: IssuePaginationOptions + ) => { try { - this.loader = loadType; + // set loader and clear store + runInAction(() => { + this.setLoader(loadType); + }); + this.clear(); - const params = this.rootStore?.projectIssuesFilter?.appliedFilters; + // get params from pagination options + const params = this.issueFilterStore?.getFilterParams(options, undefined, undefined, undefined); + // call the fetch issues API with the params const response = await this.issueService.getIssues(workspaceSlug, projectId, params); - runInAction(() => { - set( - this.issues, - [projectId], - response.map((issue) => issue.id) - ); - this.loader = undefined; - }); - - this.rootStore.issues.addIssue(response); - this.rootIssueStore.rootStore.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId); - + // after fetching issues, call the base method to process the response further + this.onfetchIssues(response, options, workspaceSlug, projectId); return response; } catch (error) { - this.loader = undefined; + // set loader to undefined if errored out + this.setLoader(undefined); throw error; } }; - createIssue = async (workspaceSlug: string, projectId: string, data: Partial) => { + /** + * This method is called subsequent pages of pagination + * if groupId/subgroupId is provided, only that specific group's next page is fetched + * else all the groups' next page is fetched + * @param workspaceSlug + * @param projectId + * @param groupId + * @param subGroupId + * @returns + */ + fetchNextIssues = async (workspaceSlug: string, projectId: string, groupId?: string, subGroupId?: string) => { + const cursorObject = this.getPaginationData(groupId, subGroupId); + // if there are no pagination options and the next page results do not exist the return + if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return; try { - const response = await this.issueService.createIssue(workspaceSlug, projectId, data); + // set Loader + this.setLoader("pagination", groupId, subGroupId); - runInAction(() => { - update(this.issues, [projectId], (issueIds) => { - if (!issueIds) return [response.id]; - return concat(issueIds, response.id); - }); - }); - - this.rootStore.issues.addIssue([response]); - this.rootIssueStore.rootStore.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId); + // get params from stored pagination options + const params = this.issueFilterStore?.getFilterParams( + this.paginationOptions, + cursorObject?.nextCursor, + groupId, + subGroupId + ); + // call the fetch issues API with the params for next page in issues + const response = await this.issueService.getIssues(workspaceSlug, projectId, params); + // after the next page of issues are fetched, call the base method to process the response + this.onfetchNexIssues(response, groupId, subGroupId); return response; } catch (error) { + // set Loader as undefined if errored out + this.setLoader(undefined, groupId, subGroupId); throw error; } }; - updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { - try { - this.rootStore.issues.updateIssue(issueId, data); - - await this.issueService.patchIssue(workspaceSlug, projectId, issueId, data); - } catch (error) { - this.fetchIssues(workspaceSlug, projectId, "mutation"); - throw error; - } + /** + * This Method exists to fetch the first page of the issues with the existing stored pagination + * This is useful for refetching when filters, groupBy, orderBy etc changes + * @param workspaceSlug + * @param projectId + * @param loadType + * @returns + */ + fetchIssuesWithExistingPagination = async ( + workspaceSlug: string, + projectId: string, + loadType: TLoader = "mutation" + ) => { + if (!this.paginationOptions) return; + return await this.fetchIssues(workspaceSlug, projectId, loadType, this.paginationOptions); }; - removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { - try { - await this.issueService.deleteIssue(workspaceSlug, projectId, issueId); - - runInAction(() => { - pull(this.issues[projectId], issueId); - }); - - this.rootStore.issues.removeIssue(issueId); - this.rootIssueStore.rootStore.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId); - } catch (error) { - throw error; - } - }; - - archiveIssue = async (workspaceSlug: string, projectId: string, issueId: string) => { - try { - const response = await this.issueArchiveService.archiveIssue(workspaceSlug, projectId, issueId); - - runInAction(() => { - this.rootStore.issues.updateIssue(issueId, { - archived_at: response.archived_at, - }); - pull(this.issues[projectId], issueId); - }); - - this.rootIssueStore.rootStore.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId); - } catch (error) { - throw error; - } - }; - - quickAddIssue = async (workspaceSlug: string, projectId: string, data: TIssue) => { - try { - runInAction(() => { - this.issues[projectId].push(data.id); - this.rootStore.issues.addIssue([data]); - }); - - const response = await this.createIssue(workspaceSlug, projectId, data); - - const quickAddIssueIndex = this.issues[projectId].findIndex((_issueId) => _issueId === data.id); - - if (quickAddIssueIndex >= 0) { - runInAction(() => { - this.issues[projectId].splice(quickAddIssueIndex, 1); - this.rootStore.issues.removeIssue(data.id); - }); - } - - const currentCycleId = data.cycle_id !== "" && data.cycle_id === "None" ? undefined : data.cycle_id; - const currentModuleIds = - data.module_ids && data.module_ids.length > 0 ? data.module_ids.filter((moduleId) => moduleId != "None") : []; - - const multipleIssuePromises = []; - if (currentCycleId) { - multipleIssuePromises.push( - this.rootStore.cycleIssues.addCycleToIssue(workspaceSlug, projectId, currentCycleId, response.id) - ); - } - - if (currentModuleIds.length > 0) { - multipleIssuePromises.push( - this.rootStore.moduleIssues.changeModulesInIssue(workspaceSlug, projectId, response.id, currentModuleIds, []) - ); - } - - if (multipleIssuePromises && multipleIssuePromises.length > 0) { - await Promise.all(multipleIssuePromises); - } - - return response; - } catch (error) { - this.fetchIssues(workspaceSlug, projectId, "mutation"); - throw error; - } - }; - - removeBulkIssues = async (workspaceSlug: string, projectId: string, issueIds: string[]) => { - try { - runInAction(() => { - issueIds.forEach((issueId) => { - pull(this.issues[projectId], issueId); - this.rootStore.issues.removeIssue(issueId); - }); - }); - - const response = await this.issueService.bulkDeleteIssues(workspaceSlug, projectId, { issue_ids: issueIds }); - this.rootIssueStore.rootStore.projectRoot.project.fetchProjectDetails(workspaceSlug, projectId); - - return response; - } catch (error) { - this.fetchIssues(workspaceSlug, projectId, "mutation"); - throw error; - } - }; + archiveBulkIssues = this.bulkArchiveIssues; + quickAddIssue = this.issueQuickAdd; } diff --git a/web/store/issue/root.store.ts b/web/store/issue/root.store.ts index 08359cd19..08faff721 100644 --- a/web/store/issue/root.store.ts +++ b/web/store/issue/root.store.ts @@ -184,28 +184,28 @@ export class IssueRootStore implements IIssueRootStore { this.issueDetail = new IssueDetail(this); this.workspaceIssuesFilter = new WorkspaceIssuesFilter(this); - this.workspaceIssues = new WorkspaceIssues(this); + this.workspaceIssues = new WorkspaceIssues(this, this.workspaceIssuesFilter); this.profileIssuesFilter = new ProfileIssuesFilter(this); - this.profileIssues = new ProfileIssues(this); + this.profileIssues = new ProfileIssues(this, this.profileIssuesFilter); this.projectIssuesFilter = new ProjectIssuesFilter(this); - this.projectIssues = new ProjectIssues(this); + this.projectIssues = new ProjectIssues(this, this.projectIssuesFilter); this.cycleIssuesFilter = new CycleIssuesFilter(this); - this.cycleIssues = new CycleIssues(this); + this.cycleIssues = new CycleIssues(this, this.cycleIssuesFilter); this.moduleIssuesFilter = new ModuleIssuesFilter(this); - this.moduleIssues = new ModuleIssues(this); + this.moduleIssues = new ModuleIssues(this, this.moduleIssuesFilter); this.projectViewIssuesFilter = new ProjectViewIssuesFilter(this); - this.projectViewIssues = new ProjectViewIssues(this); + this.projectViewIssues = new ProjectViewIssues(this, this.projectViewIssuesFilter); this.archivedIssuesFilter = new ArchivedIssuesFilter(this); - this.archivedIssues = new ArchivedIssues(this); + this.archivedIssues = new ArchivedIssues(this, this.archivedIssuesFilter); this.draftIssuesFilter = new DraftIssuesFilter(this); - this.draftIssues = new DraftIssues(this); + this.draftIssues = new DraftIssues(this, this.draftIssuesFilter); this.issueKanBanView = new IssueKanBanViewStore(this); this.issueCalendarView = new CalendarStore(); diff --git a/web/store/issue/workspace/filter.store.ts b/web/store/issue/workspace/filter.store.ts index 101c3f0c7..8e6448341 100644 --- a/web/store/issue/workspace/filter.store.ts +++ b/web/store/issue/workspace/filter.store.ts @@ -4,7 +4,7 @@ import pickBy from "lodash/pickBy"; import set from "lodash/set"; import { action, computed, makeObservable, observable, runInAction } from "mobx"; // base class -import { EIssueFilterType, EIssuesStoreType } from "@/constants/issue"; +import { EIssueFilterType, EIssueLayoutTypes, EIssuesStoreType } from "@/constants/issue"; import { handleIssueQueryParamsByLayout } from "@/helpers/issue.helper"; import { WorkspaceService } from "@/services/workspace.service"; import { @@ -15,21 +15,19 @@ import { IIssueFilters, TIssueParams, TStaticViewTypes, + IssuePaginationOptions, } from "@plane/types"; -import { IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; +import { IBaseIssueFilterStore, IssueFilterHelperStore } from "../helpers/issue-filter-helper.store"; // helpers // types import { IIssueRootStore } from "../root.store"; +import { computedFn } from "mobx-utils"; // constants // services type TWorkspaceFilters = "all-issues" | "assigned" | "created" | "subscribed" | string; -export interface IWorkspaceIssuesFilter { - // observables - filters: Record; // Record defines viewId as key and IIssueFilters as value - // computed - issueFilters: IIssueFilters | undefined; - appliedFilters: Partial> | undefined; + +export interface IWorkspaceIssuesFilter extends IBaseIssueFilterStore { // fetch action fetchFilters: (workspaceSlug: string, viewId: string) => Promise; updateFilters: ( @@ -42,6 +40,13 @@ export interface IWorkspaceIssuesFilter { //helper action getIssueFilters: (viewId: string | undefined) => IIssueFilters | undefined; getAppliedFilters: (viewId: string) => Partial> | undefined; + getFilterParams: ( + viewId: string, + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => Partial>; } export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWorkspaceIssuesFilter { @@ -63,9 +68,6 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo // fetch actions fetchFilters: action, updateFilters: action, - // helper actions - getIssueFilters: action, - getAppliedFilters: action, }); // root store this.rootIssueStore = _rootStore; @@ -102,6 +104,21 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo return filteredRouteParams; }; + getFilterParams = computedFn( + ( + viewId: string, + options: IssuePaginationOptions, + cursor: string | undefined, + groupId: string | undefined, + subGroupId: string | undefined + ) => { + const filterParams = this.getAppliedFilters(viewId); + + const paginationParams = this.getPaginationParams(filterParams, options, cursor, groupId, subGroupId); + return paginationParams; + } + ); + get issueFilters() { const viewId = this.rootIssueStore.globalViewId; return this.getIssueFilters(viewId); @@ -123,7 +140,9 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo }; const _filters = this.handleIssuesLocalFilters.get(EIssuesStoreType.GLOBAL, workspaceSlug, undefined, viewId); - displayFilters = this.computedDisplayFilters(_filters?.display_filters, { layout: "spreadsheet" }); + displayFilters = this.computedDisplayFilters(_filters?.display_filters, { + layout: EIssueLayoutTypes.SPREADSHEET, + }); displayProperties = this.computedDisplayProperties(_filters?.display_properties); kanbanFilters = { group_by: _filters?.kanban_filters?.group_by || [], @@ -182,7 +201,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo }); const appliedFilters = _filters.filters || {}; const filteredFilters = pickBy(appliedFilters, (value) => value && isArray(value) && value.length > 0); - this.rootIssueStore.workspaceIssues.fetchIssues( + this.rootIssueStore.workspaceIssues.fetchIssuesWithExistingPagination( workspaceSlug, viewId, isEmpty(filteredFilters) ? "init-loader" : "mutation" @@ -221,8 +240,7 @@ export class WorkspaceIssuesFilter extends IssueFilterHelperStore implements IWo }); }); - if (this.requiresServerUpdate(updatedDisplayFilters)) - this.rootIssueStore.workspaceIssues.fetchIssues(workspaceSlug, viewId, "mutation"); + this.rootIssueStore.workspaceIssues.fetchIssuesWithExistingPagination(workspaceSlug, viewId, "mutation"); if (["all-issues", "assigned", "created", "subscribed"].includes(viewId)) this.handleIssuesLocalFilters.set(EIssuesStoreType.GLOBAL, type, workspaceSlug, undefined, viewId, { diff --git a/web/store/issue/workspace/issue.store.ts b/web/store/issue/workspace/issue.store.ts index ad77ec061..c1eaaa66d 100644 --- a/web/store/issue/workspace/issue.store.ts +++ b/web/store/issue/workspace/issue.store.ts @@ -1,215 +1,157 @@ -import pull from "lodash/pull"; -import set from "lodash/set"; -import { action, observable, makeObservable, computed, runInAction } from "mobx"; +import { action, makeObservable, runInAction } from "mobx"; // base class -import { IssueService, IssueArchiveService } from "@/services/issue"; import { WorkspaceService } from "@/services/workspace.service"; -import { TIssue, TLoader, TUnGroupedIssues, ViewFlags } from "@plane/types"; -import { IssueHelperStore } from "../helpers/issue-helper.store"; +import { IssuePaginationOptions, TBulkOperationsPayload, TIssue, TIssuesResponse, TLoader, ViewFlags } from "@plane/types"; // services // types import { IIssueRootStore } from "../root.store"; +import { BaseIssuesStore, IBaseIssuesStore } from "../helpers/base-issues.store"; +import { IWorkspaceIssuesFilter } from "./filter.store"; -export interface IWorkspaceIssues { +export interface IWorkspaceIssues extends IBaseIssuesStore { // observable - loader: TLoader; - issues: { [viewId: string]: string[] }; viewFlags: ViewFlags; - // computed - groupedIssueIds: { dataViewId: string; issueIds: TUnGroupedIssues | undefined }; // actions - fetchIssues: (workspaceSlug: string, viewId: string, loadType: TLoader) => Promise; - createIssue: ( + fetchIssues: ( workspaceSlug: string, - projectId: string, - data: Partial, - viewId: string - ) => Promise; - updateIssue: ( + viewId: string, + loadType: TLoader, + options: IssuePaginationOptions + ) => Promise; + fetchIssuesWithExistingPagination: ( workspaceSlug: string, - projectId: string, - issueId: string, - data: Partial, - viewId: string - ) => Promise; - removeIssue: (workspaceSlug: string, projectId: string, issueId: string, viewId: string) => Promise; - archiveIssue: ( + viewId: string, + loadType: TLoader + ) => Promise; + fetchNextIssues: ( workspaceSlug: string, - projectId: string, - issueId: string, - viewId?: string | undefined - ) => Promise; + viewId: string, + groupId?: string, + subGroupId?: string + ) => Promise; + + createIssue: (workspaceSlug: string, projectId: string, data: Partial) => Promise; + updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; + archiveIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; + removeBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + archiveBulkIssues: (workspaceSlug: string, projectId: string, issueIds: string[]) => Promise; + bulkUpdateProperties: (workspaceSlug: string, projectId: string, data: TBulkOperationsPayload) => Promise; + quickAddIssue: undefined; + clear(): void; } -export class WorkspaceIssues extends IssueHelperStore implements IWorkspaceIssues { - loader: TLoader = "init-loader"; - issues: { [viewId: string]: string[] } = {}; +export class WorkspaceIssues extends BaseIssuesStore implements IWorkspaceIssues { viewFlags = { enableQuickAdd: true, enableIssueCreation: true, enableInlineEditing: true, }; - // root store - rootIssueStore: IIssueRootStore; // service workspaceService; - issueService; - issueArchiveService; + // filterStore + issueFilterStore; - quickAddIssue = undefined; - - constructor(_rootStore: IIssueRootStore) { - super(_rootStore); + constructor(_rootStore: IIssueRootStore, issueFilterStore: IWorkspaceIssuesFilter) { + super(_rootStore, issueFilterStore); makeObservable(this, { - // observable - loader: observable.ref, - issues: observable, - // computed - groupedIssueIds: computed, // action fetchIssues: action, - createIssue: action, - updateIssue: action, - removeIssue: action, - archiveIssue: action, + fetchNextIssues: action, + fetchIssuesWithExistingPagination: action, }); - // root store - this.rootIssueStore = _rootStore; // services this.workspaceService = new WorkspaceService(); - this.issueService = new IssueService(); - this.issueArchiveService = new IssueArchiveService(); + // filter store + this.issueFilterStore = issueFilterStore; } - get groupedIssueIds() { - const viewId = this.rootIssueStore.globalViewId; - const workspaceSlug = this.rootIssueStore.workspaceSlug; - if (!workspaceSlug || !viewId) return { dataViewId: "", issueIds: undefined }; + fetchParentStats = () => {}; - const uniqueViewId = `${workspaceSlug}_${viewId}`; - - const displayFilters = this.rootIssueStore?.workspaceIssuesFilter?.filters?.[viewId]?.displayFilters; - if (!displayFilters) return { dataViewId: viewId, issueIds: undefined }; - - const orderBy = displayFilters?.order_by; - - const viewIssueIds = this.issues[uniqueViewId]; - - if (!viewIssueIds) return { dataViewId: viewId, issueIds: undefined }; - - const _issues = this.rootStore.issues.getIssuesByIds(viewIssueIds, "un-archived"); - if (!_issues) return { dataViewId: viewId, issueIds: [] }; - - let issueIds: TIssue | TUnGroupedIssues | undefined = undefined; - - issueIds = this.unGroupedIssues(orderBy ?? "-created_at", _issues); - - return { dataViewId: viewId, issueIds }; - } - - fetchIssues = async (workspaceSlug: string, viewId: string, loadType: TLoader = "init-loader") => { + /** + * This method is called to fetch the first issues of pagination + * @param workspaceSlug + * @param viewId + * @param loadType + * @param options + * @returns + */ + fetchIssues = async (workspaceSlug: string, viewId: string, loadType: TLoader, options: IssuePaginationOptions) => { try { - this.loader = loadType; + // set loader and clear store + runInAction(() => { + this.setLoader(loadType); + }); + this.clear(); - const uniqueViewId = `${workspaceSlug}_${viewId}`; - - const params = this.rootIssueStore?.workspaceIssuesFilter?.getAppliedFilters(viewId); + // get params from pagination options + const params = this.issueFilterStore?.getFilterParams(viewId, options, undefined, undefined, undefined); + // call the fetch issues API with the params const response = await this.workspaceService.getViewIssues(workspaceSlug, params); - runInAction(() => { - set( - this.issues, - [uniqueViewId], - response.map((issue) => issue.id) - ); - this.loader = undefined; - }); - - this.rootIssueStore.issues.addIssue(response); - + // after fetching issues, call the base method to process the response further + this.onfetchIssues(response, options, workspaceSlug); return response; } catch (error) { - console.error(error); - this.loader = undefined; + // set loader to undefined if errored out + this.setLoader(undefined); throw error; } }; - createIssue = async (workspaceSlug: string, projectId: string, data: Partial, viewId: string) => { + /** + * This method is called subsequent pages of pagination + * if groupId/subgroupId is provided, only that specific group's next page is fetched + * else all the groups' next page is fetched + * @param workspaceSlug + * @param viewId + * @param groupId + * @param subGroupId + * @returns + */ + fetchNextIssues = async (workspaceSlug: string, viewId: string, groupId?: string, subGroupId?: string) => { + const cursorObject = this.getPaginationData(groupId, subGroupId); + // if there are no pagination options and the next page results do not exist the return + if (!this.paginationOptions || (cursorObject && !cursorObject?.nextPageResults)) return; try { - const uniqueViewId = `${workspaceSlug}_${viewId}`; + // set Loader + this.setLoader("pagination", groupId, subGroupId); - const response = await this.issueService.createIssue(workspaceSlug, projectId, data); - - runInAction(() => { - this.issues[uniqueViewId].push(response.id); - }); - - this.rootStore.issues.addIssue([response]); + // get params from stored pagination options + const params = this.issueFilterStore?.getFilterParams( + viewId, + this.paginationOptions, + cursorObject?.nextCursor, + groupId, + subGroupId + ); + // call the fetch issues API with the params for next page in issues + const response = await this.workspaceService.getViewIssues(workspaceSlug, params); + // after the next page of issues are fetched, call the base method to process the response + this.onfetchNexIssues(response, groupId, subGroupId); return response; } catch (error) { + // set Loader as undefined if errored out + this.setLoader(undefined, groupId, subGroupId); throw error; } }; - updateIssue = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - data: Partial, - viewId: string - ) => { - try { - this.rootStore.issues.updateIssue(issueId, data); - await this.issueService.patchIssue(workspaceSlug, projectId, issueId, data); - } catch (error) { - if (viewId) this.fetchIssues(workspaceSlug, viewId, "mutation"); - throw error; - } + /** + * This Method exists to fetch the first page of the issues with the existing stored pagination + * This is useful for refetching when filters, groupBy, orderBy etc changes + * @param workspaceSlug + * @param viewId + * @param loadType + * @returns + */ + fetchIssuesWithExistingPagination = async (workspaceSlug: string, viewId: string, loadType: TLoader) => { + if (!this.paginationOptions) return; + return await this.fetchIssues(workspaceSlug, viewId, loadType, this.paginationOptions); }; - removeIssue = async (workspaceSlug: string, projectId: string, issueId: string, viewId: string) => { - try { - const uniqueViewId = `${workspaceSlug}_${viewId}`; - - await this.issueService.deleteIssue(workspaceSlug, projectId, issueId); - - const issueIndex = this.issues[uniqueViewId].findIndex((_issueId) => _issueId === issueId); - if (issueIndex >= 0) - runInAction(() => { - this.issues[uniqueViewId].splice(issueIndex, 1); - }); - - this.rootStore.issues.removeIssue(issueId); - } catch (error) { - throw error; - } - }; - - archiveIssue = async ( - workspaceSlug: string, - projectId: string, - issueId: string, - viewId: string | undefined = undefined - ) => { - try { - if (!viewId) throw new Error("View id is required"); - - const uniqueViewId = `${workspaceSlug}_${viewId}`; - - const response = await this.issueArchiveService.archiveIssue(workspaceSlug, projectId, issueId); - - runInAction(() => { - this.rootStore.issues.updateIssue(issueId, { - archived_at: response.archived_at, - }); - pull(this.issues[uniqueViewId], issueId); - }); - } catch (error) { - throw error; - } - }; + archiveBulkIssues = this.bulkArchiveIssues; + quickAddIssue = undefined; } diff --git a/web/store/label.store.ts b/web/store/label.store.ts index a477c6f7c..474feab23 100644 --- a/web/store/label.store.ts +++ b/web/store/label.store.ts @@ -21,7 +21,7 @@ export interface ILabelStore { projectLabelsTree: IIssueLabelTree[] | undefined; workspaceLabels: IIssueLabel[] | undefined; //computed actions - getProjectLabels: (projectId: string | null) => IIssueLabel[] | undefined; + getProjectLabels: (projectId: string | undefined | null) => IIssueLabel[] | undefined; getLabelById: (labelId: string) => IIssueLabel | null; // fetch actions fetchWorkspaceLabels: (workspaceSlug: string) => Promise; @@ -110,7 +110,7 @@ export class LabelStore implements ILabelStore { return buildTree(this.projectLabels); } - getProjectLabels = computedFn((projectId: string | null) => { + getProjectLabels = computedFn((projectId: string | undefined | null) => { const workspaceSlug = this.rootStore.router.workspaceSlug || ""; if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return; return sortBy( diff --git a/web/store/project/project.store.ts b/web/store/project/project.store.ts index 8fb576e1c..0f09bfdb1 100644 --- a/web/store/project/project.store.ts +++ b/web/store/project/project.store.ts @@ -27,8 +27,8 @@ export interface IProjectStore { favoriteProjectIds: string[]; currentProjectDetails: IProject | undefined; // actions - getProjectById: (projectId: string) => IProject | null; - getProjectIdentifierById: (projectId: string) => string | undefined; + getProjectById: (projectId: string | undefined | null) => IProject | undefined; + getProjectIdentifierById: (projectId: string | undefined | null) => string; // fetch actions fetchProjects: (workspaceSlug: string) => Promise; fetchProjectDetails: (workspaceSlug: string, projectId: string) => Promise; @@ -251,8 +251,8 @@ export class ProjectStore implements IProjectStore { * @param projectId * @returns IProject | null */ - getProjectById = computedFn((projectId: string) => { - const projectInfo = this.projectMap[projectId] || null; + getProjectById = computedFn((projectId: string | undefined | null) => { + const projectInfo = this.projectMap[projectId ?? ""] || undefined; return projectInfo; }); @@ -261,8 +261,8 @@ export class ProjectStore implements IProjectStore { * @param projectId * @returns string */ - getProjectIdentifierById = computedFn((projectId: string): string | undefined => { - const projectInfo = this.projectMap?.[projectId]; + getProjectIdentifierById = computedFn((projectId: string | undefined | null) => { + const projectInfo = this.projectMap?.[projectId ?? ""]; return projectInfo?.identifier; }); diff --git a/web/store/router.store.ts b/web/store/router.store.ts index 4b49df852..ae5013836 100644 --- a/web/store/router.store.ts +++ b/web/store/router.store.ts @@ -1,6 +1,7 @@ import { ParsedUrlQuery } from "node:querystring"; import { action, makeObservable, observable, computed, runInAction } from "mobx"; +import { TProfileViews } from "@plane/types"; export interface IRouterStore { // observables query: ParsedUrlQuery; @@ -13,6 +14,7 @@ export interface IRouterStore { moduleId: string | undefined; viewId: string | undefined; globalViewId: string | undefined; + profileViewId: TProfileViews | undefined; userId: string | undefined; peekId: string | undefined; issueId: string | undefined; @@ -37,6 +39,7 @@ export class RouterStore implements IRouterStore { moduleId: computed, viewId: computed, globalViewId: computed, + profileViewId: computed, userId: computed, peekId: computed, issueId: computed, @@ -103,6 +106,14 @@ export class RouterStore implements IRouterStore { return this.query?.globalViewId?.toString(); } + /** + * Returns the profile view id from the query + * @returns string|undefined + */ + get profileViewId() { + return this.query?.profileViewId?.toString() as TProfileViews; + } + /** * Returns the user id from the query * @returns string|undefined diff --git a/web/store/state.store.ts b/web/store/state.store.ts index 04be1b687..7a4e20ad4 100644 --- a/web/store/state.store.ts +++ b/web/store/state.store.ts @@ -21,8 +21,8 @@ export interface IStateStore { projectStates: IState[] | undefined; groupedProjectStates: Record | undefined; // computed actions - getStateById: (stateId: string) => IState | undefined; - getProjectStates: (projectId: string) => IState[] | undefined; + getStateById: (stateId: string | null | undefined) => IState | undefined; + getProjectStates: (projectId: string | null | undefined) => IState[] | undefined; // fetch actions fetchProjectStates: (workspaceSlug: string, projectId: string) => Promise; fetchWorkspaceStates: (workspaceSlug: string) => Promise; @@ -97,7 +97,7 @@ export class StateStore implements IStateStore { * Returns the stateMap belongs to a specific project grouped by group */ get groupedProjectStates() { - if (!this.router.query?.projectId) return; + if (!this.router.projectId) return; return groupBy(this.projectStates, "group") as Record; } @@ -105,8 +105,8 @@ export class StateStore implements IStateStore { * @description returns state details using state id * @param stateId */ - getStateById = computedFn((stateId: string) => { - if (!this.stateMap) return; + getStateById = computedFn((stateId: string | null | undefined) => { + if (!this.stateMap || !stateId) return; return this.stateMap[stateId] ?? undefined; }); @@ -115,7 +115,7 @@ export class StateStore implements IStateStore { * @param projectId * @returns IState[] */ - getProjectStates = computedFn((projectId: string) => { + getProjectStates = computedFn((projectId: string | null | undefined) => { const workspaceSlug = this.router.workspaceSlug || ""; if (!projectId || !(this.fetchedMap[projectId] || this.fetchedMap[workspaceSlug])) return; return sortStates(Object.values(this.stateMap).filter((state) => state.project_id === projectId)); diff --git a/web/styles/globals.css b/web/styles/globals.css index 953127cc4..751be587d 100644 --- a/web/styles/globals.css +++ b/web/styles/globals.css @@ -236,7 +236,7 @@ --color-text-100: 229, 229, 229; /* primary text */ --color-text-200: 163, 163, 163; /* secondary text */ --color-text-300: 115, 115, 115; /* tertiary text */ - --color-text-350: 130, 130, 130; + --color-text-350: 130, 130, 130; --color-text-400: 82, 82, 82; /* placeholder text */ --color-scrollbar: 82, 82, 82; /* scrollbar thumb */ @@ -293,8 +293,7 @@ --color-text-100: 250, 250, 250; /* primary text */ --color-text-200: 241, 241, 241; /* secondary text */ --color-text-300: 212, 212, 212; /* tertiary text */ - --color-text-350: 190, 190, 190 - --color-text-400: 115, 115, 115; /* placeholder text */ + --color-text-350: 190, 190, 190 --color-text-400: 115, 115, 115; /* placeholder text */ --color-scrollbar: 115, 115, 115; /* scrollbar thumb */ diff --git a/yarn.lock b/yarn.lock index 0c2f8d2ec..cf4ccf04f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10801,7 +10801,7 @@ prelude-ls@^1.2.1: resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g== -"prettier-fallback@npm:prettier@^3": +"prettier-fallback@npm:prettier@^3", prettier@^3.1.1, prettier@^3.2.5, prettier@latest: version "3.3.1" resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.1.tgz#e68935518dd90bb7ec4821ba970e68f8de16e1ac" integrity sha512-7CAwy5dRsxs8PHXT3twixW9/OEll8MLE0VRPCJyl7CkS6VHGPSlsVaWTiASPTyGyYRyApxlaWTzwUxVNrhcwDg== @@ -10828,11 +10828,6 @@ prettier@^2.8.8: resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.8.8.tgz#e8c5d7e98a4305ffe3de2e1fc4aca1a71c28b1da" integrity sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q== -prettier@^3.1.1, prettier@^3.2.5, prettier@latest: - version "3.3.1" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.1.tgz#e68935518dd90bb7ec4821ba970e68f8de16e1ac" - integrity sha512-7CAwy5dRsxs8PHXT3twixW9/OEll8MLE0VRPCJyl7CkS6VHGPSlsVaWTiASPTyGyYRyApxlaWTzwUxVNrhcwDg== - pretty-bytes@^5.3.0, pretty-bytes@^5.4.1: version "5.6.0" resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" @@ -11012,9 +11007,9 @@ prosemirror-schema-basic@^1.2.2: prosemirror-model "^1.19.0" prosemirror-schema-list@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.3.0.tgz#05374702cf35a3ba5e7ec31079e355a488d52519" - integrity sha512-Hz/7gM4skaaYfRPNgr421CU4GSwotmEwBVvJh5ltGiffUJwm7C8GfN/Bc6DR1EKEp5pDKhODmdXXyi9uIsZl5A== + version "1.4.0" + resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.4.0.tgz#03f210a25ec0e36b717defb486d2081d733a40dc" + integrity sha512-nZOIq/AkBSzCENxUyLm5ltWE53e2PLk65ghMN8qLQptOmDVixZlPqtMeQdiNw0odL9vNpalEjl3upgRkuJ/Jyw== dependencies: prosemirror-model "^1.0.0" prosemirror-state "^1.0.0" @@ -12232,16 +12227,7 @@ string-argv@~0.3.2: resolved "https://registry.yarnpkg.com/string-argv/-/string-argv-0.3.2.tgz#2b6d0ef24b656274d957d54e0a4bbf6153dc02b6" integrity sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q== -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -12337,14 +12323,7 @@ stringify-object@^3.3.0: is-obj "^1.0.1" is-regexp "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -13768,16 +13747,7 @@ workbox-window@6.6.1, workbox-window@^6.5.4: "@types/trusted-types" "^2.0.2" workbox-core "6.6.1" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==