@semiont/react-ui 0.4.17 → 0.4.19

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.
@@ -0,0 +1,221 @@
1
+ /**
2
+ * SearchModal — wiring + UI tests
3
+ *
4
+ * Verifies that SearchModal correctly wires createSearchPipeline to
5
+ * browse.resources, maps results to its SearchResult shape, and renders
6
+ * each emission. Pure pipeline behavior (debounce, distinct, switchMap,
7
+ * loading state) is covered by search-pipeline.test.ts and not duplicated
8
+ * here.
9
+ *
10
+ * Mocks HeadlessUI to dodge the jsdom OOM that prevents the older
11
+ * SearchModal.* test files from running.
12
+ */
13
+
14
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
15
+ import React from 'react';
16
+ import { screen, fireEvent, waitFor, act } from '@testing-library/react';
17
+ import { BehaviorSubject } from 'rxjs';
18
+ import { renderWithProviders } from '../../../test-utils';
19
+ import '@testing-library/jest-dom';
20
+ import { SearchModal } from '../SearchModal';
21
+
22
+ // Mock HeadlessUI to avoid jsdom OOM issues
23
+ vi.mock('@headlessui/react', () => ({
24
+ Dialog: ({ children, ...props }: any) => (
25
+ <div role="dialog" {...props}>
26
+ {typeof children === 'function' ? children({ open: true }) : children}
27
+ </div>
28
+ ),
29
+ DialogPanel: ({ children, ...props }: any) => <div {...props}>{children}</div>,
30
+ DialogTitle: ({ children, ...props }: any) => <h2 {...props}>{children}</h2>,
31
+ Transition: ({ show, children }: any) => (show ? <>{children}</> : null),
32
+ TransitionChild: ({ children }: any) => <>{children}</>,
33
+ }));
34
+
35
+ // Mock the api-client Observable surface
36
+ const browseResourcesSubject = new BehaviorSubject<any[] | undefined>(undefined);
37
+ const browseResourcesMock = vi.fn(() => browseResourcesSubject.asObservable());
38
+
39
+ // Stable client reference — useApiClient is called on every render, so a
40
+ // fresh object literal would invalidate useMemo deps and restart the RxJS
41
+ // pipeline on every keystroke. The real ApiClientProvider holds a single
42
+ // instance; the mock must do the same.
43
+ const stableMockClient = { browse: { resources: browseResourcesMock } };
44
+
45
+ vi.mock('../../../contexts/ApiClientContext', async () => {
46
+ const actual = await vi.importActual<typeof import('../../../contexts/ApiClientContext')>(
47
+ '../../../contexts/ApiClientContext'
48
+ );
49
+ return {
50
+ ...actual,
51
+ useApiClient: () => stableMockClient,
52
+ };
53
+ });
54
+
55
+ // Mock search announcements
56
+ vi.mock('../../../hooks/useSearchAnnouncements', () => ({
57
+ useSearchAnnouncements: vi.fn(() => ({
58
+ announceSearchResults: vi.fn(),
59
+ announceSearching: vi.fn(),
60
+ announceNavigation: vi.fn(),
61
+ })),
62
+ }));
63
+
64
+ function setBrowseResults(resources: any[] | undefined) {
65
+ act(() => {
66
+ browseResourcesSubject.next(resources);
67
+ });
68
+ }
69
+
70
+ const buildResource = (id: string, name: string, content?: string) => ({
71
+ '@context': 'https://www.w3.org/ns/anno.jsonld',
72
+ '@id': id,
73
+ name,
74
+ content,
75
+ representations: [{ mediaType: 'text/plain', isPrimary: true }],
76
+ });
77
+
78
+ describe('SearchModal — search wiring', () => {
79
+ const defaultProps = {
80
+ isOpen: true,
81
+ onClose: vi.fn(),
82
+ onNavigate: vi.fn(),
83
+ };
84
+
85
+ beforeEach(() => {
86
+ vi.clearAllMocks();
87
+ browseResourcesSubject.next(undefined);
88
+ });
89
+
90
+ it('renders the search input when open', () => {
91
+ renderWithProviders(<SearchModal {...defaultProps} />);
92
+ expect(screen.getByPlaceholderText('Search resources, entities...')).toBeInTheDocument();
93
+ });
94
+
95
+ it('does not render when closed', () => {
96
+ renderWithProviders(<SearchModal {...defaultProps} isOpen={false} />);
97
+ expect(screen.queryByPlaceholderText('Search resources, entities...')).not.toBeInTheDocument();
98
+ });
99
+
100
+ it('shows the start-typing hint when query is empty', () => {
101
+ renderWithProviders(<SearchModal {...defaultProps} />);
102
+ expect(screen.getByText('Start typing to search...')).toBeInTheDocument();
103
+ });
104
+
105
+ it('wires the modal to browse.resources with the correct shape', async () => {
106
+ // One integration check that the modal calls browse.resources with the
107
+ // limit and search shape it advertises. Pipeline mechanics (debounce,
108
+ // empty-query gating) live in search-pipeline.test.ts.
109
+ renderWithProviders(<SearchModal {...defaultProps} />);
110
+ const input = screen.getByPlaceholderText('Search resources, entities...');
111
+
112
+ fireEvent.change(input, { target: { value: 'marathon' } });
113
+
114
+ await waitFor(
115
+ () => expect(browseResourcesMock).toHaveBeenCalledWith({ search: 'marathon', limit: 5 }),
116
+ { timeout: 1500 }
117
+ );
118
+ });
119
+
120
+ it('renders results when the Observable emits a non-empty list', async () => {
121
+ renderWithProviders(<SearchModal {...defaultProps} />);
122
+ const input = screen.getByPlaceholderText('Search resources, entities...');
123
+ fireEvent.change(input, { target: { value: 'test' } });
124
+
125
+ await waitFor(() => expect(browseResourcesMock).toHaveBeenCalled(), { timeout: 1500 });
126
+ setBrowseResults([
127
+ buildResource('res-1', 'Test Document', 'Some content here'),
128
+ buildResource('res-2', 'Another Result', 'More content'),
129
+ ]);
130
+
131
+ await waitFor(() => {
132
+ expect(screen.getByText('Test Document')).toBeInTheDocument();
133
+ expect(screen.getByText('Another Result')).toBeInTheDocument();
134
+ }, { timeout: 1500 });
135
+ });
136
+
137
+ it('shows the no-results message after an empty emission', async () => {
138
+ renderWithProviders(<SearchModal {...defaultProps} />);
139
+ const input = screen.getByPlaceholderText('Search resources, entities...');
140
+ fireEvent.change(input, { target: { value: 'xyz' } });
141
+
142
+ await waitFor(() => expect(browseResourcesMock).toHaveBeenCalled(), { timeout: 1500 });
143
+ setBrowseResults([]);
144
+
145
+ await waitFor(() => {
146
+ expect(screen.getByText(/No results found for/)).toBeInTheDocument();
147
+ expect(screen.getByText(/"xyz"/)).toBeInTheDocument();
148
+ }, { timeout: 1500 });
149
+ });
150
+
151
+ it('shows the searching state while the Observable has not emitted', async () => {
152
+ renderWithProviders(<SearchModal {...defaultProps} />);
153
+ const input = screen.getByPlaceholderText('Search resources, entities...');
154
+ fireEvent.change(input, { target: { value: 'foo' } });
155
+
156
+ // After debounce fires, the inner observable starts with { results: [], loading: true }.
157
+ await waitFor(() => expect(screen.getByText('Searching...')).toBeInTheDocument(), {
158
+ timeout: 1500,
159
+ });
160
+ });
161
+
162
+ it('navigates to a result and closes when clicked', async () => {
163
+ const onClose = vi.fn();
164
+ const onNavigate = vi.fn();
165
+ renderWithProviders(
166
+ <SearchModal {...defaultProps} onClose={onClose} onNavigate={onNavigate} />
167
+ );
168
+
169
+ const input = screen.getByPlaceholderText('Search resources, entities...');
170
+ fireEvent.change(input, { target: { value: 'doc' } });
171
+
172
+ await waitFor(() => expect(browseResourcesMock).toHaveBeenCalled(), { timeout: 1500 });
173
+ setBrowseResults([buildResource('res-1', 'Pickable', 'preview')]);
174
+
175
+ await waitFor(() => expect(screen.getByText('Pickable')).toBeInTheDocument(), {
176
+ timeout: 1500,
177
+ });
178
+
179
+ fireEvent.click(screen.getByText('Pickable'));
180
+ expect(onNavigate).toHaveBeenCalledWith('resource', 'res-1');
181
+ expect(onClose).toHaveBeenCalled();
182
+ });
183
+
184
+ it('moves selection with arrow keys and selects with Enter', async () => {
185
+ const onNavigate = vi.fn();
186
+ renderWithProviders(<SearchModal {...defaultProps} onNavigate={onNavigate} />);
187
+
188
+ const input = screen.getByPlaceholderText('Search resources, entities...');
189
+ fireEvent.change(input, { target: { value: 'doc' } });
190
+
191
+ await waitFor(() => expect(browseResourcesMock).toHaveBeenCalled(), { timeout: 1500 });
192
+ setBrowseResults([
193
+ buildResource('res-1', 'First'),
194
+ buildResource('res-2', 'Second'),
195
+ buildResource('res-3', 'Third'),
196
+ ]);
197
+
198
+ await waitFor(() => expect(screen.getByText('Third')).toBeInTheDocument(), { timeout: 1500 });
199
+
200
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
201
+ fireEvent.keyDown(input, { key: 'ArrowDown' });
202
+ fireEvent.keyDown(input, { key: 'Enter' });
203
+
204
+ expect(onNavigate).toHaveBeenCalledWith('resource', 'res-3');
205
+ });
206
+
207
+ it('skips resources without an @id', async () => {
208
+ renderWithProviders(<SearchModal {...defaultProps} />);
209
+ const input = screen.getByPlaceholderText('Search resources, entities...');
210
+ fireEvent.change(input, { target: { value: 'q' } });
211
+
212
+ await waitFor(() => expect(browseResourcesMock).toHaveBeenCalled(), { timeout: 1500 });
213
+ setBrowseResults([
214
+ { '@context': 'https://www.w3.org/ns/anno.jsonld', name: 'No ID', representations: [] },
215
+ buildResource('res-keep', 'Has ID'),
216
+ ]);
217
+
218
+ await waitFor(() => expect(screen.getByText('Has ID')).toBeInTheDocument(), { timeout: 1500 });
219
+ expect(screen.queryByText('No ID')).not.toBeInTheDocument();
220
+ });
221
+ });
@@ -90,9 +90,6 @@ export function formatEventType(type: PersistedEventType, t: TranslateFn, payloa
90
90
  case 'yield:representation-removed':
91
91
  return t('representationEvent');
92
92
 
93
- case 'embedding:computed':
94
- return t('embeddingComputed');
95
-
96
93
  default:
97
94
  return type;
98
95
  }
