@semiont/react-ui 0.5.5 → 0.5.6

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 (87) hide show
  1. package/README.md +59 -55
  2. package/dist/{PdfAnnotationCanvas.client-CN3C3S55.js → PdfAnnotationCanvas.client-NIMALXNZ.js} +7 -27
  3. package/dist/PdfAnnotationCanvas.client-NIMALXNZ.js.map +1 -0
  4. package/dist/index.css +6 -0
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.ts +243 -99
  7. package/dist/index.js +539 -405
  8. package/dist/index.js.map +1 -1
  9. package/package.json +16 -12
  10. package/src/components/Button/__tests__/Button.test.tsx +0 -2
  11. package/src/components/CodeMirrorRenderer.tsx +2 -0
  12. package/src/components/ErrorBoundary.tsx +0 -9
  13. package/src/components/ProtectedErrorBoundary.tsx +6 -2
  14. package/src/components/__tests__/AnnotateReferencesProgressWidget.test.tsx +0 -1
  15. package/src/components/__tests__/ErrorBoundary.test.tsx +20 -13
  16. package/src/components/__tests__/LiveRegion.hooks.test.tsx +1 -1
  17. package/src/components/__tests__/ProtectedErrorBoundary.test.tsx +2 -1
  18. package/src/components/__tests__/ResizeHandle.test.tsx +0 -1
  19. package/src/components/__tests__/SessionExpiryBanner.test.tsx +0 -1
  20. package/src/components/__tests__/StatusDisplay.test.tsx +0 -1
  21. package/src/components/__tests__/Toast.test.tsx +2 -3
  22. package/src/components/__tests__/Toolbar.test.tsx +0 -1
  23. package/src/components/annotation/annotations.css +14 -0
  24. package/src/components/annotation-popups/__tests__/JsonLdView.test.tsx +3 -5
  25. package/src/components/annotation-popups/__tests__/SharedPopupElements.test.tsx +0 -1
  26. package/src/components/branding/__tests__/SemiontBranding.test.tsx +1 -2
  27. package/src/components/layout/__tests__/LeftSidebar.test.tsx +5 -6
  28. package/src/components/layout/__tests__/PageLayout.test.tsx +1 -3
  29. package/src/components/layout/__tests__/SkipLinks.a11y.test.tsx +8 -8
  30. package/src/components/layout/__tests__/UnifiedHeader.test.tsx +12 -1
  31. package/src/components/modals/__tests__/KeyboardShortcutsHelpModal.test.tsx +0 -1
  32. package/src/components/modals/__tests__/PermissionDeniedModal.test.tsx +3 -4
  33. package/src/components/modals/__tests__/ResourceSearchModal.test.tsx +0 -1
  34. package/src/components/modals/__tests__/SearchModal.basic.test.tsx +1 -1
  35. package/src/components/modals/__tests__/SearchModal.keyboard.test.tsx +0 -5
  36. package/src/components/modals/__tests__/SearchModal.search-wiring.test.tsx +0 -1
  37. package/src/components/modals/__tests__/SearchModal.visual.test.tsx +2 -2
  38. package/src/components/modals/__tests__/SessionExpiredModal.test.tsx +0 -1
  39. package/src/components/navigation/NavigationMenu.tsx +1 -1
  40. package/src/components/navigation/__tests__/Footer.a11y.test.tsx +4 -0
  41. package/src/components/navigation/__tests__/Footer.test.tsx +3 -6
  42. package/src/components/navigation/__tests__/NavigationMenu.a11y.test.tsx +1 -1
  43. package/src/components/navigation/__tests__/NavigationMenu.test.tsx +7 -9
  44. package/src/components/navigation/__tests__/ObservableLink.test.tsx +0 -1
  45. package/src/components/navigation/__tests__/SimpleNavigation.test.tsx +1 -2
  46. package/src/components/navigation/__tests__/SortableResourceTab.test.tsx +0 -1
  47. package/src/components/pdf-annotation/PdfAnnotationCanvas.tsx +6 -4
  48. package/src/components/pdf-annotation/__tests__/PdfAnnotationCanvas.test.tsx +10 -19
  49. package/src/components/resource/__tests__/BrowseView.test.tsx +8 -6
  50. package/src/components/resource/__tests__/HistoryEvent.test.tsx +0 -4
  51. package/src/components/resource/__tests__/ResourceViewer.mode-switch.test.tsx +4 -6
  52. package/src/components/resource/panels/ReferencesPanel.tsx +1 -1
  53. package/src/components/resource/panels/__tests__/AssessmentEntry.test.tsx +3 -4
  54. package/src/components/resource/panels/__tests__/AssessmentPanel.test.tsx +7 -6
  55. package/src/components/resource/panels/__tests__/AssistSection.test.tsx +14 -10
  56. package/src/components/resource/panels/__tests__/CollaborationPanel.test.tsx +0 -1
  57. package/src/components/resource/panels/__tests__/CommentEntry.test.tsx +30 -17
  58. package/src/components/resource/panels/__tests__/CommentsPanel.test.tsx +6 -5
  59. package/src/components/resource/panels/__tests__/HighlightEntry.test.tsx +4 -5
  60. package/src/components/resource/panels/__tests__/HighlightPanel.annotationProgress.test.tsx +19 -13
  61. package/src/components/resource/panels/__tests__/JsonLdPanel.test.tsx +1 -3
  62. package/src/components/resource/panels/__tests__/PanelHeader.test.tsx +0 -1
  63. package/src/components/resource/panels/__tests__/ReferenceEntry.test.tsx +5 -5
  64. package/src/components/resource/panels/__tests__/ReferencesPanel.test.tsx +40 -7
  65. package/src/components/resource/panels/__tests__/ResourceInfoPanel.test.tsx +3 -3
  66. package/src/components/resource/panels/__tests__/StatisticsPanel.test.tsx +30 -32
  67. package/src/components/resource/panels/__tests__/TagEntry.test.tsx +5 -5
  68. package/src/components/resource/panels/__tests__/TaggingPanel.test.tsx +6 -5
  69. package/src/components/settings/__tests__/SettingsPanel.test.tsx +0 -1
  70. package/src/components/viewers/__tests__/ImageViewer.test.tsx +0 -1
  71. package/src/features/auth/__tests__/SignInForm.a11y.test.tsx +2 -0
  72. package/src/features/auth/__tests__/SignUpForm.a11y.test.tsx +11 -12
  73. package/src/features/auth/__tests__/SignUpForm.test.tsx +3 -3
  74. package/src/features/moderate-tag-schemas/components/TagSchemasPage.tsx +1 -0
  75. package/src/features/resource-compose/__tests__/ResourceComposePage.test.tsx +2 -1
  76. package/src/features/resource-discovery/__tests__/ResourceCard.test.tsx +0 -1
  77. package/src/features/resource-discovery/__tests__/ResourceDiscoveryPage.test.tsx +33 -35
  78. package/src/features/resource-discovery/components/ResourceDiscoveryPage.tsx +12 -11
  79. package/src/features/resource-discovery/state/__tests__/discover-state-unit.test.ts +204 -11
  80. package/src/features/resource-discovery/state/discover-state-unit.ts +70 -11
  81. package/src/features/resource-viewer/__tests__/ResourceViewerPage.test.tsx +2 -2
  82. package/src/features/resource-viewer/components/ResourceViewerPage.tsx +3 -2
  83. package/src/features/resource-viewer/state/__tests__/resource-viewer-page-state-unit.test.ts +0 -1
  84. package/src/features/resource-viewer/state/resource-viewer-page-state-unit.ts +5 -2
  85. package/src/integrations/__tests__/css-modules-helper.test.tsx +2 -3
  86. package/src/integrations/__tests__/styled-components-theme.test.ts +1 -3
  87. package/dist/PdfAnnotationCanvas.client-CN3C3S55.js.map +0 -1
