feat: Add IconPicker component and integrate icon selection in IncidentRoles

This commit is contained in:
Nawaz Dhandala
2026-01-30 11:52:21 +00:00
parent 8bb3a5b7ac
commit a8af991a80
8 changed files with 300 additions and 7 deletions

View File

@@ -410,6 +410,43 @@ export default class IncidentRole extends BaseModel {
})
public color?: Color = undefined;
@ColumnAccessControl({
create: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.CreateIncidentRole,
],
read: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.ReadIncidentRole,
Permission.ReadAllProjectResources,
],
update: [
Permission.ProjectOwner,
Permission.ProjectAdmin,
Permission.ProjectMember,
Permission.EditIncidentRole,
],
})
@TableColumn({
title: "Icon",
required: false,
unique: false,
type: TableColumnType.ShortText,
canReadOnRelationQuery: true,
description: "Icon for this incident role (e.g., User, Shield, etc.)",
})
@Column({
type: ColumnType.ShortText,
length: ColumnLength.ShortText,
unique: false,
nullable: true,
})
public icon?: string = undefined;
@ColumnAccessControl({
create: [],
read: [

View File

@@ -18,6 +18,7 @@ import RadioButtons from "../../RadioButtons/GroupRadioButtons";
import TextArea from "../../TextArea/TextArea";
import Toggle from "../../Toggle/Toggle";
import ColorPicker from "../Fields/ColorPicker";
import IconPicker from "../Fields/IconPicker";
import FieldLabelElement from "../Fields/FieldLabel";
import Field, { FormFieldStyleType } from "../Types/Field";
import FormFieldSchemaType from "../Types/FormFieldSchemaType";
@@ -25,6 +26,7 @@ import FormValues from "../Types/FormValues";
import FileModel from "../../../../Models/DatabaseModels/DatabaseBaseModel/FileModel";
import CodeType from "../../../../Types/Code/CodeType";
import Color from "../../../../Types/Color";
import IconProp from "../../../../Types/Icon/IconProp";
import OneUptimeDate from "../../../../Types/Date";
import BadDataException from "../../../../Types/Exception/BadDataException";
import MimeType from "../../../../Types/File/MimeType";
@@ -358,6 +360,26 @@ const FormField: <T extends GenericObject>(
/>
)}
{props.field.fieldType === FormFieldSchemaType.Icon && (
<IconPicker
error={props.touched && props.error ? props.error : undefined}
dataTestId={props.field.dataTestId}
onChange={async (value: IconProp | null) => {
onChange(value);
props.setFieldValue(props.fieldName, value);
props.setFieldTouched(props.fieldName, true);
}}
tabIndex={index}
placeholder={props.field.placeholder || ""}
initialValue={
props.currentValues &&
(props.currentValues as any)[props.fieldName]
? (props.currentValues as any)[props.fieldName]
: undefined
}
/>
)}
{(props.field.fieldType === FormFieldSchemaType.Dropdown ||
props.field.fieldType ===
FormFieldSchemaType.MultiSelectDropdown) && (

View File

@@ -0,0 +1,185 @@
import useComponentOutsideClick from "../../../Types/UseComponentOutsideClick";
import Icon, { SizeProp, ThickProp } from "../../Icon/Icon";
import Input, { InputType } from "../../Input/Input";
import IconProp from "../../../../Types/Icon/IconProp";
import React, {
FunctionComponent,
ReactElement,
useEffect,
useState,
} from "react";
export interface ComponentProps {
onChange: (value: IconProp | null) => void;
initialValue?: undefined | IconProp;
placeholder: string;
onFocus?: (() => void) | undefined;
tabIndex?: number | undefined;
value?: string | undefined;
readOnly?: boolean | undefined;
disabled?: boolean | undefined;
onBlur?: (() => void) | undefined;
dataTestId?: string | undefined;
onEnterPress?: (() => void) | undefined;
error?: string | undefined;
}
const IconPicker: FunctionComponent<ComponentProps> = (
props: ComponentProps,
): ReactElement => {
const [selectedIcon, setSelectedIcon] = useState<IconProp | null>(null);
const [searchQuery, setSearchQuery] = useState<string>("");
const { ref, isComponentVisible, setIsComponentVisible } =
useComponentOutsideClick(false);
const [isInitialValuesInitialized, setIsInitialValuesInitialized] =
useState<boolean>(false);
useEffect(() => {
if (props.initialValue && !isInitialValuesInitialized) {
setSelectedIcon(props.initialValue);
setIsInitialValuesInitialized(true);
}
}, [props.initialValue]);
type HandleChangeFunction = (icon: IconProp | null) => void;
const handleChange: HandleChangeFunction = (icon: IconProp | null): void => {
setSelectedIcon(icon);
props.onChange(icon);
setIsComponentVisible(false);
};
// Get all icons from IconProp enum
const allIcons: Array<IconProp> = Object.values(IconProp);
// Filter icons based on search query
const filteredIcons: Array<IconProp> = allIcons.filter((icon: IconProp) => {
return icon.toLowerCase().includes(searchQuery.toLowerCase());
});
return (
<div>
<div className="flex block w-full rounded-md border border-gray-300 bg-white py-2 pl-3 pr-3 text-sm placeholder-gray-500 focus:border-indigo-500 focus:text-gray-900 focus:placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm">
<div
onClick={() => {
if (!props.readOnly && !props.disabled) {
setIsComponentVisible(!isComponentVisible);
}
}}
className="flex items-center justify-center h-5 w-5 cursor-pointer"
>
{selectedIcon ? (
<Icon
icon={selectedIcon}
size={SizeProp.Regular}
thick={ThickProp.Normal}
className="text-gray-600"
/>
) : (
<div className="h-5 w-5 border border-dashed border-gray-300 rounded"></div>
)}
</div>
<Input
onClick={() => {
if (!props.readOnly && !props.disabled) {
setIsComponentVisible(!isComponentVisible);
}
}}
disabled={props.disabled}
dataTestId={props.dataTestId}
onBlur={props.onBlur}
onEnterPress={props.onEnterPress}
className="border-none focus:outline-none w-full pl-2 text-gray-500 cursor-pointer"
outerDivClassName='className="border-none focus:outline-none w-full pl-2 text-gray-500 cursor-pointer"'
placeholder={props.placeholder}
value={selectedIcon || props.value || ""}
readOnly={true}
type={InputType.TEXT}
tabIndex={props.tabIndex}
onChange={() => {}}
onFocus={props.onFocus || undefined}
/>
{selectedIcon && !props.disabled && (
<Icon
icon={IconProp.Close}
className="text-gray-400 h-5 w-5 cursor-pointer hover:text-gray-600"
onClick={() => {
setSelectedIcon(null);
props.onChange(null);
}}
/>
)}
{isComponentVisible ? (
<div
ref={ref}
className="absolute z-50 mt-8 bg-white border border-gray-200 rounded-lg shadow-lg p-3"
style={{
width: "320px",
maxHeight: "400px",
}}
>
{/* Search input */}
<div className="mb-3">
<Input
type={InputType.TEXT}
placeholder="Search icons..."
value={searchQuery}
onChange={(value: string) => {
setSearchQuery(value);
}}
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
/>
</div>
{/* Icons grid */}
<div
className="grid grid-cols-6 gap-2 overflow-y-auto"
style={{ maxHeight: "300px" }}
>
{filteredIcons.map((icon: IconProp) => {
return (
<div
key={icon}
onClick={() => {
handleChange(icon);
}}
className={`flex items-center justify-center p-2 rounded cursor-pointer hover:bg-gray-100 ${
selectedIcon === icon
? "bg-indigo-100 ring-2 ring-indigo-500"
: ""
}`}
title={icon}
>
<Icon
icon={icon}
size={SizeProp.Regular}
thick={ThickProp.Normal}
className="text-gray-600"
/>
</div>
);
})}
</div>
{filteredIcons.length === 0 && (
<div className="text-center text-gray-500 py-4">
No icons found
</div>
)}
</div>
) : (
<></>
)}
</div>
{props.error && (
<p data-testid="error-message" className="mt-1 text-sm text-red-400">
{props.error}
</p>
)}
</div>
);
};
export default IconPicker;

View File

@@ -37,6 +37,7 @@ enum FormFieldSchemaType {
CategoryCheckbox = "CategoryCheckbox",
Dictionary = "Dictionary",
CardSelect = "CardSelect",
Icon = "Icon",
}
export default FormFieldSchemaType;

View File

@@ -76,6 +76,8 @@ export default class FormFieldSchemaTypeUtil {
return FieldType.Boolean;
case FormFieldSchemaType.CategoryCheckbox:
return FieldType.Boolean;
case FormFieldSchemaType.Icon:
return FieldType.Icon;
default:
return FieldType.Text;

View File

@@ -1,11 +1,14 @@
import Color from "../../../Types/Color";
import { Black } from "../../../Types/BrandColors";
import IconProp from "../../../Types/Icon/IconProp";
import Icon, { SizeProp, ThickProp } from "../Icon/Icon";
import Tooltip from "../Tooltip/Tooltip";
import React, { FunctionComponent, ReactElement } from "react";
export interface ComponentProps {
name: string;
color?: Color | undefined;
icon?: IconProp | string | undefined;
description?: string | undefined;
}
@@ -14,15 +17,46 @@ const RoleLabel: FunctionComponent<ComponentProps> = (
): ReactElement => {
const resolvedColor: Color = props.color || Black;
// Convert string icon to IconProp if needed
const resolvedIcon: IconProp | undefined = (() => {
if (!props.icon) {
return undefined;
}
if (typeof props.icon === "string") {
// Check if it's a valid IconProp value
if (Object.values(IconProp).includes(props.icon as IconProp)) {
return props.icon as IconProp;
}
return undefined;
}
return props.icon;
})();
const element: ReactElement = (
<div className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full flex-shrink-0"
style={{
backgroundColor: resolvedColor.toString(),
}}
aria-hidden="true"
/>
{resolvedIcon ? (
<div
className="flex items-center justify-center h-6 w-6 rounded-md flex-shrink-0"
style={{
backgroundColor: resolvedColor.toString(),
}}
>
<Icon
icon={resolvedIcon}
size={SizeProp.Smaller}
thick={ThickProp.Normal}
color={Color.shouldUseDarkText(resolvedColor) ? Black : new Color("#ffffff")}
/>
</div>
) : (
<div
className="h-3 w-3 rounded-full flex-shrink-0"
style={{
backgroundColor: resolvedColor.toString(),
}}
aria-hidden="true"
/>
)}
<span className="text-sm font-medium text-gray-900">{props.name}</span>
</div>
);

View File

@@ -37,6 +37,7 @@ enum FieldType {
ArrayOfText = "ArrayOfText",
Code = "Code",
InlineCode = "InlineCode",
Icon = "Icon",
}
export default FieldType;

View File

@@ -53,6 +53,15 @@ const IncidentRoles: FunctionComponent<
required: false,
placeholder: "Primary decision maker during an incident.",
},
{
field: {
icon: true,
},
title: "Role Icon",
fieldType: FormFieldSchemaType.Icon,
required: false,
placeholder: "Select an icon for this role",
},
{
field: {
color: true,
@@ -66,6 +75,7 @@ const IncidentRoles: FunctionComponent<
showRefreshButton={true}
selectMoreFields={{
color: true,
icon: true,
}}
showViewIdButton={true}
filters={[
@@ -96,6 +106,7 @@ const IncidentRoles: FunctionComponent<
<RoleLabel
name={item.name || ""}
color={item.color || undefined}
icon={item.icon || undefined}
description={item.description || undefined}
/>
);