@semiont/react-ui 0.4.16 → 0.4.18

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semiont/react-ui",
3
- "version": "0.4.16",
3
+ "version": "0.4.18",
4
4
  "description": "React components and hooks for Semiont",
5
5
  "main": "./dist/index.mjs",
6
6
  "types": "./dist/index.d.mts",
@@ -1,9 +1,38 @@
1
1
  'use client';
2
2
 
3
- import React, { useState, useEffect } from 'react';
3
+ import { useState, useEffect } from 'react';
4
4
  import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
5
- import { useResources } from '../../lib/api-hooks';
5
+ import { map } from 'rxjs/operators';
6
+ import type { components } from '@semiont/core';
7
+ import { getResourceId, getPrimaryRepresentation } from '@semiont/api-client';
8
+ import { useApiClient } from '../../contexts/ApiClientContext';
9
+ import { useObservable } from '../../hooks/useObservable';
6
10
  import { useSearchAnnouncements } from '../../hooks/useSearchAnnouncements';
11
+ import { createSearchPipeline } from '../../lib/search-pipeline';
12
+
13
+ type ResourceDescriptor = components['schemas']['ResourceDescriptor'];
14
+
15
+ type SearchResult = {
16
+ id: string;
17
+ name: string;
18
+ content?: string;
19
+ mediaType?: string;
20
+ };
21
+
22
+ const SEARCH_DEBOUNCE_MS = 300;
23
+ const SEARCH_LIMIT = 50;
24
+
25
+ function toSearchResult(resource: ResourceDescriptor & { content?: string }): SearchResult | null {
26
+ const id = getResourceId(resource);
27
+ if (!id) return null;
28
+ const primary = getPrimaryRepresentation(resource);
29
+ return {
30
+ id,
31
+ name: resource.name,
32
+ content: resource.content,
33
+ mediaType: primary?.mediaType,
34
+ };
35
+ }
7
36
 
