АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: канон составных detail-menu и customButton-trigger

This commit is contained in:
DCCONSTRUCTIONS 2026-04-22 12:36:23 +03:00
parent c86fc16cdf
commit 082948a69c
7 changed files with 203 additions and 103 deletions

View File

@ -8,10 +8,12 @@ import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button";
import { PlusIcon } from "@plane/propel/icons"; import { PlusIcon } from "@plane/propel/icons";
// plane imports // plane imports
import type { TIssueServiceType } from "@plane/types"; import type { TIssueServiceType } from "@plane/types";
import { CustomMenu } from "@plane/ui"; import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
// hooks // hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useIssueDetail } from "@/hooks/store/use-issue-detail";
// Plane-web // Plane-web
@ -40,33 +42,32 @@ export const RelationActionButton = observer(function RelationActionButton(props
}; };
// button element // button element
const customButtonElement = customButton ? <>{customButton}</> : <PlusIcon className="h-4 w-4" />; const items: TContextMenuItem[] = Object.values(ISSUE_RELATION_OPTIONS).flatMap((item, index) =>
item
return ( ? [
<CustomMenu {
customButton={customButtonElement} key: `${item.key}-${index}`,
placement="bottom-start" title: t(item.i18n_label),
disabled={disabled} action: () => handleOnClick(item.key),
maxHeight="lg" customContent: (
closeOnSelect
>
{Object.values(ISSUE_RELATION_OPTIONS).map((item, index) => {
if (!item) return <></>;
return (
<CustomMenu.MenuItem
key={index}
onClick={() => {
handleOnClick(item.key);
}}
>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{item.icon(12)} {item.icon(12)}
<span>{t(item.i18n_label)}</span> <span>{t(item.i18n_label)}</span>
</div> </div>
</CustomMenu.MenuItem> ),
},
]
: []
); );
})}
</CustomMenu> return (
<ActionDropdown
items={items}
button={customButton ?? <PlusIcon className="h-4 w-4" />}
buttonAsChild={!!customButton}
buttonClassName={!customButton ? getIconButtonStyling("ghost", "sm") : undefined}
placement="bottom-start"
disabled={disabled}
/>
); );
}); });

View File

@ -8,9 +8,11 @@ import React from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// plane imports // plane imports
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button";
import { PlusIcon, WorkItemsIcon } from "@plane/propel/icons"; import { PlusIcon, WorkItemsIcon } from "@plane/propel/icons";
import type { TIssue, TIssueServiceType } from "@plane/types"; import type { TIssue, TIssueServiceType } from "@plane/types";
import { CustomMenu } from "@plane/ui"; import type { TContextMenuItem } from "@plane/ui";
import { ActionDropdown } from "@plane/ui";
// hooks // hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail"; import { useIssueDetail } from "@/hooks/store/use-issue-detail";
@ -66,37 +68,29 @@ export const SubIssuesActionButton = observer(function SubIssuesActionButton(pro
}; };
// options // options
const optionItems = [ const optionItems: TContextMenuItem[] = [
{ {
i18n_label: "common.create_new", key: "create-new",
icon: <PlusIcon className="h-3 w-3" />, title: t("common.create_new"),
onClick: handleCreateNew, icon: PlusIcon,
action: handleCreateNew,
}, },
{ {
i18n_label: "common.add_existing", key: "add-existing",
icon: <WorkItemsIcon className="h-3 w-3" />, title: t("common.add_existing"),
onClick: handleAddExisting, icon: WorkItemsIcon,
action: handleAddExisting,
}, },
]; ];
// button element
const customButtonElement = customButton ? <>{customButton}</> : <PlusIcon className="h-4 w-4" />;
return ( return (
<CustomMenu customButton={customButtonElement} placement="bottom-start" disabled={disabled} closeOnSelect> <ActionDropdown
{optionItems.map((item, index) => ( items={optionItems}
<CustomMenu.MenuItem button={customButton ?? <PlusIcon className="h-4 w-4" />}
key={index} buttonAsChild={!!customButton}
onClick={() => { buttonClassName={!customButton ? getIconButtonStyling("ghost", "sm") : undefined}
item.onClick(); placement="bottom-start"
}} disabled={disabled}
> />
<div className="flex items-center gap-2">
{item.icon}
<span>{t(item.i18n_label)}</span>
</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
); );
}); });

