Compare commits

...

8 Commits

4 changed files with 542 additions and 58 deletions

View File

@@ -2,17 +2,37 @@ import ObjectID from "../../../Types/ObjectID";
import React, {
FunctionComponent,
ReactElement,
ReactNode,
useLayoutEffect,
useRef,
useState,
} from "react";
import Select, { ControlProps, GroupBase, OptionProps } from "react-select";
import Select, {
ControlProps,
DropdownIndicatorProps,
FormatOptionLabelMeta,
GroupBase,
OptionProps,
components as selectComponents,
} from "react-select";
import Pill, { PillSize } from "../Pill/Pill";
import { Black } from "../../../Types/BrandColors";
import Color from "../../../Types/Color";
import IconProp from "../../../Types/Icon/IconProp";
export type DropdownValue = string | number | boolean;
export interface DropdownOptionLabel {
id?: string | null;
name?: string | null;
color?: string | { toString: () => string } | null;
slug?: string | null;
}
export interface DropdownOption {
value: DropdownValue;
label: string;
labels?: Array<DropdownOptionLabel> | undefined;
}
export interface ComponentProps {
@@ -37,6 +57,8 @@ export interface ComponentProps {
const Dropdown: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
type LabelBadgeSize = "menu" | "value";
type GetDropdownOptionFromValueFunctionProps =
| undefined
| DropdownValue
@@ -111,33 +133,188 @@ const Dropdown: FunctionComponent<ComponentProps> = (
const firstUpdate: React.MutableRefObject<boolean> = useRef(true);
const resolveLabelColor: (label: DropdownOptionLabel) => Color = (
label: DropdownOptionLabel,
): Color => {
const labelColor: DropdownOptionLabel["color"] = label.color;
if (!labelColor) {
return Black;
}
if (labelColor instanceof Color) {
return labelColor;
}
if (typeof labelColor === "string") {
try {
return Color.fromString(labelColor);
} catch (_) {
return Black;
}
}
if (typeof (labelColor as any)?.toString === "function") {
try {
return Color.fromString((labelColor as any).toString());
} catch (_) {
return Black;
}
}
return Black;
};
const renderLabelBadges = (
optionLabels?: Array<DropdownOptionLabel>,
config?: { maxVisible?: number; size?: LabelBadgeSize },
): ReactElement | null => {
if (!optionLabels || optionLabels.length === 0) {
return null;
}
const sanitizedLabels: Array<DropdownOptionLabel> = optionLabels.filter(
(label: DropdownOptionLabel | null | undefined) => {
return Boolean(label && (label.name || label.slug));
},
) as Array<DropdownOptionLabel>;
if (sanitizedLabels.length === 0) {
return null;
}
const maxVisible: number = config?.maxVisible || 3;
const size: LabelBadgeSize = config?.size || "menu";
const visibleLabels: Array<DropdownOptionLabel> = sanitizedLabels.slice(
0,
Math.max(0, maxVisible),
);
const hiddenCount: number = sanitizedLabels.length - visibleLabels.length;
const containerClassName: string =
size === "menu"
? "flex flex-wrap items-center gap-2"
: "flex flex-wrap items-center gap-1.5";
const moreLabelClassName: string =
size === "menu"
? "inline-flex items-center rounded-full bg-slate-50 px-2 py-0.5 text-[10px] font-semibold text-slate-500 ring-1 ring-inset ring-slate-200"
: "inline-flex items-center rounded-full bg-slate-50 px-1.5 py-0.5 text-[9px] font-semibold text-slate-500 ring-1 ring-inset ring-slate-200";
const pillSize: PillSize = size === "menu" ? PillSize.Normal : PillSize.Small;
const pillStyle =
size === "menu"
? {
maxWidth: "220px",
display: "inline-flex",
alignItems: "center",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
}
: {
maxWidth: "180px",
display: "inline-flex",
alignItems: "center",
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis",
};
return (
<div className={containerClassName}>
{visibleLabels.map((label: DropdownOptionLabel, index: number) => {
const labelId: string =
label.id?.toString() ||
label.slug?.toString() ||
label.name?.toString() ||
`label-${index}`;
return (
<Pill
key={labelId}
color={resolveLabelColor(label)}
text={label.name || label.slug || "Label"}
size={pillSize}
style={pillStyle}
icon={IconProp.Label}
/>
);
})}
{hiddenCount > 0 && (
<span className={`${moreLabelClassName} transition-colors duration-150 ease-out hover:bg-slate-100`}>
+{hiddenCount}
</span>
)}
</div>
);
};
const formatOptionLabel = (
option: DropdownOption,
meta: FormatOptionLabelMeta<DropdownOption>,
): ReactNode => {
const labelBadges: ReactElement | null = renderLabelBadges(option.labels, {
maxVisible: meta.context === "value" ? 2 : 3,
size: meta.context === "value" ? "value" : "menu",
});
if (meta.context === "value") {
if (props.isMultiSelect) {
return (
<span className="truncate text-sm font-semibold text-slate-700">
{option.label}
</span>
);
}
return (
<div className="flex flex-col gap-1 truncate">
<span className="truncate text-sm font-semibold text-slate-900">
{option.label}
</span>
{labelBadges}
</div>
);
}
return (
<div className="flex flex-col gap-1 py-1">
<span className="text-sm font-semibold text-slate-900">
{option.label}
</span>
{labelBadges}
</div>
);
};
useLayoutEffect(() => {
if (firstUpdate.current && props.initialValue) {
firstUpdate.current = false;
return;
}
const value: DropdownOption | Array<DropdownOption> | undefined =
const newValue: DropdownOption | Array<DropdownOption> | undefined =
getDropdownOptionFromValue(
props.value === null ? undefined : props.value,
);
setValue(value);
setValue(newValue);
}, [props.value]);
const containerClassName: string =
props.className || "group relative mb-1 mt-2 w-full overflow-visible";
return (
<div
id={props.id}
className={`${
props.className ||
"relative mt-2 mb-1 rounded-md w-full overflow-visible"
}`}
className={containerClassName}
onClick={() => {
props.onClick?.();
props.onFocus?.();
}}
>
<Select
formatOptionLabel={formatOptionLabel}
onBlur={() => {
props.onBlur?.();
}}
@@ -152,23 +329,92 @@ const Dropdown: FunctionComponent<ComponentProps> = (
control: (
state: ControlProps<any, boolean, GroupBase<any>>,
): string => {
return state.isFocused
? "!border-indigo-500"
: "border-Gray500-300";
const baseClasses: string =
"!min-h-[44px] !rounded-xl !border transition-all duration-150 ease-out !shadow-sm bg-white";
const errorClasses: string = props.error
? "!border-red-400 !ring-2 !ring-red-100"
: "";
const focusClasses: string = state.isFocused
? "!border-indigo-400 !ring-2 !ring-indigo-200/60"
: "hover:!border-slate-300 !border-slate-200";
return `${baseClasses} ${errorClasses} ${focusClasses}`.trim();
},
option: (
state: OptionProps<any, boolean, GroupBase<any>>,
): string => {
const baseClasses: string =
"rounded-lg px-3 py-2 text-sm transition-all duration-150 ease-out flex flex-col gap-1";
if (state.isDisabled) {
return "bg-gray-100";
return `${baseClasses} cursor-not-allowed bg-slate-50 text-slate-400`;
}
if (state.isSelected) {
return "!bg-indigo-500";
return `${baseClasses} !bg-indigo-100 !text-indigo-700 shadow-sm ring-1 ring-indigo-200/70`;
}
if (state.isFocused) {
return "!bg-indigo-100";
return `${baseClasses} !bg-indigo-50/80 !text-indigo-700 shadow-sm ring-1 ring-indigo-100`;
}
return "";
return `${baseClasses} text-slate-700 hover:bg-slate-50 hover:text-slate-900`;
},
menu: (): string => {
return "!mt-2 !rounded-2xl !border !border-slate-100 !bg-white !shadow-2xl overflow-hidden";
},
menuList: (): string => {
return "!p-2 flex flex-col gap-1";
},
valueContainer: (): string => {
return "!px-3 !py-2 gap-2";
},
placeholder: (): string => {
return "!text-sm !text-slate-400";
},
singleValue: (): string => {
return "!text-sm !font-semibold !text-slate-900 flex flex-col";
},
multiValue: (): string => {
return "!rounded-full !border !border-transparent !bg-slate-100/80 !px-1.5 !py-0.5 items-center gap-1 transition-colors duration-150 hover:!bg-slate-100 hover:!border-slate-200/70";
},
multiValueLabel: (): string => {
return "!text-[11px] !font-medium !text-slate-600 flex items-center gap-1";
},
multiValueRemove: (): string => {
return "!text-slate-400 hover:!bg-red-50 hover:!text-red-500/80 rounded-full transition-colors";
},
dropdownIndicator: (): string => {
return "!px-3 text-slate-400 transition-all duration-150";
},
input: (): string => {
return "!text-sm !text-slate-900";
},
noOptionsMessage: (): string => {
return "!py-2 !text-sm !text-slate-500";
},
}}
components={{
IndicatorSeparator: () => null,
DropdownIndicator: (
indicatorProps: DropdownIndicatorProps<DropdownOption, boolean>,
): ReactElement => {
return (
<selectComponents.DropdownIndicator {...indicatorProps}>
<svg
aria-hidden="true"
className={`h-4 w-4 transition-transform duration-200 ease-out ${
indicatorProps.selectProps.menuIsOpen ? "rotate-180" : ""
}`}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
viewBox="0 0 24 24"
>
<path
d="M6 9l6 6 6-6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</selectComponents.DropdownIndicator>
);
},
}}
isClearable={true}
@@ -178,19 +424,19 @@ const Dropdown: FunctionComponent<ComponentProps> = (
onChange={(option: any | null) => {
if (option) {
if (props.isMultiSelect) {
const value: Array<DropdownOption> =
const selectedOptions: Array<DropdownOption> =
option as Array<DropdownOption>;
setValue(value);
setValue(selectedOptions);
props.onChange?.(
value.map((i: DropdownOption) => {
return i.value;
selectedOptions.map((item: DropdownOption) => {
return item.value;
}),
);
} else {
const value: DropdownOption = option as DropdownOption;
setValue(value);
props.onChange?.(value.value);
const selectedOption: DropdownOption = option as DropdownOption;
setValue(selectedOption);
props.onChange?.(selectedOption.value);
}
}

View File

@@ -13,6 +13,10 @@ import {
CheckboxCategory,
} from "../CategoryCheckbox/CategoryCheckboxTypes";
import Loader, { LoaderType } from "../Loader/Loader";
import {
DropdownOption,
DropdownOptionLabel,
} from "../Dropdown/Dropdown";
import Pill, { PillSize } from "../Pill/Pill";
import { FormErrors, FormProps, FormSummaryConfig } from "./BasicForm";
import BasicModelForm from "./BasicModelForm";
@@ -407,22 +411,22 @@ const ModelForm: <TBaseModel extends BaseModel>(
for (const field of fields) {
if (field.dropdownModal && field.dropdownModal.type) {
const tempModel: BaseModel = new field.dropdownModal.type();
const select: any = {
const select: JSONObject = {
[field.dropdownModal.labelField]: true,
[field.dropdownModal.valueField]: true,
} as any;
} as JSONObject;
let hasAccessControlColumn: boolean = false;
const labelsColumn: string | null = tempModel.getAccessControlColumn();
// also select labels, so they can select resources by labels. This is useful for resources like monitors, etc.
if (tempModel.getAccessControlColumn()) {
select[tempModel.getAccessControlColumn()!] = {
if (labelsColumn) {
select[labelsColumn] = {
_id: true,
name: true,
color: true,
} as any;
hasAccessControlColumn = true;
} as JSONObject;
}
const listResult: ListResult<BaseModel> =
@@ -431,7 +435,7 @@ const ModelForm: <TBaseModel extends BaseModel>(
query: {},
limit: LIMIT_PER_PROJECT,
skip: 0,
select: select,
select: select as any,
sort: {},
});
@@ -441,14 +445,88 @@ const ModelForm: <TBaseModel extends BaseModel>(
throw new BadDataException("Dropdown Modal value mot found");
}
return {
label: (item as any)[
field.dropdownModal?.labelField
].toString(),
value: (item as any)[
field.dropdownModal?.valueField
].toString(),
const optionLabels: Array<DropdownOptionLabel> = [];
const labelColumnsToRead: Array<string> = [];
if (labelsColumn) {
labelColumnsToRead.push(labelsColumn);
}
if (
hasAccessControlColumn &&
accessControlColumn &&
!labelColumnsToRead.includes(accessControlColumn)
) {
labelColumnsToRead.push(accessControlColumn);
}
for (const columnName of labelColumnsToRead) {
const columnValue: Array<GenericObject> =
((item as any)[columnName] as Array<GenericObject>) || [];
for (const label of columnValue) {
if (!label) {
continue;
}
const rawName: unknown =
(label as any).name ??
(label as any).title ??
(label as any).slug ??
null;
const labelName: string = rawName
? rawName.toString().trim()
: "";
if (!labelName) {
continue;
}
const labelId: string =
(label as any)._id?.toString() ||
(label as any).id?.toString() ||
(label as any).slug?.toString() ||
labelName;
const exists: boolean = optionLabels.some(
(existing: DropdownOptionLabel) => {
return (
existing.id === labelId && existing.name === labelName
);
},
);
if (!exists) {
optionLabels.push({
id: labelId,
name: labelName,
color: (label as any).color || null,
slug: (label as any).slug?.toString() || null,
});
}
}
}
const dropdownLabelValue: string =
(item as any)[field.dropdownModal.labelField]?.toString() ||
"";
const dropdownValueValue: string =
(item as any)[field.dropdownModal.valueField]?.toString() ||
"";
const dropdownOption: DropdownOption = {
label: dropdownLabelValue,
value: dropdownValueValue,
};
if (optionLabels.length > 0) {
dropdownOption.labels = optionLabels;
}
return dropdownOption;
});
if (hasAccessControlColumn) {

View File

@@ -3,12 +3,14 @@ import Color from "../../../Types/Color";
import React, { CSSProperties, FunctionComponent, ReactElement } from "react";
import Tooltip from "../Tooltip/Tooltip";
import { GetReactElementFunction } from "../../Types/FunctionTypes";
import Icon, { IconType, SizeProp, ThickProp } from "../Icon/Icon";
import IconProp from "../../../Types/Icon/IconProp";
export enum PillSize {
Small = "10px",
Normal = "13px",
Large = "15px",
ExtraLarge = "18px",
Small = "9px",
Normal = "12px",
Large = "14px",
ExtraLarge = "16px",
}
export interface ComponentProps {
@@ -18,6 +20,7 @@ export interface ComponentProps {
style?: CSSProperties;
isMinimal?: boolean | undefined;
tooltip?: string | undefined;
icon?: IconProp | IconType | undefined;
}
const Pill: FunctionComponent<ComponentProps> = (
@@ -43,28 +46,183 @@ const Pill: FunctionComponent<ComponentProps> = (
);
}
const resolvedSize: PillSize = props.size ? props.size : PillSize.Normal;
const spacingBySize: Record<PillSize, { px: string; py: string; gap: string }> = {
[PillSize.Small]: { px: "0.5rem", py: "0.22rem", gap: "0.28rem" },
[PillSize.Normal]: { px: "0.65rem", py: "0.35rem", gap: "0.3rem" },
[PillSize.Large]: { px: "0.85rem", py: "0.45rem", gap: "0.4rem" },
[PillSize.ExtraLarge]: { px: "0.95rem", py: "0.55rem", gap: "0.45rem" },
};
const spacing = spacingBySize[resolvedSize];
const iconLookups: Record<IconType, IconProp> = {
[IconType.Danger]: IconProp.Error,
[IconType.Success]: IconProp.CheckCircle,
[IconType.Info]: IconProp.Info,
[IconType.Warning]: IconProp.Alert,
};
const isIconTypeValue = (value: unknown): value is IconType => {
if (!value || typeof value !== "string") {
return false;
}
return (Object.values(IconType) as Array<string>).includes(value);
};
// Softly shifts a hex color towards white (positive) or black (negative) for hover/focus accents.
const adjustColor = (color: string, intensity: number): string => {
try {
const source: Color = Color.fromString(color);
const rgb = Color.colorToRgb(source);
const delta = Math.abs(intensity);
const adjustChannel = (channel: number, lighten: boolean): number => {
const boundary = lighten ? 255 : 0;
const offset = boundary - channel;
return Math.round(channel + offset * delta) as number;
};
const shouldLighten = intensity >= 0;
const updated = {
red: adjustChannel(rgb.red, shouldLighten),
green: adjustChannel(rgb.green, shouldLighten),
blue: adjustChannel(rgb.blue, shouldLighten),
};
return Color.rgbToColor(updated).toString();
} catch (error) {
return color;
}
};
const baseColor: string = props.style?.backgroundColor
? `${props.style.backgroundColor}`
: props.color
? props.color.toString()
: Black.toString();
const hexColorRegex: RegExp = /^#(?:[0-9a-fA-F]{3}){1,2}$/;
const isHexColor: boolean = hexColorRegex.test(baseColor);
const prefersDarkText: boolean = (() => {
try {
return Color.shouldUseDarkText(Color.fromString(baseColor));
} catch (error) {
return true;
}
})();
const contrastText: string = props.style?.color
? `${props.style.color}`
: prefersDarkText
? "#0f172a"
: "#f8fafc";
const hoverColor: string = isHexColor
? adjustColor(baseColor, prefersDarkText ? -0.12 : 0.14)
: baseColor;
const focusRingColor: string = isHexColor
? adjustColor(baseColor, prefersDarkText ? -0.25 : 0.25)
: prefersDarkText
? "rgba(15, 23, 42, 0.25)"
: "rgba(255, 255, 255, 0.35)";
const borderColor: string = prefersDarkText
? "rgba(15, 23, 42, 0.12)"
: "rgba(255, 255, 255, 0.24)";
const style: CSSProperties = {
fontSize: resolvedSize.toString(),
...props.style,
};
style.backgroundColor = baseColor;
style.color = contrastText;
(style as CSSProperties & Record<string, string>)["--pill-px"] = spacing.px;
(style as CSSProperties & Record<string, string>)["--pill-py"] = spacing.py;
(style as CSSProperties & Record<string, string>)["--pill-gap"] = spacing.gap;
(style as CSSProperties & Record<string, string>)["--pill-bg"] = baseColor;
(style as CSSProperties & Record<string, string>)["--pill-hover-bg"] = hoverColor;
(style as CSSProperties & Record<string, string>)["--pill-text"] = contrastText;
(style as CSSProperties & Record<string, string>)["--pill-ring"] = focusRingColor;
(style as CSSProperties & Record<string, string>)["--pill-border"] = borderColor;
const iconGlyph: IconProp | null = (() => {
if (!props.icon) {
return null;
}
if (isIconTypeValue(props.icon)) {
return iconLookups[props.icon];
}
return props.icon as IconProp;
})();
const iconSizeClass: string = (() => {
if (resolvedSize === PillSize.ExtraLarge) {
return "h-5 w-5";
}
if (resolvedSize === PillSize.Large) {
return "h-4 w-4";
}
if (resolvedSize === PillSize.Small) {
return "h-3 w-3";
}
return "h-3.5 w-3.5";
})();
const iconComponentSize: SizeProp = (() => {
if (resolvedSize === PillSize.ExtraLarge) {
return SizeProp.Large;
}
if (resolvedSize === PillSize.Large) {
return SizeProp.Small;
}
if (resolvedSize === PillSize.Small) {
return SizeProp.Smaller;
}
return SizeProp.Small;
})();
const iconClassNames: string = isIconTypeValue(props.icon)
? iconSizeClass
: `text-[color:var(--pill-text)] ${iconSizeClass}`;
const getPillElement: GetReactElementFunction = (): ReactElement => {
const iconElement: ReactElement | null = iconGlyph
? (
<span className="flex items-center justify-center">
<Icon
icon={iconGlyph}
type={isIconTypeValue(props.icon) ? props.icon : undefined}
size={iconComponentSize}
thick={ThickProp.Thick}
className={iconClassNames}
data-testid="pill-icon"
/>
</span>
)
: null;
return (
<span
data-testid="pill"
className="rounded-full p-1 pl-3 pr-3"
style={{
// https://stackoverflow.com/questions/3942878/how-to-decide-font-color-in-white-or-black-depending-on-background-color
color:
props.style?.color || Color.shouldUseDarkText(props.color || Black)
? "#000000"
: "#ffffff",
backgroundColor:
props.style?.backgroundColor || props.color
? props.color.toString()
: Black.toString(),
fontSize: props.size ? props.size.toString() : PillSize.Normal,
...props.style,
}}
className="inline-flex items-center gap-[var(--pill-gap)] rounded-full border border-[color:var(--pill-border)] bg-[color:var(--pill-bg)] px-[var(--pill-px)] py-[var(--pill-py)] font-semibold leading-none tracking-tight text-[color:var(--pill-text)] shadow-sm transition-colors duration-200 ease-out hover:bg-[color:var(--pill-hover-bg)] hover:shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[color:var(--pill-ring)] focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:focus-visible:ring-offset-slate-950 active:shadow-sm"
style={style}
>
{" "}
{props.text}{" "}
{iconElement}
{props.text}
</span>
);
};

View File

@@ -2,6 +2,7 @@ import { Black } from "Common/Types/BrandColors";
import Pill from "Common/UI/Components/Pill/Pill";
import Label from "Common/Models/DatabaseModels/Label";
import React, { FunctionComponent, ReactElement } from "react";
import IconProp from "Common/Types/Icon/IconProp";
export interface ComponentProps {
label: Label;
@@ -12,6 +13,7 @@ const LabelElement: FunctionComponent<ComponentProps> = (
): ReactElement => {
return (
<Pill
icon={IconProp.Label}
color={props.label.color || Black}
text={props.label.name || ""}
style={{