Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/api/plane/db/models/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ class EntityTypeContext(models.TextChoices):
PROJECT_COVER = "PROJECT_COVER"
DRAFT_ISSUE_ATTACHMENT = "DRAFT_ISSUE_ATTACHMENT"
DRAFT_ISSUE_DESCRIPTION = "DRAFT_ISSUE_DESCRIPTION"
MODULE_DESCRIPTION = "MODULE_DESCRIPTION"
Comment thread
coderabbitai[bot] marked this conversation as resolved.

attributes = models.JSONField(default=dict)
asset = models.FileField(upload_to=get_upload_path, max_length=800)
Expand Down Expand Up @@ -97,6 +98,7 @@ def asset_url(self):
self.EntityTypeContext.COMMENT_DESCRIPTION,
self.EntityTypeContext.PAGE_DESCRIPTION,
self.EntityTypeContext.DRAFT_ISSUE_DESCRIPTION,
self.EntityTypeContext.MODULE_DESCRIPTION,
]:
return f"/api/assets/v2/workspaces/{self.workspace.slug}/projects/{self.project_id}/{self.id}/"

Expand Down
45 changes: 32 additions & 13 deletions apps/web/core/components/modules/analytics-sidebar/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* See the LICENSE file for details.
*/

import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
Expand All @@ -23,14 +23,17 @@ import {
ChevronRightIcon,
} from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { ILinkDetails, IModule, ModuleLink } from "@plane/types";
import type { ILinkDetails, IModule, ModuleLink, TModuleStatus, TNameDescriptionLoader } from "@plane/types";
import { EFileAssetType } from "@plane/types";
// plane ui
import { Loader, CustomSelect, TextArea } from "@plane/ui";
import { Loader, CustomSelect } from "@plane/ui";
// components
// helpers
import { getDate, renderFormattedPayloadDate } from "@plane/utils";
import { getDate, getModuleDescriptionInitialValue, renderFormattedPayloadDate } from "@plane/utils";
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { DescriptionInput } from "@/components/editor/rich-text/description-input";
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
import { CreateUpdateModuleLinkModal, ModuleAnalyticsProgress, ModuleLinksList } from "@/components/modules";
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
Expand All @@ -57,6 +60,7 @@ export const ModuleAnalyticsSidebar = observer(function ModuleAnalyticsSidebar(p
// states
const [moduleLinkModal, setModuleLinkModal] = useState(false);
const [selectedLinkToUpdate, setSelectedLinkToUpdate] = useState<ILinkDetails | null>(null);
const [isSubmittingDescription, setIsSubmittingDescription] = useState<TNameDescriptionLoader>("saved");
// router
const { workspaceSlug, projectId } = useParams();

Expand All @@ -71,7 +75,9 @@ export const ModuleAnalyticsSidebar = observer(function ModuleAnalyticsSidebar(p
const moduleDetails = getModuleById(moduleId);
const areEstimateEnabled = projectId && areEstimateEnabledByProjectId(projectId.toString());
const estimateType = areEstimateEnabled && currentActiveEstimateId && estimateById(currentActiveEstimateId);
const isEstimatePointValid = estimateType && estimateType?.type == EEstimateSystem.POINTS ? true : false;
const isEstimatePointValid = !!(estimateType && estimateType?.type == EEstimateSystem.POINTS);
// the editor is uncontrolled, so only recompute its starting content when the description actually changes
const descriptionInitialValue = useMemo(() => getModuleDescriptionInitialValue(moduleDetails), [moduleDetails]);

const { reset, control } = useForm({
defaultValues,
Expand Down Expand Up @@ -215,8 +221,8 @@ export const ModuleAnalyticsSidebar = observer(function ModuleAnalyticsSidebar(p
</span>
}
value={value}
onChange={(value: any) => {
submitChanges({ status: value });
onChange={(selectedStatus: TModuleStatus) => {
submitChanges({ status: selectedStatus });
}}
disabled={!isEditingAllowed || isArchived}
>
Expand All @@ -235,14 +241,27 @@ export const ModuleAnalyticsSidebar = observer(function ModuleAnalyticsSidebar(p
<h4 className="w-full text-18 font-semibold break-words text-primary">{moduleDetails.name}</h4>
</div>

{moduleDetails.description && (
<TextArea
className="ring-none !m-0 max-h-max w-full resize-none !border-0 bg-transparent !p-0 text-13 leading-5 text-secondary outline-none"
value={moduleDetails.description}
disabled
/>
{isSubmittingDescription !== "saved" && (
<div className="flex justify-end">
<NameDescriptionUpdateStatus isSubmitting={isSubmittingDescription} />
</div>
)}

<DescriptionInput
containerClassName="-ml-3 border-none p-0! pl-3! text-13"
disabled={!isEditingAllowed || isArchived}
entityId={moduleId}
fileAssetType={EFileAssetType.MODULE_DESCRIPTION}
initialValue={descriptionInitialValue}
key={moduleId}
onSubmit={async (value) => {
await submitChanges({ description_html: value.description_html });
}}
projectId={projectId?.toString()}
setIsSubmitting={setIsSubmittingDescription}
workspaceSlug={workspaceSlug?.toString()}
/>

<div className="flex flex-col gap-5 pt-2.5 pb-6">
<div className="flex items-center justify-start gap-1">
<div className="flex w-2/5 items-center justify-start gap-2 text-tertiary">
Expand Down
106 changes: 106 additions & 0 deletions apps/web/core/components/modules/description-editor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/

import { observer } from "mobx-react";
import type { Control } from "react-hook-form";
import { Controller } from "react-hook-form";
// plane imports
import { useTranslation } from "@plane/i18n";
import type { IModule } from "@plane/types";
import { EFileAssetType } from "@plane/types";
import { getDescriptionPlaceholderI18n } from "@plane/utils";
// components
import { RichTextEditor } from "@/components/editor/rich-text";
// hooks
import { useEditorAsset } from "@/hooks/store/use-editor-asset";
import { useWorkspace } from "@/hooks/store/use-workspace";
// services
import { WorkspaceService } from "@/services/workspace.service";

const workspaceService = new WorkspaceService();

type Props = {
control: Control<IModule>;
initialValue: string;
moduleId: string | undefined;
projectId: string;
tabIndex?: number;
workspaceSlug: string;
};

export const ModuleDescriptionEditor = observer(function ModuleDescriptionEditor(props: Props) {
const { control, initialValue, moduleId, projectId, tabIndex, workspaceSlug } = props;
// store hooks
const { getWorkspaceBySlug } = useWorkspace();
const { uploadEditorAsset, duplicateEditorAsset } = useEditorAsset();
// i18n
const { t } = useTranslation();
// derived values
const workspaceId = getWorkspaceBySlug(workspaceSlug)?.id;

if (!workspaceId) return null;

return (
<Controller
name="description_html"
control={control}
render={({ field: { onChange } }) => (
<RichTextEditor
key={moduleId}
editable
id="module-modal-editor"
initialValue={initialValue}
workspaceSlug={workspaceSlug}
workspaceId={workspaceId}
projectId={projectId}
onChange={(_description, description_html) => onChange(description_html)}
tabIndex={tabIndex}
placeholder={(isFocused, description) => t(getDescriptionPlaceholderI18n(isFocused, description))}
searchMentionCallback={async (payload) =>
await workspaceService.searchEntity(workspaceSlug, {
...payload,
project_id: projectId,
})
}
containerClassName="pt-3 min-h-[120px] border-[0.5px] border-subtle-1 rounded-lg"
uploadFile={async (blockId, file) => {
try {
const { asset_id } = await uploadEditorAsset({
blockId,
data: {
entity_identifier: moduleId ?? "",
entity_type: EFileAssetType.MODULE_DESCRIPTION,
},
file,
projectId,
workspaceSlug,
});
return asset_id;
} catch (error) {
console.error("Error in uploading module description asset:", error);
throw new Error("Asset upload failed. Please try again later.", { cause: error });
}
}}
duplicateFile={async (assetId: string) => {
try {
const { asset_id } = await duplicateEditorAsset({
assetId,
entityId: moduleId,
entityType: EFileAssetType.MODULE_DESCRIPTION,
projectId,
workspaceSlug,
});
return asset_id;
} catch (error) {
console.error("Error in duplicating module description asset:", error);
throw new Error("Asset duplication failed. Please try again later.", { cause: error });
}
}}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)}
/>
);
});
43 changes: 24 additions & 19 deletions apps/web/core/components/modules/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,23 @@
* See the LICENSE file for details.
*/

import { useEffect } from "react";
import { useEffect, useMemo } from "react";
import { useParams } from "next/navigation";
import { Controller, useForm } from "react-hook-form";
// plane imports
import { ETabIndices } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import type { IModule } from "@plane/types";
// ui
import { Input, TextArea } from "@plane/ui";
import { getDate, renderFormattedPayloadDate, getTabIndex } from "@plane/utils";
import { Input } from "@plane/ui";
import { getDate, getModuleDescriptionInitialValue, renderFormattedPayloadDate, getTabIndex } from "@plane/utils";
// components
import { DateRangeDropdown } from "@/components/dropdowns/date-range";
import { MemberDropdown } from "@/components/dropdowns/member/dropdown";
import { ProjectDropdown } from "@/components/dropdowns/project/dropdown";
import { ModuleStatusSelect } from "@/components/modules";
import { ModuleDescriptionEditor } from "@/components/modules/description-editor";
// hooks
import { useUser } from "@/hooks/store/user/user-user";

Expand All @@ -35,15 +37,21 @@ type Props = {
const defaultValues: Partial<IModule> = {
name: "",
description: "",
description_html: "<p></p>",
status: "backlog",
lead_id: null,
member_ids: [],
};

export function ModuleForm(props: Props) {
const { handleFormSubmit, handleClose, status, projectId, setActiveProject, data, isMobile = false } = props;
// router
const { workspaceSlug } = useParams();
// store hooks
const { projectsWithCreatePermissions } = useUser();
// derived values
// the editor is uncontrolled, so its starting content must stay stable across re-renders
const descriptionInitialValue = useMemo(() => getModuleDescriptionInitialValue(data), [data]);
// form info
const {
formState: { errors, isSubmitting, dirtyFields },
Expand All @@ -55,6 +63,7 @@ export function ModuleForm(props: Props) {
project_id: projectId,
name: data?.name || "",
description: data?.description || "",
description_html: descriptionInitialValue,
status: data?.status || "backlog",
lead_id: data?.lead_id || null,
member_ids: data?.member_ids || [],
Expand All @@ -77,8 +86,11 @@ export function ModuleForm(props: Props) {
reset({
...defaultValues,
...data,
// keep the form value in sync with what the editor renders, so submitting an
// untouched form migrates a legacy plain text description instead of clearing it
description_html: descriptionInitialValue,
});
}, [data, reset]);
}, [data, descriptionInitialValue, reset]);

return (
<form onSubmit={handleSubmit(handleCreateUpdateModule)}>
Expand All @@ -100,7 +112,7 @@ export function ModuleForm(props: Props) {
}}
multiple={false}
buttonVariant="border-with-text"
renderCondition={(projectId) => !!projectsWithCreatePermissions?.[projectId]}
renderCondition={(id) => !!projectsWithCreatePermissions?.[id]}
tabIndex={getIndex("cover_image")}
/>
</div>
Expand Down Expand Up @@ -134,28 +146,21 @@ export function ModuleForm(props: Props) {
placeholder={t("title")}
className="w-full text-14"
tabIndex={getIndex("name")}
// oxlint-disable-next-line jsx_a11y/no-autofocus
autoFocus
/>
)}
/>
<span className="text-11 text-danger-primary">{errors?.name?.message}</span>
</div>
<div>
<Controller
name="description"
<ModuleDescriptionEditor
control={control}
render={({ field: { value, onChange } }) => (
<TextArea
id="description"
name="description"
value={value}
onChange={onChange}
placeholder={t("description")}
className="min-h-24 w-full resize-none text-14"
hasError={Boolean(errors?.description)}
tabIndex={getIndex("description")}
/>
)}
initialValue={descriptionInitialValue}
moduleId={data?.id}
projectId={projectId}
tabIndex={getIndex("description")}
workspaceSlug={workspaceSlug?.toString() ?? ""}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
Expand Down
4 changes: 3 additions & 1 deletion packages/types/src/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ export enum EFileAssetType {
INITIATIVE_DESCRIPTION = "INITIATIVE_DESCRIPTION",
PROJECT_DESCRIPTION = "PROJECT_DESCRIPTION",
TEAM_SPACE_COMMENT_DESCRIPTION = "TEAM_SPACE_COMMENT_DESCRIPTION",
MODULE_DESCRIPTION = "MODULE_DESCRIPTION",
}

export type TEditorAssetType =
Expand All @@ -82,7 +83,8 @@ export type TEditorAssetType =
| EFileAssetType.TEAM_SPACE_DESCRIPTION
| EFileAssetType.INITIATIVE_DESCRIPTION
| EFileAssetType.PROJECT_DESCRIPTION
| EFileAssetType.TEAM_SPACE_COMMENT_DESCRIPTION;
| EFileAssetType.TEAM_SPACE_COMMENT_DESCRIPTION
| EFileAssetType.MODULE_DESCRIPTION;

export enum EUpdateStatus {
OFF_TRACK = "OFF-TRACK",
Expand Down
Loading