@jmruthers/pace-core 0.5.186 → 0.5.187

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 (284) hide show
  1. package/dist/{DataTable-Z9NLVJh0.d.ts → DataTable-IVYljGJ6.d.ts} +1 -1
  2. package/dist/{DataTable-IX2NBUTP.js → DataTable-K3RJRSOX.js} +7 -7
  3. package/dist/{PublicPageProvider-DIzEzwKl.d.ts → PublicPageProvider-DrLDztHt.d.ts} +211 -106
  4. package/dist/{UnifiedAuthProvider-A4BCQRJY.js → UnifiedAuthProvider-B76OWOAT.js} +2 -2
  5. package/dist/{api-BMFCXVQX.js → api-YP7XD5L6.js} +3 -3
  6. package/dist/{audit-WRS3KJKI.js → audit-B5P6FFIR.js} +2 -2
  7. package/dist/{chunk-445GEP27.js → chunk-3IC5WCMO.js} +33 -8
  8. package/dist/chunk-3IC5WCMO.js.map +1 -0
  9. package/dist/{chunk-OALXJH4Y.js → chunk-3NFNJOO7.js} +8 -8
  10. package/dist/chunk-3NFNJOO7.js.map +1 -0
  11. package/dist/{chunk-FSFQFJCU.js → chunk-63FOKYGO.js} +174 -6
  12. package/dist/chunk-63FOKYGO.js.map +1 -0
  13. package/dist/{chunk-TC7D3CR3.js → chunk-C4OYJOV4.js} +556 -101
  14. package/dist/chunk-C4OYJOV4.js.map +1 -0
  15. package/dist/{chunk-HGPQUCBC.js → chunk-FMTK4XNN.js} +3 -3
  16. package/dist/{chunk-U6WNSFX5.js → chunk-HEHYGYOX.js} +279 -44
  17. package/dist/chunk-HEHYGYOX.js.map +1 -0
  18. package/dist/{chunk-XAUHJD3L.js → chunk-K2JGDXGU.js} +2 -2
  19. package/dist/{chunk-HDCUMOOI.js → chunk-LBBUPSSC.js} +792 -559
  20. package/dist/chunk-LBBUPSSC.js.map +1 -0
  21. package/dist/{chunk-UQWSHFVX.js → chunk-SAUPYVLF.js} +1 -1
  22. package/dist/{chunk-UQWSHFVX.js.map → chunk-SAUPYVLF.js.map} +1 -1
  23. package/dist/{chunk-GRIQLQ52.js → chunk-T6ZJVI3A.js} +27 -23
  24. package/dist/chunk-T6ZJVI3A.js.map +1 -0
  25. package/dist/{chunk-DAGICKHT.js → chunk-ULX5FYEM.js} +3 -3
  26. package/dist/{chunk-FXFJRTKI.js → chunk-WK2Y6TGA.js} +3 -3
  27. package/dist/chunk-WK2Y6TGA.js.map +1 -0
  28. package/dist/chunk-YHCN776L.js +447 -0
  29. package/dist/chunk-YHCN776L.js.map +1 -0
  30. package/dist/components.d.ts +4 -4
  31. package/dist/components.js +12 -10
  32. package/dist/components.js.map +1 -1
  33. package/dist/{file-reference-PRTSLxKx.d.ts → file-reference-D037xOFK.d.ts} +0 -1
  34. package/dist/hooks.d.ts +221 -6
  35. package/dist/hooks.js +146 -49
  36. package/dist/hooks.js.map +1 -1
  37. package/dist/index.d.ts +24 -9
  38. package/dist/index.js +62 -28
  39. package/dist/index.js.map +1 -1
  40. package/dist/providers.js +1 -1
  41. package/dist/rbac/index.d.ts +124 -7
  42. package/dist/rbac/index.js +27 -7
  43. package/dist/{types-DUyCRSTj.d.ts → types-Bwgl--Xo.d.ts} +162 -1
  44. package/dist/types.d.ts +1 -1
  45. package/dist/types.js +1 -1
  46. package/dist/{usePublicRouteParams-D71QLlg4.d.ts → usePublicRouteParams-CTDELQ7H.d.ts} +2 -2
  47. package/dist/utils.d.ts +213 -3
  48. package/dist/utils.js +22 -2
  49. package/dist/utils.js.map +1 -1
  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/Logger.md +1 -1
  54. package/docs/api/classes/MissingUserContextError.md +1 -1
  55. package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
  56. package/docs/api/classes/PermissionDeniedError.md +1 -1
  57. package/docs/api/classes/RBACAuditManager.md +21 -17
  58. package/docs/api/classes/RBACCache.md +31 -23
  59. package/docs/api/classes/RBACEngine.md +5 -5
  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/enums/LogLevel.md +1 -1
  66. package/docs/api/enums/RBACErrorCode.md +1 -1
  67. package/docs/api/enums/RPCFunction.md +1 -1
  68. package/docs/api/interfaces/AddressFieldProps.md +241 -0
  69. package/docs/api/interfaces/AddressFieldRef.md +94 -0
  70. package/docs/api/interfaces/AggregateConfig.md +1 -1
  71. package/docs/api/interfaces/AutocompleteOptions.md +75 -0
  72. package/docs/api/interfaces/BadgeProps.md +1 -1
  73. package/docs/api/interfaces/ButtonProps.md +1 -1
  74. package/docs/api/interfaces/CalendarProps.md +1 -1
  75. package/docs/api/interfaces/CardProps.md +1 -1
  76. package/docs/api/interfaces/ColorPalette.md +1 -1
  77. package/docs/api/interfaces/ColorShade.md +1 -1
  78. package/docs/api/interfaces/ComplianceResult.md +1 -1
  79. package/docs/api/interfaces/DataAccessRecord.md +1 -1
  80. package/docs/api/interfaces/DataRecord.md +1 -1
  81. package/docs/api/interfaces/DataTableAction.md +1 -1
  82. package/docs/api/interfaces/DataTableColumn.md +1 -1
  83. package/docs/api/interfaces/DataTableProps.md +1 -1
  84. package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
  85. package/docs/api/interfaces/DatabaseComplianceResult.md +1 -1
  86. package/docs/api/interfaces/DatabaseIssue.md +1 -1
  87. package/docs/api/interfaces/EmptyStateConfig.md +1 -1
  88. package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
  89. package/docs/api/interfaces/EventAppRoleData.md +1 -1
  90. package/docs/api/interfaces/ExportColumn.md +1 -1
  91. package/docs/api/interfaces/ExportOptions.md +1 -1
  92. package/docs/api/interfaces/FileDisplayProps.md +15 -15
  93. package/docs/api/interfaces/FileMetadata.md +1 -1
  94. package/docs/api/interfaces/FileReference.md +1 -1
  95. package/docs/api/interfaces/FileSizeLimits.md +1 -1
  96. package/docs/api/interfaces/FileUploadOptions.md +1 -1
  97. package/docs/api/interfaces/FileUploadProps.md +1 -1
  98. package/docs/api/interfaces/FooterProps.md +1 -1
  99. package/docs/api/interfaces/FormFieldProps.md +1 -1
  100. package/docs/api/interfaces/FormProps.md +1 -1
  101. package/docs/api/interfaces/GrantEventAppRoleParams.md +1 -1
  102. package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
  103. package/docs/api/interfaces/InputProps.md +1 -1
  104. package/docs/api/interfaces/LabelProps.md +1 -1
  105. package/docs/api/interfaces/LoggerConfig.md +1 -1
  106. package/docs/api/interfaces/LoginFormProps.md +1 -1
  107. package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
  108. package/docs/api/interfaces/NavigationContextType.md +1 -1
  109. package/docs/api/interfaces/NavigationGuardProps.md +1 -1
  110. package/docs/api/interfaces/NavigationItem.md +1 -1
  111. package/docs/api/interfaces/NavigationMenuProps.md +1 -1
  112. package/docs/api/interfaces/NavigationProviderProps.md +1 -1
  113. package/docs/api/interfaces/Organisation.md +1 -1
  114. package/docs/api/interfaces/OrganisationContextType.md +1 -1
  115. package/docs/api/interfaces/OrganisationMembership.md +1 -1
  116. package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
  117. package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
  118. package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
  119. package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
  120. package/docs/api/interfaces/PageAccessRecord.md +1 -1
  121. package/docs/api/interfaces/PagePermissionContextType.md +1 -1
  122. package/docs/api/interfaces/PagePermissionGuardProps.md +11 -11
  123. package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
  124. package/docs/api/interfaces/PaletteData.md +1 -1
  125. package/docs/api/interfaces/ParsedAddress.md +120 -0
  126. package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
  127. package/docs/api/interfaces/ProgressProps.md +1 -1
  128. package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
  129. package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
  130. package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
  131. package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
  132. package/docs/api/interfaces/QuickFix.md +1 -1
  133. package/docs/api/interfaces/RBACAccessValidateParams.md +1 -1
  134. package/docs/api/interfaces/RBACAccessValidateResult.md +1 -1
  135. package/docs/api/interfaces/RBACAuditLogParams.md +1 -1
  136. package/docs/api/interfaces/RBACAuditLogResult.md +1 -1
  137. package/docs/api/interfaces/RBACConfig.md +26 -3
  138. package/docs/api/interfaces/RBACContext.md +1 -1
  139. package/docs/api/interfaces/RBACLogger.md +5 -5
  140. package/docs/api/interfaces/RBACPageAccessCheckParams.md +1 -1
  141. package/docs/api/interfaces/RBACPerformanceMetrics.md +138 -0
  142. package/docs/api/interfaces/RBACPermissionCheckParams.md +1 -1
  143. package/docs/api/interfaces/RBACPermissionCheckResult.md +1 -1
  144. package/docs/api/interfaces/RBACPermissionsGetParams.md +1 -1
  145. package/docs/api/interfaces/RBACPermissionsGetResult.md +1 -1
  146. package/docs/api/interfaces/RBACResult.md +1 -1
  147. package/docs/api/interfaces/RBACRoleGrantParams.md +1 -1
  148. package/docs/api/interfaces/RBACRoleGrantResult.md +1 -1
  149. package/docs/api/interfaces/RBACRoleRevokeParams.md +1 -1
  150. package/docs/api/interfaces/RBACRoleRevokeResult.md +1 -1
  151. package/docs/api/interfaces/RBACRoleValidateParams.md +1 -1
  152. package/docs/api/interfaces/RBACRoleValidateResult.md +1 -1
  153. package/docs/api/interfaces/RBACRolesListParams.md +1 -1
  154. package/docs/api/interfaces/RBACRolesListResult.md +1 -1
  155. package/docs/api/interfaces/RBACSessionTrackParams.md +1 -1
  156. package/docs/api/interfaces/RBACSessionTrackResult.md +1 -1
  157. package/docs/api/interfaces/ResourcePermissions.md +1 -1
  158. package/docs/api/interfaces/RevokeEventAppRoleParams.md +1 -1
  159. package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
  160. package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
  161. package/docs/api/interfaces/RoleManagementResult.md +1 -1
  162. package/docs/api/interfaces/RouteAccessRecord.md +1 -1
  163. package/docs/api/interfaces/RouteConfig.md +1 -1
  164. package/docs/api/interfaces/RuntimeComplianceResult.md +1 -1
  165. package/docs/api/interfaces/SecureDataContextType.md +1 -1
  166. package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
  167. package/docs/api/interfaces/SessionRestorationLoaderProps.md +1 -1
  168. package/docs/api/interfaces/SetupIssue.md +1 -1
  169. package/docs/api/interfaces/StorageConfig.md +1 -1
  170. package/docs/api/interfaces/StorageFileInfo.md +1 -1
  171. package/docs/api/interfaces/StorageFileMetadata.md +1 -1
  172. package/docs/api/interfaces/StorageListOptions.md +1 -1
  173. package/docs/api/interfaces/StorageListResult.md +1 -1
  174. package/docs/api/interfaces/StorageUploadOptions.md +1 -1
  175. package/docs/api/interfaces/StorageUploadResult.md +1 -1
  176. package/docs/api/interfaces/StorageUrlOptions.md +1 -1
  177. package/docs/api/interfaces/StyleImport.md +1 -1
  178. package/docs/api/interfaces/SwitchProps.md +1 -1
  179. package/docs/api/interfaces/TabsContentProps.md +1 -1
  180. package/docs/api/interfaces/TabsListProps.md +1 -1
  181. package/docs/api/interfaces/TabsProps.md +1 -1
  182. package/docs/api/interfaces/TabsTriggerProps.md +1 -1
  183. package/docs/api/interfaces/TextareaProps.md +1 -1
  184. package/docs/api/interfaces/ToastActionElement.md +1 -1
  185. package/docs/api/interfaces/ToastProps.md +1 -1
  186. package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
  187. package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
  188. package/docs/api/interfaces/UseFormDialogOptions.md +1 -1
  189. package/docs/api/interfaces/UseFormDialogReturn.md +1 -1
  190. package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
  191. package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
  192. package/docs/api/interfaces/UsePublicEventLogoOptions.md +1 -1
  193. package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
  194. package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
  195. package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
  196. package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
  197. package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
  198. package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
  199. package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
  200. package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
  201. package/docs/api/interfaces/UseResourcePermissionsOptions.md +1 -1
  202. package/docs/api/interfaces/UserEventAccess.md +1 -1
  203. package/docs/api/interfaces/UserMenuProps.md +1 -1
  204. package/docs/api/interfaces/UserProfile.md +1 -1
  205. package/docs/api/modules.md +318 -59
  206. package/docs/best-practices/performance.md +11 -0
  207. package/docs/implementation-guides/file-upload-storage.md +29 -0
  208. package/docs/rbac/README.md +2 -1
  209. package/docs/rbac/api-reference.md +11 -0
  210. package/docs/rbac/performance.md +320 -0
  211. package/docs/standards/01-architecture-standard.md +5 -0
  212. package/docs/standards/05-security-standard.md +12 -0
  213. package/package.json +1 -1
  214. package/src/components/AddressField/AddressField.test.tsx +411 -0
  215. package/src/components/AddressField/AddressField.tsx +323 -0
  216. package/src/components/AddressField/README.md +336 -0
  217. package/src/components/AddressField/index.ts +10 -0
  218. package/src/components/AddressField/types.ts +65 -0
  219. package/src/components/FileDisplay/FileDisplay.test.tsx +454 -0
  220. package/src/components/FileDisplay/FileDisplay.tsx +28 -1
  221. package/src/components/index.ts +2 -0
  222. package/src/hooks/__tests__/useFileDisplay.unit.test.ts +30 -5
  223. package/src/hooks/__tests__/useOrganisationSecurity.unit.test.tsx +11 -10
  224. package/src/hooks/__tests__/usePublicFileDisplay.test.ts +31 -6
  225. package/src/hooks/index.ts +6 -0
  226. package/src/hooks/public/usePublicFileDisplay.ts +8 -10
  227. package/src/hooks/useAddressAutocomplete.test.ts +318 -0
  228. package/src/hooks/useAddressAutocomplete.ts +268 -0
  229. package/src/hooks/useFileDisplay.ts +3 -15
  230. package/src/hooks/useFileReference.test.ts +20 -3
  231. package/src/hooks/useFileReference.ts +3 -24
  232. package/src/hooks/useFileUrlCache.ts +246 -0
  233. package/src/hooks/useInactivityTracker.ts +31 -20
  234. package/src/hooks/useOrganisationSecurity.test.ts +10 -7
  235. package/src/hooks/useOrganisationSecurity.ts +3 -3
  236. package/src/hooks/useQueryCache.ts +315 -0
  237. package/src/index.ts +2 -0
  238. package/src/providers/services/EventServiceProvider.tsx +4 -1
  239. package/src/rbac/api.test.ts +21 -6
  240. package/src/rbac/api.ts +32 -11
  241. package/src/rbac/audit-batched.ts +223 -0
  242. package/src/rbac/audit-enhanced.ts +2 -2
  243. package/src/rbac/audit.test.ts +6 -5
  244. package/src/rbac/audit.ts +34 -6
  245. package/src/rbac/cache-invalidation.ts +63 -12
  246. package/src/rbac/cache.test.ts +2 -2
  247. package/src/rbac/cache.ts +61 -14
  248. package/src/rbac/components/PagePermissionGuard.tsx +19 -10
  249. package/src/rbac/components/__tests__/PagePermissionGuard.performance.test.tsx +248 -0
  250. package/src/rbac/config.ts +9 -0
  251. package/src/rbac/engine.ts +2 -21
  252. package/src/rbac/hooks/usePermissions.ts +21 -5
  253. package/src/rbac/index.ts +19 -0
  254. package/src/rbac/performance.ts +210 -0
  255. package/src/rbac/request-deduplication.ts +87 -0
  256. package/src/rbac/utils/deep-equal.ts +93 -0
  257. package/src/types/file-reference.ts +0 -1
  258. package/src/utils/file-reference/__tests__/file-reference.test.ts +31 -4
  259. package/src/utils/file-reference/index.ts +44 -15
  260. package/src/utils/google-places/googlePlacesUtils.test.ts +403 -0
  261. package/src/utils/google-places/googlePlacesUtils.ts +475 -0
  262. package/src/utils/google-places/index.ts +26 -0
  263. package/src/utils/google-places/loadGoogleMapsScript.ts +207 -0
  264. package/src/utils/google-places/types.ts +94 -0
  265. package/src/utils/index.ts +23 -0
  266. package/src/utils/request-deduplication.ts +165 -0
  267. package/src/utils/storage/helpers.ts +143 -4
  268. package/dist/chunk-445GEP27.js.map +0 -1
  269. package/dist/chunk-FMUCXFII.js +0 -76
  270. package/dist/chunk-FMUCXFII.js.map +0 -1
  271. package/dist/chunk-FSFQFJCU.js.map +0 -1
  272. package/dist/chunk-FXFJRTKI.js.map +0 -1
  273. package/dist/chunk-GRIQLQ52.js.map +0 -1
  274. package/dist/chunk-HDCUMOOI.js.map +0 -1
  275. package/dist/chunk-OALXJH4Y.js.map +0 -1
  276. package/dist/chunk-TC7D3CR3.js.map +0 -1
  277. package/dist/chunk-U6WNSFX5.js.map +0 -1
  278. /package/dist/{DataTable-IX2NBUTP.js.map → DataTable-K3RJRSOX.js.map} +0 -0
  279. /package/dist/{UnifiedAuthProvider-A4BCQRJY.js.map → UnifiedAuthProvider-B76OWOAT.js.map} +0 -0
  280. /package/dist/{api-BMFCXVQX.js.map → api-YP7XD5L6.js.map} +0 -0
  281. /package/dist/{audit-WRS3KJKI.js.map → audit-B5P6FFIR.js.map} +0 -0
  282. /package/dist/{chunk-HGPQUCBC.js.map → chunk-FMTK4XNN.js.map} +0 -0
  283. /package/dist/{chunk-XAUHJD3L.js.map → chunk-K2JGDXGU.js.map} +0 -0
  284. /package/dist/{chunk-DAGICKHT.js.map → chunk-ULX5FYEM.js.map} +0 -0
