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
22 changes: 22 additions & 0 deletions apps/api/plane/app/serializers/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ class IssueCreateSerializer(BaseSerializer):
write_only=True,
required=False,
)
cover_image_attachment_id = serializers.PrimaryKeyRelatedField(
source="cover_image_attachment",
queryset=FileAsset.objects.all(),
required=False,
allow_null=True,
)
project_id = serializers.UUIDField(source="project.id", read_only=True)
workspace_id = serializers.UUIDField(source="workspace.id", read_only=True)

Expand Down Expand Up @@ -194,6 +200,21 @@ def validate(self, attrs):
):
raise serializers.ValidationError("Estimate point is not valid please pass a valid estimate_point_id")

# Cover image must be an image attachment belonging to this issue. A new
# issue has no attachments yet, so a cover can only be set on update.
if attrs.get("cover_image_attachment"):
cover = attrs["cover_image_attachment"]
if (
self.instance is None
or cover.entity_type != FileAsset.EntityTypeContext.ISSUE_ATTACHMENT
or str(cover.issue_id) != str(self.instance.id)
or str(cover.project_id) != str(self.context.get("project_id"))
or not str(cover.attributes.get("type", "")).startswith("image/")
):
raise serializers.ValidationError(
"Cover image must be an image attachment belonging to this work item"
)

return attrs

def create(self, validated_data):
Expand Down Expand Up @@ -800,6 +821,7 @@ class Meta:
"link_count",
"is_draft",
"archived_at",
"cover_image_attachment_id",
]
read_only_fields = fields

Expand Down
6 changes: 5 additions & 1 deletion apps/api/plane/app/views/issue/attachment.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
# Module imports
from .. import BaseAPIView
from plane.app.serializers import IssueAttachmentSerializer
from plane.db.models import FileAsset, Workspace
from plane.db.models import FileAsset, Issue, Workspace
from plane.bgtasks.issue_activities_task import issue_activity
from plane.app.permissions import allow_permission, ROLE
from plane.settings.storage import S3Storage
Expand Down Expand Up @@ -153,6 +153,10 @@ def delete(self, request, slug, project_id, issue_id, pk):
issue_attachment.deleted_at = timezone.now()
issue_attachment.save()

# Soft-deleting the asset does not trip the cover FK's on_delete, so any
# work item using it as its cover would keep a dangling reference. Clear it.
Issue.objects.filter(cover_image_attachment_id=pk).update(cover_image_attachment=None)

issue_activity.delay(
type="attachment.activity.deleted",
requested_data=None,
Expand Down
3 changes: 3 additions & 0 deletions apps/api/plane/app/views/issue/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ def get(self, request, slug, project_id):
"is_draft",
"archived_at",
"deleted_at",
"cover_image_attachment_id",
)
datetime_fields = ["created_at", "updated_at"]
issues = user_timezone_converter(issues, datetime_fields, request.user.user_timezone)
Expand Down Expand Up @@ -452,6 +453,7 @@ def create(self, request, slug, project_id):
"is_draft",
"archived_at",
"deleted_at",
"cover_image_attachment_id",
)
.first()
)
Expand Down Expand Up @@ -883,6 +885,7 @@ def list(self, request, slug, project_id):
"link_count",
"attachment_count",
"sub_issues_count",
"cover_image_attachment_id",
]

if str(is_description_required).lower() == "true":
Expand Down
1 change: 1 addition & 0 deletions apps/api/plane/app/views/issue/sub_issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ def get(self, request, slug, project_id, issue_id):
"is_draft",
"archived_at",
"state_group",
"cover_image_attachment_id",
)
)

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 4.2.29 on 2026-05-28 20:50

from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):

dependencies = [
('db', '0121_alter_estimate_type'),
]

operations = [
migrations.AddField(
model_name='issue',
name='cover_image_attachment',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='cover_image_issues', to='db.fileasset'),
),
]
7 changes: 7 additions & 0 deletions apps/api/plane/db/models/issue.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,13 @@ class Issue(ChangeTrackerMixin, ProjectBaseModel):
null=True,
blank=True,
)
cover_image_attachment = models.ForeignKey(
"db.FileAsset",
on_delete=models.SET_NULL,
related_name="cover_image_issues",
null=True,
blank=True,
)

issue_objects = IssueManager()

