@jmruthers/pace-core 0.5.105 → 0.5.107

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 (159) hide show
  1. package/dist/{DataTable-BE0OXZKQ.d.ts → DataTable-D5cBRca8.d.ts} +1 -1
  2. package/dist/{DataTable-LWHFLTEW.js → DataTable-H2WIR2DN.js} +3 -3
  3. package/dist/{chunk-QPCAGLUS.js → chunk-4OX5PXHX.js} +5 -2
  4. package/dist/chunk-4OX5PXHX.js.map +1 -0
  5. package/dist/{chunk-75G3NZWN.js → chunk-5JJCXTVE.js} +293 -37
  6. package/dist/chunk-5JJCXTVE.js.map +1 -0
  7. package/dist/{chunk-HBGPLSA5.js → chunk-DMNMZKWS.js} +70 -24
  8. package/dist/chunk-DMNMZKWS.js.map +1 -0
  9. package/dist/{chunk-AZFPGDCJ.js → chunk-EWKCROSF.js} +133 -49
  10. package/dist/chunk-EWKCROSF.js.map +1 -0
  11. package/dist/{chunk-4BWGRQBG.js → chunk-NFPV7MRN.js} +22 -2
  12. package/dist/chunk-NFPV7MRN.js.map +1 -0
  13. package/dist/{chunk-DWYMGSGU.js → chunk-VJ7MPS2K.js} +2 -2
  14. package/dist/components.d.ts +3 -3
  15. package/dist/components.js +4 -4
  16. package/dist/{formatting-BfDeV-ja.d.ts → formatting-BiEv5oEk.d.ts} +32 -2
  17. package/dist/hooks.d.ts +2 -2
  18. package/dist/hooks.js +3 -3
  19. package/dist/index.d.ts +5 -5
  20. package/dist/index.js +6 -6
  21. package/dist/{types-BDg1mAGG.d.ts → types-D4TVpDa1.d.ts} +24 -1
  22. package/dist/{useToast-Bm6TnSK-.d.ts → useToast-DRah6K-g.d.ts} +5 -2
  23. package/dist/utils.d.ts +3 -3
  24. package/dist/utils.js +2 -2
  25. package/docs/api/classes/ColumnFactory.md +1 -1
  26. package/docs/api/classes/ErrorBoundary.md +1 -1
  27. package/docs/api/classes/InvalidScopeError.md +1 -1
  28. package/docs/api/classes/MissingUserContextError.md +1 -1
  29. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  30. package/docs/api/classes/PermissionDeniedError.md +1 -1
  31. package/docs/api/classes/PublicErrorBoundary.md +1 -1
  32. package/docs/api/classes/RBACAuditManager.md +1 -1
  33. package/docs/api/classes/RBACCache.md +1 -1
  34. package/docs/api/classes/RBACEngine.md +1 -1
  35. package/docs/api/classes/RBACError.md +1 -1
  36. package/docs/api/classes/RBACNotInitializedError.md +1 -1
  37. package/docs/api/classes/SecureSupabaseClient.md +1 -1
  38. package/docs/api/classes/StorageUtils.md +1 -1
  39. package/docs/api/enums/FileCategory.md +1 -1
  40. package/docs/api/interfaces/AggregateConfig.md +4 -4
  41. package/docs/api/interfaces/ButtonProps.md +1 -1
  42. package/docs/api/interfaces/CardProps.md +1 -1
  43. package/docs/api/interfaces/ColorPalette.md +1 -1
  44. package/docs/api/interfaces/ColorShade.md +1 -1
  45. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  46. package/docs/api/interfaces/DataRecord.md +1 -1
  47. package/docs/api/interfaces/DataTableAction.md +18 -18
  48. package/docs/api/interfaces/DataTableColumn.md +115 -10
  49. package/docs/api/interfaces/DataTableProps.md +38 -38
  50. package/docs/api/interfaces/DataTableToolbarButton.md +7 -7
  51. package/docs/api/interfaces/EmptyStateConfig.md +5 -5
  52. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  53. package/docs/api/interfaces/FileDisplayProps.md +1 -1
  54. package/docs/api/interfaces/FileMetadata.md +1 -1
  55. package/docs/api/interfaces/FileReference.md +1 -1
  56. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  57. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  58. package/docs/api/interfaces/FileUploadProps.md +1 -1
  59. package/docs/api/interfaces/FooterProps.md +1 -1
  60. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  61. package/docs/api/interfaces/InputProps.md +1 -1
  62. package/docs/api/interfaces/LabelProps.md +1 -1
  63. package/docs/api/interfaces/LoginFormProps.md +1 -1
  64. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  65. package/docs/api/interfaces/NavigationContextType.md +1 -1
  66. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  67. package/docs/api/interfaces/NavigationItem.md +1 -1
  68. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  69. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  70. package/docs/api/interfaces/Organisation.md +1 -1
  71. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  72. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  73. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  74. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  75. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  76. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  77. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  78. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  79. package/docs/api/interfaces/PagePermissionGuardProps.md +1 -1
  80. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  81. package/docs/api/interfaces/PaletteData.md +1 -1
  82. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  83. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  84. package/docs/api/interfaces/PublicErrorBoundaryProps.md +1 -1
  85. package/docs/api/interfaces/PublicErrorBoundaryState.md +1 -1
  86. package/docs/api/interfaces/PublicLoadingSpinnerProps.md +1 -1
  87. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  88. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  89. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  90. package/docs/api/interfaces/RBACConfig.md +1 -1
  91. package/docs/api/interfaces/RBACLogger.md +1 -1
  92. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  93. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  94. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  95. package/docs/api/interfaces/RouteConfig.md +1 -1
  96. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  97. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  98. package/docs/api/interfaces/StorageConfig.md +1 -1
  99. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  100. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  101. package/docs/api/interfaces/StorageListOptions.md +1 -1
  102. package/docs/api/interfaces/StorageListResult.md +1 -1
  103. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  104. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  105. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  106. package/docs/api/interfaces/StyleImport.md +1 -1
  107. package/docs/api/interfaces/SwitchProps.md +1 -1
  108. package/docs/api/interfaces/ToastActionElement.md +1 -1
  109. package/docs/api/interfaces/ToastProps.md +1 -1
  110. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  111. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  112. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  113. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  114. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  115. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  116. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  117. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  118. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  119. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  120. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  121. package/docs/api/interfaces/UserEventAccess.md +1 -1
  122. package/docs/api/interfaces/UserMenuProps.md +1 -1
  123. package/docs/api/interfaces/UserProfile.md +1 -1
  124. package/docs/api/modules.md +39 -18
  125. package/docs/api-reference/utilities.md +26 -3
  126. package/docs/implementation-guides/data-tables.md +390 -0
  127. package/package.json +1 -1
  128. package/src/components/DataTable/DataTable.tsx +4 -0
  129. package/src/components/DataTable/__tests__/DataTableCore.test.tsx +25 -10
  130. package/src/components/DataTable/components/EditableRow.tsx +174 -16
  131. package/src/components/DataTable/components/UnifiedTableBody.tsx +205 -35
  132. package/src/components/DataTable/types.ts +34 -4
  133. package/src/components/FileDisplay/FileDisplay.test.tsx +184 -201
  134. package/src/components/FileDisplay/FileDisplay.tsx +40 -39
  135. package/src/components/NavigationMenu/NavigationMenu.test.tsx +189 -13
  136. package/src/components/NavigationMenu/NavigationMenu.tsx +142 -35
  137. package/src/components/PublicLayout/__tests__/PublicPageHeader.test.tsx +4 -4
  138. package/src/components/Toast/Toast.tsx +1 -1
  139. package/src/hooks/public/usePublicFileDisplay.ts +25 -15
  140. package/src/hooks/useEventTheme.test.ts +11 -0
  141. package/src/hooks/useFileDisplay.ts +11 -0
  142. package/src/hooks/useSecureDataAccess.test.ts +22 -5
  143. package/src/hooks/useToast.ts +11 -2
  144. package/src/providers/UnifiedAuthProvider.smoke.test.tsx +67 -3
  145. package/src/providers/__tests__/ProviderLifecycle.test.tsx +72 -4
  146. package/src/services/__tests__/OrganisationService.pagination.test.ts +10 -2
  147. package/src/styles/core.css +11 -0
  148. package/src/utils/__tests__/formatting.unit.test.ts +33 -0
  149. package/src/utils/file-reference.test.ts +44 -5
  150. package/src/utils/file-reference.ts +49 -26
  151. package/src/utils/formatting.ts +57 -2
  152. package/src/validation/__tests__/passwordSchema.unit.test.ts +3 -3
  153. package/dist/chunk-4BWGRQBG.js.map +0 -1
  154. package/dist/chunk-75G3NZWN.js.map +0 -1
  155. package/dist/chunk-AZFPGDCJ.js.map +0 -1
  156. package/dist/chunk-HBGPLSA5.js.map +0 -1
  157. package/dist/chunk-QPCAGLUS.js.map +0 -1
  158. /package/dist/{DataTable-LWHFLTEW.js.map → DataTable-H2WIR2DN.js.map} +0 -0
  159. /package/dist/{chunk-DWYMGSGU.js.map → chunk-VJ7MPS2K.js.map} +0 -0
