[WEB-2880] fix: workspace initiatives (#2028)

* fix: initiative comment delete endpoint

* chore: initiative order updated in app sidebar

* fix: create/update initiative modal description section

* chore: initiatve detail spacing and modal project icon improvement

* chore: project type and initiative store updated

* chore: initiative project activity added

* chore: initiative validation and improvements
This commit is contained in:
Anmol Singh Bhatia
2024-12-27 09:44:59 +05:30
committed by GitHub
parent c1bad55295
commit fdcd63dc80
12 changed files with 286 additions and 181 deletions

View File

@@ -1,6 +1,7 @@
import React, { FC, useState } from "react";
import { useParams } from "next/navigation";
import { EFileAssetType } from "@plane/types/src/enums";
import { cn } from "@plane/utils";
import { Button, Input, setToast, TOAST_TYPE } from "@plane/ui";
// components
import { DateRangeDropdown, MemberDropdown, ProjectDropdown } from "@/components/dropdowns";
@@ -79,114 +80,105 @@ export const CreateUpdateInitiativeForm: FC<Props> = (props) => {
}
}}
>
<div className="space-y-5 p-5">
<div className="flex items-center gap-x-3">
<h3 className="text-xl font-medium text-custom-text-200">
{initiativeDetail ? "Update" : "Create"} Initiative
</h3>
</div>
<div className="space-y-3">
<div className="space-y-1">
<div className="space-y-3 p-5 pb-4">
<h3 className="text-xl font-medium text-custom-text-200">{formData.id ? "Update" : "Create"} Initiative</h3>
<div className={cn("flex items-center gap-2 w-full", errors.name && "items-start")}>
<div className="space-y-1 flew-grow w-full">
<Input
id="name"
name="name"
type="text"
value={formData?.name}
value={formData.name}
onChange={(e) => handleNameChange(e.target.value)}
hasError={Boolean(errors?.name)}
placeholder="Title"
className="w-full text-base"
placeholder="Initiative name"
className="w-full resize-none text-base"
hasError={Boolean(errors.name)}
tabIndex={1}
maxLength={255}
required
autoFocus
/>
<span className="text-xs text-red-500">{errors?.name}</span>
{errors.name && <div className="text-red-500 text-xs">{errors.name}</div>}
</div>
<div className="border-[0.5px] border-custom-border-200 rounded-lg relative">
<RichTextEditor
id="initiative-modal-editor"
initialValue={
!formData?.description_html || formData?.description_html === ""
? "<p></p>"
: formData?.description_html
}
value={formData?.description_html}
workspaceSlug={workspaceSlug.toString()}
workspaceId={currentWorkspace.id}
dragDropEnabled={false}
onChange={(description_json: object, description_html: string) => {
handleFormDataChange("description_html", description_html);
</div>
<RichTextEditor
id="initiative-modal-editor"
initialValue={
!formData?.description_html || formData?.description_html === "" ? "<p></p>" : formData?.description_html
}
value={formData?.description_html}
workspaceSlug={workspaceSlug.toString()}
workspaceId={currentWorkspace.id}
dragDropEnabled={false}
onChange={(description_json: object, description_html: string) => {
handleFormDataChange("description_html", description_html);
}}
placeholder={getDescriptionPlaceholder}
searchMentionCallback={searchEntity}
editorClassName="text-xs"
containerClassName="resize-none min-h-24 text-xs border-[0.5px] border-custom-border-200 rounded-md px-3 py-2"
tabIndex={2}
uploadFile={async (file) => {
try {
const { asset_id } = await fileService.uploadWorkspaceAsset(
workspaceSlug.toString(),
{
entity_identifier: initiativeDetail?.id ?? "",
entity_type: EFileAssetType.INITIATIVE_DESCRIPTION,
},
file
);
return asset_id;
} catch (error) {
console.log("Error in uploading issue asset:", error);
throw new Error("Asset upload failed. Please try again later.");
}
}}
/>
<div className="flex flex-wrap items-center gap-2">
<div className="h-7">
<ProjectDropdown
buttonVariant={"border-with-text"}
onChange={(val) => {
handleFormDataChange("project_ids", val);
}}
placeholder={getDescriptionPlaceholder}
searchMentionCallback={searchEntity}
editorClassName="text-xs"
containerClassName="resize-none min-h-24 max-h-64 overflow-y-scroll vertical-scrollbar scrollbar-sm text-xs border-[0.5px] border-custom-border-200 rounded-md px-3 py-2"
value={formData.project_ids || []}
multiple
showTooltip
tabIndex={2}
uploadFile={async (file) => {
try {
const { asset_id } = await fileService.uploadWorkspaceAsset(
workspaceSlug.toString(),
{
entity_identifier: initiativeDetail?.id ?? "",
entity_type: EFileAssetType.INITIATIVE_DESCRIPTION,
},
file
);
return asset_id;
} catch (error) {
console.log("Error in uploading issue asset:", error);
throw new Error("Asset upload failed. Please try again later.");
}
}}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="h-7">
<ProjectDropdown
buttonVariant={"border-with-text"}
onChange={(val) => {
handleFormDataChange("project_ids", val);
}}
value={formData.project_ids || []}
multiple
showTooltip
tabIndex={2}
/>
</div>
<DateRangeDropdown
<DateRangeDropdown
buttonVariant="border-with-text"
className="h-7"
value={{
from: getDate(formData?.start_date),
to: getDate(formData?.end_date),
}}
onSelect={(val) => {
handleFormDataChange("start_date", val?.from ? renderFormattedPayloadDate(val.from) : null);
handleFormDataChange("end_date", val?.to ? renderFormattedPayloadDate(val.to) : null);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
hideIcon={{
to: true,
}}
tabIndex={3}
/>
<div className="h-7">
<MemberDropdown
value={formData?.lead ?? ""}
onChange={(val) => handleFormDataChange("lead", val)}
multiple={false}
buttonVariant="border-with-text"
className="h-7"
value={{
from: getDate(formData?.start_date),
to: getDate(formData?.end_date),
}}
onSelect={(val) => {
handleFormDataChange("start_date", val?.from ? renderFormattedPayloadDate(val.from) : null);
handleFormDataChange("end_date", val?.to ? renderFormattedPayloadDate(val.to) : null);
}}
placeholder={{
from: "Start date",
to: "End date",
}}
hideIcon={{
to: true,
}}
tabIndex={3}
placeholder="Lead"
tabIndex={4}
showUserDetails
/>
<div className="h-7">
<MemberDropdown
value={formData?.lead ?? ""}
onChange={(val) => handleFormDataChange("lead", val)}
multiple={false}
buttonVariant="border-with-text"
placeholder="Lead"
tabIndex={4}
showUserDetails
/>
</div>
</div>
</div>
</div>

View File

@@ -10,6 +10,8 @@ import { usePlatformOS } from "@/hooks/use-platform-os";
// Plane-web
import Attributes from "@/plane-web/components/projects/layouts/attributes";
//
import { useWorkspaceFeatures } from "@/plane-web/hooks/store";
import { EWorkspaceFeatures } from "@/plane-web/types/workspace-feature";
import { QuickActions } from "./quick-actions";
type Props = {
@@ -23,9 +25,12 @@ export const ProjectItem = observer((props: Props) => {
const { getProjectById, updateProject } = useProject();
const { currentWorkspace } = useWorkspace();
const { isMobile } = usePlatformOS();
const { isWorkspaceFeatureEnabled } = useWorkspaceFeatures();
const parentRef = useRef(null);
const isProjectGroupingEnabled = isWorkspaceFeatureEnabled(EWorkspaceFeatures.IS_PROJECT_GROUPING_ENABLED);
const projectDetails = getProjectById(projectId);
if (!projectDetails || !currentWorkspace) return <Spinner />;
@@ -41,6 +46,9 @@ export const ProjectItem = observer((props: Props) => {
}
quickActionElement={
<div className="flex shrink-0 items-center gap-2">
{projectDetails.total_issues > 0 && (
<span className="text-sm font-medium text-custom-text-400 px-1">{`${projectDetails.completed_issues}/${projectDetails.total_issues}`}</span>
)}
<Attributes
project={projectDetails}
isArchived={projectDetails.archived_at !== null}
@@ -48,7 +56,12 @@ export const ProjectItem = observer((props: Props) => {
workspaceSlug={workspaceSlug.toString()}
currentWorkspace={currentWorkspace}
containerClass="px-0 py-0 md:pb-4 lg:py-2"
displayProperties={{ state: true, priority: true, lead: true, date: true }}
displayProperties={{
state: isProjectGroupingEnabled,
priority: isProjectGroupingEnabled,
lead: true,
date: true,
}}
/>
<div className={cn("hidden md:flex")}>
<QuickActions

View File

@@ -17,13 +17,18 @@ type Props = {
export const QuickActions: React.FC<Props> = observer((props: Props) => {
const { workspaceSlug, initiativeId, project } = props;
// derived states
const projectLink = `${workspaceSlug}/projects/${project.id}/issues`;
// store hooks
const {
initiative: { updateInitiative },
initiative: { getInitiativeById, updateInitiative },
} = useInitiatives();
// derived states
const projectLink = `${workspaceSlug}/projects/${project.id}/issues`;
const initiative = initiativeId ? getInitiativeById(initiativeId) : undefined;
const shouldRenderRemove = (initiative?.project_ids || [])?.length > 1;
// handler
const handleCopyText = () =>
copyUrlToClipboard(projectLink).then(() =>
setToast({
@@ -46,7 +51,7 @@ export const QuickActions: React.FC<Props> = observer((props: Props) => {
action: () => updateInitiative(workspaceSlug, initiativeId, { project_ids: [project.id] }),
title: "Remove",
icon: Trash2,
shouldRender: true,
shouldRender: shouldRenderRemove,
},
];

View File

@@ -3,10 +3,14 @@
import React, { FC, useState } from "react";
import { observer } from "mobx-react";
// Plane
import { Collapsible } from "@plane/ui";
import { Collapsible, setToast, TOAST_TYPE } from "@plane/ui";
// components
import { ProjectMultiSelectModal } from "@/components/project";
// hooks
import { useProject } from "@/hooks/store";
// Plane-web
import { useInitiatives } from "@/plane-web/hooks/store/use-initiatives";
//
// local components
import { InitiativeProjectsCollapsibleContent } from "./content";
import { InitiativeProjectsCollapsibleTitle } from "./title";
@@ -20,11 +24,11 @@ export const ProjectsCollapsible: FC<Props> = observer((props) => {
const { workspaceSlug, initiativeId, disabled = false } = props;
// store hooks
const [isOpen, setIsOpen] = useState(false);
// store hooks
const [isModalOpen, setIsModalOpen] = useState(false);
const {
initiative: { getInitiativeById },
initiative: { getInitiativeById, updateInitiative },
} = useInitiatives();
const { workspaceProjectIds } = useProject();
// derived values
const initiative = getInitiativeById(initiativeId);
@@ -33,19 +37,68 @@ export const ProjectsCollapsible: FC<Props> = observer((props) => {
if (!projectsIds) return <></>;
const handleOnClick = () => {
setIsModalOpen(true);
};
const handleProjectsUpdate = async (initiativeProjectIds: string[]) => {
if (!initiativeId) return;
if (initiativeProjectIds.length === 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please select at least one project.",
});
return;
}
await updateInitiative(workspaceSlug?.toString(), initiativeId, { project_ids: initiativeProjectIds })
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: `Initiative projects updated successfully.`,
});
})
.catch((error) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.error ?? `Failed to update initiative projects. Please try again!`,
});
});
};
return (
<Collapsible
isOpen={isOpen}
onToggle={() => setIsOpen((prevState) => !prevState)}
title={<InitiativeProjectsCollapsibleTitle isOpen={isOpen} projectsIds={projectsIds} disabled={disabled} />}
buttonClassName="w-full"
>
<InitiativeProjectsCollapsibleContent
workspaceSlug={workspaceSlug}
projectIds={projectsIds}
initiativeId={initiativeId}
disabled={disabled}
<>
<ProjectMultiSelectModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSubmit={handleProjectsUpdate}
selectedProjectIds={projectsIds}
projectIds={workspaceProjectIds || []}
/>
</Collapsible>
<Collapsible
isOpen={isOpen}
onToggle={() => setIsOpen((prevState) => !prevState)}
title={
<InitiativeProjectsCollapsibleTitle
isOpen={isOpen}
projectsIds={projectsIds}
disabled={disabled}
handleOnClick={handleOnClick}
/>
}
buttonClassName="w-full"
>
<InitiativeProjectsCollapsibleContent
workspaceSlug={workspaceSlug}
projectIds={projectsIds}
initiativeId={initiativeId}
disabled={disabled}
/>
</Collapsible>
</>
);
});

View File

@@ -1,19 +1,19 @@
"use client";
import React, { FC, useMemo } from "react";
import { observer } from "mobx-react";
import { Plus } from "lucide-react";
// Plane
import { CollapsibleButton } from "@plane/ui";
// components
import { IssueLinksActionButton } from "@/components/issues/issue-detail-widgets";
type Props = {
isOpen: boolean;
projectsIds: string[] | undefined;
handleOnClick: () => void;
disabled: boolean;
};
export const InitiativeProjectsCollapsibleTitle: FC<Props> = observer((props) => {
const { isOpen, projectsIds, disabled } = props;
const { isOpen, projectsIds, handleOnClick, disabled } = props;
const projectsCount = projectsIds?.length ?? 0;
@@ -32,7 +32,13 @@ export const InitiativeProjectsCollapsibleTitle: FC<Props> = observer((props) =>
isOpen={isOpen}
title="Projects"
indicatorElement={indicatorElement}
actionItemElement={!disabled && <IssueLinksActionButton disabled={disabled} />}
actionItemElement={
!disabled && (
<button type="button" onClick={handleOnClick} disabled={disabled}>
<Plus className="h-4 w-4" />
</button>
)
}
/>
);
});

View File

@@ -58,62 +58,60 @@ export const InitiativeDetailSection = observer((props: Props) => {
"max-w-2/3": !initiativesSidebarCollapsed,
})}
>
<ContentWrapper variant={ERowVariant.REGULAR}>
<IssueLinkCreateUpdateModal
isModalOpen={isLinkModalOpen}
handleOnClose={handleOnClose}
linkOperations={linkOperations}
<IssueLinkCreateUpdateModal
isModalOpen={isLinkModalOpen}
handleOnClose={handleOnClose}
linkOperations={linkOperations}
/>
<div className="rounded-lg flex flex-col w-full gap-3 space-y-4">
<InitiativeTitleInput
value={initiative.name}
workspaceSlug={workspaceSlug.toString()}
isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting}
initiativeId={initiative.id}
/>
<div className="rounded-lg flex flex-col w-full gap-3 space-y-4">
<InitiativeTitleInput
value={initiative.name}
workspaceSlug={workspaceSlug.toString()}
isSubmitting={isSubmitting}
setIsSubmitting={setIsSubmitting}
initiativeId={initiative.id}
/>
<InitiativeDescriptionInput
workspaceSlug={workspaceSlug.toString()}
initiativeId={initiative.id}
initialValue={initiative.description_html ?? undefined}
setIsSubmitting={setIsSubmitting}
containerClassName="min-h-[120px] w-full border-none"
/>
<div className="flex justify-between">
<InitiativeReactions workspaceSlug={workspaceSlug.toString()} initiativeId={initiative.id} />
<div className="flex items-center gap-2">
<InitiativeLinksActionButton
customButton={
<div className="flex items-center gap-1 p-2 text-custom-text-300 hover:text-custom-text-100">
<Link className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />
<span className="text-sm font-medium">Add link</span>
</div>
}
disabled={disabled}
/>
<InitiativeAttachmentActionButton
workspaceSlug={workspaceSlug.toString()}
initiativeId={initiative.id}
customButton={
<div className="flex items-center gap-1 p-2 text-custom-text-300 hover:text-custom-text-100">
<Paperclip className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />
<span className="text-sm font-medium">Attach</span>
</div>
}
disabled={disabled}
/>
</div>
<InitiativeDescriptionInput
workspaceSlug={workspaceSlug.toString()}
initiativeId={initiative.id}
initialValue={initiative.description_html ?? undefined}
setIsSubmitting={setIsSubmitting}
containerClassName="min-h-[120px] w-full border-none"
/>
<div className="flex justify-between">
<InitiativeReactions workspaceSlug={workspaceSlug.toString()} initiativeId={initiative.id} />
<div className="flex items-center gap-2">
<InitiativeLinksActionButton
customButton={
<div className="flex items-center gap-1 p-2 text-custom-text-300 hover:text-custom-text-100">
<Link className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />
<span className="text-sm font-medium">Add link</span>
</div>
}
disabled={disabled}
/>
<InitiativeAttachmentActionButton
workspaceSlug={workspaceSlug.toString()}
initiativeId={initiative.id}
customButton={
<div className="flex items-center gap-1 p-2 text-custom-text-300 hover:text-custom-text-100">
<Paperclip className="h-3.5 w-3.5 flex-shrink-0" strokeWidth={2} />
<span className="text-sm font-medium">Attach</span>
</div>
}
disabled={disabled}
/>
</div>
<InitiativeProgressRoot workspaceSlug={workspaceSlug.toString()} initiativeId={initiative.id} />
{initiativesLinks && initiativesLinks.length > 0 && (
<LinksCollapsible workspaceSlug={workspaceSlug.toString()} initiativeId={initiative.id} />
)}
{initiativesAttachments && initiativesAttachments.length > 0 && (
<AttachmentsCollapsible workspaceSlug={workspaceSlug.toString()} initiativeId={initiative.id} />
)}
<ProjectsCollapsible workspaceSlug={workspaceSlug.toString()} initiativeId={initiative.id} />
</div>
</ContentWrapper>
<InitiativeProgressRoot workspaceSlug={workspaceSlug.toString()} initiativeId={initiative.id} />
{initiativesLinks && initiativesLinks.length > 0 && (
<LinksCollapsible workspaceSlug={workspaceSlug.toString()} initiativeId={initiative.id} />
)}
{initiativesAttachments && initiativesAttachments.length > 0 && (
<AttachmentsCollapsible workspaceSlug={workspaceSlug.toString()} initiativeId={initiative.id} />
)}
<ProjectsCollapsible workspaceSlug={workspaceSlug.toString()} initiativeId={initiative.id} />
</div>
</div>
);
});

View File

@@ -1,7 +1,7 @@
"use client";
import React from "react";
import { CalendarDays, Link, MessageSquare, Network, Paperclip, Users } from "lucide-react";
import { Briefcase, CalendarDays, Link, MessageSquare, Network, Paperclip, Users } from "lucide-react";
import { InitiativeIcon } from "@plane/ui";
import { TInitiativeActivity } from "@/plane-web/types/initiative";
@@ -14,6 +14,7 @@ const iconMap = {
end_date: CalendarDays,
link: Link,
attachment: Paperclip,
projects: Briefcase,
} as const;
interface InitiativeActivityIconProps {

View File

@@ -117,6 +117,17 @@ export const InitiativeActivityItem: FC<TInitiativeActivityItem> = observer((pro
)}
</>
);
case "projects":
return (
<>
{activity.new_value === "" ? `removed a project` : `added a project`}{" "}
{activity.new_value === "" ? (
<span className="font-medium text-custom-text-100">{activity.old_value}</span>
) : (
<span className="font-medium text-custom-text-100">{activity.new_value}</span>
)}
</>
);
default:
return <></>;
}

View File

@@ -3,6 +3,7 @@
import React from "react";
import { observer } from "mobx-react";
import { Briefcase, Calendar, UserCircle2 } from "lucide-react";
import { setToast, TOAST_TYPE } from "@plane/ui";
// components
import { DateRangeDropdown, MemberDropdown, ProjectDropdown } from "@/components/dropdowns";
import { ButtonAvatars } from "@/components/dropdowns/member/avatar";
@@ -44,11 +45,36 @@ export const InitiativeSidebarPropertiesRoot: React.FC<TInitiativeSidebarPropert
lead: id,
});
const handleProjects = (ids: string | string[]) =>
updateInitiative &&
const handleProjects = (ids: string | string[]) => {
const projectIds = ids ? (Array.isArray(ids) ? ids : [ids]) : [];
if (projectIds.length === 0) {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: "Please select at least one project.",
});
return;
}
updateInitiative(workspaceSlug.toString(), initiative.id, {
project_ids: ids ? (Array.isArray(ids) ? ids : [ids]) : null,
});
project_ids: projectIds,
})
.then(() => {
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Success!",
message: "Initiative projects updated successfully.",
});
})
.catch((error) => {
setToast({
type: TOAST_TYPE.ERROR,
title: "Error!",
message: error?.error ?? "Failed to update initiative projects. Please try again!",
});
});
};
return (
<div className={`mb-2 space-y-2.5 ${disabled ? "opacity-60" : ""}`}>

View File

@@ -41,12 +41,12 @@ export const SIDEBAR_WORKSPACE_MENU: Partial<Record<TSidebarWorkspaceMenuItemKey
};
export const SIDEBAR_WORKSPACE_MENU_ITEMS: TSidebarWorkspaceMenuItems[] = [
SIDEBAR_WORKSPACE_MENU?.initiatives,
SIDEBAR_WORKSPACE_MENU?.teams,
SIDEBAR_WORKSPACE_MENU?.projects,
SIDEBAR_WORKSPACE_MENU?.["all-issues"],
SIDEBAR_WORKSPACE_MENU?.["active-cycles"],
SIDEBAR_WORKSPACE_MENU?.analytics,
SIDEBAR_WORKSPACE_MENU?.initiatives,
].filter((item): item is TSidebarWorkspaceMenuItems => item !== undefined);
export { SIDEBAR_USER_MENU_ITEMS };

View File

@@ -203,7 +203,7 @@ export class InitiativeService extends APIService {
}
async deleteInitiativeComment(workspaceSlug: string, initiativeId: string, commentId: string): Promise<void> {
return this.delete(`/api/workspaces/${workspaceSlug}/initiatives/${initiativeId}/links/${commentId}/`)
return this.delete(`/api/workspaces/${workspaceSlug}/initiatives/${initiativeId}/comments/${commentId}/`)
.then((res) => res?.data)
.catch((err) => {
throw err?.response?.data;

View File

@@ -237,7 +237,7 @@ export class InitiativeStore implements IInitiativeStore {
});
await this.initiativeService.updateInitiative(workspaceSlug, initiativeId, payload);
this.initiativeCommentActivities.fetchActivities(workspaceSlug, initiativeId);
return;
} catch (error) {
runInAction(() => {