@jmruthers/pace-core 0.5.110 → 0.5.111

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 (230) hide show
  1. package/dist/{AuthService-DrHrvXNZ.d.ts → AuthService-CVgsgtaZ.d.ts} +8 -0
  2. package/dist/{DataTable-D3BK2FCN.js → DataTable-5W2HVLLV.js} +8 -8
  3. package/dist/{UnifiedAuthProvider-A7I23UCN.js → UnifiedAuthProvider-LUM3QLS5.js} +3 -3
  4. package/dist/{api-PIE4JRFS.js → api-SIZPFBFX.js} +5 -3
  5. package/dist/{audit-65VNHEV2.js → audit-5JI5T3SL.js} +2 -2
  6. package/dist/{chunk-3J5N2T2N.js → chunk-2BIDKXQU.js} +113 -116
  7. package/dist/chunk-2BIDKXQU.js.map +1 -0
  8. package/dist/{chunk-AWK2FAUN.js → chunk-ACYQNYHB.js} +7 -7
  9. package/dist/{chunk-D6MEKC27.js → chunk-EFVQBYFN.js} +2 -2
  10. package/dist/{chunk-EZ64QG2I.js → chunk-I5YM5BGS.js} +2 -2
  11. package/dist/{chunk-Q7APDV6H.js → chunk-IWJYNWXN.js} +13 -5
  12. package/dist/chunk-IWJYNWXN.js.map +1 -0
  13. package/dist/{chunk-YFMENCR4.js → chunk-JE2GFA3O.js} +3 -3
  14. package/dist/{chunk-AUXS7XSO.js → chunk-MW73E7SP.js} +35 -11
  15. package/dist/chunk-MW73E7SP.js.map +1 -0
  16. package/dist/{chunk-XRSP3H52.js → chunk-PXXS26G5.js} +57 -23
  17. package/dist/chunk-PXXS26G5.js.map +1 -0
  18. package/dist/{chunk-HGZSO43Y.js → chunk-TD4BXGPE.js} +4 -4
  19. package/dist/{chunk-EYSXQ756.js → chunk-TDFBX7KJ.js} +2 -2
  20. package/dist/{chunk-HADXAZT3.js → chunk-UGVU7L7N.js} +52 -90
  21. package/dist/chunk-UGVU7L7N.js.map +1 -0
  22. package/dist/{chunk-2W4WKJVF.js → chunk-X7SPKHYZ.js} +290 -255
  23. package/dist/chunk-X7SPKHYZ.js.map +1 -0
  24. package/dist/{chunk-7GBEBJLR.js → chunk-ZL45MG76.js} +45 -37
  25. package/dist/chunk-ZL45MG76.js.map +1 -0
  26. package/dist/components.js +10 -10
  27. package/dist/hooks.d.ts +11 -1
  28. package/dist/hooks.js +9 -7
  29. package/dist/hooks.js.map +1 -1
  30. package/dist/index.d.ts +2 -2
  31. package/dist/index.js +13 -13
  32. package/dist/providers.d.ts +2 -2
  33. package/dist/providers.js +2 -2
  34. package/dist/rbac/index.d.ts +13 -8
  35. package/dist/rbac/index.js +9 -9
  36. package/dist/utils.js +1 -1
  37. package/docs/api/classes/ColumnFactory.md +1 -1
  38. package/docs/api/classes/ErrorBoundary.md +1 -1
  39. package/docs/api/classes/InvalidScopeError.md +4 -4
  40. package/docs/api/classes/MissingUserContextError.md +4 -4
  41. package/docs/api/classes/OrganisationContextRequiredError.md +4 -4
  42. package/docs/api/classes/PermissionDeniedError.md +4 -4
  43. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  44. package/docs/api/classes/RBACAuditManager.md +8 -8
  45. package/docs/api/classes/RBACCache.md +8 -8
  46. package/docs/api/classes/RBACEngine.md +4 -4
  47. package/docs/api/classes/RBACError.md +4 -4
  48. package/docs/api/classes/RBACNotInitializedError.md +4 -4
  49. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  50. package/docs/api/classes/StorageUtils.md +1 -1
  51. package/docs/api/enums/FileCategory.md +1 -1
  52. package/docs/api/interfaces/AggregateConfig.md +1 -1
  53. package/docs/api/interfaces/ButtonProps.md +1 -1
  54. package/docs/api/interfaces/CardProps.md +1 -1
  55. package/docs/api/interfaces/ColorPalette.md +1 -1
  56. package/docs/api/interfaces/ColorShade.md +1 -1
  57. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  58. package/docs/api/interfaces/DataRecord.md +1 -1
  59. package/docs/api/interfaces/DataTableAction.md +1 -1
  60. package/docs/api/interfaces/DataTableColumn.md +1 -1
  61. package/docs/api/interfaces/DataTableProps.md +1 -1
  62. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  63. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  64. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  65. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  66. package/docs/api/interfaces/FileMetadata.md +1 -1
  67. package/docs/api/interfaces/FileReference.md +1 -1
  68. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  69. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  70. package/docs/api/interfaces/FileUploadProps.md +1 -1
  71. package/docs/api/interfaces/FooterProps.md +1 -1
  72. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  73. package/docs/api/interfaces/InputProps.md +1 -1
  74. package/docs/api/interfaces/LabelProps.md +1 -1
  75. package/docs/api/interfaces/LoginFormProps.md +1 -1
  76. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  77. package/docs/api/interfaces/NavigationContextType.md +1 -1
  78. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  79. package/docs/api/interfaces/NavigationItem.md +1 -1
  80. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  81. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  82. package/docs/api/interfaces/Organisation.md +1 -1
  83. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  84. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  85. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  86. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  87. package/docs/api/interfaces/PaceAppLayoutProps.md +27 -27
  88. package/docs/api/interfaces/PaceLoginPageProps.md +4 -4
  89. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  90. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  91. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  92. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  93. package/docs/api/interfaces/PaletteData.md +1 -1
  94. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  95. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  96. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  97. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  98. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  99. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  100. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  101. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  102. package/docs/api/interfaces/RBACConfig.md +1 -1
  103. package/docs/api/interfaces/RBACLogger.md +1 -1
  104. package/docs/api/interfaces/RoleBasedRouterContextType.md +8 -8
  105. package/docs/api/interfaces/RoleBasedRouterProps.md +10 -10
  106. package/docs/api/interfaces/RouteAccessRecord.md +10 -10
  107. package/docs/api/interfaces/RouteConfig.md +19 -6
  108. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  109. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  110. package/docs/api/interfaces/StorageConfig.md +1 -1
  111. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  112. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  113. package/docs/api/interfaces/StorageListOptions.md +1 -1
  114. package/docs/api/interfaces/StorageListResult.md +1 -1
  115. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  116. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  117. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  118. package/docs/api/interfaces/StyleImport.md +1 -1
  119. package/docs/api/interfaces/SwitchProps.md +1 -1
  120. package/docs/api/interfaces/ToastActionElement.md +1 -1
  121. package/docs/api/interfaces/ToastProps.md +1 -1
  122. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  123. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  124. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  125. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  126. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  127. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  128. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  129. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  130. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  131. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  132. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  133. package/docs/api/interfaces/UserEventAccess.md +1 -1
  134. package/docs/api/interfaces/UserMenuProps.md +1 -1
  135. package/docs/api/interfaces/UserProfile.md +1 -1
  136. package/docs/api/modules.md +36 -36
  137. package/docs/api-reference/hooks.md +8 -4
  138. package/docs/architecture/rpc-function-standards.md +3 -1
  139. package/docs/best-practices/common-patterns.md +3 -3
  140. package/docs/best-practices/deployment.md +10 -4
  141. package/docs/best-practices/performance.md +11 -3
  142. package/docs/core-concepts/organisations.md +8 -8
  143. package/docs/core-concepts/permissions.md +133 -72
  144. package/docs/migration/rbac-migration.md +65 -66
  145. package/docs/rbac/advanced-patterns.md +15 -22
  146. package/docs/rbac/examples.md +12 -12
  147. package/docs/rbac/getting-started.md +3 -3
  148. package/docs/rbac/troubleshooting.md +2 -1
  149. package/package.json +1 -1
  150. package/src/components/DataTable/components/__tests__/ActionButtons.test.tsx +913 -0
  151. package/src/components/DataTable/components/__tests__/ColumnFilter.test.tsx +609 -0
  152. package/src/components/DataTable/components/__tests__/EmptyState.test.tsx +434 -0
  153. package/src/components/DataTable/components/__tests__/LoadingState.test.tsx +120 -0
  154. package/src/components/DataTable/components/__tests__/PaginationControls.test.tsx +519 -0
  155. package/src/components/DataTable/examples/__tests__/HierarchicalActionsExample.test.tsx +316 -0
  156. package/src/components/DataTable/examples/__tests__/InitialPageSizeExample.test.tsx +211 -0
  157. package/src/components/FileUpload/FileUpload.tsx +2 -8
  158. package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +193 -63
  159. package/src/components/PaceAppLayout/PaceAppLayout.tsx +102 -135
  160. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.accessibility.test.tsx +41 -2
  161. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.integration.test.tsx +61 -6
  162. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.performance.test.tsx +71 -21
  163. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.security.test.tsx +113 -41
  164. package/src/components/PaceAppLayout/__tests__/PaceAppLayout.unit.test.tsx +155 -45
  165. package/src/components/PaceLoginPage/PaceLoginPage.tsx +30 -1
  166. package/src/hooks/__tests__/usePermissionCache.simple.test.ts +63 -5
  167. package/src/hooks/__tests__/usePermissionCache.unit.test.ts +156 -72
  168. package/src/hooks/__tests__/useRBAC.unit.test.ts +4 -38
  169. package/src/hooks/index.ts +1 -1
  170. package/src/hooks/useFileDisplay.ts +51 -0
  171. package/src/hooks/usePermissionCache.test.ts +112 -68
  172. package/src/hooks/usePermissionCache.ts +55 -15
  173. package/src/rbac/README.md +81 -39
  174. package/src/rbac/__tests__/adapters.comprehensive.test.tsx +3 -3
  175. package/src/rbac/__tests__/engine.comprehensive.test.ts +15 -6
  176. package/src/rbac/__tests__/rbac-core.test.tsx +1 -1
  177. package/src/rbac/__tests__/rbac-engine-core-logic.test.ts +57 -4
  178. package/src/rbac/__tests__/rbac-engine-simplified.test.ts +3 -2
  179. package/src/rbac/adapters.tsx +4 -4
  180. package/src/rbac/api.test.ts +37 -13
  181. package/src/rbac/api.ts +25 -8
  182. package/src/rbac/audit.test.ts +2 -2
  183. package/src/rbac/audit.ts +14 -5
  184. package/src/rbac/cache.test.ts +12 -0
  185. package/src/rbac/cache.ts +29 -9
  186. package/src/rbac/components/EnhancedNavigationMenu.test.tsx +1 -1
  187. package/src/rbac/components/NavigationGuard.tsx +14 -14
  188. package/src/rbac/components/NavigationProvider.test.tsx +1 -1
  189. package/src/rbac/components/PagePermissionGuard.tsx +4 -3
  190. package/src/rbac/components/PagePermissionProvider.test.tsx +1 -1
  191. package/src/rbac/components/PermissionEnforcer.tsx +19 -15
  192. package/src/rbac/components/RoleBasedRouter.tsx +16 -9
  193. package/src/rbac/components/__tests__/NavigationGuard.test.tsx +123 -107
  194. package/src/rbac/components/__tests__/PagePermissionGuard.test.tsx +1 -1
  195. package/src/rbac/components/__tests__/PermissionEnforcer.test.tsx +121 -103
  196. package/src/rbac/docs/event-based-apps.md +6 -6
  197. package/src/rbac/engine.ts +12 -2
  198. package/src/rbac/hooks/useCan.test.ts +29 -2
  199. package/src/rbac/hooks/usePermissions.test.ts +25 -25
  200. package/src/rbac/hooks/usePermissions.ts +47 -23
  201. package/src/rbac/hooks/useRBAC.simple.test.ts +1 -8
  202. package/src/rbac/hooks/useRBAC.test.ts +3 -40
  203. package/src/rbac/hooks/useRBAC.ts +0 -55
  204. package/src/rbac/hooks/useResolvedScope.ts +23 -31
  205. package/src/rbac/permissions.test.ts +11 -7
  206. package/src/rbac/security.test.ts +2 -2
  207. package/src/rbac/security.ts +22 -7
  208. package/src/rbac/types.test.ts +2 -2
  209. package/src/rbac/types.ts +1 -2
  210. package/src/services/EventService.ts +41 -13
  211. package/src/services/__tests__/EventService.test.ts +25 -4
  212. package/src/services/interfaces/IEventService.ts +1 -0
  213. package/src/utils/file-reference.ts +9 -0
  214. package/dist/chunk-2W4WKJVF.js.map +0 -1
  215. package/dist/chunk-3J5N2T2N.js.map +0 -1
  216. package/dist/chunk-7GBEBJLR.js.map +0 -1
  217. package/dist/chunk-AUXS7XSO.js.map +0 -1
  218. package/dist/chunk-HADXAZT3.js.map +0 -1
  219. package/dist/chunk-Q7APDV6H.js.map +0 -1
  220. package/dist/chunk-XRSP3H52.js.map +0 -1
  221. /package/dist/{DataTable-D3BK2FCN.js.map → DataTable-5W2HVLLV.js.map} +0 -0
  222. /package/dist/{UnifiedAuthProvider-A7I23UCN.js.map → UnifiedAuthProvider-LUM3QLS5.js.map} +0 -0
  223. /package/dist/{api-PIE4JRFS.js.map → api-SIZPFBFX.js.map} +0 -0
  224. /package/dist/{audit-65VNHEV2.js.map → audit-5JI5T3SL.js.map} +0 -0
  225. /package/dist/{chunk-AWK2FAUN.js.map → chunk-ACYQNYHB.js.map} +0 -0
  226. /package/dist/{chunk-D6MEKC27.js.map → chunk-EFVQBYFN.js.map} +0 -0
  227. /package/dist/{chunk-EZ64QG2I.js.map → chunk-I5YM5BGS.js.map} +0 -0
  228. /package/dist/{chunk-YFMENCR4.js.map → chunk-JE2GFA3O.js.map} +0 -0
  229. /package/dist/{chunk-HGZSO43Y.js.map → chunk-TD4BXGPE.js.map} +0 -0
  230. /package/dist/{chunk-EYSXQ756.js.map → chunk-TDFBX7KJ.js.map} +0 -0