View File

@ -9,17 +9,22 @@ import React from "react";
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
type Props = { type Props = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "title"> & {
icon: React.ReactNode; icon: React.ReactNode;
title: string; title: string;
disabled?: boolean; disabled?: boolean;
compactView?: boolean; compactView?: boolean;
}; };
export function IssueDetailWidgetButton(props: Props) { export const IssueDetailWidgetButton = React.forwardRef<HTMLButtonElement, Props>(function IssueDetailWidgetButton(
const { icon, title, disabled = false, compactView = false } = props; props,
ref
) {
const { icon, title, disabled = false, compactView = false, className, ...buttonProps } = props;
return ( return (
<Button <Button
ref={ref}
variant={"secondary"} variant={"secondary"}
disabled={disabled} disabled={disabled}
size="xl" size="xl"
@ -27,11 +32,13 @@ export function IssueDetailWidgetButton(props: Props) {
"border-transparent shadow-none focus-visible:outline-none", "border-transparent shadow-none focus-visible:outline-none",
compactView compactView
? "h-10 rounded-[18px] bg-layer-2/80 px-4 backdrop-blur-xl hover:bg-layer-2-active" ? "h-10 rounded-[18px] bg-layer-2/80 px-4 backdrop-blur-xl hover:bg-layer-2-active"
: "rounded-md" : "rounded-md",
className
)} )}
{...buttonProps}
> >
{icon && icon} {icon && icon}
<span className="text-body-xs-medium">{title}</span> <span className="text-body-xs-medium">{title}</span>
</Button> </Button>
); );
} });

View File

@ -8,10 +8,11 @@ import { observer } from "mobx-react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { MinusCircle } from "lucide-react"; import { MinusCircle } from "lucide-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { getIconButtonStyling } from "@plane/propel/icon-button";
import type { TIssue } from "@plane/types"; import type { TIssue } from "@plane/types";
// component // component
// ui // ui
import { ControlLink, CustomMenu } from "@plane/ui"; import { ActionDropdown, ControlLink } from "@plane/ui";
// helpers // helpers
import { generateWorkItemLink } from "@plane/utils"; import { generateWorkItemLink } from "@plane/utils";
// hooks // hooks
@ -92,19 +93,38 @@ export const IssueParentDetail = observer(function IssueParentDetail(props: TIss
</div> </div>
</ControlLink> </ControlLink>
<CustomMenu ellipsis optionsClassName="p-1.5"> <ActionDropdown
<div className="border-b border-strong text-11 font-medium text-secondary">{t("issue.sibling.label")}</div> items={[]}
buttonClassName={getIconButtonStyling("ghost", "sm")}
placement="bottom-end"
menuClassName="p-1.5"
menuContent={({ closeDropdown }) => (
<div className="min-w-[12rem]">
<div className="border-b border-strong px-2 pb-2 text-11 font-medium text-secondary">
{t("issue.sibling.label")}
</div>
<IssueParentSiblings workspaceSlug={workspaceSlug} currentIssue={issue} parentIssue={parentIssue} /> <IssueParentSiblings
closeDropdown={closeDropdown}
workspaceSlug={workspaceSlug}
currentIssue={issue}
parentIssue={parentIssue}
/>
<CustomMenu.MenuItem <button
onClick={() => issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: null })} type="button"
className="flex items-center gap-2 py-2 text-danger-primary" onClick={() => {
issueOperations.update(workspaceSlug, projectId, issueId, { parent_id: null });
closeDropdown();
}}
className="flex w-full items-center gap-2 rounded-[0.9rem] px-2 py-2 text-left text-danger-primary transition-colors hover:bg-white/6"
> >
<MinusCircle className="h-4 w-4" /> <MinusCircle className="h-4 w-4" />
<span>{t("issue.remove.parent.label")}</span> <span>{t("issue.remove.parent.label")}</span>
</CustomMenu.MenuItem> </button>
</CustomMenu> </div>
)}
/>
</div> </div>
</> </>
); );

