* refactor: migrate web icons to @makeplane/propel Replace confirmed icon symbols across apps/web with their audited @makeplane/propel/icons targets. Unresolved symbols and the State, Priority and Brand icons stay on their existing imports. Lucide size props become explicit width/height, including the two relation icons and the inbox status icon that forward a size variable. Two aliased imports resolve to targets the file already imported, so the aliases are dropped rather than importing the same symbol twice. Widen the member dropdown chain and the AI menu from LucideIcon to ComponentType<SVGProps<SVGSVGElement>> so both Lucide and Propel icons satisfy them; both only ever render the icon with a className. CycleGroupIcon is deliberately not migrated: it dispatches a different glyph and color per cycle status, which CyclesOutline cannot express. apps/web already declared @makeplane/propel, so no dependency change was needed. * fix: address icon migration review on web Restore the six user-facing strings the identifier rename overwrote. The migration replaced every occurrence of a source symbol name, including ones inside string literals and JSX text, so the GitHub empty state read "StarOutline us on GitHub", the exporter column key, header and button label became "DownloadOutline", the project card action title became "SettingsOutline" and a tooltip became "PinOutline". Drop 122 strokeWidth props and 10 text-2 classes. Propel glyphs paint with fill="currentColor", so strokeWidth never changed weight, and text-2 was a bad conversion of stroke-2, which is a stroke width rather than a colour and has no meaning on a fill path. Use StarFilled for the two favorited sidebar stars. Lucide's Star with fill-yellow-500 was a solid star, and StarOutline is a ring path, so filling it does not reproduce that shape.
299 lines
11 KiB
TypeScript
299 lines
11 KiB
TypeScript
/**
|
|
* 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 { useRef, useState } from "react";
|
|
import { observer } from "mobx-react";
|
|
import { useParams } from "next/navigation";
|
|
import { usePopper } from "react-popper";
|
|
import { Combobox } from "@headlessui/react";
|
|
// plane imports
|
|
import { useTranslation } from "@plane/i18n";
|
|
import { ChevronDownOutline, EstimateOutline, SearchOutline, TickOutline } from "@makeplane/propel/icons";
|
|
import { EEstimateSystem } from "@plane/types";
|
|
import { ComboDropDown } from "@plane/ui";
|
|
import { convertMinutesToHoursMinutesString, cn } from "@plane/utils";
|
|
// hooks
|
|
import { useProjectEstimates } from "@/hooks/store/estimates";
|
|
import { useEstimate } from "@/hooks/store/estimates/use-estimate";
|
|
import { useDropdown } from "@/hooks/use-dropdown";
|
|
// components
|
|
import { DropdownButton } from "./buttons";
|
|
import { BUTTON_VARIANTS_WITH_TEXT } from "./constants";
|
|
// types
|
|
import type { TDropdownProps } from "./types";
|
|
|
|
type Props = TDropdownProps & {
|
|
button?: ReactNode;
|
|
dropdownArrow?: boolean;
|
|
dropdownArrowClassName?: string;
|
|
onChange: (val: string | undefined) => void;
|
|
onClose?: () => void;
|
|
projectId: string | undefined;
|
|
value: string | undefined | null;
|
|
renderByDefault?: boolean;
|
|
};
|
|
|
|
type DropdownOptions =
|
|
| {
|
|
value: string | null;
|
|
query: string;
|
|
content: React.ReactNode;
|
|
}[]
|
|
| undefined;
|
|
|
|
export const EstimateDropdown = observer(function EstimateDropdown(props: Props) {
|
|
const {
|
|
button,
|
|
buttonClassName,
|
|
buttonContainerClassName,
|
|
buttonVariant,
|
|
className = "",
|
|
disabled = false,
|
|
dropdownArrow = false,
|
|
dropdownArrowClassName = "",
|
|
hideIcon = false,
|
|
onChange,
|
|
onClose,
|
|
placeholder = "",
|
|
placement,
|
|
projectId,
|
|
showTooltip = false,
|
|
tabIndex,
|
|
value,
|
|
renderByDefault = true,
|
|
} = props;
|
|
// i18n
|
|
const { t } = useTranslation();
|
|
// states
|
|
const [query, setQuery] = useState("");
|
|
const [isOpen, setIsOpen] = useState(false);
|
|
// refs
|
|
const dropdownRef = useRef<HTMLDivElement | null>(null);
|
|
const inputRef = useRef<HTMLInputElement | null>(null);
|
|
// popper-js refs
|
|
const [referenceElement, setReferenceElement] = useState<HTMLButtonElement | null>(null);
|
|
const [popperElement, setPopperElement] = useState<HTMLDivElement | null>(null);
|
|
// popper-js init
|
|
const { styles, attributes } = usePopper(referenceElement, popperElement, {
|
|
placement: placement ?? "bottom-start",
|
|
modifiers: [
|
|
{
|
|
name: "preventOverflow",
|
|
options: {
|
|
padding: 12,
|
|
},
|
|
},
|
|
],
|
|
});
|
|
// router
|
|
const { workspaceSlug } = useParams();
|
|
// store hooks
|
|
const { currentActiveEstimateIdByProjectId, getProjectEstimates, getEstimateById } = useProjectEstimates();
|
|
const { estimatePointIds, estimatePointById } = useEstimate(
|
|
projectId ? currentActiveEstimateIdByProjectId(projectId) : undefined
|
|
);
|
|
|
|
const currentActiveEstimateId = projectId ? currentActiveEstimateIdByProjectId(projectId) : undefined;
|
|
|
|
const currentActiveEstimate = currentActiveEstimateId ? getEstimateById(currentActiveEstimateId) : undefined;
|
|
|
|
const options: DropdownOptions = (estimatePointIds ?? [])
|
|
?.map((estimatePoint) => {
|
|
const currentEstimatePoint = estimatePointById(estimatePoint);
|
|
if (currentEstimatePoint)
|
|
return {
|
|
value: currentEstimatePoint.id,
|
|
query: `${currentEstimatePoint?.value}`,
|
|
content: (
|
|
<div className="flex items-center gap-2">
|
|
<EstimateOutline className="h-3 w-3 flex-shrink-0" />
|
|
<span className="flex-grow truncate">
|
|
{currentActiveEstimate?.type === EEstimateSystem.TIME
|
|
? convertMinutesToHoursMinutesString(Number(currentEstimatePoint.value))
|
|
: currentEstimatePoint.value}
|
|
</span>
|
|
</div>
|
|
),
|
|
};
|
|
else undefined;
|
|
})
|
|
.filter((estimatePointDropdownOption) => estimatePointDropdownOption != undefined) as DropdownOptions;
|
|
options?.unshift({
|
|
value: null,
|
|
query: t("project_settings.estimates.no_estimate"),
|
|
content: (
|
|
<div className="flex items-center gap-2">
|
|
<EstimateOutline className="h-3 w-3 flex-shrink-0" />
|
|
<span className="flex-grow truncate">{t("project_settings.estimates.no_estimate")}</span>
|
|
</div>
|
|
),
|
|
});
|
|
|
|
const filteredOptions =
|
|
query === "" ? options : options?.filter((o) => o.query.toLowerCase().includes(query.toLowerCase()));
|
|
|
|
const selectedEstimate = value && estimatePointById ? estimatePointById(value) : undefined;
|
|
|
|
const onOpen = async () => {
|
|
if (!currentActiveEstimateId && workspaceSlug && projectId)
|
|
await getProjectEstimates(workspaceSlug.toString(), projectId);
|
|
};
|
|
|
|
const { handleClose, handleKeyDown, handleOnClick, searchInputKeyDown } = useDropdown({
|
|
dropdownRef,
|
|
inputRef,
|
|
isOpen,
|
|
onClose,
|
|
onOpen,
|
|
query,
|
|
setIsOpen,
|
|
setQuery,
|
|
});
|
|
|
|
const dropdownOnChange = (val: string | undefined) => {
|
|
onChange(val);
|
|
handleClose();
|
|
};
|
|
|
|
const comboButton = button ? (
|
|
<button
|
|
ref={setReferenceElement}
|
|
type="button"
|
|
className={cn("clickable block h-full w-full outline-none", buttonContainerClassName)}
|
|
onClick={handleOnClick}
|
|
disabled={disabled}
|
|
>
|
|
{button}
|
|
</button>
|
|
) : (
|
|
<button
|
|
ref={setReferenceElement}
|
|
type="button"
|
|
className={cn(
|
|
"clickable block h-full max-w-full outline-none",
|
|
{
|
|
"cursor-not-allowed text-secondary": disabled,
|
|
"cursor-pointer": !disabled,
|
|
},
|
|
buttonContainerClassName
|
|
)}
|
|
onClick={handleOnClick}
|
|
disabled={disabled}
|
|
>
|
|
<DropdownButton
|
|
className={buttonClassName}
|
|
isActive={isOpen}
|
|
tooltipHeading={t("project_settings.estimates.label")}
|
|
tooltipContent={selectedEstimate ? selectedEstimate?.value : placeholder}
|
|
showTooltip={showTooltip}
|
|
variant={buttonVariant}
|
|
renderToolTipByDefault={renderByDefault}
|
|
>
|
|
{!hideIcon && <EstimateOutline className="h-3 w-3 flex-shrink-0" />}
|
|
{(selectedEstimate || placeholder) && BUTTON_VARIANTS_WITH_TEXT.includes(buttonVariant) && (
|
|
<span className="truncate">
|
|
{selectedEstimate ? (
|
|
currentActiveEstimate?.type === EEstimateSystem.TIME ? (
|
|
convertMinutesToHoursMinutesString(Number(selectedEstimate.value))
|
|
) : (
|
|
selectedEstimate.value
|
|
)
|
|
) : (
|
|
<span className="text-placeholder">{placeholder}</span>
|
|
)}
|
|
</span>
|
|
)}
|
|
{dropdownArrow && (
|
|
<ChevronDownOutline className={cn("h-2.5 w-2.5 flex-shrink-0", dropdownArrowClassName)} aria-hidden="true" />
|
|
)}
|
|
</DropdownButton>
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<ComboDropDown
|
|
as="div"
|
|
ref={dropdownRef}
|
|
tabIndex={tabIndex}
|
|
className={cn("h-full w-full", className)}
|
|
value={value}
|
|
onChange={dropdownOnChange}
|
|
disabled={disabled}
|
|
onKeyDown={handleKeyDown}
|
|
button={comboButton}
|
|
renderByDefault={renderByDefault}
|
|
>
|
|
{isOpen && (
|
|
<Combobox.Options as="ul" className="fixed z-10" static>
|
|
<div
|
|
className="my-1 w-48 rounded-sm border-[0.5px] border-strong bg-surface-1 px-2 py-2.5 text-11 shadow-raised-200 focus:outline-none"
|
|
ref={setPopperElement}
|
|
style={styles.popper}
|
|
{...attributes.popper}
|
|
>
|
|
<div className="flex items-center gap-1.5 rounded-sm border border-subtle bg-surface-2 px-2">
|
|
<SearchOutline className="h-3.5 w-3.5 text-placeholder" />
|
|
<Combobox.Input
|
|
as="input"
|
|
ref={inputRef}
|
|
className="w-full bg-transparent py-1 text-11 text-secondary placeholder:text-placeholder focus:outline-none"
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={t("common.search.placeholder")}
|
|
displayValue={(assigned: any) => assigned?.name}
|
|
onKeyDown={searchInputKeyDown}
|
|
/>
|
|
</div>
|
|
<div className="mt-2 max-h-48 space-y-1 overflow-y-scroll">
|
|
{currentActiveEstimateId === undefined ? (
|
|
<div
|
|
className={`flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 text-secondary select-none`}
|
|
>
|
|
{/* NOTE: This condition renders when estimates are not enabled for the project */}
|
|
<div className="flex flex-grow items-center gap-2">
|
|
<EstimateOutline className="h-3 w-3 flex-shrink-0" />
|
|
<span className="flex-grow truncate">{t("project_settings.estimates.no_estimate")}</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
{filteredOptions ? (
|
|
filteredOptions.length > 0 ? (
|
|
filteredOptions.map((option) => (
|
|
<Combobox.Option as="li" key={option.value} value={option.value}>
|
|
{({ active, selected }) => (
|
|
<div
|
|
className={cn(
|
|
"flex w-full cursor-pointer items-center justify-between gap-2 truncate rounded-sm px-1 py-1.5 select-none",
|
|
{
|
|
"bg-layer-transparent-hover": active,
|
|
"text-primary": selected,
|
|
"text-secondary": !selected,
|
|
}
|
|
)}
|
|
>
|
|
<span className="flex-grow truncate">{option.content}</span>
|
|
{selected && <TickOutline className="h-3.5 w-3.5 flex-shrink-0" />}
|
|
</div>
|
|
)}
|
|
</Combobox.Option>
|
|
))
|
|
) : (
|
|
<p className="px-1.5 py-1 text-placeholder italic">{t("common.search.no_matching_results")}</p>
|
|
)
|
|
) : (
|
|
<p className="px-1.5 py-1 text-placeholder italic">{t("common.loading")}</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Combobox.Options>
|
|
)}
|
|
</ComboDropDown>
|
|
);
|
|
});
|