@jmruthers/pace-core 0.5.115 → 0.5.116

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 (234) hide show
  1. package/dist/{AuthService-CVgsgtaZ.d.ts → AuthService-D4646R4b.d.ts} +9 -4
  2. package/dist/{DataTable-H5KJCAIS.js → DataTable-ZOAKQ3SU.js} +10 -9
  3. package/dist/{UnifiedAuthProvider-KZZUO27W.js → UnifiedAuthProvider-YFN7YGVN.js} +4 -3
  4. package/dist/{api-PKU4PUBO.js → api-TNIBJWLM.js} +3 -3
  5. package/dist/{audit-H4YJJF7R.js → audit-T36HM7IM.js} +2 -2
  6. package/dist/{chunk-SYXOZQ4P.js → chunk-2GJ5GL77.js} +1 -1
  7. package/dist/chunk-2GJ5GL77.js.map +1 -0
  8. package/dist/{chunk-XYRZV7R5.js → chunk-2LM4QQGH.js} +30 -34
  9. package/dist/chunk-2LM4QQGH.js.map +1 -0
  10. package/dist/{chunk-3OGQLOJM.js → chunk-3DBFLLLU.js} +30 -1
  11. package/dist/chunk-3DBFLLLU.js.map +1 -0
  12. package/dist/{chunk-KTHLNIMA.js → chunk-ECOVPXYS.js} +13 -62
  13. package/dist/chunk-ECOVPXYS.js.map +1 -0
  14. package/dist/{chunk-OO3V7W4H.js → chunk-KA3PSVNV.js} +87 -40
  15. package/dist/chunk-KA3PSVNV.js.map +1 -0
  16. package/dist/{chunk-HKWQN44G.js → chunk-KMPWND3F.js} +15 -15
  17. package/dist/{chunk-L36JW4KV.js → chunk-LFS45U62.js} +2 -2
  18. package/dist/{chunk-NEONKMTU.js → chunk-LZYHAL7Y.js} +9 -4
  19. package/dist/{chunk-NEONKMTU.js.map → chunk-LZYHAL7Y.js.map} +1 -1
  20. package/dist/{chunk-BUN7NMV7.js → chunk-O3FTRYEU.js} +2 -2
  21. package/dist/{chunk-F6QB26OS.js → chunk-P3PUOL6B.js} +80 -8
  22. package/dist/chunk-P3PUOL6B.js.map +1 -0
  23. package/dist/{chunk-ZPXWJA4H.js → chunk-PHDAXDHB.js} +131 -5
  24. package/dist/chunk-PHDAXDHB.js.map +1 -0
  25. package/dist/chunk-UJI6WSMD.js +201 -0
  26. package/dist/{chunk-5CDJCTOO.js.map → chunk-UJI6WSMD.js.map} +1 -1
  27. package/dist/{chunk-OUU3SP6I.js → chunk-UKZWNQMB.js} +50 -7
  28. package/dist/{chunk-OUU3SP6I.js.map → chunk-UKZWNQMB.js.map} +1 -1
  29. package/dist/{chunk-7H75SHXZ.js → chunk-VN3OOE35.js} +2 -2
  30. package/dist/{chunk-QKIVSZ2O.js → chunk-WP5I5GLN.js} +2 -2
  31. package/dist/components.d.ts +1 -1
  32. package/dist/components.js +12 -11
  33. package/dist/components.js.map +1 -1
  34. package/dist/hooks.d.ts +1 -1
  35. package/dist/hooks.js +10 -9
  36. package/dist/hooks.js.map +1 -1
  37. package/dist/index.d.ts +4 -4
  38. package/dist/index.js +19 -16
  39. package/dist/index.js.map +1 -1
  40. package/dist/providers.d.ts +2 -2
  41. package/dist/providers.js +3 -2
  42. package/dist/rbac/index.d.ts +82 -1
  43. package/dist/rbac/index.js +13 -10
  44. package/dist/{useToast-DVT4dMtf.d.ts → useToast-Cs_g32bg.d.ts} +1 -1
  45. package/dist/utils.js +6 -4
  46. package/dist/utils.js.map +1 -1
  47. package/dist/validation.js +3 -1
  48. package/dist/validation.js.map +1 -1
  49. package/docs/README.md +4 -0
  50. package/docs/api/classes/ColumnFactory.md +1 -1
  51. package/docs/api/classes/ErrorBoundary.md +1 -1
  52. package/docs/api/classes/InvalidScopeError.md +1 -1
  53. package/docs/api/classes/MissingUserContextError.md +1 -1
  54. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  55. package/docs/api/classes/PermissionDeniedError.md +1 -1
  56. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  57. package/docs/api/classes/RBACAuditManager.md +35 -12
  58. package/docs/api/classes/RBACCache.md +1 -1
  59. package/docs/api/classes/RBACEngine.md +1 -1
  60. package/docs/api/classes/RBACError.md +1 -1
  61. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  62. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  63. package/docs/api/classes/StorageUtils.md +1 -1
  64. package/docs/api/enums/FileCategory.md +1 -1
  65. package/docs/api/interfaces/AggregateConfig.md +1 -1
  66. package/docs/api/interfaces/ButtonProps.md +1 -1
  67. package/docs/api/interfaces/CardProps.md +1 -1
  68. package/docs/api/interfaces/ColorPalette.md +1 -1
  69. package/docs/api/interfaces/ColorShade.md +1 -1
  70. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  71. package/docs/api/interfaces/DataRecord.md +1 -1
  72. package/docs/api/interfaces/DataTableAction.md +1 -1
  73. package/docs/api/interfaces/DataTableColumn.md +1 -1
  74. package/docs/api/interfaces/DataTableProps.md +1 -1
  75. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  76. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  77. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  78. package/docs/api/interfaces/EventAppRoleData.md +71 -0
  79. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  80. package/docs/api/interfaces/FileMetadata.md +1 -1
  81. package/docs/api/interfaces/FileReference.md +1 -1
  82. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  83. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  84. package/docs/api/interfaces/FileUploadProps.md +1 -1
  85. package/docs/api/interfaces/FooterProps.md +1 -1
  86. package/docs/api/interfaces/GrantEventAppRoleParams.md +122 -0
  87. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  88. package/docs/api/interfaces/InputProps.md +1 -1
  89. package/docs/api/interfaces/LabelProps.md +1 -1
  90. package/docs/api/interfaces/LoginFormProps.md +1 -1
  91. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  92. package/docs/api/interfaces/NavigationContextType.md +1 -1
  93. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  94. package/docs/api/interfaces/NavigationItem.md +1 -1
  95. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  96. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  97. package/docs/api/interfaces/Organisation.md +1 -1
  98. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  99. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  100. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  101. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  102. package/docs/api/interfaces/PaceAppLayoutProps.md +27 -27
  103. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  104. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  105. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  106. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  107. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  108. package/docs/api/interfaces/PaletteData.md +1 -1
  109. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  110. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  111. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  112. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  113. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  114. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  115. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  116. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  117. package/docs/api/interfaces/RBACConfig.md +1 -1
  118. package/docs/api/interfaces/RBACLogger.md +1 -1
  119. package/docs/api/interfaces/RevokeEventAppRoleParams.md +100 -0
  120. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  121. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  122. package/docs/api/interfaces/RoleManagementResult.md +52 -0
  123. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  124. package/docs/api/interfaces/RouteConfig.md +1 -1
  125. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  126. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  127. package/docs/api/interfaces/StorageConfig.md +1 -1
  128. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  129. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  130. package/docs/api/interfaces/StorageListOptions.md +1 -1
  131. package/docs/api/interfaces/StorageListResult.md +1 -1
  132. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  133. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  134. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  135. package/docs/api/interfaces/StyleImport.md +1 -1
  136. package/docs/api/interfaces/SwitchProps.md +1 -1
  137. package/docs/api/interfaces/ToastActionElement.md +1 -1
  138. package/docs/api/interfaces/ToastProps.md +1 -1
  139. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  140. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  141. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  142. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  143. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  144. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  145. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  146. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  147. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  148. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  149. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  150. package/docs/api/interfaces/UserEventAccess.md +1 -1
  151. package/docs/api/interfaces/UserMenuProps.md +1 -1
  152. package/docs/api/interfaces/UserProfile.md +1 -1
  153. package/docs/api/modules.md +41 -14
  154. package/docs/architecture/rpc-function-standards.md +193 -0
  155. package/package.json +1 -1
  156. package/src/__tests__/TEST_STANDARD.md +244 -2
  157. package/src/components/DataTable/__tests__/a11y.basic.test.tsx +46 -16
  158. package/src/components/DataTable/__tests__/keyboard.test.tsx +276 -217
  159. package/src/components/DataTable/components/DataTableCore.tsx +29 -2
  160. package/src/components/DataTable/components/DataTableToolbar.tsx +3 -2
  161. package/src/components/DataTable/components/EditableRow.tsx +18 -1
  162. package/src/components/DataTable/components/ViewRowModal.tsx +1 -1
  163. package/src/components/DataTable/components/__tests__/AccessDeniedPage.test.tsx +735 -0
  164. package/src/components/DataTable/components/__tests__/BulkOperationsDropdown.test.tsx +572 -0
  165. package/src/components/DataTable/components/__tests__/ColumnVisibilityDropdown.test.tsx +708 -0
  166. package/src/components/DataTable/components/__tests__/DataTableErrorBoundary.test.tsx +451 -0
  167. package/src/components/DataTable/components/__tests__/DataTableModals.test.tsx +456 -0
  168. package/src/components/DataTable/components/__tests__/EditableRow.test.tsx +454 -0
  169. package/src/components/DataTable/components/__tests__/ExpandButton.test.tsx +462 -0
  170. package/src/components/DataTable/components/__tests__/FilterRow.test.tsx +423 -0
  171. package/src/components/DataTable/components/__tests__/GroupHeader.test.tsx +393 -0
  172. package/src/components/DataTable/components/__tests__/GroupingDropdown.test.tsx +617 -0
  173. package/src/components/DataTable/components/__tests__/ImportModal.test.tsx +734 -0
  174. package/src/components/DataTable/components/__tests__/ViewRowModal.test.tsx +412 -0
  175. package/src/components/DataTable/hooks/useTableHandlers.ts +4 -0
  176. package/src/components/EventSelector/EventSelector.tsx +5 -25
  177. package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +12 -7
  178. package/src/components/PaceAppLayout/PaceAppLayout.tsx +4 -0
  179. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.accessibility.test.tsx +7 -2
  180. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.integration.test.tsx +13 -8
  181. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.performance.test.tsx +109 -100
  182. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.security.test.tsx +18 -13
  183. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.unit.test.tsx +17 -12
  184. package/src/components/PaceLoginPage/PaceLoginPage.test.tsx +2 -0
  185. package/src/components/PaceLoginPage/PaceLoginPage.tsx +11 -1
  186. package/src/components/PasswordReset/PasswordChangeForm.test.tsx +2 -2
  187. package/src/components/ProtectedRoute/ProtectedRoute.test.tsx +648 -0
  188. package/src/components/ProtectedRoute/ProtectedRoute.tsx +10 -7
  189. package/src/components/PublicLayout/__tests__/PublicErrorBoundary.test.tsx +4 -12
  190. package/src/components/Select/Select.tsx +8 -0
  191. package/src/components/Toast/Toast.tsx +1 -1
  192. package/src/hooks/__tests__/usePublicEvent.simple.test.ts +367 -3
  193. package/src/hooks/__tests__/usePublicFileDisplay.test.ts +916 -0
  194. package/src/hooks/useEventTheme.ts +49 -18
  195. package/src/hooks/usePermissionCache.ts +5 -3
  196. package/src/hooks/useSecureDataAccess.ts +11 -1
  197. package/src/hooks/useToast.ts +1 -1
  198. package/src/providers/services/EventServiceProvider.tsx +15 -8
  199. package/src/rbac/__tests__/cache-invalidation.test.ts +385 -0
  200. package/src/rbac/audit.test.ts +206 -0
  201. package/src/rbac/audit.ts +37 -2
  202. package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +26 -23
  203. package/src/rbac/errors.test.ts +340 -0
  204. package/src/rbac/hooks/index.ts +9 -0
  205. package/src/rbac/hooks/useResolvedScope.test.ts +1063 -0
  206. package/src/rbac/hooks/useRoleManagement.test.ts +908 -0
  207. package/src/rbac/hooks/useRoleManagement.ts +255 -0
  208. package/src/services/AuthService.ts +10 -0
  209. package/src/services/EventService.ts +111 -50
  210. package/src/services/__tests__/AuthService.test.ts +1 -1
  211. package/src/services/__tests__/EventService.test.ts +60 -45
  212. package/src/services/interfaces/IEventService.ts +1 -1
  213. package/src/utils/__tests__/deviceFingerprint.unit.test.ts +320 -0
  214. package/src/utils/__tests__/logger.unit.test.ts +398 -0
  215. package/src/utils/__tests__/validation.unit.test.ts +225 -1
  216. package/src/utils/file-reference.test.ts +214 -0
  217. package/dist/chunk-3OGQLOJM.js.map +0 -1
  218. package/dist/chunk-5CDJCTOO.js +0 -190
  219. package/dist/chunk-F6QB26OS.js.map +0 -1
  220. package/dist/chunk-KTHLNIMA.js.map +0 -1
  221. package/dist/chunk-OO3V7W4H.js.map +0 -1
  222. package/dist/chunk-SYXOZQ4P.js.map +0 -1
  223. package/dist/chunk-XYRZV7R5.js.map +0 -1
  224. package/dist/chunk-ZPXWJA4H.js.map +0 -1
  225. package/src/rbac/audit-enhanced.ts +0 -351
  226. /package/dist/{DataTable-H5KJCAIS.js.map → DataTable-ZOAKQ3SU.js.map} +0 -0
  227. /package/dist/{UnifiedAuthProvider-KZZUO27W.js.map → UnifiedAuthProvider-YFN7YGVN.js.map} +0 -0
  228. /package/dist/{api-PKU4PUBO.js.map → api-TNIBJWLM.js.map} +0 -0
  229. /package/dist/{audit-H4YJJF7R.js.map → audit-T36HM7IM.js.map} +0 -0
  230. /package/dist/{chunk-HKWQN44G.js.map → chunk-KMPWND3F.js.map} +0 -0
  231. /package/dist/{chunk-L36JW4KV.js.map → chunk-LFS45U62.js.map} +0 -0
  232. /package/dist/{chunk-BUN7NMV7.js.map → chunk-O3FTRYEU.js.map} +0 -0
  233. /package/dist/{chunk-7H75SHXZ.js.map → chunk-VN3OOE35.js.map} +0 -0
  234. /package/dist/{chunk-QKIVSZ2O.js.map → chunk-WP5I5GLN.js.map} +0 -0
