[WEB-1325] chore: refactor inbox issue store to avoid data loss. (#4640)

* [WEB-1325] chore: refactor inbox issue store to avoid data loss.

* chore: inbox store improvement.
This commit is contained in:
Prateek Shourya 2024-05-31 15:10:38 +05:30 committed by GitHub
parent bf4f97d7f6
commit 0a105a1c21
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 143 additions and 113 deletions

View File

@ -52,7 +52,7 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = 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<TInboxIssueActionsHeader> = 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<TInboxIssueActionsHeader> = 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(

View File

@ -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<TInboxContentRoot> = 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

View File

@ -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<InboxIssueListItemProps> = 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<InboxIssueListItemProps> = observer((props)
<div
className={cn(
`flex flex-col gap-2 relative border border-t-transparent border-l-transparent border-r-transparent border-b-custom-border-200 p-4 hover:bg-custom-primary/5 cursor-pointer transition-all`,
{ "border-custom-primary-100 border": inboxIssueId === issue.id }
{ "border-custom-primary-100 border": selectedInboxIssueId === issue.id }
)}
>
<div className="space-y-1">

View File

@ -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<InboxIssueListProps> = observer((props) => {
const { workspaceSlug, projectId, projectIdentifier, inboxIssues, setIsMobileSidebar } = props;
const { workspaceSlug, projectId, projectIdentifier, inboxIssueIds, setIsMobileSidebar } = props;
return (
<>
{inboxIssues.map((inboxIssue) => (
<Fragment key={inboxIssue.id}>
{inboxIssueIds.map((inboxIssueId) => (
<Fragment key={inboxIssueId}>
<InboxIssueListItem
setIsMobileSidebar={setIsMobileSidebar}
workspaceSlug={workspaceSlug}
projectId={projectId}
projectIdentifier={projectIdentifier}
inboxIssue={inboxIssue}
inboxIssueId={inboxIssueId}
/>
</Fragment>
))}

View File

@ -44,7 +44,7 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
currentTab,
handleCurrentTab,
loader,
inboxIssuesArray,
inboxIssueIds,
inboxIssuePaginationInfo,
fetchInboxPaginationIssues,
getAppliedFiltersCount,
@ -56,13 +56,9 @@ export const InboxSidebar: FC<IInboxSidebarProps> = 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 (
<div className="bg-custom-background-100 flex-shrink-0 w-full h-full border-r border-custom-border-300 ">
@ -108,13 +104,13 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
className="w-full h-full overflow-hidden overflow-y-auto vertical-scrollbar scrollbar-md"
ref={containerRef}
>
{inboxIssuesArray.length > 0 ? (
{inboxIssueIds.length > 0 ? (
<InboxIssueList
setIsMobileSidebar={setIsMobileSidebar}
workspaceSlug={workspaceSlug}
projectId={projectId}
projectIdentifier={currentProjectDetails?.identifier}
inboxIssues={inboxIssuesArray}
inboxIssueIds={inboxIssueIds}
/>
) : (
<div className="flex items-center justify-center h-full w-full">
@ -130,15 +126,14 @@ export const InboxSidebar: FC<IInboxSidebarProps> = observer((props) => {
/>
</div>
)}
<div ref={elementRef}>
{inboxIssuePaginationInfo?.next_page_results && (
<div ref={elementRef}>
<Loader className="mx-auto w-full space-y-4 py-4 px-2">
<Loader.Item height="64px" width="w-100" />
<Loader.Item height="64px" width="w-100" />
</Loader>
)}
</div>
)}
</div>
)}
</div>

View File

@ -1,4 +1,4 @@
import { RefObject, useState, useEffect } from "react";
import { RefObject, useEffect } from "react";
export type UseIntersectionObserverProps = {
containerRef: RefObject<HTMLDivElement>;
@ -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<HTMLDivElement>,
elementRef: RefObject<HTMLDivElement>,
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;
};

View File

@ -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<TInboxIssueSorting>;
inboxIssuePaginationInfo: TInboxIssuePaginationInfo | undefined;
inboxIssues: Record<string, IInboxIssueStore>; // 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<TInboxIssueFilter>,
@ -50,6 +53,7 @@ export interface IProjectInboxStore {
pagePerCount: number,
paginationCursor: string
) => Partial<Record<keyof TInboxIssueFilter, string>>;
createOrUpdateInboxIssue: (inboxIssues: TInboxIssue[], workspaceSlug: string, projectId: string) => void;
// actions
handleCurrentTab: (tab: TInboxIssueCurrentTab) => void;
handleInboxIssueFilters: <T extends keyof TInboxIssueFilter>(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<string, IInboxIssueStore> = {};
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) => {
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, ["inboxIssues"], {});
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 = <T extends keyof TInboxIssueFilter>(key: T, value: TInboxIssueFilter[T]) => {
runInAction(() => {
set(this.inboxFilters, key, value);
set(this, ["inboxIssues"], {});
set(this, ["inboxIssuePaginationInfo"], undefined);
});
const { workspaceSlug, projectId } = this.store.router;
if (workspaceSlug && projectId) this.fetchInboxIssues(workspaceSlug, projectId, "filter-loading");
};
handleInboxIssueSorting = <T extends keyof TInboxIssueSorting>(key: T, value: TInboxIssueSorting[T]) => {
runInAction(() => {
set(this.inboxSorting, key, value);
set(this, ["inboxIssues"], {});
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) {
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);
});
await Promise.all([
// fetching reactions
await this.store.issue.issueDetail.fetchReactions(workspaceSlug, projectId, issueId);
this.store.issue.issueDetail.fetchReactions(workspaceSlug, projectId, issueId),
// fetching activity
await this.store.issue.issueDetail.fetchActivities(workspaceSlug, projectId, issueId);
this.store.issue.issueDetail.fetchActivities(workspaceSlug, projectId, issueId),
// fetching comments
await this.store.issue.issueDetail.fetchComments(workspaceSlug, projectId, issueId);
this.store.issue.issueDetail.fetchComments(workspaceSlug, projectId, issueId),
// fetching attachments
await this.store.issue.issueDetail.fetchAttachments(workspaceSlug, projectId, issueId);
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]);
}
};
}