fix: issue layouts bugs and ui fixes (#3012)

* fix: initial issue creation issue in the list layout

* fix kanban drag n drop and updating properties

* reduce z index of spreadsheet bottom row to not overlap with other elements

* fix state update by using state id instead of state detail's id

* fix add default use state for description

* add create issue button for project views to be at par with production

* save draft issues from modal

* chore: added save view button in all layouts applied filters

* use useEffect instead of swr for fetching issue details for peek overview

* fix: resolved kanban dnd

---------

Co-authored-by: rahulramesha <rahulramesham@gmail.com>
This commit is contained in:
guru_sainath 2023-12-06 19:58:47 +05:30 committed by Aaryan Khandelwal
parent c40e45528e
commit 2b11e7771f
16 changed files with 213 additions and 148 deletions

View File

@ -6,7 +6,7 @@ import { useMobxStore } from "lib/mobx/store-provider";
// components
import { DisplayFiltersSelection, FiltersDropdown, FilterSelection, LayoutSelection } from "components/issues";
// ui
import { Breadcrumbs, CustomMenu, PhotoFilterIcon } from "@plane/ui";
import { Breadcrumbs, Button, CustomMenu, PhotoFilterIcon } from "@plane/ui";
// helpers
import { truncateText } from "helpers/string.helper";
import { renderEmoji } from "helpers/emoji.helper";
@ -15,6 +15,8 @@ import { IIssueDisplayFilterOptions, IIssueDisplayProperties, IIssueFilterOption
// constants
import { ISSUE_DISPLAY_FILTERS_BY_LAYOUT } from "constants/issue";
import { EFilterType } from "store/issues/types";
import { EProjectStore } from "store/command-palette.store";
import { Plus } from "lucide-react";
export const ProjectViewIssuesHeader: React.FC = observer(() => {
const router = useRouter();
@ -25,7 +27,6 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
};
const {
issueFilter: issueFilterStore,
projectViewFilters: projectViewFiltersStore,
project: { currentProjectDetails },
projectLabel: { projectLabels },
@ -33,6 +34,8 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
projectState: projectStateStore,
projectViews: projectViewsStore,
viewIssuesFilter: { issueFilters, updateFilters },
commandPalette: commandPaletteStore,
trackEvent: { setTrackElement },
} = useMobxStore();
const storedFilters = viewId ? projectViewFiltersStore.storedFilters[viewId.toString()] : undefined;
@ -170,6 +173,16 @@ export const ProjectViewIssuesHeader: React.FC = observer(() => {
handleDisplayPropertiesUpdate={handleDisplayProperties}
/>
</FiltersDropdown>
<Button
onClick={() => {
setTrackElement("PROJECT_VIEW_PAGE_HEADER");
commandPaletteStore.toggleCreateIssueModal(true, EProjectStore.PROJECT_VIEW);
}}
size="sm"
prependIcon={<Plus />}
>
Add Issue
</Button>
</div>
</div>
);

View File

@ -43,7 +43,6 @@ interface IssuesModalProps {
// services
const issueService = new IssueService();
const issueDraftService = new IssueDraftService();
const moduleService = new ModuleService();
export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = observer((props) => {
@ -65,7 +64,7 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = observer(
const router = useRouter();
const { workspaceSlug, projectId, cycleId, moduleId } = router.query;
const { project: projectStore, user: userStore } = useMobxStore();
const { project: projectStore, user: userStore, projectDraftIssues: draftIssueStore } = useMobxStore();
const user = userStore.currentUser;
const projects = workspaceSlug ? projectStore.projects[workspaceSlug.toString()] : undefined;
@ -167,9 +166,10 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = observer(
const createDraftIssue = async (payload: Partial<IIssue>) => {
if (!workspaceSlug || !activeProject || !user) return;
await issueDraftService
.createDraftIssue(workspaceSlug as string, activeProject ?? "", payload)
await draftIssueStore
.createIssue(workspaceSlug as string, activeProject ?? "", payload)
.then(async () => {
await draftIssueStore.fetchIssues(workspaceSlug as string, activeProject ?? "", "mutation");
setToastAlert({
type: "success",
title: "Success!",
@ -192,8 +192,8 @@ export const CreateUpdateDraftIssueModal: React.FC<IssuesModalProps> = observer(
const updateDraftIssue = async (payload: Partial<IIssue>) => {
if (!user) return;
await issueDraftService
.updateDraftIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload)
await draftIssueStore
.updateIssue(workspaceSlug as string, activeProject ?? "", data?.id ?? "", payload)
.then((res) => {
if (isUpdatingSingleIssue) {
mutate<IIssue>(PROJECT_ISSUES_DETAILS, (prevData) => ({ ...prevData, ...res }), false);

View File

@ -4,14 +4,14 @@ import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { AppliedFiltersList } from "components/issues";
import { AppliedFiltersList, SaveFilterView } from "components/issues";
// types
import { IIssueFilterOptions } from "types";
import { EFilterType } from "store/issues/types";
export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string };
const {
projectArchivedIssuesFilter: { issueFilters, updateFilters },
@ -69,7 +69,7 @@ export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => {
if (Object.keys(appliedFilters).length === 0) return null;
return (
<div className="p-4">
<div className="p-4 flex items-center justify-between">
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
@ -78,6 +78,8 @@ export const ArchivedIssueAppliedFiltersRoot: React.FC = observer(() => {
members={projectMembers?.map((m) => m.member)}
states={projectStateStore.states?.[projectId?.toString() ?? ""]}
/>
<SaveFilterView workspaceSlug={workspaceSlug} projectId={projectId} filterParams={appliedFilters} />
</div>
);
});

View File

@ -1,9 +1,10 @@
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { AppliedFiltersList } from "components/issues";
import { AppliedFiltersList, SaveFilterView } from "components/issues";
// types
import { IIssueFilterOptions } from "types";
import { EFilterType } from "store/issues/types";
@ -75,7 +76,7 @@ export const CycleAppliedFiltersRoot: React.FC = observer(() => {
if (Object.keys(appliedFilters).length === 0) return null;
return (
<div className="p-4">
<div className="p-4 flex items-center justify-between">
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
@ -84,6 +85,8 @@ export const CycleAppliedFiltersRoot: React.FC = observer(() => {
members={projectMembers?.map((m) => m.member)}
states={projectStateStore.states?.[cycleId ?? ""]}
/>
<SaveFilterView workspaceSlug={workspaceSlug} projectId={projectId} filterParams={appliedFilters} />
</div>
);
});

View File

@ -4,14 +4,14 @@ import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { AppliedFiltersList } from "components/issues";
import { AppliedFiltersList, SaveFilterView } from "components/issues";
// types
import { IIssueFilterOptions } from "types";
import { EFilterType } from "store/issues/types";
export const DraftIssueAppliedFiltersRoot: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug, projectId } = router.query as { workspaceSlug: string; projectId: string };
const {
projectDraftIssuesFilter: { issueFilters, updateFilters },
@ -64,7 +64,7 @@ export const DraftIssueAppliedFiltersRoot: React.FC = observer(() => {
if (Object.keys(appliedFilters).length === 0) return null;
return (
<div className="p-4">
<div className="p-4 flex items-center justify-between">
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
@ -73,6 +73,8 @@ export const DraftIssueAppliedFiltersRoot: React.FC = observer(() => {
members={projectMembers?.map((m) => m.member)}
states={projectStateStore.states?.[projectId?.toString() ?? ""]}
/>
<SaveFilterView workspaceSlug={workspaceSlug} projectId={projectId} filterParams={appliedFilters} />
</div>
);
});

View File

@ -4,7 +4,7 @@ import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { AppliedFiltersList } from "components/issues";
import { AppliedFiltersList, SaveFilterView } from "components/issues";
// types
import { IIssueFilterOptions } from "types";
import { EFilterType } from "store/issues/types";
@ -76,7 +76,7 @@ export const ModuleAppliedFiltersRoot: React.FC = observer(() => {
if (Object.keys(appliedFilters).length === 0) return null;
return (
<div className="p-4">
<div className="p-4 flex items-center justify-between">
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
@ -85,6 +85,8 @@ export const ModuleAppliedFiltersRoot: React.FC = observer(() => {
members={projectMembers?.map((m) => m.member)}
states={projectStateStore.states?.[moduleId ?? ""]}
/>
<SaveFilterView workspaceSlug={workspaceSlug} projectId={projectId} filterParams={appliedFilters} />
</div>
);
});

View File

@ -4,14 +4,18 @@ import { observer } from "mobx-react-lite";
// mobx store
import { useMobxStore } from "lib/mobx/store-provider";
// components
import { AppliedFiltersList } from "components/issues";
import { AppliedFiltersList, SaveFilterView } from "components/issues";
// types
import { IIssueFilterOptions } from "types";
import { EFilterType } from "store/issues/types";
export const ProjectAppliedFiltersRoot: React.FC = observer(() => {
const router = useRouter();
const { workspaceSlug, projectId } = router.query;
const { workspaceSlug, projectId } = router.query as {
workspaceSlug: string;
projectId: string;
};
const {
projectLabel: { projectLabels },
@ -60,7 +64,7 @@ export const ProjectAppliedFiltersRoot: React.FC = observer(() => {
if (Object.keys(appliedFilters).length === 0) return null;
return (
<div className="p-4">
<div className="p-4 flex items-center justify-between">
<AppliedFiltersList
appliedFilters={appliedFilters}
handleClearAllFilters={handleClearAllFilters}
@ -69,6 +73,8 @@ export const ProjectAppliedFiltersRoot: React.FC = observer(() => {
members={projectMembers?.map((m) => m.member)}
states={projectStateStore.states?.[projectId?.toString() ?? ""]}
/>
<SaveFilterView workspaceSlug={workspaceSlug} projectId={projectId} filterParams={appliedFilters} />
</div>
);
});

View File

@ -31,7 +31,6 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
} = useMobxStore();
const viewDetails = viewId ? projectViewsStore.viewDetails[viewId.toString()] : undefined;
const storedFilters = viewId ? projectViewFiltersStore.storedFilters[viewId.toString()] : undefined;
const userFilters = issueFilters?.filters;
// filters whose value not null or empty array
@ -89,7 +88,7 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
projectViewsStore.updateView(workspaceSlug.toString(), projectId.toString(), viewId.toString(), {
query_data: {
...viewDetails.query_data,
...(storedFilters ?? {}),
...(appliedFilters ?? {}),
},
});
};
@ -104,7 +103,10 @@ export const ProjectViewAppliedFiltersRoot: React.FC = observer(() => {
members={projectMembers?.map((m) => m.member)}
states={projectStateStore.states?.[projectId?.toString() ?? ""]}
/>
{storedFilters && viewDetails && areFiltersDifferent(storedFilters, viewDetails.query_data ?? {}) && (
{appliedFilters &&
viewDetails?.query_data &&
areFiltersDifferent(appliedFilters, viewDetails?.query_data ?? {}) && (
<div className="flex items-center justify-center flex-shrink-0">
<Button variant="primary" size="sm" onClick={handleUpdateView}>
Update view

View File

@ -15,3 +15,6 @@ export * from "./spreadsheet";
// properties
export * from "./properties";
// save view
export * from "./save-filter-view";

View File

@ -1,16 +1,14 @@
import { memo, useRef, useState } from "react";
import { memo } from "react";
import { Draggable } from "@hello-pangea/dnd";
import isEqual from "lodash/isEqual";
// components
import { KanBanProperties } from "./properties";
// ui
import { Tooltip } from "@plane/ui";
// hooks
import useOutsideClickDetector from "hooks/use-outside-click-detector";
// types
import { IIssueDisplayProperties, IIssue } from "types";
import { EIssueActions } from "../types";
import { useRouter } from "next/router";
import { MoreHorizontal } from "lucide-react";
interface IssueBlockProps {
sub_group_id: string;
@ -20,37 +18,28 @@ interface IssueBlockProps {
isDragDisabled: boolean;
showEmptyGroup: boolean;
handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue, action: EIssueActions) => void;
quickActions: (
sub_group_by: string | null,
group_by: string | null,
issue: IIssue,
customActionButton?: React.ReactElement
) => React.ReactNode;
quickActions: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => React.ReactNode;
displayProperties: IIssueDisplayProperties | null;
isReadOnly: boolean;
}
export const KanBanIssueMemoBlock: React.FC<IssueBlockProps> = (props) => {
const {
sub_group_id,
columnId,
index,
issue,
isDragDisabled,
showEmptyGroup,
handleIssues,
quickActions,
displayProperties,
isReadOnly,
} = props;
// router
interface IssueDetailsBlockProps {
sub_group_id: string;
columnId: string;
issue: IIssue;
showEmptyGroup: boolean;
handleIssues: (sub_group_by: string | null, group_by: string | null, issue: IIssue, action: EIssueActions) => void;
quickActions: (sub_group_by: string | null, group_by: string | null, issue: IIssue) => React.ReactNode;
displayProperties: IIssueDisplayProperties | null;
isReadOnly: boolean;
}
const KanbanIssueDetailsBlock: React.FC<IssueDetailsBlockProps> = (props) => {
const { sub_group_id, columnId, issue, showEmptyGroup, handleIssues, quickActions, displayProperties, isReadOnly } =
props;
const router = useRouter();
// states
const [isMenuActive, setIsMenuActive] = useState(false);
const menuActionRef = useRef<HTMLDivElement | null>(null);
const updateIssue = (sub_group_by: string | null, group_by: string | null, issueToUpdate: IIssue) => {
if (issueToUpdate) handleIssues(sub_group_by, group_by, issueToUpdate, EIssueActions.UPDATE);
};
@ -64,24 +53,70 @@ export const KanBanIssueMemoBlock: React.FC<IssueBlockProps> = (props) => {
});
};
return (
<>
{displayProperties && displayProperties?.key && (
<div className="relative">
<div className="text-xs line-clamp-1 text-custom-text-300">
{issue.project_detail.identifier}-{issue.sequence_id}
</div>
<div className="absolute -top-1 right-0 hidden group-hover/kanban-block:block">
{quickActions(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!columnId && columnId === "null" ? null : columnId,
issue
)}
</div>
</div>
)}
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
<div className="line-clamp-2 text-sm font-medium text-custom-text-100" onClick={handleIssuePeekOverview}>
{issue.name}
</div>
</Tooltip>
<div>
<KanBanProperties
sub_group_id={sub_group_id}
columnId={columnId}
issue={issue}
handleIssues={updateIssue}
displayProperties={displayProperties}
showEmptyGroup={showEmptyGroup}
isReadOnly={isReadOnly}
/>
</div>
</>
);
};
const validateMemo = (prevProps: IssueDetailsBlockProps, nextProps: IssueDetailsBlockProps) => {
if (prevProps.issue !== nextProps.issue) return false;
if (!isEqual(prevProps.displayProperties, nextProps.displayProperties)) {
return false;
}
return true;
};
const KanbanIssueMemoBlock = memo(KanbanIssueDetailsBlock, validateMemo);
export const KanbanIssueBlock: React.FC<IssueBlockProps> = (props) => {
const {
sub_group_id,
columnId,
index,
issue,
isDragDisabled,
showEmptyGroup,
handleIssues,
quickActions,
displayProperties,
isReadOnly,
} = props;
let draggableId = issue.id;
if (columnId) draggableId = `${draggableId}__${columnId}`;
if (sub_group_id) draggableId = `${draggableId}__${sub_group_id}`;
useOutsideClickDetector(menuActionRef, () => setIsMenuActive(false));
const customActionButton = (
<div
ref={menuActionRef}
className={`w-full cursor-pointer text-custom-sidebar-text-400 rounded p-1 hover:bg-custom-background-80 ${
isMenuActive ? "bg-custom-background-80 text-custom-text-100" : "text-custom-text-200"
}`}
onClick={() => setIsMenuActive(!isMenuActive)}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</div>
);
return (
<>
<Draggable draggableId={draggableId} index={index}>
@ -100,55 +135,20 @@ export const KanBanIssueMemoBlock: React.FC<IssueBlockProps> = (props) => {
isDragDisabled ? "" : "hover:cursor-grab"
} ${snapshot.isDragging ? `border-custom-primary-100` : `border-transparent`}`}
>
{displayProperties && displayProperties?.key && (
<div className="relative">
<div className="text-xs line-clamp-1 text-custom-text-300">
{issue.project_detail.identifier}-{issue.sequence_id}
</div>
<div
className={`absolute -top-1 right-0 hidden group-hover/kanban-block:block ${
isMenuActive ? "!block" : ""
}`}
>
{quickActions(
!sub_group_id && sub_group_id === "null" ? null : sub_group_id,
!columnId && columnId === "null" ? null : columnId,
issue,
customActionButton
)}
</div>
</div>
)}
<Tooltip tooltipHeading="Title" tooltipContent={issue.name}>
<div
className="line-clamp-2 text-sm font-medium text-custom-text-100"
onClick={handleIssuePeekOverview}
>
{issue.name}
</div>
</Tooltip>
<div>
<KanBanProperties
<KanbanIssueMemoBlock
sub_group_id={sub_group_id}
columnId={columnId}
issue={issue}
handleIssues={updateIssue}
displayProperties={displayProperties}
showEmptyGroup={showEmptyGroup}
handleIssues={handleIssues}
quickActions={quickActions}
displayProperties={displayProperties}
isReadOnly={isReadOnly}
/>
</div>
</div>
</div>
)}
</Draggable>
</>
);
};
const validateMemo = (prevProps: IssueBlockProps, nextProps: IssueBlockProps) => {
if (prevProps.issue != nextProps.issue) return true;
return false;
};
export const KanbanIssueBlock = memo(KanBanIssueMemoBlock, validateMemo);

View File

@ -22,9 +22,11 @@ export const IssueBlocksList: FC<Props> = (props) => {
return (
<div className="w-full h-full relative divide-y-[0.5px] divide-custom-border-200">
{issueIds && issueIds.length > 0 ? (
issueIds.map((issueId: string) => (
issueIds.map(
(issueId: string) =>
issues[issueId] && (
<IssueBlock
key={issues[issueId]?.id}
key={issues[issueId].id}
columnId={columnId}
issue={issues[issueId]}
handleIssues={handleIssues}
@ -32,7 +34,8 @@ export const IssueBlocksList: FC<Props> = (props) => {
isReadonly={isReadonly}
displayProperties={displayProperties}
/>
))
)
)
) : (
<div className="bg-custom-background-100 text-custom-text-400 text-sm p-3">No issues</div>
)}

View File

@ -0,0 +1,33 @@
import { FC, useState } from "react";
import { Plus } from "lucide-react";
import { Button } from "@plane/ui";
// components
import { CreateUpdateProjectViewModal } from "components/views";
interface ISaveFilterView {
workspaceSlug: string;
projectId: string;
filterParams: any;
}
export const SaveFilterView: FC<ISaveFilterView> = (props) => {
const { workspaceSlug, projectId, filterParams } = props;
const [viewModal, setViewModal] = useState<boolean>(false);
return (
<div>
<CreateUpdateProjectViewModal
workspaceSlug={workspaceSlug}
projectId={projectId}
preLoadedData={{ query_data: { ...filterParams } }}
isOpen={viewModal}
onClose={() => setViewModal(false)}
/>
<Button size="sm" prependIcon={<Plus />} onClick={() => setViewModal(true)}>
Save View
</Button>
</div>
);
};

View File

@ -26,7 +26,7 @@ export const SpreadsheetStateColumn: React.FC<Props> = (props) => {
<>
<IssuePropertyState
projectId={issue.project_detail?.id ?? null}
value={issue.state_detail.id}
value={issue.state}
defaultOptions={issue?.state_detail ? [issue.state_detail] : []}
onChange={(data) => onChange({ state: data.id, state_detail: data })}
className="h-full w-full"

View File

@ -142,7 +142,7 @@ export const SpreadsheetView: React.FC<Props> = observer((props) => {
</div>
<div className="border-t border-custom-border-100">
<div className="mb-3 z-50 sticky bottom-0 left-0">
<div className="mb-3 z-5 sticky bottom-0 left-0">
{enableQuickCreateIssue && (
<SpreadsheetQuickAddIssueForm formKey="name" quickAddCallback={quickAddCallback} viewId={viewId} />
)}

View File

@ -78,8 +78,8 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
[issue, issueUpdate]
);
const [localTitleValue, setLocalTitleValue] = useState("");
const [localIssueDescription, setLocalIssueDescription] = useState("");
const [localTitleValue, setLocalTitleValue] = useState(issue.name);
const [localIssueDescription, setLocalIssueDescription] = useState(issue.description_html);
useEffect(() => {
if (issue.id) {
@ -88,10 +88,6 @@ export const PeekOverviewIssueDetails: FC<IPeekOverviewIssueDetails> = (props) =
}
}, [issue.id]);
useEffect(() => {
setLocalTitleValue(issue.name);
}, [issue.name]);
const debouncedFormSave = debounce(async () => {
handleSubmit(handleDescriptionFormSubmit)().finally(() => setIsSubmitting("submitted"));
}, 1500);

View File

@ -1,4 +1,4 @@
import { FC, Fragment, ReactNode } from "react";
import { FC, Fragment, ReactNode, useEffect } from "react";
import { useRouter } from "next/router";
import { observer } from "mobx-react-lite";
import useSWR from "swr";
@ -40,17 +40,17 @@ export const IssuePeekOverview: FC<IIssuePeekOverview> = observer((props) => {
const { setToastAlert } = useToast();
useSWR(
workspaceSlug && projectId && issueId && peekIssueId && issueId === peekIssueId
? `ISSUE_DETAILS_${workspaceSlug}_${projectId}_${peekIssueId}`
: null,
async () => {
if (workspaceSlug && projectId && issueId && issueId === peekIssueId) {
if (isArchived) await archivedIssueDetailStore.fetchPeekIssueDetails(workspaceSlug, projectId, peekIssueId);
else await issueDetailStore.fetchPeekIssueDetails(workspaceSlug, projectId, peekIssueId);
const fetchIssueDetail = async () => {
if (workspaceSlug && projectId && peekIssueId) {
if (isArchived)
await archivedIssueDetailStore.fetchPeekIssueDetails(workspaceSlug, projectId, peekIssueId as string);
else await issueDetailStore.fetchPeekIssueDetails(workspaceSlug, projectId, peekIssueId as string);
}
}
);
};
useEffect(() => {
fetchIssueDetail();
}, [workspaceSlug, projectId, peekIssueId]);
const handleCopyText = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();