mirror of
https://github.com/OneUptime/oneuptime.git
synced 2026-04-06 00:32:12 +02:00
feat: Add IconPicker component and integrate icon selection in IncidentRoles
This commit is contained in:
@@ -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: [
|
||||
|
||||
@@ -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) && (
|
||||
|
||||
185
Common/UI/Components/Forms/Fields/IconPicker.tsx
Normal file
185
Common/UI/Components/Forms/Fields/IconPicker.tsx
Normal 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;
|
||||
@@ -37,6 +37,7 @@ enum FormFieldSchemaType {
|
||||
CategoryCheckbox = "CategoryCheckbox",
|
||||
Dictionary = "Dictionary",
|
||||
CardSelect = "CardSelect",
|
||||
Icon = "Icon",
|
||||
}
|
||||
|
||||
export default FormFieldSchemaType;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -37,6 +37,7 @@ enum FieldType {
|
||||
ArrayOfText = "ArrayOfText",
|
||||
Code = "Code",
|
||||
InlineCode = "InlineCode",
|
||||
Icon = "Icon",
|
||||
}
|
||||
|
||||
export default FieldType;
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user