Expand Down
1 change: 1 addition & 0 deletions apps/api/plane/utils/grouper.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ def issue_on_results(
"is_draft",
"archived_at",
"state__group",
"cover_image_attachment_id",
]

if group_by in FIELD_MAPPER:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type { FileRejection } from "react-dropzone";
import { useDropzone } from "react-dropzone";
import { UploadCloud } from "lucide-react";
import { useTranslation } from "@plane/i18n";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { TOAST_TYPE, setToast, setPromiseToast } from "@plane/propel/toast";
import type { TIssueServiceType } from "@plane/types";
import { EIssueServiceType } from "@plane/types";
// hooks
Expand Down Expand Up @@ -53,19 +53,51 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
toggleDeleteAttachmentModal,
fetchActivities,
} = useIssueDetail(issueServiceType);
const { updateIssue, getIssueById } = useIssueDetail(issueServiceType).issue;
const { operations: attachmentOperations, snapshot: attachmentSnapshot } = attachmentHelpers;
const { create: createAttachment } = attachmentOperations;
const { uploadStatus } = attachmentSnapshot;
// file size
const { maxFileSize } = useFileSize();
// derived values
const issueAttachments = getAttachmentsByIssueId(issueId);
const coverImageAttachmentId = getIssueById(issueId)?.cover_image_attachment_id ?? null;

// handlers
const handleFetchPropertyActivities = useCallback(() => {
fetchActivities(workspaceSlug, projectId, issueId);
}, [fetchActivities, workspaceSlug, projectId, issueId]);

const handleToggleCoverImage = useCallback(
async (attachmentId: string) => {
const isCurrentCover = getIssueById(issueId)?.cover_image_attachment_id === attachmentId;
const nextCoverId = isCurrentCover ? null : attachmentId;
const coverPromise = updateIssue(workspaceSlug, projectId, issueId, {
cover_image_attachment_id: nextCoverId,
}).then(() => {
handleFetchPropertyActivities();
return;
});

setPromiseToast(coverPromise, {
loading: isCurrentCover ? t("attachment.remove_cover_loading") : t("attachment.set_cover_loading"),
success: {
title: isCurrentCover ? t("attachment.remove_cover_success_title") : t("attachment.set_cover_success_title"),
message: () =>
isCurrentCover ? t("attachment.remove_cover_success_message") : t("attachment.set_cover_success_message"),
},
error: {
title: isCurrentCover ? t("attachment.remove_cover_error_title") : t("attachment.set_cover_error_title"),
message: () =>
isCurrentCover ? t("attachment.remove_cover_error_message") : t("attachment.set_cover_error_message"),
},
});

return coverPromise;
},
[updateIssue, getIssueById, workspaceSlug, projectId, issueId, handleFetchPropertyActivities, t]
);

const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: FileRejection[]) => {
const totalAttachedFiles = acceptedFiles.length + rejectedFiles.length;
Expand Down Expand Up @@ -100,7 +132,7 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
});
return;
},
[createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities]
[createAttachment, maxFileSize, workspaceSlug, handleFetchPropertyActivities, t]
);