@@ -0,0 +1,65 @@
1
+ /**
2
+ * @file AddressField Component Types
3
+ * @package @jmruthers/pace-core
4
+ * @module Components/AddressField
5
+ * @since 0.1.0
6
+ */
7
+
8
+ import type { ParsedAddress, AutocompleteOptions } from '../../utils/google-places';
9
+
10
+ /**
11
+ * Props for AddressField component
12
+ */
13
+ export interface AddressFieldProps
14
+ extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange' | 'value' | 'defaultValue' | 'size'> {
15
+ /** Google Places API key (required) */
16
+ apiKey: string;
17
+ /** Controlled input value */
18
+ value?: string;
19
+ /** Uncontrolled default value */
20
+ defaultValue?: string;
21
+ /** Callback when address is selected */
22
+ onChange?: (address: ParsedAddress | null) => void;
23
+ /** Callback when input value changes */
24
+ onInputChange?: (value: string) => void;
25
+ /** Placeholder text */
26
+ placeholder?: string;
27
+ /** Error state styling */
28
+ error?: boolean;
29
+ /** Disabled state */
30
+ disabled?: boolean;
31
+ /** Input size */
32
+ size?: 'sm' | 'md' | 'lg';
33
+ /** Input variant */
34
+ variant?: 'default' | 'destructive';
35
+ /** Google Places API autocomplete options */
36
+ autocompleteOptions?: AutocompleteOptions;
37
+ /** Debounce delay in milliseconds */
38
+ debounceDelay?: number;
39
+ /** Enable caching (default: true) */
40
+ cacheEnabled?: boolean;
41
+ /** Cache TTL configuration */
42
+ cacheTTL?: {
43
+ /** Autocomplete cache TTL in seconds */
44
+ autocomplete?: number;
45
+ /** Place details cache TTL in seconds */
46
+ placeDetails?: number;
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Ref type for AddressField component
52
+ */
53
+ export interface AddressFieldRef {
54
+ /** Focus the input */
55
+ focus: () => void;
56
+ /** Blur the input */
57
+ blur: () => void;
58
+ /** Get current input value */
59
+ getValue: () => string;
60
+ /** Clear the input and suggestions */
61
+ clear: () => void;
62
+ }
63
+
64
+ export type { ParsedAddress, AutocompleteOptions } from '../../utils/google-places';
65
+
@@ -39,6 +39,10 @@ vi.mock('../../hooks/public/usePublicFileDisplay', () => ({
39
39
  usePublicFileDisplay: vi.fn()
40
40
  }));
41
41
 
42
+ vi.mock('../../hooks/useFileUrl', () => ({
43
+ useFileUrl: vi.fn()
44
+ }));
45
+
42
46
  vi.mock('../../utils/storage/helpers', () => ({
43
47
  getPublicUrl: vi.fn((_, path: string) => `https://example.com/${path}`),
44
48
  getSignedUrl: vi.fn(async (_: unknown, path: string) => ({ url: `https://signed.example.com/${path}` })),
@@ -104,6 +108,15 @@ describe('[component] FileDisplay', () => {
104
108
  fileUrls: new Map(),
105
109
  refetch: vi.fn(),
106
110
  });
111
+
112
+ // Set up default mock for useFileUrl (used in displayOnly mode)
113
+ const useFileUrl = (await import('../../hooks/useFileUrl')).useFileUrl as unknown as vi.Mock;
114
+ useFileUrl.mockReturnValue({
115
+ url: null,
116
+ isLoading: false,
117
+ error: null,
118
+ clear: vi.fn(),
119
+ });
107
120
  });
108
121
 
109
122
  it('renders error state and clears error on retry', async () => {
@@ -458,6 +471,15 @@ describe('[component] FileDisplay', () => {
458
471
  refetch: vi.fn(),
459
472
  });
460
473
 
474
+ // Mock useFileUrl to return null URL so fallback is shown
475
+ const useFileUrl = (await import('../../hooks/useFileUrl')).useFileUrl as unknown as vi.Mock;
476
+ useFileUrl.mockReturnValue({
477
+ url: null,
478
+ isLoading: false,
479
+ error: null,
480
+ clear: vi.fn(),
481
+ });
482
+
461
483
  const fallbackGenerator = vi.fn(() => 'DOC');
462
484
 
463
485
  renderWithUnifiedAuth(
@@ -519,4 +541,436 @@ describe('[component] FileDisplay', () => {
519
541
  expect(image).toBeInTheDocument();
520
542
  expect(screen.queryByRole('button', { name: /Delete file/i })).toBeNull();
521
543
  });
544
+
545
+ describe('Document link rendering in displayOnly mode', () => {
546
+ it('renders non-image files as clickable links when displayOnly is true and fileUrl exists', async () => {
547
+ const pdfFile = {
548
+ id: 'fr-pdf',
549
+ table_name: 'person',
550
+ record_id: 'rec-1',
551
+ organisation_id: 'org-1',
552
+ file_path: 'bucket/path/document.pdf',
553
+ is_public: false,
554
+ file_metadata: { fileName: 'document.pdf', fileSize: 2048, fileType: 'application/pdf' },
555
+ };
556
+
557
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
558
+ useFileDisplay.mockReturnValue({
559
+ isLoading: false,
560
+ error: null,
561
+ fileUrl: 'https://signed.example.com/document.pdf',
562
+ fileReference: pdfFile,
563
+ fileReferences: [pdfFile],
564
+ fileCount: 1,
565
+ fileUrls: new Map([['fr-pdf', 'https://signed.example.com/document.pdf']]),
566
+ refetch: vi.fn(),
567
+ });
568
+
569
+ // Mock useFileUrl to return URL from map (since it's in the map, hook won't be used, but mock it anyway)
570
+ const useFileUrl = (await import('../../hooks/useFileUrl')).useFileUrl as unknown as vi.Mock;
571
+ useFileUrl.mockReturnValue({
572
+ url: null,
573
+ isLoading: false,
574
+ error: null,
575
+ clear: vi.fn(),
576
+ });
577
+
578
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} displayOnly />);
579
+
580
+ const link = await screen.findByRole('link', { name: /Open document\.pdf in new tab/i });
581
+ expect(link).toBeInTheDocument();
582
+ expect(link).toHaveAttribute('href', 'https://signed.example.com/document.pdf');
583
+ });
584
+
585
+ it('renders link with target="_blank" for new tab', async () => {
586
+ const docFile = {
587
+ id: 'fr-doc',
588
+ table_name: 'person',
589
+ record_id: 'rec-1',
590
+ organisation_id: 'org-1',
591
+ file_path: 'bucket/path/file.docx',
592
+ is_public: false,
593
+ file_metadata: { fileName: 'file.docx', fileSize: 4096, fileType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' },
594
+ };
595
+
596
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
597
+ useFileDisplay.mockReturnValue({
598
+ isLoading: false,
599
+ error: null,
600
+ fileUrl: 'https://signed.example.com/file.docx',
601
+ fileReference: docFile,
602
+ fileReferences: [docFile],
603
+ fileCount: 1,
604
+ fileUrls: new Map([['fr-doc', 'https://signed.example.com/file.docx']]),
605
+ refetch: vi.fn(),
606
+ });
607
+
608
+ // Mock useFileUrl (won't be used since URL is in map, but mock it anyway)
609
+ const useFileUrl = (await import('../../hooks/useFileUrl')).useFileUrl as unknown as vi.Mock;
610
+ useFileUrl.mockReturnValue({
611
+ url: null,
612
+ isLoading: false,
613
+ error: null,
614
+ clear: vi.fn(),
615
+ });
616
+
617
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} displayOnly />);
618
+
619
+ const link = await screen.findByRole('link');
620
+ expect(link).toHaveAttribute('target', '_blank');
621
+ });
622
+
623
+ it('renders link with rel="noopener noreferrer" for security', async () => {
624
+ const excelFile = {
625
+ id: 'fr-xls',
626
+ table_name: 'person',
627
+ record_id: 'rec-1',
628
+ organisation_id: 'org-1',
629
+ file_path: 'bucket/path/spreadsheet.xlsx',
630
+ is_public: false,
631
+ file_metadata: { fileName: 'spreadsheet.xlsx', fileSize: 8192, fileType: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
632
+ };
633
+
634
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
635
+ useFileDisplay.mockReturnValue({
636
+ isLoading: false,
637
+ error: null,
638
+ fileUrl: 'https://signed.example.com/spreadsheet.xlsx',
639
+ fileReference: excelFile,
640
+ fileReferences: [excelFile],
641
+ fileCount: 1,
642
+ fileUrls: new Map([['fr-xls', 'https://signed.example.com/spreadsheet.xlsx']]),
643
+ refetch: vi.fn(),
644
+ });
645
+
646
+ // Mock useFileUrl (won't be used since URL is in map, but mock it anyway)
647
+ const useFileUrl = (await import('../../hooks/useFileUrl')).useFileUrl as unknown as vi.Mock;
648
+ useFileUrl.mockReturnValue({
649
+ url: null,
650
+ isLoading: false,
651
+ error: null,
652
+ clear: vi.fn(),
653
+ });
654
+
655
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} displayOnly />);
656
+
657
+ const link = await screen.findByRole('link');
658
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
659
+ });
660
+
661
+ it('displays correct filename in link', async () => {
662
+ const pdfFile = {
663
+ id: 'fr-pdf',
664
+ table_name: 'person',
665
+ record_id: 'rec-1',
666
+ organisation_id: 'org-1',
667
+ file_path: 'bucket/path/my-document.pdf',
668
+ is_public: false,
669
+ file_metadata: { fileName: 'my-document.pdf', fileSize: 2048, fileType: 'application/pdf' },
670
+ };
671
+
672
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
673
+ useFileDisplay.mockReturnValue({
674
+ isLoading: false,
675
+ error: null,
676
+ fileUrl: 'https://signed.example.com/my-document.pdf',
677
+ fileReference: pdfFile,
678
+ fileReferences: [pdfFile],
679
+ fileCount: 1,
680
+ fileUrls: new Map([['fr-pdf', 'https://signed.example.com/my-document.pdf']]),
681
+ refetch: vi.fn(),
682
+ });
683
+
684
+ // Mock useFileUrl (won't be used since URL is in map, but mock it anyway)
685
+ const useFileUrl = (await import('../../hooks/useFileUrl')).useFileUrl as unknown as vi.Mock;
686
+ useFileUrl.mockReturnValue({
687
+ url: null,
688
+ isLoading: false,
689
+ error: null,
690
+ clear: vi.fn(),
691
+ });
692
+
693
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} displayOnly />);
694
+
695
+ const link = await screen.findByRole('link');
696
+ expect(link).toHaveTextContent('my-document.pdf');
697
+ });
698
+
699
+ it('renders FileText and ExternalLink icons', async () => {
700
+ const pdfFile = {
701
+ id: 'fr-pdf',
702
+ table_name: 'person',
703
+ record_id: 'rec-1',
704
+ organisation_id: 'org-1',
705
+ file_path: 'bucket/path/doc.pdf',
706
+ is_public: false,
707
+ file_metadata: { fileName: 'doc.pdf', fileSize: 2048, fileType: 'application/pdf' },
708
+ };
709
+
710
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
711
+ useFileDisplay.mockReturnValue({
712
+ isLoading: false,
713
+ error: null,
714
+ fileUrl: 'https://signed.example.com/doc.pdf',
715
+ fileReference: pdfFile,
716
+ fileReferences: [pdfFile],
717
+ fileCount: 1,
718
+ fileUrls: new Map([['fr-pdf', 'https://signed.example.com/doc.pdf']]),
719
+ refetch: vi.fn(),
720
+ });
721
+
722
+ // Mock useFileUrl (won't be used since URL is in map, but mock it anyway)
723
+ const useFileUrl = (await import('../../hooks/useFileUrl')).useFileUrl as unknown as vi.Mock;
724
+ useFileUrl.mockReturnValue({
725
+ url: null,
726
+ isLoading: false,
727
+ error: null,
728
+ clear: vi.fn(),
729
+ });
730
+
731
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} displayOnly />);
732
+
733
+ const link = await screen.findByRole('link');
734
+ // Icons should be present (they have aria-hidden="true")
735
+ // Icons are mocked as divs with data-testid in tests
736
+ const fileTextIcon = link.querySelector('[data-testid="lucide-filetext"]');
737
+ const externalLinkIcon = link.querySelector('[data-testid="lucide-externallink"]');
738
+ expect(fileTextIcon).toBeInTheDocument();
739
+ expect(externalLinkIcon).toBeInTheDocument();
740
+ });
741
+
742
+ it('falls back to existing behavior when fileUrl is null', async () => {
743
+ const pdfFile = {
744
+ id: 'fr-pdf',
745
+ table_name: 'person',
746
+ record_id: 'rec-1',
747
+ organisation_id: 'org-1',
748
+ file_path: 'bucket/path/doc.pdf',
749
+ is_public: false,
750
+ file_metadata: { fileName: 'doc.pdf', fileSize: 2048, fileType: 'application/pdf' },
751
+ };
752
+
753
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
754
+ useFileDisplay.mockReturnValue({
755
+ isLoading: false,
756
+ error: null,
757
+ fileUrl: null,
758
+ fileReference: pdfFile,
759
+ fileReferences: [pdfFile],
760
+ fileCount: 1,
761
+ fileUrls: new Map(),
762
+ refetch: vi.fn(),
763
+ });
764
+
765
+ // Mock useFileUrl to return null URL so fallback is shown
766
+ const useFileUrl = (await import('../../hooks/useFileUrl')).useFileUrl as unknown as vi.Mock;
767
+ useFileUrl.mockReturnValue({
768
+ url: null,
769
+ isLoading: false,
770
+ error: null,
771
+ clear: vi.fn(),
772
+ });
773
+
774
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} displayOnly showFallback fallbackText="View Document" />);
775
+
776
+ // Should show fallback, not a link
777
+ expect(await screen.findByText('View Document')).toBeInTheDocument();
778
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
779
+ });
780
+
781
+ it('still renders images inline (no regression)', async () => {
782
+ const imageFile = {
783
+ id: 'fr-img',
784
+ table_name: 'person',
785
+ record_id: 'rec-1',
786
+ organisation_id: 'org-1',
787
+ file_path: 'bucket/path/image.png',
788
+ is_public: false,
789
+ file_metadata: { fileName: 'image.png', fileSize: 1024, fileType: 'image/png' },
790
+ };
791
+
792
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
793
+ useFileDisplay.mockReturnValue({
794
+ isLoading: false,
795
+ error: null,
796
+ fileUrl: 'https://signed.example.com/image.png',
797
+ fileReference: imageFile,
798
+ fileReferences: [imageFile],
799
+ fileCount: 1,
800
+ fileUrls: new Map([['fr-img', 'https://signed.example.com/image.png']]),
801
+ refetch: vi.fn(),
802
+ });
803
+
804
+ // Mock useFileUrl (won't be used since URL is in map, but mock it anyway)
805
+ const useFileUrl = (await import('../../hooks/useFileUrl')).useFileUrl as unknown as vi.Mock;
806
+ useFileUrl.mockReturnValue({
807
+ url: null,
808
+ isLoading: false,
809
+ error: null,
810
+ clear: vi.fn(),
811
+ });
812
+
813
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} displayOnly />);
814
+
815
+ const image = await screen.findByRole('img', { name: /image\.png/i });
816
+ expect(image).toBeInTheDocument();
817
+ expect(screen.queryByRole('link')).not.toBeInTheDocument();
818
+ });
819
+
820
+ it('uses wrapper behavior when showDelete is true', async () => {
821
+ const pdfFile = {
822
+ id: 'fr-pdf',
823
+ table_name: 'person',
824
+ record_id: 'rec-1',
825
+ organisation_id: 'org-1',
826
+ file_path: 'bucket/path/doc.pdf',
827
+ is_public: false,
828
+ file_metadata: { fileName: 'doc.pdf', fileSize: 2048, fileType: 'application/pdf' },
829
+ };
830
+
831
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
832
+ useFileDisplay.mockReturnValue({
833
+ isLoading: false,
834
+ error: null,
835
+ fileUrl: 'https://signed.example.com/doc.pdf',
836
+ fileReference: pdfFile,
837
+ fileReferences: [pdfFile],
838
+ fileCount: 1,
839
+ fileUrls: new Map([['fr-pdf', 'https://signed.example.com/doc.pdf']]),
840
+ refetch: vi.fn(),
841
+ });
842
+
843
+ // Mock useFileUrl (won't be used since URL is in map, but mock it anyway)
844
+ const useFileUrl = (await import('../../hooks/useFileUrl')).useFileUrl as unknown as vi.Mock;
845
+ useFileUrl.mockReturnValue({
846
+ url: null,
847
+ isLoading: false,
848
+ error: null,
849
+ clear: vi.fn(),
850
+ });
851
+
852
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} displayOnly showDelete />);
853
+
854
+ // Should show wrapper with delete button, not simplified link
855
+ expect(screen.getByText('doc.pdf')).toBeInTheDocument();
856
+ expect(screen.getByRole('button', { name: /Delete file/i })).toBeInTheDocument();
857
+ // Should not be a simplified link (no link with target="_blank" in simplified form)
858
+ const link = screen.queryByRole('link', { name: /Open.*in new tab/i });
859
+ expect(link).not.toBeInTheDocument();
860
+ });
861
+
862
+ it('renders document links in public context', async () => {
863
+ mockUseIsPublicPage.mockReturnValueOnce(true);
864
+
865
+ const pdfFile = {
866
+ id: 'pub-pdf',
867
+ table_name: 'event',
868
+ record_id: 'event-1',
869
+ organisation_id: 'org-1',
870
+ file_path: 'bucket/path/public-doc.pdf',
871
+ is_public: true,
872
+ file_metadata: { fileName: 'public-doc.pdf', fileSize: 2048, fileType: 'application/pdf' },
873
+ };
874
+
875
+ const fileUrlsMap = new Map<string, string>([['pub-pdf', 'https://public.example.com/public-doc.pdf']]);
876
+ const usePublicFileDisplay = (await import('../../hooks/public/usePublicFileDisplay')).usePublicFileDisplay as unknown as vi.Mock;
877
+ usePublicFileDisplay.mockReturnValue({
878
+ isLoading: false,
879
+ error: null,
880
+ fileUrl: 'https://public.example.com/public-doc.pdf',
881
+ fileReference: pdfFile,
882
+ fileReferences: [pdfFile],
883
+ fileCount: 1,
884
+ fileUrls: fileUrlsMap,
885
+ refetch: vi.fn(),
886
+ });
887
+
888
+ renderWithPublicContext(
889
+ <FileDisplay {...baseProps} displayOnly />,
890
+ { supabase }
891
+ );
892
+
893
+ const link = await screen.findByRole('link', { name: /Open public-doc\.pdf in new tab/i });
894
+ expect(link).toBeInTheDocument();
895
+ expect(link).toHaveAttribute('href', 'https://public.example.com/public-doc.pdf');
896
+ expect(link).toHaveAttribute('target', '_blank');
897
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
898
+ });
899
+
900
+ it('includes proper ARIA label for accessibility', async () => {
901
+ const pdfFile = {
902
+ id: 'fr-pdf',
903
+ table_name: 'person',
904
+ record_id: 'rec-1',
905
+ organisation_id: 'org-1',
906
+ file_path: 'bucket/path/accessible.pdf',
907
+ is_public: false,
908
+ file_metadata: { fileName: 'accessible.pdf', fileSize: 2048, fileType: 'application/pdf' },
909
+ };
910
+
911
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
912
+ useFileDisplay.mockReturnValue({
913
+ isLoading: false,
914
+ error: null,
915
+ fileUrl: 'https://signed.example.com/accessible.pdf',
916
+ fileReference: pdfFile,
917
+ fileReferences: [pdfFile],
918
+ fileCount: 1,
919
+ fileUrls: new Map([['fr-pdf', 'https://signed.example.com/accessible.pdf']]),
920
+ refetch: vi.fn(),
921
+ });
922
+
923
+ // Mock useFileUrl (won't be used since URL is in map, but mock it anyway)
924
+ const useFileUrl = (await import('../../hooks/useFileUrl')).useFileUrl as unknown as vi.Mock;
925
+ useFileUrl.mockReturnValue({
926
+ url: null,
927
+ isLoading: false,
928
+ error: null,
929
+ clear: vi.fn(),
930
+ });
931
+
932
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} displayOnly />);
933
+
934
+ const link = await screen.findByRole('link', { name: /Open accessible\.pdf in new tab/i });
935
+ expect(link).toHaveAttribute('aria-label', 'Open accessible.pdf in new tab');
936
+ });
937
+
938
+ it('uses fallback text "Document" when fileName is missing', async () => {
939
+ const pdfFile = {
940
+ id: 'fr-pdf',
941
+ table_name: 'person',
942
+ record_id: 'rec-1',
943
+ organisation_id: 'org-1',
944
+ file_path: 'bucket/path/file.pdf',
945
+ is_public: false,
946
+ file_metadata: { fileName: '', fileSize: 2048, fileType: 'application/pdf' },
947
+ };
948
+
949
+ const useFileDisplay = (await import('../../hooks/useFileDisplay')).useFileDisplay as unknown as vi.Mock;
950
+ useFileDisplay.mockReturnValue({
951
+ isLoading: false,
952
+ error: null,
953
+ fileUrl: 'https://signed.example.com/file.pdf',
954
+ fileReference: pdfFile,
955
+ fileReferences: [pdfFile],
956
+ fileCount: 1,
957
+ fileUrls: new Map([['fr-pdf', 'https://signed.example.com/file.pdf']]),
958
+ refetch: vi.fn(),
959
+ });
960
+
961
+ // Mock useFileUrl (won't be used since URL is in map, but mock it anyway)
962
+ const useFileUrl = (await import('../../hooks/useFileUrl')).useFileUrl as unknown as vi.Mock;
963
+ useFileUrl.mockReturnValue({
964
+ url: null,
965
+ isLoading: false,
966
+ error: null,
967
+ clear: vi.fn(),
968
+ });
969
+
970
+ renderWithUnifiedAuth(<FileDisplay {...baseProps} displayOnly />);
971
+
972
+ const link = await screen.findByRole('link', { name: /Open Document in new tab/i });
973
+ expect(link).toHaveTextContent('Document');
974
+ });
975
+ });
522
976
  });
