Merge branch 'preview' of github.com:makeplane/plane into feat-breadcrumbs-revamp

This commit is contained in:
Anmol Singh Bhatia
2025-06-18 15:34:43 +05:30
17 changed files with 206 additions and 90 deletions

View File

@@ -1,7 +1,5 @@
# Third party imports
from rest_framework import serializers
from rest_framework import status
from rest_framework.response import Response
# Module imports
from .base import BaseSerializer, DynamicBaseSerializer
@@ -198,6 +196,7 @@ class WorkspaceUserLinkSerializer(BaseSerializer):
class IssueRecentVisitSerializer(serializers.ModelSerializer):
project_identifier = serializers.SerializerMethodField()
assignees = serializers.SerializerMethodField()
class Meta:
model = Issue
@@ -215,9 +214,15 @@ class IssueRecentVisitSerializer(serializers.ModelSerializer):
def get_project_identifier(self, obj):
project = obj.project
return project.identifier if project else None
def get_assignees(self, obj):
return list(
obj.assignees.filter(issue_assignee__deleted_at__isnull=True).values_list(
"id", flat=True
)
)
class ProjectRecentVisitSerializer(serializers.ModelSerializer):
project_members = serializers.SerializerMethodField()

View File

@@ -0,0 +1,75 @@
import pytest
from plane.db.models import (
Workspace,
Project,
Issue,
User,
IssueAssignee,
WorkspaceMember,
ProjectMember,
)
from plane.app.serializers.workspace import IssueRecentVisitSerializer
from django.utils import timezone
@pytest.mark.unit
class TestIssueRecentVisitSerializer:
"""Test the IssueRecentVisitSerializer"""
def test_issue_recent_visit_serializer_fields(self, db):
"""Test that the serializer includes the correct fields"""
test_user_1 = User.objects.create(
email="test_user_1@example.com", first_name="Test", last_name="User"
)
# To test for deleted issue assignee
test_user_2 = User.objects.create(
email="test_user_2@example.com",
first_name="Other",
last_name="User",
username="some user name",
)
workspace = Workspace.objects.create(
name="Test Workspace", slug="test-workspace", owner=test_user_1
)
WorkspaceMember.objects.create(member=test_user_2, role=15, workspace=workspace)
project = Project.objects.create(
name="Test Project", identifier="test-project", workspace=workspace
)
ProjectMember.objects.create(project=project, member=test_user_2)
issue = Issue.objects.create(
name="Test Issue",
workspace=workspace,
project=project,
)
IssueAssignee.objects.create(issue=issue, assignee=test_user_1, project=project)
# Deleted issue assignee
IssueAssignee.objects.create(
issue=issue,
assignee=test_user_2,
project=project,
deleted_at=timezone.now(),
)
serialized_data = IssueRecentVisitSerializer(
issue,
).data
# Check fields are present and correct
assert "name" in serialized_data
assert "assignees" in serialized_data
assert "project_identifier" in serialized_data
assert serialized_data["name"] == "Test Issue"
assert serialized_data["project_identifier"] == "TEST-PROJECT"
# Only including non-deleted issue assignees
assert serialized_data["assignees"] == [test_user_1.id]

View File

@@ -5,7 +5,6 @@ export * from "./endpoints";
export * from "./file";
export * from "./filter";
export * from "./graph";
export * from "./icons";
export * from "./instance";
export * from "./issue";
export * from "./metadata";

View File

