АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: миграция простых CustomSelect-пикеров на SelectionDropdown

This commit is contained in:
DCCONSTRUCTIONS 2026-04-22 13:39:07 +03:00
parent 305357478e
commit a49e18d0e5
12 changed files with 177 additions and 191 deletions

View File

@ -23,8 +23,9 @@ import {
} from "@plane/propel/icons";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TExternalContourRequest, TNameDescriptionLoader } from "@plane/types";
import { ControlLink, CustomSelect, Header, Row, Tooltip } from "@plane/ui";
import { ControlLink, Header, Row, Tooltip } from "@plane/ui";
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
import { NameDescriptionUpdateStatus } from "@/components/issues/issue-update-status";
import { useProject } from "@/hooks/store/use-project";
import { useProjectExternalContoursBoard } from "@/hooks/store/use-project-external-contours-board";
@ -228,19 +229,20 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
)}
{currentMode && !embedIssue && (
<CustomSelect
value={currentMode}
onChange={(value: TExternalContourPeekMode) => setPeekMode(value)}
customButton={
<SelectionDropdown
menuButton={
<Tooltip tooltipContent={t("common.toggle_peek_view_layout")}>
<button type="button">
<span>
<currentMode.icon className="h-4 w-4 text-tertiary hover:text-secondary" />
</button>
</span>
</Tooltip>
}
>
{PEEK_OPTIONS.map((mode) => (
<CustomSelect.Option key={mode.key} value={mode.key}>
menuButtonWrapperClassName="flex items-center"
options={PEEK_OPTIONS.map((mode) => ({
key: mode.key,
isChecked: currentMode.key === mode.key,
onClick: () => setPeekMode(mode.key),
title: (
<div
className={`flex items-center gap-1.5 ${
currentMode.key === mode.key ? "text-secondary" : "text-placeholder hover:text-secondary"
@ -249,9 +251,9 @@ export const ExternalContoursIssueActionsHeader = observer(function ExternalCont
<mode.icon className="-my-1 h-4 w-4 flex-shrink-0" />
{t(mode.i18n_title)}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
),
}))}
/>
)}
{issue?.project_id && issue.sequence_id && (

View File

@ -6,7 +6,7 @@
// plane package imports
import type { ChartXAxisProperty } from "@plane/types";
import { CustomSelect } from "@plane/ui";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
type Props = {
value?: ChartXAxisProperty;
@ -21,16 +21,28 @@ type Props = {
export function SelectXAxis(props: Props) {
const { value, onChange, options, hiddenOptions, allowNoValue, label } = props;
return (
<CustomSelect value={value} label={label} onChange={onChange} maxHeight="lg">
{allowNoValue && <CustomSelect.Option value={null}>No value</CustomSelect.Option>}
{options.map((item) => {
if (hiddenOptions?.includes(item.value)) return null;
return (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
);
})}
</CustomSelect>
<SelectionDropdown
menuButton={label ?? "Select"}
options={[
...(allowNoValue
? [
{
key: "__none__",
title: "No value",
isChecked: value == null,
onClick: () => onChange(null),
},
]
: []),
...options
.filter((item) => !hiddenOptions?.includes(item.value))
.map((item) => ({
key: item.value,
title: item.label,
isChecked: value === item.value,
onClick: () => onChange(item.value),
})),
]}
/>
);
}

View File

@ -10,7 +10,7 @@ import { EEstimateSystem } from "@plane/constants";
import { ProjectIcon } from "@plane/propel/icons";
import type { ChartYAxisMetric } from "@plane/types";
// plane package imports
import { CustomSelect } from "@plane/ui";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
// hooks
import { useProjectEstimates } from "@/hooks/store/estimates";
// plane web constants
@ -44,27 +44,22 @@ export const SelectYAxis = observer(function SelectYAxis({ value, onChange, hidd
};
return (
<CustomSelect
value={value}
label={
<SelectionDropdown
menuButton={
<div className="flex items-center gap-2">
<ProjectIcon className="h-3 w-3" />
<span>{options.find((v) => v.value === value)?.label ?? "Add Metric"}</span>
</div>
}
onChange={onChange}
maxHeight="lg"
>
{options.map((item) => {
if (hiddenOptions?.includes(item.value)) return null;
return (
isEstimateEnabled(item.value) && (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
)
);
})}
</CustomSelect>
options={options
.filter((item) => !hiddenOptions?.includes(item.value))
.filter((item) => isEstimateEnabled(item.value))
.map((item) => ({
key: item.value,
title: item.label,
isChecked: value === item.value,
onClick: () => onChange(item.value),
}))}
/>
);
});

View File

@ -25,11 +25,12 @@ type Props = {
menuButtonWrapperClassName?: string;
options: TSelectionDropdownOption[];
placement?: Placement;
tabIndex?: number;
title?: ReactNode;
};
export function SelectionDropdown(props: Props) {
const { disabled = false, menuButton, menuButtonWrapperClassName, options, placement = "bottom-start", title } = props;
const { disabled = false, menuButton, menuButtonWrapperClassName, options, placement = "bottom-start", tabIndex, title } = props;
const renderedOptions = options.filter((option) => option.shouldRender !== false);
@ -39,6 +40,7 @@ export function SelectionDropdown(props: Props) {
menuButtonWrapperClassName={menuButtonWrapperClassName}
placement={placement}
disabled={disabled}
tabIndex={tabIndex}
>
{({ closeDropdown }) => (
<div className="vertical-scrollbar relative scrollbar-sm h-full w-full overflow-y-auto px-2.5 py-2">

View File

@ -8,7 +8,7 @@ import React from "react";
import { CalendarDays } from "lucide-react";
// ui
import { CalendarAfterIcon, CalendarBeforeIcon } from "@plane/propel/icons";
import { CustomSelect } from "@plane/ui";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
type Props = {
title: string;
@ -42,9 +42,8 @@ const dueDateRange: DueDate[] = [
export function DateFilterSelect({ title, value, onChange }: Props) {
return (
<CustomSelect
value={value}
label={
<SelectionDropdown
menuButton={
<div className="flex items-center gap-2 text-11">
{dueDateRange.find((item) => item.value === value)?.icon}
<span>
@ -52,16 +51,18 @@ export function DateFilterSelect({ title, value, onChange }: Props) {
</span>
</div>
}
onChange={onChange}
>
{dueDateRange.map((option, index) => (
<CustomSelect.Option key={index} value={option.value}>
menuButtonWrapperClassName="flex items-center"
options={dueDateRange.map((option) => ({
key: option.value,
isChecked: value === option.value,
onClick: () => onChange(option.value),
title: (
<div className="flex items-center gap-2">
<span>{option.icon}</span>
{title} {option.name}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
),
}))}
/>
);
}

View File

@ -9,7 +9,7 @@ import type { I_THEME_OPTION } from "@plane/constants";
import { THEME_OPTIONS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
// constants
import { CustomSelect } from "@plane/ui";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
// ui
type Props = {
@ -22,70 +22,54 @@ export function ThemeSwitch(props: Props) {
// translation
const { t } = useTranslation();
const renderThemeSwatch = (themeOption: I_THEME_OPTION) => (
<div
className="relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border-1"
style={{
borderColor: themeOption.icon.border,
}}
>
<div
className="h-full w-1/2 rounded-l-full"
style={{
background: themeOption.icon.color1,
}}
/>
<div
className="h-full w-1/2 rounded-r-full border-l"
style={{
borderLeftColor: themeOption.icon.border,
background: themeOption.icon.color2,
}}
/>
</div>
);
return (
<CustomSelect
value={value}
label={
<SelectionDropdown
placement="bottom-end"
menuButton={
value ? (
<div className="flex items-center gap-2">
<div
className="relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border-1"
style={{
borderColor: value.icon.border,
}}
>
<div
className="h-full w-1/2 rounded-l-full"
style={{
background: value.icon.color1,
}}
/>
<div
className="h-full w-1/2 rounded-r-full border-l"
style={{
borderLeftColor: value.icon.border,
background: value.icon.color2,
}}
/>
</div>
{renderThemeSwatch(value)}
{t(value.key)}
</div>
) : (
t("select_your_theme")
)
}
onChange={onChange}
buttonClassName="border border-subtle-1"
placement="bottom-end"
input
>
{THEME_OPTIONS.map((themeOption) => (
<CustomSelect.Option key={themeOption.value} value={themeOption}>
menuButtonWrapperClassName="flex w-full items-center justify-between rounded-full border border-subtle-1 px-3 py-2 text-13"
options={THEME_OPTIONS.map((themeOption) => ({
key: themeOption.value,
isChecked: value?.value === themeOption.value,
onClick: () => onChange(themeOption),
title: (
<div className="flex items-center gap-2">
<div
className="relative flex h-4 w-4 rotate-45 transform items-center justify-center rounded-full border border-1"
style={{
borderColor: themeOption.icon.border,
}}
>
<div
className="h-full w-1/2 rounded-l-full"
style={{
background: themeOption.icon.color1,
}}
/>
<div
className="h-full w-1/2 rounded-r-full border-l"
style={{
borderLeftColor: themeOption.icon.border,
background: themeOption.icon.color2,
}}
/>
</div>
{renderThemeSwatch(themeOption)}
{t(themeOption.key)}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
),
}))}
/>
);
}

View File

@ -8,7 +8,7 @@ import React from "react";
import { observer } from "mobx-react";
import type { TCycleEstimateType } from "@plane/types";
import { EEstimateSystem } from "@plane/types";
import { CustomSelect } from "@plane/ui";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
import { useProjectEstimates } from "@/hooks/store/estimates";
import { useCycle } from "@/hooks/store/use-cycle";
// local imports
@ -30,19 +30,16 @@ export const EstimateTypeDropdown = observer(function EstimateTypeDropdown(props
return (getIsPointsDataAvailable(cycleId) || isCurrentProjectEstimateEnabled) &&
currentProjectEstimateType !== EEstimateSystem.CATEGORIES ? (
<div className="relative flex items-center gap-2">
<CustomSelect
value={value}
label={<span>{cycleEstimateOptions.find((v) => v.value === value)?.label ?? "None"}</span>}
onChange={onChange}
maxHeight="lg"
buttonClassName="bg-surface-2 border-none rounded-sm text-13 font-medium "
>
{cycleEstimateOptions.map((item) => (
<CustomSelect.Option key={item.value} value={item.value}>
{item.label}
</CustomSelect.Option>
))}
</CustomSelect>
<SelectionDropdown
menuButton={<span>{cycleEstimateOptions.find((v) => v.value === value)?.label ?? "None"}</span>}
menuButtonWrapperClassName="flex items-center rounded-sm bg-surface-2 px-2 py-1 text-13 font-medium"
options={cycleEstimateOptions.map((item) => ({
key: item.value,
title: item.label,
isChecked: value === item.value,
onClick: () => onChange(item.value),
}))}
/>
</div>
) : showDefault ? (
<span className="capitalize">{cycleEstimateOptions.find((v) => v.value === value)?.label ?? value}</span>

View File

@ -15,7 +15,7 @@ import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import { Tooltip } from "@plane/propel/tooltip";
import type { TNameDescriptionLoader } from "@plane/types";
import { EIssuesStoreType } from "@plane/types";
import { CustomSelect } from "@plane/ui";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
import { copyUrlToClipboard, generateWorkItemLink } from "@plane/utils";
// hooks
import { useIssueDetail } from "@/hooks/store/use-issue-detail";
@ -185,19 +185,20 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
</Tooltip>
{currentMode && showLayoutSwitcher && (
<div className="flex flex-shrink-0 items-center gap-2">
<CustomSelect
value={currentMode}
onChange={(val: any) => setPeekMode(val)}
customButton={
<SelectionDropdown
menuButton={
<Tooltip tooltipContent={t("common.toggle_peek_view_layout")} isMobile={isMobile}>
<button type="button" className="">
<span>
<currentMode.icon className="h-4 w-4 text-tertiary hover:text-secondary" />
</button>
</span>
</Tooltip>
}
>
{PEEK_OPTIONS.map((mode) => (
<CustomSelect.Option key={mode.key} value={mode.key}>
menuButtonWrapperClassName="flex items-center"
options={PEEK_OPTIONS.map((mode) => ({
key: mode.key,
isChecked: currentMode.key === mode.key,
onClick: () => setPeekMode(mode.key),
title: (
<div
className={`flex items-center gap-1.5 ${
currentMode.key === mode.key ? "text-secondary" : "text-placeholder hover:text-secondary"
@ -206,9 +207,9 @@ export const IssuePeekOverviewHeader = observer(function IssuePeekOverviewHeader
<mode.icon className="-my-1 h-4 w-4 flex-shrink-0" />
{t(mode.i18n_title)}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
),
}))}
/>
</div>
)}
{metaSlot}

View File

@ -11,7 +11,7 @@ import { useTranslation } from "@plane/i18n";
import type { TModuleStatus } from "@plane/propel/icons";
import { ModuleStatusIcon } from "@plane/propel/icons";
import type { IModule } from "@plane/types";
import { CustomSelect } from "@plane/ui";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
type Props = {
isDisabled: boolean;
@ -27,8 +27,8 @@ export const ModuleStatusDropdown = observer(function ModuleStatusDropdown(props
if (!moduleStatus) return <></>;
return (
<CustomSelect
customButton={
<SelectionDropdown
menuButton={
<span
className={`flex h-6 w-20 items-center justify-center rounded-sm text-center text-11 ${
isDisabled ? "cursor-not-allowed" : "cursor-pointer"
@ -41,20 +41,19 @@ export const ModuleStatusDropdown = observer(function ModuleStatusDropdown(props
{(moduleStatus && t(moduleStatus?.i18n_label)) ?? t("project_modules.status.backlog")}
</span>
}
value={moduleStatus?.value}
onChange={(val: TModuleStatus) => {
handleModuleDetailsChange({ status: val });
}}
disabled={isDisabled}
>
{MODULE_STATUS.map((status) => (
<CustomSelect.Option key={status.value} value={status.value}>
menuButtonWrapperClassName="flex"
options={MODULE_STATUS.map((status) => ({
key: status.value,
isChecked: moduleStatus?.value === status.value,
onClick: () => handleModuleDetailsChange({ status: status.value as TModuleStatus }),
title: (
<div className="flex items-center gap-2">
<ModuleStatusIcon status={status.value} />
{t(status.i18n_label)}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
),
}))}
/>
);
});

View File

@ -14,7 +14,7 @@ import { useTranslation } from "@plane/i18n";
import { StatePropertyIcon, ModuleStatusIcon } from "@plane/propel/icons";
import type { IModule } from "@plane/types";
// ui
import { CustomSelect } from "@plane/ui";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
// types
// constants
@ -34,9 +34,8 @@ export function ModuleStatusSelect({ control, error, tabIndex }: Props) {
render={({ field: { value, onChange } }) => {
const selectedValue = MODULE_STATUS.find((s) => s.value === value);
return (
<CustomSelect
value={value}
label={
<SelectionDropdown
menuButton={
<div
className={`flex items-center justify-center gap-2 py-0.5 text-11 ${error ? "text-danger-primary" : ""}`}
>
@ -50,19 +49,20 @@ export function ModuleStatusSelect({ control, error, tabIndex }: Props) {
)}
</div>
}
onChange={onChange}
tabIndex={tabIndex}
noChevron
>
{MODULE_STATUS.map((status) => (
<CustomSelect.Option key={status.value} value={status.value}>
menuButtonWrapperClassName="flex"
options={MODULE_STATUS.map((status) => ({
key: status.value,
isChecked: value === status.value,
onClick: () => onChange(status.value),
title: (
<div className="flex items-center gap-2">
<ModuleStatusIcon status={status.value} />
{t(status.i18n_label)}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
),
}))}
/>
);
}}
/>

View File

@ -14,7 +14,7 @@ import { useTranslation } from "@plane/i18n";
import { StatePropertyIcon } from "@plane/propel/icons";
import type { IModule } from "@plane/types";
// ui
import { CustomSelect } from "@plane/ui";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
// types
// common
// constants
@ -38,8 +38,8 @@ export function SidebarStatusSelect({ control, submitChanges, watch }: Props) {
control={control}
name="status"
render={({ field: { value } }) => (
<CustomSelect
label={
<SelectionDropdown
menuButton={
<span className={`flex items-center gap-2 text-left capitalize ${value ? "" : "text-primary"}`}>
<span
className="h-2 w-2 flex-shrink-0 rounded-full"
@ -50,20 +50,19 @@ export function SidebarStatusSelect({ control, submitChanges, watch }: Props) {
{watch("status")}
</span>
}
value={value}
onChange={(value: any) => {
submitChanges({ status: value });
}}
>
{MODULE_STATUS.map((option) => (
<CustomSelect.Option key={option.value} value={option.value}>
menuButtonWrapperClassName="flex"
options={MODULE_STATUS.map((option) => ({
key: option.value,
isChecked: value === option.value,
onClick: () => submitChanges({ status: option.value }),
title: (
<div className="flex items-center gap-2">
<span className="h-2 w-2 flex-shrink-0 rounded-full" style={{ backgroundColor: option.color }} />
{t(option.i18n_label)}
</div>
</CustomSelect.Option>
))}
</CustomSelect>
),
}))}
/>
)}
/>
</div>

View File

@ -9,7 +9,7 @@ import { observer } from "mobx-react";
import { START_OF_THE_WEEK_OPTIONS } from "@plane/constants";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { EStartOfTheWeek } from "@plane/types";
import { CustomSelect } from "@plane/ui";
import { SelectionDropdown } from "@/components/common/selection-dropdown";
// components
import { SettingsControlItem } from "@/components/settings/control-item";
// hooks
@ -38,23 +38,17 @@ export const StartOfWeekPreference = observer(function StartOfWeekPreference(pro
title={props.option.title}
description={props.option.description}
control={
<CustomSelect
value={userProfile.start_of_the_week}
label={getStartOfWeekLabel(userProfile.start_of_the_week)}
onChange={handleStartOfWeekChange}
buttonClassName="border border-subtle-1"
input
maxHeight="lg"
<SelectionDropdown
placement="bottom-end"
>
<>
{START_OF_THE_WEEK_OPTIONS.map((day) => (
<CustomSelect.Option key={day.value} value={day.value}>
{day.label}
</CustomSelect.Option>
))}
</>
</CustomSelect>
menuButton={getStartOfWeekLabel(userProfile.start_of_the_week)}
menuButtonWrapperClassName="flex w-full items-center justify-between rounded-full border border-subtle-1 px-3 py-2 text-13"
options={START_OF_THE_WEEK_OPTIONS.map((day) => ({
key: `${day.value}`,
title: day.label,
isChecked: userProfile.start_of_the_week === day.value,
onClick: () => handleStartOfWeekChange(day.value),
}))}
/>
}
/>
);