@@ -1,16 +1,16 @@
1
1
  import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
2
  import type { MockedFunction } from 'vitest';
3
3
  import React from 'react';
4
- import { render, screen, fireEvent, waitFor } from '@testing-library/react';
4
+ import { render, screen, waitFor } from '@testing-library/react';
5
5
  import userEvent from '@testing-library/user-event';
6
6
  import '@testing-library/jest-dom';
7
7
  import { of } from 'rxjs';
8
8
  import { CacheObservable } from '@semiont/sdk';
9
9
  import { TaggingPanel } from '../TaggingPanel';
10
- import type { components, EventBus, TagSchema } from '@semiont/core';
10
+ import type { EventBus, TagSchema } from '@semiont/core';
11
11
  import { createTestSemiontWrapper } from '../../../../test-utils';
12
12
 
13
- import type { Annotation } from '@semiont/core';
13
+ import type { Annotation, AnnotationId } from '@semiont/core';
14
14
 
15
15
  // Composition-based event tracker
16
16
  interface TrackedEvent {
@@ -124,7 +124,7 @@ vi.mock('@semiont/core', async () => {
124
124
 
125
125
  // Mock TagEntry component to simplify testing
126
126
  vi.mock('../TagEntry', () => ({
127
- TagEntry: ({ tag, onTagRef }: any) => (
127
+ TagEntry: ({ tag }: any) => (
128
128
  <div data-testid={`tag-${tag.id}`}>
129
129
  <div>{tag.id}</div>
130
130
  </div>
@@ -138,10 +138,11 @@ const mockGetTargetSelector = getTargetSelector as MockedFunction<typeof getTarg
138
138
  // Test data fixtures
139
139
  const createMockTag = (id: string, start: number, end: number, tagName: string = 'Issue'): Annotation => ({
140
140
  '@context': 'http://www.w3.org/ns/anno.jsonld',
141
- id,
141
+ id: id as AnnotationId,
142
142
  type: 'Annotation',
143
143
  motivation: 'tagging',
144
144
  creator: {
145
+ '@type': 'Person',
145
146
  name: `user${id}@example.com`,
146
147
  },
147
148
  created: `2024-01-0${id.slice(-1)}T10:00:00Z`,
@@ -1,5 +1,4 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
- import React from 'react';
3
2
  import { screen, fireEvent } from '@testing-library/react';
4
3
  import { renderWithProviders } from '../../../test-utils';
5
4
  import '@testing-library/jest-dom';
@@ -1,5 +1,4 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import React from 'react';
3
2
  import { screen } from '@testing-library/react';
4
3
  import '@testing-library/jest-dom';
5
4
  import { renderWithProviders } from '../../../test-utils';
@@ -45,6 +45,8 @@ const mockTranslations = {
45
45
  errorEmailRequired: 'Email is required',
46
46
  errorPasswordRequired: 'Password is required',
47
47
  errorBackendUrlRequired: 'Backend URL is required',
48
+ errorBackendUrlInvalid: 'Backend URL is not valid',
49
+ errorBackendUrlUnreachable: 'Backend URL is unreachable',
48
50
  tagline: 'make meaning',
49
51
  };
50
52
 
@@ -54,7 +54,7 @@ describe('SignUpForm - Accessibility', () => {
54
54
  });
55
55
 
56
56
  it('should have no accessibility violations during loading state', async () => {
57
- const onSignUp = vi.fn(() => new Promise(() => {})); // Never resolves
57
+ const onSignUp = vi.fn<() => Promise<void>>(() => new Promise<void>(() => {})); // Never resolves
58
58
 
59
59
  const { container } = render(
60
60
  <SignUpForm
@@ -138,7 +138,7 @@ describe('SignUpForm - Accessibility', () => {
138
138
  });
139
139
 
140
140
  it('should disable button during loading state', async () => {
141
- const onSignUp = vi.fn(() => new Promise(() => {}));
141
+ const onSignUp = vi.fn<() => Promise<void>>(() => new Promise<void>(() => {}));
142
142
 
143
143
  render(
144
144
  <SignUpForm
@@ -197,13 +197,12 @@ describe('SignUpForm - Accessibility', () => {
197
197
  it('should have Google icon inside button', () => {
198
198
  const onSignUp = vi.fn();
199
199
 
200
- const { container } = render(
201
- <SignUpForm
202
- onSignUp={onSignUp}
203
- Link={MockLink}
204
- translations={mockTranslations}
205
- />
206
- );
200
+ render(
201
+ <SignUpForm
202
+ onSignUp={onSignUp}
203
+ Link={MockLink}
204
+ translations={mockTranslations} />
205
+ );
207
206
 
208
207
  const button = screen.getByRole('button');
209
208
  const svg = button.querySelector('svg');
@@ -214,7 +213,7 @@ describe('SignUpForm - Accessibility', () => {
214
213
  });
215
214
 
216
215
  it('should show loading spinner during sign-up', async () => {
217
- const onSignUp = vi.fn(() => new Promise(() => {}));
216
+ const onSignUp = vi.fn<() => Promise<void>>(() => new Promise<void>(() => {}));
218
217
 
219
218
  const { container } = render(
220
219
  <SignUpForm
@@ -349,7 +348,7 @@ describe('SignUpForm - Accessibility', () => {
349
348
 
350
349
  describe('Loading State Accessibility', () => {
351
350
  it('should announce loading state to screen readers', async () => {
352
- const onSignUp = vi.fn(() => new Promise(() => {}));
351
+ const onSignUp = vi.fn<() => Promise<void>>(() => new Promise<void>(() => {}));
353
352
 
354
353
  render(
355
354
  <SignUpForm
@@ -368,7 +367,7 @@ describe('SignUpForm - Accessibility', () => {
368
367
  });
369
368
 
370
369
  it('should maintain button focus during loading', async () => {
371
- const onSignUp = vi.fn(() => new Promise(() => {}));
370
+ const onSignUp = vi.fn<() => Promise<void>>(() => new Promise<void>(() => {}));
372
371
 
373
372
  render(
374
373
  <SignUpForm
@@ -53,7 +53,7 @@ describe('SignUpForm', () => {
53
53
 
54
54
  describe('Sign-Up Interaction', () => {
55
55
  it('calls onSignUp when button is clicked', async () => {
56
- const onSignUp = vi.fn<[], Promise<void>>().mockResolvedValue(undefined);
56
+ const onSignUp = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
57
57
  render(<SignUpForm onSignUp={onSignUp} Link={MockLink} translations={mockTranslations} />);
58
58
 
59
59
  const button = screen.getByRole('button', { name: /Continue with Google/i });
@@ -63,7 +63,7 @@ describe('SignUpForm', () => {
63
63
  });
64
64
 
65
65
  it('shows loading state while signing up', async () => {
66
- const onSignUp = vi.fn<[], Promise<void>>(() => new Promise<void>((resolve) => setTimeout(resolve, 100)));
66
+ const onSignUp = vi.fn<() => Promise<void>>(() => new Promise<void>((resolve) => setTimeout(resolve, 100)));
67
67
  render(<SignUpForm onSignUp={onSignUp} Link={MockLink} translations={mockTranslations} />);
68
68
 
69
69
  const button = screen.getByRole('button');
@@ -83,7 +83,7 @@ describe('SignUpForm', () => {
83
83
  });
84
84
 
85
85
  it('disables button during loading', async () => {
86
- const onSignUp = vi.fn<[], Promise<void>>(() => new Promise<void>((resolve) => setTimeout(resolve, 100)));
86
+ const onSignUp = vi.fn<() => Promise<void>>(() => new Promise<void>((resolve) => setTimeout(resolve, 100)));
87
87
  render(<SignUpForm onSignUp={onSignUp} Link={MockLink} translations={mockTranslations} />);
88
88
 
89
89
  const button = screen.getByRole('button');
@@ -13,6 +13,7 @@ import {
13
13
  } from '@heroicons/react/24/outline';
14
14
  import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
15
15
  import type { TagSchema } from '@semiont/sdk';
16
+ export type { TagSchema };
16
17
 
17
18
  export interface TagSchemasPageProps {
18
19
  // Data props
@@ -8,7 +8,7 @@
8
8
  import { describe, it, expect, vi } from 'vitest';
9
9
  import { render, screen, fireEvent, waitFor } from '@testing-library/react';
10
10
  import { ResourceComposePage } from '../components/ResourceComposePage';
11
- import type { ResourceComposePageProps, SaveResourceParams } from '../components/ResourceComposePage';
11
+ import type { ResourceComposePageProps } from '../components/ResourceComposePage';
12
12
  import { createTestSemiontWrapper } from '../../../test-utils';
13
13
 
14
14
  // Mock CodeMirrorRenderer to avoid CodeMirror dependencies
@@ -63,6 +63,7 @@ const createMockProps = (overrides?: Partial<ResourceComposePageProps>): Resourc
63
63
  initialLocale: 'en',
64
64
  theme: 'light',
65
65
  showLineNumbers: false,
66
+ hoverDelayMs: 0,
66
67
  activePanel: null,
67
68
  onSaveResource: vi.fn().mockResolvedValue(undefined),
68
69
  onCancel: vi.fn(),
@@ -7,7 +7,6 @@
7
7
  import { describe, it, expect, vi } from 'vitest';
8
8
  import { render, screen, fireEvent } from '@testing-library/react';
9
9
  import { ResourceCard } from '../components/ResourceCard';
10
- import type { ResourceCardProps } from '../components/ResourceCard';
11
10
 
12
11
  const createMockResource = (overrides?: any) => ({
13
12
  '@context': 'https://www.w3.org/ns/anno.jsonld',
@@ -10,10 +10,12 @@ import { render, screen, fireEvent } from '@testing-library/react';
10
10
  import { ResourceDiscoveryPage } from '../components/ResourceDiscoveryPage';
11
11
  import type { ResourceDiscoveryPageProps } from '../components/ResourceDiscoveryPage';
12
12
  import { createTestSemiontWrapper } from '../../../test-utils';
13
+ import { resourceId } from '@semiont/core';
14
+ import type { ResourceDescriptor } from '@semiont/core';
13
15
 
14
- const createMockResource = (id: string, name: string, entityTypes: string[] = []) => ({
16
+ const createMockResource = (id: string, name: string, entityTypes: string[] = []): ResourceDescriptor => ({
15
17
  '@context': 'https://www.w3.org/ns/anno.jsonld',
16
- '@id': id,
18
+ '@id': resourceId(id),
17
19
  '@type': 'schema:DigitalDocument',
18
20
  name,
19
21
  description: `Description for ${name}`,
@@ -31,6 +33,8 @@ const createMockProps = (overrides?: Partial<ResourceDiscoveryPageProps>): Resou
31
33
  isSearching: false,
32
34
  searchQuery: '',
33
35
  onSearchQueryChange: vi.fn(),
36
+ selectedEntityType: '',
37
+ onSelectedEntityTypeChange: vi.fn(),
34
38
  theme: 'light',
35
39
  showLineNumbers: false,
36
40
  activePanel: null,
@@ -254,65 +258,59 @@ describe('ResourceDiscoveryPage', () => {
254
258
  expect(screen.getByRole('button', { name: 'Report' })).toBeInTheDocument();
255
259
  });
256
260
 
257
- it('filters documents by entity type', () => {
261
+ it('calls onSelectedEntityTypeChange when a filter chip is clicked', () => {
262
+ const onSelectedEntityTypeChange = vi.fn();
258
263
  const props = createMockProps({
259
- recentDocuments: [
260
- createMockResource('1', 'Doc 1', ['Document']),
261
- createMockResource('2', 'Doc 2', ['Article']),
262
- createMockResource('3', 'Doc 3', ['Document']),
263
- ],
264
264
  entityTypes: ['Document', 'Article'],
265
+ onSelectedEntityTypeChange,
265
266
  });
266
267
  renderWithProviders(<ResourceDiscoveryPage {...props} />);
267
268
 
268
- // Initially all documents shown
269
- expect(screen.getByText('Doc 1')).toBeInTheDocument();
270
- expect(screen.getByText('Doc 2')).toBeInTheDocument();
271
- expect(screen.getByText('Doc 3')).toBeInTheDocument();
272
-
273
- // Filter by Document
274
- const documentButton = screen.getByRole('button', { name: 'Document' });
275
- fireEvent.click(documentButton);
269
+ fireEvent.click(screen.getByRole('button', { name: 'Document' }));
270
+ expect(onSelectedEntityTypeChange).toHaveBeenCalledWith('Document');
276
271
 
277
- expect(screen.getByText('Doc 1')).toBeInTheDocument();
278
- expect(screen.queryByText('Doc 2')).not.toBeInTheDocument();
279
- expect(screen.getByText('Doc 3')).toBeInTheDocument();
272
+ fireEvent.click(screen.getByRole('button', { name: 'Article' }));
273
+ expect(onSelectedEntityTypeChange).toHaveBeenCalledWith('Article');
280
274
  });
281
275
 
282
- it('shows filtered heading when entity type selected', () => {
276
+ it('shows filtered heading when selectedEntityType prop is set', () => {
283
277
  const props = createMockProps({
284
278
  recentDocuments: [createMockResource('1', 'Doc 1', ['Document'])],
285
279
  entityTypes: ['Document'],
280
+ selectedEntityType: 'Document',
286
281
  });
287
282
  renderWithProviders(<ResourceDiscoveryPage {...props} />);
288
283
 
289
- const documentButton = screen.getByRole('button', { name: 'Document' });
290
- fireEvent.click(documentButton);
291
-
292
284
  expect(screen.getByText('Documents tagged with Document')).toBeInTheDocument();
293
285
  });
294
286
 
295
- it('resets filter when "All" button clicked', () => {
287
+ it('calls onSelectedEntityTypeChange with empty string when "All" is clicked', () => {
288
+ const onSelectedEntityTypeChange = vi.fn();
289
+ const props = createMockProps({
290
+ entityTypes: ['Document', 'Article'],
291
+ selectedEntityType: 'Document',
292
+ onSelectedEntityTypeChange,
293
+ });
294
+ renderWithProviders(<ResourceDiscoveryPage {...props} />);
295
+
296
+ fireEvent.click(screen.getByRole('button', { name: 'All' }));
297
+ expect(onSelectedEntityTypeChange).toHaveBeenCalledWith('');
298
+ });
299
+
300
+ it('renders the recentDocuments prop as-is without applying any post-filter', () => {
301
+ // The component is now controlled — backend filtering means
302
+ // `recentDocuments` already contains only the resources matching the
303
+ // active `selectedEntityType`. The component must not re-filter.
296
304
  const props = createMockProps({
297
305
  recentDocuments: [
298
306
  createMockResource('1', 'Doc 1', ['Document']),
299
307
  createMockResource('2', 'Doc 2', ['Article']),
300
308
  ],
301
309
  entityTypes: ['Document', 'Article'],
310
+ selectedEntityType: 'Document',
302
311
  });
303
312
  renderWithProviders(<ResourceDiscoveryPage {...props} />);
304
313
 
305
- // Filter by Document
306
- const documentButton = screen.getByRole('button', { name: 'Document' });
307
- fireEvent.click(documentButton);
308
-
309
- expect(screen.getByText('Doc 1')).toBeInTheDocument();
310
- expect(screen.queryByText('Doc 2')).not.toBeInTheDocument();
311
-
312
- // Click All
313
- const allButton = screen.getByRole('button', { name: 'All' });
314
- fireEvent.click(allButton);
315
-
316
314
  expect(screen.getByText('Doc 1')).toBeInTheDocument();
317
315
  expect(screen.getByText('Doc 2')).toBeInTheDocument();
318
316
  });
@@ -5,7 +5,7 @@
5
5
  * All dependencies passed as props - no Next.js hooks!
6
6
  */
7
7
 
8
- import React, { useState, useCallback, useRef } from 'react';
8
+ import React, { useCallback, useRef } from 'react';
9
9
  import { getResourceId } from '@semiont/core';
10
10
  import { COMMON_PANELS, type ToolbarPanelType } from '../../../state/shell-state-unit';
11
11
  import { useRovingTabIndex } from '../../../hooks/useRovingTabIndex';
@@ -26,6 +26,11 @@ export interface ResourceDiscoveryPageProps {
26
26
  searchQuery: string;
27
27
  onSearchQueryChange: (query: string) => void;
28
28
 
29
+ // Controlled entity-type filter — owned by the state unit so filtering
30
+ // pushes to the backend rather than running as a post-fetch array filter.
31
+ selectedEntityType: string;
32
+ onSelectedEntityTypeChange: (entityType: string) => void;
33
+
29
34
  // UI state props
30
35
  theme: 'light' | 'dark';
31
36
  showLineNumbers: boolean;
@@ -67,6 +72,8 @@ export function ResourceDiscoveryPage({
67
72
  isSearching,
68
73
  searchQuery,
69
74
  onSearchQueryChange,
75
+ selectedEntityType,
76
+ onSelectedEntityTypeChange,
70
77
  theme,
71
78
  showLineNumbers,
72
79
  activePanel,
@@ -75,17 +82,11 @@ export function ResourceDiscoveryPage({
75
82
  translations: t,
76
83
  ToolbarPanels,
77
84
  }: ResourceDiscoveryPageProps) {
78
- const [selectedEntityType, setSelectedEntityType] = useState<string>('');
79
-
80
85
  const hasSearchQuery = searchQuery.trim() !== '';
81
86
 
82
87
  // When searching, render search results; otherwise render recent.
83
- const baseDocuments = hasSearchQuery ? searchDocuments : recentDocuments;
84
- const filteredResources = !selectedEntityType
85
- ? baseDocuments
86
- : baseDocuments.filter((resource: ResourceDescriptor) =>
87
- resource.entityTypes && resource.entityTypes.includes(selectedEntityType)
88
- );
88
+ // Both already arrive entity-type-filtered from the backend — no post-filter here.
89
+ const filteredResources = hasSearchQuery ? searchDocuments : recentDocuments;
89
90
 
90
91
  // Roving tabindex for entity type filters
91
92
  const entityFilterRoving = useRovingTabIndex<HTMLDivElement>(
@@ -105,8 +106,8 @@ export function ResourceDiscoveryPage({
105
106
 
106
107
  // Memoized callbacks
107
108
  const handleEntityTypeFilter = useCallback((entityType: string) => {
108
- setSelectedEntityType(entityType);
109
- }, []);
109
+ onSelectedEntityTypeChange(entityType);
110
+ }, [onSelectedEntityTypeChange]);
110
111
 
111
112
  const openResource = useCallback((resource: ResourceDescriptor) => {
112
113
  const resourceId = getResourceId(resource);
@@ -1,6 +1,6 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
2
  import { BehaviorSubject, firstValueFrom } from 'rxjs';
3
- import { filter } from 'rxjs/operators';
3
+ import { filter, skip, take, toArray } from 'rxjs/operators';
4
4
  import type { SemiontClient } from '@semiont/sdk';
5
5
  import type { ShellStateUnit } from '../../../../state/shell-state-unit';
6
6
  import { createDiscoverStateUnit } from '../discover-state-unit';
@@ -9,23 +9,43 @@ function mockBrowse(): ShellStateUnit {
9
9
  return { dispose: vi.fn() } as unknown as ShellStateUnit;
10
10
  }
11
11
 
12
+ interface BrowseFilters {
13
+ limit?: number;
14
+ archived?: boolean;
15
+ search?: string;
16
+ entityType?: string;
17
+ }
18
+
12
19
  function mockClient(overrides: {
13
20
  resources$?: BehaviorSubject<unknown[] | undefined>;
14
21
  entityTypes$?: BehaviorSubject<string[] | undefined>;
15
- } = {}): SemiontClient {
16
- const resources$ = overrides.resources$ ?? new BehaviorSubject<unknown[] | undefined>([{ '@id': 'r1' }]);
17
- const entityTypes$ = overrides.entityTypes$ ?? new BehaviorSubject<string[] | undefined>(['Person']);
18
- return {
22
+ resourcesFn?: (filters: BrowseFilters) => BehaviorSubject<unknown[] | undefined>;
23
+ } = {}): { client: SemiontClient; resourceCalls: BrowseFilters[] } {
24
+ const resourceCalls: BrowseFilters[] = [];
25
+ const defaultResources$ =
26
+ overrides.resources$ ?? new BehaviorSubject<unknown[] | undefined>([{ '@id': 'r1' }]);
27
+ const entityTypes$ =
28
+ overrides.entityTypes$ ?? new BehaviorSubject<string[] | undefined>(['Person']);
29
+
30
+ const resourcesFn = overrides.resourcesFn ?? (() => defaultResources$);
31
+
32
+ const client = {
19
33
  browse: {
20
- resources: () => resources$.asObservable(),
34
+ resources: (filters: BrowseFilters = {}) => {
35
+ resourceCalls.push(filters);
36
+ return resourcesFn(filters).asObservable();
37
+ },
21
38
  entityTypes: () => entityTypes$.asObservable(),
22
39
  },
23
40
  } as unknown as SemiontClient;
41
+
42
+ return { client, resourceCalls };
24
43
  }
25
44
 
26
45
  describe('createDiscoverStateUnit', () => {
27
46
  it('exposes recent resources from browse namespace', async () => {
28
- const stateUnit = createDiscoverStateUnit(mockClient(), mockBrowse());
47
+ const { client } = mockClient();
48
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
29
49
 
30
50
  const recent = await firstValueFrom(stateUnit.recentResources$);
31
51
  expect(recent).toEqual([{ '@id': 'r1' }]);
@@ -34,7 +54,8 @@ describe('createDiscoverStateUnit', () => {
34
54
  });
35
55
 
36
56
  it('exposes entity types from browse namespace', async () => {
37
- const stateUnit = createDiscoverStateUnit(mockClient(), mockBrowse());
57
+ const { client } = mockClient();
58
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
38
59
 
39
60
  const types = await firstValueFrom(stateUnit.entityTypes$);
40
61
  expect(types).toEqual(['Person']);
@@ -42,9 +63,21 @@ describe('createDiscoverStateUnit', () => {
42
63
  stateUnit.dispose();
43
64
  });
44
65
 
66
+ it('falls back to [] when entityTypes() emits undefined', async () => {
67
+ const entityTypes$ = new BehaviorSubject<string[] | undefined>(undefined);
68
+ const { client } = mockClient({ entityTypes$ });
69
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
70
+
71
+ const types = await firstValueFrom(stateUnit.entityTypes$);
72
+ expect(types).toEqual([]);
73
+
74
+ stateUnit.dispose();
75
+ });
76
+
45
77
  it('reports loading when resources are undefined', async () => {
46
78
  const resources$ = new BehaviorSubject<unknown[] | undefined>(undefined);
47
- const stateUnit = createDiscoverStateUnit(mockClient({ resources$ }), mockBrowse());
79
+ const { client } = mockClient({ resources$ });
80
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
48
81
 
49
82
  const loading = await firstValueFrom(stateUnit.isLoadingRecent$);
50
83
  expect(loading).toBe(true);
@@ -57,7 +90,8 @@ describe('createDiscoverStateUnit', () => {
57
90
  });
58
91
 
59
92
  it('exposes a search pipeline', () => {
60
- const stateUnit = createDiscoverStateUnit(mockClient(), mockBrowse());
93
+ const { client } = mockClient();
94
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
61
95
 
62
96
  expect(stateUnit.search).toBeDefined();
63
97
  expect(typeof stateUnit.search.setQuery).toBe('function');
@@ -68,9 +102,168 @@ describe('createDiscoverStateUnit', () => {
68
102
 
69
103
  it('disposes browse and search on dispose', () => {
70
104
  const browse = mockBrowse();
71
- const stateUnit = createDiscoverStateUnit(mockClient(), browse);
105
+ const { client } = mockClient();
106
+ const stateUnit = createDiscoverStateUnit(client, browse);
72
107
  stateUnit.dispose();
73
108
 
74
109
  expect(browse.dispose).toHaveBeenCalled();
75
110
  });
111
+
112
+ it('initial selectedEntityType$ is empty and recent fetch carries no entityType', async () => {
113
+ const { client, resourceCalls } = mockClient();
114
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
115
+
116
+ await firstValueFrom(stateUnit.recentResources$);
117
+
118
+ expect(resourceCalls).toHaveLength(1);
119
+ expect(resourceCalls[0]).toEqual({ limit: 10, archived: false });
120
+ expect(await firstValueFrom(stateUnit.selectedEntityType$)).toBe('');
121
+
122
+ stateUnit.dispose();
123
+ });
124
+
125
+ it('setSelectedEntityType drives a refetch with the entityType filter', async () => {
126
+ const { client, resourceCalls } = mockClient();
127
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
128
+
129
+ // Prime the first subscription so the switchMap is live.
130
+ const sub = stateUnit.recentResources$.subscribe();
131
+
132
+ stateUnit.setSelectedEntityType('Person');
133
+
134
+ expect(await firstValueFrom(stateUnit.selectedEntityType$)).toBe('Person');
135
+ // Two calls expected: initial '' then 'Person'.
136
+ expect(resourceCalls.length).toBeGreaterThanOrEqual(2);
137
+ expect(resourceCalls.at(-1)).toEqual({ limit: 10, archived: false, entityType: 'Person' });
138
+
139
+ sub.unsubscribe();
140
+ stateUnit.dispose();
141
+ });
142
+
143
+ it('search with an empty query yields no results without hitting the wire', async () => {
144
+ vi.useFakeTimers();
145
+ try {
146
+ const { client, resourceCalls } = mockClient();
147
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
148
+
149
+ const collected: Array<{ results: unknown[]; isSearching: boolean }> = [];
150
+ const sub = stateUnit.search.state$.subscribe((s) => collected.push(s));
151
+
152
+ await vi.advanceTimersByTimeAsync(300);
153
+
154
+ expect(collected.at(-1)).toEqual({ results: [], isSearching: false });
155
+ const searchCalls = resourceCalls.filter((c) => c.search !== undefined);
156
+ expect(searchCalls).toHaveLength(0);
157
+
158
+ sub.unsubscribe();
159
+ stateUnit.dispose();
160
+ } finally {
161
+ vi.useRealTimers();
162
+ }
163
+ });
164
+
165
+ it('search with a non-empty query and selected entityType pushes both into the filter', async () => {
166
+ vi.useFakeTimers();
167
+ try {
168
+ const results$ = new BehaviorSubject<unknown[] | undefined>([{ '@id': 'hit' }]);
169
+ const { client, resourceCalls } = mockClient({
170
+ resourcesFn: (filters) => (filters.search ? results$ : new BehaviorSubject<unknown[] | undefined>([])),
171
+ });
172
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
173
+
174
+ const sub = stateUnit.search.state$.subscribe();
175
+ stateUnit.setSelectedEntityType('Person');
176
+ stateUnit.search.setQuery('lincoln');
177
+
178
+ await vi.advanceTimersByTimeAsync(300);
179
+
180
+ const searchCalls = resourceCalls.filter((c) => c.search !== undefined);
181
+ expect(searchCalls.length).toBeGreaterThanOrEqual(1);
182
+ expect(searchCalls.at(-1)).toEqual({
183
+ search: 'lincoln',
184
+ limit: 20,
185
+ entityType: 'Person',
186
+ });
187
+
188
+ sub.unsubscribe();
189
+ stateUnit.dispose();
190
+ } finally {
191
+ vi.useRealTimers();
192
+ }
193
+ });
194
+
195
+ it('search results flow through state$ once the debounced query fetches', async () => {
196
+ vi.useFakeTimers();
197
+ try {
198
+ const results$ = new BehaviorSubject<unknown[] | undefined>([{ '@id': 'hit' }]);
199
+ const { client } = mockClient({
200
+ resourcesFn: () => results$,
201
+ });
202
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
203
+
204
+ const collected: Array<{ results: unknown[]; isSearching: boolean }> = [];
205
+ const sub = stateUnit.search.state$.subscribe((s) => collected.push(s));
206
+
207
+ stateUnit.search.setQuery('lincoln');
208
+ await vi.advanceTimersByTimeAsync(300);
209
+
210
+ expect(collected.at(-1)).toEqual({ results: [{ '@id': 'hit' }], isSearching: false });
211
+
212
+ sub.unsubscribe();
213
+ stateUnit.dispose();
214
+ } finally {
215
+ vi.useRealTimers();
216
+ }
217
+ });
218
+
219
+ it('search reports isSearching while the fetch is in flight', async () => {
220
+ vi.useFakeTimers();
221
+ try {
222
+ const inflight$ = new BehaviorSubject<unknown[] | undefined>(undefined);
223
+ const { client } = mockClient({
224
+ resourcesFn: () => inflight$,
225
+ });
226
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
227
+
228
+ const collected: Array<{ results: unknown[]; isSearching: boolean }> = [];
229
+ const sub = stateUnit.search.state$.subscribe((s) => collected.push(s));
230
+
231
+ stateUnit.search.setQuery('lincoln');
232
+ await vi.advanceTimersByTimeAsync(300);
233
+
234
+ expect(collected.at(-1)).toEqual({ results: [], isSearching: true });
235
+
236
+ sub.unsubscribe();
237
+ stateUnit.dispose();
238
+ } finally {
239
+ vi.useRealTimers();
240
+ }
241
+ });
242
+
243
+ it('search query observable echoes the latest setQuery value', async () => {
244
+ const { client } = mockClient();
245
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
246
+
247
+ const queries = stateUnit.search.query$.pipe(skip(1), take(1), toArray()).toPromise();
248
+ stateUnit.search.setQuery('alpha');
249
+
250
+ expect(await queries).toEqual(['alpha']);
251
+
252
+ stateUnit.dispose();
253
+ });
254
+
255
+ it('omits entityType from the filter when the empty sentinel is selected', async () => {
256
+ const { client, resourceCalls } = mockClient();
257
+ const stateUnit = createDiscoverStateUnit(client, mockBrowse());
258
+
259
+ const sub = stateUnit.recentResources$.subscribe();
260
+ stateUnit.setSelectedEntityType('Person');
261
+ stateUnit.setSelectedEntityType('');
262
+
263
+ const last = resourceCalls.at(-1)!;
264
+ expect(last.entityType).toBeUndefined();
265
+
266
+ sub.unsubscribe();
267
+ stateUnit.dispose();
268
+ });
76
269
  });