UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: миграция modal property и mobile selection dropdown на общий канон

This commit is contained in:
DCCONSTRUCTIONS 2026-04-22 13:01:08 +03:00
parent 882216922e
commit 6d35fc7bee
7 changed files with 228 additions and 146 deletions

View File

@ -0,0 +1,70 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import type { ReactNode } from "react";
import type { Placement } from "@popperjs/core";
import { FiltersDropdown, FilterOption } from "@/components/issues/issue-layouts/filters/header";
export type TSelectionDropdownOption = {
key: string;
title: ReactNode;
icon?: ReactNode;
isChecked: boolean;
onClick: () => void;
shouldRender?: boolean;
disabled?: boolean;
activePulse?: boolean;
};
type Props = {
disabled?: boolean;
menuButton: ReactNode;
menuButtonWrapperClassName?: string;
options: TSelectionDropdownOption[];
placement?: Placement;
title?: ReactNode;
};
export function SelectionDropdown(props: Props) {
const { disabled = false, menuButton, menuButtonWrapperClassName, options, placement = "bottom-start", title } = props;
const renderedOptions = options.filter((option) => option.shouldRender !== false);
return (
<FiltersDropdown
menuButton={menuButton}
menuButtonWrapperClassName={menuButtonWrapperClassName}
placement={placement}
disabled={disabled}
>
{({ closeDropdown }) => (
<div className="vertical-scrollbar relative scrollbar-sm h-full w-full overflow-y-auto px-2.5 py-2">
{title && (
<div className="sticky top-0 z-[1] bg-[rgba(8,8,11,0.92)] pb-1 pt-0.5 text-caption-sm-medium text-placeholder backdrop-blur-xl">
{title}
</div>
)}
<div className={title ? "pt-2" : ""}>
{renderedOptions.map((option) => (
<FilterOption
key={option.key}
activePulse={option.activePulse}
disabled={option.disabled}
icon={option.icon}
isChecked={option.isChecked}
onClick={() => {
option.onClick();
closeDropdown();
}}
title={option.title}
/>
))}
</div>
</div>
)}
</FiltersDropdown>
);
}

View File

@ -43,6 +43,7 @@ export function SortingDropdown(props: Props) {
return ( return (
<FiltersDropdown menuButton={menuButton} placement={placement} disabled={disabled}> <FiltersDropdown menuButton={menuButton} placement={placement} disabled={disabled}>
{({ closeDropdown }) => (
<div className="vertical-scrollbar relative scrollbar-sm h-full w-full overflow-y-auto px-2.5"> <div className="vertical-scrollbar relative scrollbar-sm h-full w-full overflow-y-auto px-2.5">
<div className="sticky top-0 z-[1] bg-[rgba(8,8,11,0.92)] pb-1 pt-0.5 text-caption-sm-medium text-placeholder backdrop-blur-xl"> <div className="sticky top-0 z-[1] bg-[rgba(8,8,11,0.92)] pb-1 pt-0.5 text-caption-sm-medium text-placeholder backdrop-blur-xl">
{title} {title}
@ -58,13 +59,17 @@ export function SortingDropdown(props: Props) {
disabled={option.disabled} disabled={option.disabled}
icon={option.icon} icon={option.icon}
isChecked={option.isChecked} isChecked={option.isChecked}
onClick={option.onClick} onClick={() => {
option.onClick();
closeDropdown();
}}
title={option.title} title={option.title}
/> />
))} ))}
</div> </div>
))} ))}
</div> </div>
)}
</FiltersDropdown> </FiltersDropdown>
); );
} }

View File

