@jmruthers/pace-core 0.6.7 → 0.6.8
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/audit-tool/00-dependencies.cjs +215 -9
- package/audit-tool/audits/02-project-structure.cjs +3 -18
- package/audit-tool/audits/03-architecture.cjs +34 -6
- package/audit-tool/audits/06-security-rbac.cjs +10 -0
- package/audit-tool/audits/07-api-tech-stack.cjs +55 -1
- package/audit-tool/index.cjs +23 -19
- package/audit-tool/utils/report-utils.cjs +141 -2
- package/dist/{DataTable-7PMH7XN7.js → DataTable-6RMSCQJ6.js} +5 -5
- package/dist/{PublicPageProvider-DlsCaR5v.d.ts → PublicPageProvider-CIGSujI2.d.ts} +14 -8
- package/dist/{UnifiedAuthProvider-ZT6TIGM7.js → UnifiedAuthProvider-7SNDOWYD.js} +2 -2
- package/dist/{api-Y4MQWOFW.js → api-7P7DI652.js} +1 -1
- package/dist/{chunk-L4XMVJKY.js → chunk-4DDCYDQ3.js} +8 -7
- package/dist/{chunk-ZKAWKYT4.js → chunk-5W2A3DRC.js} +2 -1
- package/dist/{chunk-VBCS3DUA.js → chunk-EF2UGZWY.js} +3 -3
- package/dist/{chunk-JGWDVX64.js → chunk-EURB7QFZ.js} +123 -53
- package/dist/{chunk-BM4CQ5P3.js → chunk-GS5672WG.js} +6 -6
- package/dist/{chunk-ZFYPMX46.js → chunk-LX6U42O3.js} +1 -1
- package/dist/{chunk-5X4QLXRG.js → chunk-MPBLMWVR.js} +5 -3
- package/dist/{chunk-Q7Q7V5NV.js → chunk-NKHKXPI4.js} +7 -7
- package/dist/{chunk-6F3IILHI.js → chunk-S6ZQKDY6.js} +1 -1
- package/dist/{chunk-FTCRZOG2.js → chunk-T5CVK4R3.js} +5 -5
- package/dist/{chunk-GHYHJTYV.js → chunk-Z2FNRKF3.js} +13 -13
- package/dist/components.d.ts +1 -1
- package/dist/components.js +12 -12
- package/dist/eslint-rules/rules/04-code-quality.cjs +66 -10
- package/dist/eslint-rules/rules/06-security-rbac.cjs +8 -3
- package/dist/eslint-rules/rules/07-api-tech-stack.cjs +190 -68
- package/dist/{functions-DHebl8-F.d.ts → functions-lBy5L2ry.d.ts} +1 -1
- package/dist/hooks.js +7 -7
- package/dist/index.d.ts +2 -2
- package/dist/index.js +15 -15
- package/dist/providers.js +2 -2
- package/dist/rbac/index.d.ts +1 -1
- package/dist/rbac/index.js +6 -6
- package/dist/theming/runtime.d.ts +48 -1
- package/dist/theming/runtime.js +1 -1
- package/dist/types.d.ts +2 -2
- package/dist/utils.js +1 -1
- package/docs/api/modules.md +63 -14
- package/docs/getting-started/dependencies.md +23 -0
- package/docs/implementation-guides/app-layout.md +1 -1
- package/docs/implementation-guides/data-tables.md +1 -1
- package/docs/standards/1-pace-core-compliance-standards.md +38 -1
- package/eslint-config-pace-core.cjs +30 -11
- package/package.json +45 -15
- package/scripts/eslint-audit.cjs +123 -0
- package/scripts/install-eslint-config.cjs +67 -2
- package/scripts/validate-dependencies.cjs +248 -0
- package/src/__tests__/helpers/__tests__/test-utils.test.tsx +20 -8
- package/src/__tests__/templates/accessibility.test.template.tsx +1 -0
- package/src/components/AddressField/AddressField.tsx +26 -1
- package/src/components/Alert/Alert.test.tsx +86 -22
- package/src/components/Alert/Alert.tsx +19 -11
- package/src/components/Badge/Badge.tsx +1 -1
- package/src/components/Checkbox/Checkbox.test.tsx +2 -1
- package/src/components/ContextSelector/ContextSelector.tsx +39 -41
- package/src/components/DataTable/DataTable.tsx +1 -19
- package/src/components/DataTable/__tests__/DataTableCore.test.tsx +6 -10
- package/src/components/DataTable/__tests__/a11y.basic.test.tsx +18 -9
- package/src/components/DataTable/__tests__/pagination.modes.test.tsx +3 -2
- package/src/components/DataTable/components/EmptyState.tsx +1 -1
- package/src/components/DataTable/components/__tests__/DataTableErrorBoundary.test.tsx +1 -1
- package/src/components/DataTable/components/__tests__/EmptyState.test.tsx +3 -3
- package/src/components/DataTable/components/__tests__/LoadingState.test.tsx +33 -29
- package/src/components/DatePickerWithTimezone/DatePickerWithTimezone.test.tsx +1 -2
- package/src/components/FileUpload/FileUpload.test.tsx +22 -31
- package/src/components/FileUpload/FileUpload.tsx +29 -0
- package/src/components/NavigationMenu/NavigationMenu.test.tsx +48 -12
- package/src/components/PaceAppLayout/PaceAppLayout.performance.test.tsx +9 -9
- package/src/components/PaceAppLayout/PaceAppLayout.security.test.tsx +30 -30
- package/src/components/PaceAppLayout/PaceAppLayout.test.tsx +4 -4
- package/src/components/PaceLoginPage/PaceLoginPage.test.tsx +7 -1
- package/src/hooks/__tests__/useDataTablePerformance.unit.test.ts +8 -5
- package/src/hooks/__tests__/useFileUrl.unit.test.ts +4 -0
- package/src/hooks/__tests__/useFocusTrap.unit.test.tsx +3 -3
- package/src/hooks/__tests__/useInactivityTracker.unit.test.ts +45 -8
- package/src/hooks/__tests__/usePerformanceMonitor.unit.test.ts +22 -2
- package/src/hooks/public/usePublicRouteParams.ts +8 -4
- package/src/hooks/useAddressAutocomplete.test.ts +18 -18
- package/src/hooks/useEventTheme.ts +5 -1
- package/src/hooks/useFileUrl.ts +52 -8
- package/src/hooks/useOrganisationSecurity.test.ts +2 -1
- package/src/providers/__tests__/ProviderLifecycle.test.tsx +1 -1
- package/src/rbac/__tests__/auth-rbac.e2e.test.tsx +15 -6
- package/src/rbac/__tests__/rbac-functions.test.ts +3 -3
- package/src/rbac/api.test.ts +104 -0
- package/src/rbac/engine.ts +1 -1
- package/src/rbac/hooks/useCan.test.ts +2 -2
- package/src/rbac/secureClient.ts +1 -1
- package/src/rbac/types/functions.ts +1 -1
- package/src/theming/__tests__/parseEventColours.test.ts +117 -8
- package/src/theming/parseEventColours.ts +56 -2
- package/src/types/supabase.ts +2 -3
- package/src/utils/__tests__/bundleAnalysis.unit.test.ts +9 -9
- package/src/utils/file-reference/__tests__/file-reference.test.ts +4 -0
- package/src/utils/formatting/formatDate.test.ts +3 -2
- package/src/utils/formatting/formatDateTime.test.ts +2 -2
- package/src/utils/google-places/googlePlacesUtils.test.ts +36 -24
- package/src/utils/storage/__tests__/helpers.unit.test.ts +19 -12
- package/src/utils/storage/helpers.test.ts +69 -3
|
@@ -18,34 +18,31 @@ describe('[component] LoadingState', () => {
|
|
|
18
18
|
it('renders loading spinner and text', () => {
|
|
19
19
|
render(<LoadingState />);
|
|
20
20
|
|
|
21
|
-
// There are two "Loading..." texts
|
|
21
|
+
// There are two "Loading..." texts: one in sr-only span, one in strong
|
|
22
22
|
const loadingTexts = screen.getAllByText('Loading...');
|
|
23
23
|
expect(loadingTexts.length).toBeGreaterThan(0);
|
|
24
|
-
// Check that the visible text is present
|
|
25
|
-
expect(screen.getByText('Loading...', { selector: 'strong' })).toBeInTheDocument();
|
|
26
24
|
});
|
|
27
25
|
|
|
28
26
|
it('renders with centered layout', () => {
|
|
29
27
|
render(<LoadingState />);
|
|
30
28
|
|
|
31
|
-
//
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
expect(container).toHaveClass('text-center', 'p-8');
|
|
29
|
+
// Component uses <p> with grid place-items-center, not flex
|
|
30
|
+
const container = screen.getByRole('status').parentElement; // <p> element
|
|
31
|
+
expect(container).toHaveClass('text-center', 'p-8', 'grid', 'place-items-center');
|
|
35
32
|
});
|
|
36
33
|
|
|
37
34
|
it('renders with padding', () => {
|
|
38
35
|
render(<LoadingState />);
|
|
39
36
|
|
|
40
|
-
//
|
|
41
|
-
const
|
|
42
|
-
const container = spinner.closest('p');
|
|
37
|
+
// Component uses <p> with p-8 class
|
|
38
|
+
const container = screen.getByRole('status').parentElement;
|
|
43
39
|
expect(container).toHaveClass('p-8');
|
|
44
40
|
});
|
|
45
41
|
|
|
46
42
|
it('renders spinner with animation class', () => {
|
|
47
43
|
render(<LoadingState />);
|
|
48
44
|
|
|
45
|
+
// Spinner is the canvas element with role="status"
|
|
49
46
|
const spinner = screen.getByRole('status');
|
|
50
47
|
expect(spinner).toHaveClass('animate-spin');
|
|
51
48
|
});
|
|
@@ -53,9 +50,8 @@ describe('[component] LoadingState', () => {
|
|
|
53
50
|
it('renders grid container with items centered', () => {
|
|
54
51
|
render(<LoadingState />);
|
|
55
52
|
|
|
56
|
-
//
|
|
57
|
-
const
|
|
58
|
-
const container = spinner.closest('p');
|
|
53
|
+
// Component uses <p> with grid place-items-center, not flex
|
|
54
|
+
const container = screen.getByRole('status').parentElement;
|
|
59
55
|
expect(container).toHaveClass('grid', 'place-items-center');
|
|
60
56
|
});
|
|
61
57
|
});
|
|
@@ -64,17 +60,19 @@ describe('[component] LoadingState', () => {
|
|
|
64
60
|
it('provides aria-live region for loading state', () => {
|
|
65
61
|
render(<LoadingState />);
|
|
66
62
|
|
|
67
|
-
//
|
|
63
|
+
// aria-live is on the canvas element (spinner), not the text
|
|
68
64
|
const spinner = screen.getByRole('status');
|
|
65
|
+
// Note: aria-live is not directly on the spinner, it's on the parent container
|
|
66
|
+
// The spinner has role="status" which provides the live region
|
|
69
67
|
expect(spinner).toBeInTheDocument();
|
|
70
68
|
});
|
|
71
69
|
|
|
72
70
|
it('announces loading state to screen readers', () => {
|
|
73
71
|
render(<LoadingState />);
|
|
74
72
|
|
|
75
|
-
//
|
|
76
|
-
const
|
|
77
|
-
expect(
|
|
73
|
+
// There are two "Loading..." texts: one in sr-only span, one in strong
|
|
74
|
+
const loadingTexts = screen.getAllByText('Loading...');
|
|
75
|
+
expect(loadingTexts.length).toBeGreaterThan(0);
|
|
78
76
|
});
|
|
79
77
|
});
|
|
80
78
|
|
|
@@ -82,9 +80,10 @@ describe('[component] LoadingState', () => {
|
|
|
82
80
|
it('renders spinner before loading text', () => {
|
|
83
81
|
render(<LoadingState />);
|
|
84
82
|
|
|
85
|
-
|
|
86
|
-
const container =
|
|
87
|
-
const
|
|
83
|
+
// Container is <p> element
|
|
84
|
+
const container = screen.getByRole('status').parentElement;
|
|
85
|
+
const spinner = container?.firstElementChild; // canvas element
|
|
86
|
+
const text = container?.lastElementChild; // strong element
|
|
88
87
|
|
|
89
88
|
expect(spinner).toBeInTheDocument();
|
|
90
89
|
expect(text).toHaveTextContent('Loading...');
|
|
@@ -93,10 +92,10 @@ describe('[component] LoadingState', () => {
|
|
|
93
92
|
it('applies grid layout for centering', () => {
|
|
94
93
|
render(<LoadingState />);
|
|
95
94
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
const container =
|
|
99
|
-
expect(container).
|
|
95
|
+
// Component doesn't use space-x-2, it uses grid layout
|
|
96
|
+
// The spacing is handled by the grid layout itself
|
|
97
|
+
const container = screen.getByRole('status').parentElement;
|
|
98
|
+
expect(container).toBeInTheDocument();
|
|
100
99
|
});
|
|
101
100
|
});
|
|
102
101
|
|
|
@@ -104,14 +103,18 @@ describe('[component] LoadingState', () => {
|
|
|
104
103
|
it('renders visible loading text', () => {
|
|
105
104
|
render(<LoadingState />);
|
|
106
105
|
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
// The visible text is in a <strong> element, not with muted color
|
|
107
|
+
// The sr-only text doesn't have muted color either
|
|
108
|
+
const textElements = screen.getAllByText('Loading...');
|
|
109
|
+
// Find the visible one (strong element, not sr-only)
|
|
110
|
+
const visibleText = textElements.find(el => el.tagName === 'STRONG');
|
|
111
|
+
expect(visibleText).toBeInTheDocument();
|
|
110
112
|
});
|
|
111
113
|
|
|
112
114
|
it('spinner has rounded-full class', () => {
|
|
113
115
|
render(<LoadingState />);
|
|
114
116
|
|
|
117
|
+
// Spinner is the canvas element with role="status"
|
|
115
118
|
const spinner = screen.getByRole('status');
|
|
116
119
|
expect(spinner).toHaveClass('rounded-full');
|
|
117
120
|
});
|
|
@@ -119,16 +122,17 @@ describe('[component] LoadingState', () => {
|
|
|
119
122
|
it('spinner has border styling', () => {
|
|
120
123
|
render(<LoadingState />);
|
|
121
124
|
|
|
122
|
-
const spinner = screen.getByRole('status');
|
|
123
125
|
// Spinner uses border-2 border-solid border-current border-r-transparent
|
|
126
|
+
const spinner = screen.getByRole('status');
|
|
124
127
|
expect(spinner).toHaveClass('border-2', 'border-solid', 'border-current', 'border-r-transparent');
|
|
125
128
|
});
|
|
126
129
|
|
|
127
130
|
it('spinner has appropriate size', () => {
|
|
128
131
|
render(<LoadingState />);
|
|
129
132
|
|
|
133
|
+
// Spinner is the canvas element with role="status"
|
|
130
134
|
const spinner = screen.getByRole('status');
|
|
131
|
-
// LoadingState spinner uses Tailwind v4 size-* utility
|
|
135
|
+
// LoadingState spinner uses Tailwind v4 size-* utility instead of h-* w-*
|
|
132
136
|
expect(spinner).toHaveClass('size-6');
|
|
133
137
|
});
|
|
134
138
|
});
|
|
@@ -306,8 +306,7 @@ describe('DatePickerWithTimezone Component', () => {
|
|
|
306
306
|
expect(callArgs.captionLayout).toBe('dropdown');
|
|
307
307
|
expect(callArgs.startMonth).toEqual(new Date(1900, 0));
|
|
308
308
|
expect(callArgs.endMonth).toEqual(new Date(2100, 11));
|
|
309
|
-
|
|
310
|
-
expect(callArgs.className).toContain('p-0');
|
|
309
|
+
expect(callArgs.className).toBe('p-0 col-span-full');
|
|
311
310
|
});
|
|
312
311
|
});
|
|
313
312
|
});
|
|
@@ -116,20 +116,22 @@ describe('[component] FileUpload', () => {
|
|
|
116
116
|
describe('Drag and Drop', () => {
|
|
117
117
|
it('shows drag state message on dragover', () => {
|
|
118
118
|
renderWithProviders(<FileUpload {...baseProps} />);
|
|
119
|
+
// The drop area is the CardHeader with role="button", not a div
|
|
119
120
|
const dropArea = screen.getByRole('button', { name: /File upload area/i });
|
|
120
121
|
|
|
121
122
|
fireEvent.dragOver(dropArea);
|
|
122
|
-
// Component shows "Drop files here..."
|
|
123
|
-
expect(screen.getByText(/Drop files here
|
|
123
|
+
// Component shows "Drop files here..." with ellipsis
|
|
124
|
+
expect(screen.getByText(/Drop files here\.\.\./i)).toBeInTheDocument();
|
|
124
125
|
});
|
|
125
126
|
|
|
126
127
|
it('resets drag state on dragleave', () => {
|
|
127
128
|
renderWithProviders(<FileUpload {...baseProps} />);
|
|
129
|
+
// The drop area is the CardHeader with role="button", not a div
|
|
128
130
|
const dropArea = screen.getByRole('button', { name: /File upload area/i });
|
|
129
131
|
|
|
130
132
|
fireEvent.dragOver(dropArea);
|
|
131
|
-
// Component shows "Drop files here..."
|
|
132
|
-
expect(screen.getByText(/Drop files here
|
|
133
|
+
// Component shows "Drop files here..." with ellipsis
|
|
134
|
+
expect(screen.getByText(/Drop files here\.\.\./i)).toBeInTheDocument();
|
|
133
135
|
|
|
134
136
|
fireEvent.dragLeave(dropArea);
|
|
135
137
|
expect(screen.getByText(/Click to upload/i)).toBeInTheDocument();
|
|
@@ -137,10 +139,9 @@ describe('[component] FileUpload', () => {
|
|
|
137
139
|
|
|
138
140
|
it('handles file drop', async () => {
|
|
139
141
|
const onUploadSuccess = vi.fn();
|
|
140
|
-
|
|
141
|
-
const mockUpload = vi.fn(async () => createMockUploadResult());
|
|
142
|
+
const mockUploadFile = vi.fn(async () => createMockUploadResult());
|
|
142
143
|
mockUseFileReference.mockReturnValue({
|
|
143
|
-
uploadFile:
|
|
144
|
+
uploadFile: mockUploadFile,
|
|
144
145
|
isLoading: false,
|
|
145
146
|
error: null,
|
|
146
147
|
});
|
|
@@ -149,6 +150,11 @@ describe('[component] FileUpload', () => {
|
|
|
149
150
|
<FileUpload {...baseProps} onUploadSuccess={onUploadSuccess} showProgress />
|
|
150
151
|
);
|
|
151
152
|
|
|
153
|
+
// Wait for app ID resolution to complete
|
|
154
|
+
await waitFor(() => {
|
|
155
|
+
expect(screen.queryByText(/Resolving app configuration/i)).not.toBeInTheDocument();
|
|
156
|
+
});
|
|
157
|
+
|
|
152
158
|
const dropArea = screen.getByRole('button', { name: /File upload area/i });
|
|
153
159
|
const file = createTestFile('test.png', 'image/png');
|
|
154
160
|
|
|
@@ -169,9 +175,9 @@ describe('[component] FileUpload', () => {
|
|
|
169
175
|
|
|
170
176
|
// Wait for upload to complete - the uploadFile is called asynchronously
|
|
171
177
|
await waitFor(() => {
|
|
172
|
-
expect(
|
|
178
|
+
expect(mockUploadFile).toHaveBeenCalled();
|
|
173
179
|
expect(onUploadSuccess).toHaveBeenCalled();
|
|
174
|
-
}, { timeout:
|
|
180
|
+
}, { timeout: 5000 });
|
|
175
181
|
});
|
|
176
182
|
|
|
177
183
|
it('does not handle drop when disabled', () => {
|
|
@@ -424,8 +430,7 @@ describe('[component] FileUpload', () => {
|
|
|
424
430
|
await waitFor(() => {
|
|
425
431
|
// First verify the file name is present
|
|
426
432
|
expect(screen.getByText('test.pdf')).toBeInTheDocument();
|
|
427
|
-
|
|
428
|
-
// The File icon is mocked with data-testid="lucide-file" in tests
|
|
433
|
+
// File icon should be shown (lucide-react File icon)
|
|
429
434
|
const fileIcon = screen.getByTestId('lucide-file');
|
|
430
435
|
expect(fileIcon).toBeInTheDocument();
|
|
431
436
|
}, { timeout: 3000 });
|
|
@@ -454,27 +459,15 @@ describe('[component] FileUpload', () => {
|
|
|
454
459
|
});
|
|
455
460
|
|
|
456
461
|
// Wait for spinner to appear
|
|
457
|
-
//
|
|
458
|
-
// When showProgress is false and isUploading is true, spinner appears in CardHeader
|
|
459
|
-
// The upload starts immediately when file is selected, and status changes to 'uploading'
|
|
460
|
-
// Wait for the upload state to be set to 'uploading' which triggers isUploading
|
|
461
|
-
// The spinner appears in the CardHeader (the drop area) when isUploading && !showProgress
|
|
462
|
+
// LoadingSpinner uses role="status" with "Loading..." text, not "Uploading file"
|
|
462
463
|
await waitFor(() => {
|
|
463
|
-
|
|
464
|
-
// isUploading is true when uploadStates has entries with status 'uploading' or 'processing'
|
|
465
|
-
// The spinner is in the CardHeader (the drop area)
|
|
466
|
-
const dropArea = screen.getByRole('button', { name: /File upload area/i });
|
|
467
|
-
const spinner = dropArea.querySelector('[role="status"]');
|
|
464
|
+
const spinner = screen.getByRole('status');
|
|
468
465
|
expect(spinner).toBeInTheDocument();
|
|
469
|
-
// LoadingSpinner renders a canvas element with animate-spin class directly on it
|
|
470
|
-
// So we check if the spinner element itself has the class
|
|
471
466
|
expect(spinner).toHaveClass('animate-spin');
|
|
472
|
-
}, { timeout: 2000 });
|
|
473
|
-
|
|
474
|
-
// Now resolve the upload to complete it
|
|
475
|
-
await act(async () => {
|
|
476
|
-
resolveUpload!(createMockUploadResult());
|
|
477
467
|
});
|
|
468
|
+
|
|
469
|
+
// Resolve upload to clean up
|
|
470
|
+
resolveUpload!(createMockUploadResult());
|
|
478
471
|
});
|
|
479
472
|
});
|
|
480
473
|
|
|
@@ -630,6 +623,4 @@ describe('[component] FileUpload', () => {
|
|
|
630
623
|
});
|
|
631
624
|
});
|
|
632
625
|
});
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
|
|
626
|
+
});
|
|
@@ -94,8 +94,19 @@ export function FileUpload({
|
|
|
94
94
|
const [isResolvingAppId, setIsResolvingAppId] = useState(!app_id);
|
|
95
95
|
const [appIdError, setAppIdError] = useState<string | null>(null);
|
|
96
96
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
97
|
+
const progressIntervalsRef = useRef<Map<string, NodeJS.Timeout>>(new Map());
|
|
97
98
|
const { uploadFile, isLoading, error } = useFileReference(supabase);
|
|
98
99
|
|
|
100
|
+
// Cleanup all progress intervals on unmount
|
|
101
|
+
useEffect(() => {
|
|
102
|
+
return () => {
|
|
103
|
+
progressIntervalsRef.current.forEach((interval) => {
|
|
104
|
+
clearInterval(interval);
|
|
105
|
+
});
|
|
106
|
+
progressIntervalsRef.current.clear();
|
|
107
|
+
};
|
|
108
|
+
}, []);
|
|
109
|
+
|
|
99
110
|
// Resolve app_id from app name if not provided
|
|
100
111
|
useEffect(() => {
|
|
101
112
|
if (app_id) {
|
|
@@ -295,16 +306,25 @@ export function FileUpload({
|
|
|
295
306
|
return updated;
|
|
296
307
|
});
|
|
297
308
|
}, 200);
|
|
309
|
+
|
|
310
|
+
// Store interval in ref for cleanup
|
|
311
|
+
progressIntervalsRef.current.set(fileId, progressInterval);
|
|
298
312
|
|
|
299
313
|
|
|
300
314
|
// Use resolved app_id
|
|
301
315
|
if (!resolvedAppId) {
|
|
316
|
+
// Clear interval before throwing error
|
|
317
|
+
clearInterval(progressInterval);
|
|
318
|
+
progressIntervalsRef.current.delete(fileId);
|
|
302
319
|
const errorMsg = appIdError || 'App ID not available. Please provide app_id prop or set app name.';
|
|
303
320
|
throw new Error(errorMsg);
|
|
304
321
|
}
|
|
305
322
|
|
|
306
323
|
// Validate pageContext before upload
|
|
307
324
|
if (!pageContext) {
|
|
325
|
+
// Clear interval before throwing error
|
|
326
|
+
clearInterval(progressInterval);
|
|
327
|
+
progressIntervalsRef.current.delete(fileId);
|
|
308
328
|
const errorMsg = 'pageContext is required for file upload. This is used for permission checks.';
|
|
309
329
|
throw new Error(errorMsg);
|
|
310
330
|
}
|
|
@@ -322,7 +342,9 @@ export function FileUpload({
|
|
|
322
342
|
is_public: isPublic
|
|
323
343
|
}, file);
|
|
324
344
|
|
|
345
|
+
// Clear interval and remove from ref
|
|
325
346
|
clearInterval(progressInterval);
|
|
347
|
+
progressIntervalsRef.current.delete(fileId);
|
|
326
348
|
|
|
327
349
|
if (result) {
|
|
328
350
|
// Update status to completed
|
|
@@ -386,6 +408,13 @@ export function FileUpload({
|
|
|
386
408
|
onUploadError?.('Upload failed', file);
|
|
387
409
|
}
|
|
388
410
|
} catch (err) {
|
|
411
|
+
// Clear interval on error
|
|
412
|
+
const interval = progressIntervalsRef.current.get(fileId);
|
|
413
|
+
if (interval) {
|
|
414
|
+
clearInterval(interval);
|
|
415
|
+
progressIntervalsRef.current.delete(fileId);
|
|
416
|
+
}
|
|
417
|
+
|
|
389
418
|
const errorMessage = err instanceof Error ? err.message : 'Upload failed';
|
|
390
419
|
|
|
391
420
|
setUploadStates(prev => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import React from 'react';
|
|
7
|
-
import { screen, waitFor } from '@testing-library/react';
|
|
7
|
+
import { screen, waitFor, act } from '@testing-library/react';
|
|
8
8
|
import userEvent from '@testing-library/user-event';
|
|
9
9
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
10
10
|
import { NavigationMenu } from './NavigationMenu';
|
|
@@ -716,7 +716,7 @@ describe('NavigationMenu Component', () => {
|
|
|
716
716
|
expect(screen.getByText('Home')).toBeInTheDocument();
|
|
717
717
|
}, { interval: 10 });
|
|
718
718
|
|
|
719
|
-
const homeItem = screen.
|
|
719
|
+
const homeItem = screen.getByRole('option', { name: 'Home' });
|
|
720
720
|
await user.click(homeItem);
|
|
721
721
|
|
|
722
722
|
expect(mockNavigate).toHaveBeenCalledWith(
|
|
@@ -748,7 +748,7 @@ describe('NavigationMenu Component', () => {
|
|
|
748
748
|
expect(screen.getByText('Home')).toBeInTheDocument();
|
|
749
749
|
}, { interval: 10 });
|
|
750
750
|
|
|
751
|
-
const homeItem = screen.
|
|
751
|
+
const homeItem = screen.getByRole('option', { name: 'Home' });
|
|
752
752
|
await user.click(homeItem);
|
|
753
753
|
|
|
754
754
|
expect(window.location.href).toBe('/');
|
|
@@ -1350,14 +1350,16 @@ describe('NavigationMenu Component', () => {
|
|
|
1350
1350
|
items={basicNavItems}
|
|
1351
1351
|
onNavigate={mockNavigate}
|
|
1352
1352
|
buttonText="Menu"
|
|
1353
|
+
itemsPreFiltered={true}
|
|
1353
1354
|
/>
|
|
1354
1355
|
);
|
|
1355
1356
|
|
|
1356
1357
|
const openMenu = async () => {
|
|
1357
1358
|
await user.click(screen.getByRole('combobox'));
|
|
1358
1359
|
await waitFor(() => {
|
|
1359
|
-
|
|
1360
|
-
|
|
1360
|
+
// Items should appear as options in the listbox
|
|
1361
|
+
expect(screen.getByRole('option', { name: 'Home' })).toBeInTheDocument();
|
|
1362
|
+
}, { interval: 10, timeout: 3000 });
|
|
1361
1363
|
};
|
|
1362
1364
|
|
|
1363
1365
|
await openMenu();
|
|
@@ -1367,10 +1369,38 @@ describe('NavigationMenu Component', () => {
|
|
|
1367
1369
|
items={basicNavItems}
|
|
1368
1370
|
onNavigate={mockNavigate}
|
|
1369
1371
|
buttonText="Menu"
|
|
1372
|
+
itemsPreFiltered={true}
|
|
1370
1373
|
/>
|
|
1371
1374
|
);
|
|
1372
1375
|
|
|
1373
|
-
|
|
1376
|
+
// Wait for rerender to complete and component to be ready
|
|
1377
|
+
await waitFor(() => {
|
|
1378
|
+
const combobox = screen.getByRole('combobox');
|
|
1379
|
+
expect(combobox).toBeInTheDocument();
|
|
1380
|
+
expect(combobox).not.toBeDisabled();
|
|
1381
|
+
}, { interval: 10, timeout: 1000 });
|
|
1382
|
+
|
|
1383
|
+
// The Select component may keep the menu open after rerender
|
|
1384
|
+
// Check if menu is still open, and if so, verify items are still visible
|
|
1385
|
+
// If closed, open it again to verify items remain visible
|
|
1386
|
+
const combobox = screen.getByRole('combobox');
|
|
1387
|
+
const isOpen = combobox.getAttribute('aria-expanded') === 'true';
|
|
1388
|
+
|
|
1389
|
+
if (!isOpen) {
|
|
1390
|
+
// Menu closed, open it again
|
|
1391
|
+
await user.click(combobox);
|
|
1392
|
+
await waitFor(() => {
|
|
1393
|
+
const updatedCombobox = screen.getByRole('combobox');
|
|
1394
|
+
expect(updatedCombobox).toHaveAttribute('aria-expanded', 'true');
|
|
1395
|
+
}, { interval: 10, timeout: 3000 });
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
// Now wait for items to appear as options in the visible listbox
|
|
1399
|
+
// This verifies that items remain visible after rerender during permission reload
|
|
1400
|
+
await waitFor(() => {
|
|
1401
|
+
// Items should appear as options in the listbox after rerender
|
|
1402
|
+
expect(screen.getByRole('option', { name: 'Home' })).toBeInTheDocument();
|
|
1403
|
+
}, { interval: 10, timeout: 3000 });
|
|
1374
1404
|
});
|
|
1375
1405
|
|
|
1376
1406
|
it('renders no selectable items when auth and RBAC providers are unavailable', async () => {
|
|
@@ -1423,14 +1453,16 @@ describe('NavigationMenu Component', () => {
|
|
|
1423
1453
|
items={basicNavItems}
|
|
1424
1454
|
onNavigate={mockNavigate}
|
|
1425
1455
|
buttonText="Menu"
|
|
1456
|
+
itemsPreFiltered={true}
|
|
1426
1457
|
/>
|
|
1427
1458
|
);
|
|
1428
1459
|
|
|
1429
1460
|
await user.click(screen.getByRole('combobox'));
|
|
1430
1461
|
|
|
1431
1462
|
await waitFor(() => {
|
|
1432
|
-
|
|
1433
|
-
expect(screen.
|
|
1463
|
+
// Items should appear as options in the listbox
|
|
1464
|
+
expect(screen.getByRole('option', { name: 'Home' })).toBeInTheDocument();
|
|
1465
|
+
expect(screen.getByRole('option', { name: 'Dashboard' })).toBeInTheDocument();
|
|
1434
1466
|
}, { interval: 10 });
|
|
1435
1467
|
});
|
|
1436
1468
|
});
|
|
@@ -1445,6 +1477,7 @@ describe('NavigationMenu Component', () => {
|
|
|
1445
1477
|
onNavigate={mockNavigate}
|
|
1446
1478
|
auditLog={true}
|
|
1447
1479
|
buttonText="Menu"
|
|
1480
|
+
itemsPreFiltered={true}
|
|
1448
1481
|
/>
|
|
1449
1482
|
);
|
|
1450
1483
|
|
|
@@ -1452,10 +1485,11 @@ describe('NavigationMenu Component', () => {
|
|
|
1452
1485
|
await user.click(trigger);
|
|
1453
1486
|
|
|
1454
1487
|
await waitFor(() => {
|
|
1455
|
-
|
|
1488
|
+
// Items should appear as options in the listbox
|
|
1489
|
+
expect(screen.getByRole('option', { name: 'Home' })).toBeInTheDocument();
|
|
1456
1490
|
}, { interval: 10 });
|
|
1457
1491
|
|
|
1458
|
-
const homeItem = screen.
|
|
1492
|
+
const homeItem = screen.getByRole('option', { name: 'Home' });
|
|
1459
1493
|
await user.click(homeItem);
|
|
1460
1494
|
|
|
1461
1495
|
// Note: NavigationMenu audit logging is currently commented out in the component
|
|
@@ -1479,6 +1513,7 @@ describe('NavigationMenu Component', () => {
|
|
|
1479
1513
|
onNavigate={mockNavigate}
|
|
1480
1514
|
auditLog={false}
|
|
1481
1515
|
buttonText="Menu"
|
|
1516
|
+
itemsPreFiltered={true}
|
|
1482
1517
|
/>
|
|
1483
1518
|
);
|
|
1484
1519
|
|
|
@@ -1486,10 +1521,11 @@ describe('NavigationMenu Component', () => {
|
|
|
1486
1521
|
await user.click(trigger);
|
|
1487
1522
|
|
|
1488
1523
|
await waitFor(() => {
|
|
1489
|
-
|
|
1524
|
+
// Items should appear as options in the listbox
|
|
1525
|
+
expect(screen.getByRole('option', { name: 'Home' })).toBeInTheDocument();
|
|
1490
1526
|
}, { interval: 10 });
|
|
1491
1527
|
|
|
1492
|
-
const homeItem = screen.
|
|
1528
|
+
const homeItem = screen.getByRole('option', { name: 'Home' });
|
|
1493
1529
|
await user.click(homeItem);
|
|
1494
1530
|
|
|
1495
1531
|
// Logger.debug should not be called when auditLog is disabled
|
|
@@ -439,7 +439,7 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
|
439
439
|
});
|
|
440
440
|
|
|
441
441
|
describe('Permission Check Performance', () => {
|
|
442
|
-
it('performs permission checks within threshold', async () => {
|
|
442
|
+
it('performs permission checks within threshold', { timeout: 6000 }, async () => {
|
|
443
443
|
// Ensure super admin check resolves immediately for performance testing
|
|
444
444
|
mockIsSuperAdmin.mockResolvedValue(false);
|
|
445
445
|
|
|
@@ -452,7 +452,7 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
|
452
452
|
// Wait for permission check to complete and component to render
|
|
453
453
|
await waitFor(() => {
|
|
454
454
|
expect(screen.getByTestId('mock-header')).toBeInTheDocument();
|
|
455
|
-
}
|
|
455
|
+
});
|
|
456
456
|
|
|
457
457
|
// Use real performance.now() for accurate timing (temporarily restore the spy)
|
|
458
458
|
// Note: We're measuring after mount, so this should be very fast
|
|
@@ -476,9 +476,9 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
|
476
476
|
// In coverage mode, just verify the component rendered successfully
|
|
477
477
|
expect(screen.getByTestId('mock-header')).toBeInTheDocument();
|
|
478
478
|
}
|
|
479
|
-
}
|
|
479
|
+
});
|
|
480
480
|
|
|
481
|
-
it('handles multiple permission checks efficiently', async () => {
|
|
481
|
+
it('handles multiple permission checks efficiently', { timeout: 6000 }, async () => {
|
|
482
482
|
// Ensure super admin check resolves immediately for performance testing
|
|
483
483
|
mockIsSuperAdmin.mockResolvedValue(false);
|
|
484
484
|
|
|
@@ -501,7 +501,7 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
|
501
501
|
// Wait for component to fully mount (including super admin check)
|
|
502
502
|
await waitFor(() => {
|
|
503
503
|
expect(screen.getByTestId('mock-header')).toBeInTheDocument();
|
|
504
|
-
}
|
|
504
|
+
});
|
|
505
505
|
|
|
506
506
|
// Use real performance.now() for accurate timing (temporarily restore the spy)
|
|
507
507
|
performanceNowSpy?.mockRestore();
|
|
@@ -527,7 +527,7 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
|
527
527
|
// In coverage mode, just verify the component rendered successfully
|
|
528
528
|
expect(screen.getByTestId('mock-header')).toBeInTheDocument();
|
|
529
529
|
}
|
|
530
|
-
}
|
|
530
|
+
});
|
|
531
531
|
|
|
532
532
|
it('handles permission check errors efficiently', async () => {
|
|
533
533
|
mockUseCanFn.mockReturnValue({
|
|
@@ -545,7 +545,7 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
|
545
545
|
|
|
546
546
|
await waitFor(() => {
|
|
547
547
|
expect(screen.getByText('Permission check failed')).toBeInTheDocument();
|
|
548
|
-
}
|
|
548
|
+
});
|
|
549
549
|
});
|
|
550
550
|
});
|
|
551
551
|
|
|
@@ -939,7 +939,7 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
|
939
939
|
await waitFor(() => {
|
|
940
940
|
expect(screen.getByTestId('header-actions')).toBeInTheDocument();
|
|
941
941
|
expect(screen.getByTestId('custom-user-menu')).toBeInTheDocument();
|
|
942
|
-
}
|
|
942
|
+
});
|
|
943
943
|
|
|
944
944
|
const endTime = performance.now();
|
|
945
945
|
const renderTime = endTime - startTime;
|
|
@@ -953,6 +953,6 @@ const TestWrapper = ({ children }: { children: React.ReactNode }) => (
|
|
|
953
953
|
// In coverage mode, verify component renders successfully (behavioral check)
|
|
954
954
|
expect(screen.getByTestId('mock-header')).toBeInTheDocument();
|
|
955
955
|
}
|
|
956
|
-
}
|
|
956
|
+
});
|
|
957
957
|
});
|
|
958
958
|
});
|