const { getRootProps, getInputProps, isDragActive } = useDropzone({
Expand All @@ -112,8 +144,8 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList

return (
<>
{uploadStatus?.map((uploadStatus) => (
<IssueAttachmentsUploadItem key={uploadStatus.id} uploadStatus={uploadStatus} />
{uploadStatus?.map((status) => (
<IssueAttachmentsUploadItem key={status.id} uploadStatus={status} />
))}
{issueAttachments && (
<>
Expand Down Expand Up @@ -147,6 +179,8 @@ export const IssueAttachmentItemList = observer(function IssueAttachmentItemList
attachmentId={attachmentId}
disabled={disabled}
issueServiceType={issueServiceType}
onToggleCoverImage={handleToggleCoverImage}
isCoverImage={coverImageAttachmentId === attachmentId}
/>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

import { observer } from "mobx-react";
import { Image, ImageOff } from "lucide-react";

import { useTranslation } from "@plane/i18n";
import { TrashIcon } from "@plane/propel/icons";
Expand All @@ -28,12 +29,20 @@ type TIssueAttachmentsListItem = {
attachmentId: string;
disabled?: boolean;
issueServiceType?: TIssueServiceType;
onToggleCoverImage?: (attachmentId: string) => Promise<void>;
isCoverImage?: boolean;
};

export const IssueAttachmentsListItem = observer(function IssueAttachmentsListItem(props: TIssueAttachmentsListItem) {
const { t } = useTranslation();
// props
const { attachmentId, disabled, issueServiceType = EIssueServiceType.ISSUES } = props;
const {
attachmentId,
disabled,
issueServiceType = EIssueServiceType.ISSUES,
onToggleCoverImage,
isCoverImage = false,
} = props;
// store hooks
const { getUserDetails } = useMember();
const {
Expand All @@ -46,6 +55,7 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
const fileExtension = getFileExtension(attachment?.attributes.name ?? "");
const fileIcon = getFileIcon(fileExtension, 18);
const fileURL = getFileURL(attachment?.asset_url ?? "");
const isImage = attachment?.attributes.name ? /\.(jpg|jpeg|png|gif|webp)$/i.test(attachment.attributes.name) : false;
// hooks
const { isMobile } = usePlatformOS();

Expand Down Expand Up @@ -87,6 +97,22 @@ export const IssueAttachmentsListItem = observer(function IssueAttachmentsListIt
)}

<CustomMenu ellipsis closeOnSelect placement="bottom-end" disabled={disabled}>
{isImage && onToggleCoverImage && (
<CustomMenu.MenuItem
onClick={() => {
onToggleCoverImage(attachmentId);
}}
>
<div className="flex items-center gap-2">
{isCoverImage ? (
<ImageOff className="h-3.5 w-3.5" strokeWidth={2} />
) : (
<Image className="h-3.5 w-3.5" strokeWidth={2} />
)}
<span>{isCoverImage ? t("attachment.remove_cover_image") : t("attachment.make_cover_image")}</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={() => {
toggleDeleteAttachmentModal(attachmentId);
Expand Down
49 changes: 49 additions & 0 deletions apps/web/core/components/issues/issue-detail/cover-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/

import { useEffect, useState } from "react";
import { observer } from "mobx-react";
import { useParams } from "next/navigation";
import { cn } from "@plane/utils";
import { useIssueCoverImage } from "@/hooks/use-issue-cover-image";

interface IssueDetailCoverImageProps {
issueId: string;
projectId: string | null;
coverImageAttachmentId?: string | null;
// Negative margins must cancel the parent container's horizontal padding so the
// cover bleeds edge-to-edge. Defaults assume a px-8 container (peek overview);
// pass a matching value for other paddings (e.g. px-9 on the browse view).
layoutClassName?: string;
}

export const IssueDetailCoverImage = observer(function IssueDetailCoverImage(props: IssueDetailCoverImageProps) {
const { issueId, projectId, coverImageAttachmentId, layoutClassName = "-mx-8 w-[calc(100%+4rem)]" } = props;
const { workspaceSlug } = useParams();
const [imageLoadError, setImageLoadError] = useState(false);

const coverImageUrl = useIssueCoverImage(workspaceSlug?.toString(), projectId, issueId, coverImageAttachmentId);

useEffect(() => {
setImageLoadError(false);
}, [coverImageUrl]);

if (!coverImageUrl || imageLoadError) {
return null;
}

return (
<div className={cn("-mt-5 mb-4 h-60 overflow-hidden", layoutClassName)}>
<img
src={coverImageUrl}
alt="Cover"
className="h-full w-full object-cover"
onError={() => setImageLoadError(true)}
loading="lazy"
/>
</div>
);
});
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import { IssueDetailWidgets } from "../issue-detail-widgets";
import { NameDescriptionUpdateStatus } from "../issue-update-status";
import { PeekOverviewProperties } from "../peek-overview/properties";
import { IssueTitleInput } from "../title-input";
import { IssueDetailCoverImage } from "./cover-image";
import { IssueActivity } from "./issue-activity";
import { IssueParentDetail } from "./parent";
import { IssueReaction } from "./reactions";
Expand Down Expand Up @@ -92,6 +93,12 @@ export const IssueMainContent = observer(function IssueMainContent(props: Props)

return (
<>
<IssueDetailCoverImage
issueId={issueId}
projectId={issue.project_id}
coverImageAttachmentId={issue.cover_image_attachment_id}
layoutClassName="-mx-9 w-[calc(100%+4.5rem)]"
/>
<div className="space-y-4 rounded-lg">
{issue.parent_id && (
<IssueParentDetail
Expand Down
Loading