@ -8,8 +8,8 @@ import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { ChevronDownIcon } from "@plane/propel/icons"; import { ChevronDownIcon } from "@plane/propel/icons";
import type { TRecentActivityFilterKeys } from "@plane/types"; import type { TRecentActivityFilterKeys } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils"; import { cn } from "@plane/utils";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
export type TFiltersDropdown = { export type TFiltersDropdown = {
className?: string; className?: string;
@ -22,36 +22,24 @@ export const FiltersDropdown = observer(function FiltersDropdown(props: TFilters
const { className, activeFilter, setActiveFilter, filters } = props; const { className, activeFilter, setActiveFilter, filters } = props;
const { t } = useTranslation(); const { t } = useTranslation();
function DropdownOptions() {
return filters?.map((filter) => (
<CustomMenu.MenuItem
key={filter.name}
className="flex items-center gap-2 truncate text-secondary"
onClick={() => {
setActiveFilter(filter.name);
}}
>
<div className="truncate text-11 font-medium capitalize">{t(filter.i18n_key)}</div>
</CustomMenu.MenuItem>
));
}
const title = activeFilter ? filters?.find((filter) => filter.name === activeFilter)?.i18n_key : ""; const title = activeFilter ? filters?.find((filter) => filter.name === activeFilter)?.i18n_key : "";
return ( return (
<CustomMenu <SelectionDropdown
maxHeight={"md"} menuButton={
className={cn("flex w-fit justify-center text-11 text-secondary", className)} <div className="flex gap-1 rounded-sm border border-subtle px-2 py-1 capitalize hover:bg-layer-transparent-hover">
placement="bottom-start"
customButton={
<button className="flex gap-1 rounded-sm border border-subtle px-2 py-1 capitalize hover:bg-layer-transparent-hover">
<span className="my-auto text-13 font-medium">{t(title || "")}</span> <span className="my-auto text-13 font-medium">{t(title || "")}</span>
<ChevronDownIcon className={cn("my-auto size-3 text-tertiary duration-300 hover:text-secondary")} /> <ChevronDownIcon className={cn("my-auto size-3 text-tertiary duration-300 hover:text-secondary")} />
</button> </div>
} }
customButtonClassName="flex justify-center" menuButtonWrapperClassName={cn("flex w-fit justify-center text-11 text-secondary", className)}
closeOnSelect placement="bottom-start"
> options={filters.map((filter) => ({
<DropdownOptions /> key: filter.name,
</CustomMenu> title: <div className="truncate text-11 font-medium capitalize">{t(filter.i18n_key)}</div>,
icon: filter.icon,
isChecked: activeFilter === filter.name,
onClick: () => setActiveFilter(filter.name),
}))}
/>
); );
}); });

View File

