diff --git a/web/components/inbox/content/inbox-issue-header.tsx b/web/components/inbox/content/inbox-issue-header.tsx index 7fd038faa..8a3401569 100644 --- a/web/components/inbox/content/inbox-issue-header.tsx +++ b/web/components/inbox/content/inbox-issue-header.tsx @@ -52,7 +52,7 @@ export const InboxIssueActionsHeader: FC = observer((p const [declineIssueModal, setDeclineIssueModal] = useState(false); const [deleteIssueModal, setDeleteIssueModal] = useState(false); // store - const { currentTab, deleteInboxIssue, inboxIssuesArray } = useProjectInbox(); + const { currentTab, deleteInboxIssue, inboxIssueIds } = useProjectInbox(); const { data: currentUser } = useUser(); const { membership: { currentProjectRole }, @@ -76,11 +76,11 @@ export const InboxIssueActionsHeader: FC = observer((p const redirectIssue = (): string | undefined => { let nextOrPreviousIssueId: string | undefined = undefined; - const currentIssueIndex = inboxIssuesArray.findIndex((i) => i.issue.id === currentInboxIssueId); - if (inboxIssuesArray[currentIssueIndex + 1]) - nextOrPreviousIssueId = inboxIssuesArray[currentIssueIndex + 1].issue.id; - else if (inboxIssuesArray[currentIssueIndex - 1]) - nextOrPreviousIssueId = inboxIssuesArray[currentIssueIndex - 1].issue.id; + const currentIssueIndex = inboxIssueIds.findIndex((id) => id === currentInboxIssueId); + if (inboxIssueIds[currentIssueIndex + 1]) + nextOrPreviousIssueId = inboxIssueIds[currentIssueIndex + 1]; + else if (inboxIssueIds[currentIssueIndex - 1]) + nextOrPreviousIssueId = inboxIssueIds[currentIssueIndex - 1]; else nextOrPreviousIssueId = undefined; return nextOrPreviousIssueId; }; @@ -134,22 +134,22 @@ export const InboxIssueActionsHeader: FC = observer((p }) ); - const currentIssueIndex = inboxIssuesArray.findIndex((issue) => issue.issue.id === currentInboxIssueId) ?? 0; + const currentIssueIndex = inboxIssueIds.findIndex((issueId) => issueId === currentInboxIssueId) ?? 0; const handleInboxIssueNavigation = useCallback( (direction: "next" | "prev") => { - if (!inboxIssuesArray || !currentInboxIssueId) return; + if (!inboxIssueIds || !currentInboxIssueId) return; const activeElement = document.activeElement as HTMLElement; if (activeElement && (activeElement.classList.contains("tiptap") || activeElement.id === "title-input")) return; const nextIssueIndex = direction === "next" - ? (currentIssueIndex + 1) % inboxIssuesArray.length - : (currentIssueIndex - 1 + inboxIssuesArray.length) % inboxIssuesArray.length; - const nextIssueId = inboxIssuesArray[nextIssueIndex].issue.id; + ? (currentIssueIndex + 1) % inboxIssueIds.length + : (currentIssueIndex - 1 + inboxIssueIds.length) % inboxIssueIds.length; + const nextIssueId = inboxIssueIds[nextIssueIndex]; if (!nextIssueId) return; router.push(`/${workspaceSlug}/projects/${projectId}/inbox?inboxIssueId=${nextIssueId}`); }, - [currentInboxIssueId, currentIssueIndex, inboxIssuesArray, projectId, router, workspaceSlug] + [currentInboxIssueId, currentIssueIndex, inboxIssueIds, projectId, router, workspaceSlug] ); const onKeyDown = useCallback( diff --git a/web/components/inbox/content/root.tsx b/web/components/inbox/content/root.tsx index 0049807cd..b1dc8f08c 100644 --- a/web/components/inbox/content/root.tsx +++ b/web/components/inbox/content/root.tsx @@ -1,5 +1,6 @@ -import { FC, useState } from "react"; +import { FC, useEffect, useState } from "react"; import { observer } from "mobx-react"; +import { useRouter } from "next/router"; import useSWR from "swr"; import { InboxIssueActionsHeader, InboxIssueMainContent } from "@/components/inbox"; import { EUserProjectRoles } from "@/constants/project"; @@ -15,14 +16,25 @@ type TInboxContentRoot = { export const InboxContentRoot: FC = observer((props) => { const { workspaceSlug, projectId, inboxIssueId, isMobileSidebar, setIsMobileSidebar } = props; + /// router + const router = useRouter(); // states const [isSubmitting, setIsSubmitting] = useState<"submitting" | "submitted" | "saved">("saved"); // hooks - const { fetchInboxIssueById, getIssueInboxByIssueId } = useProjectInbox(); + const { currentTab, fetchInboxIssueById, getIssueInboxByIssueId, getIsIssueAvailable } = useProjectInbox(); const inboxIssue = getIssueInboxByIssueId(inboxIssueId); const { membership: { currentProjectRole }, } = useUser(); + // derived values + const isIssueAvailable = getIsIssueAvailable(inboxIssueId?.toString() || ""); + + useEffect(() => { + if (!isIssueAvailable && inboxIssueId) { + router.replace(`/${workspaceSlug}/projects/${projectId}/inbox?currentTab=${currentTab}`); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isIssueAvailable]); useSWR( workspaceSlug && projectId && inboxIssueId diff --git a/web/components/inbox/sidebar/inbox-list-item.tsx b/web/components/inbox/sidebar/inbox-list-item.tsx index 6faae778a..715342289 100644 --- a/web/components/inbox/sidebar/inbox-list-item.tsx +++ b/web/components/inbox/sidebar/inbox-list-item.tsx @@ -12,31 +12,30 @@ import { renderFormattedDate } from "@/helpers/date-time.helper"; // hooks import { useLabel, useMember, useProjectInbox } from "@/hooks/store"; import { usePlatformOS } from "@/hooks/use-platform-os"; -// store -import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; type InboxIssueListItemProps = { workspaceSlug: string; projectId: string; projectIdentifier?: string; - inboxIssue: IInboxIssueStore; + inboxIssueId: string; setIsMobileSidebar: (value: boolean) => void; }; export const InboxIssueListItem: FC = observer((props) => { - const { workspaceSlug, projectId, inboxIssue, projectIdentifier, setIsMobileSidebar } = props; + const { workspaceSlug, projectId, inboxIssueId, projectIdentifier, setIsMobileSidebar } = props; // router const router = useRouter(); - const { inboxIssueId } = router.query; + const { inboxIssueId: selectedInboxIssueId } = router.query; // store - const { currentTab } = useProjectInbox(); + const { currentTab, getIssueInboxByIssueId } = useProjectInbox(); const { projectLabels } = useLabel(); const { isMobile } = usePlatformOS(); const { getUserDetails } = useMember(); - const issue = inboxIssue.issue; + const inboxIssue = getIssueInboxByIssueId(inboxIssueId); + const issue = inboxIssue?.issue; const handleIssueRedirection = (event: MouseEvent, currentIssueId: string | undefined) => { - if (inboxIssueId === currentIssueId) event.preventDefault(); + if (selectedInboxIssueId === currentIssueId) event.preventDefault(); setIsMobileSidebar(false); }; @@ -55,7 +54,7 @@ export const InboxIssueListItem: FC = observer((props)
diff --git a/web/components/inbox/sidebar/inbox-list.tsx b/web/components/inbox/sidebar/inbox-list.tsx index be435cd77..95d692b60 100644 --- a/web/components/inbox/sidebar/inbox-list.tsx +++ b/web/components/inbox/sidebar/inbox-list.tsx @@ -2,30 +2,28 @@ import { FC, Fragment } from "react"; import { observer } from "mobx-react"; // components import { InboxIssueListItem } from "@/components/inbox"; -// store -import { IInboxIssueStore } from "@/store/inbox/inbox-issue.store"; export type InboxIssueListProps = { workspaceSlug: string; projectId: string; projectIdentifier?: string; - inboxIssues: IInboxIssueStore[]; + inboxIssueIds: string[]; setIsMobileSidebar: (value: boolean) => void; }; export const InboxIssueList: FC = observer((props) => { - const { workspaceSlug, projectId, projectIdentifier, inboxIssues, setIsMobileSidebar } = props; + const { workspaceSlug, projectId, projectIdentifier, inboxIssueIds, setIsMobileSidebar } = props; return ( <> - {inboxIssues.map((inboxIssue) => ( - + {inboxIssueIds.map((inboxIssueId) => ( + ))} diff --git a/web/components/inbox/sidebar/root.tsx b/web/components/inbox/sidebar/root.tsx index f33cb3c2f..ed6d0cdd2 100644 --- a/web/components/inbox/sidebar/root.tsx +++ b/web/components/inbox/sidebar/root.tsx @@ -44,7 +44,7 @@ export const InboxSidebar: FC = observer((props) => { currentTab, handleCurrentTab, loader, - inboxIssuesArray, + inboxIssueIds, inboxIssuePaginationInfo, fetchInboxPaginationIssues, getAppliedFiltersCount, @@ -56,13 +56,9 @@ export const InboxSidebar: FC = observer((props) => { if (!workspaceSlug || !projectId) return; fetchInboxPaginationIssues(workspaceSlug.toString(), projectId.toString()); }, [workspaceSlug, projectId, fetchInboxPaginationIssues]); + // page observer - useIntersectionObserver({ - containerRef, - elementRef, - callback: fetchNextPages, - rootMargin: "20%", - }); + useIntersectionObserver(containerRef, elementRef, fetchNextPages, "20%"); return (
@@ -108,13 +104,13 @@ export const InboxSidebar: FC = observer((props) => { className="w-full h-full overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-md" ref={containerRef} > - {inboxIssuesArray.length > 0 ? ( + {inboxIssueIds.length > 0 ? ( ) : (
@@ -130,15 +126,14 @@ export const InboxSidebar: FC = observer((props) => { />
)} - -
- {inboxIssuePaginationInfo?.next_page_results && ( + {inboxIssuePaginationInfo?.next_page_results && ( +
- )} -
+
+ )}
)}
diff --git a/web/hooks/use-intersection-observer.tsx b/web/hooks/use-intersection-observer.tsx index eb57e58af..71c5f4b7f 100644 --- a/web/hooks/use-intersection-observer.tsx +++ b/web/hooks/use-intersection-observer.tsx @@ -1,4 +1,4 @@ -import { RefObject, useState, useEffect } from "react"; +import { RefObject, useEffect } from "react"; export type UseIntersectionObserverProps = { containerRef: RefObject; @@ -7,18 +7,19 @@ export type UseIntersectionObserverProps = { rootMargin?: string; }; -export const useIntersectionObserver = (props: UseIntersectionObserverProps) => { - const { containerRef, elementRef, callback, rootMargin = "0px" } = props; - const [isVisible, setVisibility] = useState(false); - +export const useIntersectionObserver = ( + containerRef: RefObject, + elementRef: RefObject, + callback: () => void, + rootMargin?: string +) => { useEffect(() => { if (elementRef.current) { const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting) { + (entries) => { + if (entries[entries.length - 1].isIntersecting) { callback(); } - setVisibility(entry.isIntersecting); }, { root: containerRef.current, @@ -37,6 +38,4 @@ export const useIntersectionObserver = (props: UseIntersectionObserverProps) => // fix this eslint warning with caution // eslint-disable-next-line react-hooks/exhaustive-deps }, [rootMargin, callback, elementRef.current, containerRef.current]); - - return isVisible; }; diff --git a/web/store/inbox/project-inbox.store.ts b/web/store/inbox/project-inbox.store.ts index 3ff5495eb..830a58106 100644 --- a/web/store/inbox/project-inbox.store.ts +++ b/web/store/inbox/project-inbox.store.ts @@ -1,3 +1,4 @@ +import { uniq, update } from "lodash"; import isEmpty from "lodash/isEmpty"; import omit from "lodash/omit"; import orderBy from "lodash/orderBy"; @@ -38,11 +39,13 @@ export interface IProjectInboxStore { inboxSorting: Partial; inboxIssuePaginationInfo: TInboxIssuePaginationInfo | undefined; inboxIssues: Record; // issue_id -> IInboxIssueStore + inboxIssueIds: string[]; // computed getAppliedFiltersCount: number; - inboxIssuesArray: IInboxIssueStore[]; - // helper actions + // computed functions getIssueInboxByIssueId: (issueId: string) => IInboxIssueStore; + getIsIssueAvailable: (inboxIssueId: string) => boolean; + // helper actions inboxIssueSorting: (issues: IInboxIssueStore[]) => IInboxIssueStore[]; inboxIssueQueryParams: ( inboxFilters: Partial, @@ -50,6 +53,7 @@ export interface IProjectInboxStore { pagePerCount: number, paginationCursor: string ) => Partial>; + createOrUpdateInboxIssue: (inboxIssues: TInboxIssue[], workspaceSlug: string, projectId: string) => void; // actions handleCurrentTab: (tab: TInboxIssueCurrentTab) => void; handleInboxIssueFilters: (key: T, value: TInboxIssueFilter[T]) => void; // if user sends me undefined, I will remove the value from the filter key @@ -82,6 +86,7 @@ export class ProjectInboxStore implements IProjectInboxStore { }; inboxIssuePaginationInfo: TInboxIssuePaginationInfo | undefined = undefined; inboxIssues: Record = {}; + inboxIssueIds: string[] = []; // services inboxIssueService; @@ -95,9 +100,9 @@ export class ProjectInboxStore implements IProjectInboxStore { inboxSorting: observable, inboxIssuePaginationInfo: observable, inboxIssues: observable, + inboxIssueIds: observable, // computed getAppliedFiltersCount: computed, - inboxIssuesArray: computed, // actions handleInboxIssueFilters: action, handleInboxIssueSorting: action, @@ -122,19 +127,13 @@ export class ProjectInboxStore implements IProjectInboxStore { return count; } - get inboxIssuesArray() { - let appliedFilters = - this.currentTab === EInboxIssueCurrentTab.OPEN - ? [EInboxIssueStatus.PENDING, EInboxIssueStatus.SNOOZED] - : [EInboxIssueStatus.ACCEPTED, EInboxIssueStatus.DECLINED, EInboxIssueStatus.DUPLICATE]; - appliedFilters = appliedFilters.filter((filter) => this.inboxFilters?.status?.includes(filter)); - return this.inboxIssueSorting( - Object.values(this.inboxIssues || {}).filter((inbox) => appliedFilters.includes(inbox.status)) - ); - } - getIssueInboxByIssueId = computedFn((issueId: string) => this.inboxIssues?.[issueId]); + getIsIssueAvailable = computedFn((inboxIssueId: string) => { + if (!this.inboxIssueIds) return true; + return this.inboxIssueIds.includes(inboxIssueId); + }); + // helpers inboxIssueSorting = (issues: IInboxIssueStore[]) => { let inboxIssues: IInboxIssueStore[] = issues; @@ -210,32 +209,55 @@ export class ProjectInboxStore implements IProjectInboxStore { }; }; + createOrUpdateInboxIssue = (inboxIssues: TInboxIssue[], workspaceSlug: string, projectId: string) => { + if (inboxIssues && inboxIssues.length > 0) { + inboxIssues.forEach((inbox: TInboxIssue) => { + const inboxIssueDetail = this.getIssueInboxByIssueId(inbox?.issue?.id); + if (inboxIssueDetail) + update(this.inboxIssues, [inbox?.issue?.id], (existingInboxIssue) => ({ + ...existingInboxIssue, + ...inbox, + issue: { + ...existingInboxIssue?.issue, + ...inbox?.issue, + }, + })); + else + set(this.inboxIssues, [inbox?.issue?.id], new InboxIssueStore(workspaceSlug, projectId, inbox, this.store)); + }); + } + }; + // actions handleCurrentTab = (tab: TInboxIssueCurrentTab) => { - set(this, "currentTab", tab); - set(this, "inboxFilters", undefined); - set(this, ["inboxSorting", "order_by"], "issue__created_at"); - set(this, ["inboxSorting", "sort_by"], "desc"); - set(this, ["inboxIssues"], {}); - set(this, ["inboxIssuePaginationInfo"], undefined); - if (tab === "closed") set(this, ["inboxFilters", "status"], [-1, 1, 2]); - else set(this, ["inboxFilters", "status"], [-2]); + runInAction(() => { + set(this, "currentTab", tab); + set(this, "inboxFilters", undefined); + set(this, ["inboxSorting", "order_by"], "issue__created_at"); + set(this, ["inboxSorting", "sort_by"], "desc"); + set(this, ["inboxIssueIds"], []); + set(this, ["inboxIssuePaginationInfo"], undefined); + if (tab === "closed") set(this, ["inboxFilters", "status"], [-1, 1, 2]); + else set(this, ["inboxFilters", "status"], [-2]); + }); const { workspaceSlug, projectId } = this.store.router; if (workspaceSlug && projectId) this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading"); }; handleInboxIssueFilters = (key: T, value: TInboxIssueFilter[T]) => { - set(this.inboxFilters, key, value); - set(this, ["inboxIssues"], {}); - set(this, ["inboxIssuePaginationInfo"], undefined); + runInAction(() => { + set(this.inboxFilters, key, value); + set(this, ["inboxIssuePaginationInfo"], undefined); + }); const { workspaceSlug, projectId } = this.store.router; if (workspaceSlug && projectId) this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading"); }; handleInboxIssueSorting = (key: T, value: TInboxIssueSorting[T]) => { - set(this.inboxSorting, key, value); - set(this, ["inboxIssues"], {}); - set(this, ["inboxIssuePaginationInfo"], undefined); + runInAction(() => { + set(this.inboxSorting, key, value); + set(this, ["inboxIssuePaginationInfo"], undefined); + }); const { workspaceSlug, projectId } = this.store.router; if (workspaceSlug && projectId) this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading"); }; @@ -248,11 +270,14 @@ export class ProjectInboxStore implements IProjectInboxStore { fetchInboxIssues = async (workspaceSlug: string, projectId: string, loadingType: TLoader = undefined) => { try { if (this.currentInboxProjectId != projectId) { - set(this, ["currentInboxProjectId"], projectId); - set(this, ["inboxIssues"], {}); - set(this, ["inboxIssuePaginationInfo"], undefined); + runInAction(() => { + set(this, ["currentInboxProjectId"], projectId); + set(this, ["inboxIssues"], {}); + set(this, ["inboxIssueIds"], []); + set(this, ["inboxIssuePaginationInfo"], undefined); + }); } - if (Object.keys(this.inboxIssues).length === 0) this.loader = "init-loading"; + if (Object.keys(this.inboxIssueIds).length === 0) this.loader = "init-loading"; else this.loader = "mutation-loading"; if (loadingType) this.loader = loadingType; @@ -267,15 +292,11 @@ export class ProjectInboxStore implements IProjectInboxStore { runInAction(() => { this.loader = undefined; set(this, "inboxIssuePaginationInfo", paginationInfo); - if (results && results.length > 0) - results.forEach((value: TInboxIssue) => { - if (this.getIssueInboxByIssueId(value?.issue?.id) === undefined) - set( - this.inboxIssues, - [value?.issue?.id], - new InboxIssueStore(workspaceSlug, projectId, value, this.store) - ); - }); + if (results) { + const issueIds = results.map((value) => value?.issue?.id); + set(this, ["inboxIssueIds"], issueIds); + this.createOrUpdateInboxIssue(results, workspaceSlug, projectId); + } }); } catch (error) { console.error("Error fetching the inbox issues", error); @@ -299,7 +320,7 @@ export class ProjectInboxStore implements IProjectInboxStore { this.inboxIssuePaginationInfo && (!this.inboxIssuePaginationInfo?.total_results || (this.inboxIssuePaginationInfo?.total_results && - this.inboxIssuesArray.length < this.inboxIssuePaginationInfo?.total_results)) + this.inboxIssueIds.length < this.inboxIssuePaginationInfo?.total_results)) ) { this.loader = "pagination-loading"; @@ -314,15 +335,11 @@ export class ProjectInboxStore implements IProjectInboxStore { runInAction(() => { this.loader = undefined; set(this, "inboxIssuePaginationInfo", paginationInfo); - if (results && results.length > 0) - results.forEach((value: TInboxIssue) => { - if (this.getIssueInboxByIssueId(value?.issue?.id) === undefined) - set( - this.inboxIssues, - [value?.issue?.id], - new InboxIssueStore(workspaceSlug, projectId, value, this.store) - ); - }); + if (results && results.length > 0) { + const issueIds = results.map((value) => value?.issue?.id); + update(this, ["inboxIssueIds"], (ids) => uniq([...ids, ...issueIds])); + this.createOrUpdateInboxIssue(results, workspaceSlug, projectId); + } }); } else set(this, ["inboxIssuePaginationInfo", "next_page_results"], false); } catch (error) { @@ -357,14 +374,16 @@ export class ProjectInboxStore implements IProjectInboxStore { set(this.inboxIssues, [issueId], new InboxIssueStore(workspaceSlug, projectId, inboxIssue, this.store)); set(this, "loader", undefined); }); - // fetching reactions - await this.store.issue.issueDetail.fetchReactions(workspaceSlug, projectId, issueId); - // fetching activity - await this.store.issue.issueDetail.fetchActivities(workspaceSlug, projectId, issueId); - // fetching comments - await this.store.issue.issueDetail.fetchComments(workspaceSlug, projectId, issueId); - // fetching attachments - await this.store.issue.issueDetail.fetchAttachments(workspaceSlug, projectId, issueId); + await Promise.all([ + // fetching reactions + this.store.issue.issueDetail.fetchReactions(workspaceSlug, projectId, issueId), + // fetching activity + this.store.issue.issueDetail.fetchActivities(workspaceSlug, projectId, issueId), + // fetching comments + this.store.issue.issueDetail.fetchComments(workspaceSlug, projectId, issueId), + // fetching attachments + this.store.issue.issueDetail.fetchAttachments(workspaceSlug, projectId, issueId), + ]); } return inboxIssue; } catch (error) { @@ -385,6 +404,7 @@ export class ProjectInboxStore implements IProjectInboxStore { const inboxIssueResponse = await this.inboxIssueService.create(workspaceSlug, projectId, data); if (inboxIssueResponse) runInAction(() => { + update(this, ["inboxIssueIds"], (ids) => [...ids, inboxIssueResponse?.issue?.id]); set( this.inboxIssues, [inboxIssueResponse?.issue?.id], @@ -419,11 +439,18 @@ export class ProjectInboxStore implements IProjectInboxStore { (this.inboxIssuePaginationInfo?.total_results || 0) - 1 ); set(this, "inboxIssues", omit(this.inboxIssues, inboxIssueId)); + set( + this, + ["inboxIssueIds"], + this.inboxIssueIds.filter((id) => id !== inboxIssueId) + ); }); await this.inboxIssueService.destroy(workspaceSlug, projectId, inboxIssueId); } catch { console.error("Error removing the inbox issue"); set(this.inboxIssues, [inboxIssueId], currentIssue); + set(this, ["inboxIssuePaginationInfo", "total_results"], (this.inboxIssuePaginationInfo?.total_results || 0) + 1); + set(this, ["inboxIssueIds"], [...this.inboxIssueIds, inboxIssueId]); } }; }