@@ -220,6 +220,11 @@ export type GroupByColumnTypes =
| "created_by"
| "team_project";
type TGetColumns = {
isWorkspaceLevel?: boolean;
projectId?: string;
};
export interface IGroupByColumn {
id: string;
name: string;

View File

@@ -152,6 +152,8 @@ import {
CircleChevronDown,
UsersRound,
ToggleLeft,
Search,
User,
} from "lucide-react";
export const MATERIAL_ICONS_LIST = [
@@ -912,6 +914,8 @@ export const LUCIDE_ICONS_LIST = [
{ name: "Minus", element: Minus },
{ name: "MinusCircle", element: MinusCircle },
{ name: "MinusSquare", element: MinusSquare },
{ name: "Search", element: Search },
{ name: "ToggleLeft", element: ToggleLeft },
{ name: "User", element: User },
{ name: "UsersRound", element: UsersRound },
];

View File

@@ -0,0 +1 @@
export * from "./icons";

View File

@@ -2,7 +2,7 @@ import { Search } from "lucide-react";
import React, { useEffect, useState } from "react";
// icons
import useFontFaceObserver from "use-font-face-observer";
import { MATERIAL_ICONS_LIST } from "@plane/constants";
import { MATERIAL_ICONS_LIST } from "..";
import { cn } from "../../helpers";
import { Input } from "../form-fields";
import { InfoIcon } from "../icons";

View File

@@ -1,9 +1,8 @@
import { Emoji } from "emoji-picker-react";
import React, { FC } from "react";
import useFontFaceObserver from "use-font-face-observer";
// plane imports
import { LUCIDE_ICONS_LIST } from "@plane/constants";
// local imports
import { LUCIDE_ICONS_LIST } from "..";
import { emojiCodeToUnicode } from "./helpers";
export type TEmojiLogoProps = {

View File

@@ -1,8 +1,7 @@
import { Search } from "lucide-react";
import React, { useEffect, useState } from "react";
// plane imports
import { LUCIDE_ICONS_LIST } from "@plane/constants";
// local imports
import { LUCIDE_ICONS_LIST } from "..";
import { cn } from "../../helpers";
import { Input } from "../form-fields";
import { InfoIcon } from "../icons";

View File

@@ -2,34 +2,36 @@ export * from "./avatar";
export * from "./badge";
export * from "./breadcrumbs";
export * from "./button";
export * from "./calendar";
export * from "./card";
export * from "./collapsible";
export * from "./color-picker";
export * from "./constants";
export * from "./content-wrapper";
export * from "./control-link";
export * from "./drag-handle";
export * from "./drop-indicator";
export * from "./dropdown";
export * from "./dropdowns";
export * from "./emoji";
export * from "./favorite-star";
export * from "./form-fields";
export * from "./header";
export * from "./hooks";
export * from "./icons";
export * from "./link";
export * from "./loader";
export * from "./modals";
export * from "./popovers";
export * from "./progress";
export * from "./row";
export * from "./scroll-area";
export * from "./sortable";
export * from "./spinners";
export * from "./tables";
export * from "./tabs";
export * from "./tag";
export * from "./toast";
export * from "./tooltip";
export * from "./typography";
export * from "./drag-handle";
export * from "./drop-indicator";
export * from "./favorite-star";
export * from "./loader";
export * from "./collapsible";
export * from "./popovers";
export * from "./tables";
export * from "./header";
export * from "./row";
export * from "./scroll-area";
export * from "./content-wrapper";
export * from "./card";
export * from "./tag";
export * from "./tabs";
export * from "./calendar";
export * from "./color-picker";
export * from "./link";
export * from "./utils";

View File

@@ -0,0 +1,7 @@
import { LUCIDE_ICONS_LIST } from "..";
/**
* Returns a random icon name from the LUCIDE_ICONS_LIST array
*/
export const getRandomIconName = (): string =>
LUCIDE_ICONS_LIST[Math.floor(Math.random() * LUCIDE_ICONS_LIST.length)].name;

View File

@@ -0,0 +1 @@
export * from "./icons";

View File

@@ -1,7 +1,7 @@
"use client";
// plane imports
import { LUCIDE_ICONS_LIST, RANDOM_EMOJI_CODES } from "@plane/constants";
import { RANDOM_EMOJI_CODES } from "@plane/constants";
/**
* Converts a hyphen-separated hexadecimal emoji code to its decimal representation
@@ -83,9 +83,3 @@ export const groupReactions: (reactions: any[], key: string) => { [key: string]:
* @returns {string} A random emoji code
*/
export const getRandomEmoji = (): string => RANDOM_EMOJI_CODES[Math.floor(Math.random() * RANDOM_EMOJI_CODES.length)];
/**
* Returns a random icon name from the LUCIDE_ICONS_LIST array
*/
export const getRandomIconName = (): string =>
LUCIDE_ICONS_LIST[Math.floor(Math.random() * LUCIDE_ICONS_LIST.length)].name;

View File

@@ -13,7 +13,7 @@ import {
Users,
} from "lucide-react";
// types
import { IGroupByColumn, IIssueDisplayProperties, TSpreadsheetColumn } from "@plane/types";
import { IGroupByColumn, IIssueDisplayProperties, TGetColumns, TSpreadsheetColumn } from "@plane/types";
import { DiceIcon, DoubleCircleIcon, ISvgIcons } from "@plane/ui";
// components
import {
@@ -32,6 +32,36 @@ import {
SpreadsheetSubIssueColumn,
SpreadsheetUpdatedOnColumn,
} from "@/components/issues/issue-layouts/spreadsheet";
// store
import { store } from "@/lib/store-context";
export type TGetScopeMemberIdsResult = {
memberIds: string[];
includeNone: boolean;
};
export const getScopeMemberIds = ({ isWorkspaceLevel, projectId }: TGetColumns): TGetScopeMemberIdsResult => {
// store values
const { workspaceMemberIds } = store.memberRoot.workspace;
const { projectMemberIds } = store.memberRoot.project;
// derived values
const memberIds = workspaceMemberIds;
if (isWorkspaceLevel) {
return { memberIds: memberIds ?? [], includeNone: true };
}
if (projectId || (projectMemberIds && projectMemberIds.length > 0)) {
const { getProjectMemberIds } = store.memberRoot.project;
const _projectMemberIds = projectId ? getProjectMemberIds(projectId, false) : projectMemberIds;
return {
memberIds: _projectMemberIds ?? [],
includeNone: true,
};
}
return { memberIds: [], includeNone: true };
};
export const getTeamProjectColumns = (): IGroupByColumn[] | undefined => undefined;

View File

@@ -7,8 +7,8 @@ import { Emoji } from "emoji-picker-react";
// eslint-disable-next-line import/order
import useFontFaceObserver from "use-font-face-observer";
// plane imports
import { LUCIDE_ICONS_LIST } from "@plane/constants";
import { TLogoProps } from "@plane/types";
import { LUCIDE_ICONS_LIST } from "@plane/ui";
import { emojiCodeToUnicode } from "@plane/utils";
type Props = {

View File

@@ -2,7 +2,6 @@
import { CSSProperties, FC } from "react";
import { extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { isEmpty } from "lodash";
import clone from "lodash/clone";
import concat from "lodash/concat";
import isEqual from "lodash/isEqual";
@@ -27,6 +26,7 @@ import {
TGroupedIssues,
IWorkspaceView,
IIssueDisplayFilterOptions,
TGetColumns,
} from "@plane/types";
// plane ui
import { Avatar, CycleGroupIcon, DiceIcon, ISvgIcons, PriorityIcon, StateGroupIcon } from "@plane/ui";
@@ -37,7 +37,11 @@ import { Logo } from "@/components/common";
// store
import { store } from "@/lib/store-context";
// plane web store
import { getTeamProjectColumns, SpreadSheetPropertyIconMap } from "@/plane-web/components/issues/issue-layouts/utils";
import {
getScopeMemberIds,
getTeamProjectColumns,
SpreadSheetPropertyIconMap,
} from "@/plane-web/components/issues/issue-layouts/utils";
// store
import { ISSUE_FILTER_DEFAULT_DATA } from "@/store/issue/helpers/base-issues.store";
import { DEFAULT_DISPLAY_PROPERTIES } from "@/store/issue/issue-details/sub_issues_filter.store";
@@ -60,13 +64,16 @@ export type IssueUpdates = {
};
};
type TGetColumns = {
isWorkspaceLevel?: boolean;
projectId?: string;
};
export const isWorkspaceLevel = (type: EIssuesStoreType) =>
[EIssuesStoreType.PROFILE, EIssuesStoreType.GLOBAL].includes(type) ? true : false;
[
EIssuesStoreType.PROFILE,
EIssuesStoreType.GLOBAL,
EIssuesStoreType.TEAM,
EIssuesStoreType.TEAM_VIEW,
EIssuesStoreType.PROJECT_VIEW,
].includes(type)
? true
: false;
type TGetGroupByColumns = {
groupBy: GroupByColumnTypes | null;
@@ -264,40 +271,28 @@ const getLabelsColumns = ({ isWorkspaceLevel }: TGetColumns): IGroupByColumn[] =
};
const getAssigneeColumns = ({ isWorkspaceLevel, projectId }: TGetColumns): IGroupByColumn[] | undefined => {
// store values
const { getUserDetails } = store.memberRoot;
// derived values
const { memberIds, includeNone } = getScopeMemberIds({ isWorkspaceLevel, projectId });
const assigneeColumns: IGroupByColumn[] = [];
const {
project: { projectMemberIds, getProjectMemberIds },
getUserDetails,
} = store.memberRoot;
// if workspace level
if (isWorkspaceLevel) {
const { workspaceMemberIds } = store.memberRoot.workspace;
if (!workspaceMemberIds) return;
workspaceMemberIds.forEach((memberId) => {
const member = getUserDetails(memberId);
assigneeColumns.push({
id: memberId,
name: member?.display_name || "",
icon: <Avatar name={member?.display_name} src={getFileURL(member?.avatar_url ?? "")} size="md" />,
payload: { assignee_ids: [memberId] },
});
});
} else {
// if project level
const _projectMemberIds = projectId ? getProjectMemberIds(projectId, false) : projectMemberIds;
if (!_projectMemberIds) return;
// Map project member ids to group by assignee columns
_projectMemberIds.forEach((memberId) => {
const member = getUserDetails(memberId);
assigneeColumns.push({
id: memberId,
name: member?.display_name || "",
icon: <Avatar name={member?.display_name} src={getFileURL(member?.avatar_url ?? "")} size="md" />,
payload: { assignee_ids: [memberId] },
});
if (!memberIds) return [];
memberIds.forEach((memberId) => {
const member = getUserDetails(memberId);
if (!member) return;
assigneeColumns.push({
id: memberId,
name: member?.display_name || "",
icon: <Avatar name={member?.display_name} src={getFileURL(member?.avatar_url ?? "")} size="md" />,
payload: { assignee_ids: [memberId] },
});
});
if (includeNone) {
assigneeColumns.push({ id: "None", name: "None", icon: <Avatar size="md" />, payload: {} });
}
assigneeColumns.push({ id: "None", name: "None", icon: <Avatar size="md" />, payload: {} });
return assigneeColumns;
};

View File

@@ -7,8 +7,7 @@ import { pointerOutsideOfPreview } from "@atlaskit/pragmatic-drag-and-drop/eleme
import { setCustomNativeDragPreview } from "@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview";
import { attachInstruction, extractInstruction } from "@atlaskit/pragmatic-drag-and-drop-hitbox/tree-item";
import { observer } from "mobx-react";
import Link from "next/link";
import { useParams } from "next/navigation";
import { useParams, useRouter } from "next/navigation";
import { createRoot } from "react-dom/client";
import { LinkIcon, Settings, Share2, LogOut, MoreHorizontal, ChevronRight } from "lucide-react";
import { Disclosure, Transition } from "@headlessui/react";
@@ -78,6 +77,7 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
const dragHandleRef = useRef<HTMLButtonElement | null>(null);
// router
const { workspaceSlug, projectId: URLProjectId } = useParams();
const router = useRouter();
// derived values
const project = getPartialProjectById(projectId);
// toggle project list open
@@ -353,26 +353,26 @@ export const SidebarProjectsListItem: React.FC<Props> = observer((props) => {
</span>
</CustomMenu.MenuItem>
{isAuthorized && (
<CustomMenu.MenuItem>
<Link href={`/${workspaceSlug}/projects/${project?.id}/archives/issues`}>
<div className="flex items-center justify-start gap-2">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("archives")}</span>
</div>
</Link>
<CustomMenu.MenuItem
onClick={() => {
router.push(`/${workspaceSlug}/projects/${project?.id}/archives/issues`);
}}
>
<div className="flex items-center justify-start gap-2 cursor-pointer">
<ArchiveIcon className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("archives")}</span>
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem
onClick={() => {
setIsMenuActive(false);
router.push(`/${workspaceSlug}/settings/projects/${project?.id}`);
}}
>
<Link href={`/${workspaceSlug}/settings/projects/${project?.id}`}>
<div className="flex items-center justify-start gap-2">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("settings")}</span>
</div>
</Link>
<div className="flex items-center justify-start gap-2 cursor-pointer">
<Settings className="h-3.5 w-3.5 stroke-[1.5]" />
<span>{t("settings")}</span>
</div>
</CustomMenu.MenuItem>
{/* leave project */}
{!isAuthorized && (