@ -4,12 +4,14 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useState } from "react"; import { useMemo, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import { PencilLine, Unlink2 } from "lucide-react";
import { ETabIndices } from "@plane/constants"; import { ETabIndices } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ParentPropertyIcon } from "@plane/propel/icons"; import { ParentPropertyIcon } from "@plane/propel/icons";
import type { ISearchIssueResponse, TIssue } from "@plane/types"; import type { ISearchIssueResponse, TIssue } from "@plane/types";
import { CustomMenu } from "@plane/ui"; import { ActionDropdown, type TContextMenuItem } from "@plane/ui";
import { cn, getDate, getTabIndex, renderFormattedPayloadDate } from "@plane/utils"; import { cn, getDate, getTabIndex, renderFormattedPayloadDate } from "@plane/utils";
// components // components
import { CycleDropdown } from "@/components/dropdowns/cycle"; import { CycleDropdown } from "@/components/dropdowns/cycle";
@ -40,6 +42,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
// hooks // hooks
const { areEstimateEnabledByProjectId } = useProjectEstimates(); const { areEstimateEnabledByProjectId } = useProjectEstimates();
const { isMobile } = usePlatformOS(); const { isMobile } = usePlatformOS();
const { t } = useTranslation();
// states // states
const [parentIssueModalOpen, setParentIssueModalOpen] = useState(false); const [parentIssueModalOpen, setParentIssueModalOpen] = useState(false);
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | undefined>(undefined); const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | undefined>(undefined);
@ -54,6 +57,26 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
const maxDate = getDate(targetDate); const maxDate = getDate(targetDate);
maxDate?.setDate(maxDate.getDate()); maxDate?.setDate(maxDate.getDate());
const parentMenuItems = useMemo<TContextMenuItem[]>(
() => [
{
key: "change-parent",
title: t("change_parent_issue"),
icon: PencilLine,
action: () => setParentIssueModalOpen(true),
},
{
key: "remove-parent",
title: t("remove_parent_issue"),
icon: Unlink2,
action: () => {
handleData("parent_id", "");
setSelectedParentIssue(undefined);
},
},
],
[handleData, t]
);
return ( return (
<div className={cn("relative flex flex-wrap items-center gap-2", rootClassName)}> <div className={cn("relative flex flex-wrap items-center gap-2", rootClassName)}>
@ -185,8 +208,8 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
{isVisible && ( {isVisible && (
<div className="h-7"> <div className="h-7">
{selectedParentIssue ? ( {selectedParentIssue ? (
<CustomMenu <ActionDropdown
customButton={ button={
<button <button
type="button" type="button"
className={cn( className={cn(
@ -198,30 +221,15 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
<span className="whitespace-nowrap"> <span className="whitespace-nowrap">
{selectedParentIssue {selectedParentIssue
? `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}` ? `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
: `Add parent`} : t("add_parent")}
</span> </span>
</button> </button>
} }
buttonAsChild
buttonClassName="h-full"
items={parentMenuItems}
placement="bottom-start" placement="bottom-start"
className="h-full w-full" />
customButtonClassName="h-full"
tabIndex={getIndex("parent_id")}
>
<>
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueModalOpen(true)}>
Change parent work item
</CustomMenu.MenuItem>
<CustomMenu.MenuItem
className="!p-1"
onClick={() => {
handleData("parent_id", "");
setSelectedParentIssue(undefined);
}}
>
Remove parent work item
</CustomMenu.MenuItem>
</>
</CustomMenu>
) : ( ) : (
<button <button
type="button" type="button"
@ -232,7 +240,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
onClick={() => setParentIssueModalOpen(true)} onClick={() => setParentIssueModalOpen(true)}
> >
<ParentPropertyIcon className="h-3 w-3 flex-shrink-0" /> <ParentPropertyIcon className="h-3 w-3 flex-shrink-0" />
<span className="whitespace-nowrap">Add parent</span> <span className="whitespace-nowrap">{t("add_parent")}</span>
</button> </button>
)} )}

View File

@ -13,7 +13,7 @@ import { Popover, Portal, Transition } from "@headlessui/react";
import { Button } from "@plane/propel/button"; import { Button } from "@plane/propel/button";
type Props = { type Props = {
children: React.ReactNode; children: React.ReactNode | ((props: { closeDropdown: () => void }) => React.ReactNode);
icon?: React.ReactElement; icon?: React.ReactElement;
miniIcon?: React.ReactNode; miniIcon?: React.ReactNode;
title?: string; title?: string;
@ -21,6 +21,7 @@ type Props = {
disabled?: boolean; disabled?: boolean;
tabIndex?: number; tabIndex?: number;
menuButton?: React.ReactNode; menuButton?: React.ReactNode;
menuButtonWrapperClassName?: string;
isFiltersApplied?: boolean; isFiltersApplied?: boolean;
}; };
@ -34,6 +35,7 @@ export function FiltersDropdown(props: Props) {
disabled = false, disabled = false,
tabIndex, tabIndex,
menuButton, menuButton,
menuButtonWrapperClassName,
isFiltersApplied = false, isFiltersApplied = false,
} = props; } = props;
@ -46,11 +48,16 @@ export function FiltersDropdown(props: Props) {
return ( return (
<Popover as="div"> <Popover as="div">
{({ open }) => ( {({ open, close }) => (
<> <>
<Popover.Button as={React.Fragment}> <Popover.Button as={React.Fragment}>
{menuButton ? ( {menuButton ? (
<button type="button" ref={setReferenceElement}> <button
type="button"
ref={setReferenceElement}
className={menuButtonWrapperClassName}
disabled={disabled}
>
{menuButton} {menuButton}
</button> </button>
) : ( ) : (
@ -109,7 +116,7 @@ export function FiltersDropdown(props: Props) {
{...attributes.popper} {...attributes.popper}
> >
<div className="flex max-h-[30rem] w-[18.75rem] flex-col overflow-hidden lg:max-h-[37.5rem]"> <div className="flex max-h-[30rem] w-[18.75rem] flex-col overflow-hidden lg:max-h-[37.5rem]">
{children} {typeof children === "function" ? children({ closeDropdown: close }) : children}
</div> </div>
</div> </div>
</Popover.Panel> </Popover.Panel>

View File

@ -6,10 +6,10 @@
import { ISSUE_LAYOUTS } from "@plane/constants"; import { ISSUE_LAYOUTS } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { Button } from "@plane/propel/button";
import { ChevronDownIcon } from "@plane/propel/icons"; import { ChevronDownIcon } from "@plane/propel/icons";
import type { EIssueLayoutTypes } from "@plane/types"; import type { EIssueLayoutTypes } from "@plane/types";
import { CustomMenu } from "@plane/ui"; import { cn } from "@plane/utils";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
import { IssueLayoutIcon } from "../../layout-icon"; import { IssueLayoutIcon } from "../../layout-icon";
export function MobileLayoutSelection({ export function MobileLayoutSelection({
@ -24,35 +24,26 @@ export function MobileLayoutSelection({
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<CustomMenu <SelectionDropdown
maxHeight={"md"} menuButton={
className="flex flex-grow justify-center text-13 text-secondary" <div className="nodedc-toolbar-pill relative flex items-center gap-2 px-3">
placement="bottom-start"
customButton={
<Button variant="secondary" className="nodedc-toolbar-pill relative gap-2 px-3">
<span className="nodedc-toolbar-icon-active-dot bg-[rgb(var(--nodedc-accent-rgb))] text-[#0b1117]"> <span className="nodedc-toolbar-icon-active-dot bg-[rgb(var(--nodedc-accent-rgb))] text-[#0b1117]">
{activeLayout && ( {activeLayout && (
<IssueLayoutIcon layout={activeLayout} size={14} strokeWidth={2} className={`h-3.5 w-3.5`} /> <IssueLayoutIcon layout={activeLayout} size={14} strokeWidth={2} className="h-3.5 w-3.5" />
)} )}
</span> </span>
<ChevronDownIcon className="my-auto size-3 text-secondary" strokeWidth={2} /> <ChevronDownIcon className="my-auto size-3 text-secondary" strokeWidth={2} />
</Button> </div>
} }
customButtonClassName="flex flex-grow justify-center text-secondary text-13" menuButtonWrapperClassName="flex flex-grow justify-center text-13 text-secondary"
closeOnSelect placement="bottom-start"
> options={ISSUE_LAYOUTS.filter((layout) => layouts.includes(layout.key)).map((layout) => ({
{ISSUE_LAYOUTS.filter((l) => layouts.includes(l.key)).map((layout, index) => ( key: layout.key,
<CustomMenu.MenuItem title: <div className={cn("text-tertiary", { "text-secondary": activeLayout === layout.key })}>{t(layout.i18n_label)}</div>,
key={index} icon: <IssueLayoutIcon layout={layout.key} className="h-3 w-3" />,
onClick={() => { isChecked: activeLayout === layout.key,
onChange(layout.key); onClick: () => onChange(layout.key),
}} }))}
className="flex items-center gap-2" />
>
<IssueLayoutIcon layout={layout.key} className="h-3 w-3" />
<div className="text-tertiary">{t(layout.i18n_label)}</div>
</CustomMenu.MenuItem>
))}
</CustomMenu>
); );
} }

View File

@ -4,17 +4,18 @@
* See the LICENSE file for details. * See the LICENSE file for details.
*/ */
import { useState } from "react"; import { useMemo, useState } from "react";
import { observer } from "mobx-react"; import { observer } from "mobx-react";
import type { Control } from "react-hook-form"; import type { Control } from "react-hook-form";
import { Controller } from "react-hook-form"; import { Controller } from "react-hook-form";
import { PencilLine, Unlink2 } from "lucide-react";
import { ETabIndices, EUserPermissions, EUserPermissionsLevel } from "@plane/constants"; import { ETabIndices, EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { useTranslation } from "@plane/i18n"; import { useTranslation } from "@plane/i18n";
import { ParentPropertyIcon } from "@plane/propel/icons"; import { ParentPropertyIcon } from "@plane/propel/icons";
// types // types
import type { ISearchIssueResponse, TIssue } from "@plane/types"; import type { ISearchIssueResponse, TIssue } from "@plane/types";
// ui // ui
import { CustomMenu } from "@plane/ui"; import { ActionDropdown, type TContextMenuItem } from "@plane/ui";
import { getDate, renderFormattedPayloadDate, getTabIndex } from "@plane/utils"; import { getDate, renderFormattedPayloadDate, getTabIndex } from "@plane/utils";
// components // components
import { CycleDropdown } from "@/components/dropdowns/cycle"; import { CycleDropdown } from "@/components/dropdowns/cycle";
@ -46,7 +47,7 @@ type TIssueDefaultPropertiesProps = {
parentId: string | null; parentId: string | null;
isDraft: boolean; isDraft: boolean;
handleFormChange: () => void; handleFormChange: () => void;
setSelectedParentIssue: (issue: ISearchIssueResponse) => void; setSelectedParentIssue: (issue: ISearchIssueResponse | null) => void;
}; };
export const IssueDefaultProperties = observer(function IssueDefaultProperties(props: TIssueDefaultPropertiesProps) { export const IssueDefaultProperties = observer(function IssueDefaultProperties(props: TIssueDefaultPropertiesProps) {
@ -85,6 +86,23 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
const maxDate = getDate(targetDate); const maxDate = getDate(targetDate);
maxDate?.setDate(maxDate.getDate()); maxDate?.setDate(maxDate.getDate());
const propertyButtonClassName = "nodedc-work-item-property-button"; const propertyButtonClassName = "nodedc-work-item-property-button";
const parentMenuItems = useMemo<TContextMenuItem[]>(
() => [
{
key: "change-parent",
title: t("change_parent_issue"),
icon: PencilLine,
action: () => setParentIssueListModalOpen(true),
},
{
key: "remove-parent",
title: t("remove_parent_issue"),
icon: Unlink2,
action: () => setSelectedParentIssue(null),
},
],
[setSelectedParentIssue, t]
);
return ( return (
<div className="nodedc-work-item-properties-row"> <div className="nodedc-work-item-properties-row">
@ -276,8 +294,12 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
)} )}
<div className="h-7"> <div className="h-7">
{parentId ? ( {parentId ? (
<CustomMenu <Controller
customButton={ control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<ActionDropdown
button={
<button <button
type="button" type="button"
className={`${propertyButtonClassName} flex h-full cursor-pointer items-center justify-between gap-1 whitespace-nowrap`} className={`${propertyButtonClassName} flex h-full cursor-pointer items-center justify-between gap-1 whitespace-nowrap`}
@ -293,32 +315,23 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
)} )}
</button> </button>
} }
placement="bottom-start" buttonAsChild
className="h-full w-full" buttonClassName="h-full"
customButtonClassName="h-full" items={[
tabIndex={getIndex("parent_id")} parentMenuItems[0],
> {
<> ...parentMenuItems[1],
<CustomMenu.MenuItem className="!p-1" onClick={() => setParentIssueListModalOpen(true)}> action: () => {
{t("change_parent_issue")}
</CustomMenu.MenuItem>
<Controller
control={control}
name="parent_id"
render={({ field: { onChange } }) => (
<CustomMenu.MenuItem
className="!p-1"
onClick={() => {
onChange(null); onChange(null);
handleFormChange(); handleFormChange();
}} setSelectedParentIssue(null);
> },
{t("remove_parent_issue")} },
</CustomMenu.MenuItem> ]}
placement="bottom-start"
/>
)} )}
/> />
</>
</CustomMenu>
) : ( ) : (
<button <button
type="button" type="button"