@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.
- package/dist/{DataTable-Z9NLVJh0.d.ts → DataTable-IVYljGJ6.d.ts} +1 -1
- package/dist/{DataTable-IX2NBUTP.js → DataTable-K3RJRSOX.js} +7 -7
- package/dist/{PublicPageProvider-DIzEzwKl.d.ts → PublicPageProvider-DrLDztHt.d.ts} +211 -106
- package/dist/{UnifiedAuthProvider-A4BCQRJY.js → UnifiedAuthProvider-B76OWOAT.js} +2 -2
- package/dist/{api-BMFCXVQX.js → api-YP7XD5L6.js} +3 -3
- package/dist/{audit-WRS3KJKI.js → audit-B5P6FFIR.js} +2 -2
- package/dist/{chunk-445GEP27.js → chunk-3IC5WCMO.js} +33 -8
- package/dist/chunk-3IC5WCMO.js.map +1 -0
- package/dist/{chunk-OALXJH4Y.js → chunk-3NFNJOO7.js} +8 -8
- package/dist/chunk-3NFNJOO7.js.map +1 -0
- package/dist/{chunk-FSFQFJCU.js → chunk-63FOKYGO.js} +174 -6
- package/dist/chunk-63FOKYGO.js.map +1 -0
- package/dist/{chunk-TC7D3CR3.js → chunk-C4OYJOV4.js} +556 -101
- package/dist/chunk-C4OYJOV4.js.map +1 -0
- package/dist/{chunk-HGPQUCBC.js → chunk-FMTK4XNN.js} +3 -3
- package/dist/{chunk-U6WNSFX5.js → chunk-HEHYGYOX.js} +279 -44
- package/dist/chunk-HEHYGYOX.js.map +1 -0
- package/dist/{chunk-XAUHJD3L.js → chunk-K2JGDXGU.js} +2 -2
- package/dist/{chunk-HDCUMOOI.js → chunk-LBBUPSSC.js} +792 -559
- package/dist/chunk-LBBUPSSC.js.map +1 -0
- package/dist/{chunk-UQWSHFVX.js → chunk-SAUPYVLF.js} +1 -1
- package/dist/{chunk-UQWSHFVX.js.map → chunk-SAUPYVLF.js.map} +1 -1
- package/dist/{chunk-GRIQLQ52.js → chunk-T6ZJVI3A.js} +27 -23
- package/dist/chunk-T6ZJVI3A.js.map +1 -0
- package/dist/{chunk-DAGICKHT.js → chunk-ULX5FYEM.js} +3 -3
- package/dist/{chunk-FXFJRTKI.js → chunk-WK2Y6TGA.js} +3 -3
- package/dist/chunk-WK2Y6TGA.js.map +1 -0
- package/dist/chunk-YHCN776L.js +447 -0
- package/dist/chunk-YHCN776L.js.map +1 -0
- package/dist/components.d.ts +4 -4
- package/dist/components.js +12 -10
- package/dist/components.js.map +1 -1
- package/dist/{file-reference-PRTSLxKx.d.ts → file-reference-D037xOFK.d.ts} +0 -1
- package/dist/hooks.d.ts +221 -6
- package/dist/hooks.js +146 -49
- package/dist/hooks.js.map +1 -1
- package/dist/index.d.ts +24 -9
- package/dist/index.js +62 -28
- package/dist/index.js.map +1 -1
- package/dist/providers.js +1 -1
- package/dist/rbac/index.d.ts +124 -7
- package/dist/rbac/index.js +27 -7
- package/dist/{types-DUyCRSTj.d.ts → types-Bwgl--Xo.d.ts} +162 -1
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/{usePublicRouteParams-D71QLlg4.d.ts → usePublicRouteParams-CTDELQ7H.d.ts} +2 -2
- package/dist/utils.d.ts +213 -3
- package/dist/utils.js +22 -2
- package/dist/utils.js.map +1 -1
- package/docs/api/classes/ColumnFactory.md +1 -1
- package/docs/api/classes/ErrorBoundary.md +1 -1
- package/docs/api/classes/InvalidScopeError.md +1 -1
- package/docs/api/classes/Logger.md +1 -1
- package/docs/api/classes/MissingUserContextError.md +1 -1
- package/docs/api/classes/OrganisationContextRequiredError.md +1 -1
- package/docs/api/classes/PermissionDeniedError.md +1 -1
- package/docs/api/classes/RBACAuditManager.md +21 -17
- package/docs/api/classes/RBACCache.md +31 -23
- package/docs/api/classes/RBACEngine.md +5 -5
- package/docs/api/classes/RBACError.md +1 -1
- package/docs/api/classes/RBACNotInitializedError.md +1 -1
- package/docs/api/classes/SecureSupabaseClient.md +1 -1
- package/docs/api/classes/StorageUtils.md +1 -1
- package/docs/api/enums/FileCategory.md +1 -1
- package/docs/api/enums/LogLevel.md +1 -1
- package/docs/api/enums/RBACErrorCode.md +1 -1
- package/docs/api/enums/RPCFunction.md +1 -1
- package/docs/api/interfaces/AddressFieldProps.md +241 -0
- package/docs/api/interfaces/AddressFieldRef.md +94 -0
- package/docs/api/interfaces/AggregateConfig.md +1 -1
- package/docs/api/interfaces/AutocompleteOptions.md +75 -0
- package/docs/api/interfaces/BadgeProps.md +1 -1
- package/docs/api/interfaces/ButtonProps.md +1 -1
- package/docs/api/interfaces/CalendarProps.md +1 -1
- package/docs/api/interfaces/CardProps.md +1 -1
- package/docs/api/interfaces/ColorPalette.md +1 -1
- package/docs/api/interfaces/ColorShade.md +1 -1
- package/docs/api/interfaces/ComplianceResult.md +1 -1
- package/docs/api/interfaces/DataAccessRecord.md +1 -1
- package/docs/api/interfaces/DataRecord.md +1 -1
- package/docs/api/interfaces/DataTableAction.md +1 -1
- package/docs/api/interfaces/DataTableColumn.md +1 -1
- package/docs/api/interfaces/DataTableProps.md +1 -1
- package/docs/api/interfaces/DataTableToolbarButton.md +1 -1
- package/docs/api/interfaces/DatabaseComplianceResult.md +1 -1
- package/docs/api/interfaces/DatabaseIssue.md +1 -1
- package/docs/api/interfaces/EmptyStateConfig.md +1 -1
- package/docs/api/interfaces/EnhancedNavigationMenuProps.md +1 -1
- package/docs/api/interfaces/EventAppRoleData.md +1 -1
- package/docs/api/interfaces/ExportColumn.md +1 -1
- package/docs/api/interfaces/ExportOptions.md +1 -1
- package/docs/api/interfaces/FileDisplayProps.md +15 -15
- package/docs/api/interfaces/FileMetadata.md +1 -1
- package/docs/api/interfaces/FileReference.md +1 -1
- package/docs/api/interfaces/FileSizeLimits.md +1 -1
- package/docs/api/interfaces/FileUploadOptions.md +1 -1
- package/docs/api/interfaces/FileUploadProps.md +1 -1
- package/docs/api/interfaces/FooterProps.md +1 -1
- package/docs/api/interfaces/FormFieldProps.md +1 -1
- package/docs/api/interfaces/FormProps.md +1 -1
- package/docs/api/interfaces/GrantEventAppRoleParams.md +1 -1
- package/docs/api/interfaces/InactivityWarningModalProps.md +1 -1
- package/docs/api/interfaces/InputProps.md +1 -1
- package/docs/api/interfaces/LabelProps.md +1 -1
- package/docs/api/interfaces/LoggerConfig.md +1 -1
- package/docs/api/interfaces/LoginFormProps.md +1 -1
- package/docs/api/interfaces/NavigationAccessRecord.md +1 -1
- package/docs/api/interfaces/NavigationContextType.md +1 -1
- package/docs/api/interfaces/NavigationGuardProps.md +1 -1
- package/docs/api/interfaces/NavigationItem.md +1 -1
- package/docs/api/interfaces/NavigationMenuProps.md +1 -1
- package/docs/api/interfaces/NavigationProviderProps.md +1 -1
- package/docs/api/interfaces/Organisation.md +1 -1
- package/docs/api/interfaces/OrganisationContextType.md +1 -1
- package/docs/api/interfaces/OrganisationMembership.md +1 -1
- package/docs/api/interfaces/OrganisationProviderProps.md +1 -1
- package/docs/api/interfaces/OrganisationSecurityError.md +1 -1
- package/docs/api/interfaces/PaceAppLayoutProps.md +1 -1
- package/docs/api/interfaces/PaceLoginPageProps.md +1 -1
- package/docs/api/interfaces/PageAccessRecord.md +1 -1
- package/docs/api/interfaces/PagePermissionContextType.md +1 -1
- package/docs/api/interfaces/PagePermissionGuardProps.md +11 -11
- package/docs/api/interfaces/PagePermissionProviderProps.md +1 -1
- package/docs/api/interfaces/PaletteData.md +1 -1
- package/docs/api/interfaces/ParsedAddress.md +120 -0
- package/docs/api/interfaces/PermissionEnforcerProps.md +1 -1
- package/docs/api/interfaces/ProgressProps.md +1 -1
- package/docs/api/interfaces/ProtectedRouteProps.md +1 -1
- package/docs/api/interfaces/PublicPageFooterProps.md +1 -1
- package/docs/api/interfaces/PublicPageHeaderProps.md +1 -1
- package/docs/api/interfaces/PublicPageLayoutProps.md +1 -1
- package/docs/api/interfaces/QuickFix.md +1 -1
- package/docs/api/interfaces/RBACAccessValidateParams.md +1 -1
- package/docs/api/interfaces/RBACAccessValidateResult.md +1 -1
- package/docs/api/interfaces/RBACAuditLogParams.md +1 -1
- package/docs/api/interfaces/RBACAuditLogResult.md +1 -1
- package/docs/api/interfaces/RBACConfig.md +26 -3
- package/docs/api/interfaces/RBACContext.md +1 -1
- package/docs/api/interfaces/RBACLogger.md +5 -5
- package/docs/api/interfaces/RBACPageAccessCheckParams.md +1 -1
- package/docs/api/interfaces/RBACPerformanceMetrics.md +138 -0
- package/docs/api/interfaces/RBACPermissionCheckParams.md +1 -1
- package/docs/api/interfaces/RBACPermissionCheckResult.md +1 -1
- package/docs/api/interfaces/RBACPermissionsGetParams.md +1 -1
- package/docs/api/interfaces/RBACPermissionsGetResult.md +1 -1
- package/docs/api/interfaces/RBACResult.md +1 -1
- package/docs/api/interfaces/RBACRoleGrantParams.md +1 -1
- package/docs/api/interfaces/RBACRoleGrantResult.md +1 -1
- package/docs/api/interfaces/RBACRoleRevokeParams.md +1 -1
- package/docs/api/interfaces/RBACRoleRevokeResult.md +1 -1
- package/docs/api/interfaces/RBACRoleValidateParams.md +1 -1
- package/docs/api/interfaces/RBACRoleValidateResult.md +1 -1
- package/docs/api/interfaces/RBACRolesListParams.md +1 -1
- package/docs/api/interfaces/RBACRolesListResult.md +1 -1
- package/docs/api/interfaces/RBACSessionTrackParams.md +1 -1
- package/docs/api/interfaces/RBACSessionTrackResult.md +1 -1
- package/docs/api/interfaces/ResourcePermissions.md +1 -1
- package/docs/api/interfaces/RevokeEventAppRoleParams.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterContextType.md +1 -1
- package/docs/api/interfaces/RoleBasedRouterProps.md +1 -1
- package/docs/api/interfaces/RoleManagementResult.md +1 -1
- package/docs/api/interfaces/RouteAccessRecord.md +1 -1
- package/docs/api/interfaces/RouteConfig.md +1 -1
- package/docs/api/interfaces/RuntimeComplianceResult.md +1 -1
- package/docs/api/interfaces/SecureDataContextType.md +1 -1
- package/docs/api/interfaces/SecureDataProviderProps.md +1 -1
- package/docs/api/interfaces/SessionRestorationLoaderProps.md +1 -1
- package/docs/api/interfaces/SetupIssue.md +1 -1
- package/docs/api/interfaces/StorageConfig.md +1 -1
- package/docs/api/interfaces/StorageFileInfo.md +1 -1
- package/docs/api/interfaces/StorageFileMetadata.md +1 -1
- package/docs/api/interfaces/StorageListOptions.md +1 -1
- package/docs/api/interfaces/StorageListResult.md +1 -1
- package/docs/api/interfaces/StorageUploadOptions.md +1 -1
- package/docs/api/interfaces/StorageUploadResult.md +1 -1
- package/docs/api/interfaces/StorageUrlOptions.md +1 -1
- package/docs/api/interfaces/StyleImport.md +1 -1
- package/docs/api/interfaces/SwitchProps.md +1 -1
- package/docs/api/interfaces/TabsContentProps.md +1 -1
- package/docs/api/interfaces/TabsListProps.md +1 -1
- package/docs/api/interfaces/TabsProps.md +1 -1
- package/docs/api/interfaces/TabsTriggerProps.md +1 -1
- package/docs/api/interfaces/TextareaProps.md +1 -1
- package/docs/api/interfaces/ToastActionElement.md +1 -1
- package/docs/api/interfaces/ToastProps.md +1 -1
- package/docs/api/interfaces/UnifiedAuthContextType.md +1 -1
- package/docs/api/interfaces/UnifiedAuthProviderProps.md +1 -1
- package/docs/api/interfaces/UseFormDialogOptions.md +1 -1
- package/docs/api/interfaces/UseFormDialogReturn.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerOptions.md +1 -1
- package/docs/api/interfaces/UseInactivityTrackerReturn.md +1 -1
- package/docs/api/interfaces/UsePublicEventLogoOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventLogoReturn.md +1 -1
- package/docs/api/interfaces/UsePublicEventOptions.md +1 -1
- package/docs/api/interfaces/UsePublicEventReturn.md +1 -1
- package/docs/api/interfaces/UsePublicFileDisplayOptions.md +1 -1
- package/docs/api/interfaces/UsePublicFileDisplayReturn.md +1 -1
- package/docs/api/interfaces/UsePublicRouteParamsReturn.md +1 -1
- package/docs/api/interfaces/UseResolvedScopeOptions.md +1 -1
- package/docs/api/interfaces/UseResolvedScopeReturn.md +1 -1
- package/docs/api/interfaces/UseResourcePermissionsOptions.md +1 -1
- package/docs/api/interfaces/UserEventAccess.md +1 -1
- package/docs/api/interfaces/UserMenuProps.md +1 -1
- package/docs/api/interfaces/UserProfile.md +1 -1
- package/docs/api/modules.md +318 -59
- package/docs/best-practices/performance.md +11 -0
- package/docs/implementation-guides/file-upload-storage.md +29 -0
- package/docs/rbac/README.md +2 -1
- package/docs/rbac/api-reference.md +11 -0
- package/docs/rbac/performance.md +320 -0
- package/docs/standards/01-architecture-standard.md +5 -0
- package/docs/standards/05-security-standard.md +12 -0
- package/package.json +1 -1
- package/src/components/AddressField/AddressField.test.tsx +411 -0
- package/src/components/AddressField/AddressField.tsx +323 -0
- package/src/components/AddressField/README.md +336 -0
- package/src/components/AddressField/index.ts +10 -0
- package/src/components/AddressField/types.ts +65 -0
- package/src/components/FileDisplay/FileDisplay.test.tsx +454 -0
- package/src/components/FileDisplay/FileDisplay.tsx +28 -1
- package/src/components/index.ts +2 -0
- package/src/hooks/__tests__/useFileDisplay.unit.test.ts +30 -5
- package/src/hooks/__tests__/useOrganisationSecurity.unit.test.tsx +11 -10
- package/src/hooks/__tests__/usePublicFileDisplay.test.ts +31 -6
- package/src/hooks/index.ts +6 -0
- package/src/hooks/public/usePublicFileDisplay.ts +8 -10
- package/src/hooks/useAddressAutocomplete.test.ts +318 -0
- package/src/hooks/useAddressAutocomplete.ts +268 -0
- package/src/hooks/useFileDisplay.ts +3 -15
- package/src/hooks/useFileReference.test.ts +20 -3
- package/src/hooks/useFileReference.ts +3 -24
- package/src/hooks/useFileUrlCache.ts +246 -0
- package/src/hooks/useInactivityTracker.ts +31 -20
- package/src/hooks/useOrganisationSecurity.test.ts +10 -7
- package/src/hooks/useOrganisationSecurity.ts +3 -3
- package/src/hooks/useQueryCache.ts +315 -0
- package/src/index.ts +2 -0
- package/src/providers/services/EventServiceProvider.tsx +4 -1
- package/src/rbac/api.test.ts +21 -6
- package/src/rbac/api.ts +32 -11
- package/src/rbac/audit-batched.ts +223 -0
- package/src/rbac/audit-enhanced.ts +2 -2
- package/src/rbac/audit.test.ts +6 -5
- package/src/rbac/audit.ts +34 -6
- package/src/rbac/cache-invalidation.ts +63 -12
- package/src/rbac/cache.test.ts +2 -2
- package/src/rbac/cache.ts +61 -14
- package/src/rbac/components/PagePermissionGuard.tsx +19 -10
- package/src/rbac/components/__tests__/PagePermissionGuard.performance.test.tsx +248 -0
- package/src/rbac/config.ts +9 -0
- package/src/rbac/engine.ts +2 -21
- package/src/rbac/hooks/usePermissions.ts +21 -5
- package/src/rbac/index.ts +19 -0
- package/src/rbac/performance.ts +210 -0
- package/src/rbac/request-deduplication.ts +87 -0
- package/src/rbac/utils/deep-equal.ts +93 -0
- package/src/types/file-reference.ts +0 -1
- package/src/utils/file-reference/__tests__/file-reference.test.ts +31 -4
- package/src/utils/file-reference/index.ts +44 -15
- package/src/utils/google-places/googlePlacesUtils.test.ts +403 -0
- package/src/utils/google-places/googlePlacesUtils.ts +475 -0
- package/src/utils/google-places/index.ts +26 -0
- package/src/utils/google-places/loadGoogleMapsScript.ts +207 -0
- package/src/utils/google-places/types.ts +94 -0
- package/src/utils/index.ts +23 -0
- package/src/utils/request-deduplication.ts +165 -0
- package/src/utils/storage/helpers.ts +143 -4
- package/dist/chunk-445GEP27.js.map +0 -1
- package/dist/chunk-FMUCXFII.js +0 -76
- package/dist/chunk-FMUCXFII.js.map +0 -1
- package/dist/chunk-FSFQFJCU.js.map +0 -1
- package/dist/chunk-FXFJRTKI.js.map +0 -1
- package/dist/chunk-GRIQLQ52.js.map +0 -1
- package/dist/chunk-HDCUMOOI.js.map +0 -1
- package/dist/chunk-OALXJH4Y.js.map +0 -1
- package/dist/chunk-TC7D3CR3.js.map +0 -1
- package/dist/chunk-U6WNSFX5.js.map +0 -1
- /package/dist/{DataTable-IX2NBUTP.js.map → DataTable-K3RJRSOX.js.map} +0 -0
- /package/dist/{UnifiedAuthProvider-A4BCQRJY.js.map → UnifiedAuthProvider-B76OWOAT.js.map} +0 -0
- /package/dist/{api-BMFCXVQX.js.map → api-YP7XD5L6.js.map} +0 -0
- /package/dist/{audit-WRS3KJKI.js.map → audit-B5P6FFIR.js.map} +0 -0
- /package/dist/{chunk-HGPQUCBC.js.map → chunk-FMTK4XNN.js.map} +0 -0
- /package/dist/{chunk-XAUHJD3L.js.map → chunk-K2JGDXGU.js.map} +0 -0
- /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
|
|
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
|
*/
|
package/src/components/index.ts
CHANGED
|
@@ -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
|
});
|