From 34d6b135f2be363e368f251e0453892a89b0ea73 Mon Sep 17 00:00:00 2001 From: guru_sainath Date: Tue, 27 Feb 2024 16:58:46 +0530 Subject: [PATCH] [WEB-581] fix: issue editing functionality enhancement in Create/Edit modal (#3809) * chore: draft issue update request * chore: changed the serializer * chore: handled issue description in issue modal, inbox issues mutation and draft issue mutaion and changed the endpoints * chore: handled draft toggle in make a issue payload in issues * chore: handled issue labels in the inbox issues --------- Co-authored-by: NarayanBavisetti --- apiserver/plane/app/views/issue.py | 11 +- web/components/inbox/inbox-issue-actions.tsx | 15 +- .../issues/issue-detail/inbox/root.tsx | 4 +- .../issues/issue-detail/inbox/sidebar.tsx | 4 + .../issues/issue-detail/label/root.tsx | 31 ++-- .../quick-action-dropdowns/project-issue.tsx | 5 +- .../roots/draft-issue-layout-root.tsx | 2 +- web/components/issues/issue-modal/form.tsx | 169 ++++++++++-------- web/components/issues/issue-modal/modal.tsx | 40 ++++- web/components/issues/peek-overview/root.tsx | 11 +- .../archived-issues/[archivedIssueId].tsx | 2 +- web/services/issue/issue_draft.service.ts | 6 +- web/store/inbox/inbox_issue.store.ts | 44 +---- web/store/issue/draft/issue.store.ts | 4 +- web/store/issue/issue-details/issue.store.ts | 50 +++++- web/store/issue/issue-details/root.store.ts | 8 +- 16 files changed, 250 insertions(+), 156 deletions(-) diff --git a/apiserver/plane/app/views/issue.py b/apiserver/plane/app/views/issue.py index 25c42dc5b..66cc3caf7 100644 --- a/apiserver/plane/app/views/issue.py +++ b/apiserver/plane/app/views/issue.py @@ -2310,17 +2310,10 @@ class IssueDraftViewSet(BaseViewSet): status=status.HTTP_404_NOT_FOUND, ) - serializer = IssueSerializer(issue, data=request.data, partial=True) + serializer = IssueCreateSerializer(issue, data=request.data, partial=True) if serializer.is_valid(): - if request.data.get( - "is_draft" - ) is not None and not request.data.get("is_draft"): - serializer.save( - created_at=timezone.now(), updated_at=timezone.now() - ) - else: - serializer.save() + serializer.save() issue_activity.delay( type="issue_draft.activity.updated", requested_data=json.dumps(request.data, cls=DjangoJSONEncoder), diff --git a/web/components/inbox/inbox-issue-actions.tsx b/web/components/inbox/inbox-issue-actions.tsx index 691ce36c7..8a3bb4261 100644 --- a/web/components/inbox/inbox-issue-actions.tsx +++ b/web/components/inbox/inbox-issue-actions.tsx @@ -92,7 +92,7 @@ export const InboxIssueActionsHeader: FC = observer((p id: inboxIssueId, state: "SUCCESS", element: "Inbox page", - } + }, }); router.push({ pathname: `/${workspaceSlug}/projects/${projectId}/inbox/${inboxId}`, @@ -269,12 +269,17 @@ export const InboxIssueActionsHeader: FC = observer((p { if (!date) return; setDate(date) }} + onSelect={(date) => { + if (!date) return; + setDate(date); + }} mode="single" className="border border-custom-border-200 rounded-md p-3" - disabled={[{ - before: tomorrow, - }]} + disabled={[ + { + before: tomorrow, + }, + ]} /> - )} - {envConfig?.has_openai_configured && ( - { - setGptAssistantModal((prevData) => !prevData); - // this is done so that the title do not reset after gpt popover closed - reset(getValues()); - }} - onResponse={(response) => { - handleAiAssistance(response); - }} - placement="top-end" - button={ + {data?.description_html === undefined ? ( + + +
+ + +
+
+ + +
+ +
+ +
+
+ + +
+
+ ) : ( + +
+ {issueName && issueName.trim() !== "" && envConfig?.has_openai_configured && ( - } + )} + {envConfig?.has_openai_configured && ( + { + setGptAssistantModal((prevData) => !prevData); + // this is done so that the title do not reset after gpt popover closed + reset(getValues()); + }} + onResponse={(response) => { + handleAiAssistance(response); + }} + placement="top-end" + button={ + + } + /> + )} +
+ ( + { + onChange(description_html); + handleFormChange(); + }} + mentionHighlights={mentionHighlights} + mentionSuggestions={mentionSuggestions} + // tabIndex={2} + /> + )} /> - )} - - ( - { - onChange(description_html); - handleFormChange(); - }} - mentionHighlights={mentionHighlights} - mentionSuggestions={mentionSuggestions} - // tabIndex={2} - /> - )} - /> +
+ )}
= observer((prop const [changesMade, setChangesMade] = useState | null>(null); const [createMore, setCreateMore] = useState(false); const [activeProjectId, setActiveProjectId] = useState(null); + const [description, setDescription] = useState(undefined); // store hooks const { captureIssueEvent } = useEventTracker(); const { @@ -53,7 +63,8 @@ export const CreateUpdateIssueModal: React.FC = observer((prop const { issues: cycleIssues } = useIssues(EIssuesStoreType.CYCLE); const { issues: viewIssues } = useIssues(EIssuesStoreType.PROJECT_VIEW); const { issues: profileIssues } = useIssues(EIssuesStoreType.PROFILE); - const { issues: draftIssueStore } = useIssues(EIssuesStoreType.DRAFT); + const { issues: draftIssues } = useIssues(EIssuesStoreType.DRAFT); + const { fetchIssue } = useIssueDetail(); // store mapping based on current store const issueStores = { [EIssuesStoreType.PROJECT]: { @@ -86,7 +97,20 @@ export const CreateUpdateIssueModal: React.FC = observer((prop // current store details const { store: currentIssueStore, viewId } = issueStores[storeType]; + const fetchIssueDetail = async (issueId: string | undefined) => { + if (!workspaceSlug || !projectId) return; + if (issueId === undefined) { + setDescription("

"); + return; + } + const response = await fetchIssue(workspaceSlug, projectId, issueId, isDraft ? "DRAFT" : "DEFAULT"); + if (response) setDescription(response?.description_html || "

"); + }; + useEffect(() => { + // fetching issue details + if (isOpen) fetchIssueDetail(data?.id); + // if modal is closed, reset active project to null // and return to avoid activeProjectId being set to some other project if (!isOpen) { @@ -105,6 +129,9 @@ export const CreateUpdateIssueModal: React.FC = observer((prop // in the url. This has the least priority. if (workspaceProjectIds && workspaceProjectIds.length > 0 && !activeProjectId) setActiveProjectId(projectId ?? workspaceProjectIds?.[0]); + + // clearing up the description state when we leave the component + return () => setDescription(undefined); }, [data, projectId, workspaceProjectIds, isOpen, activeProjectId]); const addIssueToCycle = async (issue: TIssue, cycleId: string) => { @@ -142,7 +169,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop try { const response = is_draft_issue - ? await draftIssueStore.createIssue(workspaceSlug, payload.project_id, payload) + ? await draftIssues.createIssue(workspaceSlug, payload.project_id, payload) : await currentIssueStore.createIssue(workspaceSlug, payload.project_id, payload, viewId); if (!response) throw new Error(); @@ -183,7 +210,10 @@ export const CreateUpdateIssueModal: React.FC = observer((prop if (!workspaceSlug || !payload.project_id || !data?.id) return; try { - await currentIssueStore.updateIssue(workspaceSlug, payload.project_id, data.id, payload, viewId); + isDraft + ? await draftIssues.updateIssue(workspaceSlug, payload.project_id, data.id, payload) + : await currentIssueStore.updateIssue(workspaceSlug, payload.project_id, data.id, payload, viewId); + setToastAlert({ type: "success", title: "Success!", @@ -261,6 +291,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop changesMade={changesMade} data={{ ...data, + description_html: description, cycle_id: data?.cycle_id ? data?.cycle_id : cycleId ? cycleId : null, module_ids: data?.module_ids ? data?.module_ids : moduleId ? [moduleId] : null, }} @@ -276,6 +307,7 @@ export const CreateUpdateIssueModal: React.FC = observer((prop = observer((props) => { - const { is_archived = false } = props; + const { is_archived = false, is_draft = false } = props; // hooks const { setToastAlert } = useToast(); // router @@ -72,7 +73,12 @@ export const IssuePeekOverview: FC = observer((props) => { () => ({ fetch: async (workspaceSlug: string, projectId: string, issueId: string) => { try { - await fetchIssue(workspaceSlug, projectId, issueId, is_archived); + await fetchIssue( + workspaceSlug, + projectId, + issueId, + is_archived ? "ARCHIVED" : is_draft ? "DRAFT" : "DEFAULT" + ); } catch (error) { console.error("Error fetching the parent issue"); } @@ -302,6 +308,7 @@ export const IssuePeekOverview: FC = observer((props) => { }), [ is_archived, + is_draft, fetchIssue, updateIssue, removeIssue, diff --git a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx b/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx index 1538d240f..17c002c3c 100644 --- a/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx +++ b/web/pages/[workspaceSlug]/projects/[projectId]/archived-issues/[archivedIssueId].tsx @@ -42,7 +42,7 @@ const ArchivedIssueDetailsPage: NextPageWithLayout = observer(() => { ? `ARCHIVED_ISSUE_DETAIL_${workspaceSlug}_${projectId}_${archivedIssueId}` : null, workspaceSlug && projectId && archivedIssueId - ? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString(), true) + ? () => fetchIssue(workspaceSlug.toString(), projectId.toString(), archivedIssueId.toString(), "ARCHIVED") : null ); diff --git a/web/services/issue/issue_draft.service.ts b/web/services/issue/issue_draft.service.ts index b4eb995b0..a93bda776 100644 --- a/web/services/issue/issue_draft.service.ts +++ b/web/services/issue/issue_draft.service.ts @@ -42,8 +42,10 @@ export class IssueDraftService extends APIService { }); } - async getDraftIssueById(workspaceSlug: string, projectId: string, issueId: string): Promise { - return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/${issueId}/`) + async getDraftIssueById(workspaceSlug: string, projectId: string, issueId: string, queries?: any): Promise { + return this.get(`/api/workspaces/${workspaceSlug}/projects/${projectId}/issue-drafts/${issueId}/`, { + params: queries, + }) .then((response) => response?.data) .catch((error) => { throw error?.response; diff --git a/web/store/inbox/inbox_issue.store.ts b/web/store/inbox/inbox_issue.store.ts index 2fedb73dc..4f980357f 100644 --- a/web/store/inbox/inbox_issue.store.ts +++ b/web/store/inbox/inbox_issue.store.ts @@ -53,7 +53,7 @@ export interface IInboxIssue { inboxId: string, inboxIssueId: string, data: Partial - ) => Promise; + ) => Promise; removeInboxIssue: (workspaceSlug: string, projectId: string, inboxId: string, issueId: string) => Promise; updateInboxIssueStatus: ( workspaceSlug: string, @@ -61,7 +61,7 @@ export interface IInboxIssue { inboxId: string, inboxIssueId: string, data: TInboxDetailedStatus - ) => Promise; + ) => Promise; } export class InboxIssue implements IInboxIssue { @@ -215,22 +215,9 @@ export class InboxIssue implements IInboxIssue { issue: data, }); - runInAction(() => { - const { ["issue_inbox"]: issueInboxDetail, ...issue } = response; - this.rootStore.inbox.rootStore.issue.issues.updateIssue(issue.id, issue); - const { ["id"]: omittedId, ...inboxIssue } = issueInboxDetail[0]; - set(this.inboxIssueMap, [inboxId, response.id], inboxIssue); - }); - - runInAction(() => { - update(this.inboxIssues, inboxId, (inboxIssueIds: string[] = []) => { - if (inboxIssueIds.includes(response.id)) return inboxIssueIds; - return uniq(concat(inboxIssueIds, response.id)); - }); - }); + this.rootStore.inbox.rootStore.issue.issues.updateIssue(inboxIssueId, data); await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, inboxIssueId); - return response as any; } catch (error) { throw error; } @@ -238,7 +225,7 @@ export class InboxIssue implements IInboxIssue { removeInboxIssue = async (workspaceSlug: string, projectId: string, inboxId: string, inboxIssueId: string) => { try { - const response = await this.inboxIssueService.removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId); + await this.inboxIssueService.removeInboxIssue(workspaceSlug, projectId, inboxId, inboxIssueId); runInAction(() => { pull(this.inboxIssues[inboxId], inboxIssueId); @@ -248,7 +235,6 @@ export class InboxIssue implements IInboxIssue { }); await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, inboxIssueId); - return response as any; } catch (error) { throw error; } @@ -262,34 +248,18 @@ export class InboxIssue implements IInboxIssue { data: TInboxDetailedStatus ) => { try { - const response = await this.inboxIssueService.updateInboxIssueStatus( - workspaceSlug, - projectId, - inboxId, - inboxIssueId, - data - ); + await this.inboxIssueService.updateInboxIssueStatus(workspaceSlug, projectId, inboxId, inboxIssueId, data); const pendingStatus = -2; runInAction(() => { - const { ["issue_inbox"]: issueInboxDetail, ...issue } = response; - this.rootStore.inbox.rootStore.issue.issues.addIssue([issue]); - const { ["id"]: omittedId, ...inboxIssue } = issueInboxDetail[0]; - set(this.inboxIssueMap, [inboxId, response.id], inboxIssue); + set(this.inboxIssueMap, [inboxId, inboxIssueId, "status"], data.status); + update(this.rootStore.inbox.inbox.inboxMap, [inboxId, "pending_issue_count"], (count: number = 0) => data.status === pendingStatus ? count + 1 : count - 1 ); }); - runInAction(() => { - update(this.inboxIssues, inboxId, (inboxIssueIds: string[] = []) => { - if (inboxIssueIds.includes(response.id)) return inboxIssueIds; - return uniq(concat(inboxIssueIds, response.id)); - }); - }); - await this.rootStore.issue.issueDetail.fetchActivities(workspaceSlug, projectId, inboxIssueId); - return response as any; } catch (error) { throw error; } diff --git a/web/store/issue/draft/issue.store.ts b/web/store/issue/draft/issue.store.ts index ee6d785ec..824bbb3c1 100644 --- a/web/store/issue/draft/issue.store.ts +++ b/web/store/issue/draft/issue.store.ts @@ -141,7 +141,9 @@ export class DraftIssues extends IssueHelperStore implements IDraftIssues { updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => { try { - await this.rootIssueStore.projectIssues.updateIssue(workspaceSlug, projectId, issueId, data); + await this.issueDraftService.updateDraftIssue(workspaceSlug, projectId, issueId, data); + + this.rootStore.issues.updateIssue(issueId, data); if (data.hasOwnProperty("is_draft") && data?.is_draft === false) { runInAction(() => { diff --git a/web/store/issue/issue-details/issue.store.ts b/web/store/issue/issue-details/issue.store.ts index 8731bf478..46b96c27e 100644 --- a/web/store/issue/issue-details/issue.store.ts +++ b/web/store/issue/issue-details/issue.store.ts @@ -1,6 +1,6 @@ import { makeObservable } from "mobx"; // services -import { IssueArchiveService, IssueService } from "services/issue"; +import { IssueArchiveService, IssueDraftService, IssueService } from "services/issue"; // types import { TIssue } from "@plane/types"; import { computedFn } from "mobx-utils"; @@ -8,7 +8,12 @@ import { IIssueDetail } from "./root.store"; export interface IIssueStoreActions { // actions - fetchIssue: (workspaceSlug: string, projectId: string, issueId: string, isArchived?: boolean) => Promise; + fetchIssue: ( + workspaceSlug: string, + projectId: string, + issueId: string, + issueType?: "DEFAULT" | "DRAFT" | "ARCHIVED" + ) => Promise; updateIssue: (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => Promise; removeIssue: (workspaceSlug: string, projectId: string, issueId: string) => Promise; addIssueToCycle: (workspaceSlug: string, projectId: string, cycleId: string, issueIds: string[]) => Promise; @@ -34,6 +39,7 @@ export class IssueStore implements IIssueStore { // services issueService; issueArchiveService; + issueDraftService; constructor(rootStore: IIssueDetail) { makeObservable(this, {}); @@ -42,6 +48,7 @@ export class IssueStore implements IIssueStore { // services this.issueService = new IssueService(); this.issueArchiveService = new IssueArchiveService(); + this.issueDraftService = new IssueDraftService(); } // helper methods @@ -51,21 +58,54 @@ export class IssueStore implements IIssueStore { }); // actions - fetchIssue = async (workspaceSlug: string, projectId: string, issueId: string, isArchived = false) => { + fetchIssue = async (workspaceSlug: string, projectId: string, issueId: string, issueType = "DEFAULT") => { try { const query = { expand: "issue_reactions,issue_attachment,issue_link,parent", }; let issue: TIssue; + let issuePayload: TIssue; - if (isArchived) + if (issueType === "ARCHIVED") issue = await this.issueArchiveService.retrieveArchivedIssue(workspaceSlug, projectId, issueId, query); + else if (issueType === "DRAFT") + issue = await this.issueDraftService.getDraftIssueById(workspaceSlug, projectId, issueId, query); else issue = await this.issueService.retrieve(workspaceSlug, projectId, issueId, query); if (!issue) throw new Error("Issue not found"); - this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issue], true); + issuePayload = { + id: issue?.id, + sequence_id: issue?.sequence_id, + name: issue?.name, + description_html: issue?.description_html, + sort_order: issue?.sort_order, + state_id: issue?.state_id, + priority: issue?.priority, + label_ids: issue?.label_ids, + assignee_ids: issue?.assignee_ids, + estimate_point: issue?.estimate_point, + sub_issues_count: issue?.sub_issues_count, + attachment_count: issue?.attachment_count, + link_count: issue?.link_count, + project_id: issue?.project_id, + parent_id: issue?.parent_id, + cycle_id: issue?.cycle_id, + module_ids: issue?.module_ids, + created_at: issue?.created_at, + updated_at: issue?.updated_at, + start_date: issue?.start_date, + target_date: issue?.target_date, + completed_at: issue?.completed_at, + archived_at: issue?.archived_at, + created_by: issue?.created_by, + updated_by: issue?.updated_by, + is_draft: issue?.is_draft, + is_subscribed: issue?.is_subscribed, + }; + + this.rootIssueDetailStore.rootIssueStore.issues.addIssue([issuePayload], true); // store handlers from issue detail // parent diff --git a/web/store/issue/issue-details/root.store.ts b/web/store/issue/issue-details/root.store.ts index 4c2d6add1..daaae749e 100644 --- a/web/store/issue/issue-details/root.store.ts +++ b/web/store/issue/issue-details/root.store.ts @@ -140,8 +140,12 @@ export class IssueDetail implements IIssueDetail { toggleRelationModal = (value: TIssueRelationTypes | null) => (this.isRelationModalOpen = value); // issue - fetchIssue = async (workspaceSlug: string, projectId: string, issueId: string, isArchived = false) => - this.issue.fetchIssue(workspaceSlug, projectId, issueId, isArchived); + fetchIssue = async ( + workspaceSlug: string, + projectId: string, + issueId: string, + issueType: "DEFAULT" | "ARCHIVED" | "DRAFT" = "DEFAULT" + ) => this.issue.fetchIssue(workspaceSlug, projectId, issueId, issueType); updateIssue = async (workspaceSlug: string, projectId: string, issueId: string, data: Partial) => this.issue.updateIssue(workspaceSlug, projectId, issueId, data); removeIssue = async (workspaceSlug: string, projectId: string, issueId: string) =>