@@ -2,9 +2,16 @@ import React from 'react';
2
2
  import { renderWithProviders, screen, userEvent, act, waitFor } from '../../__tests__/helpers/test-utils';
3
3
  import { FileDisplay } from './FileDisplay';
4
4
  import { createMockSupabaseClient } from '../../__tests__/helpers/test-utils';
5
+ import { UnifiedAuthProvider } from '../../providers/services/UnifiedAuthProvider';
5
6
 
6
7
  const supabase = createMockSupabaseClient() as any;
7
8
 
9
+ // Mock useIsPublicPage to return false so FileDisplay uses authenticated version
10
+ vi.mock('../PublicLayout/PublicPageProvider', () => ({
11
+ useIsPublicPage: vi.fn(() => false),
12
+ PublicPageContext: React.createContext(null)
13
+ }));
14
+
8
15
  vi.mock('../../hooks/useFileReference', () => ({
9
16
  useFileReferenceForRecord: vi.fn(),
10
17
  useFileReference: vi.fn(() => ({
@@ -14,11 +21,32 @@ vi.mock('../../hooks/useFileReference', () => ({
14
21
  })),
15
22
  }));
16
23
 
24
+ // Mock useFileDisplay hook which is used by FileDisplayAuthenticated
25
+ vi.mock('../../hooks/useFileDisplay', () => ({
26
+ useFileDisplay: vi.fn()
27
+ }));
28
+
17
29
  vi.mock('../../utils/storage/helpers', () => ({
18
30
  getPublicUrl: vi.fn((_, path: string) => `https://example.com/${path}`),
19
31
  getSignedUrl: vi.fn(async (_: any, path: string) => ({ url: `https://signed.example.com/${path}` })),
20
32
  }));
21
33
 
34
+ // Helper to render with UnifiedAuthProvider
35
+ const renderWithUnifiedAuth = (ui: React.ReactElement) => {
36
+ return renderWithProviders(
37
+ <UnifiedAuthProvider
38
+ supabaseClient={supabase}
39
+ appName="test-app"
40
+ idleTimeoutMs={30 * 60 * 1000}
41
+ warnBeforeMs={60 * 1000}
42
+ onIdleLogout={() => {}}
43
+ dangerouslyDisableInactivity={true}
44
+ >
45
+ {ui}
46
+ </UnifiedAuthProvider>
47
+ );
48
+ };
49
+
22
50
  const baseProps = {
23
51
  supabase,
24
52
  table_name: 'person',
@@ -27,59 +55,62 @@ const baseProps = {
27
55
  };
28
56
 
29
57
  describe('[component] FileDisplay', () => {
30
- beforeEach(() => {
58
+ beforeEach(async () => {
31
59
  vi.clearAllMocks();
60
+ // Set up default mock for useFileDisplay
61
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
62
+ useFileDisplay.mockReturnValue({
63
+ isLoading: false,
64
+ error: null,
65
+ fileUrl: null,
66
+ fileReference: null,
67
+ fileReferences: [],
68
+ fileCount: 0,
69
+ fileUrls: new Map(),
70
+ refetch: vi.fn(),
71
+ });
32
72
  });
33
73
 
34
74
  it('renders error state and clears error on retry', async () => {
35
- const clearError = vi.fn();
36
- const useFileReferenceForRecord = (await import('../../hooks/useFileReference')).useFileReferenceForRecord as unknown as vi.Mock;
37
- useFileReferenceForRecord.mockReturnValue({
75
+ const refetch = vi.fn();
76
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
77
+ useFileDisplay.mockReturnValue({
38
78
  isLoading: false,
39
- error: 'boom',
79
+ error: new Error('boom'),
40
80
  fileUrl: null,
41
81
  fileReference: null,
42
82
  fileReferences: [],
43
83
  fileCount: 0,
44
- loadFileReference: vi.fn(),
45
- loadFileUrl: vi.fn(),
46
- loadFileReferences: vi.fn(),
47
- loadFileCount: vi.fn(),
48
- deleteFile: vi.fn(),
49
- clearError,
84
+ fileUrls: new Map(),
85
+ refetch,
50
86
  });
51
87
 
52
- renderWithProviders(<FileDisplay {...baseProps} />);
88
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} />);
53
89
 
54
90
  expect(screen.getByText(/Error loading file: boom/i)).toBeInTheDocument();
55
91
  const retry = screen.getByRole('button', { name: /Try again/i });
56
92
  await userEvent.click(retry);
57
- expect(clearError).toHaveBeenCalled();
93
+ expect(refetch).toHaveBeenCalled();
58
94
  });
59
95
 
60
96
  it('renders empty state when no files are found', async () => {
61
- const useFileReferenceForRecord = (await import('../../hooks/useFileReference')).useFileReferenceForRecord as unknown as vi.Mock;
62
- useFileReferenceForRecord.mockReturnValue({
97
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
98
+ useFileDisplay.mockReturnValue({
63
99
  isLoading: false,
64
100
  error: null,
65
101
  fileUrl: null,
66
102
  fileReference: null,
67
103
  fileReferences: [],
68
104
  fileCount: 0,
69
- loadFileReference: vi.fn(),
70
- loadFileUrl: vi.fn(),
71
- loadFileReferences: vi.fn(),
72
- loadFileCount: vi.fn(),
73
- deleteFile: vi.fn(),
74
- clearError: vi.fn(),
105
+ fileUrls: new Map(),
106
+ refetch: vi.fn(),
75
107
  });
76
108
 
77
- renderWithProviders(<FileDisplay {...baseProps} />);
109
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} />);
78
110
  expect(screen.getByText(/No files found/i)).toBeInTheDocument();
79
111
  });
80
112
 
81
113
  it('renders a single image with delete button and handles delete', async () => {
82
- const deleteFile = vi.fn(async () => true);
83
114
  const categoryFile = {
84
115
  id: 'fr-1',
85
116
  table_name: 'person',
@@ -90,49 +121,36 @@ describe('[component] FileDisplay', () => {
90
121
  file_metadata: { fileName: 'file.png', fileSize: 2048, fileType: 'image/png' },
91
122
  };
92
123
 
93
- // Mock getFilesByCategory for category mode
94
- const useFileReference = (await import('../../hooks/useFileReference')).useFileReference as unknown as vi.Mock;
95
- useFileReference.mockReturnValue({
96
- getFilesByCategory: vi.fn(() => Promise.resolve([categoryFile])),
97
- isLoading: false,
98
- error: null,
99
- });
124
+ const fileUrlsMap = new Map<string, string>();
125
+ fileUrlsMap.set('fr-1', 'https://example.com/file.png');
100
126
 
101
- const useFileReferenceForRecord = (await import('../../hooks/useFileReference')).useFileReferenceForRecord as unknown as vi.Mock;
102
- useFileReferenceForRecord.mockReturnValue({
127
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
128
+ useFileDisplay.mockReturnValue({
103
129
  isLoading: false,
104
130
  error: null,
105
- fileUrl: null,
106
- fileReference: null,
107
- fileReferences: [],
108
- fileCount: 0,
109
- loadFileReference: vi.fn(),
110
- loadFileUrl: vi.fn(),
111
- loadFileReferences: vi.fn(),
112
- loadFileCount: vi.fn(),
113
- deleteFile,
114
- clearError: vi.fn(),
115
- });
116
-
117
- // Mock getSignedUrl for the category file URL generation
118
- const { getSignedUrl } = await import('../../utils/storage/helpers');
119
- vi.spyOn(await import('../../utils/storage/helpers'), 'getSignedUrl').mockResolvedValue({
120
- url: 'https://example.com/file.png'
131
+ fileUrl: 'https://example.com/file.png',
132
+ fileReference: categoryFile,
133
+ fileReferences: [categoryFile],
134
+ fileCount: 1,
135
+ fileUrls: fileUrlsMap,
136
+ refetch: vi.fn(),
121
137
  });
122
138
 
123
139
  // Confirm dialog needs to be accepted for deletion
124
140
  const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
125
141
 
126
- renderWithProviders(<FileDisplay {...baseProps} showDelete category={1 as any} />);
142
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} showDelete category={1 as any} />);
127
143
 
128
144
  const image = await screen.findByRole('img', { name: /file\.png/i });
129
145
  expect(image).toBeInTheDocument();
130
146
 
131
147
  const deleteBtn = screen.getByRole('button', { name: /Delete file/i });
132
148
  await userEvent.click(deleteBtn);
133
- expect(confirmSpy).toHaveBeenCalled();
134
- // Note: deleteFile from useFileReferenceForRecord won't be called in category mode
135
- // Category mode uses a different delete mechanism
149
+
150
+ // Dialog should open
151
+ expect(await screen.findByText('Confirm Delete')).toBeInTheDocument();
152
+ const confirmDeleteBtn = screen.getByRole('button', { name: /^Delete$/i });
153
+ await userEvent.click(confirmDeleteBtn);
136
154
 
137
155
  confirmSpy.mockRestore();
138
156
  });
@@ -148,42 +166,24 @@ describe('[component] FileDisplay', () => {
148
166
  file_metadata: { fileName: 'report.pdf', fileSize: 4096, fileType: 'application/pdf' },
149
167
  };
150
168
 
151
- // Mock getFilesByCategory for category mode
152
- const useFileReference = (await import('../../hooks/useFileReference')).useFileReference as unknown as vi.Mock;
153
- useFileReference.mockReturnValue({
154
- getFilesByCategory: vi.fn(() => Promise.resolve([categoryFile])),
155
- isLoading: false,
156
- error: null,
157
- });
158
-
159
- const useFileReferenceForRecord = (await import('../../hooks/useFileReference')).useFileReferenceForRecord as unknown as vi.Mock;
160
- useFileReferenceForRecord.mockReturnValue({
169
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
170
+ useFileDisplay.mockReturnValue({
161
171
  isLoading: false,
162
172
  error: null,
163
- fileUrl: null,
164
- fileReference: null,
165
- fileReferences: [],
166
- fileCount: 0,
167
- loadFileReference: vi.fn(),
168
- loadFileUrl: vi.fn(),
169
- loadFileReferences: vi.fn(),
170
- loadFileCount: vi.fn(),
171
- deleteFile: vi.fn(),
172
- clearError: vi.fn(),
173
- });
174
-
175
- // Mock getSignedUrl for the category file URL generation
176
- const { getSignedUrl } = await import('../../utils/storage/helpers');
177
- vi.spyOn(await import('../../utils/storage/helpers'), 'getSignedUrl').mockResolvedValue({
178
- url: null // PDFs don't need URLs for display
173
+ fileUrl: null, // PDFs don't need URLs for display
174
+ fileReference: categoryFile,
175
+ fileReferences: [categoryFile],
176
+ fileCount: 1,
177
+ fileUrls: new Map(),
178
+ refetch: vi.fn(),
179
179
  });
180
180
 
181
- renderWithProviders(<FileDisplay {...baseProps} category={1 as any} />);
181
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} category={1 as any} />);
182
182
  expect(await screen.findByText('report.pdf')).toBeInTheDocument();
183
183
  expect(screen.getByText(/application\/pdf/i)).toBeInTheDocument();
184
184
  });
185
185
 
186
- it.skip('renders multiple files and shows download link for non-image files', async () => {
186
+ it('renders multiple files and shows download link for non-image files', async () => {
187
187
  const fileRefs = [
188
188
  {
189
189
  id: 'f1',
@@ -205,60 +205,45 @@ describe('[component] FileDisplay', () => {
205
205
  },
206
206
  ];
207
207
 
208
- const useFileReferenceForRecord = (await import('../../hooks/useFileReference')).useFileReferenceForRecord as unknown as vi.Mock;
209
- useFileReferenceForRecord.mockReturnValue({
208
+ // Create a stable Map reference that won't trigger unnecessary resets
209
+ const fileUrlsMap = new Map<string, string>();
210
+ fileUrlsMap.set('f1', 'https://example.com/bucket/path/a.png');
211
+ fileUrlsMap.set('f2', 'https://signed.example.com/bucket/path/b.pdf');
212
+
213
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
214
+ useFileDisplay.mockReturnValue({
210
215
  isLoading: false,
211
216
  error: null,
212
217
  fileUrl: null,
213
218
  fileReference: null,
214
219
  fileReferences: fileRefs,
215
220
  fileCount: 2,
216
- loadFileReference: vi.fn(),
217
- loadFileUrl: vi.fn(),
218
- loadFileReferences: vi.fn(),
219
- loadFileCount: vi.fn(),
220
- deleteFile: vi.fn(),
221
- clearError: vi.fn(),
221
+ fileUrls: fileUrlsMap,
222
+ refetch: vi.fn(),
222
223
  });
223
224
 
224
- // Verify mocks are set up
225
- const storageHelpers = await import('../../utils/storage/helpers');
226
- const getPublicUrlMock = vi.mocked(storageHelpers.getPublicUrl);
227
- const getSignedUrlMock = vi.mocked(storageHelpers.getSignedUrl);
225
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} />);
228
226
 
229
- renderWithProviders(<FileDisplay {...baseProps} />);
230
-
231
- // Wait for files to render (they should appear immediately)
227
+ // Wait for files to render - both file names should appear
232
228
  await waitFor(() => {
233
229
  expect(screen.getByText('a.png')).toBeInTheDocument();
234
230
  expect(screen.getByText('b.pdf')).toBeInTheDocument();
235
231
  });
236
232
 
237
- // The image URL loads asynchronously in FileDisplayContent's useEffect (line 91-140)
238
- // It calls getPublicUrl for public files (synchronously), then updates state asynchronously
239
- // Wait for getPublicUrl to be called (it should be called immediately for the public file)
233
+ // The component syncs fileUrls in useEffect - wait for the sync to complete
234
+ // The download link appears when canDownload is true: !isImage && fileUrl (from internalFileUrls map)
240
235
  await waitFor(() => {
241
- expect(getPublicUrlMock).toHaveBeenCalledWith(
242
- expect.anything(),
243
- 'bucket/path/a.png',
244
- true
245
- );
246
- }, { timeout: 2000 });
247
-
248
- // Wait for the image to appear - React state update happens asynchronously
249
- const image = await screen.findByRole('img', { name: /a\.png/i }, { timeout: 5000 });
250
- expect(image).toBeInTheDocument();
251
- // The top-level mock returns `https://example.com/${path}`, so for 'bucket/path/a.png':
252
- expect(image).toHaveAttribute('src', 'https://example.com/bucket/path/a.png');
253
-
254
- // Wait for getSignedUrl to be called for the private PDF file
255
- await waitFor(() => {
256
- expect(getSignedUrlMock).toHaveBeenCalled();
257
- }, { timeout: 2000 });
236
+ const link = screen.queryByRole('link');
237
+ if (link) {
238
+ expect(link).toHaveAttribute('href', 'bucket/path/b.pdf');
239
+ expect(link).toHaveAttribute('download', 'b.pdf');
240
+ return true;
241
+ }
242
+ return false;
243
+ }, { timeout: 3000 });
258
244
 
259
- // The PDF download link should appear once the signed URL loads
260
- // Note: canDownload requires fileUrl to exist (line 285), and download link uses file_path (line 318)
261
- const link = await screen.findByRole('link', { name: /↓/i }, { timeout: 5000 });
245
+ // Verify download link exists and has correct attributes
246
+ const link = screen.getByRole('link');
262
247
  expect(link).toBeInTheDocument();
263
248
  expect(link).toHaveAttribute('href', 'bucket/path/b.pdf');
264
249
  expect(link).toHaveAttribute('download', 'b.pdf');
@@ -275,27 +260,22 @@ describe('[component] FileDisplay', () => {
275
260
  file_metadata: { fileName: 'logo.png', fileSize: 1024, fileType: 'image/png' },
276
261
  };
277
262
 
278
- const useFileReferenceForRecord = (await import('../../hooks/useFileReference')).useFileReferenceForRecord as unknown as vi.Mock;
279
- useFileReferenceForRecord.mockReturnValue({
263
+ const fileUrlsMap = new Map<string, string>();
264
+ fileUrlsMap.set('fr-3', 'https://example.com/logo.png');
265
+
266
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
267
+ useFileDisplay.mockReturnValue({
280
268
  isLoading: false,
281
269
  error: null,
282
- fileUrl: null, // displayOnly mode generates its own URL
283
- fileReference: null, // displayOnly mode uses first from fileReferences
270
+ fileUrl: null,
271
+ fileReference: null,
284
272
  fileReferences: [logoFileRef],
285
273
  fileCount: 1,
286
- loadFileReference: vi.fn(),
287
- loadFileUrl: vi.fn(),
288
- loadFileReferences: vi.fn(),
289
- loadFileCount: vi.fn(),
290
- deleteFile: vi.fn(),
291
- clearError: vi.fn(),
274
+ fileUrls: fileUrlsMap,
275
+ refetch: vi.fn(),
292
276
  });
293
277
 
294
- // Mock getPublicUrl for displayOnly URL generation
295
- const { getPublicUrl } = await import('../../utils/storage/helpers');
296
- vi.spyOn(await import('../../utils/storage/helpers'), 'getPublicUrl').mockReturnValue('https://example.com/logo.png');
297
-
298
- renderWithProviders(<FileDisplay {...baseProps} displayOnly />);
278
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} displayOnly />);
299
279
 
300
280
  // Wait for async URL generation in displayOnly mode
301
281
  const image = await screen.findByRole('img', { name: /logo\.png/i });
@@ -310,43 +290,32 @@ describe('[component] FileDisplay', () => {
310
290
  });
311
291
 
312
292
  it('renders with wrapper when displayOnly is true but showDelete is also true', async () => {
313
- const deleteFile = vi.fn(async () => true);
314
- const useFileReferenceForRecord = (await import('../../hooks/useFileReference')).useFileReferenceForRecord as unknown as vi.Mock;
315
- useFileReferenceForRecord.mockReturnValue({
293
+ const logoFileRef = {
294
+ id: 'fr-4',
295
+ table_name: 'event',
296
+ record_id: 'event-1',
297
+ organisation_id: 'org-1',
298
+ file_path: 'bucket/path/logo.png',
299
+ is_public: true,
300
+ file_metadata: { fileName: 'logo.png', fileSize: 1024, fileType: 'image/png' },
301
+ };
302
+
303
+ const fileUrlsMap = new Map<string, string>();
304
+ fileUrlsMap.set('fr-4', 'https://example.com/logo.png');
305
+
306
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
307
+ useFileDisplay.mockReturnValue({
316
308
  isLoading: false,
317
309
  error: null,
318
310
  fileUrl: 'https://example.com/logo.png',
319
- fileReference: {
320
- id: 'fr-4',
321
- table_name: 'event',
322
- record_id: 'event-1',
323
- organisation_id: 'org-1',
324
- file_path: 'bucket/path/logo.png',
325
- is_public: true,
326
- file_metadata: { fileName: 'logo.png', fileSize: 1024, fileType: 'image/png' },
327
- },
328
- fileReferences: [
329
- {
330
- id: 'fr-4',
331
- table_name: 'event',
332
- record_id: 'event-1',
333
- organisation_id: 'org-1',
334
- file_path: 'bucket/path/logo.png',
335
- is_public: true,
336
- file_metadata: { fileName: 'logo.png', fileSize: 1024, fileType: 'image/png' },
337
- },
338
- ],
311
+ fileReference: logoFileRef,
312
+ fileReferences: [logoFileRef],
339
313
  fileCount: 1,
340
- loadFileReference: vi.fn(),
341
- loadFileUrl: vi.fn(),
342
- loadFileReferences: vi.fn(),
343
- loadFileCount: vi.fn(),
344
- deleteFile,
345
- clearError: vi.fn(),
314
+ fileUrls: fileUrlsMap,
315
+ refetch: vi.fn(),
346
316
  });
347
317
 
348
- const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
349
- renderWithProviders(<FileDisplay {...baseProps} displayOnly showDelete />);
318
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} displayOnly showDelete />);
350
319
 
351
320
  const image = await screen.findByRole('img', { name: /logo\.png/i });
352
321
  expect(image).toBeInTheDocument();
@@ -357,55 +326,56 @@ describe('[component] FileDisplay', () => {
357
326
 
358
327
  const deleteBtn = screen.getByRole('button', { name: /Delete file/i });
359
328
  await userEvent.click(deleteBtn);
360
- expect(deleteFile).toHaveBeenCalled();
361
-
362
- confirmSpy.mockRestore();
329
+
330
+ // Dialog should open
331
+ expect(await screen.findByText('Confirm Delete')).toBeInTheDocument();
332
+ const confirmDeleteBtn = screen.getByRole('button', { name: /^Delete$/i });
333
+ await userEvent.click(confirmDeleteBtn);
363
334
  });
364
335
 
365
336
  it('prefers image files when displayOnly is true and multiple files exist', async () => {
366
- const useFileReferenceForRecord = (await import('../../hooks/useFileReference')).useFileReferenceForRecord as unknown as vi.Mock;
367
- useFileReferenceForRecord.mockReturnValue({
337
+ const docFile = {
338
+ id: 'fr-doc',
339
+ table_name: 'person',
340
+ record_id: 'rec-1',
341
+ organisation_id: 'org-1',
342
+ file_path: 'bucket/path/doc.pdf',
343
+ is_public: false,
344
+ file_metadata: { fileName: 'doc.pdf', fileSize: 2048, fileType: 'application/pdf' },
345
+ };
346
+ const imageFile = {
347
+ id: 'fr-img',
348
+ table_name: 'person',
349
+ record_id: 'rec-1',
350
+ organisation_id: 'org-1',
351
+ file_path: 'bucket/path/image.png',
352
+ is_public: true,
353
+ file_metadata: { fileName: 'image.png', fileSize: 1024, fileType: 'image/png' },
354
+ };
355
+
356
+ const fileUrlsMap = new Map<string, string>();
357
+ fileUrlsMap.set('fr-img', 'https://example.com/image.png');
358
+
359
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
360
+ useFileDisplay.mockReturnValue({
368
361
  isLoading: false,
369
362
  error: null,
370
363
  fileUrl: null,
371
364
  fileReference: null,
372
- fileReferences: [
373
- {
374
- id: 'fr-doc',
375
- table_name: 'person',
376
- record_id: 'rec-1',
377
- organisation_id: 'org-1',
378
- file_path: 'bucket/path/doc.pdf',
379
- is_public: false,
380
- file_metadata: { fileName: 'doc.pdf', fileSize: 2048, fileType: 'application/pdf' },
381
- },
382
- {
383
- id: 'fr-img',
384
- table_name: 'person',
385
- record_id: 'rec-1',
386
- organisation_id: 'org-1',
387
- file_path: 'bucket/path/image.png',
388
- is_public: true,
389
- file_metadata: { fileName: 'image.png', fileSize: 1024, fileType: 'image/png' },
390
- },
391
- ],
365
+ fileReferences: [docFile, imageFile], // PDF first, but component should prefer image
392
366
  fileCount: 2,
393
- loadFileReference: vi.fn(),
394
- loadFileUrl: vi.fn(),
395
- loadFileReferences: vi.fn(),
396
- loadFileCount: vi.fn(),
397
- deleteFile: vi.fn(),
398
- clearError: vi.fn(),
367
+ fileUrls: fileUrlsMap,
368
+ refetch: vi.fn(),
399
369
  });
400
370
 
401
371
  // This test verifies that displayOnly logic prefers images
402
372
  // The component should select the image file over the PDF
403
- renderWithProviders(<FileDisplay {...baseProps} displayOnly />);
373
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} displayOnly />);
404
374
 
405
375
  // Wait for the component to process and select the image
406
376
  await waitFor(() => {
407
377
  // The component should eventually show the image file
408
- expect(useFileReferenceForRecord).toHaveBeenCalled();
378
+ expect(screen.queryByRole('img', { name: /image\.png/i })).toBeInTheDocument();
409
379
  }, { timeout: 3000 });
410
380
  });
411
381
  });
@@ -431,8 +401,20 @@ const mockUseFileReference = vi.fn();
431
401
  const mockGetPublicUrl = vi.fn();
432
402
  const mockGetSignedUrl = vi.fn();
433
403
 
434
- beforeEach(() => {
404
+ beforeEach(async () => {
435
405
  vi.clearAllMocks();
406
+ // Set up default mock for useFileDisplay
407
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
408
+ useFileDisplay.mockReturnValue({
409
+ isLoading: false,
410
+ error: null,
411
+ fileUrl: null,
412
+ fileReference: null,
413
+ fileReferences: [],
414
+ fileCount: 0,
415
+ fileUrls: new Map(),
416
+ refetch: vi.fn(),
417
+ });
436
418
  mockUseFileReferenceForRecord.mockReturnValue({
437
419
  isLoading: false,
438
420
  error: null,
@@ -472,9 +454,10 @@ const defaultProps = {
472
454
  };
473
455
 
474
456
  describe('FileDisplay Component (relocated)', () => {
475
- it('renders with default props', () => {
476
- renderWithProviders(<FileDisplay {...defaultProps} />);
477
- expect(mockUseFileReferenceForRecord).toHaveBeenCalled();
457
+ it('renders with default props', async () => {
458
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
459
+ renderWithUnifiedAuth(<FileDisplay {...defaultProps} />);
460
+ expect(useFileDisplay).toHaveBeenCalled();
478
461
  });
479
462
  });
480
463
 
@@ -130,21 +130,20 @@ function FileDisplayContent({
130
130
  }, [fallbackSize, className]);
131
131
 
132
132
  // Sync fileUrls prop with internal state
133
- useEffect(() => {
134
- setInternalFileUrls(new Map(fileUrls));
135
- }, [fileUrls]);
136
-
137
- // Track file references to detect when they change
133
+ // Track file references to detect when they change and sync URLs
138
134
  useEffect(() => {
139
135
  const currentIds = fileReferences.map(f => f.id).join(',');
140
136
  const prevIds = fileReferencesRef.current.map(f => f.id).join(',');
141
137
 
142
138
  if (currentIds !== prevIds) {
143
139
  fileReferencesRef.current = fileReferences;
144
- // Reset internal URLs when file references change
145
- setInternalFileUrls(new Map());
140
+ // Reset internal URLs when file references change, then immediately sync from props
141
+ setInternalFileUrls(new Map(fileUrls));
142
+ } else {
143
+ // If file references haven't changed, just sync URLs
144
+ setInternalFileUrls(new Map(fileUrls));
146
145
  }
147
- }, [fileReferences]);
146
+ }, [fileReferences, fileUrls]);
148
147
 
149
148
  const handleDeleteClick = () => {
150
149
  setDeleteDialogOpen(true);
@@ -201,6 +200,38 @@ function FileDisplayContent({
201
200
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
202
201
  };
203
202
 
203
+ // Check for errors first - errors should be shown even if fileCount is 0
204
+ if (error) {
205
+ if (ErrorComponent) {
206
+ return <ErrorComponent error={error} retry={clearError} />;
207
+ }
208
+
209
+ // Show fallback if enabled
210
+ if (showFallback) {
211
+ return (
212
+ <div className={fallbackClasses} title="File unavailable">
213
+ {computedFallbackText}
214
+ </div>
215
+ );
216
+ }
217
+
218
+ return (
219
+ <div className={`p-4 bg-acc-50 border border-acc-200 rounded-lg ${className}`}>
220
+ <div className="text-acc-600">
221
+ Error loading file: {error instanceof Error ? error.message : String(error)}
222
+ </div>
223
+ {clearError && (
224
+ <button
225
+ onClick={clearError}
226
+ className="mt-2 text-sm text-acc-700 hover:text-acc-800 underline"
227
+ >
228
+ Try again
229
+ </button>
230
+ )}
231
+ </div>
232
+ );
233
+ }
234
+
204
235
  // Show fallback immediately if enabled and we have no files (even during loading)
205
236
  // This provides better UX by showing fallback UI instead of a spinner when we know there are no files
206
237
  if (fileCount === 0 && !isLoading) {
@@ -243,37 +274,6 @@ function FileDisplayContent({
243
274
  );
244
275
  }
245
276
 
246
- if (error) {
247
- if (ErrorComponent) {
248
- return <ErrorComponent error={error} retry={clearError} />;
249
- }
250
-
251
- // Show fallback if enabled
252
- if (showFallback) {
253
- return (
254
- <div className={fallbackClasses} title="File unavailable">
255
- {computedFallbackText}
256
- </div>
257
- );
258
- }
259
-
260
- return (
261
- <div className={`p-4 bg-acc-50 border border-acc-200 rounded-lg ${className}`}>
262
- <div className="text-acc-600">
263
- Error loading file: {error instanceof Error ? error.message : String(error)}
264
- </div>
265
- {clearError && (
266
- <button
267
- onClick={clearError}
268
- className="mt-2 text-sm text-acc-700 hover:text-acc-800 underline"
269
- >
270
- Try again
271
- </button>
272
- )}
273
- </div>
274
- );
275
- }
276
-
277
277
  // Single file display (when category or displayOnly is specified)
278
278
  if ((category || displayOnly) && fileReference) {
279
279
  const isImage = fileReference.file_metadata.fileType?.startsWith('image/');
@@ -697,6 +697,7 @@ function FileDisplayAuthenticated({
697
697
  className={className}
698
698
  children={children}
699
699
  onDelete={showDelete ? handleDelete : undefined}
700
+ clearError={refetch}
700
701
  organisation_id={organisation_id}
701
702
  loadingComponent={loadingComponent}
702
703
  errorComponent={errorComponent}