8
37
  interface ResourceSearchModalProps {
9
38
  isOpen: boolean;
@@ -27,8 +56,7 @@ export function ResourceSearchModal({
27
56
  translations = {}
28
57
  }: ResourceSearchModalProps) {
29
58
  const { announceSearchResults, announceSearching, announceNavigation } = useSearchAnnouncements();
30
- const [search, setSearch] = useState(searchTerm);
31
- const [debouncedSearch, setDebouncedSearch] = useState(searchTerm);
59
+ const semiont = useApiClient();
32
60
 
33
61
  const t = {
34
62
  title: translations.title || 'Search Resources',
@@ -38,63 +66,47 @@ export function ResourceSearchModal({
38
66
  close: translations.close || '✕',
39
67
  };
40
68
 
41
- // Debounce search
42
- useEffect(() => {
43
- const timer = setTimeout(() => {
44
- setDebouncedSearch(search);
45
- }, 300);
46
- return () => clearTimeout(timer);
47
- }, [search]);
48
-
49
- // Use React Query for search
50
- const resources = useResources();
51
- const { data: searchData, isFetching: loading } = resources.search.useQuery(
52
- debouncedSearch,
53
- 50 // Limit to 50 results
69
+ // ── Search pipeline ─────────────────────────────────────────────────────
70
+ const [pipeline] = useState(() =>
71
+ createSearchPipeline<SearchResult>(
72
+ (q) =>
73
+ semiont.browse.resources({ search: q, limit: SEARCH_LIMIT }).pipe(
74
+ map((resources) => {
75
+ if (resources === undefined) return undefined;
76
+ return resources
77
+ .map(toSearchResult)
78
+ .filter((r): r is SearchResult => r !== null);
79
+ }),
80
+ ),
81
+ { debounceMs: SEARCH_DEBOUNCE_MS, initialQuery: searchTerm },
82
+ ),
54
83
  );
84
+ useEffect(() => () => pipeline.dispose(), [pipeline]);
55
85
 
56
- // Extract results from search data
57
- const results = searchData?.resources?.map((resource: any) => {
58
- // Get mediaType from primary representation
59
- const reps = resource.representations;
60
- const mediaType = Array.isArray(reps) && reps.length > 0 && reps[0]
61
- ? reps[0].mediaType
62
- : undefined;
63
-
64
- return {
65
- id: resource['@id'],
66
- name: resource.name,
67
- content: resource.content,
68
- mediaType
69
- };
70
- }) || [];
71
-
72
- // Announce search results
73
- useEffect(() => {
74
- if (!loading && debouncedSearch) {
75
- announceSearchResults(results.length, debouncedSearch);
76
- }
77
- }, [loading, results.length, debouncedSearch]);
86
+ const search = useObservable(pipeline.query$) ?? '';
87
+ const searchState = useObservable(pipeline.state$);
88
+ const results = searchState?.results ?? [];
89
+ const loading = searchState?.isSearching ?? false;
78
90
 
79
- // Announce when searching
91
+ // Re-seed when modal re-opens with a different searchTerm prop. The
92
+ // initialQuery option only applies to the first construction; subsequent
93
+ // prop changes need an explicit setQuery.
80
94
  useEffect(() => {
81
- if (loading && debouncedSearch) {
82
- announceSearching();
95
+ if (isOpen && searchTerm) {
96
+ pipeline.setQuery(searchTerm);
83
97
  }
84
- }, [loading, debouncedSearch]);
98
+ }, [isOpen, searchTerm, pipeline]);
85
99
 
86
- // Update search term when modal opens
100
+ // Accessibility announcements for search lifecycle.
87
101
  useEffect(() => {
88
- if (isOpen && searchTerm) {
89
- setSearch(searchTerm);
90
- setDebouncedSearch(searchTerm);
102
+ if (!search.trim()) return;
103
+ if (loading) {
104
+ announceSearching();
105
+ } else {
106
+ announceSearchResults(results.length, search);
91
107
  }
92
- }, [isOpen, searchTerm]);
93
-
94
- const handleSearch = (e: React.FormEvent) => {
95
- e.preventDefault();
96
- // Search is handled by React Query hook
97
- };
108
+ // eslint-disable-next-line react-hooks/exhaustive-deps
109
+ }, [loading, results.length]);
98
110
 
99
111
  const handleSelect = (resourceId: string, resourceName: string) => {
100
112
  announceNavigation(resourceName, 'resource');
@@ -142,16 +154,16 @@ export function ResourceSearchModal({
142
154
  </button>
143
155
  </div>
144
156
 
145
- <form onSubmit={handleSearch} className="semiont-search-modal__search-form">
157
+ <div className="semiont-search-modal__search-form">
146
158
  <input
147
159
  type="text"
148
160
  value={search}
149
- onChange={(e) => setSearch(e.target.value)}
161
+ onChange={(e) => pipeline.setQuery(e.target.value)}
150
162
  placeholder={t.placeholder}
151
163
  className="semiont-search-modal__search-input"
152
164
  autoFocus
153
165
  />
154
- </form>
166
+ </div>
155
167
 
156
168
  <div className="semiont-search-modal__results">
157
169
  {loading && (
@@ -168,7 +180,7 @@ export function ResourceSearchModal({
168
180
 
169
181
  {!loading && results.length > 0 && (
170
182
  <div className="semiont-search-modal__resource-list">
171
- {results.map((resource: any) => {
183
+ {results.map((resource) => {
172
184
  const isImage = resource.mediaType?.startsWith('image/');
173
185
 
174
186
  return (
@@ -2,11 +2,17 @@
2
2
 
3
3
  import React, { useState, useEffect } from 'react';
4
4
  import { Dialog, DialogPanel, Transition, TransitionChild } from '@headlessui/react';
5
- // import { useResources } from '../../hooks/useResources';
6
- import { useSearchAnnouncements } from '../../hooks/useSearchAnnouncements';
5
+ import { map } from 'rxjs/operators';
7
6
  import { getResourceId } from '@semiont/api-client';
7
+ import { useSearchAnnouncements } from '../../hooks/useSearchAnnouncements';
8
+ import { useApiClient } from '../../contexts/ApiClientContext';
9
+ import { useObservable } from '../../hooks/useObservable';
10
+ import { createSearchPipeline } from '../../lib/search-pipeline';
8
11
  import './SearchModal.css';
9
12
 
13
+ const SEARCH_DEBOUNCE_MS = 300;
14
+ const SEARCH_LIMIT = 5;
15
+
10
16
  interface SearchModalProps {
11
17
  isOpen: boolean;
12
18
  onClose: () => void;
@@ -39,9 +45,7 @@ export function SearchModal({
39
45
  translations = {}
40
46
  }: SearchModalProps) {
41
47
  const { announceSearchResults, announceSearching } = useSearchAnnouncements();
42
- const [query, setQuery] = useState('');
43
- const [debouncedQuery, setDebouncedQuery] = useState('');
44
- const [results, setResults] = useState<SearchResult[]>([]);
48
+ const semiont = useApiClient();
45
49
  const [selectedIndex, setSelectedIndex] = useState(0);
46
50
 
47
51
  const t = {
@@ -56,63 +60,62 @@ export function SearchModal({
56
60
  esc: translations.esc || 'ESC',
57
61
  };
58
62
 
59
- // Debounce query
60
- useEffect(() => {
61
- const timer = setTimeout(() => {
62
- setDebouncedQuery(query);
63
- }, 300);
64
- return () => clearTimeout(timer);
65
- }, [query]);
66
-
67
- // Use React Query for search
68
- // const resources = useResources();
69
- // const { data: searchData, isFetching: loading } = resources.search.useQuery(
70
- // debouncedQuery,
71
- // 5
72
- // );
63
+ // ── Search pipeline ─────────────────────────────────────────────────────
64
+ // The fetch closure maps ResourceDescriptor → SearchResult inside the
65
+ // map operator. The helper handles debounce, switchMap, and loading state.
66
+ const [pipeline] = useState(() =>
67
+ createSearchPipeline<SearchResult>(
68
+ (q) =>
69
+ semiont.browse.resources({ search: q, limit: SEARCH_LIMIT }).pipe(
70
+ map((resources) => {
71
+ if (resources === undefined) return undefined;
72
+ return resources
73
+ .map((resource): SearchResult | null => {
74
+ const id = getResourceId(resource);
75
+ if (!id) return null;
76
+ return {
77
+ type: 'resource',
78
+ id,
79
+ name: resource.name,
80
+ content: (resource as { content?: string }).content?.substring(0, 150),
81
+ };
82
+ })
83
+ .filter((r): r is SearchResult => r !== null);
84
+ }),
85
+ ),
86
+ { debounceMs: SEARCH_DEBOUNCE_MS },
87
+ ),
88
+ );
89
+ useEffect(() => () => pipeline.dispose(), [pipeline]);
73
90
 
74
- // TODO: This should come from props or context
75
- const searchData = { resources: [], entities: [] };
76
- const loading = false;
91
+ const query = useObservable(pipeline.query$) ?? '';
92
+ const searchState = useObservable(pipeline.state$);
93
+ const results = searchState?.results ?? [];
94
+ const loading = searchState?.isSearching ?? false;
77
95
 
78
- // Reset state when modal opens/closes
96
+ // Reset query and selection when modal opens.
79
97
  useEffect(() => {
80
98
  if (isOpen) {
81
- setQuery('');
82
- setDebouncedQuery('');
83
- setResults([]);
99
+ pipeline.setQuery('');
84
100
  setSelectedIndex(0);
85
101
  }
86
- }, [isOpen]);
102
+ }, [isOpen, pipeline]);
87
103
 
88
- // Update results when search data changes
104
+ // Reset selection cursor when results change.
89
105
  useEffect(() => {
90
- if (!debouncedQuery.trim()) {
91
- setResults([]);
92
- return;
93
- }
106
+ setSelectedIndex(0);
107
+ }, [results.length]);
94
108
 
109
+ // Accessibility announcements for search lifecycle.
110
+ useEffect(() => {
111
+ if (!query.trim()) return;
95
112
  if (loading) {
96
113
  announceSearching();
97
- } else if (searchData) {
98
- const resourceResults: SearchResult[] = (searchData.resources || [])
99
- .filter((resource: any) => getResourceId(resource) !== undefined)
100
- .map((resource: any) => ({
101
- type: 'resource' as const,
102
- id: getResourceId(resource)!,
103
- name: resource.name,
104
- content: resource.content?.substring(0, 150)
105
- }));
106
-
107
- // TODO: Add entities search when API is ready
108
- const entityResults: SearchResult[] = [];
109
-
110
- const allResults = [...resourceResults, ...entityResults];
111
- setResults(allResults);
112
- setSelectedIndex(0);
113
- announceSearchResults(allResults.length, debouncedQuery);
114
+ } else {
115
+ announceSearchResults(results.length, query);
114
116
  }
115
- }, [searchData, loading, debouncedQuery]);
117
+ // eslint-disable-next-line react-hooks/exhaustive-deps
118
+ }, [loading, results.length]);
116
119
 
117
120
  // Handle keyboard navigation
118
121
  const handleKeyDown = (e: React.KeyboardEvent) => {
@@ -170,7 +173,7 @@ export function SearchModal({
170
173
  <input
171
174
  type="text"
172
175
  value={query}
173
- onChange={(e) => setQuery(e.target.value)}
176
+ onChange={(e) => pipeline.setQuery(e.target.value)}
174
177
  onKeyDown={handleKeyDown}
175
178
  placeholder={t.placeholder}
176
179
  className="semiont-search-modal__input"
@@ -1,6 +1,7 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import React from 'react';
3
- import { screen, fireEvent } from '@testing-library/react';
3
+ import { screen, fireEvent, waitFor } from '@testing-library/react';
4
+ import { BehaviorSubject } from 'rxjs';
4
5
  import { renderWithProviders } from '../../../test-utils';
5
6
  import '@testing-library/jest-dom';
6
7
  import { ResourceSearchModal } from '../ResourceSearchModal';
@@ -14,19 +15,24 @@ vi.mock('@headlessui/react', () => ({
14
15
  TransitionChild: ({ children }: any) => <>{children}</>,
15
16
  }));
16
17
 
17
- // Mock api-hooks
18
- const mockUseQuery = vi.fn(() => ({
19
- data: null,
20
- isFetching: false,
21
- }));
22
-
23
- vi.mock('../../../lib/api-hooks', () => ({
24
- useResources: vi.fn(() => ({
25
- search: {
26
- useQuery: mockUseQuery,
27
- },
28
- })),
29
- }));
18
+ // Mock the api-client Observable surface.
19
+ // Note: useApiClient is called on every render. The real ApiClientProvider
20
+ // holds a single instance — the mock must do the same, otherwise useMemo deps
21
+ // invalidate on every render and RxJS pipelines restart from their initial
22
+ // value on each keystroke.
23
+ const browseResourcesSubject = new BehaviorSubject<any[] | undefined>(undefined);
24
+ const browseResourcesMock = vi.fn(() => browseResourcesSubject.asObservable());
25
+ const stableMockClient = { browse: { resources: browseResourcesMock } };
26
+
27
+ vi.mock('../../../contexts/ApiClientContext', async () => {
28
+ const actual = await vi.importActual<typeof import('../../../contexts/ApiClientContext')>(
29
+ '../../../contexts/ApiClientContext'
30
+ );
31
+ return {
32
+ ...actual,
33
+ useApiClient: () => stableMockClient,
34
+ };
35
+ });
30
36
 
31
37
  // Mock search announcements
32
38
  vi.mock('../../../hooks/useSearchAnnouncements', () => ({
@@ -37,6 +43,10 @@ vi.mock('../../../hooks/useSearchAnnouncements', () => ({
37
43
  })),
38
44
  }));
39
45
 
46
+ function setBrowseResults(resources: any[] | undefined) {
47
+ browseResourcesSubject.next(resources);
48
+ }
49
+
40
50
  describe('ResourceSearchModal', () => {
41
51
  const defaultProps = {
42
52
  isOpen: true,
@@ -46,7 +56,15 @@ describe('ResourceSearchModal', () => {
46
56
 
47
57
  beforeEach(() => {
48
58
  vi.clearAllMocks();
49
- mockUseQuery.mockReturnValue({ data: null, isFetching: false });
59
+ browseResourcesSubject.next(undefined);
60
+ });
61
+
62
+ const buildResource = (id: string, name: string, mediaType = 'text/plain', content?: string) => ({
63
+ '@context': 'https://www.w3.org/ns/anno.jsonld',
64
+ '@id': id,
65
+ name,
66
+ content,
67
+ representations: [{ mediaType, isPrimary: true }],
50
68
  });
51
69
 
52
70
  it('renders modal with title when open', () => {
@@ -77,63 +95,27 @@ describe('ResourceSearchModal', () => {
77
95
  expect(screen.getByPlaceholderText('Type here...')).toBeInTheDocument();
78
96
  });
79
97
 
80
- it('shows loading state when fetching', () => {
81
- mockUseQuery.mockReturnValue({ data: null, isFetching: true });
82
- renderWithProviders(<ResourceSearchModal {...defaultProps} />);
83
- expect(screen.getByText('Searching...')).toBeInTheDocument();
98
+ it('shows loading state while the Observable has not emitted', async () => {
99
+ renderWithProviders(<ResourceSearchModal {...defaultProps} searchTerm="something" />);
100
+ await waitFor(() => expect(screen.getByText('Searching...')).toBeInTheDocument(), { timeout: 1000 });
84
101
  });
85
102
 
86
- it('shows no results message when search has no matches', () => {
87
- mockUseQuery.mockReturnValue({
88
- data: { resources: [] },
89
- isFetching: false,
90
- });
91
-
92
- renderWithProviders(
93
- <ResourceSearchModal {...defaultProps} searchTerm="xyz" />
94
- );
95
- expect(screen.getByText('No documents found')).toBeInTheDocument();
103
+ it('shows no results message when search returns an empty array', async () => {
104
+ renderWithProviders(<ResourceSearchModal {...defaultProps} searchTerm="xyz" />);
105
+ setBrowseResults([]);
106
+ await waitFor(() => expect(screen.getByText('No documents found')).toBeInTheDocument(), { timeout: 1000 });
96
107
  });
97
108
 
98
- it('renders search results', () => {
99
- mockUseQuery.mockReturnValue({
100
- data: {
101
- resources: [
102
- {
103
- '@id': 'res-1',
104
- name: 'Test Document',
105
- content: 'Some content here',
106
- representations: [{ mediaType: 'text/plain' }],
107
- },
108
- ],
109
- },
110
- isFetching: false,
111
- });
112
-
113
- renderWithProviders(
114
- <ResourceSearchModal {...defaultProps} searchTerm="test" />
115
- );
116
- expect(screen.getByText('Test Document')).toBeInTheDocument();
109
+ it('renders search results', async () => {
110
+ renderWithProviders(<ResourceSearchModal {...defaultProps} searchTerm="test" />);
111
+ setBrowseResults([buildResource('res-1', 'Test Document', 'text/plain', 'Some content here')]);
112
+ await waitFor(() => expect(screen.getByText('Test Document')).toBeInTheDocument(), { timeout: 1000 });
117
113
  });
118
114
 
119
- it('calls onSelect and onClose when a result is clicked', () => {
115
+ it('calls onSelect and onClose when a result is clicked', async () => {
120
116
  const onSelect = vi.fn();
121
117
  const onClose = vi.fn();
122
118
 
123
- mockUseQuery.mockReturnValue({
124
- data: {
125
- resources: [
126
- {
127
- '@id': 'res-1',
128
- name: 'Test Document',
129
- content: 'Some content',
130
- representations: [{ mediaType: 'text/plain' }],
131
- },
132
- ],
133
- },
134
- isFetching: false,
135
- });
136
-
137
119
  renderWithProviders(
138
120
  <ResourceSearchModal
139
121
  {...defaultProps}
@@ -142,31 +124,18 @@ describe('ResourceSearchModal', () => {
142
124
  onClose={onClose}
143
125
  />
144
126
  );
127
+ setBrowseResults([buildResource('res-1', 'Test Document', 'text/plain', 'Some content')]);
128
+ await waitFor(() => expect(screen.getByText('Test Document')).toBeInTheDocument(), { timeout: 1000 });
145
129
 
146
130
  fireEvent.click(screen.getByText('Test Document'));
147
131
  expect(onSelect).toHaveBeenCalledWith('res-1');
148
132
  expect(onClose).toHaveBeenCalled();
149
133
  });
150
134
 
151
- it('shows media type for image results', () => {
152
- mockUseQuery.mockReturnValue({
153
- data: {
154
- resources: [
155
- {
156
- '@id': 'res-img',
157
- name: 'Photo',
158
- content: 'image data',
159
- representations: [{ mediaType: 'image/png' }],
160
- },
161
- ],
162
- },
163
- isFetching: false,
164
- });
165
-
166
- renderWithProviders(
167
- <ResourceSearchModal {...defaultProps} searchTerm="photo" />
168
- );
169
- expect(screen.getByText('image/png')).toBeInTheDocument();
135
+ it('shows media type for image results', async () => {
136
+ renderWithProviders(<ResourceSearchModal {...defaultProps} searchTerm="photo" />);
137
+ setBrowseResults([buildResource('res-img', 'Photo', 'image/png')]);
138
+ await waitFor(() => expect(screen.getByText('image/png')).toBeInTheDocument(), { timeout: 1000 });
170
139
  });
171
140
 
172
141
  it('calls onClose when close button is clicked', () => {
@@ -177,4 +146,12 @@ describe('ResourceSearchModal', () => {
177
146
  fireEvent.click(screen.getByLabelText('✕'));
178
147
  expect(onClose).toHaveBeenCalled();
179
148
  });
149
+
150
+ it('passes the search term through to browse.resources', async () => {
151
+ renderWithProviders(<ResourceSearchModal {...defaultProps} searchTerm="hello" />);
152
+ await waitFor(
153
+ () => expect(browseResourcesMock).toHaveBeenCalledWith({ search: 'hello', limit: 50 }),
154
+ { timeout: 1000 }
155
+ );
156
+ });
180
157
  });