Sync: Community Changes #3951

This commit is contained in:
pushya22
2025-08-22 13:29:47 +05:30
committed by GitHub
10 changed files with 367 additions and 10 deletions

View File

@@ -1078,12 +1078,14 @@ class IssueLiteSerializer(DynamicBaseSerializer):
class IssueDetailSerializer(IssueSerializer):
description_html = serializers.CharField()
is_subscribed = serializers.BooleanField(read_only=True)
is_intake = serializers.BooleanField(read_only=True)
is_epic = serializers.BooleanField(read_only=True)
class Meta(IssueSerializer.Meta):
fields = IssueSerializer.Meta.fields + [
"description_html",
"is_subscribed",
"is_intake",
"is_epic",
]
read_only_fields = fields

View File

@@ -53,6 +53,7 @@ from plane.db.models import (
IssueRelation,
IssueAssignee,
IssueLabel,
IntakeIssue,
)
from plane.ee.models import TeamspaceProject, TeamspaceMember, CustomerRequestIssue
from plane.utils.grouper import (
@@ -1555,13 +1556,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
# Fetch the issue
issue = (
Issue.objects.filter(
Q(issue_intake__status=1)
| Q(issue_intake__status=-1)
| Q(issue_intake__status=2)
| Q(issue_intake__isnull=True)
)
.filter(project_id=project.id)
Issue.objects.filter(project_id=project.id)
.filter(workspace__slug=slug)
.select_related("workspace", "project", "state")
.prefetch_related("assignees", "labels", "issue_module__module")
@@ -1657,7 +1652,18 @@ class IssueDetailIdentifierEndpoint(BaseAPIView):
)
)
)
).annotate(is_epic=F("type__is_epic"))
.annotate(
is_intake=Exists(
IntakeIssue.objects.filter(
issue=OuterRef("id"),
status__in=[-2, 0],
workspace__slug=slug,
project_id=project.id,
)
)
).annotate(is_epic=F("type__is_epic"))
).first()
if check_workspace_feature_flag(
feature_key=FeatureFlag.CUSTOMERS,

View File

@@ -85,6 +85,12 @@ const IssueDetailsPage = observer(() => {
return () => window.removeEventListener("resize", handleToggleIssueDetailSidebar);
}, [issueDetailSidebarCollapsed, toggleIssueDetailSidebar]);
useEffect(() => {
if (data?.is_intake) {
router.push(`/${workspaceSlug}/projects/${data.project_id}/intake/?currentTab=open&inboxIssueId=${data?.id}`);
}
}, [workspaceSlug, data]);
const renderPrivateProjectEmptyState = error && error.type === 0;
return (

View File

@@ -104,7 +104,6 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
const currentInboxIssueId = inboxIssue?.issue?.id;
const intakeIssueLink = `${workspaceSlug}/projects/${issue?.project_id}/intake/?currentTab=${currentTab}&inboxIssueId=${currentInboxIssueId}`;
const redirectIssue = (): string | undefined => {
let nextOrPreviousIssueId: string | undefined = undefined;
@@ -413,7 +412,7 @@ export const InboxIssueActionsHeader: FC<TInboxIssueActionsHeader> = observer((p
</div>
</CustomMenu.MenuItem>
)}
<CustomMenu.MenuItem onClick={() => handleCopyIssueLink(intakeIssueLink)}>
<CustomMenu.MenuItem onClick={() => handleCopyIssueLink(workItemLink)}>
<div className="flex items-center gap-2">
<Copy size={14} strokeWidth={2} />
{t("inbox_issue.actions.copy")}

View File

@@ -19,6 +19,8 @@
"./table": "./src/table/index.ts",
"./tabs": "./src/tabs/index.ts",
"./popover": "./src/popover/index.ts",
"./command": "./src/command/index.ts",
"./combobox": "./src/combobox/index.ts",
"./tooltip": "./src/tooltip/index.ts",
"./styles/fonts": "./src/styles/fonts/index.css"
},

View File

@@ -0,0 +1,298 @@
import * as React from "react";
import { Command } from "../command/command";
import { Popover } from "../popover/root";
import { cn } from "@plane/utils";
export interface ComboboxOption {
value: unknown;
query: string;
content: React.ReactNode;
disabled?: boolean;
tooltip?: string | React.ReactNode;
}
export interface ComboboxProps {
value?: string | string[];
defaultValue?: string | string[];
onValueChange?: (value: string | string[]) => void;
multiSelect?: boolean;
maxSelections?: number;
disabled?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
children: React.ReactNode;
}
export interface ComboboxButtonProps {
disabled?: boolean;
children?: React.ReactNode;
className?: string;
}
export interface ComboboxOptionsProps {
searchPlaceholder?: string;
emptyMessage?: string;
showSearch?: boolean;
showCheckIcon?: boolean;
className?: string;
children?: React.ReactNode;
maxHeight?: "lg" | "md" | "rg" | "sm";
inputClassName?: string;
optionsContainerClassName?: string;
}
export interface ComboboxOptionProps {
value: string;
disabled?: boolean;
children?: React.ReactNode;
className?: string;
}
// Context for sharing state between components
interface ComboboxContextType {
value: string | string[];
onValueChange?: (value: string | string[]) => void;
multiSelect: boolean;
maxSelections?: number;
disabled: boolean;
open: boolean;
setOpen: (open: boolean) => void;
handleValueChange: (newValue: string) => void;
handleRemoveSelection: (valueToRemove: string) => void;
}
const ComboboxContext = React.createContext<ComboboxContextType | null>(null);
function useComboboxContext() {
const context = React.useContext(ComboboxContext);
if (!context) {
throw new Error("Combobox components must be used within a Combobox");
}
return context;
}
function ComboboxComponent({
value,
defaultValue,
onValueChange,
multiSelect = false,
maxSelections,
disabled = false,
open: openProp,
onOpenChange,
children,
}: ComboboxProps) {
// Controlled/uncontrolled value
const isControlledValue = value !== undefined;
const [internalValue, setInternalValue] = React.useState<string | string[]>(
(isControlledValue ? (value as string | string[]) : defaultValue) ?? (multiSelect ? [] : "")
);
// Controlled/uncontrolled open state
const isControlledOpen = openProp !== undefined;
const [internalOpen, setInternalOpen] = React.useState(false);
const open = isControlledOpen ? (openProp as boolean) : internalOpen;
const setOpen = React.useCallback(
(nextOpen: boolean) => {
if (!isControlledOpen) {
setInternalOpen(nextOpen);
}
onOpenChange?.(nextOpen);
},
[isControlledOpen, onOpenChange]
);
// Update internal value when prop changes
React.useEffect(() => {
if (isControlledValue) {
setInternalValue(value as string | string[]);
}
}, [isControlledValue, value]);
const handleValueChange = React.useCallback(
(newValue: string) => {
if (multiSelect) {
// Functional update to avoid stale closures
if (!isControlledValue) {
setInternalValue((prev) => {
const currentValues = Array.isArray(prev) ? (prev as string[]) : [];
const isSelected = currentValues.includes(newValue);
if (!isSelected) {
if (maxSelections && currentValues.length >= maxSelections) {
return currentValues; // limit reached
}
const updated = [...currentValues, newValue];
onValueChange?.(updated);
return updated;
}
const updated = currentValues.filter((v) => v !== newValue);
onValueChange?.(updated);
return updated;
});
} else {
// Controlled value: compute next and notify only
const currentValues = Array.isArray(internalValue) ? (internalValue as string[]) : [];
const isSelected = currentValues.includes(newValue);
let updated: string[];
if (isSelected) {
updated = currentValues.filter((v) => v !== newValue);
} else {
if (maxSelections && currentValues.length >= maxSelections) {
return;
}
updated = [...currentValues, newValue];
}
onValueChange?.(updated);
}
} else {
if (!isControlledValue) {
setInternalValue(newValue);
}
onValueChange?.(newValue);
setOpen(false);
}
},
[multiSelect, isControlledValue, internalValue, maxSelections, onValueChange, setOpen]
);
const handleRemoveSelection = React.useCallback(
(valueToRemove: string) => {
if (!multiSelect) return;
if (!isControlledValue) {
setInternalValue((prev) => {
const currentValues = Array.isArray(prev) ? (prev as string[]) : [];
const updated = currentValues.filter((v) => v !== valueToRemove);
onValueChange?.(updated);
return updated;
});
} else {
const currentValues = Array.isArray(internalValue) ? (internalValue as string[]) : [];
const updated = currentValues.filter((v) => v !== valueToRemove);
onValueChange?.(updated);
}
},
[multiSelect, isControlledValue, internalValue, onValueChange]
);
const contextValue = React.useMemo<ComboboxContextType>(
() => ({
value: internalValue,
onValueChange,
multiSelect,
maxSelections,
disabled,
open,
setOpen,
handleValueChange,
handleRemoveSelection,
}),
[
internalValue,
onValueChange,
multiSelect,
maxSelections,
disabled,
open,
setOpen,
handleValueChange,
handleRemoveSelection,
]
);
return (
<ComboboxContext.Provider value={contextValue}>
<Popover open={open} onOpenChange={setOpen}>
{children}
</Popover>
</ComboboxContext.Provider>
);
}
function ComboboxButton({ className, children, disabled = false }: ComboboxButtonProps) {
const { disabled: ctxDisabled, open } = useComboboxContext();
const isDisabled = disabled || ctxDisabled;
return (
<Popover.Button
disabled={isDisabled}
aria-disabled={isDisabled || undefined}
aria-haspopup="listbox"
aria-expanded={open}
className={className}
>
{children}
</Popover.Button>
);
}
function ComboboxOptions({
children,
showSearch = false,
searchPlaceholder,
maxHeight,
className,
inputClassName,
optionsContainerClassName,
emptyMessage,
}: ComboboxOptionsProps) {
const { multiSelect } = useComboboxContext();
return (
<Popover.Panel sideOffset={8} className={cn(className)}>
<Command>
{showSearch && <Command.Input placeholder={searchPlaceholder} className={cn(inputClassName)} />}
<Command.List
className={cn(
{
"max-h-60": maxHeight === "lg",
"max-h-48": maxHeight === "md",
"max-h-36": maxHeight === "rg",
"max-h-28": maxHeight === "sm",
},
optionsContainerClassName
)}
role="listbox"
aria-multiselectable={multiSelect || undefined}
>
{children}
</Command.List>
<Command.Empty>{emptyMessage ?? "No options found."}</Command.Empty>
</Command>
</Popover.Panel>
);
}
function ComboboxOption({ value, children, disabled, className }: ComboboxOptionProps) {
const { handleValueChange, multiSelect, maxSelections, value: selectedValue } = useComboboxContext();
const stringValue = value;
const isSelected = React.useMemo(() => {
if (!multiSelect) return false;
return Array.isArray(selectedValue) ? (selectedValue as string[]).includes(stringValue) : false;
}, [multiSelect, selectedValue, stringValue]);
const reachedMax = React.useMemo(() => {
if (!multiSelect || !maxSelections) return false;
const currentLength = Array.isArray(selectedValue) ? (selectedValue as string[]).length : 0;
return currentLength >= maxSelections && !isSelected;
}, [multiSelect, maxSelections, selectedValue, isSelected]);
const isDisabled = disabled || reachedMax;
return (
<Command.Item value={stringValue} disabled={isDisabled} onSelect={handleValueChange} className={className}>
{children}
</Command.Item>
);
}
// compound component
const Combobox = Object.assign(ComboboxComponent, {
Button: ComboboxButton,
Options: ComboboxOptions,
Option: ComboboxOption,
});
export { Combobox };

View File

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

View File

@@ -0,0 +1,41 @@
import { Command as CommandPrimitive } from "cmdk";
import { SearchIcon } from "lucide-react";
import * as React from "react";
import { cn } from "@plane/ui";
function CommandComponent({ className, ...props }: React.ComponentProps<typeof CommandPrimitive>) {
return <CommandPrimitive data-slot="command" className={cn("", className)} {...props} />;
}
function CommandInput({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex items-center gap-1.5 rounded border border-custom-border-100 bg-custom-background-90 px-2"
>
<SearchIcon className="size-3.5 flex-shrink-0 text-custom-text-400" strokeWidth={1.5} />
<CommandPrimitive.Input data-slot="command-input" className={cn(className)} {...props} />
</div>
);
}
function CommandList({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.List>) {
return <CommandPrimitive.List data-slot="command-list" {...props} />;
}
function CommandEmpty({ ...props }: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return <CommandPrimitive.Empty data-slot="command-empty" {...props} />;
}
function CommandItem({ className, ...props }: React.ComponentProps<typeof CommandPrimitive.Item>) {
return <CommandPrimitive.Item data-slot="command-item" {...props} />;
}
const Command = Object.assign(CommandComponent, {
Input: CommandInput,
List: CommandList,
Empty: CommandEmpty,
Item: CommandItem,
});
export { Command };

View File

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

View File

@@ -72,6 +72,7 @@ export type TBaseIssue = {
is_draft: boolean;
is_epic?: boolean;
is_intake?: boolean;
};
export type IssueRelation = {