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,28 +43,33 @@ export function SortingDropdown(props: Props) {
return (
<FiltersDropdown menuButton={menuButton} placement={placement} disabled={disabled}>
<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">
{title}
</div>
{renderedSections.map((section, index) => (
<div
key={section.key}
className={index === 0 ? "py-2" : "border-t border-subtle-1 py-2"}
>
{section.options.map((option) => (
<FilterOption
key={option.key}
disabled={option.disabled}
icon={option.icon}
isChecked={option.isChecked}
onClick={option.onClick}
title={option.title}
/>
))}
{({ closeDropdown }) => (
<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">
{title}
</div>
))}
</div>
{renderedSections.map((section, index) => (
<div
key={section.key}
className={index === 0 ? "py-2" : "border-t border-subtle-1 py-2"}
>
{section.options.map((option) => (
<FilterOption
key={option.key}
disabled={option.disabled}
icon={option.icon}
isChecked={option.isChecked}
onClick={() => {
option.onClick();
closeDropdown();
}}
title={option.title}
/>
))}
</div>
))}
</div>
)}
</FiltersDropdown>
);
}

View File

@ -8,8 +8,8 @@ import { observer } from "mobx-react";
import { useTranslation } from "@plane/i18n";
import { ChevronDownIcon } from "@plane/propel/icons";
import type { TRecentActivityFilterKeys } from "@plane/types";
import { CustomMenu } from "@plane/ui";
import { cn } from "@plane/utils";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
export type TFiltersDropdown = {
className?: string;
@ -22,36 +22,24 @@ export const FiltersDropdown = observer(function FiltersDropdown(props: TFilters
const { className, activeFilter, setActiveFilter, filters } = props;
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 : "";
return (
<CustomMenu
maxHeight={"md"}
className={cn("flex w-fit justify-center text-11 text-secondary", className)}
placement="bottom-start"
customButton={
<button className="flex gap-1 rounded-sm border border-subtle px-2 py-1 capitalize hover:bg-layer-transparent-hover">
<SelectionDropdown
menuButton={
<div 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>
<ChevronDownIcon className={cn("my-auto size-3 text-tertiary duration-300 hover:text-secondary")} />
</button>
</div>
}
customButtonClassName="flex justify-center"
closeOnSelect
>
<DropdownOptions />
</CustomMenu>
menuButtonWrapperClassName={cn("flex w-fit justify-center text-11 text-secondary", className)}
placement="bottom-start"
options={filters.map((filter) => ({
key: filter.name,
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.
*/
import { useState } from "react";
import { useMemo, useState } from "react";
import { observer } from "mobx-react";
import { PencilLine, Unlink2 } from "lucide-react";
import { ETabIndices } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { ParentPropertyIcon } from "@plane/propel/icons";
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";
// components
import { CycleDropdown } from "@/components/dropdowns/cycle";
@ -40,6 +42,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
// hooks
const { areEstimateEnabledByProjectId } = useProjectEstimates();
const { isMobile } = usePlatformOS();
const { t } = useTranslation();
// states
const [parentIssueModalOpen, setParentIssueModalOpen] = useState(false);
const [selectedParentIssue, setSelectedParentIssue] = useState<ISearchIssueResponse | undefined>(undefined);
@ -54,6 +57,26 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
const maxDate = getDate(targetDate);
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 (
<div className={cn("relative flex flex-wrap items-center gap-2", rootClassName)}>
@ -185,8 +208,8 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
{isVisible && (
<div className="h-7">
{selectedParentIssue ? (
<CustomMenu
customButton={
<ActionDropdown
button={
<button
type="button"
className={cn(
@ -198,30 +221,15 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
<span className="whitespace-nowrap">
{selectedParentIssue
? `${selectedParentIssue.project__identifier}-${selectedParentIssue.sequence_id}`
: `Add parent`}
: t("add_parent")}
</span>
</button>
}
buttonAsChild
buttonClassName="h-full"
items={parentMenuItems}
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
type="button"
@ -232,7 +240,7 @@ export const InboxIssueProperties = observer(function InboxIssueProperties(props
onClick={() => setParentIssueModalOpen(true)}
>
<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>
)}

View File

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

View File

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

View File

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