@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.
Files changed (120) hide show
  1. package/lib/core/components/Link.js +1 -1
  2. package/lib/core/components/Link.js.map +1 -1
  3. package/lib/core/components/Spinner.js +1 -1
  4. package/lib/core/components/Spinner.js.map +1 -1
  5. package/lib/core/components/TagsInput.js +7 -7
  6. package/lib/core/components/TagsInput.js.map +1 -1
  7. package/lib/core/components/shadcn/badge.js +1 -1
  8. package/lib/core/components/shadcn/badge.js.map +1 -1
  9. package/lib/core/components/shadcn/button.d.ts.map +1 -1
  10. package/lib/core/components/shadcn/button.js +2 -2
  11. package/lib/core/components/shadcn/button.js.map +1 -1
  12. package/lib/core/components/shadcn/filters/comboBox/DateCombobox.js +2 -2
  13. package/lib/core/components/shadcn/filters/comboBox/DateCombobox.js.map +1 -1
  14. package/lib/core/components/shadcn/filters/comboBox/SelectCombobox.js +1 -1
  15. package/lib/core/components/shadcn/filters/comboBox/SelectCombobox.js.map +1 -1
  16. package/lib/core/components/shadcn/filters/comboBox/StringListCombobox.js +1 -1
  17. package/lib/core/components/shadcn/filters/comboBox/StringListCombobox.js.map +1 -1
  18. package/lib/core/components/shadcn/filters/comboBox/TextCombobox.js +1 -1
  19. package/lib/core/components/shadcn/filters/comboBox/TextCombobox.js.map +1 -1
  20. package/lib/core/components/shadcn/filters/filter/dateFilter.js +1 -1
  21. package/lib/core/components/shadcn/filters/filter/dateFilter.js.map +1 -1
  22. package/lib/core/components/shadcn/filters/filters.js +1 -1
  23. package/lib/core/components/shadcn/filters/filters.js.map +1 -1
  24. package/lib/core/components/shadcn/overflowTabs.js +2 -2
  25. package/lib/core/components/shadcn/overflowTabs.js.map +1 -1
  26. package/lib/core/components/shadcn/tabs.js +1 -1
  27. package/lib/core/components/shadcn/tabs.js.map +1 -1
  28. package/lib/features/activity-doc/ActivityDoc.js +1 -1
  29. package/lib/features/activity-doc/ActivityDoc.js.map +1 -1
  30. package/lib/features/agent/chat/AskUserWidget.d.ts +1 -0
  31. package/lib/features/agent/chat/AskUserWidget.d.ts.map +1 -1
  32. package/lib/features/agent/chat/AskUserWidget.js +15 -4
  33. package/lib/features/agent/chat/AskUserWidget.js.map +1 -1
  34. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.d.ts.map +1 -1
  35. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js +5 -3
  36. package/lib/features/agent/chat/ModernAgentOutput/AllMessagesMixed.js.map +1 -1
  37. package/lib/features/agent/chat/ModernAgentOutput/MessageItem.d.ts.map +1 -1
  38. package/lib/features/agent/chat/ModernAgentOutput/MessageItem.js +3 -2
  39. package/lib/features/agent/chat/ModernAgentOutput/MessageItem.js.map +1 -1
  40. package/lib/features/layout/GenericPageNavHeader.js +1 -1
  41. package/lib/features/layout/GenericPageNavHeader.js.map +1 -1
  42. package/lib/features/magic-pdf/AnnotatedImageSlider.js +1 -1
  43. package/lib/features/magic-pdf/AnnotatedImageSlider.js.map +1 -1
  44. package/lib/features/magic-pdf/GroundedExtractionView.js +3 -3
  45. package/lib/features/magic-pdf/GroundedExtractionView.js.map +1 -1
  46. package/lib/features/permissions/UserPermissionsProvider.js +1 -1
  47. package/lib/features/permissions/UserPermissionsProvider.js.map +1 -1
  48. package/lib/features/store/objects/DocumentPreviewPanel.js +5 -5
  49. package/lib/features/store/objects/DocumentPreviewPanel.js.map +1 -1
  50. package/lib/features/store/objects/components/DocumentInput.js +1 -1
  51. package/lib/features/store/objects/components/DocumentInput.js.map +1 -1
  52. package/lib/features/store/objects/upload/DocumentUploadModal.js +1 -1
  53. package/lib/features/store/objects/upload/DocumentUploadModal.js.map +1 -1
  54. package/lib/features/user/UserInfo.d.ts +2 -0
  55. package/lib/features/user/UserInfo.d.ts.map +1 -1
  56. package/lib/features/user/UserInfo.js +1 -1
  57. package/lib/features/user/UserInfo.js.map +1 -1
  58. package/lib/router/RouteComponent.d.ts.map +1 -1
  59. package/lib/router/RouteComponent.js +15 -12
  60. package/lib/router/RouteComponent.js.map +1 -1
  61. package/lib/session/UserSession.d.ts.map +1 -1
  62. package/lib/session/UserSession.js +4 -0
  63. package/lib/session/UserSession.js.map +1 -1
  64. package/lib/shell/SplashScreen.js +1 -1
  65. package/lib/shell/SplashScreen.js.map +1 -1
  66. package/lib/vertesia-ui-core.js +3 -3
  67. package/lib/vertesia-ui-core.js.map +1 -1
  68. package/lib/vertesia-ui-features.js +32 -32
  69. package/lib/vertesia-ui-features.js.map +1 -1
  70. package/lib/vertesia-ui-router.js +1 -1
  71. package/lib/vertesia-ui-router.js.map +1 -1
  72. package/lib/vertesia-ui-session.js +1 -1
  73. package/lib/vertesia-ui-session.js.map +1 -1
  74. package/lib/vertesia-ui-shell.js +1 -1
  75. package/lib/vertesia-ui-shell.js.map +1 -1
  76. package/lib/vertesia-ui-widgets.js +2 -2
  77. package/lib/vertesia-ui-widgets.js.map +1 -1
  78. package/lib/widgets/json-view/JSONCode.js +1 -1
  79. package/lib/widgets/json-view/JSONCode.js.map +1 -1
  80. package/lib/widgets/upload/DropZone.d.ts.map +1 -1
  81. package/lib/widgets/upload/DropZone.js +1 -1
  82. package/lib/widgets/upload/DropZone.js.map +1 -1
  83. package/package.json +7 -7
  84. package/src/core/components/Link.tsx +1 -1
  85. package/src/core/components/Spinner.tsx +1 -1
  86. package/src/core/components/TagsInput.tsx +7 -7
  87. package/src/core/components/shadcn/badge.tsx +1 -1
  88. package/src/core/components/shadcn/button.tsx +2 -3
  89. package/src/core/components/shadcn/filters/comboBox/DateCombobox.tsx +2 -2
  90. package/src/core/components/shadcn/filters/comboBox/SelectCombobox.tsx +1 -1
  91. package/src/core/components/shadcn/filters/comboBox/StringListCombobox.tsx +1 -1
  92. package/src/core/components/shadcn/filters/comboBox/TextCombobox.tsx +1 -1
  93. package/src/core/components/shadcn/filters/filter/dateFilter.tsx +1 -1
  94. package/src/core/components/shadcn/filters/filters.tsx +1 -1
  95. package/src/core/components/shadcn/overflowTabs.tsx +2 -2
  96. package/src/core/components/shadcn/tabs.tsx +1 -1
  97. package/src/css/utilities.css +7 -0
  98. package/src/features/activity-doc/ActivityDoc.tsx +1 -1
  99. package/src/features/agent/chat/AskUserWidget.test.tsx +24 -2
  100. package/src/features/agent/chat/AskUserWidget.tsx +19 -4
  101. package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.test.tsx +62 -0
  102. package/src/features/agent/chat/ModernAgentOutput/AllMessagesMixed.tsx +8 -7
  103. package/src/features/agent/chat/ModernAgentOutput/MessageItem.tsx +4 -3
  104. package/src/features/layout/GenericPageNavHeader.test.tsx +36 -0
  105. package/src/features/layout/GenericPageNavHeader.tsx +1 -1
  106. package/src/features/magic-pdf/AnnotatedImageSlider.tsx +1 -1
  107. package/src/features/magic-pdf/GroundedExtractionView.tsx +3 -3
  108. package/src/features/permissions/UserPermissionsProvider.tsx +1 -1
  109. package/src/features/store/objects/DocumentPreviewPanel.tsx +10 -10
  110. package/src/features/store/objects/components/DocumentInput.tsx +1 -1
  111. package/src/features/store/objects/upload/DocumentUploadModal.tsx +1 -1
  112. package/src/features/user/UserInfo.tsx +1 -1
  113. package/src/router/RouteComponent.test.tsx +65 -1
  114. package/src/router/RouteComponent.tsx +18 -15
  115. package/src/session/UserSession.test.ts +65 -0
  116. package/src/session/UserSession.ts +4 -0
  117. package/src/shell/SplashScreen.tsx +1 -1
  118. package/src/widgets/json-view/JSONCode.test.tsx +2 -2
  119. package/src/widgets/json-view/JSONCode.tsx +1 -1
  120. 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 && breadcrumbs.length > 0) {
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-primary bg-primary/10' : 'text-muted hover:text-foreground hover:bg-muted',
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-primary bg-primary/20 z-10'
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-primary/15 ring-1 ring-primary'
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-primary/15 ring-1 ring-primary' : citation ? 'hover:bg-muted' : '',
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-primary" />)}
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-primary me-2" />
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-primary hover:text-primary"
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-primary mb-2" />
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-primary'
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-primary'
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-primary" />
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-primary dark:prose-invert">
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-primary" />
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-primary mb-4" />
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-primary border-primary hover:bg-info"
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-primary" />
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-primary" size={size} />
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 { afterEach, beforeEach, describe, expect, it } from 'vitest';
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
- return route.LazyComponent().then((module) => {
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;
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((component) => {
72
- if (!stale) {
73
- // we need to wrap the component type in an arrow function
74
- // otherwise the setState function will execute the function as a state update function
75
- setComponent(() => component);
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;
@@ -58,7 +58,7 @@ function LoadingIcon() {
58
58
  <svg
59
59
  width="32"
60
60
  height="32"
61
- className="w-8 h-8 text-primary"
61
+ className="w-8 h-8 text-info"
62
62
  viewBox="0 0 50 50"
63
63
  xmlns="http://www.w3.org/2000/svg"
64
64
  role="img"
@@ -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-primary">true</span>');
21
- expect(html).toContain('<span class="text-primary">null</span>');
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', () => {
@@ -54,7 +54,7 @@ export function renderJsonLine(line: string) {
54
54
  : stringValue
55
55
  ? 'text-success'
56
56
  : literal
57
- ? 'text-primary'
57
+ ? 'text-info'
58
58
  : numberValue
59
59
  ? 'text-attention'
60
60
  : undefined;
@@ -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-primary bg-primary/10' : 'border-dashed border-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">