@@ -1,4 +1,5 @@
1
1
  import React, { useState, useEffect, useCallback, useRef, useContext, useMemo } from 'react';
2
+ import { FileText, ExternalLink } from 'lucide-react';
2
3
  import { FileReference, FileCategory } from '../../types/file-reference';
3
4
  import { usePublicFileDisplay } from '../../hooks/public/usePublicFileDisplay';
4
5
  import { useFileDisplay } from '../../hooks/useFileDisplay';
@@ -312,6 +313,29 @@ function FileDisplayContent({
312
313
  );
313
314
  }
314
315
 
316
+ // Document link display when displayOnly is true and file is not an image
317
+ // Render non-image files as clickable links that open in a new tab
318
+ if (displayOnly && !isImage && fileUrl && fileReference && !showDelete) {
319
+ const fileName = fileReference.file_metadata?.fileName || 'Document';
320
+ const ariaLabel = `Open ${fileName} in new tab`;
321
+
322
+ return (
323
+ <a
324
+ href={fileUrl}
325
+ target="_blank"
326
+ rel="noopener noreferrer"
327
+ aria-label={ariaLabel}
328
+ className={`flex items-center gap-2 p-3 bg-sec-50 border border-sec-200 rounded-lg hover:bg-sec-100 transition-colors text-main-600 hover:text-main-700 focus:outline-none focus:ring-2 focus:ring-main-500 focus:ring-offset-2 ${className || ''}`.trim()}
329
+ >
330
+ <FileText className="h-5 w-5 shrink-0" aria-hidden="true" />
331
+ <span className="flex-1 font-medium truncate">
332
+ {fileName}
333
+ </span>
334
+ <ExternalLink className="h-5 w-5 shrink-0" aria-hidden="true" />
335
+ </a>
336
+ );
337
+ }
338
+
315
339
  // Standard single file display with wrapper
