feat: Add canAssignMultipleUsers field to IncidentRole and update related components for multi-user assignment

This commit is contained in:
Nawaz Dhandala
2026-01-30 19:55:13 +00:00
parent edf05944c1
commit 847c019aea
8 changed files with 366 additions and 240 deletions

View File

@@ -493,4 +493,38 @@ export default class IncidentRole extends BaseModel {
default: true,
})
public isDeleteable?: boolean = 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({
isDefaultValueColumn: true,
type: TableColumnType.Boolean,
title: "Can Assign Multiple Users",
description:
"Can multiple users be assigned to this role? If false, only one user can be assigned.",
})
@Column({
type: ColumnType.Boolean,
default: false,
})
public canAssignMultipleUsers?: boolean = undefined;
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class MigrationName1769802715014 implements MigrationInterface {
public name = "MigrationName1769802715014";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "IncidentRole" ADD "canAssignMultipleUsers" boolean NOT NULL DEFAULT false`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "IncidentRole" DROP COLUMN "canAssignMultipleUsers"`,
);
}
}

View File

@@ -247,6 +247,7 @@ import { MigrationName1769723982900 } from "./1769723982900-MigrationName";
import { MigrationName1769772215532 } from "./1769772215532-MigrationName";
import { MigrationName1769774527481 } from "./1769774527481-MigrationName";
import { MigrationName1769780297584 } from "./1769780297584-MigrationName";
import { MigrationName1769802715014 } from "./1769802715014-MigrationName";
export default [
InitialMigration,
@@ -498,4 +499,5 @@ export default [
MigrationName1769772215532,
MigrationName1769774527481,
MigrationName1769780297584,
MigrationName1769802715014,
];

View File

@@ -1006,6 +1006,7 @@ export class ProjectService extends DatabaseService<Model> {
observer.color = Gray500;
observer.roleIcon = IconProp.Activity;
observer.projectId = createdItem.id!;
observer.canAssignMultipleUsers = true;
observer = await IncidentRoleService.create({
data: observer,

View File

@@ -24,6 +24,7 @@ export interface MemberRole {
color: Color;
isPrimaryRole?: boolean;
icon?: IconProp;
canAssignMultipleUsers?: boolean;
}
export interface AssignedMember {
@@ -108,28 +109,39 @@ const MemberRoleAssignment: FunctionComponent<ComponentProps> = (
[props.onUnassignMember],
);
// Get member for a specific role (only one per role)
const getMemberForRole: (roleId: ObjectID) => AssignedMember | null =
// Get members for a specific role
const getMembersForRole: (roleId: ObjectID) => Array<AssignedMember> =
useCallback(
(roleId: ObjectID): AssignedMember | null => {
return (
props.assignedMembers.find((member: AssignedMember) => {
return member.roleId.toString() === roleId.toString();
}) || null
);
(roleId: ObjectID): Array<AssignedMember> => {
return props.assignedMembers.filter((member: AssignedMember) => {
return member.roleId.toString() === roleId.toString();
});
},
[props.assignedMembers],
);
const getUserDropdownOptions: () => Array<DropdownOption> =
useCallback((): Array<DropdownOption> => {
return props.availableUsers.map((user: AvailableUser) => {
return {
value: user.id.toString(),
label: user.name || user.email,
};
});
}, [props.availableUsers]);
// Get available users for a role (excluding already assigned users for that role)
const getAvailableUsersForRole: (roleId: ObjectID) => Array<DropdownOption> =
useCallback(
(roleId: ObjectID): Array<DropdownOption> => {
const assignedUserIds: Set<string> = new Set(
getMembersForRole(roleId).map((m: AssignedMember) => {
return m.userId.toString();
}),
);
return props.availableUsers
.filter((user: AvailableUser) => {
return !assignedUserIds.has(user.id.toString());
})
.map((user: AvailableUser) => {
return {
value: user.id.toString(),
label: user.name || user.email,
};
});
},
[props.availableUsers, getMembersForRole],
);
const cardTitle: string = props.title || "Team Members";
const cardDescription: string =
@@ -159,6 +171,65 @@ const MemberRoleAssignment: FunctionComponent<ComponentProps> = (
);
}
// Render a single member row
const renderMemberRow: (
member: AssignedMember,
isLastInGroup?: boolean,
) => ReactElement = (
member: AssignedMember,
isLastInGroup?: boolean,
): ReactElement => {
return (
<div
key={member.memberId.toString()}
className={`flex items-center justify-between py-2 ${!isLastInGroup ? "border-b border-gray-100" : ""}`}
>
<div className="flex items-center gap-3">
{member.userProfilePictureUrl ? (
<Image
imageUrl={Route.fromString(member.userProfilePictureUrl)}
alt={member.userName}
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center">
<span className="text-xs font-medium text-white">
{member.userName?.charAt(0)?.toUpperCase() ||
member.userEmail?.charAt(0)?.toUpperCase() ||
"?"}
</span>
</div>
)}
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{member.userName || member.userEmail}
</p>
{member.userName && member.userEmail && (
<p className="text-xs text-gray-500 truncate">
{member.userEmail}
</p>
)}
</div>
</div>
<button
type="button"
onClick={() => {
setShowConfirmDelete(member);
}}
disabled={isUnassigning?.toString() === member.memberId.toString()}
className="inline-flex items-center p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors disabled:opacity-50"
title="Remove"
>
{isUnassigning?.toString() === member.memberId.toString() ? (
<Icon icon={IconProp.Refresh} className="w-4 h-4 animate-spin" />
) : (
<Icon icon={IconProp.Close} className="w-4 h-4" />
)}
</button>
</div>
);
};
return (
<>
<Card
@@ -202,232 +273,180 @@ const MemberRoleAssignment: FunctionComponent<ComponentProps> = (
</p>
</div>
) : (
<div className="overflow-hidden border border-gray-200 rounded-lg">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th
scope="col"
className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider w-1/3"
>
Role
</th>
<th
scope="col"
className="px-4 py-3 text-left text-xs font-semibold text-gray-600 uppercase tracking-wider"
>
Assigned Member
</th>
<th
scope="col"
className="px-4 py-3 text-right text-xs font-semibold text-gray-600 uppercase tracking-wider w-24"
>
Action
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{props.roles.map((role: MemberRole) => {
const member: AssignedMember | null = getMemberForRole(
role.id,
);
const isDropdownActive: boolean =
activeRoleDropdown?.toString() === role.id.toString();
const availableUsers: Array<DropdownOption> =
getUserDropdownOptions();
<div className="space-y-4">
{props.roles.map((role: MemberRole) => {
const members: Array<AssignedMember> = getMembersForRole(
role.id,
);
const isDropdownActive: boolean =
activeRoleDropdown?.toString() === role.id.toString();
const availableUsers: Array<DropdownOption> =
getAvailableUsersForRole(role.id);
const canAddMore: boolean =
role.canAssignMultipleUsers || members.length === 0;
return (
<tr key={role.id.toString()} className="hover:bg-gray-50">
{/* Role Column */}
<td className="px-4 py-4">
<div className="flex items-center gap-3">
<div
className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
backgroundColor: role.color
? `${role.color.toString()}20`
: "#f3f4f6",
}}
>
<Icon
icon={role.icon || IconProp.User}
className="w-4 h-4"
style={{
color: role.color?.toString() || "#6b7280",
}}
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-900">
{role.name}
</span>
{role.isPrimaryRole && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
Primary
</span>
)}
</div>
</div>
</td>
{/* Member Column */}
<td className="px-4 py-4">
{isDropdownActive ? (
<div className="flex items-center gap-2">
<div className="flex-1 max-w-xs">
<Dropdown
options={availableUsers}
placeholder="Select member..."
onChange={(
value:
| DropdownValue
| Array<DropdownValue>
| null,
) => {
if (value && !Array.isArray(value)) {
const option:
| DropdownOption
| undefined = availableUsers.find(
(opt: DropdownOption) => {
return (
opt.value.toString() ===
value.toString()
);
},
);
setSelectedUserOption(option || null);
} else {
setSelectedUserOption(null);
}
}}
value={selectedUserOption || undefined}
/>
</div>
<button
type="button"
onClick={() => {
if (selectedUserOption) {
handleAssign(
new ObjectID(
selectedUserOption.value.toString(),
),
role.id,
);
}
}}
disabled={!selectedUserOption || isAssigning}
className="inline-flex items-center px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isAssigning ? (
<Icon
icon={IconProp.Refresh}
className="w-3.5 h-3.5 animate-spin"
/>
) : (
"Save"
)}
</button>
<button
type="button"
onClick={() => {
setActiveRoleDropdown(null);
setSelectedUserOption(null);
}}
className="inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
) : member ? (
<div className="flex items-center gap-3">
{/* Avatar */}
{member.userProfilePictureUrl ? (
<Image
imageUrl={Route.fromString(
member.userProfilePictureUrl,
)}
alt={member.userName}
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<div className="h-8 w-8 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center">
<span className="text-xs font-medium text-white">
{member.userName?.charAt(0)?.toUpperCase() ||
member.userEmail
?.charAt(0)
?.toUpperCase() ||
"?"}
</span>
</div>
)}
{/* User Info */}
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{member.userName || member.userEmail}
</p>
{member.userName && member.userEmail && (
<p className="text-xs text-gray-500 truncate">
{member.userEmail}
</p>
)}
</div>
</div>
) : (
<span className="text-sm text-gray-400 italic">
Not assigned
return (
<div
key={role.id.toString()}
className="border border-gray-200 rounded-lg overflow-hidden"
>
{/* Role Header */}
<div
className="px-4 py-3 flex items-center justify-between"
style={{
backgroundColor: role.color
? `${role.color.toString()}10`
: "#f9fafb",
}}
>
<div className="flex items-center gap-3">
<div
className="w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0"
style={{
backgroundColor: role.color
? `${role.color.toString()}25`
: "#e5e7eb",
}}
>
<Icon
icon={role.icon || IconProp.User}
className="w-4.5 h-4.5"
style={{
color: role.color?.toString() || "#6b7280",
}}
/>
</div>
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-gray-900">
{role.name}
</span>
)}
</td>
{role.isPrimaryRole && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-amber-100 text-amber-800">
Primary
</span>
)}
{role.canAssignMultipleUsers && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700">
Multiple
</span>
)}
</div>
<p className="text-xs text-gray-500 mt-0.5">
{members.length}{" "}
{members.length === 1 ? "member" : "members"}{" "}
assigned
</p>
</div>
</div>
{/* Action Column */}
<td className="px-4 py-4 text-right">
{!isDropdownActive && (
<>
{member ? (
<button
type="button"
onClick={() => {
setShowConfirmDelete(member);
}}
disabled={
isUnassigning?.toString() ===
member.memberId.toString()
}
className="inline-flex items-center px-2.5 py-1.5 text-xs font-medium text-red-600 bg-red-50 border border-red-200 rounded-md hover:bg-red-100 hover:border-red-300 transition-colors disabled:opacity-50"
>
{isUnassigning?.toString() ===
member.memberId.toString() ? (
<Icon
icon={IconProp.Refresh}
className="w-3.5 h-3.5 animate-spin"
/>
) : (
"Remove"
)}
</button>
) : availableUsers.length > 0 ? (
<button
type="button"
onClick={() => {
setActiveRoleDropdown(role.id);
}}
className="inline-flex items-center px-2.5 py-1.5 text-xs font-medium text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-md hover:bg-indigo-100 hover:border-indigo-300 transition-colors"
>
Assign
</button>
) : (
<span className="text-xs text-gray-400">
No users
</span>
)}
</>
)}
</td>
</tr>
);
})}
</tbody>
</table>
{/* Add Button */}
{!isDropdownActive &&
canAddMore &&
availableUsers.length > 0 && (
<button
type="button"
onClick={() => {
setActiveRoleDropdown(role.id);
}}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 hover:border-gray-400 transition-all shadow-sm"
>
<Icon icon={IconProp.Add} className="w-3.5 h-3.5" />
{members.length === 0 ? "Assign" : "Add"}
</button>
)}
</div>
{/* Dropdown for adding member */}
{isDropdownActive && (
<div className="px-4 py-3 bg-gray-50 border-b border-gray-200">
<div className="flex items-center gap-2">
<div className="flex-1 max-w-sm">
<Dropdown
options={availableUsers}
placeholder="Select member..."
onChange={(
value:
| DropdownValue
| Array<DropdownValue>
| null,
) => {
if (value && !Array.isArray(value)) {
const option: DropdownOption | undefined =
availableUsers.find(
(opt: DropdownOption) => {
return (
opt.value.toString() ===
value.toString()
);
},
);
setSelectedUserOption(option || null);
} else {
setSelectedUserOption(null);
}
}}
value={selectedUserOption || undefined}
/>
</div>
<button
type="button"
onClick={() => {
if (selectedUserOption) {
handleAssign(
new ObjectID(
selectedUserOption.value.toString(),
),
role.id,
);
}
}}
disabled={!selectedUserOption || isAssigning}
className="inline-flex items-center px-3 py-1.5 text-xs font-medium text-white bg-indigo-600 border border-transparent rounded-md hover:bg-indigo-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{isAssigning ? (
<Icon
icon={IconProp.Refresh}
className="w-3.5 h-3.5 animate-spin"
/>
) : (
"Save"
)}
</button>
<button
type="button"
onClick={() => {
setActiveRoleDropdown(null);
setSelectedUserOption(null);
}}
className="inline-flex items-center px-3 py-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
>
Cancel
</button>
</div>
</div>
)}
{/* Members List */}
<div className="px-4 py-2 bg-white">
{members.length === 0 ? (
<p className="text-sm text-gray-400 italic py-2">
Not assigned
</p>
) : (
members.map(
(member: AssignedMember, index: number) => {
return renderMemberRow(
member,
index === members.length - 1,
);
},
)
)}
</div>
</div>
);
})}
</div>
)}
</div>

View File

@@ -70,6 +70,7 @@ const IncidentMemberRoleAssignment: FunctionComponent<ComponentProps> = (
color: true,
isPrimaryRole: true,
roleIcon: true,
canAssignMultipleUsers: true,
},
sort: {
isPrimaryRole: SortOrder.Descending,
@@ -118,6 +119,7 @@ const IncidentMemberRoleAssignment: FunctionComponent<ComponentProps> = (
color: role.color || Color.fromString("#6b7280"),
isPrimaryRole: role.isPrimaryRole || false,
icon: role.roleIcon || undefined,
canAssignMultipleUsers: role.canAssignMultipleUsers || false,
};
});

View File

@@ -55,6 +55,7 @@ import LowercaseDomains from "./LowercaseDomains";
import AddAttributeKeysColumnToTelemetryTables from "./AddAttributeKeysColumnToTelemetryTables";
import AddDefaultIncidentRolesToExistingProjects from "./AddDefaultIncidentRolesToExistingProjects";
import AddDefaultIconsToIncidentRoles from "./AddDefaultIconsToIncidentRoles";
import UpdateObserverRoleToAllowMultipleUsers from "./UpdateObserverRoleToAllowMultipleUsers";
// This is the order in which the migrations will be run. Add new migrations to the end of the array.
@@ -114,6 +115,7 @@ const DataMigrations: Array<DataMigrationBase> = [
new AddAttributeKeysColumnToTelemetryTables(),
new AddDefaultIncidentRolesToExistingProjects(),
new AddDefaultIconsToIncidentRoles(),
new UpdateObserverRoleToAllowMultipleUsers(),
];
export default DataMigrations;

View File

@@ -0,0 +1,49 @@
import DataMigrationBase from "./DataMigrationBase";
import { LIMIT_INFINITY } from "Common/Types/Database/LimitMax";
import IncidentRoleService from "Common/Server/Services/IncidentRoleService";
import IncidentRole from "Common/Models/DatabaseModels/IncidentRole";
export default class UpdateObserverRoleToAllowMultipleUsers extends DataMigrationBase {
public constructor() {
super("UpdateObserverRoleToAllowMultipleUsers");
}
public override async migrate(): Promise<void> {
// Get all Observer roles across all projects
const observerRoles: Array<IncidentRole> = await IncidentRoleService.findBy(
{
query: {
name: "Observer",
},
select: {
_id: true,
canAssignMultipleUsers: true,
},
skip: 0,
limit: LIMIT_INFINITY,
props: {
isRoot: true,
},
},
);
for (const role of observerRoles) {
// Update the role to allow multiple users
if (!role.canAssignMultipleUsers) {
await IncidentRoleService.updateOneById({
id: role.id!,
data: {
canAssignMultipleUsers: true,
},
props: {
isRoot: true,
},
});
}
}
}
public override async rollback(): Promise<void> {
return;
}
}