@@ -0,0 +1,572 @@
1
+ /**
2
+ * @file BulkOperationsDropdown Component Tests
3
+ * @package @jmruthers/pace-core
4
+ * @module Components/DataTable/Components/__tests__
5
+ * @since 0.4.0
6
+ *
7
+ * Comprehensive test suite for BulkOperationsDropdown component following testing guidelines.
8
+ * Tests cover all major functionality, edge cases, and user interactions.
9
+ */
10
+
11
+ import React from 'react';
12
+ import { render, screen, waitFor } from '@testing-library/react';
13
+ import userEvent from '@testing-library/user-event';
14
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
15
+ import { BulkOperationsDropdown } from '../BulkOperationsDropdown';
16
+
17
+ // Mock lucide-react icons - use importActual to include ChevronDown for Select
18
+ vi.mock('lucide-react', async () => {
19
+ const actual = await vi.importActual('lucide-react');
20
+ return {
21
+ ...actual,
22
+ MoreHorizontal: ({ className }: { className?: string }) => (
23
+ <div data-testid="more-horizontal-icon" className={className}>More</div>
24
+ ),
25
+ Download: ({ className }: { className?: string }) => (
26
+ <div data-testid="download-icon" className={className}>Download</div>
27
+ ),
28
+ Trash: ({ className }: { className?: string }) => (
29
+ <div data-testid="trash-icon" className={className}>Trash</div>
30
+ ),
31
+ };
32
+ });
33
+
34
+ // Mock Button component
35
+ vi.mock('../../Button/Button', () => ({
36
+ Button: ({ children, onClick, disabled, variant, size, className, ...props }: any) => (
37
+ <button
38
+ onClick={onClick}
39
+ disabled={disabled}
40
+ data-variant={variant}
41
+ data-size={size}
42
+ className={className}
43
+ {...props}
44
+ >
45
+ {children}
46
+ </button>
47
+ ),
48
+ }));
49
+
50
+ // Mock Select components - use importActual to avoid missing ChevronDown
51
+ vi.mock('../../Select/Select', async () => {
52
+ const actual = await vi.importActual('../../Select/Select');
53
+ return {
54
+ ...actual,
55
+ Select: ({ children, disabled }: any) => {
56
+ // Store disabled state in context or pass to children
57
+ return (
58
+ <form data-testid="select-root" data-disabled={disabled}>
59
+ {children}
60
+ </form>
61
+ );
62
+ },
63
+ SelectTrigger: ({ children, asChild, className, disabled }: any) =>
64
+ asChild ? children : <button data-testid="select-trigger" role="combobox" disabled={disabled} className={className}>{children}</button>,
65
+ SelectContent: ({ children, className }: any) => (
66
+ <ul data-testid="select-content" role="listbox" className={className}>{children}</ul>
67
+ ),
68
+ SelectItem: ({ children, onClick, value, disabled, className }: any) => (
69
+ <li
70
+ data-testid="select-item"
71
+ data-value={value}
72
+ data-disabled={disabled ? 'true' : undefined}
73
+ onClick={onClick}
74
+ role="option"
75
+ aria-disabled={disabled}
76
+ className={className}
77
+ >
78
+ {children}
79
+ </li>
80
+ ),
81
+ };
82
+ });
83
+
84
+ describe('[component] BulkOperationsDropdown', () => {
85
+ const mockSelectedRows = {
86
+ 'row-1': true,
87
+ 'row-2': true,
88
+ 'row-3': false,
89
+ };
90
+
91
+ beforeEach(() => {
92
+ vi.clearAllMocks();
93
+ });
94
+
95
+ afterEach(() => {
96
+ vi.clearAllMocks();
97
+ });
98
+
99
+ describe('Rendering', () => {
100
+ it('renders dropdown with operations', () => {
101
+ render(
102
+ <BulkOperationsDropdown
103
+ operations={['export', 'delete']}
104
+ selectedRows={mockSelectedRows}
105
+ />
106
+ );
107
+
108
+ expect(screen.getByTestId('select-root')).toBeInTheDocument();
109
+ expect(screen.getByText(/Bulk Actions/i)).toBeInTheDocument();
110
+ });
111
+
112
+ it('displays selected count in button text', () => {
113
+ render(
114
+ <BulkOperationsDropdown
115
+ operations={['export', 'delete']}
116
+ selectedRows={mockSelectedRows}
117
+ />
118
+ );
119
+
120
+ expect(screen.getByText(/Bulk Actions \(2\)/i)).toBeInTheDocument();
121
+ });
122
+
123
+ it('renders MoreHorizontal icon', () => {
124
+ render(
125
+ <BulkOperationsDropdown
126
+ operations={['export', 'delete']}
127
+ selectedRows={mockSelectedRows}
128
+ />
129
+ );
130
+
131
+ expect(screen.getByTestId('more-horizontal-icon')).toBeInTheDocument();
132
+ });
133
+
134
+ it('renders all operation items', async () => {
135
+ const user = userEvent.setup();
136
+ render(
137
+ <BulkOperationsDropdown
138
+ operations={['export', 'delete']}
139
+ selectedRows={mockSelectedRows}
140
+ />
141
+ );
142
+
143
+ // Open the select dropdown
144
+ const trigger = screen.getByTestId('select-trigger');
145
+ await user.click(trigger);
146
+
147
+ await waitFor(() => {
148
+ const items = screen.getAllByTestId('select-item');
149
+ const exportItem = items.find(item => item.getAttribute('data-value') === 'export');
150
+ const deleteItem = items.find(item => item.getAttribute('data-value') === 'delete');
151
+ expect(exportItem).toBeInTheDocument();
152
+ expect(deleteItem).toBeInTheDocument();
153
+ });
154
+ });
155
+
156
+ it('renders export operation with Download icon', async () => {
157
+ const user = userEvent.setup();
158
+ render(
159
+ <BulkOperationsDropdown
160
+ operations={['export']}
161
+ selectedRows={mockSelectedRows}
162
+ />
163
+ );
164
+
165
+ const trigger = screen.getByTestId('select-trigger');
166
+ await user.click(trigger);
167
+
168
+ await waitFor(() => {
169
+ const items = screen.getAllByTestId('select-item');
170
+ const exportItem = items.find(item => item.getAttribute('data-value') === 'export');
171
+ expect(exportItem).toBeInTheDocument();
172
+ });
173
+
174
+ expect(screen.getByTestId('download-icon')).toBeInTheDocument();
175
+ expect(screen.getByText('Export Selected')).toBeInTheDocument();
176
+ });
177
+
178
+ it('renders delete operation with Trash icon', async () => {
179
+ const user = userEvent.setup();
180
+ render(
181
+ <BulkOperationsDropdown
182
+ operations={['delete']}
183
+ selectedRows={mockSelectedRows}
184
+ />
185
+ );
186
+
187
+ const trigger = screen.getByTestId('select-trigger');
188
+ await user.click(trigger);
189
+
190
+ await waitFor(() => {
191
+ const items = screen.getAllByTestId('select-item');
192
+ const deleteItem = items.find(item => item.getAttribute('data-value') === 'delete');
193
+ expect(deleteItem).toBeInTheDocument();
194
+ });
195
+
196
+ expect(screen.getByTestId('trash-icon')).toBeInTheDocument();
197
+ expect(screen.getByText('Delete')).toBeInTheDocument();
198
+ });
199
+ });
200
+
201
+ describe('Disabled State', () => {
202
+ it('disables dropdown when no rows are selected', () => {
203
+ render(
204
+ <BulkOperationsDropdown
205
+ operations={['export', 'delete']}
206
+ selectedRows={{}}
207
+ />
208
+ );
209
+
210
+ // The disabled state is on the Button (trigger), not the select-root
211
+ const trigger = screen.getByTestId('select-trigger');
212
+ expect(trigger).toBeDisabled();
213
+ });
214
+
215
+ it('disables dropdown when selectedRows is empty object', () => {
216
+ render(
217
+ <BulkOperationsDropdown
218
+ operations={['export', 'delete']}
219
+ selectedRows={{}}
220
+ />
221
+ );
222
+
223
+ // The disabled state is on the Button (trigger), not the select-root
224
+ const trigger = screen.getByTestId('select-trigger');
225
+ expect(trigger).toBeDisabled();
226
+ });
227
+
228
+ it('disables dropdown when all rows are false', () => {
229
+ render(
230
+ <BulkOperationsDropdown
231
+ operations={['export', 'delete']}
232
+ selectedRows={{ 'row-1': false, 'row-2': false }}
233
+ />
234
+ );
235
+
236
+ // The disabled state is on the Button (trigger), not the select-root
237
+ const trigger = screen.getByTestId('select-trigger');
238
+ expect(trigger).toBeDisabled();
239
+ });
240
+
241
+ it('enables dropdown when rows are selected', () => {
242
+ render(
243
+ <BulkOperationsDropdown
244
+ operations={['export', 'delete']}
245
+ selectedRows={mockSelectedRows}
246
+ />
247
+ );
248
+
249
+ // The disabled state is on the Button (trigger), not the select-root
250
+ const trigger = screen.getByTestId('select-trigger');
251
+ expect(trigger).not.toBeDisabled();
252
+ });
253
+
254
+ it('disables operation items when no selection', async () => {
255
+ const user = userEvent.setup();
256
+ render(
257
+ <BulkOperationsDropdown
258
+ operations={['export', 'delete']}
259
+ selectedRows={{}}
260
+ />
261
+ );
262
+
263
+ const trigger = screen.getByTestId('select-trigger');
264
+ await user.click(trigger);
265
+
266
+ await waitFor(() => {
267
+ const items = screen.getAllByTestId('select-item');
268
+ const exportItem = items.find(item => item.getAttribute('data-value') === 'export');
269
+ expect(exportItem).toBeInTheDocument();
270
+ expect(exportItem).toHaveAttribute('data-disabled', 'true');
271
+ });
272
+ });
273
+ });
274
+
275
+ describe('User Interactions', () => {
276
+ it('calls onOperation when export is clicked', async () => {
277
+ const user = userEvent.setup();
278
+ const handleOperation = vi.fn();
279
+
280
+ render(
281
+ <BulkOperationsDropdown
282
+ operations={['export']}
283
+ selectedRows={mockSelectedRows}
284
+ onOperation={handleOperation}
285
+ />
286
+ );
287
+
288
+ const trigger = screen.getByTestId('select-trigger');
289
+ await user.click(trigger);
290
+
291
+ await waitFor(() => {
292
+ const items = screen.getAllByTestId('select-item');
293
+ const exportItem = items.find(item => item.getAttribute('data-value') === 'export');
294
+ expect(exportItem).toBeInTheDocument();
295
+ });
296
+
297
+ const items = screen.getAllByTestId('select-item');
298
+ const exportItem = items.find(item => item.getAttribute('data-value') === 'export');
299
+ await user.click(exportItem!);
300
+
301
+ expect(handleOperation).toHaveBeenCalledTimes(1);
302
+ expect(handleOperation).toHaveBeenCalledWith('export', ['row-1', 'row-2']);
303
+ });
304
+
305
+ it('calls onOperation when delete is clicked', async () => {
306
+ const user = userEvent.setup();
307
+ const handleOperation = vi.fn();
308
+
309
+ render(
310
+ <BulkOperationsDropdown
311
+ operations={['delete']}
312
+ selectedRows={mockSelectedRows}
313
+ onOperation={handleOperation}
314
+ />
315
+ );
316
+
317
+ const trigger = screen.getByTestId('select-trigger');
318
+ await user.click(trigger);
319
+
320
+ await waitFor(() => {
321
+ const items = screen.getAllByTestId('select-item');
322
+ const deleteItem = items.find(item => item.getAttribute('data-value') === 'delete');
323
+ expect(deleteItem).toBeInTheDocument();
324
+ });
325
+
326
+ const items = screen.getAllByTestId('select-item');
327
+ const deleteItem = items.find(item => item.getAttribute('data-value') === 'delete');
328
+ await user.click(deleteItem!);
329
+
330
+ expect(handleOperation).toHaveBeenCalledTimes(1);
331
+ expect(handleOperation).toHaveBeenCalledWith('delete', ['row-1', 'row-2']);
332
+ });
333
+
334
+ it('does not call onOperation when no rows are selected', async () => {
335
+ const user = userEvent.setup();
336
+ const handleOperation = vi.fn();
337
+
338
+ render(
339
+ <BulkOperationsDropdown
340
+ operations={['export']}
341
+ selectedRows={{}}
342
+ onOperation={handleOperation}
343
+ />
344
+ );
345
+
346
+ const trigger = screen.getByTestId('select-trigger');
347
+ await user.click(trigger);
348
+
349
+ await waitFor(() => {
350
+ const items = screen.getAllByTestId('select-item');
351
+ const exportItem = items.find(item => item.getAttribute('data-value') === 'export');
352
+ expect(exportItem).toBeInTheDocument();
353
+ });
354
+
355
+ const items = screen.getAllByTestId('select-item');
356
+ const exportItem = items.find(item => item.getAttribute('data-value') === 'export');
357
+ if (exportItem) {
358
+ await user.click(exportItem);
359
+ }
360
+
361
+ expect(handleOperation).not.toHaveBeenCalled();
362
+ });
363
+
364
+ it('passes correct selected row IDs to onOperation', async () => {
365
+ const user = userEvent.setup();
366
+ const handleOperation = vi.fn();
367
+ const selectedRows = {
368
+ 'row-1': true,
369
+ 'row-2': false,
370
+ 'row-3': true,
371
+ 'row-4': false,
372
+ };
373
+
374
+ render(
375
+ <BulkOperationsDropdown
376
+ operations={['export']}
377
+ selectedRows={selectedRows}
378
+ onOperation={handleOperation}
379
+ />
380
+ );
381
+
382
+ const trigger = screen.getByTestId('select-trigger');
383
+ await user.click(trigger);
384
+
385
+ await waitFor(() => {
386
+ const items = screen.getAllByTestId('select-item');
387
+ const exportItem = items.find(item => item.getAttribute('data-value') === 'export');
388
+ expect(exportItem).toBeInTheDocument();
389
+ });
390
+
391
+ const items = screen.getAllByTestId('select-item');
392
+ const exportItem = items.find(item => item.getAttribute('data-value') === 'export');
393
+ await user.click(exportItem!);
394
+
395
+ expect(handleOperation).toHaveBeenCalledWith('export', ['row-1', 'row-3']);
396
+ });
397
+ });
398
+
399
+ describe('Selected Count Display', () => {
400
+ it('displays correct count for multiple selections', () => {
401
+ render(
402
+ <BulkOperationsDropdown
403
+ operations={['export']}
404
+ selectedRows={mockSelectedRows}
405
+ />
406
+ );
407
+
408
+ expect(screen.getByText(/Bulk Actions \(2\)/i)).toBeInTheDocument();
409
+ });
410
+
411
+ it('displays count of 0 when no selections', () => {
412
+ render(
413
+ <BulkOperationsDropdown
414
+ operations={['export']}
415
+ selectedRows={{}}
416
+ />
417
+ );
418
+
419
+ expect(screen.getByText(/Bulk Actions \(0\)/i)).toBeInTheDocument();
420
+ });
421
+
422
+ it('displays count of 1 for single selection', () => {
423
+ render(
424
+ <BulkOperationsDropdown
425
+ operations={['export']}
426
+ selectedRows={{ 'row-1': true }}
427
+ />
428
+ );
429
+
430
+ expect(screen.getByText(/Bulk Actions \(1\)/i)).toBeInTheDocument();
431
+ });
432
+ });
433
+
434
+ describe('Operation Variants', () => {
435
+ it('applies destructive variant to delete operation', async () => {
436
+ const user = userEvent.setup();
437
+ render(
438
+ <BulkOperationsDropdown
439
+ operations={['delete']}
440
+ selectedRows={mockSelectedRows}
441
+ />
442
+ );
443
+
444
+ const trigger = screen.getByTestId('select-trigger');
445
+ await user.click(trigger);
446
+
447
+ await waitFor(() => {
448
+ const items = screen.getAllByTestId('select-item');
449
+ const deleteItem = items.find(item => item.getAttribute('data-value') === 'delete');
450
+ expect(deleteItem).toBeInTheDocument();
451
+ expect(deleteItem?.className).toContain('text-destructive');
452
+ });
453
+ });
454
+
455
+ it('applies outline variant to export operation', async () => {
456
+ const user = userEvent.setup();
457
+ render(
458
+ <BulkOperationsDropdown
459
+ operations={['export']}
460
+ selectedRows={mockSelectedRows}
461
+ />
462
+ );
463
+
464
+ const trigger = screen.getByTestId('select-trigger');
465
+ await user.click(trigger);
466
+
467
+ await waitFor(() => {
468
+ const items = screen.getAllByTestId('select-item');
469
+ const exportItem = items.find(item => item.getAttribute('data-value') === 'export');
470
+ expect(exportItem).toBeInTheDocument();
471
+ });
472
+ });
473
+ });
474
+
475
+ describe('Edge Cases', () => {
476
+ it('handles undefined selectedRows gracefully', () => {
477
+ render(
478
+ <BulkOperationsDropdown
479
+ operations={['export']}
480
+ selectedRows={undefined as any}
481
+ />
482
+ );
483
+
484
+ expect(screen.getByText(/Bulk Actions \(0\)/i)).toBeInTheDocument();
485
+ });
486
+
487
+ it('handles null selectedRows gracefully', () => {
488
+ render(
489
+ <BulkOperationsDropdown
490
+ operations={['export']}
491
+ selectedRows={null as any}
492
+ />
493
+ );
494
+
495
+ expect(screen.getByText(/Bulk Actions \(0\)/i)).toBeInTheDocument();
496
+ });
497
+
498
+ it('handles empty operations array', () => {
499
+ render(
500
+ <BulkOperationsDropdown
501
+ operations={[]}
502
+ selectedRows={mockSelectedRows}
503
+ />
504
+ );
505
+
506
+ expect(screen.getByTestId('select-root')).toBeInTheDocument();
507
+ });
508
+
509
+ it('handles single operation', () => {
510
+ render(
511
+ <BulkOperationsDropdown
512
+ operations={['export']}
513
+ selectedRows={mockSelectedRows}
514
+ />
515
+ );
516
+
517
+ expect(screen.getByTestId('select-root')).toBeInTheDocument();
518
+ });
519
+
520
+ it('handles multiple operations', () => {
521
+ render(
522
+ <BulkOperationsDropdown
523
+ operations={['export', 'delete']}
524
+ selectedRows={mockSelectedRows}
525
+ />
526
+ );
527
+
528
+ expect(screen.getByTestId('select-root')).toBeInTheDocument();
529
+ });
530
+ });
531
+
532
+ describe('Accessibility', () => {
533
+ it('provides accessible button structure', () => {
534
+ render(
535
+ <BulkOperationsDropdown
536
+ operations={['export']}
537
+ selectedRows={mockSelectedRows}
538
+ />
539
+ );
540
+
541
+ const button = screen.getByRole('combobox');
542
+ expect(button).toBeInTheDocument();
543
+ });
544
+
545
+ it('displays selected count for screen readers', () => {
546
+ render(
547
+ <BulkOperationsDropdown
548
+ operations={['export']}
549
+ selectedRows={mockSelectedRows}
550
+ />
551
+ );
552
+
553
+ expect(screen.getByText(/Bulk Actions \(2\)/i)).toBeInTheDocument();
554
+ });
555
+ });
556
+
557
+ describe('Custom Styling', () => {
558
+ it('applies custom className', () => {
559
+ const { container } = render(
560
+ <BulkOperationsDropdown
561
+ operations={['export']}
562
+ selectedRows={mockSelectedRows}
563
+ className="custom-class"
564
+ />
565
+ );
566
+
567
+ const button = screen.getByRole('combobox');
568
+ expect(button.className).toContain('custom-class');
569
+ });
570
+ });
571
+ });
572
+