@vertesia/ui 1.5.0-dev.20260910.052912Z → 1.5.0
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/lib/core/components/Link.js +1 -1
- package/lib/core/components/Link.js.map +1 -1
- package/lib/core/components/Spinner.js +1 -1
- package/lib/core/components/Spinner.js.map +1 -1
- package/lib/core/components/TagsInput.js +7 -7
- package/lib/core/components/TagsInput.js.map +1 -1
- package/lib/core/components/shadcn/badge.js +1 -1
- package/lib/core/components/shadcn/badge.js.map +1 -1
- package/lib/core/components/shadcn/button.d.ts.map +1 -1
- package/lib/core/components/shadcn/button.js +2 -2
- package/lib/core/components/shadcn/button.js.map +1 -1
- package/lib/core/components/shadcn/filters/comboBox/DateCombobox.js +2 -2
- package/lib/core/components/shadcn/filters/comboBox/DateCombobox.js.map +1 -1
- package/lib/core/components/shadcn/filters/comboBox/SelectCombobox.js +1 -1
- package/lib/core/components/shadcn/filters/comboBox/SelectCombobox.js.map +1 -1
- package/lib/core/components/shadcn/filters/comboBox/StringListCombobox.js +1 -1
- package/lib/core/components/shadcn/filters/comboBox/StringListCombobox.js.map +1 -1
- package/lib/core/components/shadcn/filters/comboBox/TextCombobox.js +1 -1
- package/lib/core/components/shadcn/filters/comboBox/TextCombobox.js.map +1 -1
- package/lib/core/components/shadcn/filters/filter/dateFilter.js +1 -1
- package/lib/core/components/shadcn/filters/filter/dateFilter.js.map +1 -1
- package/lib/core/components/shadcn/filters/filters.js +1 -1
- package/lib/core/components/shadcn/filters/filters.js.map +1 -1
- package/lib/core/components/shadcn/overflowTabs.js +2 -2
- package/lib/core/components/shadcn/overflowTabs.js.map +1 -1
- package/lib/core/components/shadcn/tabs.js +1 -1
- package/lib/core/components/shadcn/tabs.js.map +1 -1
- package/lib/features/activity-doc/ActivityDoc.js +1 -1
- package/lib/features/activity-doc/ActivityDoc.js.map +1 -1
- package/lib/features/agent/chat/AskUserWidget.d.ts +1 -0
- package/lib/features/agent/chat/AskUserWidget.d.ts.map +1 -1
- package/lib/features/agent/chat/AskUserWidget.js +15 -4
- package/lib/features/agent/chat/AskUserWidget.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js +5 -3
- package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/MessageItem.d.ts.map +1 -1
- package/lib/features/agent/chat/ModernAgentOutput/MessageItem.js +3 -2
- package/lib/features/agent/chat/ModernAgentOutput/MessageItem.js.map +1 -1
- package/lib/features/layout/GenericPageNavHeader.js +1 -1
- package/lib/features/layout/GenericPageNavHeader.js.map +1 -1
- package/lib/features/magic-pdf/AnnotatedImageSlider.js +1 -1
- package/lib/features/magic-pdf/AnnotatedImageSlider.js.map +1 -1
- package/lib/features/magic-pdf/GroundedExtractionView.js +3 -3
- package/lib/features/magic-pdf/GroundedExtractionView.js.map +1 -1
- package/lib/features/permissions/UserPermissionsProvider.js +1 -1
- package/lib/features/permissions/UserPermissionsProvider.js.map +1 -1
- package/lib/features/store/objects/DocumentPreviewPanel.js +5 -5
- package/lib/features/store/objects/DocumentPreviewPanel.js.map +1 -1
- package/lib/features/store/objects/components/DocumentInput.js +1 -1
- package/lib/features/store/objects/components/DocumentInput.js.map +1 -1
- package/lib/features/store/objects/upload/DocumentUploadModal.js +1 -1
- package/lib/features/store/objects/upload/DocumentUploadModal.js.map +1 -1
- package/lib/features/user/UserInfo.d.ts +2 -0
- package/lib/features/user/UserInfo.d.ts.map +1 -1
- package/lib/features/user/UserInfo.js +1 -1
- package/lib/features/user/UserInfo.js.map +1 -1
- package/lib/router/RouteComponent.d.ts.map +1 -1
- package/lib/router/RouteComponent.js +15 -12
- package/lib/router/RouteComponent.js.map +1 -1
- package/lib/session/UserSession.d.ts.map +1 -1
- package/lib/session/UserSession.js +4 -0
- package/lib/session/UserSession.js.map +1 -1
- package/lib/shell/SplashScreen.js +1 -1
- package/lib/shell/SplashScreen.js.map +1 -1
- package/lib/vertesia-ui-core.js +3 -3
- package/lib/vertesia-ui-core.js.map +1 -1
- package/lib/vertesia-ui-features.js +32 -32
- package/lib/vertesia-ui-features.js.map +1 -1
- package/lib/vertesia-ui-router.js +1 -1
- package/lib/vertesia-ui-router.js.map +1 -1
- package/lib/vertesia-ui-session.js +1 -1
- package/lib/vertesia-ui-session.js.map +1 -1
- package/lib/vertesia-ui-shell.js +1 -1
- package/lib/vertesia-ui-shell.js.map +1 -1
- package/lib/vertesia-ui-widgets.js +2 -2
- package/lib/vertesia-ui-widgets.js.map +1 -1
- package/lib/widgets/json-view/JSONCode.js +1 -1
- package/lib/widgets/json-view/JSONCode.js.map +1 -1
- package/lib/widgets/upload/DropZone.d.ts.map +1 -1
- package/lib/widgets/upload/DropZone.js +1 -1
- package/lib/widgets/upload/DropZone.js.map +1 -1
- package/package.json +7 -7
- package/src/core/components/Link.tsx +1 -1
- package/src/core/components/Spinner.tsx +1 -1
- package/src/core/components/TagsInput.tsx +7 -7
- package/src/core/components/shadcn/badge.tsx +1 -1
- package/src/core/components/shadcn/button.tsx +2 -3
- package/src/core/components/shadcn/filters/comboBox/DateCombobox.tsx +2 -2
- package/src/core/components/shadcn/filters/comboBox/SelectCombobox.tsx +1 -1
- package/src/core/components/shadcn/filters/comboBox/StringListCombobox.tsx +1 -1
- package/src/core/components/shadcn/filters/comboBox/TextCombobox.tsx +1 -1
- package/src/core/components/shadcn/filters/filter/dateFilter.tsx +1 -1
- package/src/core/components/shadcn/filters/filters.tsx +1 -1
- package/src/core/components/shadcn/overflowTabs.tsx +2 -2
- package/src/core/components/shadcn/tabs.tsx +1 -1
- package/src/css/utilities.css +7 -0
- package/src/features/activity-doc/ActivityDoc.tsx +1 -1
- package/src/features/agent/chat/AskUserWidget.test.tsx +24 -2
- package/src/features/agent/chat/AskUserWidget.tsx +19 -4
- package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.test.tsx +62 -0
- package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.tsx +8 -7
- package/src/features/agent/chat/ModernAgentOutput/MessageItem.tsx +4 -3
- package/src/features/layout/GenericPageNavHeader.test.tsx +36 -0
- package/src/features/layout/GenericPageNavHeader.tsx +1 -1
- package/src/features/magic-pdf/AnnotatedImageSlider.tsx +1 -1
- package/src/features/magic-pdf/GroundedExtractionView.tsx +3 -3
- package/src/features/permissions/UserPermissionsProvider.tsx +1 -1
- package/src/features/store/objects/DocumentPreviewPanel.tsx +10 -10
- package/src/features/store/objects/components/DocumentInput.tsx +1 -1
- package/src/features/store/objects/upload/DocumentUploadModal.tsx +1 -1
- package/src/features/user/UserInfo.tsx +1 -1
- package/src/router/RouteComponent.test.tsx +65 -1
- package/src/router/RouteComponent.tsx +18 -15
- package/src/session/UserSession.test.ts +65 -0
- package/src/session/UserSession.ts +4 -0
- package/src/shell/SplashScreen.tsx +1 -1
- package/src/widgets/json-view/JSONCode.test.tsx +2 -2
- package/src/widgets/json-view/JSONCode.tsx +1 -1
- package/src/widgets/upload/DropZone.tsx +2 -4
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { screen } from '@testing-library/react';
|
|
2
|
+
import type { ComponentProps } from 'react';
|
|
3
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { renderWithProviders } from '../../__tests__/test-utils';
|
|
5
|
+
import { GenericPageNavHeader } from './GenericPageNavHeader';
|
|
6
|
+
|
|
7
|
+
vi.mock('@vertesia/ui/router', () => ({ useNavigate: () => vi.fn() }));
|
|
8
|
+
|
|
9
|
+
describe('GenericPageNavHeader breadcrumbs', () => {
|
|
10
|
+
it.each(['Parent', null])('ignores non-array breadcrumbs: %s', (breadcrumbs) => {
|
|
11
|
+
renderWithProviders(
|
|
12
|
+
<GenericPageNavHeader
|
|
13
|
+
title="Page"
|
|
14
|
+
useDynamicBreadcrumbs={false}
|
|
15
|
+
breadcrumbs={breadcrumbs as unknown as ComponentProps<typeof GenericPageNavHeader>['breadcrumbs']}
|
|
16
|
+
/>,
|
|
17
|
+
);
|
|
18
|
+
expect(screen.getByText('Page')).toBeDefined();
|
|
19
|
+
expect(screen.queryByText('Parent')).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('preserves breadcrumb elements', () => {
|
|
23
|
+
renderWithProviders(
|
|
24
|
+
<GenericPageNavHeader
|
|
25
|
+
title="Page"
|
|
26
|
+
useDynamicBreadcrumbs={false}
|
|
27
|
+
breadcrumbs={[
|
|
28
|
+
<a key="parent" href="/parent">
|
|
29
|
+
Parent
|
|
30
|
+
</a>,
|
|
31
|
+
]}
|
|
32
|
+
/>,
|
|
33
|
+
);
|
|
34
|
+
expect(screen.getByText('Parent')).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -124,7 +124,7 @@ export function GenericPageNavHeader({
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
// Add current page breadcrumbs
|
|
127
|
-
if (breadcrumbs
|
|
127
|
+
if (Array.isArray(breadcrumbs)) {
|
|
128
128
|
breadcrumbs.forEach((breadcrumb) => {
|
|
129
129
|
// Preserve the entire React element as label
|
|
130
130
|
const label = breadcrumb.props?.children || breadcrumb;
|
|
@@ -326,7 +326,7 @@ function ImageTypeButton({ type, currentType, onClick, icon, tooltip }: ImageTyp
|
|
|
326
326
|
aria-pressed={isSelected}
|
|
327
327
|
className={clsx(
|
|
328
328
|
'p-1 rounded cursor-pointer transition-colors',
|
|
329
|
-
isSelected ? 'text-
|
|
329
|
+
isSelected ? 'text-info bg-info' : 'text-muted hover:text-foreground hover:bg-muted',
|
|
330
330
|
)}
|
|
331
331
|
onClick={onClick}
|
|
332
332
|
>
|
|
@@ -659,7 +659,7 @@ function PageWithOverlay({
|
|
|
659
659
|
'absolute cursor-pointer border rounded-[1px] transition-colors',
|
|
660
660
|
citation.misaligned && 'border-dashed',
|
|
661
661
|
isSelected
|
|
662
|
-
? 'border-2 border-
|
|
662
|
+
? 'border-2 border-info bg-info z-10'
|
|
663
663
|
: citation.verified
|
|
664
664
|
? 'border-success/70 hover:bg-success/20'
|
|
665
665
|
: citation.reviewed
|
|
@@ -853,7 +853,7 @@ function ArrayTable({
|
|
|
853
853
|
className={cn(
|
|
854
854
|
'text-start rounded px-1 w-full',
|
|
855
855
|
isSelected
|
|
856
|
-
? 'bg-
|
|
856
|
+
? 'bg-info ring-1 ring-primary'
|
|
857
857
|
: citation
|
|
858
858
|
? 'hover:bg-muted cursor-pointer'
|
|
859
859
|
: 'text-muted cursor-default',
|
|
@@ -909,7 +909,7 @@ function LeafRow({
|
|
|
909
909
|
onClick={() => citation && onSelect(path)}
|
|
910
910
|
className={cn(
|
|
911
911
|
'flex items-center gap-2 w-full text-start text-sm rounded px-1.5 py-0.5',
|
|
912
|
-
isSelected ? 'bg-
|
|
912
|
+
isSelected ? 'bg-info ring-1 ring-primary' : citation ? 'hover:bg-muted' : '',
|
|
913
913
|
!citation && 'cursor-default',
|
|
914
914
|
)}
|
|
915
915
|
>
|
|
@@ -104,7 +104,7 @@ export function UserPermissionProvider({ children, loadingIcon }: UserPermission
|
|
|
104
104
|
<div className="flex min-h-dvh items-center justify-center bg-background px-6 text-foreground">
|
|
105
105
|
<div className="w-full max-w-md space-y-6 text-center">
|
|
106
106
|
<div aria-hidden="true" className="flex justify-center">
|
|
107
|
-
{loadingIcon || (!failed && <Spinner size="2xl" className="text-
|
|
107
|
+
{loadingIcon || (!failed && <Spinner size="2xl" className="text-info" />)}
|
|
108
108
|
</div>
|
|
109
109
|
<div
|
|
110
110
|
role={failed ? 'alert' : 'status'}
|
|
@@ -132,7 +132,7 @@ export function DocumentPreviewPanel({ objectId, isOpen, onClose }: DocumentPrev
|
|
|
132
132
|
{/* Header */}
|
|
133
133
|
<div className="flex items-center justify-between px-4 py-3 border-b dark:border-border bg-gradient-to-r from-indigo-50 to-gray-50 dark:from-gray-800 dark:to-gray-900">
|
|
134
134
|
<div className="flex items-center">
|
|
135
|
-
<FileText className="h-5 w-5 text-
|
|
135
|
+
<FileText className="h-5 w-5 text-info me-2" />
|
|
136
136
|
<h2 className="text-lg font-semibold text-foreground truncate">
|
|
137
137
|
{isLoading ? t('store.loadingDocument') : object?.name || t('store.documentPreview')}
|
|
138
138
|
</h2>
|
|
@@ -142,7 +142,7 @@ export function DocumentPreviewPanel({ objectId, isOpen, onClose }: DocumentPrev
|
|
|
142
142
|
variant="ghost"
|
|
143
143
|
size="sm"
|
|
144
144
|
onClick={handleViewFullDocument}
|
|
145
|
-
className="flex items-center gap-1 text-
|
|
145
|
+
className="flex items-center gap-1 text-info hover:text-info"
|
|
146
146
|
>
|
|
147
147
|
<Maximize2 className="h-4 w-4" />
|
|
148
148
|
<span>{t('store.fullView')}</span>
|
|
@@ -162,7 +162,7 @@ export function DocumentPreviewPanel({ objectId, isOpen, onClose }: DocumentPrev
|
|
|
162
162
|
{isLoading ? (
|
|
163
163
|
<div className="flex-1 flex items-center justify-center">
|
|
164
164
|
<div className="text-center">
|
|
165
|
-
<Spinner size="lg" className="text-
|
|
165
|
+
<Spinner size="lg" className="text-info mb-2" />
|
|
166
166
|
<p className="text-muted">{t('store.loadingDocument')}</p>
|
|
167
167
|
</div>
|
|
168
168
|
</div>
|
|
@@ -175,7 +175,7 @@ export function DocumentPreviewPanel({ objectId, isOpen, onClose }: DocumentPrev
|
|
|
175
175
|
variant="unstyled"
|
|
176
176
|
className={`py-2 px-4 font-medium border-b-2 ${
|
|
177
177
|
currentTab === 'preview'
|
|
178
|
-
? 'border-primary text-
|
|
178
|
+
? 'border-primary text-info'
|
|
179
179
|
: 'border-transparent text-muted hover:text-foreground'
|
|
180
180
|
}`}
|
|
181
181
|
onClick={() => setCurrentTab('preview')}
|
|
@@ -189,7 +189,7 @@ export function DocumentPreviewPanel({ objectId, isOpen, onClose }: DocumentPrev
|
|
|
189
189
|
variant="unstyled"
|
|
190
190
|
className={`py-2 px-4 font-medium border-b-2 ${
|
|
191
191
|
currentTab === 'properties'
|
|
192
|
-
? 'border-primary text-
|
|
192
|
+
? 'border-primary text-info'
|
|
193
193
|
: 'border-transparent text-muted hover:text-foreground'
|
|
194
194
|
}`}
|
|
195
195
|
onClick={() => setCurrentTab('properties')}
|
|
@@ -209,12 +209,12 @@ export function DocumentPreviewPanel({ objectId, isOpen, onClose }: DocumentPrev
|
|
|
209
209
|
{/* Text/Markdown Content */}
|
|
210
210
|
{loadingText ? (
|
|
211
211
|
<div className="flex items-center justify-center h-40">
|
|
212
|
-
<Spinner size="md" className="text-
|
|
212
|
+
<Spinner size="md" className="text-info" />
|
|
213
213
|
</div>
|
|
214
214
|
) : text ? (
|
|
215
215
|
<div className="shadow rounded-md p-4 border border-border bg-white dark:bg-muted">
|
|
216
216
|
{seemsMarkdown ? (
|
|
217
|
-
<div className="prose prose-sm max-w-none prose-p:my-2 prose-pre:bg-muted prose-pre:my-2 prose-headings:text-
|
|
217
|
+
<div className="prose prose-sm max-w-none prose-p:my-2 prose-pre:bg-muted prose-pre:my-2 prose-headings:text-info dark:prose-invert">
|
|
218
218
|
<MarkdownRenderer>{text}</MarkdownRenderer>
|
|
219
219
|
</div>
|
|
220
220
|
) : (
|
|
@@ -238,7 +238,7 @@ export function DocumentPreviewPanel({ objectId, isOpen, onClose }: DocumentPrev
|
|
|
238
238
|
</div>
|
|
239
239
|
) : (
|
|
240
240
|
<div className="flex items-center justify-center h-40">
|
|
241
|
-
<Spinner size="md" className="text-
|
|
241
|
+
<Spinner size="md" className="text-info" />
|
|
242
242
|
</div>
|
|
243
243
|
)}
|
|
244
244
|
</div>
|
|
@@ -247,7 +247,7 @@ export function DocumentPreviewPanel({ objectId, isOpen, onClose }: DocumentPrev
|
|
|
247
247
|
{/* PDF Content Notice */}
|
|
248
248
|
{isPdf && (
|
|
249
249
|
<div className="flex flex-col items-center justify-center h-64 border border-border rounded-md p-4 bg-muted">
|
|
250
|
-
<FileText className="h-16 w-16 text-
|
|
250
|
+
<FileText className="h-16 w-16 text-info mb-4" />
|
|
251
251
|
<p className="text-muted text-center mb-4">{t('store.pdfPreviewFullView')}</p>
|
|
252
252
|
<Button
|
|
253
253
|
onClick={handleViewFullDocument}
|
|
@@ -353,7 +353,7 @@ export function DocumentPreviewPanel({ objectId, isOpen, onClose }: DocumentPrev
|
|
|
353
353
|
}
|
|
354
354
|
}}
|
|
355
355
|
variant="outline"
|
|
356
|
-
className="text-
|
|
356
|
+
className="text-info border-primary hover:bg-info"
|
|
357
357
|
>
|
|
358
358
|
<Download className="h-4 w-4 me-2" />
|
|
359
359
|
{t('pdf.download')}
|
|
@@ -87,7 +87,7 @@ export function DocumentInput({ object }: DocumentInputProps) {
|
|
|
87
87
|
<SelectDocumentModal value={actualValue} isOpen={isOn} onClose={onSelect} />
|
|
88
88
|
</div>
|
|
89
89
|
{doc && (
|
|
90
|
-
<div className="p-1 semibold text-sm text-muted dark:text-foreground">
|
|
90
|
+
<div className="p-1 font-semibold text-sm text-muted dark:text-foreground">
|
|
91
91
|
{typeof doc.properties?.title === 'string' ? doc.properties.title : doc.name}
|
|
92
92
|
</div>
|
|
93
93
|
)}
|
|
@@ -678,7 +678,7 @@ export function DocumentUploadModal({
|
|
|
678
678
|
<div className="grid grid-cols-3 gap-4">
|
|
679
679
|
<div className="flex flex-col items-center">
|
|
680
680
|
<div className="flex items-center gap-2">
|
|
681
|
-
<UploadIcon className="size-5 text-
|
|
681
|
+
<UploadIcon className="size-5 text-info" />
|
|
682
682
|
<span className="font-medium">{t('upload.new')}</span>
|
|
683
683
|
</div>
|
|
684
684
|
<div className="text-2xl font-semibold">{processingStats.toCreate}</div>
|
|
@@ -466,7 +466,7 @@ function GroupAvatar({ userId, showTitle = false, size = 'md' }: GroupAvatarProp
|
|
|
466
466
|
return (
|
|
467
467
|
<UserPopoverPanel title={group.name || t('user.unnamedGroup')} description={description}>
|
|
468
468
|
<div className="flex flex-row items-center gap-2">
|
|
469
|
-
<Users className="size-6 text-
|
|
469
|
+
<Users className="size-6 text-info" size={size} />
|
|
470
470
|
{showTitle && <div className="text-sm font-semibold ps-2">{group.name || t('user.unnamedGroup')}</div>}
|
|
471
471
|
</div>
|
|
472
472
|
</UserPopoverPanel>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
2
|
-
import {
|
|
2
|
+
import { ErrorBoundary } from 'react-error-boundary';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
3
4
|
import { prefetchLazyRoutes, RouteComponent } from './RouteComponent';
|
|
4
5
|
import { type LazyRouteModule, useNavigate } from './Router';
|
|
5
6
|
import { RouterProvider } from './RouterProvider';
|
|
@@ -38,6 +39,44 @@ describe('RouteComponent lazy routes', () => {
|
|
|
38
39
|
window.history.pushState({}, '', '/');
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
it.each(['missing module', 'rejected import', 'synchronous failure'] as const)(
|
|
43
|
+
'reports a %s through the error boundary instead of remaining on the spinner',
|
|
44
|
+
async (failure) => {
|
|
45
|
+
const error = new Error('Module could not be loaded');
|
|
46
|
+
const onError = vi.fn();
|
|
47
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
48
|
+
const routes = [
|
|
49
|
+
{
|
|
50
|
+
path: '/a',
|
|
51
|
+
LazyComponent: () => {
|
|
52
|
+
if (failure === 'synchronous failure') throw error;
|
|
53
|
+
return failure === 'missing module'
|
|
54
|
+
? Promise.resolve(undefined as unknown as LazyRouteModule)
|
|
55
|
+
: Promise.reject(error);
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
];
|
|
59
|
+
try {
|
|
60
|
+
render(
|
|
61
|
+
<ErrorBoundary fallback={<div>Route unavailable</div>} onError={onError}>
|
|
62
|
+
<RouterProvider routes={routes}>
|
|
63
|
+
<RouteComponent spinner={<div>loading</div>} />
|
|
64
|
+
</RouterProvider>
|
|
65
|
+
</ErrorBoundary>,
|
|
66
|
+
);
|
|
67
|
+
await screen.findByText('Route unavailable');
|
|
68
|
+
expect(screen.queryByText('loading')).toBeNull();
|
|
69
|
+
expect(onError.mock.calls[0][0]).toEqual(
|
|
70
|
+
failure === 'missing module'
|
|
71
|
+
? new Error('Lazy module for /a does not have a default export')
|
|
72
|
+
: error,
|
|
73
|
+
);
|
|
74
|
+
} finally {
|
|
75
|
+
consoleError.mockRestore();
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
);
|
|
79
|
+
|
|
41
80
|
it('stops showing the previous lazy route while the next one loads', async () => {
|
|
42
81
|
const pageA = deferredModule('Page A');
|
|
43
82
|
const pageB = deferredModule('Page B');
|
|
@@ -87,6 +126,31 @@ describe('RouteComponent lazy routes', () => {
|
|
|
87
126
|
expect(screen.getByText('Page B')).toBeDefined();
|
|
88
127
|
});
|
|
89
128
|
|
|
129
|
+
it('ignores a rejected import after navigating away', async () => {
|
|
130
|
+
let reject!: (error: Error) => void;
|
|
131
|
+
const promise = new Promise<LazyRouteModule>((_resolve, rejectPromise) => {
|
|
132
|
+
reject = rejectPromise;
|
|
133
|
+
});
|
|
134
|
+
const onError = vi.fn();
|
|
135
|
+
render(
|
|
136
|
+
<ErrorBoundary fallback={<div>Route unavailable</div>} onError={onError}>
|
|
137
|
+
<RouterProvider
|
|
138
|
+
routes={[
|
|
139
|
+
{ path: '/a', LazyComponent: () => promise },
|
|
140
|
+
{ path: '/b', Component: () => <div>Page B</div> },
|
|
141
|
+
]}
|
|
142
|
+
>
|
|
143
|
+
<CaptureNavigate />
|
|
144
|
+
<RouteComponent />
|
|
145
|
+
</RouterProvider>
|
|
146
|
+
</ErrorBoundary>,
|
|
147
|
+
);
|
|
148
|
+
await act(async () => navigateFn('/b'));
|
|
149
|
+
await act(async () => reject(new Error('Previous route failed')));
|
|
150
|
+
expect(screen.getByText('Page B')).toBeDefined();
|
|
151
|
+
expect(onError).not.toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
90
154
|
it('renders a previously resolved lazy route synchronously on revisit', async () => {
|
|
91
155
|
const pageA = deferredModule('Page A');
|
|
92
156
|
const pageB = deferredModule('Page B');
|
|
@@ -13,14 +13,13 @@ import {
|
|
|
13
13
|
// between two lazy routes blanks the page for at least a frame, even when the module is loaded.
|
|
14
14
|
const resolvedComponents = new Map<LazyComponentRoute['LazyComponent'], LazyRouteModule['default']>();
|
|
15
15
|
|
|
16
|
-
function resolveLazyRoute(route: LazyComponentRoute): Promise<LazyRouteModule['default']> {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
});
|
|
16
|
+
async function resolveLazyRoute(route: LazyComponentRoute): Promise<LazyRouteModule['default']> {
|
|
17
|
+
const module = await route.LazyComponent();
|
|
18
|
+
if (!module?.default) {
|
|
19
|
+
throw new Error(`Lazy module for ${route.path} does not have a default export`);
|
|
20
|
+
}
|
|
21
|
+
resolvedComponents.set(route.LazyComponent, module.default);
|
|
22
|
+
return module.default;
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
/**
|
|
@@ -64,21 +63,25 @@ function LazyRouteComponent({ route, spinner }: LazyRouteComponentProps) {
|
|
|
64
63
|
const [Component, setComponent] = useState<LazyRouteModule['default'] | null>(
|
|
65
64
|
() => resolvedComponents.get(route.LazyComponent) ?? null,
|
|
66
65
|
);
|
|
66
|
+
const [error, setError] = useState<unknown>();
|
|
67
67
|
useEffect(() => {
|
|
68
68
|
// A slow import must not land after this instance moved on (unmount, or a route object
|
|
69
69
|
// swap without a path change): the stale module would overwrite the current one.
|
|
70
70
|
let stale = false;
|
|
71
|
-
void resolveLazyRoute(route).then(
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
void resolveLazyRoute(route).then(
|
|
72
|
+
(component) => {
|
|
73
|
+
if (!stale) setComponent(() => component);
|
|
74
|
+
},
|
|
75
|
+
(error: unknown) => {
|
|
76
|
+
// Render failures through the surrounding error boundary, including import failures.
|
|
77
|
+
if (!stale) setError(() => error);
|
|
78
|
+
},
|
|
79
|
+
);
|
|
78
80
|
return () => {
|
|
79
81
|
stale = true;
|
|
80
82
|
};
|
|
81
83
|
}, [route]);
|
|
82
84
|
|
|
85
|
+
if (error !== undefined) throw error;
|
|
83
86
|
return Component ? <Component /> : spinner || null;
|
|
84
87
|
}
|
|
@@ -1,17 +1,64 @@
|
|
|
1
1
|
import type { VertesiaClient } from '@vertesia/client';
|
|
2
|
+
import { type AuthTokenPayload, PrincipalType } from '@vertesia/common';
|
|
2
3
|
import { describe, expect, it, vi } from 'vitest';
|
|
3
4
|
import { UserSession } from './UserSession';
|
|
4
5
|
|
|
6
|
+
const projectToken: AuthTokenPayload = {
|
|
7
|
+
sub: 'test-user',
|
|
8
|
+
name: 'Test User',
|
|
9
|
+
type: PrincipalType.User,
|
|
10
|
+
account: { id: 'test-account', name: 'Test Account' },
|
|
11
|
+
account_roles: [],
|
|
12
|
+
accounts: [],
|
|
13
|
+
project: { id: 'test-project', name: 'Test Project', account: 'test-account' },
|
|
14
|
+
apps: [],
|
|
15
|
+
iss: 'https://auth.example.com',
|
|
16
|
+
aud: 'test-client',
|
|
17
|
+
exp: 4102444800,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
function encodeToken(payload: AuthTokenPayload): string {
|
|
21
|
+
return `${btoa(JSON.stringify({ alg: 'none' }))}.${btoa(JSON.stringify(payload))}.`;
|
|
22
|
+
}
|
|
23
|
+
|
|
5
24
|
function createSession(onboardingProgress: () => Promise<Record<string, boolean>>) {
|
|
6
25
|
const client = {
|
|
7
26
|
account: { onboardingProgress },
|
|
27
|
+
withAuthCallback: vi.fn(),
|
|
8
28
|
} as unknown as VertesiaClient;
|
|
9
29
|
const setSession = vi.fn<(session: UserSession) => void>();
|
|
10
30
|
const session = new UserSession(client, setSession);
|
|
31
|
+
session.authToken = { ...projectToken };
|
|
11
32
|
return { session, setSession };
|
|
12
33
|
}
|
|
13
34
|
|
|
14
35
|
describe('UserSession.fetchOnboardingStatus', () => {
|
|
36
|
+
it('skips onboarding before authentication without publishing a status', async () => {
|
|
37
|
+
const onboardingProgress = vi.fn(async () => ({ project_created: true }));
|
|
38
|
+
const { session, setSession } = createSession(onboardingProgress);
|
|
39
|
+
session.authToken = undefined;
|
|
40
|
+
|
|
41
|
+
await expect(session.fetchOnboardingStatus()).resolves.toBe(false);
|
|
42
|
+
expect(onboardingProgress).not.toHaveBeenCalled();
|
|
43
|
+
expect(session.onboardingComplete).toBeUndefined();
|
|
44
|
+
expect(setSession).not.toHaveBeenCalled();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('defers onboarding during account-only login and loads it after project selection', async () => {
|
|
48
|
+
const onboardingProgress = vi.fn(async () => ({ project_created: true }));
|
|
49
|
+
const { session, setSession } = createSession(onboardingProgress);
|
|
50
|
+
|
|
51
|
+
await session.login(encodeToken({ ...projectToken, project: undefined }));
|
|
52
|
+
expect(onboardingProgress).not.toHaveBeenCalled();
|
|
53
|
+
expect(session.onboardingComplete).toBeUndefined();
|
|
54
|
+
expect(setSession).not.toHaveBeenCalled();
|
|
55
|
+
|
|
56
|
+
await session.login(encodeToken(projectToken));
|
|
57
|
+
expect(onboardingProgress).toHaveBeenCalledOnce();
|
|
58
|
+
expect(session.onboardingComplete).toBe(true);
|
|
59
|
+
expect(setSession).toHaveBeenCalledOnce();
|
|
60
|
+
});
|
|
61
|
+
|
|
15
62
|
it('does not replace the session when an incomplete status is unchanged', async () => {
|
|
16
63
|
const { session, setSession } = createSession(async () => ({ project_created: false }));
|
|
17
64
|
session.onboardingComplete = false;
|
|
@@ -37,3 +84,21 @@ describe('UserSession.fetchOnboardingStatus', () => {
|
|
|
37
84
|
expect(setSession.mock.calls[0]?.[0]?.onboardingComplete).toBe(false);
|
|
38
85
|
});
|
|
39
86
|
});
|
|
87
|
+
|
|
88
|
+
describe('UserSession.signOut', () => {
|
|
89
|
+
it('keeps the receiver when used as a callback, including cloned sessions', () => {
|
|
90
|
+
const { session } = createSession(async () => ({}));
|
|
91
|
+
const clone = session.clone();
|
|
92
|
+
const logout = vi.spyOn(session, 'logout').mockImplementation(() => undefined);
|
|
93
|
+
const cloneLogout = vi.spyOn(clone, 'logout').mockImplementation(() => undefined);
|
|
94
|
+
const { signOut } = session;
|
|
95
|
+
const { signOut: signOutClone } = clone;
|
|
96
|
+
|
|
97
|
+
signOut();
|
|
98
|
+
expect(logout).toHaveBeenCalledOnce();
|
|
99
|
+
expect(cloneLogout).not.toHaveBeenCalled();
|
|
100
|
+
signOutClone();
|
|
101
|
+
expect(cloneLogout).toHaveBeenCalledOnce();
|
|
102
|
+
expect(logout).toHaveBeenCalledOnce();
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -44,6 +44,7 @@ class UserSession {
|
|
|
44
44
|
}
|
|
45
45
|
|
|
46
46
|
this.logout = this.logout.bind(this);
|
|
47
|
+
this.signOut = this.signOut.bind(this);
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
get store() {
|
|
@@ -251,6 +252,9 @@ class UserSession {
|
|
|
251
252
|
}
|
|
252
253
|
|
|
253
254
|
async fetchOnboardingStatus(): Promise<boolean> {
|
|
255
|
+
if (!this.project?.id) {
|
|
256
|
+
return false;
|
|
257
|
+
}
|
|
254
258
|
if (this.onboardingComplete) {
|
|
255
259
|
console.log('Onboarding already completed');
|
|
256
260
|
return false;
|
|
@@ -17,8 +17,8 @@ describe('JSONCode', () => {
|
|
|
17
17
|
// number
|
|
18
18
|
expect(html).toContain('<span class="text-attention">42</span>');
|
|
19
19
|
// literals
|
|
20
|
-
expect(html).toContain('<span class="text-
|
|
21
|
-
expect(html).toContain('<span class="text-
|
|
20
|
+
expect(html).toContain('<span class="text-info">true</span>');
|
|
21
|
+
expect(html).toContain('<span class="text-info">null</span>');
|
|
22
22
|
});
|
|
23
23
|
|
|
24
24
|
it('treats a string with escaped quotes as a single token', () => {
|
|
@@ -257,15 +257,13 @@ export function DropZone({ onDrop, message, buttonLabel, allowFolders = true, cl
|
|
|
257
257
|
// biome-ignore lint/a11y/noStaticElementInteractions: drag/drop target; file selection is exposed via the buttons inside.
|
|
258
258
|
<div
|
|
259
259
|
className={`flex flex-col items-center justify-center py-12 border-2 rounded-lg transition-colors ${
|
|
260
|
-
isDragging ? 'border-
|
|
260
|
+
isDragging ? 'border-info bg-info' : 'border-dashed border-border'
|
|
261
261
|
} ${className}`}
|
|
262
262
|
onDragOver={handleDragOver}
|
|
263
263
|
onDragLeave={handleDragLeave}
|
|
264
264
|
onDrop={handleDrop}
|
|
265
265
|
>
|
|
266
|
-
<UploadIcon
|
|
267
|
-
className={`h-12 w-12 mb-3 transition-colors ${isDragging ? 'text-primary' : 'text-muted/50'}`}
|
|
268
|
-
/>
|
|
266
|
+
<UploadIcon className={`h-12 w-12 mb-3 transition-colors ${isDragging ? 'text-info' : 'text-muted/50'}`} />
|
|
269
267
|
<p className="text-muted">{message}</p>
|
|
270
268
|
|
|
271
269
|
<div className="flex gap-2 justify-center mt-2">
|