@@ -0,0 +1,913 @@
1
+ /**
2
+ * @file ActionButtons Component Tests
3
+ * @package @jmruthers/pace-core
4
+ * @module Components/DataTable/Components/__tests__
5
+ * @since 0.4.0
6
+ *
7
+ * Comprehensive test suite for ActionButtons 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, within } from '@testing-library/react';
13
+ import userEvent from '@testing-library/user-event';
14
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
15
+ import { ActionButtons } from '../ActionButtons';
16
+ import type { Row } from '@tanstack/react-table';
17
+ import type { DataTableAction, DataRecord } from '../../types';
18
+
19
+ // Mock lucide-react icons - need to include ChevronDown for Select
20
+ vi.mock('lucide-react', async () => {
21
+ const actual = await vi.importActual('lucide-react');
22
+ return {
23
+ ...actual,
24
+ MoreHorizontal: ({ className }: { className?: string }) => (
25
+ <div data-testid="more-horizontal-icon" className={className}>More</div>
26
+ ),
27
+ };
28
+ });
29
+
30
+ // Mock logger
31
+ vi.mock('../../../utils/logger', () => ({
32
+ createLogger: () => ({
33
+ debug: vi.fn(),
34
+ info: vi.fn(),
35
+ warn: vi.fn(),
36
+ error: vi.fn(),
37
+ }),
38
+ }));
39
+
40
+ // Mock Button component
41
+ vi.mock('../../Button/Button', () => ({
42
+ Button: ({ children, onClick, disabled, 'aria-label': ariaLabel, 'data-testid': testId, ...props }: any) => (
43
+ <button
44
+ onClick={onClick}
45
+ disabled={disabled}
46
+ aria-label={ariaLabel}
47
+ data-testid={testId}
48
+ {...props}
49
+ >
50
+ {children}
51
+ </button>
52
+ ),
53
+ }));
54
+
55
+ // Mock Select components - use importOriginal to avoid missing icons
56
+ vi.mock('../../Select/Select', async () => {
57
+ const actual = await vi.importActual('../../Select/Select');
58
+ return {
59
+ ...actual,
60
+ Select: ({ children }: { children: React.ReactNode }) => <div data-testid="select">{children}</div>,
61
+ SelectTrigger: ({ children, asChild, className }: any) =>
62
+ asChild ? children : <button data-testid="select-trigger" className={className}>{children}</button>,
63
+ SelectContent: ({ children, className }: any) => (
64
+ <div data-testid="select-content" className={className}>{children}</div>
65
+ ),
66
+ SelectItem: ({ children, onClick, value, 'data-testid': testId, className }: any) => (
67
+ <button
68
+ data-testid={testId}
69
+ onClick={onClick}
70
+ value={value}
71
+ className={className}
72
+ >
73
+ {children}
74
+ </button>
75
+ ),
76
+ };
77
+ });
78
+
79
+ interface TestData extends DataRecord {
80
+ id: string;
81
+ name: string;
82
+ active: boolean;
83
+ }
84
+
85
+ const createMockRow = (data: TestData): Row<TestData> => ({
86
+ original: data,
87
+ id: data.id,
88
+ index: 0,
89
+ depth: 0,
90
+ getValue: vi.fn(),
91
+ renderValue: vi.fn(),
92
+ getUniqueValues: vi.fn(),
93
+ toggleSelected: vi.fn(),
94
+ getIsSelected: vi.fn(() => false),
95
+ getIsSomeSelected: vi.fn(() => false),
96
+ getIsAllSubRowsSelected: vi.fn(() => false),
97
+ getCanSelect: vi.fn(() => true),
98
+ getCanSelectSubRows: vi.fn(() => false),
99
+ getCanMultiSelect: vi.fn(() => true),
100
+ getToggleSelectedHandler: vi.fn(),
101
+ getParentRow: vi.fn(),
102
+ getParentRows: vi.fn(),
103
+ subRows: [],
104
+ getLeafValues: vi.fn(),
105
+ getAllCells: vi.fn(),
106
+ getLeftVisibleCells: vi.fn(),
107
+ getRightVisibleCells: vi.fn(),
108
+ getVisibleCells: vi.fn(),
109
+ getCenterVisibleCells: vi.fn(),
110
+ } as unknown as Row<TestData>);
111
+
112
+ describe('[component] ActionButtons', () => {
113
+ const mockRow = createMockRow({ id: '1', name: 'Test User', active: true });
114
+ const defaultPermissions = {
115
+ canRead: { can: true, isLoading: false },
116
+ canCreate: { can: true, isLoading: false },
117
+ canUpdate: { can: true, isLoading: false },
118
+ canDelete: { can: true, isLoading: false },
119
+ canExport: { can: true, isLoading: false },
120
+ canImport: { can: true, isLoading: false },
121
+ };
122
+
123
+ beforeEach(() => {
124
+ vi.clearAllMocks();
125
+ });
126
+
127
+ afterEach(() => {
128
+ vi.clearAllMocks();
129
+ });
130
+
131
+ describe('Rendering', () => {
132
+ it('returns null when no actions provided', () => {
133
+ const { container } = render(
134
+ <ActionButtons row={mockRow} actions={[]} />
135
+ );
136
+ expect(container.firstChild).toBeNull();
137
+ });
138
+
139
+ it('renders action buttons when actions are provided', () => {
140
+ const mockActions: DataTableAction<TestData>[] = [
141
+ {
142
+ label: 'Edit',
143
+ onClick: vi.fn(),
144
+ testId: 'edit-action',
145
+ },
146
+ {
147
+ label: 'Delete',
148
+ onClick: vi.fn(),
149
+ testId: 'delete-action',
150
+ },
151
+ ];
152
+
153
+ render(
154
+ <ActionButtons row={mockRow} actions={mockActions} />
155
+ );
156
+
157
+ expect(screen.getByTestId('edit-action')).toBeInTheDocument();
158
+ expect(screen.getByTestId('delete-action')).toBeInTheDocument();
159
+ });
160
+
161
+ it('renders action buttons with icons', () => {
162
+ const MockIcon = ({ className }: { className?: string }) => (
163
+ <span data-testid="edit-icon" className={className}>Icon</span>
164
+ );
165
+
166
+ const mockActions: DataTableAction<TestData>[] = [
167
+ {
168
+ label: 'Edit',
169
+ icon: MockIcon,
170
+ onClick: vi.fn(),
171
+ testId: 'edit-action',
172
+ },
173
+ ];
174
+
175
+ render(
176
+ <ActionButtons row={mockRow} actions={mockActions} />
177
+ );
178
+
179
+ expect(screen.getByTestId('edit-action')).toBeInTheDocument();
180
+ expect(screen.getByTestId('edit-icon')).toBeInTheDocument();
181
+ });
182
+
183
+ it('returns null when all actions are filtered out', () => {
184
+ const mockActions: DataTableAction<TestData>[] = [
185
+ {
186
+ label: 'Edit',
187
+ onClick: vi.fn(),
188
+ hidden: true,
189
+ },
190
+ ];
191
+
192
+ const { container } = render(
193
+ <ActionButtons row={mockRow} actions={mockActions} />
194
+ );
195
+ expect(container.firstChild).toBeNull();
196
+ });
197
+ });
198
+
199
+ describe('User Interactions', () => {
200
+ it('calls onClick handler when action button is clicked', async () => {
201
+ const user = userEvent.setup();
202
+ const handleClick = vi.fn();
203
+
204
+ const mockActions: DataTableAction<TestData>[] = [
205
+ {
206
+ label: 'Edit',
207
+ onClick: handleClick,
208
+ testId: 'edit-action',
209
+ },
210
+ ];
211
+
212
+ render(
213
+ <ActionButtons row={mockRow} actions={mockActions} />
214
+ );
215
+
216
+ const editButton = screen.getByTestId('edit-action');
217
+ await user.click(editButton);
218
+
219
+ expect(handleClick).toHaveBeenCalledTimes(1);
220
+ expect(handleClick).toHaveBeenCalledWith(mockRow.original);
221
+ });
222
+
223
+ it('does not call onClick when action is disabled', async () => {
224
+ const user = userEvent.setup();
225
+ const handleClick = vi.fn();
226
+
227
+ const mockActions: DataTableAction<TestData>[] = [
228
+ {
229
+ label: 'Edit',
230
+ onClick: handleClick,
231
+ disabled: true,
232
+ testId: 'edit-action',
233
+ },
234
+ ];
235
+
236
+ render(
237
+ <ActionButtons row={mockRow} actions={mockActions} />
238
+ );
239
+
240
+ const editButton = screen.getByTestId('edit-action');
241
+ expect(editButton).toBeDisabled();
242
+
243
+ await user.click(editButton);
244
+ expect(handleClick).not.toHaveBeenCalled();
245
+ });
246
+
247
+ it('handles multiple action clicks correctly', async () => {
248
+ const user = userEvent.setup();
249
+ const handleEdit = vi.fn();
250
+ const handleDelete = vi.fn();
251
+
252
+ const mockActions: DataTableAction<TestData>[] = [
253
+ {
254
+ label: 'Edit',
255
+ onClick: handleEdit,
256
+ testId: 'edit-action',
257
+ },
258
+ {
259
+ label: 'Delete',
260
+ onClick: handleDelete,
261
+ testId: 'delete-action',
262
+ },
263
+ ];
264
+
265
+ render(
266
+ <ActionButtons row={mockRow} actions={mockActions} />
267
+ );
268
+
269
+ await user.click(screen.getByTestId('edit-action'));
270
+ await user.click(screen.getByTestId('delete-action'));
271
+
272
+ expect(handleEdit).toHaveBeenCalledTimes(1);
273
+ expect(handleDelete).toHaveBeenCalledTimes(1);
274
+ });
275
+ });
276
+
277
+ describe('Action Visibility', () => {
278
+ it('filters actions based on hidden property', () => {
279
+ const mockActions: DataTableAction<TestData>[] = [
280
+ {
281
+ label: 'Edit',
282
+ onClick: vi.fn(),
283
+ hidden: false,
284
+ testId: 'edit-action',
285
+ },
286
+ {
287
+ label: 'Delete',
288
+ onClick: vi.fn(),
289
+ hidden: true,
290
+ },
291
+ ];
292
+
293
+ render(
294
+ <ActionButtons row={mockRow} actions={mockActions} />
295
+ );
296
+
297
+ expect(screen.getByTestId('edit-action')).toBeInTheDocument();
298
+ expect(screen.queryByText('Delete')).not.toBeInTheDocument();
299
+ });
300
+
301
+ it('filters Edit action when user lacks update permission', () => {
302
+ const permissions = {
303
+ ...defaultPermissions,
304
+ canUpdate: { can: false, isLoading: false },
305
+ };
306
+
307
+ const mockActions: DataTableAction<TestData>[] = [
308
+ {
309
+ label: 'Edit',
310
+ onClick: vi.fn(),
311
+ testId: 'edit-action',
312
+ },
313
+ ];
314
+
315
+ const { container } = render(
316
+ <ActionButtons
317
+ row={mockRow}
318
+ actions={mockActions}
319
+ permissions={permissions}
320
+ />
321
+ );
322
+
323
+ expect(container.firstChild).toBeNull();
324
+ });
325
+
326
+ it('filters Delete action when user lacks delete permission', () => {
327
+ const permissions = {
328
+ ...defaultPermissions,
329
+ canDelete: { can: false, isLoading: false },
330
+ };
331
+
332
+ const mockActions: DataTableAction<TestData>[] = [
333
+ {
334
+ label: 'Delete',
335
+ onClick: vi.fn(),
336
+ testId: 'delete-action',
337
+ },
338
+ ];
339
+
340
+ const { container } = render(
341
+ <ActionButtons
342
+ row={mockRow}
343
+ actions={mockActions}
344
+ permissions={permissions}
345
+ />
346
+ );
347
+
348
+ expect(container.firstChild).toBeNull();
349
+ });
350
+
351
+ it('shows action when visible condition returns true', () => {
352
+ const mockActions: DataTableAction<TestData>[] = [
353
+ {
354
+ label: 'Edit',
355
+ onClick: vi.fn(),
356
+ visible: (row) => row.active === true,
357
+ testId: 'edit-action',
358
+ },
359
+ ];
360
+
361
+ render(
362
+ <ActionButtons row={mockRow} actions={mockActions} />
363
+ );
364
+
365
+ expect(screen.getByTestId('edit-action')).toBeInTheDocument();
366
+ });
367
+
368
+ it('hides action when visible condition returns false', () => {
369
+ const mockActions: DataTableAction<TestData>[] = [
370
+ {
371
+ label: 'Edit',
372
+ onClick: vi.fn(),
373
+ visible: (row) => row.active === false,
374
+ testId: 'edit-action',
375
+ },
376
+ ];
377
+
378
+ const { container } = render(
379
+ <ActionButtons row={mockRow} actions={mockActions} />
380
+ );
381
+
382
+ expect(container.firstChild).toBeNull();
383
+ });
384
+
385
+ it('shows action when visible boolean is true', () => {
386
+ const mockActions: DataTableAction<TestData>[] = [
387
+ {
388
+ label: 'Edit',
389
+ onClick: vi.fn(),
390
+ visible: true,
391
+ testId: 'edit-action',
392
+ },
393
+ ];
394
+
395
+ render(
396
+ <ActionButtons row={mockRow} actions={mockActions} />
397
+ );
398
+
399
+ expect(screen.getByTestId('edit-action')).toBeInTheDocument();
400
+ });
401
+
402
+ it('hides action when visible boolean is false', () => {
403
+ const mockActions: DataTableAction<TestData>[] = [
404
+ {
405
+ label: 'Edit',
406
+ onClick: vi.fn(),
407
+ visible: false,
408
+ },
409
+ ];
410
+
411
+ const { container } = render(
412
+ <ActionButtons row={mockRow} actions={mockActions} />
413
+ );
414
+
415
+ expect(container.firstChild).toBeNull();
416
+ });
417
+ });
418
+
419
+ describe('Action Disabled State', () => {
420
+ it('disables action when disabled condition returns true', () => {
421
+ const mockActions: DataTableAction<TestData>[] = [
422
+ {
423
+ label: 'Edit',
424
+ onClick: vi.fn(),
425
+ disabled: (row) => !row.active,
426
+ testId: 'edit-action',
427
+ },
428
+ ];
429
+
430
+ const inactiveRow = createMockRow({ id: '2', name: 'Inactive User', active: false });
431
+ render(
432
+ <ActionButtons row={inactiveRow} actions={mockActions} />
433
+ );
434
+
435
+ const editButton = screen.getByTestId('edit-action');
436
+ expect(editButton).toBeDisabled();
437
+ });
438
+
439
+ it('enables action when disabled condition returns false', () => {
440
+ const mockActions: DataTableAction<TestData>[] = [
441
+ {
442
+ label: 'Edit',
443
+ onClick: vi.fn(),
444
+ disabled: (row) => !row.active,
445
+ testId: 'edit-action',
446
+ },
447
+ ];
448
+
449
+ render(
450
+ <ActionButtons row={mockRow} actions={mockActions} />
451
+ );
452
+
453
+ const editButton = screen.getByTestId('edit-action');
454
+ expect(editButton).not.toBeDisabled();
455
+ });
456
+
457
+ it('disables action when disabled boolean is true', () => {
458
+ const mockActions: DataTableAction<TestData>[] = [
459
+ {
460
+ label: 'Edit',
461
+ onClick: vi.fn(),
462
+ disabled: true,
463
+ testId: 'edit-action',
464
+ },
465
+ ];
466
+
467
+ render(
468
+ <ActionButtons row={mockRow} actions={mockActions} />
469
+ );
470
+
471
+ const editButton = screen.getByTestId('edit-action');
472
+ expect(editButton).toBeDisabled();
473
+ });
474
+
475
+ it('enables action when disabled boolean is false', () => {
476
+ const mockActions: DataTableAction<TestData>[] = [
477
+ {
478
+ label: 'Edit',
479
+ onClick: vi.fn(),
480
+ disabled: false,
481
+ testId: 'edit-action',
482
+ },
483
+ ];
484
+
485
+ render(
486
+ <ActionButtons row={mockRow} actions={mockActions} />
487
+ );
488
+
489
+ const editButton = screen.getByTestId('edit-action');
490
+ expect(editButton).not.toBeDisabled();
491
+ });
492
+ });
493
+
494
+ describe('Hierarchical Actions', () => {
495
+ it('shows parent-specific actions only for parent rows', () => {
496
+ const mockActions: DataTableAction<TestData>[] = [
497
+ {
498
+ label: 'Parent Action',
499
+ onClick: vi.fn(),
500
+ showForParent: true,
501
+ testId: 'parent-action',
502
+ },
503
+ ];
504
+
505
+ render(
506
+ <ActionButtons
507
+ row={mockRow}
508
+ actions={mockActions}
509
+ isParent={true}
510
+ hierarchical={true}
511
+ />
512
+ );
513
+
514
+ expect(screen.getByTestId('parent-action')).toBeInTheDocument();
515
+ });
516
+
517
+ it('hides parent-specific actions for child rows', () => {
518
+ const mockActions: DataTableAction<TestData>[] = [
519
+ {
520
+ label: 'Parent Action',
521
+ onClick: vi.fn(),
522
+ showForParent: true,
523
+ },
524
+ ];
525
+
526
+ const { container } = render(
527
+ <ActionButtons
528
+ row={mockRow}
529
+ actions={mockActions}
530
+ isParent={false}
531
+ hierarchical={true}
532
+ />
533
+ );
534
+
535
+ expect(container.firstChild).toBeNull();
536
+ });
537
+
538
+ it('shows child-specific actions only for child rows', () => {
539
+ const mockActions: DataTableAction<TestData>[] = [
540
+ {
541
+ label: 'Child Action',
542
+ onClick: vi.fn(),
543
+ showForChild: true,
544
+ testId: 'child-action',
545
+ },
546
+ ];
547
+
548
+ render(
549
+ <ActionButtons
550
+ row={mockRow}
551
+ actions={mockActions}
552
+ isParent={false}
553
+ hierarchical={true}
554
+ />
555
+ );
556
+
557
+ expect(screen.getByTestId('child-action')).toBeInTheDocument();
558
+ });
559
+
560
+ it('hides child-specific actions for parent rows', () => {
561
+ const mockActions: DataTableAction<TestData>[] = [
562
+ {
563
+ label: 'Child Action',
564
+ onClick: vi.fn(),
565
+ showForChild: true,
566
+ },
567
+ ];
568
+
569
+ const { container } = render(
570
+ <ActionButtons
571
+ row={mockRow}
572
+ actions={mockActions}
573
+ isParent={true}
574
+ hierarchical={true}
575
+ />
576
+ );
577
+
578
+ expect(container.firstChild).toBeNull();
579
+ });
580
+
581
+ it('uses parent icon when provided for parent rows', () => {
582
+ const ParentIcon = () => <span data-testid="parent-icon">Parent</span>;
583
+ const ChildIcon = () => <span data-testid="child-icon">Child</span>;
584
+
585
+ const mockActions: DataTableAction<TestData>[] = [
586
+ {
587
+ label: 'Action',
588
+ icon: ChildIcon,
589
+ parentIcon: ParentIcon,
590
+ onClick: vi.fn(),
591
+ showForParent: true,
592
+ testId: 'action',
593
+ },
594
+ ];
595
+
596
+ render(
597
+ <ActionButtons
598
+ row={mockRow}
599
+ actions={mockActions}
600
+ isParent={true}
601
+ hierarchical={true}
602
+ />
603
+ );
604
+
605
+ expect(screen.getByTestId('parent-icon')).toBeInTheDocument();
606
+ expect(screen.queryByTestId('child-icon')).not.toBeInTheDocument();
607
+ });
608
+
609
+ it('uses parent label when provided for parent rows', () => {
610
+ const mockActions: DataTableAction<TestData>[] = [
611
+ {
612
+ label: 'Default Label',
613
+ parentLabel: 'Parent Label',
614
+ onClick: vi.fn(),
615
+ showForParent: true,
616
+ testId: 'action',
617
+ },
618
+ ];
619
+
620
+ render(
621
+ <ActionButtons
622
+ row={mockRow}
623
+ actions={mockActions}
624
+ isParent={true}
625
+ hierarchical={true}
626
+ />
627
+ );
628
+
629
+ const button = screen.getByTestId('action');
630
+ expect(button).toHaveAttribute('aria-label', 'Parent Label');
631
+ });
632
+ });
633
+
634
+ describe('Edit Mode Behavior', () => {
635
+ it('shows actions with showInEditMode true when in edit mode', () => {
636
+ const mockActions: DataTableAction<TestData>[] = [
637
+ {
638
+ label: 'Save',
639
+ onClick: vi.fn(),
640
+ showInEditMode: true,
641
+ testId: 'save-action',
642
+ },
643
+ ];
644
+
645
+ render(
646
+ <ActionButtons
647
+ row={mockRow}
648
+ actions={mockActions}
649
+ isEditing={true}
650
+ />
651
+ );
652
+
653
+ expect(screen.getByTestId('save-action')).toBeInTheDocument();
654
+ });
655
+
656
+ it('hides actions with showInEditMode false when in edit mode', () => {
657
+ const mockActions: DataTableAction<TestData>[] = [
658
+ {
659
+ label: 'Edit',
660
+ onClick: vi.fn(),
661
+ showInEditMode: false,
662
+ },
663
+ ];
664
+
665
+ const { container } = render(
666
+ <ActionButtons
667
+ row={mockRow}
668
+ actions={mockActions}
669
+ isEditing={true}
670
+ />
671
+ );
672
+
673
+ expect(container.firstChild).toBeNull();
674
+ });
675
+
676
+ it('shows actions with hideInViewMode false when in view mode', () => {
677
+ const mockActions: DataTableAction<TestData>[] = [
678
+ {
679
+ label: 'Edit',
680
+ onClick: vi.fn(),
681
+ hideInViewMode: false,
682
+ testId: 'edit-action',
683
+ },
684
+ ];
685
+
686
+ render(
687
+ <ActionButtons
688
+ row={mockRow}
689
+ actions={mockActions}
690
+ isEditing={false}
691
+ />
692
+ );
693
+
694
+ expect(screen.getByTestId('edit-action')).toBeInTheDocument();
695
+ });
696
+
697
+ it('hides actions with hideInViewMode true when in view mode', () => {
698
+ const mockActions: DataTableAction<TestData>[] = [
699
+ {
700
+ label: 'Save',
701
+ onClick: vi.fn(),
702
+ hideInViewMode: true,
703
+ },
704
+ ];
705
+
706
+ const { container } = render(
707
+ <ActionButtons
708
+ row={mockRow}
709
+ actions={mockActions}
710
+ isEditing={false}
711
+ />
712
+ );
713
+
714
+ expect(container.firstChild).toBeNull();
715
+ });
716
+ });
717
+
718
+ describe('Dropdown Menu for Many Actions', () => {
719
+ it('renders dropdown menu when more than 6 actions', () => {
720
+ const mockActions: DataTableAction<TestData>[] = Array.from({ length: 7 }, (_, i) => ({
721
+ label: `Action ${i + 1}`,
722
+ onClick: vi.fn(),
723
+ testId: `action-${i + 1}`,
724
+ }));
725
+
726
+ render(
727
+ <ActionButtons row={mockRow} actions={mockActions} />
728
+ );
729
+
730
+ // Select component renders as form with select-root test ID
731
+ expect(screen.getByTestId('select-root')).toBeInTheDocument();
732
+ expect(screen.getByTestId('select-trigger')).toBeInTheDocument();
733
+ expect(screen.getByText('Open menu')).toBeInTheDocument();
734
+ });
735
+
736
+ it('renders individual buttons when 6 or fewer actions', () => {
737
+ const mockActions: DataTableAction<TestData>[] = Array.from({ length: 6 }, (_, i) => ({
738
+ label: `Action ${i + 1}`,
739
+ onClick: vi.fn(),
740
+ testId: `action-${i + 1}`,
741
+ }));
742
+
743
+ render(
744
+ <ActionButtons row={mockRow} actions={mockActions} />
745
+ );
746
+
747
+ expect(screen.queryByTestId('select')).not.toBeInTheDocument();
748
+ expect(screen.getByTestId('action-1')).toBeInTheDocument();
749
+ expect(screen.getByTestId('action-6')).toBeInTheDocument();
750
+ });
751
+
752
+ it('calls onClick when action in dropdown is clicked', async () => {
753
+ const user = userEvent.setup();
754
+ const handleClick = vi.fn();
755
+
756
+ const mockActions: DataTableAction<TestData>[] = Array.from({ length: 7 }, (_, i) => ({
757
+ label: `Action ${i + 1}`,
758
+ onClick: handleClick,
759
+ testId: `action-${i + 1}`,
760
+ }));
761
+
762
+ render(
763
+ <ActionButtons row={mockRow} actions={mockActions} />
764
+ );
765
+
766
+ // Actions are rendered as select-item elements
767
+ const actionItems = screen.getAllByTestId('select-item');
768
+ expect(actionItems.length).toBeGreaterThan(0);
769
+
770
+ // Click the first action item
771
+ await user.click(actionItems[0]);
772
+
773
+ expect(handleClick).toHaveBeenCalledTimes(1);
774
+ expect(handleClick).toHaveBeenCalledWith(mockRow.original);
775
+ });
776
+ });
777
+
778
+ describe('Action Variants', () => {
779
+ it('applies destructive variant for destructive actions', () => {
780
+ const mockActions: DataTableAction<TestData>[] = [
781
+ {
782
+ label: 'Delete',
783
+ onClick: vi.fn(),
784
+ variant: 'destructive',
785
+ testId: 'delete-action',
786
+ },
787
+ ];
788
+
789
+ render(
790
+ <ActionButtons row={mockRow} actions={mockActions} />
791
+ );
792
+
793
+ const deleteButton = screen.getByTestId('delete-action');
794
+ expect(deleteButton).toBeInTheDocument();
795
+ });
796
+
797
+ it('applies default variant for non-destructive actions', () => {
798
+ const mockActions: DataTableAction<TestData>[] = [
799
+ {
800
+ label: 'Edit',
801
+ onClick: vi.fn(),
802
+ variant: 'default',
803
+ testId: 'edit-action',
804
+ },
805
+ ];
806
+
807
+ render(
808
+ <ActionButtons row={mockRow} actions={mockActions} />
809
+ );
810
+
811
+ const editButton = screen.getByTestId('edit-action');
812
+ expect(editButton).toBeInTheDocument();
813
+ });
814
+ });
815
+
816
+ describe('Accessibility', () => {
817
+ it('provides aria-label for action buttons', () => {
818
+ const mockActions: DataTableAction<TestData>[] = [
819
+ {
820
+ label: 'Edit',
821
+ onClick: vi.fn(),
822
+ testId: 'edit-action',
823
+ },
824
+ ];
825
+
826
+ render(
827
+ <ActionButtons row={mockRow} actions={mockActions} />
828
+ );
829
+
830
+ const editButton = screen.getByTestId('edit-action');
831
+ expect(editButton).toHaveAttribute('aria-label', 'Edit');
832
+ });
833
+
834
+ it('provides aria-disabled for disabled buttons', () => {
835
+ const mockActions: DataTableAction<TestData>[] = [
836
+ {
837
+ label: 'Edit',
838
+ onClick: vi.fn(),
839
+ disabled: true,
840
+ testId: 'edit-action',
841
+ },
842
+ ];
843
+
844
+ render(
845
+ <ActionButtons row={mockRow} actions={mockActions} />
846
+ );
847
+
848
+ const editButton = screen.getByTestId('edit-action');
849
+ expect(editButton).toHaveAttribute('aria-disabled', 'true');
850
+ });
851
+
852
+ it('provides screen reader text for dropdown trigger', () => {
853
+ const mockActions: DataTableAction<TestData>[] = Array.from({ length: 7 }, (_, i) => ({
854
+ label: `Action ${i + 1}`,
855
+ onClick: vi.fn(),
856
+ }));
857
+
858
+ render(
859
+ <ActionButtons row={mockRow} actions={mockActions} />
860
+ );
861
+
862
+ expect(screen.getByText('Open menu')).toBeInTheDocument();
863
+ });
864
+ });
865
+
866
+ describe('Edge Cases', () => {
867
+ it('handles undefined actions array gracefully', () => {
868
+ const { container } = render(
869
+ <ActionButtons row={mockRow} actions={undefined} />
870
+ );
871
+ expect(container.firstChild).toBeNull();
872
+ });
873
+
874
+ it('handles actions with missing onClick gracefully', () => {
875
+ const mockActions: DataTableAction<TestData>[] = [
876
+ {
877
+ label: 'Action',
878
+ onClick: undefined as any,
879
+ testId: 'action',
880
+ },
881
+ ];
882
+
883
+ // Should not throw error
884
+ expect(() => {
885
+ render(<ActionButtons row={mockRow} actions={mockActions} />);
886
+ }).not.toThrow();
887
+ });
888
+
889
+ it('handles rapid successive clicks', async () => {
890
+ const user = userEvent.setup();
891
+ const handleClick = vi.fn();
892
+
893
+ const mockActions: DataTableAction<TestData>[] = [
894
+ {
895
+ label: 'Edit',
896
+ onClick: handleClick,
897
+ testId: 'edit-action',
898
+ },
899
+ ];
900
+
901
+ render(
902
+ <ActionButtons row={mockRow} actions={mockActions} />
903
+ );
904
+
905
+ const editButton = screen.getByTestId('edit-action');
906
+ await user.click(editButton);
907
+ await user.click(editButton);
908
+ await user.click(editButton);
909
+
910
+ expect(handleClick).toHaveBeenCalledTimes(3);
911
+ });
912
+ });
913
+ });