[WEB-1255] chore: Refactor existing Space app for project publish (#5107)
* chore: paginated the issues in space app * chore: storing query using filters * chore: added filters for priority * chore: issue view model save function * chore: votes and reactions added in issues endpoint * chore: added filters in the public endpoint * chore: issue detail endpoint * chore: added labels, modules and assignees * refactor existing project publish in space app * fix clear all filters in space App * chore: removed the extra serialier * remove optional chaining and fallback to an empty array --------- Co-authored-by: NarayanBavisetti <narayan3119@gmail.com>
This commit is contained in:
parent
22671ec8a7
commit
08d9e95a86
73 changed files with 2245 additions and 651 deletions
|
|
@ -3,8 +3,7 @@
|
|||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
// types
|
||||
import { IStateLite } from "@plane/types";
|
||||
import { IIssueLabel, TFilters } from "@/types/issue";
|
||||
import { TFilters } from "@/types/issue";
|
||||
// components
|
||||
import { AppliedPriorityFilters } from "./priority";
|
||||
import { AppliedStateFilters } from "./state";
|
||||
|
|
@ -13,14 +12,12 @@ type Props = {
|
|||
appliedFilters: TFilters;
|
||||
handleRemoveAllFilters: () => void;
|
||||
handleRemoveFilter: (key: keyof TFilters, value: string | null) => void;
|
||||
labels?: IIssueLabel[] | undefined;
|
||||
states?: IStateLite[] | undefined;
|
||||
};
|
||||
|
||||
export const replaceUnderscoreIfSnakeCase = (str: string) => str.replace(/_/g, " ");
|
||||
|
||||
export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
||||
const { appliedFilters = {}, handleRemoveAllFilters, handleRemoveFilter, states } = props;
|
||||
const { appliedFilters = {}, handleRemoveAllFilters, handleRemoveFilter } = props;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-stretch gap-2">
|
||||
|
|
@ -52,10 +49,9 @@ export const AppliedFiltersList: React.FC<Props> = observer((props) => {
|
|||
/>
|
||||
)} */}
|
||||
|
||||
{filterKey === "state" && states && (
|
||||
{filterKey === "state" && (
|
||||
<AppliedStateFilters
|
||||
handleRemove={(val) => handleRemoveFilter("state", val)}
|
||||
states={states}
|
||||
values={filterValue ?? []}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import cloneDeep from "lodash/cloneDeep";
|
|||
import { observer } from "mobx-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
// hooks
|
||||
import { useIssue, useIssueFilter } from "@/hooks/store";
|
||||
import { useIssueFilter } from "@/hooks/store";
|
||||
// store
|
||||
import { TIssueQueryFilters } from "@/types/issue";
|
||||
// components
|
||||
|
|
@ -21,7 +21,6 @@ export const IssueAppliedFilters: FC<TIssueAppliedFilters> = observer((props) =>
|
|||
const router = useRouter();
|
||||
// store hooks
|
||||
const { getIssueFilters, initIssueFilters, updateIssueFilters } = useIssueFilter();
|
||||
const { states, labels } = useIssue();
|
||||
// derived values
|
||||
const issueFilters = getIssueFilters(anchor);
|
||||
const activeLayout = issueFilters?.display_filters?.layout || undefined;
|
||||
|
|
@ -65,14 +64,18 @@ export const IssueAppliedFilters: FC<TIssueAppliedFilters> = observer((props) =>
|
|||
);
|
||||
|
||||
const handleRemoveAllFilters = () => {
|
||||
initIssueFilters(anchor, {
|
||||
display_filters: { layout: activeLayout || "list" },
|
||||
filters: {
|
||||
state: [],
|
||||
priority: [],
|
||||
labels: [],
|
||||
initIssueFilters(
|
||||
anchor,
|
||||
{
|
||||
display_filters: { layout: activeLayout || "list" },
|
||||
filters: {
|
||||
state: [],
|
||||
priority: [],
|
||||
labels: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
true
|
||||
);
|
||||
|
||||
router.push(`/issues/${anchor}?${`board=${activeLayout || "list"}`}`);
|
||||
};
|
||||
|
|
@ -85,8 +88,6 @@ export const IssueAppliedFilters: FC<TIssueAppliedFilters> = observer((props) =>
|
|||
appliedFilters={appliedFilters || {}}
|
||||
handleRemoveFilter={handleFilters as any}
|
||||
handleRemoveAllFilters={handleRemoveAllFilters}
|
||||
labels={labels ?? []}
|
||||
states={states ?? []}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2,19 +2,20 @@
|
|||
|
||||
import { observer } from "mobx-react";
|
||||
import { X } from "lucide-react";
|
||||
// types
|
||||
import { IStateLite } from "@plane/types";
|
||||
// ui
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
// hooks
|
||||
import { useStates } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
handleRemove: (val: string) => void;
|
||||
states: IStateLite[];
|
||||
values: string[];
|
||||
};
|
||||
|
||||
export const AppliedStateFilters: React.FC<Props> = observer((props) => {
|
||||
const { handleRemove, states, values } = props;
|
||||
const { handleRemove, values } = props;
|
||||
|
||||
const { states } = useStates();
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
|||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useIssue, useIssueFilter } from "@/hooks/store";
|
||||
import { useIssueFilter } from "@/hooks/store";
|
||||
// types
|
||||
import { TIssueQueryFilters } from "@/types/issue";
|
||||
|
||||
|
|
@ -26,7 +26,6 @@ export const IssueFiltersDropdown: FC<IssueFiltersDropdownProps> = observer((pro
|
|||
const router = useRouter();
|
||||
// hooks
|
||||
const { getIssueFilters, updateIssueFilters } = useIssueFilter();
|
||||
const { states, labels } = useIssue();
|
||||
// derived values
|
||||
const issueFilters = getIssueFilters(anchor);
|
||||
const activeLayout = issueFilters?.display_filters?.layout || undefined;
|
||||
|
|
@ -65,8 +64,6 @@ export const IssueFiltersDropdown: FC<IssueFiltersDropdownProps> = observer((pro
|
|||
filters={issueFilters?.filters ?? {}}
|
||||
handleFilters={handleFilters as any}
|
||||
layoutDisplayFiltersOptions={activeLayout ? ISSUE_DISPLAY_FILTERS_BY_LAYOUT?.[activeLayout]?.filters : []}
|
||||
states={states ?? undefined}
|
||||
labels={labels ?? undefined}
|
||||
/>
|
||||
</FiltersDropdown>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import React, { useState } from "react";
|
|||
import { observer } from "mobx-react";
|
||||
import { Search, X } from "lucide-react";
|
||||
// types
|
||||
import { IStateLite } from "@plane/types";
|
||||
import { IIssueLabel, IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue";
|
||||
import { IIssueFilterOptions, TIssueFilterKeys } from "@/types/issue";
|
||||
// components
|
||||
import { FilterPriority, FilterState } from ".";
|
||||
|
||||
|
|
@ -13,12 +12,10 @@ type Props = {
|
|||
filters: IIssueFilterOptions;
|
||||
handleFilters: (key: keyof IIssueFilterOptions, value: string | string[]) => void;
|
||||
layoutDisplayFiltersOptions: TIssueFilterKeys[];
|
||||
labels?: IIssueLabel[] | undefined;
|
||||
states?: IStateLite[] | undefined;
|
||||
};
|
||||
|
||||
export const FilterSelection: React.FC<Props> = observer((props) => {
|
||||
const { filters, handleFilters, layoutDisplayFiltersOptions, states } = props;
|
||||
const { filters, handleFilters, layoutDisplayFiltersOptions } = props;
|
||||
|
||||
const [filtersSearchQuery, setFiltersSearchQuery] = useState("");
|
||||
|
||||
|
|
@ -63,7 +60,6 @@ export const FilterSelection: React.FC<Props> = observer((props) => {
|
|||
appliedFilters={filters.state ?? null}
|
||||
handleUpdate={(val) => handleFilters("state", val)}
|
||||
searchQuery={filtersSearchQuery}
|
||||
states={states}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,24 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
// types
|
||||
import { IStateLite } from "@plane/types";
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { Loader, StateGroupIcon } from "@plane/ui";
|
||||
// components
|
||||
import { FilterHeader, FilterOption } from "@/components/issues/filters/helpers";
|
||||
// hooks
|
||||
import { useStates } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
appliedFilters: string[] | null;
|
||||
handleUpdate: (val: string) => void;
|
||||
searchQuery: string;
|
||||
states: IStateLite[] | undefined;
|
||||
};
|
||||
|
||||
export const FilterState: React.FC<Props> = (props) => {
|
||||
const { appliedFilters, handleUpdate, searchQuery, states } = props;
|
||||
export const FilterState: React.FC<Props> = observer((props) => {
|
||||
const { appliedFilters, handleUpdate, searchQuery } = props;
|
||||
|
||||
const { states } = useStates();
|
||||
|
||||
const [itemsToRender, setItemsToRender] = useState(5);
|
||||
const [previewEnabled, setPreviewEnabled] = useState(true);
|
||||
|
|
@ -77,4 +79,4 @@ export const FilterState: React.FC<Props> = (props) => {
|
|||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,18 +9,17 @@ import { IssueBlockDueDate, IssueBlockPriority, IssueBlockState } from "@/compon
|
|||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hooks
|
||||
import { useIssueDetails, usePublish } from "@/hooks/store";
|
||||
import { useIssue, useIssueDetails, usePublish } from "@/hooks/store";
|
||||
// interfaces
|
||||
import { IIssue } from "@/types/issue";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
issue: IIssue;
|
||||
params: any;
|
||||
issueId: string;
|
||||
};
|
||||
|
||||
export const IssueKanBanBlock: FC<Props> = observer((props) => {
|
||||
const { anchor, issue } = props;
|
||||
const { anchor, issueId } = props;
|
||||
const { getIssueById } = useIssue();
|
||||
const searchParams = useSearchParams();
|
||||
// query params
|
||||
const board = searchParams.get("board");
|
||||
|
|
@ -31,12 +30,16 @@ export const IssueKanBanBlock: FC<Props> = observer((props) => {
|
|||
const { project_details } = usePublish(anchor);
|
||||
const { setPeekId } = useIssueDetails();
|
||||
|
||||
const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels });
|
||||
const { queryParam } = queryParamGenerator({ board, peekId: issueId, priority, state, labels });
|
||||
|
||||
const handleBlockClick = () => {
|
||||
setPeekId(issue.id);
|
||||
setPeekId(issueId);
|
||||
};
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return <></>;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${anchor}?${queryParam}`}
|
||||
|
|
@ -61,15 +64,15 @@ export const IssueKanBanBlock: FC<Props> = observer((props) => {
|
|||
</div>
|
||||
)}
|
||||
{/* state */}
|
||||
{issue?.state_detail && (
|
||||
{issue?.state_id && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockState state={issue?.state_detail} />
|
||||
<IssueBlockState stateId={issue?.state_id} />
|
||||
</div>
|
||||
)}
|
||||
{/* due date */}
|
||||
{issue?.target_date && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
|
||||
<IssueBlockDueDate due_date={issue?.target_date} stateId={issue?.state_id ?? undefined} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
70
space/core/components/issues/issue-layouts/kanban/column.tsx
Normal file
70
space/core/components/issues/issue-layouts/kanban/column.tsx
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { useCallback, useRef, useState } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { Icon } from "@/components/ui";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
import { useIntersectionObserver } from "@/hooks/use-intersection-observer";
|
||||
// components
|
||||
import { IssueKanBanBlock } from "./block";
|
||||
import { IssueKanBanHeader } from "./header";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
stateId: string;
|
||||
issueIds: string[];
|
||||
};
|
||||
|
||||
export const Column = observer((props: Props) => {
|
||||
const { anchor, stateId, issueIds } = props;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [intersectionElement, setIntersectionElement] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
const { fetchNextPublicIssues, getPaginationData, getIssueLoader, getGroupIssueCount } = useIssue();
|
||||
|
||||
const loadMoreIssuesInThisGroup = useCallback(() => {
|
||||
fetchNextPublicIssues(anchor, stateId);
|
||||
}, [fetchNextPublicIssues, anchor, stateId]);
|
||||
|
||||
const isPaginating = !!getIssueLoader(stateId);
|
||||
const nextPageResults = getPaginationData(stateId, undefined)?.nextPageResults;
|
||||
|
||||
useIntersectionObserver(
|
||||
containerRef,
|
||||
isPaginating ? null : intersectionElement,
|
||||
loadMoreIssuesInThisGroup,
|
||||
`0% 100% 100% 100%`
|
||||
);
|
||||
|
||||
const groupIssueCount = getGroupIssueCount(stateId, undefined, false);
|
||||
const shouldLoadMore =
|
||||
nextPageResults === undefined && groupIssueCount !== undefined
|
||||
? issueIds?.length < groupIssueCount
|
||||
: !!nextPageResults;
|
||||
|
||||
return (
|
||||
<div key={stateId} className="relative flex h-full w-[340px] flex-shrink-0 flex-col">
|
||||
<div className="flex-shrink-0">
|
||||
<IssueKanBanHeader stateId={stateId} />
|
||||
</div>
|
||||
<div className="hide-vertical-scrollbar h-full w-full overflow-hidden overflow-y-auto" ref={containerRef}>
|
||||
{issueIds && issueIds.length > 0 ? (
|
||||
<div className="space-y-3 px-2 pb-2">
|
||||
{issueIds.map((issueId) => (
|
||||
<IssueKanBanBlock key={issueId} anchor={anchor} issueId={issueId} />
|
||||
))}
|
||||
{shouldLoadMore && (
|
||||
<div className="w-full h-[100px] bg-custom-background-80 animate-pulse" ref={setIntersectionElement} />
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2 pt-10 text-center text-sm font-medium text-custom-text-200">
|
||||
<Icon iconName="stack" />
|
||||
No issues in this state
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -1,25 +1,32 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { IStateLite } from "@plane/types";
|
||||
// ui
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssue, useStates } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
state: IStateLite;
|
||||
stateId: string;
|
||||
};
|
||||
|
||||
export const IssueKanBanHeader: React.FC<Props> = observer((props) => {
|
||||
const { state } = props;
|
||||
const { stateId } = props;
|
||||
|
||||
const { getStateById } = useStates();
|
||||
const { getGroupIssueCount } = useIssue();
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 px-2 pb-2">
|
||||
<div className="flex h-3.5 w-3.5 flex-shrink-0 items-center justify-center">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} height="14" width="14" />
|
||||
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} height="14" width="14" />
|
||||
</div>
|
||||
<div className="mr-1 truncate font-medium capitalize text-custom-text-200">{state?.name}</div>
|
||||
{/* <span className="flex-shrink-0 rounded-full text-custom-text-300">{getCountOfIssuesByState(state.id)}</span> */}
|
||||
<div className="mr-1 truncate font-medium capitalize text-custom-text-200">{state?.name ?? "State"}</div>
|
||||
<span className="flex-shrink-0 rounded-full text-custom-text-300">
|
||||
{getGroupIssueCount(stateId, undefined, false) ?? 0}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,12 +2,11 @@
|
|||
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { IssueKanBanBlock, IssueKanBanHeader } from "@/components/issues";
|
||||
// ui
|
||||
import { Icon } from "@/components/ui";
|
||||
// mobx hook
|
||||
import { TGroupedIssues } from "@plane/types";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
import { Column } from "./column";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
|
|
@ -16,34 +15,19 @@ type Props = {
|
|||
export const IssueKanbanLayoutRoot: FC<Props> = observer((props) => {
|
||||
const { anchor } = props;
|
||||
// store hooks
|
||||
const { states, getFilteredIssuesByState } = useIssue();
|
||||
const { groupedIssueIds } = useIssue();
|
||||
|
||||
const groupedIssues = groupedIssueIds as TGroupedIssues | undefined;
|
||||
|
||||
if (!groupedIssues) return <></>;
|
||||
|
||||
const issueGroupIds = Object.keys(groupedIssues);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full w-full gap-3 overflow-hidden overflow-x-auto">
|
||||
{states?.map((state) => {
|
||||
const issues = getFilteredIssuesByState(state.id);
|
||||
|
||||
return (
|
||||
<div key={state.id} className="relative flex h-full w-[340px] flex-shrink-0 flex-col">
|
||||
<div className="flex-shrink-0">
|
||||
<IssueKanBanHeader state={state} />
|
||||
</div>
|
||||
<div className="hide-vertical-scrollbar h-full w-full overflow-hidden overflow-y-auto">
|
||||
{issues && issues.length > 0 ? (
|
||||
<div className="space-y-3 px-2 pb-2">
|
||||
{issues.map((issue) => (
|
||||
<IssueKanBanBlock key={issue.id} anchor={anchor} issue={issue} params={{}} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-center gap-2 pt-10 text-center text-sm font-medium text-custom-text-200">
|
||||
<Icon iconName="stack" />
|
||||
No issues in this state
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{issueGroupIds?.map((stateId) => {
|
||||
const issueIds = groupedIssues[stateId];
|
||||
return <Column key={stateId} anchor={anchor} stateId={stateId} issueIds={issueIds} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -8,17 +8,16 @@ import { IssueBlockDueDate, IssueBlockLabels, IssueBlockPriority, IssueBlockStat
|
|||
// helpers
|
||||
import { queryParamGenerator } from "@/helpers/query-param-generator";
|
||||
// hook
|
||||
import { useIssueDetails, usePublish } from "@/hooks/store";
|
||||
// types
|
||||
import { IIssue } from "@/types/issue";
|
||||
import { useIssue, useIssueDetails, usePublish } from "@/hooks/store";
|
||||
|
||||
type IssueListBlockProps = {
|
||||
anchor: string;
|
||||
issue: IIssue;
|
||||
issueId: string;
|
||||
};
|
||||
|
||||
export const IssueListLayoutBlock: FC<IssueListBlockProps> = observer((props) => {
|
||||
const { anchor, issue } = props;
|
||||
const { anchor, issueId } = props;
|
||||
const { getIssueById } = useIssue();
|
||||
// query params
|
||||
const searchParams = useSearchParams();
|
||||
const board = searchParams.get("board") || undefined;
|
||||
|
|
@ -29,11 +28,15 @@ export const IssueListLayoutBlock: FC<IssueListBlockProps> = observer((props) =>
|
|||
const { setPeekId } = useIssueDetails();
|
||||
const { project_details } = usePublish(anchor);
|
||||
|
||||
const { queryParam } = queryParamGenerator({ board, peekId: issue.id, priority, state, labels });
|
||||
const { queryParam } = queryParamGenerator({ board, peekId: issueId, priority, state, labels });
|
||||
const handleBlockClick = () => {
|
||||
setPeekId(issue.id);
|
||||
setPeekId(issueId);
|
||||
};
|
||||
|
||||
const issue = getIssueById(issueId);
|
||||
|
||||
if (!issue) return <></>;
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/issues/${anchor}?${queryParam}`}
|
||||
|
|
@ -60,23 +63,23 @@ export const IssueListLayoutBlock: FC<IssueListBlockProps> = observer((props) =>
|
|||
)}
|
||||
|
||||
{/* state */}
|
||||
{issue?.state_detail && (
|
||||
{issue?.state_id && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockState state={issue?.state_detail} />
|
||||
<IssueBlockState stateId={issue?.state_id} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* labels */}
|
||||
{issue?.label_details && issue?.label_details.length > 0 && (
|
||||
{issue?.label_ids && issue?.label_ids.length > 0 && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockLabels labels={issue?.label_details} />
|
||||
<IssueBlockLabels labelIds={issue?.label_ids} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* due date */}
|
||||
{issue?.target_date && (
|
||||
<div className="flex-shrink-0">
|
||||
<IssueBlockDueDate due_date={issue?.target_date} group={issue?.state_detail?.group} />
|
||||
<IssueBlockDueDate due_date={issue?.target_date} stateId={issue?.state_id ?? undefined} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
59
space/core/components/issues/issue-layouts/list/group.tsx
Normal file
59
space/core/components/issues/issue-layouts/list/group.tsx
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { useCallback } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// hooks
|
||||
import { useIssue } from "@/hooks/store";
|
||||
// components
|
||||
import { IssueListLayoutBlock } from "./block";
|
||||
import { IssueListLayoutHeader } from "./header";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
stateId: string;
|
||||
issueIds: string[];
|
||||
};
|
||||
|
||||
export const Group = observer((props: Props) => {
|
||||
const { anchor, stateId, issueIds } = props;
|
||||
|
||||
const { fetchNextPublicIssues, getPaginationData, getIssueLoader, getGroupIssueCount } = useIssue();
|
||||
|
||||
const loadMoreIssuesInThisGroup = useCallback(() => {
|
||||
fetchNextPublicIssues(anchor, stateId);
|
||||
}, [stateId]);
|
||||
|
||||
const isPaginating = !!getIssueLoader(stateId);
|
||||
const nextPageResults = getPaginationData(stateId, undefined)?.nextPageResults;
|
||||
|
||||
const groupIssueCount = getGroupIssueCount(stateId, undefined, false);
|
||||
const shouldLoadMore =
|
||||
nextPageResults === undefined && groupIssueCount !== undefined
|
||||
? issueIds?.length < groupIssueCount
|
||||
: !!nextPageResults;
|
||||
|
||||
return (
|
||||
<div key={stateId} className="relative w-full">
|
||||
<IssueListLayoutHeader stateId={stateId} />
|
||||
{issueIds && issueIds.length > 0 ? (
|
||||
<div className="divide-y divide-custom-border-200">
|
||||
{issueIds.map((issueId) => (
|
||||
<IssueListLayoutBlock key={issueId} anchor={anchor} issueId={issueId} />
|
||||
))}
|
||||
{isPaginating ? (
|
||||
<div className="w-full h-[46px] bg-custom-background-80 animate-pulse" />
|
||||
) : (
|
||||
shouldLoadMore && (
|
||||
<div
|
||||
className="w-full min-h-[45px] bg-custom-background-100 p-3 text-sm border-b-[1px] cursor-pointer text-custom-text-350 hover:text-custom-text-300"
|
||||
onClick={loadMoreIssuesInThisGroup}
|
||||
>
|
||||
Load More ↓
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-custom-background-100 p-3 text-sm text-custom-text-400">No issues.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
@ -2,25 +2,32 @@
|
|||
|
||||
import React from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// types
|
||||
import { IStateLite } from "@plane/types";
|
||||
// ui
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
// hooks
|
||||
import { useIssue, useStates } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
state: IStateLite;
|
||||
stateId: string;
|
||||
};
|
||||
|
||||
export const IssueListLayoutHeader: React.FC<Props> = observer((props) => {
|
||||
const { state } = props;
|
||||
const { stateId } = props;
|
||||
|
||||
const { getStateById } = useStates();
|
||||
const { getGroupIssueCount } = useIssue();
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 p-3">
|
||||
<div className="flex sticky top-0 items-center gap-2 p-3 bg-custom-background-90 z-[1] border-b-[1px] border-custom-border-200">
|
||||
<div className="flex h-3.5 w-3.5 items-center justify-center">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} height="14" width="14" />
|
||||
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} height="14" width="14" />
|
||||
</div>
|
||||
<div className="mr-1 font-medium capitalize">{state?.name}</div>
|
||||
{/* <div className="text-sm font-medium text-custom-text-200">{count}</div> */}
|
||||
<div className="text-sm font-medium text-custom-text-200">
|
||||
{getGroupIssueCount(stateId, undefined, false) ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
"use client";
|
||||
import { FC } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { IssueListLayoutBlock, IssueListLayoutHeader } from "@/components/issues";
|
||||
// types
|
||||
import { TGroupedIssues } from "@plane/types";
|
||||
// mobx hook
|
||||
import { useIssue } from "@/hooks/store";
|
||||
import { Group } from "./group";
|
||||
|
||||
type Props = {
|
||||
anchor: string;
|
||||
|
|
@ -13,27 +14,20 @@ type Props = {
|
|||
export const IssuesListLayoutRoot: FC<Props> = observer((props) => {
|
||||
const { anchor } = props;
|
||||
// store hooks
|
||||
const { states, getFilteredIssuesByState } = useIssue();
|
||||
const { groupedIssueIds } = useIssue();
|
||||
|
||||
const groupedIssues = groupedIssueIds as TGroupedIssues | undefined;
|
||||
|
||||
if (!groupedIssues) return <></>;
|
||||
|
||||
const issueGroupIds = Object.keys(groupedIssues);
|
||||
|
||||
return (
|
||||
<>
|
||||
{states?.map((state) => {
|
||||
const issues = getFilteredIssuesByState(state.id);
|
||||
{issueGroupIds?.map((stateId) => {
|
||||
const issueIds = groupedIssues[stateId];
|
||||
|
||||
return (
|
||||
<div key={state.id} className="relative w-full">
|
||||
<IssueListLayoutHeader state={state} />
|
||||
{issues && issues.length > 0 ? (
|
||||
<div className="divide-y divide-custom-border-200">
|
||||
{issues.map((issue) => (
|
||||
<IssueListLayoutBlock key={issue.id} anchor={anchor} issue={issue} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-custom-background-100 p-3 text-sm text-custom-text-400">No issues.</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return <Group key={stateId} anchor={anchor} stateId={stateId} issueIds={issueIds} />;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,27 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { CalendarCheck2 } from "lucide-react";
|
||||
// types
|
||||
import { TStateGroups } from "@plane/types";
|
||||
// helpers
|
||||
import { cn } from "@/helpers/common.helper";
|
||||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
// hooks
|
||||
import { useStates } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
due_date: string;
|
||||
group: TStateGroups;
|
||||
stateId: string | undefined;
|
||||
};
|
||||
|
||||
export const IssueBlockDueDate = (props: Props) => {
|
||||
const { due_date, group } = props;
|
||||
export const IssueBlockDueDate = observer((props: Props) => {
|
||||
const { due_date, stateId } = props;
|
||||
const { getStateById } = useStates();
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs text-custom-text-100",
|
||||
{
|
||||
"text-red-500": shouldHighlightIssueDueDate(due_date, group),
|
||||
"text-red-500": shouldHighlightIssueDueDate(due_date, state?.group),
|
||||
}
|
||||
)}
|
||||
>
|
||||
|
|
@ -29,4 +33,4 @@ export const IssueBlockDueDate = (props: Props) => {
|
|||
{renderFormattedDate(due_date)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,17 +1,41 @@
|
|||
"use client";
|
||||
|
||||
export const IssueBlockLabels = ({ labels }: any) => (
|
||||
<div className="relative flex flex-wrap items-center gap-1">
|
||||
{labels?.map((_label: any) => (
|
||||
<div
|
||||
key={_label?.id}
|
||||
className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: `${_label?.color}` }} />
|
||||
<div className="text-xs">{_label?.name}</div>
|
||||
import { observer } from "mobx-react";
|
||||
import { Tooltip } from "@plane/ui";
|
||||
import { useLabel } from "@/hooks/store";
|
||||
|
||||
type Props = {
|
||||
labelIds: string[];
|
||||
};
|
||||
|
||||
export const IssueBlockLabels = observer(({ labelIds }: Props) => {
|
||||
const { getLabelsByIds } = useLabel();
|
||||
|
||||
const labels = getLabelsByIds(labelIds);
|
||||
|
||||
const labelsString = labels.map((label) => label.name).join(", ");
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-wrap items-center gap-1">
|
||||
{labels.length === 1 ? (
|
||||
<div
|
||||
key={labels[0].id}
|
||||
className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm"
|
||||
>
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<div className="h-2 w-2 rounded-full" style={{ backgroundColor: `${labels[0].color}` }} />
|
||||
<div className="text-xs">{labels[0].name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
) : (
|
||||
<Tooltip tooltipContent={labelsString}>
|
||||
<div className="flex flex-shrink-0 cursor-default items-center rounded-md border border-custom-border-300 px-2.5 py-1 text-xs shadow-sm">
|
||||
<div className="flex items-center gap-1.5 text-custom-text-200">
|
||||
<div className="text-xs">{labels.length} Labels</div>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,27 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
// ui
|
||||
import { StateGroupIcon } from "@plane/ui";
|
||||
//hooks
|
||||
import { useStates } from "@/hooks/store";
|
||||
|
||||
export const IssueBlockState = ({ state }: any) => (
|
||||
<div className="flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs shadow-sm duration-300 focus:outline-none">
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} />
|
||||
<div className="text-xs">{state?.name}</div>
|
||||
type Props = {
|
||||
stateId: string;
|
||||
};
|
||||
export const IssueBlockState = observer(({ stateId }: Props) => {
|
||||
const { getStateById } = useStates();
|
||||
|
||||
const state = getStateById(stateId);
|
||||
|
||||
if (!state) return <></>;
|
||||
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between gap-1 rounded border-[0.5px] border-custom-border-300 px-2.5 py-1 text-xs shadow-sm duration-300 focus:outline-none">
|
||||
<div className="flex w-full items-center gap-1.5">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} />
|
||||
<div className="text-xs">{state?.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@
|
|||
import { FC, useEffect } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
import Image from "next/image";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import useSWR from "swr";
|
||||
// components
|
||||
import { IssueKanbanLayoutRoot, IssuesListLayoutRoot } from "@/components/issues";
|
||||
|
|
@ -23,22 +22,22 @@ type Props = {
|
|||
|
||||
export const IssuesLayoutsRoot: FC<Props> = observer((props) => {
|
||||
const { peekId, publishSettings } = props;
|
||||
// query params
|
||||
const searchParams = useSearchParams();
|
||||
const states = searchParams.get("states") || undefined;
|
||||
const priority = searchParams.get("priority") || undefined;
|
||||
const labels = searchParams.get("labels") || undefined;
|
||||
// store hooks
|
||||
const { getIssueFilters } = useIssueFilter();
|
||||
const { loader, issues, error, fetchPublicIssues } = useIssue();
|
||||
const { loader, groupedIssueIds, fetchPublicIssues } = useIssue();
|
||||
const issueDetailStore = useIssueDetails();
|
||||
// derived values
|
||||
const { anchor } = publishSettings;
|
||||
const issueFilters = anchor ? getIssueFilters(anchor) : undefined;
|
||||
// derived values
|
||||
const activeLayout = issueFilters?.display_filters?.layout || undefined;
|
||||
|
||||
useSWR(
|
||||
const { error } = useSWR(
|
||||
anchor ? `PUBLIC_ISSUES_${anchor}` : null,
|
||||
anchor ? () => fetchPublicIssues(anchor, { states, priority, labels }) : null
|
||||
anchor
|
||||
? () => fetchPublicIssues(anchor, "init-loader", { groupedBy: "state", canGroup: true, perPageCount: 50 })
|
||||
: null,
|
||||
{ revalidateIfStale: false, revalidateOnFocus: false }
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -47,16 +46,13 @@ export const IssuesLayoutsRoot: FC<Props> = observer((props) => {
|
|||
}
|
||||
}, [peekId, issueDetailStore]);
|
||||
|
||||
// derived values
|
||||
const activeLayout = issueFilters?.display_filters?.layout || undefined;
|
||||
|
||||
if (!anchor) return null;
|
||||
|
||||
return (
|
||||
<div className="relative h-full w-full overflow-hidden">
|
||||
{peekId && <IssuePeekOverview anchor={anchor} peekId={peekId} />}
|
||||
|
||||
{loader && !issues ? (
|
||||
{loader && !groupedIssueIds ? (
|
||||
<div className="py-10 text-center text-sm text-custom-text-100">Loading...</div>
|
||||
) : (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { observer } from "mobx-react";
|
||||
// components
|
||||
import { RichTextReadOnlyEditor } from "@/components/editor";
|
||||
import { IssueReactions } from "@/components/issues/peek-overview";
|
||||
import { usePublish } from "@/hooks/store";
|
||||
// types
|
||||
import { IIssue } from "@/types/issue";
|
||||
|
||||
|
|
@ -9,15 +11,17 @@ type Props = {
|
|||
issueDetails: IIssue;
|
||||
};
|
||||
|
||||
export const PeekOverviewIssueDetails: React.FC<Props> = (props) => {
|
||||
export const PeekOverviewIssueDetails: React.FC<Props> = observer((props) => {
|
||||
const { anchor, issueDetails } = props;
|
||||
|
||||
const { project_details } = usePublish(anchor);
|
||||
|
||||
const description = issueDetails.description_html;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h6 className="text-base font-medium text-custom-text-400">
|
||||
{issueDetails.project_detail?.identifier}-{issueDetails?.sequence_id}
|
||||
{project_details?.identifier}-{issueDetails?.sequence_id}
|
||||
</h6>
|
||||
<h4 className="break-words text-2xl font-medium">{issueDetails.name}</h4>
|
||||
{description !== "" && description !== "<p></p>" && (
|
||||
|
|
@ -34,4 +38,4 @@ export const PeekOverviewIssueDetails: React.FC<Props> = (props) => {
|
|||
<IssueReactions anchor={anchor} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,10 +32,10 @@ export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer(
|
|||
const { data: user } = useUser();
|
||||
|
||||
const issueId = issueDetailsStore.peekId;
|
||||
const reactions = issueId ? issueDetailsStore.details[issueId]?.reactions || [] : [];
|
||||
const reactions = issueDetailsStore.details[issueId ?? ""]?.reaction_items ?? [];
|
||||
const groupedReactions = groupReactions(reactions, "reaction");
|
||||
|
||||
const userReactions = reactions?.filter((r) => r.actor_detail.id === user?.id);
|
||||
const userReactions = reactions.filter((r) => r.actor_details?.id === user?.id);
|
||||
|
||||
const handleAddReaction = (reactionHex: string) => {
|
||||
if (!issueId) return;
|
||||
|
|
@ -48,7 +48,7 @@ export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer(
|
|||
};
|
||||
|
||||
const handleReactionClick = (reactionHex: string) => {
|
||||
const userReaction = userReactions?.find((r) => r.actor_detail.id === user?.id && r.reaction === reactionHex);
|
||||
const userReaction = userReactions?.find((r) => r.actor_details?.id === user?.id && r.reaction === reactionHex);
|
||||
if (userReaction) handleRemoveReaction(reactionHex);
|
||||
else handleAddReaction(reactionHex);
|
||||
};
|
||||
|
|
@ -78,9 +78,9 @@ export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer(
|
|||
tooltipContent={
|
||||
<div>
|
||||
{reactions
|
||||
.map((r) => r.actor_detail.display_name)
|
||||
.splice(0, REACTIONS_LIMIT)
|
||||
.join(", ")}
|
||||
?.map((r) => r?.actor_details?.display_name)
|
||||
?.splice(0, REACTIONS_LIMIT)
|
||||
?.join(", ")}
|
||||
{reactions.length > REACTIONS_LIMIT && " and " + (reactions.length - REACTIONS_LIMIT) + " more"}
|
||||
</div>
|
||||
}
|
||||
|
|
@ -92,7 +92,7 @@ export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer(
|
|||
else router.push(`/?next_path=${pathName}?${queryParam}`);
|
||||
}}
|
||||
className={`flex h-full items-center gap-1 rounded-md px-2 py-1 text-sm text-custom-text-100 ${
|
||||
reactions?.some((r) => r.actor_detail.id === user?.id && r.reaction === reaction)
|
||||
reactions.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction)
|
||||
? "bg-custom-primary-100/10"
|
||||
: "bg-custom-background-80"
|
||||
}`}
|
||||
|
|
@ -100,7 +100,7 @@ export const IssueEmojiReactions: React.FC<IssueEmojiReactionsProps> = observer(
|
|||
<span>{renderEmoji(reaction)}</span>
|
||||
<span
|
||||
className={
|
||||
reactions?.some((r) => r.actor_detail.id === user?.id && r.reaction === reaction)
|
||||
reactions.some((r) => r?.actor_details?.id === user?.id && r.reaction === reaction)
|
||||
? "text-custom-primary-100"
|
||||
: ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import { observer } from "mobx-react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { CalendarCheck2, Signal } from "lucide-react";
|
||||
// ui
|
||||
import { DoubleCircleIcon, StateGroupIcon, TOAST_TYPE, setToast } from "@plane/ui";
|
||||
|
|
@ -12,6 +14,8 @@ import { cn } from "@/helpers/common.helper";
|
|||
import { renderFormattedDate } from "@/helpers/date-time.helper";
|
||||
import { shouldHighlightIssueDueDate } from "@/helpers/issue.helper";
|
||||
import { copyTextToClipboard, addSpaceIfCamelCase } from "@/helpers/string.helper";
|
||||
// hooks
|
||||
import { usePublish, useStates } from "@/hooks/store";
|
||||
// types
|
||||
import { IIssue, IPeekMode } from "@/types/issue";
|
||||
|
||||
|
|
@ -20,8 +24,13 @@ type Props = {
|
|||
mode?: IPeekMode;
|
||||
};
|
||||
|
||||
export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mode }) => {
|
||||
const state = issueDetails.state_detail;
|
||||
export const PeekOverviewIssueProperties: React.FC<Props> = observer(({ issueDetails, mode }) => {
|
||||
const { getStateById } = useStates();
|
||||
const state = getStateById(issueDetails?.state_id ?? undefined);
|
||||
|
||||
const { anchor } = useParams();
|
||||
|
||||
const { project_details } = usePublish(anchor?.toString());
|
||||
|
||||
const priority = issueDetails.priority ? issuePriorityFilter(issueDetails.priority) : null;
|
||||
|
||||
|
|
@ -42,7 +51,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mod
|
|||
{mode === "full" && (
|
||||
<div className="flex justify-between gap-2 pb-3">
|
||||
<h6 className="flex items-center gap-2 font-medium">
|
||||
{issueDetails.project_detail.identifier}-{issueDetails.sequence_id}
|
||||
{project_details?.identifier}-{issueDetails.sequence_id}
|
||||
</h6>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={handleCopyLink} className="-rotate-45">
|
||||
|
|
@ -58,7 +67,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mod
|
|||
<span>State</span>
|
||||
</div>
|
||||
<div className="w-3/4 flex items-center gap-1.5 py-0.5 text-sm">
|
||||
<StateGroupIcon stateGroup={state.group} color={state.color} />
|
||||
<StateGroupIcon stateGroup={state?.group ?? "backlog"} color={state?.color} />
|
||||
{addSpaceIfCamelCase(state?.name ?? "")}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -101,10 +110,7 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mod
|
|||
{issueDetails.target_date ? (
|
||||
<div
|
||||
className={cn("flex items-center gap-1.5 rounded py-0.5 text-xs text-custom-text-100", {
|
||||
"text-red-500": shouldHighlightIssueDueDate(
|
||||
issueDetails.target_date,
|
||||
issueDetails.state_detail.group
|
||||
),
|
||||
"text-red-500": shouldHighlightIssueDueDate(issueDetails.target_date, state?.group),
|
||||
})}
|
||||
>
|
||||
<CalendarCheck2 className="size-3" />
|
||||
|
|
@ -118,4 +124,4 @@ export const PeekOverviewIssueProperties: React.FC<Props> = ({ issueDetails, mod
|
|||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,20 +37,20 @@ export const IssueVotes: React.FC<TIssueVotes> = observer((props) => {
|
|||
|
||||
const issueId = issueDetailsStore.peekId;
|
||||
|
||||
const votes = issueId ? issueDetailsStore.details[issueId]?.votes : [];
|
||||
const votes = issueDetailsStore.details[issueId ?? ""]?.vote_items ?? [];
|
||||
|
||||
const allUpVotes = votes?.filter((vote) => vote.vote === 1);
|
||||
const allDownVotes = votes?.filter((vote) => vote.vote === -1);
|
||||
const allUpVotes = votes.filter((vote) => vote.vote === 1);
|
||||
const allDownVotes = votes.filter((vote) => vote.vote === -1);
|
||||
|
||||
const isUpVotedByUser = allUpVotes?.some((vote) => vote.actor === user?.id);
|
||||
const isDownVotedByUser = allDownVotes?.some((vote) => vote.actor === user?.id);
|
||||
const isUpVotedByUser = allUpVotes.some((vote) => vote.actor_details?.id === user?.id);
|
||||
const isDownVotedByUser = allDownVotes.some((vote) => vote.actor_details?.id === user?.id);
|
||||
|
||||
const handleVote = async (e: any, voteValue: 1 | -1) => {
|
||||
if (!issueId) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
const actionPerformed = votes?.find((vote) => vote.actor === user?.id && vote.vote === voteValue);
|
||||
const actionPerformed = votes?.find((vote) => vote.actor_details?.id === user?.id && vote.vote === voteValue);
|
||||
|
||||
if (actionPerformed) await issueDetailsStore.removeIssueVote(anchor, issueId);
|
||||
else {
|
||||
|
|
@ -76,7 +76,7 @@ export const IssueVotes: React.FC<TIssueVotes> = observer((props) => {
|
|||
{allUpVotes.length > 0 ? (
|
||||
<>
|
||||
{allUpVotes
|
||||
.map((r) => r.actor_detail.display_name)
|
||||
.map((r) => r.actor_details?.display_name)
|
||||
.splice(0, VOTES_LIMIT)
|
||||
.join(", ")}
|
||||
{allUpVotes.length > VOTES_LIMIT && " and " + (allUpVotes.length - VOTES_LIMIT) + " more"}
|
||||
|
|
@ -116,7 +116,7 @@ export const IssueVotes: React.FC<TIssueVotes> = observer((props) => {
|
|||
{allDownVotes.length > 0 ? (
|
||||
<>
|
||||
{allDownVotes
|
||||
.map((r) => r.actor_detail.display_name)
|
||||
.map((r) => r.actor_details.display_name)
|
||||
.splice(0, VOTES_LIMIT)
|
||||
.join(", ")}
|
||||
{allDownVotes.length > VOTES_LIMIT && " and " + (allDownVotes.length - VOTES_LIMIT) + " more"}
|
||||
|
|
|
|||
|
|
@ -33,12 +33,12 @@ export const IssuePeekOverview: FC<TIssuePeekOverview> = observer((props) => {
|
|||
const issueDetails = issueDetailStore.peekId && peekId ? issueDetailStore.details[peekId.toString()] : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (anchor && peekId && issueStore.issues && issueStore.issues.length > 0) {
|
||||
if (anchor && peekId && issueStore.groupedIssueIds) {
|
||||
if (!issueDetails) {
|
||||
issueDetailStore.fetchIssueDetails(anchor, peekId.toString());
|
||||
}
|
||||
}
|
||||
}, [anchor, issueDetailStore, issueDetails, peekId, issueStore.issues]);
|
||||
}, [anchor, issueDetailStore, issueDetails, peekId, issueStore.groupedIssueIds]);
|
||||
|
||||
const handleClose = () => {
|
||||
issueDetailStore.setPeekId(null);
|
||||
|
|
|
|||
|
|
@ -75,4 +75,4 @@ export const issuePriorityFilter = (priorityKey: TIssuePriorities): TIssueFilter
|
|||
|
||||
if (currentIssuePriority) return currentIssuePriority;
|
||||
return undefined;
|
||||
};
|
||||
};
|
||||
|
|
@ -5,3 +5,5 @@ export * from "./use-user";
|
|||
export * from "./use-user-profile";
|
||||
export * from "./use-issue-details";
|
||||
export * from "./use-issue-filter";
|
||||
export * from "./use-state";
|
||||
export * from "./use-label";
|
||||
|
|
|
|||
|
|
@ -6,6 +6,6 @@ import { IIssueStore } from "@/store/issue.store";
|
|||
|
||||
export const useIssue = (): IIssueStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useUserProfile must be used within StoreProvider");
|
||||
if (context === undefined) throw new Error("useIssue must be used within StoreProvider");
|
||||
return context.issue;
|
||||
};
|
||||
|
|
|
|||
11
space/core/hooks/store/use-label.ts
Normal file
11
space/core/hooks/store/use-label.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useContext } from "react";
|
||||
// lib
|
||||
import { StoreContext } from "@/lib/store-provider";
|
||||
// store
|
||||
import { IIssueLabelStore } from "@/store/label.store";
|
||||
|
||||
export const useLabel = (): IIssueLabelStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useLabel must be used within StoreProvider");
|
||||
return context.label;
|
||||
};
|
||||
11
space/core/hooks/store/use-state.ts
Normal file
11
space/core/hooks/store/use-state.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import { useContext } from "react";
|
||||
// lib
|
||||
import { StoreContext } from "@/lib/store-provider";
|
||||
// store
|
||||
import { IStateStore } from "@/store/state.store";
|
||||
|
||||
export const useStates = (): IStateStore => {
|
||||
const context = useContext(StoreContext);
|
||||
if (context === undefined) throw new Error("useState must be used within StoreProvider");
|
||||
return context.state;
|
||||
};
|
||||
|
|
@ -6,6 +6,7 @@ const useClipboardWritePermission = () => {
|
|||
useEffect(() => {
|
||||
const checkClipboardWriteAccess = () => {
|
||||
navigator.permissions
|
||||
//eslint-disable-next-line no-undef
|
||||
.query({ name: "clipboard-write" as PermissionName })
|
||||
.then((result) => {
|
||||
if (result.state === "granted") {
|
||||
|
|
|
|||
41
space/core/hooks/use-intersection-observer.tsx
Normal file
41
space/core/hooks/use-intersection-observer.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import { RefObject, useEffect } from "react";
|
||||
|
||||
export type UseIntersectionObserverProps = {
|
||||
containerRef: RefObject<HTMLDivElement | null> | undefined;
|
||||
elementRef: HTMLElement | null;
|
||||
callback: () => void;
|
||||
rootMargin?: string;
|
||||
};
|
||||
|
||||
export const useIntersectionObserver = (
|
||||
containerRef: RefObject<HTMLDivElement | null>,
|
||||
elementRef: HTMLElement | null,
|
||||
callback: (() => void) | undefined,
|
||||
rootMargin?: string
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (elementRef) {
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries[entries.length - 1].isIntersecting) {
|
||||
callback && callback();
|
||||
}
|
||||
},
|
||||
{
|
||||
root: containerRef?.current,
|
||||
rootMargin,
|
||||
}
|
||||
);
|
||||
observer.observe(elementRef);
|
||||
return () => {
|
||||
if (elementRef) {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
observer.unobserve(elementRef);
|
||||
}
|
||||
};
|
||||
}
|
||||
// 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]);
|
||||
};
|
||||
|
|
@ -2,7 +2,7 @@ import { API_BASE_URL } from "@/helpers/common.helper";
|
|||
// services
|
||||
import { APIService } from "@/services/api.service";
|
||||
// types
|
||||
import { TIssuesResponse } from "@/types/issue";
|
||||
import { TIssuesResponse, IIssue } from "@/types/issue";
|
||||
|
||||
class IssueService extends APIService {
|
||||
constructor() {
|
||||
|
|
@ -19,7 +19,7 @@ class IssueService extends APIService {
|
|||
});
|
||||
}
|
||||
|
||||
async getIssueById(anchor: string, issueID: string): Promise<any> {
|
||||
async getIssueById(anchor: string, issueID: string): Promise<IIssue> {
|
||||
return this.get(`/api/public/anchor/${anchor}/issues/${issueID}/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
|
|
|
|||
17
space/core/services/label.service.ts
Normal file
17
space/core/services/label.service.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { IIssueLabel } from "@plane/types";
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import { APIService } from "./api.service";
|
||||
|
||||
export class LabelService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async getLabels(anchor: string): Promise<IIssueLabel[]> {
|
||||
return this.get(`api/public/anchor/${anchor}/labels/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
17
space/core/services/state.service.ts
Normal file
17
space/core/services/state.service.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { IState } from "@plane/types";
|
||||
import { API_BASE_URL } from "@/helpers/common.helper";
|
||||
import { APIService } from "./api.service";
|
||||
|
||||
export class StateService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async getStates(anchor: string): Promise<IState[]> {
|
||||
return this.get(`api/public/anchor/${anchor}/states/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
553
space/core/store/helpers/base-issues.store.ts
Normal file
553
space/core/store/helpers/base-issues.store.ts
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
import concat from "lodash/concat";
|
||||
import get from "lodash/get";
|
||||
import isEmpty from "lodash/isEmpty";
|
||||
import set from "lodash/set";
|
||||
import uniq from "lodash/uniq";
|
||||
import update from "lodash/update";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane constants
|
||||
import { ALL_ISSUES } from "@plane/constants";
|
||||
// types
|
||||
import {
|
||||
TIssueGroupByOptions,
|
||||
TGroupedIssues,
|
||||
TSubGroupedIssues,
|
||||
TLoader,
|
||||
IssuePaginationOptions,
|
||||
TIssues,
|
||||
TIssuePaginationData,
|
||||
TGroupedIssueCount,
|
||||
TPaginationData,
|
||||
} from "@plane/types";
|
||||
// services
|
||||
import IssueService from "@/services/issue.service";
|
||||
import { IIssue, TIssuesResponse } from "@/types/issue";
|
||||
import { IIssueFilterStore } from "../issue-filters.store";
|
||||
import { CoreRootStore } from "../root.store";
|
||||
// constants
|
||||
// helpers
|
||||
|
||||
export type TIssueDisplayFilterOptions = Exclude<TIssueGroupByOptions, null> | "target_date";
|
||||
|
||||
export enum EIssueGroupedAction {
|
||||
ADD = "ADD",
|
||||
DELETE = "DELETE",
|
||||
REORDER = "REORDER",
|
||||
}
|
||||
|
||||
export interface IBaseIssuesStore {
|
||||
// observable
|
||||
loader: Record<string, TLoader>;
|
||||
issuesMap: Record<string, IIssue>; // Record defines issue_id as key and IIssue as value
|
||||
// actions
|
||||
addIssue(issues: IIssue[], shouldReplace?: boolean): void;
|
||||
// helper methods
|
||||
getIssueById(issueId: string): undefined | IIssue;
|
||||
|
||||
fetchIssueById(anchorId: string, issueId: string): Promise<IIssue | undefined>;
|
||||
|
||||
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
|
||||
|
||||
// helper methods
|
||||
getIssueIds: (groupId?: string, subGroupId?: string) => string[] | undefined;
|
||||
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;
|
||||
}
|
||||
|
||||
export const ISSUE_FILTER_DEFAULT_DATA: Record<TIssueDisplayFilterOptions, keyof IIssue> = {
|
||||
project: "project_id",
|
||||
cycle: "cycle_id",
|
||||
module: "module_ids",
|
||||
state: "state_id",
|
||||
"state_detail.group": "state_group" as keyof IIssue, // 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",
|
||||
};
|
||||
|
||||
export abstract class BaseIssuesStore implements IBaseIssuesStore {
|
||||
loader: Record<string, TLoader> = {};
|
||||
groupedIssueIds: TIssues | undefined = undefined;
|
||||
issuePaginationData: TIssuePaginationData = {};
|
||||
issuesMap: Record<string, IIssue> = {}; // Record defines issue_id as key and TIssue as value
|
||||
groupedIssueCount: TGroupedIssueCount = {};
|
||||
//
|
||||
paginationOptions: IssuePaginationOptions | undefined = undefined;
|
||||
|
||||
issueService;
|
||||
// root store
|
||||
rootIssueStore;
|
||||
issueFilterStore;
|
||||
|
||||
constructor(_rootStore: CoreRootStore, issueFilterStore: IIssueFilterStore) {
|
||||
makeObservable(this, {
|
||||
// observable
|
||||
loader: observable,
|
||||
groupedIssueIds: observable,
|
||||
issuePaginationData: observable,
|
||||
groupedIssueCount: observable,
|
||||
|
||||
paginationOptions: observable,
|
||||
// action
|
||||
storePreviousPaginationValues: action.bound,
|
||||
|
||||
onfetchIssues: action.bound,
|
||||
onfetchNexIssues: action.bound,
|
||||
clear: action.bound,
|
||||
setLoader: action.bound,
|
||||
});
|
||||
this.rootIssueStore = _rootStore;
|
||||
this.issueFilterStore = issueFilterStore;
|
||||
this.issueService = new IssueService();
|
||||
}
|
||||
|
||||
getIssueIds = (groupId?: string, subGroupId?: string) => {
|
||||
const groupedIssueIds = this.groupedIssueIds;
|
||||
|
||||
if (!groupedIssueIds) return undefined;
|
||||
|
||||
const allIssues = groupedIssueIds[ALL_ISSUES] ?? [];
|
||||
if (allIssues && Array.isArray(allIssues)) {
|
||||
return allIssues as string[];
|
||||
}
|
||||
|
||||
if (groupId && groupedIssueIds?.[groupId] && Array.isArray(groupedIssueIds[groupId])) {
|
||||
return (groupedIssueIds[groupId] ?? []) as string[];
|
||||
}
|
||||
|
||||
if (groupId && subGroupId) {
|
||||
return ((groupedIssueIds as TSubGroupedIssues)[groupId]?.[subGroupId] ?? []) as string[];
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method will add issues to the issuesMap
|
||||
* @param {IIssue[]} issues
|
||||
* @returns {void}
|
||||
*/
|
||||
addIssue = (issues: IIssue[], shouldReplace = false) => {
|
||||
if (issues && issues.length <= 0) return;
|
||||
runInAction(() => {
|
||||
issues.forEach((issue) => {
|
||||
if (!this.issuesMap[issue.id] || shouldReplace) set(this.issuesMap, issue.id, issue);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* @description This method will return the issue from the issuesMap
|
||||
* @param {string} issueId
|
||||
* @returns {IIssue | undefined}
|
||||
*/
|
||||
getIssueById = computedFn((issueId: string) => {
|
||||
if (!issueId || isEmpty(this.issuesMap) || !this.issuesMap[issueId]) return undefined;
|
||||
return this.issuesMap[issueId];
|
||||
});
|
||||
|
||||
fetchIssueById = async (anchorId: string, issueId: string) => {
|
||||
try {
|
||||
const issueDetails = await this.issueService.getIssueById(anchorId, issueId);
|
||||
|
||||
runInAction(() => {
|
||||
set(this.issuesMap, [issueId], issueDetails);
|
||||
});
|
||||
|
||||
return issueDetails;
|
||||
} catch (e) {
|
||||
console.error("error fetching issue details");
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 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, [this.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, this.getGroupKey(groupId, subGroupId), loaderValue);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* gets the Loader value of particular group/subgroup/ALL_ISSUES
|
||||
*/
|
||||
getIssueLoader = (groupId?: string, subGroupId?: string) => get(this.loader, this.getGroupKey(groupId, subGroupId));
|
||||
|
||||
/**
|
||||
* gets the pagination data of particular group/subgroup/ALL_ISSUES
|
||||
*/
|
||||
getPaginationData = computedFn(
|
||||
(groupId: string | undefined, subGroupId: string | undefined): TPaginationData | undefined =>
|
||||
get(this.issuePaginationData, [this.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, [this.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) {
|
||||
// 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.addIssue(issueList);
|
||||
|
||||
// Update all the GroupIds to this Store's groupedIssueIds and update Individual group issue counts
|
||||
runInAction(() => {
|
||||
this.updateGroupedIssueIds(groupedIssues, groupedIssueCount);
|
||||
this.loader[this.getGroupKey()] = undefined;
|
||||
});
|
||||
|
||||
// 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.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[this.getGroupKey(groupId, subGroupId)] = undefined;
|
||||
});
|
||||
|
||||
// store Pagination data like next cursor etc
|
||||
this.storePreviousPaginationValues(issuesResponse, undefined, groupId, subGroupId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Method called to clear out the current store
|
||||
*/
|
||||
clear(shouldClearPaginationOptions = true) {
|
||||
runInAction(() => {
|
||||
this.groupedIssueIds = undefined;
|
||||
this.issuePaginationData = {};
|
||||
this.groupedIssueCount = {};
|
||||
if (shouldClearPaginationOptions) {
|
||||
this.paginationOptions = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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: IIssue[];
|
||||
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: IIssue[] = [];
|
||||
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, [this.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, [this.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[this.getGroupKey(groupId, subGroupId)];
|
||||
|
||||
// update the subGroupId's issue count
|
||||
set(this.groupedIssueCount, [this.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[] = []) =>
|
||||
uniq(concat(issueIds, groupedIssueIds as string[]))
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 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
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
getGroupKey = (groupId?: string, subGroupId?: string) => {
|
||||
if (groupId && subGroupId && subGroupId !== "null") return `${groupId}_${subGroupId}`;
|
||||
|
||||
if (groupId) return groupId;
|
||||
|
||||
return ALL_ISSUES;
|
||||
};
|
||||
}
|
||||
63
space/core/store/helpers/filter.helpers.ts
Normal file
63
space/core/store/helpers/filter.helpers.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import { EIssueGroupByToServerOptions, EServerGroupByToFilterOptions } from "@plane/constants";
|
||||
import { IssuePaginationOptions, TIssueParams } from "@plane/types";
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
export const getPaginationParams = (
|
||||
filterParams: Partial<Record<TIssueParams, string | boolean>> | 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<Record<TIssueParams, string | boolean>> = {
|
||||
...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 = EIssueGroupByToServerOptions[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;
|
||||
};
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import set from "lodash/set";
|
||||
import { makeObservable, observable, action, runInAction } from "mobx";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
// services
|
||||
|
|
@ -97,7 +98,7 @@ export class IssueDetailStore implements IIssueDetailStore {
|
|||
this.loader = true;
|
||||
this.error = null;
|
||||
|
||||
const issueDetails = this.rootStore.issue.issues?.find((i) => i.id === issueID);
|
||||
const issueDetails = await this.rootStore.issue.fetchIssueById(anchor, issueID);
|
||||
const commentsResponse = await this.issueService.getIssueComments(anchor, issueID);
|
||||
|
||||
if (issueDetails) {
|
||||
|
|
@ -119,17 +120,11 @@ export class IssueDetailStore implements IIssueDetailStore {
|
|||
|
||||
addIssueComment = async (anchor: string, issueID: string, data: any) => {
|
||||
try {
|
||||
const issueDetails = this.rootStore.issue.issues?.find((i) => i.id === issueID);
|
||||
const issueDetails = this.rootStore.issue.getIssueById(issueID);
|
||||
const issueCommentResponse = await this.issueService.createIssueComment(anchor, issueID, data);
|
||||
if (issueDetails) {
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueID]: {
|
||||
...issueDetails,
|
||||
comments: [...this.details[issueID].comments, issueCommentResponse],
|
||||
},
|
||||
};
|
||||
set(this.details, [issueID, "comments"], [...this.details[issueID].comments, issueCommentResponse]);
|
||||
});
|
||||
}
|
||||
return issueCommentResponse;
|
||||
|
|
@ -267,21 +262,17 @@ export class IssueDetailStore implements IIssueDetailStore {
|
|||
addIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => {
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueID]: {
|
||||
...this.details[issueID],
|
||||
reactions: [
|
||||
...this.details[issueID].reactions,
|
||||
{
|
||||
id: uuidv4(),
|
||||
issue: issueID,
|
||||
reaction: reactionHex,
|
||||
actor_detail: this.rootStore.user.currentActor,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
set(
|
||||
this.details,
|
||||
[issueID, "reaction_items"],
|
||||
[
|
||||
...this.details[issueID].reaction_items,
|
||||
{
|
||||
reaction: reactionHex,
|
||||
actor_detail: this.rootStore.user.currentActor,
|
||||
},
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
await this.issueService.createIssueReaction(anchor, issueID, {
|
||||
|
|
@ -291,31 +282,19 @@ export class IssueDetailStore implements IIssueDetailStore {
|
|||
console.log("Failed to add issue vote");
|
||||
const issueReactions = await this.issueService.getIssueReactions(anchor, issueID);
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueID]: {
|
||||
...this.details[issueID],
|
||||
reactions: issueReactions,
|
||||
},
|
||||
};
|
||||
set(this.details, [issueID, "reaction_items"], issueReactions);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
removeIssueReaction = async (anchor: string, issueID: string, reactionHex: string) => {
|
||||
try {
|
||||
const newReactions = this.details[issueID].reactions.filter(
|
||||
(_r) => !(_r.reaction === reactionHex && _r.actor_detail.id === this.rootStore.user.data?.id)
|
||||
const newReactions = this.details[issueID].reaction_items.filter(
|
||||
(_r) => !(_r.reaction === reactionHex && _r.actor_details.id === this.rootStore.user.data?.id)
|
||||
);
|
||||
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueID]: {
|
||||
...this.details[issueID],
|
||||
reactions: newReactions,
|
||||
},
|
||||
};
|
||||
set(this.details, [issueID, "reaction_items"], newReactions);
|
||||
});
|
||||
|
||||
await this.issueService.deleteIssueReaction(anchor, issueID, reactionHex);
|
||||
|
|
@ -323,13 +302,7 @@ export class IssueDetailStore implements IIssueDetailStore {
|
|||
console.log("Failed to remove issue reaction");
|
||||
const reactions = await this.issueService.getIssueReactions(anchor, issueID);
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueID]: {
|
||||
...this.details[issueID],
|
||||
reactions: reactions,
|
||||
},
|
||||
};
|
||||
set(this.details, [issueID, "reaction_items"], reactions);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -341,25 +314,19 @@ export class IssueDetailStore implements IIssueDetailStore {
|
|||
if (!projectID || !workspaceSlug) throw new Error("Publish settings not found");
|
||||
|
||||
const newVote: IVote = {
|
||||
actor: this.rootStore.user.data?.id ?? "",
|
||||
actor_detail: this.rootStore.user.currentActor,
|
||||
issue: issueID,
|
||||
project: projectID,
|
||||
workspace: workspaceSlug,
|
||||
actor_details: this.rootStore.user.currentActor,
|
||||
vote: data.vote,
|
||||
};
|
||||
|
||||
const filteredVotes = this.details[issueID].votes.filter((v) => v.actor !== this.rootStore.user.data?.id);
|
||||
const filteredVotes = this.details[issueID].vote_items.filter(
|
||||
(v) => v.actor_details?.id !== this.rootStore.user.data?.id
|
||||
);
|
||||
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueID]: {
|
||||
...this.details[issueID],
|
||||
votes: [...filteredVotes, newVote],
|
||||
},
|
||||
};
|
||||
runInAction(() => {
|
||||
set(this.details, [issueID, "vote_items"], [...filteredVotes, newVote]);
|
||||
});
|
||||
});
|
||||
|
||||
await this.issueService.createIssueVote(anchor, issueID, data);
|
||||
|
|
@ -368,29 +335,19 @@ export class IssueDetailStore implements IIssueDetailStore {
|
|||
const issueVotes = await this.issueService.getIssueVotes(anchor, issueID);
|
||||
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueID]: {
|
||||
...this.details[issueID],
|
||||
votes: issueVotes,
|
||||
},
|
||||
};
|
||||
set(this.details, [issueID, "vote_items"], issueVotes);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
removeIssueVote = async (anchor: string, issueID: string) => {
|
||||
const newVotes = this.details[issueID].votes.filter((v) => v.actor !== this.rootStore.user.data?.id);
|
||||
const newVotes = this.details[issueID].vote_items.filter(
|
||||
(v) => v.actor_details?.id !== this.rootStore.user.data?.id
|
||||
);
|
||||
|
||||
try {
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueID]: {
|
||||
...this.details[issueID],
|
||||
votes: newVotes,
|
||||
},
|
||||
};
|
||||
set(this.details, [issueID, "vote_items"], newVotes);
|
||||
});
|
||||
|
||||
await this.issueService.deleteIssueVote(anchor, issueID);
|
||||
|
|
@ -399,13 +356,7 @@ export class IssueDetailStore implements IIssueDetailStore {
|
|||
const issueVotes = await this.issueService.getIssueVotes(anchor, issueID);
|
||||
|
||||
runInAction(() => {
|
||||
this.details = {
|
||||
...this.details,
|
||||
[issueID]: {
|
||||
...this.details[issueID],
|
||||
votes: issueVotes,
|
||||
},
|
||||
};
|
||||
set(this.details, [issueID, "vote_items"], issueVotes);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import isEqual from "lodash/isEqual";
|
|||
import set from "lodash/set";
|
||||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
// plane types
|
||||
import { IssuePaginationOptions, TIssueParams } from "@plane/types";
|
||||
// constants
|
||||
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "@/constants/issue";
|
||||
// store
|
||||
|
|
@ -15,6 +17,7 @@ import {
|
|||
TIssueQueryFiltersParams,
|
||||
TIssueFilterKeys,
|
||||
} from "@/types/issue";
|
||||
import { getPaginationParams } from "./helpers/filter.helpers";
|
||||
|
||||
export interface IIssueFilterStore {
|
||||
// observables
|
||||
|
|
@ -27,13 +30,20 @@ export interface IIssueFilterStore {
|
|||
getAppliedFilters: (anchor: string) => TIssueQueryFiltersParams | undefined;
|
||||
// actions
|
||||
updateLayoutOptions: (layout: TIssueLayoutOptions) => void;
|
||||
initIssueFilters: (anchor: string, filters: TIssueFilters) => void;
|
||||
initIssueFilters: (anchor: string, filters: TIssueFilters, shouldFetchIssues?: boolean) => void;
|
||||
updateIssueFilters: <K extends keyof TIssueFilters>(
|
||||
anchor: string,
|
||||
filterKind: K,
|
||||
filterKey: keyof TIssueFilters[K],
|
||||
filters: TIssueFilters[K][typeof filterKey]
|
||||
) => Promise<void>;
|
||||
getFilterParams: (
|
||||
options: IssuePaginationOptions,
|
||||
anchor: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => Partial<Record<TIssueParams, string | boolean>>;
|
||||
}
|
||||
|
||||
export class IssueFilterStore implements IIssueFilterStore {
|
||||
|
|
@ -114,14 +124,27 @@ export class IssueFilterStore implements IIssueFilterStore {
|
|||
// actions
|
||||
updateLayoutOptions = (options: TIssueLayoutOptions) => set(this, ["layoutOptions"], options);
|
||||
|
||||
initIssueFilters = async (anchor: string, initFilters: TIssueFilters) => {
|
||||
initIssueFilters = async (anchor: string, initFilters: TIssueFilters, shouldFetchIssues: boolean = false) => {
|
||||
if (this.filters === undefined) runInAction(() => (this.filters = {}));
|
||||
if (this.filters && initFilters) set(this.filters, [anchor], initFilters);
|
||||
|
||||
const appliedFilters = this.getAppliedFilters(anchor);
|
||||
await this.store.issue.fetchPublicIssues(anchor, appliedFilters);
|
||||
if (shouldFetchIssues) await this.store.issue.fetchPublicIssuesWithExistingPagination(anchor, "mutation");
|
||||
};
|
||||
|
||||
getFilterParams = computedFn(
|
||||
(
|
||||
options: IssuePaginationOptions,
|
||||
anchor: string,
|
||||
cursor: string | undefined,
|
||||
groupId: string | undefined,
|
||||
subGroupId: string | undefined
|
||||
) => {
|
||||
const filterParams = this.getAppliedFilters(anchor);
|
||||
const paginationParams = getPaginationParams(filterParams, options, cursor, groupId, subGroupId);
|
||||
return paginationParams;
|
||||
}
|
||||
);
|
||||
|
||||
updateIssueFilters = async <K extends keyof TIssueFilters>(
|
||||
anchor: string,
|
||||
filterKind: K,
|
||||
|
|
@ -135,7 +158,6 @@ export class IssueFilterStore implements IIssueFilterStore {
|
|||
if (this.filters) set(this.filters, [anchor, filterKind, filterKey], filterValue);
|
||||
});
|
||||
|
||||
const appliedFilters = this.getAppliedFilters(anchor);
|
||||
await this.store.issue.fetchPublicIssues(anchor, appliedFilters);
|
||||
if (filterKey !== "layout") await this.store.issue.fetchPublicIssuesWithExistingPagination(anchor, "mutation");
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,62 +1,38 @@
|
|||
import { observable, action, makeObservable, runInAction } from "mobx";
|
||||
import { computedFn } from "mobx-utils";
|
||||
import { action, makeObservable, runInAction } from "mobx";
|
||||
// types
|
||||
import { IStateLite } from "@plane/types";
|
||||
import { IssuePaginationOptions, TLoader } from "@plane/types";
|
||||
// services
|
||||
import IssueService from "@/services/issue.service";
|
||||
// store
|
||||
import { CoreRootStore } from "@/store/root.store";
|
||||
// types
|
||||
import { IIssue, IIssueLabel } from "@/types/issue";
|
||||
import { BaseIssuesStore, IBaseIssuesStore } from "./helpers/base-issues.store";
|
||||
|
||||
export interface IIssueStore {
|
||||
loader: boolean;
|
||||
error: any;
|
||||
// observables
|
||||
issues: IIssue[];
|
||||
states: IStateLite[];
|
||||
labels: IIssueLabel[];
|
||||
// filter observables
|
||||
filteredStates: string[];
|
||||
filteredLabels: string[];
|
||||
filteredPriorities: string[];
|
||||
export interface IIssueStore extends IBaseIssuesStore {
|
||||
// actions
|
||||
fetchPublicIssues: (anchor: string, params: any) => Promise<void>;
|
||||
// helpers
|
||||
getCountOfIssuesByState: (stateID: string) => number;
|
||||
getFilteredIssuesByState: (stateID: string) => IIssue[];
|
||||
fetchPublicIssues: (
|
||||
anchor: string,
|
||||
loadType: TLoader,
|
||||
options: IssuePaginationOptions,
|
||||
isExistingPaginationOptions?: boolean
|
||||
) => Promise<void>;
|
||||
fetchNextPublicIssues: (anchor: string, groupId?: string, subGroupId?: string) => Promise<void>;
|
||||
fetchPublicIssuesWithExistingPagination: (anchor: string, loadType?: TLoader) => Promise<void>;
|
||||
}
|
||||
|
||||
export class IssueStore implements IIssueStore {
|
||||
loader: boolean = false;
|
||||
error: any | null = null;
|
||||
// observables
|
||||
states: IStateLite[] = [];
|
||||
labels: IIssueLabel[] = [];
|
||||
issues: IIssue[] = [];
|
||||
// filter observables
|
||||
filteredStates: string[] = [];
|
||||
filteredLabels: string[] = [];
|
||||
filteredPriorities: string[] = [];
|
||||
export class IssueStore extends BaseIssuesStore implements IIssueStore {
|
||||
// root store
|
||||
rootStore: CoreRootStore;
|
||||
// services
|
||||
issueService: IssueService;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
super(_rootStore, _rootStore.issueFilter);
|
||||
makeObservable(this, {
|
||||
loader: observable.ref,
|
||||
error: observable,
|
||||
// observables
|
||||
states: observable,
|
||||
labels: observable,
|
||||
issues: observable,
|
||||
// filter observables
|
||||
filteredStates: observable,
|
||||
filteredLabels: observable,
|
||||
filteredPriorities: observable,
|
||||
// actions
|
||||
fetchPublicIssues: action,
|
||||
fetchNextPublicIssues: action,
|
||||
fetchPublicIssuesWithExistingPagination: action,
|
||||
});
|
||||
|
||||
this.rootStore = _rootStore;
|
||||
|
|
@ -68,45 +44,69 @@ export class IssueStore implements IIssueStore {
|
|||
* @param {string} anchor
|
||||
* @param params
|
||||
*/
|
||||
fetchPublicIssues = async (anchor: string, params: any) => {
|
||||
fetchPublicIssues = async (
|
||||
anchor: string,
|
||||
loadType: TLoader = "init-loader",
|
||||
options: IssuePaginationOptions,
|
||||
isExistingPaginationOptions: boolean = false
|
||||
) => {
|
||||
try {
|
||||
// set loader and clear store
|
||||
runInAction(() => {
|
||||
this.loader = true;
|
||||
this.error = null;
|
||||
this.setLoader(loadType);
|
||||
});
|
||||
this.clear(!isExistingPaginationOptions);
|
||||
|
||||
const params = this.rootStore.issueFilter.getFilterParams(options, anchor, undefined, undefined, undefined);
|
||||
|
||||
const response = await this.issueService.fetchPublicIssues(anchor, params);
|
||||
|
||||
if (response) {
|
||||
runInAction(() => {
|
||||
this.states = response.states;
|
||||
this.labels = response.labels;
|
||||
this.issues = response.issues;
|
||||
this.loader = false;
|
||||
});
|
||||
}
|
||||
// after fetching issues, call the base method to process the response further
|
||||
this.onfetchIssues(response, options);
|
||||
} catch (error) {
|
||||
this.loader = false;
|
||||
this.error = error;
|
||||
this.setLoader(undefined);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
fetchNextPublicIssues = async (anchor: 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
|
||||
const params = this.rootStore.issueFilter.getFilterParams(
|
||||
this.paginationOptions,
|
||||
anchor,
|
||||
cursorObject?.nextCursor,
|
||||
groupId,
|
||||
subGroupId
|
||||
);
|
||||
// call the fetch issues API with the params for next page in issues
|
||||
const response = await this.issueService.fetchPublicIssues(anchor, params);
|
||||
|
||||
// after the next page of issues are fetched, call the base method to process the response
|
||||
this.onfetchNexIssues(response, groupId, subGroupId);
|
||||
} catch (error) {
|
||||
// set Loader as undefined if errored out
|
||||
this.setLoader(undefined, groupId, subGroupId);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @description get total count of issues under a particular state
|
||||
* @param {string} stateID
|
||||
* @returns {number}
|
||||
* 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
|
||||
*/
|
||||
getCountOfIssuesByState = computedFn(
|
||||
(stateID: string) => this.issues?.filter((issue) => issue.state == stateID).length || 0
|
||||
);
|
||||
|
||||
/**
|
||||
* @description get array of issues under a particular state
|
||||
* @param {string} stateID
|
||||
* @returns {IIssue[]}
|
||||
*/
|
||||
getFilteredIssuesByState = computedFn(
|
||||
(stateID: string) => this.issues?.filter((issue) => issue.state == stateID) || []
|
||||
);
|
||||
fetchPublicIssuesWithExistingPagination = async (anchor: string, loadType: TLoader = "mutation") => {
|
||||
if (!this.paginationOptions) return;
|
||||
return await this.fetchPublicIssues(anchor, loadType, this.paginationOptions, true);
|
||||
};
|
||||
}
|
||||
|
|
|
|||
63
space/core/store/label.store.ts
Normal file
63
space/core/store/label.store.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import set from "lodash/set";
|
||||
import { action, computed, makeObservable, observable, runInAction } from "mobx";
|
||||
import { IIssueLabel } from "@plane/types";
|
||||
import { LabelService } from "@/services/label.service";
|
||||
import { CoreRootStore } from "./root.store";
|
||||
|
||||
export interface IIssueLabelStore {
|
||||
// observables
|
||||
labels: IIssueLabel[] | undefined;
|
||||
// computed actions
|
||||
getLabelById: (labelId: string | undefined) => IIssueLabel | undefined;
|
||||
getLabelsByIds: (labelIds: string[]) => IIssueLabel[];
|
||||
// fetch actions
|
||||
fetchLabels: (anchor: string) => Promise<IIssueLabel[]>;
|
||||
}
|
||||
|
||||
export class LabelStore implements IIssueLabelStore {
|
||||
labelMap: Record<string, IIssueLabel> = {};
|
||||
labelService: LabelService;
|
||||
rootStore: CoreRootStore;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
labelMap: observable,
|
||||
// computed
|
||||
labels: computed,
|
||||
// fetch action
|
||||
fetchLabels: action,
|
||||
});
|
||||
this.labelService = new LabelService();
|
||||
this.rootStore = _rootStore;
|
||||
}
|
||||
|
||||
get labels() {
|
||||
return Object.values(this.labelMap);
|
||||
}
|
||||
|
||||
getLabelById = (labelId: string | undefined) => (labelId ? this.labelMap[labelId] : undefined);
|
||||
|
||||
getLabelsByIds = (labelIds: string[]) => {
|
||||
const currLabels = [];
|
||||
for (const labelId of labelIds) {
|
||||
const label = this.getLabelById(labelId);
|
||||
if (label) {
|
||||
currLabels.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
return currLabels;
|
||||
};
|
||||
|
||||
fetchLabels = async (anchor: string) => {
|
||||
const labelsResponse = await this.labelService.getLabels(anchor);
|
||||
runInAction(() => {
|
||||
this.labelMap = {};
|
||||
for (const label of labelsResponse) {
|
||||
set(this.labelMap, [label.id], label);
|
||||
}
|
||||
});
|
||||
return labelsResponse;
|
||||
};
|
||||
}
|
||||
|
|
@ -5,8 +5,10 @@ import { IssueDetailStore, IIssueDetailStore } from "@/store/issue-detail.store"
|
|||
import { IssueStore, IIssueStore } from "@/store/issue.store";
|
||||
import { IUserStore, UserStore } from "@/store/user.store";
|
||||
import { IssueFilterStore, IIssueFilterStore } from "./issue-filters.store";
|
||||
import { IIssueLabelStore, LabelStore } from "./label.store";
|
||||
import { IMentionsStore, MentionsStore } from "./mentions.store";
|
||||
import { IPublishListStore, PublishListStore } from "./publish/publish_list.store";
|
||||
import { IStateStore, StateStore } from "./state.store";
|
||||
|
||||
enableStaticRendering(typeof window === "undefined");
|
||||
|
||||
|
|
@ -16,6 +18,8 @@ export class CoreRootStore {
|
|||
issue: IIssueStore;
|
||||
issueDetail: IIssueDetailStore;
|
||||
mentionStore: IMentionsStore;
|
||||
state: IStateStore;
|
||||
label: IIssueLabelStore;
|
||||
issueFilter: IIssueFilterStore;
|
||||
publishList: IPublishListStore;
|
||||
|
||||
|
|
@ -25,6 +29,8 @@ export class CoreRootStore {
|
|||
this.issue = new IssueStore(this);
|
||||
this.issueDetail = new IssueDetailStore(this);
|
||||
this.mentionStore = new MentionsStore(this);
|
||||
this.state = new StateStore(this);
|
||||
this.label = new LabelStore(this);
|
||||
this.issueFilter = new IssueFilterStore(this);
|
||||
this.publishList = new PublishListStore(this);
|
||||
}
|
||||
|
|
@ -43,6 +49,8 @@ export class CoreRootStore {
|
|||
this.issue = new IssueStore(this);
|
||||
this.issueDetail = new IssueDetailStore(this);
|
||||
this.mentionStore = new MentionsStore(this);
|
||||
this.state = new StateStore(this);
|
||||
this.label = new LabelStore(this);
|
||||
this.issueFilter = new IssueFilterStore(this);
|
||||
this.publishList = new PublishListStore(this);
|
||||
}
|
||||
|
|
|
|||
40
space/core/store/state.store.ts
Normal file
40
space/core/store/state.store.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { action, makeObservable, observable, runInAction } from "mobx";
|
||||
import { IState } from "@plane/types";
|
||||
import { StateService } from "@/services/state.service";
|
||||
import { CoreRootStore } from "./root.store";
|
||||
|
||||
export interface IStateStore {
|
||||
// observables
|
||||
states: IState[] | undefined;
|
||||
// computed actions
|
||||
getStateById: (stateId: string | undefined) => IState | undefined;
|
||||
// fetch actions
|
||||
fetchStates: (anchor: string) => Promise<IState[]>;
|
||||
}
|
||||
|
||||
export class StateStore implements IStateStore {
|
||||
states: IState[] | undefined = undefined;
|
||||
stateService: StateService;
|
||||
rootStore: CoreRootStore;
|
||||
|
||||
constructor(_rootStore: CoreRootStore) {
|
||||
makeObservable(this, {
|
||||
// observables
|
||||
states: observable,
|
||||
// fetch action
|
||||
fetchStates: action,
|
||||
});
|
||||
this.stateService = new StateService();
|
||||
this.rootStore = _rootStore;
|
||||
}
|
||||
|
||||
getStateById = (stateId: string | undefined) => this.states?.find((state) => state.id === stateId);
|
||||
|
||||
fetchStates = async (anchor: string) => {
|
||||
const statesResponse = await this.stateService.getStates(anchor);
|
||||
runInAction(() => {
|
||||
this.states = statesResponse;
|
||||
});
|
||||
return statesResponse;
|
||||
};
|
||||
}
|
||||
82
space/core/types/issue.d.ts
vendored
82
space/core/types/issue.d.ts
vendored
|
|
@ -1,4 +1,4 @@
|
|||
import { IStateLite, IWorkspaceLite, TIssue, TIssuePriorities, TStateGroups } from "@plane/types";
|
||||
import { IWorkspaceLite, TIssue, TIssuePriorities, TStateGroups } from "@plane/types";
|
||||
|
||||
export type TIssueLayout = "list" | "kanban" | "calendar" | "spreadsheet" | "gantt";
|
||||
export type TIssueLayoutOptions = {
|
||||
|
|
@ -33,31 +33,61 @@ export type TIssueQueryFilters = Partial<TFilters>;
|
|||
|
||||
export type TIssueQueryFiltersParams = Partial<Record<keyof TFilters, string>>;
|
||||
|
||||
export type TIssuesResponse = {
|
||||
states: IStateLite[];
|
||||
labels: IIssueLabel[];
|
||||
issues: IIssue[];
|
||||
};
|
||||
|
||||
export interface IIssue
|
||||
extends Pick<TIssue, "description_html" | "id" | "name" | "priority" | "sequence_id" | "start_date" | "target_date"> {
|
||||
extends Pick<
|
||||
TIssue,
|
||||
| "description_html"
|
||||
| "created_by"
|
||||
| "id"
|
||||
| "name"
|
||||
| "priority"
|
||||
| "state_id"
|
||||
| "project_id"
|
||||
| "sequence_id"
|
||||
| "sort_order"
|
||||
| "start_date"
|
||||
| "target_date"
|
||||
| "cycle_id"
|
||||
| "module_ids"
|
||||
| "label_ids"
|
||||
| "assignee_ids"
|
||||
> {
|
||||
comments: Comment[];
|
||||
label_details: any;
|
||||
project: string;
|
||||
project_detail: any;
|
||||
reactions: IIssueReaction[];
|
||||
state: string;
|
||||
state_detail: {
|
||||
id: string;
|
||||
name: string;
|
||||
group: TIssueGroupKey;
|
||||
color: string;
|
||||
};
|
||||
votes: IVote[];
|
||||
reaction_items: IIssueReaction[];
|
||||
vote_items: IVote[];
|
||||
}
|
||||
|
||||
export type IPeekMode = "side" | "modal" | "full";
|
||||
|
||||
type TIssueResponseResults =
|
||||
| IIssue[]
|
||||
| {
|
||||
[key: string]: {
|
||||
results:
|
||||
| IIssue[]
|
||||
| {
|
||||
[key: string]: {
|
||||
results: IIssue[];
|
||||
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 interface IIssueLabel {
|
||||
id: string;
|
||||
name: string;
|
||||
|
|
@ -66,12 +96,8 @@ export interface IIssueLabel {
|
|||
}
|
||||
|
||||
export interface IVote {
|
||||
issue: string;
|
||||
vote: -1 | 1;
|
||||
workspace: string;
|
||||
project: string;
|
||||
actor: string;
|
||||
actor_detail: ActorDetail;
|
||||
actor_details: ActorDetail;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
|
|
@ -102,9 +128,7 @@ export interface Comment {
|
|||
}
|
||||
|
||||
export interface IIssueReaction {
|
||||
actor_detail: ActorDetail;
|
||||
id: string;
|
||||
issue: string;
|
||||
actor_details: ActorDetail;
|
||||
reaction: string;
|
||||
}
|
||||
|
||||
|
|
@ -112,8 +136,8 @@ export interface ActorDetail {
|
|||
avatar?: string;
|
||||
display_name?: string;
|
||||
first_name?: string;
|
||||
id?: string;
|
||||
is_bot?: boolean;
|
||||
id?: string;
|
||||
last_name?: string;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue