@oneuptime/common 10.4.14 → 10.4.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.ts +49 -0
  2. package/Models/AnalyticsModels/AuditLog.ts +8 -0
  3. package/Models/AnalyticsModels/ExceptionInstance.ts +1 -0
  4. package/Models/AnalyticsModels/Log.ts +1 -0
  5. package/Models/AnalyticsModels/Metric.ts +10 -0
  6. package/Models/AnalyticsModels/MonitorLog.ts +1 -0
  7. package/Models/AnalyticsModels/Profile.ts +1 -0
  8. package/Models/AnalyticsModels/ProfileSample.ts +1 -0
  9. package/Models/AnalyticsModels/Span.ts +1 -0
  10. package/Models/DatabaseModels/AlertCustomField.ts +37 -0
  11. package/Models/DatabaseModels/IncidentCustomField.ts +37 -0
  12. package/Models/DatabaseModels/IncidentMember.ts +9 -0
  13. package/Models/DatabaseModels/MonitorCustomField.ts +37 -0
  14. package/Models/DatabaseModels/OnCallDutyPolicyCustomField.ts +37 -0
  15. package/Models/DatabaseModels/ScheduledMaintenanceCustomField.ts +37 -0
  16. package/Models/DatabaseModels/StatusPageCustomField.ts +37 -0
  17. package/Models/DatabaseModels/TableView.ts +40 -0
  18. package/Models/DatabaseModels/TeamMemberCustomField.ts +37 -0
  19. package/Server/API/BaseAnalyticsAPI.ts +128 -20
  20. package/Server/API/MetricAPI.ts +5 -138
  21. package/Server/API/StatusAPI.ts +103 -7
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1779536271671-AddFacetsToTableView.ts +13 -0
  23. package/Server/Infrastructure/Postgres/SchemaMigrations/1779540427366-AddIsMemberNotifiedIndex.ts +34 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/1779619108628-AddDropdownOptionsToCustomFields.ts +67 -0
  25. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +6 -0
  26. package/Server/Services/AccessTokenService.ts +1 -1
  27. package/Server/Services/AnalyticsDatabaseService.ts +24 -4
  28. package/Server/Services/MetricService.ts +113 -0
  29. package/Server/Services/ProjectService.ts +21 -1
  30. package/Server/Utils/Response.ts +4 -1
  31. package/Server/Utils/UserPermission/UserPermission.ts +17 -1
  32. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +2 -2
  33. package/Types/API/HTTPResponse.ts +16 -0
  34. package/Types/BaseDatabase/ListResult.ts +6 -0
  35. package/Types/CustomField/CustomFieldType.ts +2 -0
  36. package/Types/Date.ts +9 -1
  37. package/Types/ListData.ts +14 -0
  38. package/Types/Monitor/DnsMonitor/DnsMonitorResponse.ts +3 -0
  39. package/Types/Monitor/DnssecMonitor/DnssecMonitorResponse.ts +5 -0
  40. package/Types/Monitor/DomainMonitor/DomainMonitorResponse.ts +4 -0
  41. package/Types/Monitor/ExternalStatusPageMonitor/ExternalStatusPageMonitorResponse.ts +4 -0
  42. package/Types/Monitor/SnmpMonitor/SnmpMonitorResponse.ts +3 -0
  43. package/Types/Probe/ProbeAttempt.ts +9 -0
  44. package/Types/Probe/ProbeMonitorResponse.ts +3 -0
  45. package/UI/Components/BulkUpdate/BulkOwnerActions.tsx +504 -0
  46. package/UI/Components/BulkUpdate/BulkUpdateForm.tsx +64 -54
  47. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +38 -0
  48. package/UI/Components/CustomFields/DropdownOptionsInput.tsx +150 -0
  49. package/UI/Components/Detail/Detail.tsx +78 -11
  50. package/UI/Components/List/List.tsx +6 -0
  51. package/UI/Components/ModelTable/BaseModelTable.tsx +74 -2
  52. package/UI/Components/ModelTable/TableView.tsx +70 -30
  53. package/UI/Components/Pagination/Pagination.tsx +75 -33
  54. package/UI/Components/Table/Table.tsx +6 -0
  55. package/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.ts +1 -0
  56. package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js +33 -0
  57. package/build/dist/Models/AnalyticsModels/AnalyticsBaseModel/AnalyticsBaseModel.js.map +1 -1
  58. package/build/dist/Models/AnalyticsModels/AuditLog.js +8 -0
  59. package/build/dist/Models/AnalyticsModels/AuditLog.js.map +1 -1
  60. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js +1 -0
  61. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js.map +1 -1
  62. package/build/dist/Models/AnalyticsModels/Log.js +1 -0
  63. package/build/dist/Models/AnalyticsModels/Log.js.map +1 -1
  64. package/build/dist/Models/AnalyticsModels/Metric.js +10 -0
  65. package/build/dist/Models/AnalyticsModels/Metric.js.map +1 -1
  66. package/build/dist/Models/AnalyticsModels/MonitorLog.js +1 -0
  67. package/build/dist/Models/AnalyticsModels/MonitorLog.js.map +1 -1
  68. package/build/dist/Models/AnalyticsModels/Profile.js +1 -0
  69. package/build/dist/Models/AnalyticsModels/Profile.js.map +1 -1
  70. package/build/dist/Models/AnalyticsModels/ProfileSample.js +1 -0
  71. package/build/dist/Models/AnalyticsModels/ProfileSample.js.map +1 -1
  72. package/build/dist/Models/AnalyticsModels/Span.js +1 -0
  73. package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
  74. package/build/dist/Models/DatabaseModels/AlertCustomField.js +38 -0
  75. package/build/dist/Models/DatabaseModels/AlertCustomField.js.map +1 -1
  76. package/build/dist/Models/DatabaseModels/IncidentCustomField.js +38 -0
  77. package/build/dist/Models/DatabaseModels/IncidentCustomField.js.map +1 -1
  78. package/build/dist/Models/DatabaseModels/IncidentMember.js +11 -1
  79. package/build/dist/Models/DatabaseModels/IncidentMember.js.map +1 -1
  80. package/build/dist/Models/DatabaseModels/MonitorCustomField.js +38 -0
  81. package/build/dist/Models/DatabaseModels/MonitorCustomField.js.map +1 -1
  82. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyCustomField.js +38 -0
  83. package/build/dist/Models/DatabaseModels/OnCallDutyPolicyCustomField.js.map +1 -1
  84. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceCustomField.js +38 -0
  85. package/build/dist/Models/DatabaseModels/ScheduledMaintenanceCustomField.js.map +1 -1
  86. package/build/dist/Models/DatabaseModels/StatusPageCustomField.js +38 -0
  87. package/build/dist/Models/DatabaseModels/StatusPageCustomField.js.map +1 -1
  88. package/build/dist/Models/DatabaseModels/TableView.js +40 -0
  89. package/build/dist/Models/DatabaseModels/TableView.js.map +1 -1
  90. package/build/dist/Models/DatabaseModels/TeamMemberCustomField.js +38 -0
  91. package/build/dist/Models/DatabaseModels/TeamMemberCustomField.js.map +1 -1
  92. package/build/dist/Server/API/BaseAnalyticsAPI.js +105 -18
  93. package/build/dist/Server/API/BaseAnalyticsAPI.js.map +1 -1
  94. package/build/dist/Server/API/MetricAPI.js +5 -113
  95. package/build/dist/Server/API/MetricAPI.js.map +1 -1
  96. package/build/dist/Server/API/StatusAPI.js +75 -8
  97. package/build/dist/Server/API/StatusAPI.js.map +1 -1
  98. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779536271671-AddFacetsToTableView.js +12 -0
  99. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779536271671-AddFacetsToTableView.js.map +1 -0
  100. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779540427366-AddIsMemberNotifiedIndex.js +27 -0
  101. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779540427366-AddIsMemberNotifiedIndex.js.map +1 -0
  102. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779619108628-AddDropdownOptionsToCustomFields.js +28 -0
  103. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1779619108628-AddDropdownOptionsToCustomFields.js.map +1 -0
  104. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +6 -0
  105. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  106. package/build/dist/Server/Services/AccessTokenService.js +1 -1
  107. package/build/dist/Server/Services/AccessTokenService.js.map +1 -1
  108. package/build/dist/Server/Services/AnalyticsDatabaseService.js +22 -3
  109. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  110. package/build/dist/Server/Services/MetricService.js +89 -0
  111. package/build/dist/Server/Services/MetricService.js.map +1 -1
  112. package/build/dist/Server/Services/ProjectService.js +19 -1
  113. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  114. package/build/dist/Server/Utils/Response.js +6 -5
  115. package/build/dist/Server/Utils/Response.js.map +1 -1
  116. package/build/dist/Server/Utils/UserPermission/UserPermission.js +13 -1
  117. package/build/dist/Server/Utils/UserPermission/UserPermission.js.map +1 -1
  118. package/build/dist/Tests/Server/Services/AnalyticsDatabaseService.test.js +2 -2
  119. package/build/dist/Tests/Server/Services/AnalyticsDatabaseService.test.js.map +1 -1
  120. package/build/dist/Types/API/HTTPResponse.js +15 -0
  121. package/build/dist/Types/API/HTTPResponse.js.map +1 -1
  122. package/build/dist/Types/CustomField/CustomFieldType.js +2 -0
  123. package/build/dist/Types/CustomField/CustomFieldType.js.map +1 -1
  124. package/build/dist/Types/Date.js +10 -1
  125. package/build/dist/Types/Date.js.map +1 -1
  126. package/build/dist/Types/ListData.js +4 -0
  127. package/build/dist/Types/ListData.js.map +1 -1
  128. package/build/dist/Types/Probe/ProbeAttempt.js +2 -0
  129. package/build/dist/Types/Probe/ProbeAttempt.js.map +1 -0
  130. package/build/dist/UI/Components/BulkUpdate/BulkOwnerActions.js +376 -0
  131. package/build/dist/UI/Components/BulkUpdate/BulkOwnerActions.js.map +1 -0
  132. package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js +32 -25
  133. package/build/dist/UI/Components/BulkUpdate/BulkUpdateForm.js.map +1 -1
  134. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +32 -0
  135. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  136. package/build/dist/UI/Components/CustomFields/DropdownOptionsInput.js +84 -0
  137. package/build/dist/UI/Components/CustomFields/DropdownOptionsInput.js.map +1 -0
  138. package/build/dist/UI/Components/Detail/Detail.js +34 -3
  139. package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
  140. package/build/dist/UI/Components/List/List.js +1 -1
  141. package/build/dist/UI/Components/List/List.js.map +1 -1
  142. package/build/dist/UI/Components/ModelTable/BaseModelTable.js +45 -5
  143. package/build/dist/UI/Components/ModelTable/BaseModelTable.js.map +1 -1
  144. package/build/dist/UI/Components/ModelTable/TableView.js +40 -19
  145. package/build/dist/UI/Components/ModelTable/TableView.js.map +1 -1
  146. package/build/dist/UI/Components/Pagination/Pagination.js +62 -36
  147. package/build/dist/UI/Components/Pagination/Pagination.js.map +1 -1
  148. package/build/dist/UI/Components/Table/Table.js +1 -1
  149. package/build/dist/UI/Components/Table/Table.js.map +1 -1
  150. package/build/dist/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.js +1 -0
  151. package/build/dist/UI/Utils/AnalyticsModelAPI/AnalyticsModelAPI.js.map +1 -1
  152. package/package.json +1 -1
@@ -0,0 +1,504 @@
1
+ import React, { ReactElement, useEffect, useMemo, useState } from "react";
2
+
3
+ import BaseModel from "../../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
4
+ import Team from "../../../Models/DatabaseModels/Team";
5
+ import TeamMember from "../../../Models/DatabaseModels/TeamMember";
6
+ import BadDataException from "../../../Types/Exception/BadDataException";
7
+ import IconProp from "../../../Types/Icon/IconProp";
8
+ import { LIMIT_PER_PROJECT } from "../../../Types/Database/LimitMax";
9
+ import Includes from "../../../Types/BaseDatabase/Includes";
10
+ import ListResult from "../../../Types/BaseDatabase/ListResult";
11
+ import Query from "../../../Types/BaseDatabase/Query";
12
+ import SortOrder from "../../../Types/BaseDatabase/SortOrder";
13
+ import ObjectID from "../../../Types/ObjectID";
14
+ import API from "../../Utils/API/API";
15
+ import ModelAPI from "../../Utils/ModelAPI/ModelAPI";
16
+ import ProjectUtil from "../../Utils/Project";
17
+ import { ButtonStyleType } from "../Button/Button";
18
+ import { DropdownOption, DropdownOptionGroup } from "../Dropdown/Dropdown";
19
+ import BasicFormModal from "../FormModal/BasicFormModal";
20
+ import FormFieldSchemaType from "../Forms/Types/FormFieldSchemaType";
21
+ import {
22
+ BulkActionButtonSchema,
23
+ BulkActionFailed,
24
+ BulkActionOnClickProps,
25
+ } from "./BulkUpdateForm";
26
+
27
+ type OwnerJunctionModel = BaseModel;
28
+
29
+ export interface BulkOwnerActionsConfig {
30
+ ownerUserModelType: { new (): OwnerJunctionModel };
31
+ ownerTeamModelType: { new (): OwnerJunctionModel };
32
+ resourceIdField: string;
33
+ }
34
+
35
+ export interface BulkOwnerActionsResult<T extends BaseModel> {
36
+ bulkActions: Array<BulkActionButtonSchema<T>>;
37
+ modals: ReactElement;
38
+ }
39
+
40
+ type BulkOwnerMode = "add" | "remove";
41
+
42
+ const USER_PREFIX: string = "user:";
43
+ const TEAM_PREFIX: string = "team:";
44
+
45
+ /**
46
+ * Reusable hook that provides "Add Owner" and "Remove Owner" bulk actions
47
+ * for any ModelTable whose model has companion `<Resource>OwnerUser` and
48
+ * `<Resource>OwnerTeam` junction tables (e.g., `ServiceOwnerUser` +
49
+ * `ServiceOwnerTeam` with `serviceId` as the foreign key).
50
+ *
51
+ * Usage:
52
+ * const { bulkActions, modals } = useBulkOwnerActions<Service>({
53
+ * ownerUserModelType: ServiceOwnerUser,
54
+ * ownerTeamModelType: ServiceOwnerTeam,
55
+ * resourceIdField: "serviceId",
56
+ * });
57
+ * <ModelTable bulkActions={{ buttons: [...bulkActions, ...] }} />
58
+ * {modals}
59
+ */
60
+ function useBulkOwnerActions<T extends BaseModel>(
61
+ config: BulkOwnerActionsConfig,
62
+ ): BulkOwnerActionsResult<T> {
63
+ const [userOptions, setUserOptions] = useState<Array<DropdownOption>>([]);
64
+ const [teamOptions, setTeamOptions] = useState<Array<DropdownOption>>([]);
65
+ const [showAddModal, setShowAddModal] = useState<boolean>(false);
66
+ const [showRemoveModal, setShowRemoveModal] = useState<boolean>(false);
67
+ const [bulkActionProps, setBulkActionProps] =
68
+ useState<BulkActionOnClickProps<T> | null>(null);
69
+
70
+ useEffect(() => {
71
+ const projectId: ObjectID | null = ProjectUtil.getCurrentProjectId();
72
+ if (!projectId) {
73
+ return;
74
+ }
75
+
76
+ const fetchOwners: () => Promise<void> = async (): Promise<void> => {
77
+ try {
78
+ const [teamMembersResult, teamsResult]: [
79
+ ListResult<TeamMember>,
80
+ ListResult<Team>,
81
+ ] = await Promise.all([
82
+ ModelAPI.getList<TeamMember>({
83
+ modelType: TeamMember,
84
+ query: { projectId: projectId },
85
+ limit: LIMIT_PER_PROJECT,
86
+ skip: 0,
87
+ select: {
88
+ _id: true,
89
+ user: {
90
+ _id: true,
91
+ name: true,
92
+ email: true,
93
+ },
94
+ },
95
+ sort: {},
96
+ }),
97
+ ModelAPI.getList<Team>({
98
+ modelType: Team,
99
+ query: { projectId: projectId },
100
+ limit: LIMIT_PER_PROJECT,
101
+ skip: 0,
102
+ select: {
103
+ _id: true,
104
+ name: true,
105
+ },
106
+ sort: {
107
+ name: SortOrder.Ascending,
108
+ },
109
+ }),
110
+ ]);
111
+
112
+ const seenUserIds: Set<string> = new Set<string>();
113
+ const users: Array<DropdownOption> = [];
114
+
115
+ for (const member of teamMembersResult.data) {
116
+ const userId: string = member.user?._id?.toString() || "";
117
+ if (!userId || seenUserIds.has(userId)) {
118
+ continue;
119
+ }
120
+ seenUserIds.add(userId);
121
+ users.push({
122
+ value: `${USER_PREFIX}${userId}`,
123
+ label:
124
+ member.user?.name?.toString() ||
125
+ member.user?.email?.toString() ||
126
+ "",
127
+ });
128
+ }
129
+
130
+ users.sort((a: DropdownOption, b: DropdownOption) => {
131
+ return a.label.toLowerCase().localeCompare(b.label.toLowerCase());
132
+ });
133
+
134
+ setUserOptions(users);
135
+
136
+ setTeamOptions(
137
+ teamsResult.data.map((team: Team) => {
138
+ return {
139
+ value: `${TEAM_PREFIX}${team._id?.toString() || ""}`,
140
+ label: team.name?.toString() || "",
141
+ };
142
+ }),
143
+ );
144
+ } catch {
145
+ // dropdowns will remain empty; modal will show no options
146
+ }
147
+ };
148
+
149
+ void fetchOwners();
150
+ }, []);
151
+
152
+ const applyOwners: (
153
+ selectedKeys: Array<string>,
154
+ mode: BulkOwnerMode,
155
+ ) => Promise<void> = async (
156
+ selectedKeys: Array<string>,
157
+ mode: BulkOwnerMode,
158
+ ): Promise<void> => {
159
+ if (!bulkActionProps) {
160
+ return;
161
+ }
162
+
163
+ const { items, onProgressInfo, onBulkActionStart, onBulkActionEnd } =
164
+ bulkActionProps;
165
+
166
+ // Close the form modal first so the progress modal is visible
167
+ setShowAddModal(false);
168
+ setShowRemoveModal(false);
169
+
170
+ const userIds: Array<string> = [];
171
+ const teamIds: Array<string> = [];
172
+
173
+ for (const key of selectedKeys) {
174
+ if (key.startsWith(USER_PREFIX)) {
175
+ const id: string = key.slice(USER_PREFIX.length);
176
+ if (id) {
177
+ userIds.push(id);
178
+ }
179
+ } else if (key.startsWith(TEAM_PREFIX)) {
180
+ const id: string = key.slice(TEAM_PREFIX.length);
181
+ if (id) {
182
+ teamIds.push(id);
183
+ }
184
+ }
185
+ }
186
+
187
+ const projectId: ObjectID | null = ProjectUtil.getCurrentProjectId();
188
+
189
+ onBulkActionStart();
190
+
191
+ const totalItems: Array<T> = [...items];
192
+ const inProgressItems: Array<T> = [...items];
193
+ const successItems: Array<T> = [];
194
+ const failedItems: Array<BulkActionFailed<T>> = [];
195
+
196
+ for (const item of totalItems) {
197
+ inProgressItems.splice(inProgressItems.indexOf(item), 1);
198
+
199
+ try {
200
+ if (!item.id) {
201
+ throw new BadDataException("Item ID not found");
202
+ }
203
+ if (!projectId) {
204
+ throw new BadDataException("Project not found");
205
+ }
206
+
207
+ if (mode === "add") {
208
+ const fetchUsers: Promise<Array<OwnerJunctionModel>> =
209
+ userIds.length > 0
210
+ ? ModelAPI.getList<OwnerJunctionModel>({
211
+ modelType: config.ownerUserModelType,
212
+ query: {
213
+ [config.resourceIdField]: item.id,
214
+ projectId: projectId,
215
+ } as Query<OwnerJunctionModel>,
216
+ limit: LIMIT_PER_PROJECT,
217
+ skip: 0,
218
+ select: { userId: true } as Record<string, unknown>,
219
+ sort: {},
220
+ }).then((r: ListResult<OwnerJunctionModel>) => {
221
+ return r.data;
222
+ })
223
+ : Promise.resolve([] as Array<OwnerJunctionModel>);
224
+
225
+ const fetchTeams: Promise<Array<OwnerJunctionModel>> =
226
+ teamIds.length > 0
227
+ ? ModelAPI.getList<OwnerJunctionModel>({
228
+ modelType: config.ownerTeamModelType,
229
+ query: {
230
+ [config.resourceIdField]: item.id,
231
+ projectId: projectId,
232
+ } as Query<OwnerJunctionModel>,
233
+ limit: LIMIT_PER_PROJECT,
234
+ skip: 0,
235
+ select: { teamId: true } as Record<string, unknown>,
236
+ sort: {},
237
+ }).then((r: ListResult<OwnerJunctionModel>) => {
238
+ return r.data;
239
+ })
240
+ : Promise.resolve([] as Array<OwnerJunctionModel>);
241
+
242
+ const [existingUsers, existingTeams]: [
243
+ Array<OwnerJunctionModel>,
244
+ Array<OwnerJunctionModel>,
245
+ ] = await Promise.all([fetchUsers, fetchTeams]);
246
+
247
+ const existingUserIds: Set<string> = new Set<string>(
248
+ existingUsers
249
+ .map((row: OwnerJunctionModel) => {
250
+ return (
251
+ (
252
+ row as unknown as { userId?: ObjectID }
253
+ ).userId?.toString() || ""
254
+ );
255
+ })
256
+ .filter((id: string) => {
257
+ return id.length > 0;
258
+ }),
259
+ );
260
+
261
+ const existingTeamIds: Set<string> = new Set<string>(
262
+ existingTeams
263
+ .map((row: OwnerJunctionModel) => {
264
+ return (
265
+ (
266
+ row as unknown as { teamId?: ObjectID }
267
+ ).teamId?.toString() || ""
268
+ );
269
+ })
270
+ .filter((id: string) => {
271
+ return id.length > 0;
272
+ }),
273
+ );
274
+
275
+ for (const userId of userIds) {
276
+ if (existingUserIds.has(userId)) {
277
+ continue;
278
+ }
279
+ const ownerModel: OwnerJunctionModel =
280
+ new config.ownerUserModelType();
281
+ (ownerModel as unknown as Record<string, unknown>)["userId"] =
282
+ new ObjectID(userId);
283
+ (ownerModel as unknown as Record<string, unknown>)[
284
+ config.resourceIdField
285
+ ] = item.id;
286
+ (ownerModel as unknown as Record<string, unknown>)["projectId"] =
287
+ projectId;
288
+ await ModelAPI.create<OwnerJunctionModel>({
289
+ model: ownerModel,
290
+ modelType: config.ownerUserModelType,
291
+ });
292
+ }
293
+
294
+ for (const teamId of teamIds) {
295
+ if (existingTeamIds.has(teamId)) {
296
+ continue;
297
+ }
298
+ const ownerModel: OwnerJunctionModel =
299
+ new config.ownerTeamModelType();
300
+ (ownerModel as unknown as Record<string, unknown>)["teamId"] =
301
+ new ObjectID(teamId);
302
+ (ownerModel as unknown as Record<string, unknown>)[
303
+ config.resourceIdField
304
+ ] = item.id;
305
+ (ownerModel as unknown as Record<string, unknown>)["projectId"] =
306
+ projectId;
307
+ await ModelAPI.create<OwnerJunctionModel>({
308
+ model: ownerModel,
309
+ modelType: config.ownerTeamModelType,
310
+ });
311
+ }
312
+ } else {
313
+ const fetchMatchingUsers: Promise<Array<OwnerJunctionModel>> =
314
+ userIds.length > 0
315
+ ? ModelAPI.getList<OwnerJunctionModel>({
316
+ modelType: config.ownerUserModelType,
317
+ query: {
318
+ [config.resourceIdField]: item.id,
319
+ projectId: projectId,
320
+ userId: new Includes(
321
+ userIds.map((id: string) => {
322
+ return new ObjectID(id);
323
+ }),
324
+ ),
325
+ } as Query<OwnerJunctionModel>,
326
+ limit: LIMIT_PER_PROJECT,
327
+ skip: 0,
328
+ select: { _id: true } as Record<string, unknown>,
329
+ sort: {},
330
+ }).then((r: ListResult<OwnerJunctionModel>) => {
331
+ return r.data;
332
+ })
333
+ : Promise.resolve([] as Array<OwnerJunctionModel>);
334
+
335
+ const fetchMatchingTeams: Promise<Array<OwnerJunctionModel>> =
336
+ teamIds.length > 0
337
+ ? ModelAPI.getList<OwnerJunctionModel>({
338
+ modelType: config.ownerTeamModelType,
339
+ query: {
340
+ [config.resourceIdField]: item.id,
341
+ projectId: projectId,
342
+ teamId: new Includes(
343
+ teamIds.map((id: string) => {
344
+ return new ObjectID(id);
345
+ }),
346
+ ),
347
+ } as Query<OwnerJunctionModel>,
348
+ limit: LIMIT_PER_PROJECT,
349
+ skip: 0,
350
+ select: { _id: true } as Record<string, unknown>,
351
+ sort: {},
352
+ }).then((r: ListResult<OwnerJunctionModel>) => {
353
+ return r.data;
354
+ })
355
+ : Promise.resolve([] as Array<OwnerJunctionModel>);
356
+
357
+ const [matchingUsers, matchingTeams]: [
358
+ Array<OwnerJunctionModel>,
359
+ Array<OwnerJunctionModel>,
360
+ ] = await Promise.all([fetchMatchingUsers, fetchMatchingTeams]);
361
+
362
+ for (const row of matchingUsers) {
363
+ if (!row.id) {
364
+ continue;
365
+ }
366
+ await ModelAPI.deleteItem<OwnerJunctionModel>({
367
+ modelType: config.ownerUserModelType,
368
+ id: row.id,
369
+ });
370
+ }
371
+ for (const row of matchingTeams) {
372
+ if (!row.id) {
373
+ continue;
374
+ }
375
+ await ModelAPI.deleteItem<OwnerJunctionModel>({
376
+ modelType: config.ownerTeamModelType,
377
+ id: row.id,
378
+ });
379
+ }
380
+ }
381
+
382
+ successItems.push(item);
383
+ } catch (err) {
384
+ failedItems.push({
385
+ item: item,
386
+ failedMessage: API.getFriendlyMessage(err),
387
+ });
388
+ }
389
+
390
+ onProgressInfo({
391
+ totalItems: totalItems,
392
+ failed: failedItems,
393
+ successItems: successItems,
394
+ inProgressItems: inProgressItems,
395
+ });
396
+ }
397
+
398
+ onBulkActionEnd();
399
+ setBulkActionProps(null);
400
+ };
401
+
402
+ const groupedOwnerOptions: Array<DropdownOption | DropdownOptionGroup> =
403
+ useMemo((): Array<DropdownOption | DropdownOptionGroup> => {
404
+ const groups: Array<DropdownOptionGroup> = [];
405
+ if (userOptions.length > 0) {
406
+ groups.push({ label: "People", options: userOptions });
407
+ }
408
+ if (teamOptions.length > 0) {
409
+ groups.push({ label: "Teams", options: teamOptions });
410
+ }
411
+ return groups;
412
+ }, [userOptions, teamOptions]);
413
+
414
+ const addOwnerAction: BulkActionButtonSchema<T> = {
415
+ title: "Add Owner",
416
+ buttonStyleType: ButtonStyleType.NORMAL,
417
+ icon: IconProp.UserPlus,
418
+ onClick: async (actionProps: BulkActionOnClickProps<T>): Promise<void> => {
419
+ setBulkActionProps(actionProps);
420
+ setShowAddModal(true);
421
+ },
422
+ };
423
+
424
+ const removeOwnerAction: BulkActionButtonSchema<T> = {
425
+ title: "Remove Owner",
426
+ buttonStyleType: ButtonStyleType.NORMAL,
427
+ icon: IconProp.UserMinus,
428
+ onClick: async (actionProps: BulkActionOnClickProps<T>): Promise<void> => {
429
+ setBulkActionProps(actionProps);
430
+ setShowRemoveModal(true);
431
+ },
432
+ };
433
+
434
+ const modals: ReactElement = (
435
+ <>
436
+ {showAddModal && (
437
+ <BasicFormModal
438
+ title="Add Owner"
439
+ description="Select users and/or teams to add as owners to the selected items. Existing owners will be preserved."
440
+ onClose={() => {
441
+ setShowAddModal(false);
442
+ setBulkActionProps(null);
443
+ }}
444
+ submitButtonText="Add Owner"
445
+ onSubmit={async (formData: { ownerKeys: Array<string> }) => {
446
+ await applyOwners(formData.ownerKeys || [], "add");
447
+ }}
448
+ formProps={{
449
+ fields: [
450
+ {
451
+ field: {
452
+ ownerKeys: true,
453
+ },
454
+ title: "Select Owners",
455
+ description:
456
+ "These users and teams will be added as owners to each selected item.",
457
+ fieldType: FormFieldSchemaType.MultiSelectDropdown,
458
+ required: true,
459
+ dropdownOptions: groupedOwnerOptions,
460
+ },
461
+ ],
462
+ }}
463
+ />
464
+ )}
465
+
466
+ {showRemoveModal && (
467
+ <BasicFormModal
468
+ title="Remove Owner"
469
+ description="Select users and/or teams to remove from the selected items. Items that do not have any of these owners will be skipped."
470
+ onClose={() => {
471
+ setShowRemoveModal(false);
472
+ setBulkActionProps(null);
473
+ }}
474
+ submitButtonText="Remove Owner"
475
+ onSubmit={async (formData: { ownerKeys: Array<string> }) => {
476
+ await applyOwners(formData.ownerKeys || [], "remove");
477
+ }}
478
+ formProps={{
479
+ fields: [
480
+ {
481
+ field: {
482
+ ownerKeys: true,
483
+ },
484
+ title: "Select Owners",
485
+ description:
486
+ "These users and teams will be removed as owners from each selected item.",
487
+ fieldType: FormFieldSchemaType.MultiSelectDropdown,
488
+ required: true,
489
+ dropdownOptions: groupedOwnerOptions,
490
+ },
491
+ ],
492
+ }}
493
+ />
494
+ )}
495
+ </>
496
+ );
497
+
498
+ return {
499
+ bulkActions: [addOwnerAction, removeOwnerAction],
500
+ modals: modals,
501
+ };
502
+ }
503
+
504
+ export default useBulkOwnerActions;
@@ -1,6 +1,6 @@
1
1
  import { GetReactElementFunction } from "../../Types/FunctionTypes";
2
- import Button, { ButtonStyleType } from "../Button/Button";
3
- import Icon, { SizeProp } from "../Icon/Icon";
2
+ import { ButtonStyleType } from "../Button/Button";
3
+ import Icon from "../Icon/Icon";
4
4
  import ConfirmModal, {
5
5
  ComponentProps as ConfirmModalProps,
6
6
  } from "../Modal/ConfirmModal";
@@ -317,76 +317,86 @@ const BulkUpdateForm: <T extends GenericObject>(
317
317
  menuChildren.push(renderMenuItem(button, safeButtons.length + index));
318
318
  });
319
319
 
320
+ const showLimitWarning: boolean =
321
+ props.isAllItemsSelected &&
322
+ props.selectedItems.length === LIMIT_PER_PROJECT;
323
+
320
324
  return (
321
325
  <div>
322
326
  <div>
323
- <div className="flex mt-5 mb-5 bg-gray-50 rounded rounded-xl p-5 border border-2 border-gray-100 justify-between">
324
- <div className="-mt-1">
325
- <div className="flex mt-1">
326
- <div className="flex-auto py-0.5 text-sm leading-5">
327
- <span className="font-semibold">
328
- {props.selectedItems.length} {props.pluralLabel + " " || ""}
329
- Selected
330
- </span>{" "}
331
- {props.isAllItemsSelected &&
332
- props.selectedItems.length === LIMIT_PER_PROJECT && (
333
- <span className="text-gray-500">
334
- (You can only select {LIMIT_PER_PROJECT}{" "}
335
- {props.pluralLabel} at a time. This is for performance
336
- reasons.)
337
- </span>
338
- )}
327
+ <div className="mt-5 mb-5 bg-gray-50 rounded-xl p-4 border-2 border-gray-100">
328
+ <div className="flex items-center justify-between gap-3 flex-wrap">
329
+ <div className="flex items-center gap-2 flex-wrap">
330
+ {/** Selected Count Badge */}
331
+ <div className="inline-flex items-center gap-1.5 rounded-lg bg-indigo-50 px-3 py-1.5 text-sm font-semibold text-indigo-700 border border-indigo-100">
332
+ <Icon
333
+ icon={IconProp.CheckCircle}
334
+ className="h-4 w-4 text-indigo-600"
335
+ />
336
+ <span>
337
+ {props.selectedItems.length} {props.pluralLabel} Selected
338
+ </span>
339
339
  </div>
340
- </div>
341
- <div className="flex -ml-3 mt-1">
340
+
341
+ {/** Divider */}
342
+ <div className="h-6 w-px bg-gray-300 mx-1" />
343
+
342
344
  {/** Select All Button */}
343
345
  {!props.isAllItemsSelected && (
344
- <Button
345
- className="font-medium text-gray-900"
346
- icon={IconProp.CheckCircle}
346
+ <button
347
+ type="button"
347
348
  onClick={() => {
348
349
  props.onSelectAllClick();
349
350
  }}
350
- title={`Select All ${props.pluralLabel}`}
351
- iconSize={SizeProp.Smaller}
352
- buttonStyle={ButtonStyleType.SECONDARY_LINK}
353
- />
351
+ className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm hover:bg-indigo-50 hover:border-indigo-300 hover:text-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-all duration-150 cursor-pointer select-none"
352
+ >
353
+ <Icon icon={IconProp.CheckCircle} className="h-3.5 w-3.5" />
354
+ <span>Select All {props.pluralLabel}</span>
355
+ </button>
354
356
  )}
355
357
 
356
358
  {/** Clear Selection Button */}
357
- <Button
359
+ <button
360
+ type="button"
358
361
  onClick={() => {
359
362
  props.onClearSelectionClick();
360
363
  }}
361
- className="font-medium text-gray-900 -ml-2"
362
- icon={IconProp.Close}
363
- title="Clear Selection"
364
- buttonStyle={ButtonStyleType.SECONDARY_LINK}
365
- />
364
+ className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm hover:bg-red-50 hover:border-red-300 hover:text-red-700 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 transition-all duration-150 cursor-pointer select-none"
365
+ >
366
+ <Icon icon={IconProp.Close} className="h-3.5 w-3.5" />
367
+ <span>Clear Selection</span>
368
+ </button>
366
369
  </div>
367
- </div>
368
370
 
369
- <div className="flex items-center">
370
- {menuChildren.length > 0 && (
371
- <MoreMenu
372
- elementToBeShownInsteadOfButton={
373
- <div className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3.5 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 hover:border-gray-400 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-all duration-150 cursor-pointer select-none">
374
- <Icon
375
- icon={IconProp.Bolt}
376
- className="h-4 w-4 text-gray-500"
377
- />
378
- <span>Bulk Actions</span>
379
- <Icon
380
- icon={IconProp.ChevronDown}
381
- className="h-3.5 w-3.5 text-gray-400 ml-0.5"
382
- />
383
- </div>
384
- }
385
- >
386
- {menuChildren}
387
- </MoreMenu>
388
- )}
371
+ <div className="flex items-center">
372
+ {menuChildren.length > 0 && (
373
+ <MoreMenu
374
+ elementToBeShownInsteadOfButton={
375
+ <div className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-3.5 py-1.5 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 hover:border-gray-400 hover:text-gray-900 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 transition-all duration-150 cursor-pointer select-none">
376
+ <Icon
377
+ icon={IconProp.Bolt}
378
+ className="h-4 w-4 text-gray-500"
379
+ />
380
+ <span>Bulk Actions</span>
381
+ <Icon
382
+ icon={IconProp.ChevronDown}
383
+ className="h-3.5 w-3.5 text-gray-400 ml-0.5"
384
+ />
385
+ </div>
386
+ }
387
+ >
388
+ {menuChildren}
389
+ </MoreMenu>
390
+ )}
391
+ </div>
389
392
  </div>
393
+
394
+ {showLimitWarning && (
395
+ <div className="mt-2 text-xs text-gray-500">
396
+ You can only select {LIMIT_PER_PROJECT} {props.pluralLabel} at a
397
+ time. This is for performance reasons.
398
+ </div>
399
+ )}
390
400
  </div>
391
401
  </div>
392
402