@@ -141,9 +138,6 @@ export function getEventEmoji(type: PersistedEventType, payload?: any): string {
141
138
  case 'yield:representation-removed':
142
139
  return '📄';
143
140
 
144
- case 'embedding:computed':
145
- return '🧮';
146
-
147
141
  default:
148
142
  return '📝';
149
143
  }
@@ -79,7 +79,7 @@ export function ReferenceEntry({
79
79
  semiont.bind.body(
80
80
  resourceId(source),
81
81
  annotationId(reference.id),
82
- [{ op: 'remove', item: { type: 'SpecificResource', source: resolvedResourceUri } }],
82
+ [{ op: 'remove', item: { type: 'SpecificResource', source: resolvedResourceUri, purpose: 'linking' } }],
83
83
  ).catch(() => { /* error handled by events-stream */ });
84
84
  }
85
85
  };
@@ -320,7 +320,7 @@ describe('ReferenceEntry', () => {
320
320
  expect(bindSpy).toHaveBeenCalledWith(
321
321
  'resource-1',
322
322
  'ref-1',
323
- { operations: [{ op: 'remove', item: { type: 'SpecificResource', source: 'linked-doc' } }] },
323
+ { operations: [{ op: 'remove', item: { type: 'SpecificResource', source: 'linked-doc', purpose: 'linking' } }] },
324
324
  expect.anything(),
325
325
  );
326
326
 
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import { describe, it, expect, vi, beforeEach } from 'vitest';
9
- import { render, screen, fireEvent, waitFor } from '@testing-library/react';
9
+ 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 { EventBusProvider } from '../../../contexts/EventBusContext';
@@ -29,6 +29,8 @@ const createMockProps = (overrides?: Partial<ResourceDiscoveryPageProps>): Resou
29
29
  entityTypes: [],
30
30
  isLoadingRecent: false,
31
31
  isSearching: false,
32
+ searchQuery: '',
33
+ onSearchQueryChange: vi.fn(),
32
34
  theme: 'light',
33
35
  showLineNumbers: false,
34
36
  activePanel: null,
@@ -88,13 +90,6 @@ describe('ResourceDiscoveryPage', () => {
88
90
  expect(screen.getByPlaceholderText('Search resources...')).toBeInTheDocument();
89
91
  });
90
92
 
91
- it('renders search button', () => {
92
- const props = createMockProps();
93
- renderWithProviders(<ResourceDiscoveryPage {...props} />);
94
-
95
- expect(screen.getByRole('button', { name: 'Search' })).toBeInTheDocument();
96
- });
97
-
98
93
  it('renders toolbar component', () => {
99
94
  const props = createMockProps();
100
95
  const { container } = renderWithProviders(<ResourceDiscoveryPage {...props} />);
@@ -172,69 +167,75 @@ describe('ResourceDiscoveryPage', () => {
172
167
  });
173
168
 
174
169
  describe('Search Functionality', () => {
175
- it('allows typing in search input', () => {
176
- const props = createMockProps();
170
+ it('reflects controlled searchQuery prop in the input', () => {
171
+ const props = createMockProps({ searchQuery: 'hello' });
177
172
  renderWithProviders(<ResourceDiscoveryPage {...props} />);
178
173
 
179
174
  const input = screen.getByPlaceholderText('Search resources...') as HTMLInputElement;
180
- fireEvent.change(input, { target: { value: 'test query' } });
181
-
182
- expect(input.value).toBe('test query');
175
+ expect(input.value).toBe('hello');
183
176
  });
184
177
 
185
- it('shows "Searching..." when isSearching is true', () => {
186
- const props = createMockProps({ isSearching: true });
178
+ it('calls onSearchQueryChange on every keystroke', () => {
179
+ const onSearchQueryChange = vi.fn();
180
+ const props = createMockProps({ onSearchQueryChange });
187
181
  renderWithProviders(<ResourceDiscoveryPage {...props} />);
188
182
 
189
- expect(screen.getByRole('button', { name: 'Searching...' })).toBeInTheDocument();
183
+ const input = screen.getByPlaceholderText('Search resources...');
184
+ fireEvent.change(input, { target: { value: 'a' } });
185
+ fireEvent.change(input, { target: { value: 'ab' } });
186
+ fireEvent.change(input, { target: { value: 'abc' } });
187
+
188
+ expect(onSearchQueryChange).toHaveBeenCalledTimes(3);
189
+ expect(onSearchQueryChange).toHaveBeenNthCalledWith(1, 'a');
190
+ expect(onSearchQueryChange).toHaveBeenNthCalledWith(2, 'ab');
191
+ expect(onSearchQueryChange).toHaveBeenNthCalledWith(3, 'abc');
190
192
  });
191
193
 
192
- it('disables search input when isSearching is true', () => {
193
- const props = createMockProps({ isSearching: true });
194
+ it('shows the searching indicator when isSearching is true', () => {
195
+ const props = createMockProps({ isSearching: true, searchQuery: 'foo' });
194
196
  renderWithProviders(<ResourceDiscoveryPage {...props} />);
195
197
 
196
- const input = screen.getByPlaceholderText('Search resources...') as HTMLInputElement;
197
- expect(input).toBeDisabled();
198
+ expect(screen.getByText('Searching...')).toBeInTheDocument();
198
199
  });
199
200
 
200
- it('disables search button when isSearching is true', () => {
201
- const props = createMockProps({ isSearching: true });
201
+ it('renders searchDocuments when searchQuery is non-empty', () => {
202
+ const props = createMockProps({
203
+ searchQuery: 'res',
204
+ searchDocuments: [
205
+ createMockResource('1', 'Search Result 1'),
206
+ createMockResource('2', 'Search Result 2'),
207
+ ],
208
+ recentDocuments: [createMockResource('99', 'Recent Doc')],
209
+ });
202
210
  renderWithProviders(<ResourceDiscoveryPage {...props} />);
203
211
 
204
- const button = screen.getByRole('button', { name: 'Searching...' });
205
- expect(button).toBeDisabled();
212
+ expect(screen.getByText('Search Result 1')).toBeInTheDocument();
213
+ expect(screen.getByText('Search Result 2')).toBeInTheDocument();
214
+ expect(screen.queryByText('Recent Doc')).not.toBeInTheDocument();
215
+ expect(screen.getByText('2 results found')).toBeInTheDocument();
206
216
  });
207
217
 
208
- it('displays search results with count', () => {
209
- const searchDocuments = [
210
- createMockResource('1', 'Result 1'),
211
- createMockResource('2', 'Result 2'),
212
- ];
213
-
214
- const props = createMockProps({ searchDocuments });
218
+ it('shows no-results warning when searchQuery is non-empty, results empty, and not searching', () => {
219
+ const props = createMockProps({
220
+ searchQuery: 'nonexistent',
221
+ searchDocuments: [],
222
+ isSearching: false,
223
+ recentDocuments: [createMockResource('1', 'Recent Doc')],
224
+ });
215
225
  renderWithProviders(<ResourceDiscoveryPage {...props} />);
216
226
 
217
- // Type in search input to trigger search state
218
- const input = screen.getByPlaceholderText('Search resources...');
219
- fireEvent.change(input, { target: { value: 'test' } });
220
-
221
- expect(screen.getByText('Result 1')).toBeInTheDocument();
222
- expect(screen.getByText('Result 2')).toBeInTheDocument();
227
+ expect(screen.getByText('No results found for "nonexistent"')).toBeInTheDocument();
223
228
  });
224
229
 
225
- it('shows no results warning when search returns nothing', async () => {
230
+ it('does not show no-results warning while still searching', () => {
226
231
  const props = createMockProps({
232
+ searchQuery: 'foo',
227
233
  searchDocuments: [],
228
- recentDocuments: [createMockResource('1', 'Recent Doc')],
234
+ isSearching: true,
229
235
  });
230
236
  renderWithProviders(<ResourceDiscoveryPage {...props} />);
231
237
 
232
- const input = screen.getByPlaceholderText('Search resources...');
233
- fireEvent.change(input, { target: { value: 'nonexistent' } });
234
-
235
- await waitFor(() => {
236
- expect(screen.getByText('No results found for "nonexistent"')).toBeInTheDocument();
237
- });
238
+ expect(screen.queryByText(/No results found/)).not.toBeInTheDocument();
238
239
  });
239
240
  });
240
241
 
@@ -23,6 +23,10 @@ export interface ResourceDiscoveryPageProps {
23
23
  isLoadingRecent: boolean;
24
24
  isSearching: boolean;
25
25
 
26
+ // Controlled search state
27
+ searchQuery: string;
28
+ onSearchQueryChange: (query: string) => void;
29
+
26
30
  // UI state props
27
31
  theme: 'light' | 'dark';
28
32
  showLineNumbers: boolean;
@@ -62,6 +66,8 @@ export function ResourceDiscoveryPage({
62
66
  entityTypes,
63
67
  isLoadingRecent,
64
68
  isSearching,
69
+ searchQuery,
70
+ onSearchQueryChange,
65
71
  theme,
66
72
  showLineNumbers,
67
73
  activePanel,
@@ -70,15 +76,12 @@ export function ResourceDiscoveryPage({
70
76
  translations: t,
71
77
  ToolbarPanels,
72
78
  }: ResourceDiscoveryPageProps) {
73
- // Search and filter state
74
- const [searchQuery, setSearchQuery] = useState('');
75
79
  const [selectedEntityType, setSelectedEntityType] = useState<string>('');
76
80
 
77
81
  const hasSearchQuery = searchQuery.trim() !== '';
78
- const hasSearchResults = searchDocuments.length > 0;
79
82
 
80
- // Filtered documents
81
- const baseDocuments = hasSearchResults ? searchDocuments : recentDocuments;
83
+ // When searching, render search results; otherwise render recent.
84
+ const baseDocuments = hasSearchQuery ? searchDocuments : recentDocuments;
82
85
  const filteredResources = !selectedEntityType
83
86
  ? baseDocuments
84
87
  : baseDocuments.filter((resource: ResourceDescriptor) =>
@@ -113,11 +116,6 @@ export function ResourceDiscoveryPage({
113
116
  }
114
117
  }, []);
115
118
 
116
- const handleSearchSubmit = useCallback((e: React.FormEvent) => {
117
- e.preventDefault();
118
- // Search is handled by debounced effect
119
- }, []);
120
-
121
119
  // Loading state
122
120
  if (isLoadingRecent) {
123
121
  return (
@@ -127,7 +125,7 @@ export function ResourceDiscoveryPage({
127
125
  );
128
126
  }
129
127
 
130
- const showNoResultsWarning = hasSearchQuery && !hasSearchResults && !isSearching;
128
+ const showNoResultsWarning = hasSearchQuery && searchDocuments.length === 0 && !isSearching;
131
129
 
132
130
  return (
133
131
  <div className={`semiont-page${activePanel && COMMON_PANELS.includes(activePanel as ToolbarPanelType) ? ' semiont-page--panel-open' : ''}`}>
@@ -144,25 +142,23 @@ export function ResourceDiscoveryPage({
144
142
  {/* Search and Filter Section */}
145
143
  <div className="semiont-card">
146
144
  {/* Search Bar */}
147
- <form onSubmit={handleSearchSubmit} className="semiont-card__search-form">
145
+ <div className="semiont-card__search-form">
148
146
  <div className="semiont-card__search-wrapper">
149
147
  <input
150
148
  type="text"
151
149
  value={searchQuery}
152
- onChange={(e) => setSearchQuery(e.target.value)}
150
+ onChange={(e) => onSearchQueryChange(e.target.value)}
153
151
  placeholder={t.searchPlaceholder}
154
152
  className="semiont-card__search-input"
155
- disabled={isSearching}
153
+ aria-label={t.searchPlaceholder}
156
154
  />
157
- <button
158
- type="submit"
159
- disabled={isSearching}
160
- className="semiont-card__search-button"
161
- >
162
- {isSearching ? t.searching : t.searchButton}
163
- </button>
155
+ {isSearching && (
156
+ <span className="semiont-card__search-status" aria-live="polite">
157
+ {t.searching}
158
+ </span>
159
+ )}
164
160
  </div>
165
- </form>
161
+ </div>
166
162
 
167
163
  {/* Entity Type Filters */}
168
164
  {entityTypes.length > 0 && (
@@ -205,13 +201,11 @@ export function ResourceDiscoveryPage({
205
201
  {/* Documents Grid */}
206
202
  <div className="semiont-card__documents">
207
203
  <h3 className="semiont-card__documents-label">
208
- {showNoResultsWarning
209
- ? t.recentResources
210
- : hasSearchResults
211
- ? t.searchResults(searchDocuments.length)
212
- : selectedEntityType
213
- ? t.documentsTaggedWith(selectedEntityType)
214
- : t.recentResources
204
+ {hasSearchQuery && searchDocuments.length > 0
205
+ ? t.searchResults(searchDocuments.length)
206
+ : selectedEntityType
207
+ ? t.documentsTaggedWith(selectedEntityType)
208
+ : t.recentResources
215
209
  }
216
210
  </h3>
217
211