View File

@ -5,8 +5,6 @@
*/ */
import { observer } from "mobx-react"; import { observer } from "mobx-react";
// ui
import { CustomMenu } from "@plane/ui";
// helpers // helpers
import { generateWorkItemLink } from "@plane/utils"; import { generateWorkItemLink } from "@plane/utils";
// hooks // hooks
@ -16,12 +14,13 @@ import { useProject } from "@/hooks/store/use-project";
import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier"; import { IssueIdentifier } from "@/plane-web/components/issues/issue-details/issue-identifier";
type TIssueParentSiblingItem = { type TIssueParentSiblingItem = {
closeDropdown?: () => void;
workspaceSlug: string; workspaceSlug: string;
issueId: string; issueId: string;
}; };
export const IssueParentSiblingItem = observer(function IssueParentSiblingItem(props: TIssueParentSiblingItem) { export const IssueParentSiblingItem = observer(function IssueParentSiblingItem(props: TIssueParentSiblingItem) {
const { workspaceSlug, issueId } = props; const { closeDropdown, workspaceSlug, issueId } = props;
// hooks // hooks
const { getProjectById } = useProject(); const { getProjectById } = useProject();
const { const {
@ -43,12 +42,14 @@ export const IssueParentSiblingItem = observer(function IssueParentSiblingItem(p
}); });
return ( return (
<> <button
<CustomMenu.MenuItem type="button"
key={issueDetail.id} className="flex w-full items-center gap-2 rounded-[0.9rem] px-2 py-2 text-left text-secondary transition-colors hover:bg-white/6"
onClick={() => window.open(workItemLink, "_blank", "noopener,noreferrer")} onClick={() => {
closeDropdown?.();
window.open(workItemLink, "_blank", "noopener,noreferrer");
}}
> >
<div className="flex items-center gap-2 py-0.5">
{issueDetail.project_id && projectDetails?.identifier && ( {issueDetail.project_id && projectDetails?.identifier && (
<IssueIdentifier <IssueIdentifier
projectId={issueDetail.project_id} projectId={issueDetail.project_id}
@ -58,8 +59,6 @@ export const IssueParentSiblingItem = observer(function IssueParentSiblingItem(p
size="xs" size="xs"
/> />
)} )}
</div> </button>
</CustomMenu.MenuItem>
</>
); );
}); });

View File

@ -14,13 +14,14 @@ import { useIssueDetail } from "@/hooks/store/use-issue-detail";
import { IssueParentSiblingItem } from "./sibling-item"; import { IssueParentSiblingItem } from "./sibling-item";
export type TIssueParentSiblings = { export type TIssueParentSiblings = {
closeDropdown?: () => void;
workspaceSlug: string; workspaceSlug: string;
currentIssue: TIssue; currentIssue: TIssue;
parentIssue: TIssue; parentIssue: TIssue;
}; };
export const IssueParentSiblings = observer(function IssueParentSiblings(props: TIssueParentSiblings) { export const IssueParentSiblings = observer(function IssueParentSiblings(props: TIssueParentSiblings) {
const { workspaceSlug, currentIssue, parentIssue } = props; const { closeDropdown, workspaceSlug, currentIssue, parentIssue } = props;
// hooks // hooks
const { const {
fetchSubIssues, fetchSubIssues,
@ -41,18 +42,23 @@ export const IssueParentSiblings = observer(function IssueParentSiblings(props:
return ( return (
<div className="my-1"> <div className="my-1">
{isLoading ? ( {isLoading ? (
<div className="flex items-center gap-2 px-1 py-1 text-left text-11 whitespace-nowrap text-secondary"> <div className="flex items-center gap-2 px-2 py-1.5 text-left text-11 whitespace-nowrap text-secondary">
Loading Loading
</div> </div>
) : subIssueIds && subIssueIds.length > 0 ? ( ) : subIssueIds && subIssueIds.length > 0 ? (
subIssueIds.map( subIssueIds.map(
(issueId) => (issueId) =>
currentIssue.id != issueId && ( currentIssue.id != issueId && (
<IssueParentSiblingItem key={issueId} workspaceSlug={workspaceSlug} issueId={issueId} /> <IssueParentSiblingItem
key={issueId}
closeDropdown={closeDropdown}
workspaceSlug={workspaceSlug}
issueId={issueId}
/>
) )
) )
) : ( ) : (
<div className="flex items-center gap-2 px-1 py-1 text-left text-11 whitespace-nowrap text-secondary"> <div className="flex items-center gap-2 px-2 py-1.5 text-left text-11 whitespace-nowrap text-secondary">
No sibling work items No sibling work items
</div> </div>
)} )}

View File

@ -16,19 +16,42 @@ import { cn } from "../utils";
import type { TContextMenuItem } from "./context-menu"; import type { TContextMenuItem } from "./context-menu";
export type TActionDropdownTrigger = React.ReactNode; export type TActionDropdownTrigger = React.ReactNode;
type TActionDropdownMenuContent = React.ReactNode | ((props: { closeDropdown: () => void }) => React.ReactNode);
export interface IActionDropdownProps { export interface IActionDropdownProps {
button?: TActionDropdownTrigger; button?: TActionDropdownTrigger;
buttonAsChild?: boolean;
buttonClassName?: string; buttonClassName?: string;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
items: TContextMenuItem[]; items: TContextMenuItem[];
menuContent?: TActionDropdownMenuContent;
menuClassName?: string; menuClassName?: string;
onOpenChange?: (isOpen: boolean) => void; onOpenChange?: (isOpen: boolean) => void;
placement?: TPlacement; placement?: TPlacement;
portalElement?: Element | null; portalElement?: Element | null;
} }
const assignRef = <T,>(ref: React.Ref<T> | undefined, value: T) => {
if (!ref) return;
if (typeof ref === "function") {
ref(value);
return;
}
(ref as React.MutableRefObject<T>).current = value;
};
const composeEventHandlers = <E extends { defaultPrevented?: boolean }>(
...handlers: Array<((event: E) => void) | undefined>
) => {
return (event: E) => {
for (const handler of handlers) {
handler?.(event);
if (event.defaultPrevented) break;
}
};
};
const renderActionContent = (item: TContextMenuItem) => const renderActionContent = (item: TContextMenuItem) =>
item.customContent ?? ( item.customContent ?? (
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
@ -108,14 +131,27 @@ function ActionDropdownItem(props: TActionDropdownItemProps) {
} }
export function ActionDropdown(props: IActionDropdownProps) { export function ActionDropdown(props: IActionDropdownProps) {
const { button, buttonClassName, className, disabled = false, items, menuClassName, onOpenChange, placement, portalElement } = props; const {
button,
buttonAsChild = false,
buttonClassName,
className,
disabled = false,
items,
menuContent,
menuClassName,
onOpenChange,
placement,
portalElement,
} = props;
const dropdownRef = React.useRef<HTMLDivElement | null>(null); const dropdownRef = React.useRef<HTMLDivElement | null>(null);
const [referenceElement, setReferenceElement] = React.useState<HTMLElement | null>(null); const [referenceElement, setReferenceElement] = React.useState<HTMLElement | null>(null);
const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null); const [popperElement, setPopperElement] = React.useState<HTMLDivElement | null>(null);
const [isOpen, setIsOpen] = React.useState(false); const [isOpen, setIsOpen] = React.useState(false);
const renderedItems = items.filter((item) => item.shouldRender !== false); const renderedItems = items.filter((item) => item.shouldRender !== false);
const isDropdownDisabled = disabled || renderedItems.length === 0; const hasMenuContent = menuContent !== undefined && menuContent !== null;
const isDropdownDisabled = disabled || (!hasMenuContent && renderedItems.length === 0);
const { styles, attributes } = usePopper(referenceElement, popperElement, { const { styles, attributes } = usePopper(referenceElement, popperElement, {
placement: placement ?? "bottom-end", placement: placement ?? "bottom-end",
@ -172,9 +208,13 @@ export function ActionDropdown(props: IActionDropdownProps) {
useOutsideClickDetector(dropdownRef, closeDropdown); useOutsideClickDetector(dropdownRef, closeDropdown);
const setReferenceNode = React.useCallback((node: HTMLElement | null) => {
setReferenceElement(node);
}, []);
const defaultButton = ( const defaultButton = (
<button <button
ref={setReferenceElement as React.Ref<HTMLButtonElement>} ref={setReferenceNode as React.Ref<HTMLButtonElement>}
type="button" type="button"
className={cn( className={cn(
"clickable relative grid place-items-center rounded-sm border-0 bg-transparent p-1 text-secondary shadow-none outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 hover:text-primary", "clickable relative grid place-items-center rounded-sm border-0 bg-transparent p-1 text-secondary shadow-none outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0 hover:text-primary",
@ -196,9 +236,34 @@ export function ActionDropdown(props: IActionDropdownProps) {
</button> </button>
); );
const asChildButton =
buttonAsChild && React.isValidElement(button) && button.type !== React.Fragment
? React.cloneElement(button, {
ref: (node: HTMLElement | null) => {
setReferenceNode(node);
assignRef((button as React.ReactElement & { ref?: React.Ref<HTMLElement> }).ref, node);
},
className: cn((button.props as { className?: string }).className, buttonClassName),
disabled: isDropdownDisabled || (button.props as { disabled?: boolean }).disabled,
onClick: composeEventHandlers(
(button.props as { onClick?: (event: React.MouseEvent<HTMLElement>) => void }).onClick,
handleTriggerClick
),
onKeyDown: composeEventHandlers(
(button.props as { onKeyDown?: (event: React.KeyboardEvent<HTMLElement>) => void }).onKeyDown,
handleKeyDown
),
type: (button.props as { type?: string }).type ?? "button",
"aria-haspopup": "menu",
"aria-expanded": isOpen,
"aria-label": (button.props as { ["aria-label"]?: string })["aria-label"] ?? "Work item actions",
"data-action-dropdown-trigger": "true",
})
: null;
const customButton = button ? ( const customButton = button ? (
<button <button
ref={setReferenceElement as React.Ref<HTMLButtonElement>} ref={setReferenceNode as React.Ref<HTMLButtonElement>}
type="button" type="button"
className={cn( className={cn(
"clickable block h-full rounded-full border-0 bg-transparent p-0 shadow-none outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0", "clickable block h-full rounded-full border-0 bg-transparent p-0 shadow-none outline-none focus:outline-none focus-visible:outline-none focus-visible:ring-0",
@ -236,11 +301,19 @@ export function ActionDropdown(props: IActionDropdownProps) {
menuClassName menuClassName
)} )}
> >
{hasMenuContent ? (
typeof menuContent === "function" ? (
menuContent({ closeDropdown })
) : (
menuContent
)
) : (
<div className="max-h-[min(85vh,40rem)] space-y-1 overflow-y-auto"> <div className="max-h-[min(85vh,40rem)] space-y-1 overflow-y-auto">
{renderedItems.map((item) => ( {renderedItems.map((item) => (
<ActionDropdownItem key={item.key} item={item} onSelect={closeDropdown} /> <ActionDropdownItem key={item.key} item={item} onSelect={closeDropdown} />
))} ))}
</div> </div>
)}
</div> </div>
</div>, </div>,
portalElement ?? document.body portalElement ?? document.body
@ -249,7 +322,7 @@ export function ActionDropdown(props: IActionDropdownProps) {
return ( return (
<div ref={dropdownRef} className={cn("relative h-full", className)} onKeyDownCapture={handleKeyDown}> <div ref={dropdownRef} className={cn("relative h-full", className)} onKeyDownCapture={handleKeyDown}>
{customButton ?? defaultButton} {asChildButton ?? customButton ?? defaultButton}
{popup} {popup}
</div> </div>
); );