316
340
  // For displayOnly mode, if fallback is enabled and there's no URL or image error, show fallback instead of folder icon
317
341
  if (displayOnly && showFallback && (!fileUrl || imageError || !isImage)) {
@@ -763,7 +787,10 @@ function FileDisplayAuthenticated({
763
787
  * - UnifiedAuthProvider context for authenticated pages
764
788
  *
765
789
  * @param props - File display configuration
766
- * @param props.displayOnly - Display only a single file instead of all files. Uses first file (prefers images) from all files, without category filtering. When true with an image, renders a simplified image-only display without metadata or wrapper divs.
790
+ * @param props.displayOnly - Display only a single file instead of all files. Uses first file (prefers images) from all files, without category filtering. When true:
791
+ * - **Image files**: Renders a simplified image-only display without metadata or wrapper divs
792
+ * - **Non-image files** (PDFs, Word docs, Excel files, etc.): Renders as clickable links that open in a new tab with document icon, filename, and external link icon. Links include proper security attributes (`rel="noopener noreferrer"`) and accessibility features (ARIA labels, keyboard navigation, visible focus states)
793
+ * - If `showDelete={true}`, uses standard wrapper behavior instead of simplified display
767
794
  * @param props.category - Optional category filter. When specified, only displays files matching this category and uses single file display variant.
768
795
  * @returns React element with file display
769
796
  */
@@ -43,6 +43,8 @@ export type { CardProps, CardActionsProps } from './Card';
43
43
 
44
44
  export { Input } from './Input';
45
45
  export type { InputProps } from './Input';
46
+ export { AddressField } from './AddressField';
47
+ export type { AddressFieldProps, AddressFieldRef, ParsedAddress, AutocompleteOptions } from './AddressField';
46
48
  export { Label } from './Label';
47
49
  export type { LabelProps } from './Label';
48
50
 
@@ -25,7 +25,18 @@ import { FileCategory as FileCategoryEnum } from '../../types/file-reference';
25
25
  // Mock storage helpers
26
26
  vi.mock('../../utils/storage/helpers', () => ({
27
27
  getPublicUrl: vi.fn((supabase: any, path: string) => `https://example.com/${path}`),
28
- getSignedUrl: vi.fn().mockResolvedValue({ url: 'https://example.com/signed-file.jpg', expiresAt: new Date().toISOString() })
28
+ getSignedUrl: vi.fn().mockResolvedValue({ url: 'https://example.com/signed-file.jpg', expiresAt: new Date().toISOString() }),
29
+ generateFileUrlsBatch: vi.fn().mockImplementation(async (supabase, files) => {
30
+ const urlMap = new Map<string, string>();
31
+ for (const file of files) {
32
+ if (file.is_public) {
33
+ urlMap.set(file.id, `https://example.com/${file.file_path}`);
34
+ } else {
35
+ urlMap.set(file.id, `https://example.com/signed/${file.file_path}`);
36
+ }
37
+ }
38
+ return urlMap;
39
+ })
29
40
  }));
30
41
 
31
42
  // Mock file reference service
@@ -38,7 +49,7 @@ vi.mock('../../utils/file-reference', () => ({
38
49
  createFileReferenceService: vi.fn(() => mockService)
39
50
  }));
40
51
 
41
- import { getPublicUrl, getSignedUrl } from '../../utils/storage/helpers';
52
+ import { getPublicUrl, getSignedUrl, generateFileUrlsBatch } from '../../utils/storage/helpers';
42
53
  import { createFileReferenceService } from '../../utils/file-reference';
43
54
 
44
55
  describe('useFileDisplay Hook', () => {
@@ -87,6 +98,19 @@ describe('useFileDisplay Hook', () => {
87
98
  mockSupabase = createMockSupabaseClient() as any;
88
99
  mockService.getFilesByCategory.mockResolvedValue([]);
89
100
  mockService.listFileReferences.mockResolvedValue([]);
101
+
102
+ // Reset generateFileUrlsBatch mock to ensure it returns a Map
103
+ vi.mocked(generateFileUrlsBatch).mockImplementation(async (supabase, files) => {
104
+ const urlMap = new Map<string, string>();
105
+ for (const file of files) {
106
+ if (file.is_public) {
107
+ urlMap.set(file.id, `https://example.com/${file.file_path}`);
108
+ } else {
109
+ urlMap.set(file.id, `https://example.com/signed/${file.file_path}`);
110
+ }
111
+ }
112
+ return urlMap;
113
+ });
90
114
  });
91
115
 
92
116
  afterEach(() => {
@@ -305,13 +329,14 @@ describe('useFileDisplay Hook', () => {
305
329
  await waitFor(
306
330
  () => {
307
331
  expect(result.current.isLoading).toBe(false);
332
+ expect(result.current.fileCount).toBe(2);
333
+ expect(result.current.fileReferences.length).toBe(2);
334
+ expect(result.current.fileUrls).toBeDefined();
335
+ expect(result.current.fileUrls.size).toBe(2);
308
336
  },
309
337
  { timeout: 2000 }
310
338
  );
311
339
 
312
- expect(result.current.fileCount).toBe(2);
313
- expect(result.current.fileReferences.length).toBe(2);
314
- expect(result.current.fileUrls.size).toBe(2);
315
340
  expect(result.current.fileUrl).toBe(null); // No single file URL in multiple mode
316
341
  expect(result.current.fileReference).toBe(null); // No single file reference in multiple mode
317
342
  });