From e86b40ac82a9c52dbc400d21fc31cdc949bffc24 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Thu, 21 Aug 2025 15:32:29 +0530 Subject: [PATCH 1/3] [WEB-4682 | WEB-4685] feat: propel comobobox and command component (#7615) * feat: comobobox and command component added to propel package * fix: format error * chore: code refactor * chore: code refactor * fix: format error --------- Co-authored-by: sriram veeraghanta --- packages/propel/package.json | 2 + packages/propel/src/combobox/combobox.tsx | 298 ++++++++++++++++++++++ packages/propel/src/combobox/index.ts | 1 + packages/propel/src/command/command.tsx | 41 +++ packages/propel/src/command/index.ts | 1 + 5 files changed, 343 insertions(+) create mode 100644 packages/propel/src/combobox/combobox.tsx create mode 100644 packages/propel/src/combobox/index.ts create mode 100644 packages/propel/src/command/command.tsx create mode 100644 packages/propel/src/command/index.ts 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"; From d0f26f87347615f70a9d1bf6f7f3a9b9fc296756 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia <121005188+anmolsinghbhatia@users.noreply.github.com> Date: Fri, 22 Aug 2025 12:54:46 +0530 Subject: [PATCH 2/3] [WEB-4726] fix: intake work item redirection (#7619) * chore: added is intake for email notifications * fix: intake work item redirection * chore: code refactor * chore: code refactor --------- Co-authored-by: NarayanBavisetti --- apps/api/plane/app/serializers/issue.py | 7 ++++++- apps/api/plane/app/views/issue/base.py | 13 ++++++++++++- .../(projects)/browse/[workItem]/page.tsx | 6 ++++++ .../components/inbox/content/inbox-issue-header.tsx | 3 +-- packages/types/src/issues/issue.ts | 1 + 5 files changed, 26 insertions(+), 4 deletions(-) diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index d002de3390..691140eba0 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -908,9 +908,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) class Meta(IssueSerializer.Meta): - fields = IssueSerializer.Meta.fields + ["description_html", "is_subscribed"] + fields = IssueSerializer.Meta.fields + [ + "description_html", + "is_subscribed", + "is_intake", + ] 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 21e5eaf709..4d0d4457ea 100644 --- a/apps/api/plane/app/views/issue/base.py +++ b/apps/api/plane/app/views/issue/base.py @@ -51,6 +51,7 @@ from plane.db.models import ( IssueRelation, IssueAssignee, IssueLabel, + IntakeIssue, ) from plane.utils.grouper import ( issue_group_values, @@ -1223,7 +1224,7 @@ class IssueDetailIdentifierEndpoint(BaseAPIView): # Fetch the issue issue = ( - Issue.issue_objects.filter(project_id=project.id) + Issue.objects.filter(project_id=project.id) .filter(workspace__slug=slug) .select_related("workspace", "project", "state", "parent") .prefetch_related("assignees", "labels", "issue_module__module") @@ -1315,6 +1316,16 @@ class IssueDetailIdentifierEndpoint(BaseAPIView): ) ) ) + .annotate( + is_intake=Exists( + IntakeIssue.objects.filter( + issue=OuterRef("id"), + status__in=[-2, 0], + workspace__slug=slug, + project_id=project.id, + ) + ) + ) ).first() # Check if the issue exists 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 1d24faa46c..dabb43eb7c 100644 --- a/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx +++ b/apps/web/app/(all)/[workspaceSlug]/(projects)/browse/[workItem]/page.tsx @@ -78,6 +78,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]); + 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/types/src/issues/issue.ts b/packages/types/src/issues/issue.ts index 33470b4829..f2adb48110 100644 --- a/packages/types/src/issues/issue.ts +++ b/packages/types/src/issues/issue.ts @@ -70,6 +70,7 @@ export type TBaseIssue = { is_draft: boolean; is_epic?: boolean; + is_intake?: boolean; }; export type IssueRelation = { From d153bb35187816f8c047d93741602a5486e8d268 Mon Sep 17 00:00:00 2001 From: Anmol Singh Bhatia Date: Fri, 22 Aug 2025 13:28:54 +0530 Subject: [PATCH 3/3] fix: merge conflict --- apps/api/plane/app/serializers/issue.py | 6 ------ 1 file changed, 6 deletions(-) diff --git a/apps/api/plane/app/serializers/issue.py b/apps/api/plane/app/serializers/issue.py index 0830b488b6..379d8454d7 100644 --- a/apps/api/plane/app/serializers/issue.py +++ b/apps/api/plane/app/serializers/issue.py @@ -1078,21 +1078,15 @@ class IssueLiteSerializer(DynamicBaseSerializer): class IssueDetailSerializer(IssueSerializer): description_html = serializers.CharField() is_subscribed = serializers.BooleanField(read_only=True) -<<<<<<< HEAD is_intake = serializers.BooleanField(read_only=True) -======= is_epic = serializers.BooleanField(read_only=True) ->>>>>>> 67449cd382f4a0d8035a27d852281904df5314ad class Meta(IssueSerializer.Meta): fields = IssueSerializer.Meta.fields + [ "description_html", "is_subscribed", -<<<<<<< HEAD "is_intake", -======= "is_epic", ->>>>>>> 67449cd382f4a0d8035a27d852281904df5314ad ] read_only_fields = fields