diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index 33171623c4..379d8454d7 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -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 diff --git a/apps/api/plane/app/views/issue/base.py b/apps/api/plane/app/views/issue/base.py index 3b707cbbc4..c336a60a62 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -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, diff --git a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx index a630583a54..30c7b6a22d 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx @@ -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 ( diff --git a/apps/web/core/components/inbox/content/inbox-issue-header.tsx b/apps/web/core/components/inbox/content/inbox-issue-header.tsx index e379c869c2..c42e6a747e 100644 --- a/apps/web/core/components/inbox/content/inbox-issue-header.tsx +++ b/apps/web/core/components/inbox/content/inbox-issue-header.tsx @@ -104,7 +104,6 @@ export const InboxIssueActionsHeader: FC = 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 = observer((p )} - handleCopyIssueLink(intakeIssueLink)}> + handleCopyIssueLink(workItemLink)}>
{t("inbox_issue.actions.copy")} diff --git a/packages/propel/package.json b/packages/propel/package.json index 967ba3af2c..11e6921eb6 100644 --- a/packages/propel/package.json +++ b/packages/propel/package.json @@ -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" }, diff --git a/packages/propel/src/combobox/combobox.tsx b/packages/propel/src/combobox/combobox.tsx new file mode 100644 index 0000000000..91e5637263 --- /dev/null +++ b/packages/propel/src/combobox/combobox.tsx @@ -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(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( + (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( + () => ({ + value: internalValue, + onValueChange, + multiSelect, + maxSelections, + disabled, + open, + setOpen, + handleValueChange, + handleRemoveSelection, + }), + [ + internalValue, + onValueChange, + multiSelect, + maxSelections, + disabled, + open, + setOpen, + handleValueChange, + handleRemoveSelection, + ] + ); + + return ( + + + {children} + + + ); +} + +function ComboboxButton({ className, children, disabled = false }: ComboboxButtonProps) { + const { disabled: ctxDisabled, open } = useComboboxContext(); + const isDisabled = disabled || ctxDisabled; + return ( + + {children} + + ); +} + +function ComboboxOptions({ + children, + showSearch = false, + searchPlaceholder, + maxHeight, + className, + inputClassName, + optionsContainerClassName, + emptyMessage, +}: ComboboxOptionsProps) { + const { multiSelect } = useComboboxContext(); + return ( + + + {showSearch && } + + {children} + + {emptyMessage ?? "No options found."} + + + ); +} + +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 ( + + {children} + + ); +} + +// compound component +const Combobox = Object.assign(ComboboxComponent, { + Button: ComboboxButton, + Options: ComboboxOptions, + Option: ComboboxOption, +}); + +export { Combobox }; diff --git a/packages/propel/src/combobox/index.ts b/packages/propel/src/combobox/index.ts new file mode 100644 index 0000000000..1be314de73 --- /dev/null +++ b/packages/propel/src/combobox/index.ts @@ -0,0 +1 @@ +export * from "./combobox"; diff --git a/packages/propel/src/command/command.tsx b/packages/propel/src/command/command.tsx new file mode 100644 index 0000000000..25969aada8 --- /dev/null +++ b/packages/propel/src/command/command.tsx @@ -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) { + return ; +} + +function CommandInput({ className, ...props }: React.ComponentProps) { + return ( +
+ + +
+ ); +} + +function CommandList({ className, ...props }: React.ComponentProps) { + return ; +} + +function CommandEmpty({ ...props }: React.ComponentProps) { + return ; +} + +function CommandItem({ className, ...props }: React.ComponentProps) { + return ; +} + +const Command = Object.assign(CommandComponent, { + Input: CommandInput, + List: CommandList, + Empty: CommandEmpty, + Item: CommandItem, +}); + +export { Command }; diff --git a/packages/propel/src/command/index.ts b/packages/propel/src/command/index.ts new file mode 100644 index 0000000000..f4c8ea9e0f --- /dev/null +++ b/packages/propel/src/command/index.ts @@ -0,0 +1 @@ +export * from "./command"; diff --git a/packages/types/src/issues/issue.ts b/packages/types/src/issues/issue.ts index bfd88f4e3b..32e817aac8 100644 --- a/packages/types/src/issues/issue.ts +++ b/packages/types/src/issues/issue.ts @@ -72,6 +72,7 @@ export type TBaseIssue = { is_draft: boolean; is_epic?: boolean; + is_intake?: boolean; }; export type IssueRelation = {