UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: миграция modal property и mobile selection dropdown на общий канон
This commit is contained in:
parent
882216922e
commit
6d35fc7bee
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ export function SortingDropdown(props: Props) {
|
|||
|
||||
return (
|
||||
<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="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}
|
||||
|
|
@ -58,13 +59,17 @@ export function SortingDropdown(props: Props) {
|
|||
disabled={option.disabled}
|
||||
icon={option.icon}
|
||||
isChecked={option.isChecked}
|
||||
onClick={option.onClick}
|
||||
onClick={() => {
|
||||
option.onClick();
|
||||
closeDropdown();
|
||||
}}
|
||||
title={option.title}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</FiltersDropdown>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,8 +294,12 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
|
|||
)}
|
||||
<div className="h-7">
|
||||
{parentId ? (
|
||||
<CustomMenu
|
||||
customButton={
|
||||
<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`}
|
||||
|
|
@ -293,32 +315,23 @@ export const IssueDefaultProperties = observer(function IssueDefaultProperties(p
|
|||
)}
|
||||
</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={() => {
|
||||
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"
|
||||
|
|
|
|||
Loading…
Reference in New Issue