@intellegens/cornerstone-client 0.0.9999-alpha-16 → 0.0.9999-alpha-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.
@@ -0,0 +1,32 @@
1
+ import { IIdentifiable } from '../../data';
2
+ export declare class SearchAdapter<TKey, TDto extends IIdentifiable<TKey>> {
3
+ private _readClient;
4
+ private _isLoading;
5
+ private _searchResults;
6
+ private _totalItemCount;
7
+ private _searchTerm;
8
+ private _selectedType?;
9
+ private _lastInputSearchTerm?;
10
+ private _lastInputType?;
11
+ private _searchTriggerLength;
12
+ private _searchTriggerDelay;
13
+ private _triggeredSearch;
14
+ private _selection;
15
+ private _multiSelectMode;
16
+ constructor(controllerName: string, multiSelect: boolean, searchTriggerLength: number, searchTriggerDelay: number, dataChangedCallback: (isLoading: boolean, data: TDto[] | undefined, error: string | undefined) => void);
17
+ getSelectedItems(): TDto[];
18
+ getSelectedType(): string | undefined;
19
+ getSelectMode(): boolean;
20
+ addToSelection(item: TDto): void;
21
+ removeFromSelection(item: TDto): void;
22
+ search(searchTerm: string, type?: string): Promise<TDto[]>;
23
+ clearSearch(): void;
24
+ private readonly _dataChangedCallback;
25
+ private _fetchCurrentPageDataTimeout?;
26
+ private _fetchCurrentPageDataPromises;
27
+ private _currentAbortController?;
28
+ private _fetchCurrentPageData;
29
+ private _fetchCurrentPageDataDebounced;
30
+ private _parseToSearchDefinition;
31
+ private _sortByName;
32
+ }
@@ -0,0 +1,234 @@
1
+ import { ReadSelectedComparisonOperator, ReadSelectedLogicalOperator, ReadSelectedOrderingDirection, ReadSelectedPropertyType, } from '../../data';
2
+ import { ApiReadControllerClient } from '../../services';
3
+ export class SearchAdapter {
4
+ _readClient;
5
+ _isLoading = false;
6
+ _searchResults = [];
7
+ _totalItemCount = 1000;
8
+ _searchTerm = '';
9
+ _selectedType;
10
+ _lastInputSearchTerm;
11
+ _lastInputType;
12
+ _searchTriggerLength;
13
+ _searchTriggerDelay;
14
+ _triggeredSearch = false;
15
+ _selection;
16
+ _multiSelectMode = true;
17
+ constructor(controllerName, multiSelect, searchTriggerLength, searchTriggerDelay, dataChangedCallback) {
18
+ this._readClient = new ApiReadControllerClient(controllerName);
19
+ this._searchTriggerLength = searchTriggerLength;
20
+ this._searchTriggerDelay = searchTriggerDelay;
21
+ this._multiSelectMode = multiSelect;
22
+ this._dataChangedCallback = dataChangedCallback;
23
+ this._fetchCurrentPageData();
24
+ }
25
+ //#region Public methods
26
+ getSelectedItems() {
27
+ return this._selection || [];
28
+ }
29
+ getSelectedType() {
30
+ return this._selectedType;
31
+ }
32
+ getSelectMode() {
33
+ return this._multiSelectMode;
34
+ }
35
+ addToSelection(item) {
36
+ if (!this._selection) {
37
+ this._selection = [];
38
+ }
39
+ if (this._multiSelectMode) {
40
+ if (!this._selection.find(s => s.id === item.id)) {
41
+ this._selection.push(item);
42
+ }
43
+ }
44
+ else {
45
+ // single-select mode - replace the item and return the old one to shown results
46
+ if (this._selection.length > 0) {
47
+ this._searchResults = [...this._searchResults, ...this._selection];
48
+ }
49
+ this._selection = [item];
50
+ }
51
+ this._searchResults = this._searchResults.filter(d => d.id !== item.id);
52
+ this._dataChangedCallback(this._isLoading, this._sortByName(this._searchResults), undefined);
53
+ }
54
+ removeFromSelection(item) {
55
+ if (!this._selection) {
56
+ this._selection = [];
57
+ return;
58
+ }
59
+ this._selection = this._selection.filter(s => s.id !== item.id);
60
+ this._searchResults = [...this._searchResults, item];
61
+ this._dataChangedCallback(this._isLoading, this._sortByName(this._searchResults), undefined);
62
+ }
63
+ search(searchTerm, type) {
64
+ if (this._lastInputSearchTerm === searchTerm && this._lastInputType === type) {
65
+ // no changes to search condition
66
+ return Promise.resolve(this._searchResults);
67
+ }
68
+ this._lastInputSearchTerm = searchTerm;
69
+ this._lastInputType = type;
70
+ if (type !== undefined && type !== null) {
71
+ // has type selected -> single-select mode
72
+ if (searchTerm.length >= this._searchTriggerLength) {
73
+ // include search by string
74
+ this._searchTerm = searchTerm;
75
+ this._selectedType = type;
76
+ this._triggeredSearch = true;
77
+ return this._fetchCurrentPageData();
78
+ }
79
+ else {
80
+ // search all by type
81
+ this._searchTerm = '';
82
+ this._selectedType = type;
83
+ this._triggeredSearch = true;
84
+ return this._fetchCurrentPageData();
85
+ }
86
+ }
87
+ else if (searchTerm.length >= this._searchTriggerLength) {
88
+ // no type involved -> search by input string only
89
+ this._searchTerm = searchTerm;
90
+ this._selectedType = type;
91
+ this._triggeredSearch = true;
92
+ return this._fetchCurrentPageData();
93
+ }
94
+ else if (this._triggeredSearch && searchTerm.length < this._searchTriggerLength) {
95
+ // user has deleted part of the search term after having seen some results - fetch everything to show
96
+ this._searchTerm = '';
97
+ this._selectedType = type;
98
+ return this._fetchCurrentPageData();
99
+ }
100
+ else {
101
+ // no type, searchTerm < this._searchTriggerLength
102
+ return Promise.resolve([]);
103
+ }
104
+ }
105
+ clearSearch() {
106
+ this._selection = [];
107
+ this._selectedType = undefined;
108
+ this._searchTerm = '';
109
+ this._dataChangedCallback(this._isLoading, [], undefined);
110
+ this._triggeredSearch = false;
111
+ }
112
+ //#endregion
113
+ //#region Private methods
114
+ _dataChangedCallback;
115
+ _fetchCurrentPageDataTimeout;
116
+ _fetchCurrentPageDataPromises = [];
117
+ _currentAbortController;
118
+ async _fetchCurrentPageData() {
119
+ return new Promise((resolve, reject) => {
120
+ this._fetchCurrentPageDataPromises.push({ resolve, reject });
121
+ if (this._fetchCurrentPageDataTimeout !== undefined) {
122
+ clearTimeout(this._fetchCurrentPageDataTimeout);
123
+ }
124
+ this._fetchCurrentPageDataTimeout = setTimeout(async () => {
125
+ try {
126
+ const result = await this._fetchCurrentPageDataDebounced();
127
+ const promises = this._fetchCurrentPageDataPromises.splice(0, this._fetchCurrentPageDataPromises.length);
128
+ for (const p of promises)
129
+ p.resolve(result);
130
+ }
131
+ catch (err) {
132
+ const promises = this._fetchCurrentPageDataPromises.splice(0, this._fetchCurrentPageDataPromises.length);
133
+ for (const p of promises)
134
+ p.reject(err);
135
+ }
136
+ }, this._searchTriggerDelay);
137
+ });
138
+ }
139
+ async _fetchCurrentPageDataDebounced() {
140
+ this._isLoading = true;
141
+ this._dataChangedCallback(this._isLoading, undefined, undefined);
142
+ let abortController;
143
+ let caughtError;
144
+ try {
145
+ // Cancel any in-flight request before starting a new one
146
+ if (this._currentAbortController) {
147
+ try {
148
+ this._currentAbortController.abort();
149
+ }
150
+ catch {
151
+ // Ignore abort errors as they are expected when debouncing
152
+ }
153
+ }
154
+ abortController = new AbortController();
155
+ this._currentAbortController = abortController;
156
+ const definition = this._parseToSearchDefinition();
157
+ const response = await this._readClient.readSelected(definition, abortController?.signal);
158
+ if (!response.ok) {
159
+ throw response.error;
160
+ }
161
+ // Filter out selected items from results
162
+ const selectedIds = new Set((this._selection || []).map(item => item.id));
163
+ this._searchResults = response.result.filter(item => !selectedIds.has(item.id));
164
+ return this._searchResults;
165
+ }
166
+ catch (error) {
167
+ caughtError = error;
168
+ // Do not log abort-related errors as they are expected when debouncing
169
+ if (error instanceof DOMException && error.name === 'AbortError') {
170
+ return Promise.reject(error);
171
+ }
172
+ console.error('Error fetching data:', error);
173
+ return Promise.reject(error);
174
+ }
175
+ finally {
176
+ this._isLoading = false;
177
+ let errorName;
178
+ if (caughtError && !(caughtError instanceof DOMException && caughtError.name === 'AbortError')) {
179
+ errorName = caughtError instanceof Error ? caughtError.name : undefined;
180
+ }
181
+ this._dataChangedCallback(this._isLoading, this._searchResults, errorName);
182
+ // Clear the abort controller only if it belongs to this request (avoid racing with a newer one)
183
+ if (abortController && this._currentAbortController === abortController) {
184
+ this._currentAbortController = undefined;
185
+ }
186
+ }
187
+ }
188
+ _parseToSearchDefinition() {
189
+ const definition = {
190
+ paginationDefinition: {
191
+ skip: 0,
192
+ limit: this._totalItemCount,
193
+ },
194
+ };
195
+ // Apply ordering
196
+ definition.orderingDefinition = {
197
+ order: [
198
+ {
199
+ propertyPath: ['Name'],
200
+ direction: ReadSelectedOrderingDirection.Ascending,
201
+ },
202
+ ],
203
+ };
204
+ // Apply search
205
+ const propertyCriteria = [
206
+ {
207
+ propertyName: 'Name',
208
+ comparisonOperator: ReadSelectedComparisonOperator.IContains,
209
+ valueType: ReadSelectedPropertyType.String,
210
+ value: this._searchTerm,
211
+ },
212
+ ];
213
+ if (this._selectedType !== undefined) {
214
+ propertyCriteria.push({
215
+ propertyName: 'Type',
216
+ comparisonOperator: ReadSelectedComparisonOperator.Equal,
217
+ valueType: ReadSelectedPropertyType.String,
218
+ value: this._selectedType,
219
+ });
220
+ }
221
+ definition.searchDefinition = {
222
+ logicalOperator: ReadSelectedLogicalOperator.And,
223
+ propertyCriteria: propertyCriteria,
224
+ };
225
+ return definition;
226
+ }
227
+ _sortByName(array) {
228
+ return array.sort((a, b) => {
229
+ const nameA = a.name?.toLowerCase() || '';
230
+ const nameB = b.name?.toLowerCase() || '';
231
+ return nameA.localeCompare(nameB);
232
+ });
233
+ }
234
+ }
@@ -1 +1,2 @@
1
1
  export * from './CollectionViewAdapter';
2
+ export * from './SearchAdapter';
@@ -1 +1,2 @@
1
1
  export * from './CollectionViewAdapter';
2
+ export * from './SearchAdapter';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@intellegens/cornerstone-client",
3
- "version": "0.0.9999-alpha-16",
3
+ "version": "0.0.9999-alpha-18",
4
4
  "private": false,
5
5
  "publishable": true,
6
6
  "main": "./dist/index.js",
@@ -15,6 +15,7 @@
15
15
  ],
16
16
  "devDependencies": {
17
17
  "@eslint/js": "^9.21.0",
18
+ "@jest/globals": "^30.2.0",
18
19
  "@types/express": "^5.0.0",
19
20
  "@types/jest": "^29.5.12",
20
21
  "@types/node": "^22.13.9",
@@ -25,6 +26,7 @@
25
26
  "jest": "^29.7.0",
26
27
  "jest-environment-jsdom": "^29.7.0",
27
28
  "prettier": "^3.5.2",
29
+ "rxjs": "~7.8.0",
28
30
  "ts-jest": "^29.1.2",
29
31
  "tsc-alias": "^1.8.11",
30
32
  "typescript-eslint": "^8.26.0",
@@ -0,0 +1,289 @@
1
+ import {
2
+ IIdentifiable,
3
+ ReadSelectedComparisonOperator,
4
+ ReadSelectedDefinitionDto,
5
+ ReadSelectedLogicalOperator,
6
+ ReadSelectedOrderingDirection,
7
+ ReadSelectedPropertyType,
8
+ ReadSelectedSearchDefinitionDto,
9
+ ReadSelectedSearchPropertyDefinitionDto,
10
+ } from '@data';
11
+ import { ApiReadControllerClient } from '@services';
12
+
13
+ export class SearchAdapter<TKey, TDto extends IIdentifiable<TKey>> {
14
+ private _readClient!: ApiReadControllerClient<TKey, TDto, TDto>;
15
+
16
+ private _isLoading = false;
17
+ private _searchResults: TDto[] = [];
18
+ private _totalItemCount: number = 1000;
19
+
20
+ private _searchTerm: string = '';
21
+ private _selectedType?: string;
22
+ private _lastInputSearchTerm?: string;
23
+ private _lastInputType?: string;
24
+
25
+ private _searchTriggerLength!: number;
26
+ private _searchTriggerDelay!: number;
27
+ private _triggeredSearch = false;
28
+
29
+ private _selection!: TDto[];
30
+ private _multiSelectMode = true;
31
+
32
+ constructor(
33
+ controllerName: string,
34
+ multiSelect: boolean,
35
+ searchTriggerLength: number,
36
+ searchTriggerDelay: number,
37
+ dataChangedCallback: (isLoading: boolean, data: TDto[] | undefined, error: string | undefined) => void,
38
+ ) {
39
+ this._readClient = new ApiReadControllerClient<TKey, TDto, TDto>(controllerName);
40
+
41
+ this._searchTriggerLength = searchTriggerLength;
42
+ this._searchTriggerDelay = searchTriggerDelay;
43
+ this._multiSelectMode = multiSelect;
44
+
45
+ this._dataChangedCallback = dataChangedCallback;
46
+ this._fetchCurrentPageData();
47
+ }
48
+
49
+ //#region Public methods
50
+
51
+ public getSelectedItems(): TDto[] {
52
+ return this._selection || [];
53
+ }
54
+
55
+ public getSelectedType(): string | undefined {
56
+ return this._selectedType;
57
+ }
58
+
59
+ public getSelectMode(): boolean {
60
+ return this._multiSelectMode;
61
+ }
62
+
63
+ public addToSelection(item: TDto): void {
64
+ if (!this._selection) {
65
+ this._selection = [];
66
+ }
67
+
68
+ if (this._multiSelectMode) {
69
+ if (!this._selection.find(s => s.id === item.id)) {
70
+ this._selection.push(item);
71
+ }
72
+ } else {
73
+ // single-select mode - replace the item and return the old one to shown results
74
+ if (this._selection.length > 0) {
75
+ this._searchResults = [...this._searchResults, ...this._selection];
76
+ }
77
+ this._selection = [item];
78
+ }
79
+ this._searchResults = this._searchResults.filter(d => d.id !== item.id);
80
+ this._dataChangedCallback(this._isLoading, this._sortByName(this._searchResults), undefined);
81
+ }
82
+
83
+ public removeFromSelection(item: TDto): void {
84
+ if (!this._selection) {
85
+ this._selection = [];
86
+ return;
87
+ }
88
+
89
+ this._selection = this._selection.filter(s => s.id !== item.id);
90
+ this._searchResults = [...this._searchResults, item];
91
+
92
+ this._dataChangedCallback(this._isLoading, this._sortByName(this._searchResults), undefined);
93
+ }
94
+
95
+ public search(searchTerm: string, type?: string) {
96
+ if (this._lastInputSearchTerm === searchTerm && this._lastInputType === type) {
97
+ // no changes to search condition
98
+ return Promise.resolve(this._searchResults);
99
+ }
100
+
101
+ this._lastInputSearchTerm = searchTerm;
102
+ this._lastInputType = type;
103
+
104
+ if (type !== undefined && type !== null) {
105
+ // has type selected -> single-select mode
106
+ if (searchTerm.length >= this._searchTriggerLength) {
107
+ // include search by string
108
+ this._searchTerm = searchTerm;
109
+ this._selectedType = type;
110
+ this._triggeredSearch = true;
111
+ return this._fetchCurrentPageData();
112
+ } else {
113
+ // search all by type
114
+ this._searchTerm = '';
115
+ this._selectedType = type;
116
+ this._triggeredSearch = true;
117
+ return this._fetchCurrentPageData();
118
+ }
119
+ } else if (searchTerm.length >= this._searchTriggerLength) {
120
+ // no type involved -> search by input string only
121
+ this._searchTerm = searchTerm;
122
+ this._selectedType = type;
123
+ this._triggeredSearch = true;
124
+ return this._fetchCurrentPageData();
125
+ } else if (this._triggeredSearch && searchTerm.length < this._searchTriggerLength) {
126
+ // user has deleted part of the search term after having seen some results - fetch everything to show
127
+ this._searchTerm = '';
128
+ this._selectedType = type;
129
+ return this._fetchCurrentPageData();
130
+ } else {
131
+ // no type, searchTerm < this._searchTriggerLength
132
+ return Promise.resolve([]);
133
+ }
134
+ }
135
+
136
+ public clearSearch(): void {
137
+ this._selection = [];
138
+ this._selectedType = undefined;
139
+ this._searchTerm = '';
140
+ this._dataChangedCallback(this._isLoading, [], undefined);
141
+ this._triggeredSearch = false;
142
+ }
143
+
144
+ //#endregion
145
+
146
+ //#region Private methods
147
+
148
+ private readonly _dataChangedCallback!: (isLoading: boolean, data: TDto[] | undefined, errorMessage: string | undefined) => void;
149
+
150
+ private _fetchCurrentPageDataTimeout?: any;
151
+
152
+ private _fetchCurrentPageDataPromises: {
153
+ resolve: (value: TDto[] | PromiseLike<TDto[]>) => void;
154
+ reject: (reason?: unknown) => void;
155
+ }[] = [];
156
+
157
+ private _currentAbortController?: AbortController;
158
+
159
+ private async _fetchCurrentPageData(): Promise<TDto[]> {
160
+ return new Promise((resolve, reject) => {
161
+ this._fetchCurrentPageDataPromises.push({ resolve, reject });
162
+ if (this._fetchCurrentPageDataTimeout !== undefined) {
163
+ clearTimeout(this._fetchCurrentPageDataTimeout);
164
+ }
165
+ this._fetchCurrentPageDataTimeout = setTimeout(async () => {
166
+ try {
167
+ const result = await this._fetchCurrentPageDataDebounced();
168
+ const promises = this._fetchCurrentPageDataPromises.splice(0, this._fetchCurrentPageDataPromises.length);
169
+ for (const p of promises) p.resolve(result);
170
+ } catch (err) {
171
+ const promises = this._fetchCurrentPageDataPromises.splice(0, this._fetchCurrentPageDataPromises.length);
172
+ for (const p of promises) p.reject(err);
173
+ }
174
+ }, this._searchTriggerDelay);
175
+ });
176
+ }
177
+
178
+ private async _fetchCurrentPageDataDebounced(): Promise<TDto[]> {
179
+ this._isLoading = true;
180
+ this._dataChangedCallback(this._isLoading, undefined, undefined);
181
+ let abortController: AbortController | undefined;
182
+ let caughtError: unknown;
183
+
184
+ try {
185
+ // Cancel any in-flight request before starting a new one
186
+ if (this._currentAbortController) {
187
+ try {
188
+ this._currentAbortController.abort();
189
+ } catch {
190
+ // Ignore abort errors as they are expected when debouncing
191
+ }
192
+ }
193
+ abortController = new AbortController();
194
+ this._currentAbortController = abortController;
195
+
196
+ const definition = this._parseToSearchDefinition();
197
+ const response = await this._readClient.readSelected(definition, abortController?.signal);
198
+
199
+ if (!response.ok) {
200
+ throw response.error;
201
+ }
202
+
203
+ // Filter out selected items from results
204
+ const selectedIds = new Set((this._selection || []).map(item => item.id));
205
+ this._searchResults = response.result.filter(item => !selectedIds.has(item.id));
206
+
207
+ return this._searchResults;
208
+ } catch (error: unknown) {
209
+ caughtError = error;
210
+
211
+ // Do not log abort-related errors as they are expected when debouncing
212
+ if (error instanceof DOMException && error.name === 'AbortError') {
213
+ return Promise.reject(error);
214
+ }
215
+
216
+ console.error('Error fetching data:', error);
217
+ return Promise.reject(error);
218
+ } finally {
219
+ this._isLoading = false;
220
+
221
+ let errorName: string | undefined;
222
+ if (caughtError && !(caughtError instanceof DOMException && caughtError.name === 'AbortError')) {
223
+ errorName = caughtError instanceof Error ? caughtError.name : undefined;
224
+ }
225
+ this._dataChangedCallback(this._isLoading, this._searchResults, errorName);
226
+
227
+ // Clear the abort controller only if it belongs to this request (avoid racing with a newer one)
228
+
229
+ if (abortController && this._currentAbortController === abortController) {
230
+ this._currentAbortController = undefined;
231
+ }
232
+ }
233
+ }
234
+
235
+ private _parseToSearchDefinition(): ReadSelectedDefinitionDto<TDto> {
236
+ const definition: ReadSelectedDefinitionDto<TDto> = {
237
+ paginationDefinition: {
238
+ skip: 0,
239
+ limit: this._totalItemCount,
240
+ },
241
+ };
242
+
243
+ // Apply ordering
244
+ definition.orderingDefinition = {
245
+ order: [
246
+ {
247
+ propertyPath: ['Name'],
248
+ direction: ReadSelectedOrderingDirection.Ascending,
249
+ },
250
+ ],
251
+ };
252
+
253
+ // Apply search
254
+ const propertyCriteria: ReadSelectedSearchPropertyDefinitionDto<TDto, keyof TDto>[] = [
255
+ {
256
+ propertyName: 'Name' as any,
257
+ comparisonOperator: ReadSelectedComparisonOperator.IContains,
258
+ valueType: ReadSelectedPropertyType.String,
259
+ value: this._searchTerm,
260
+ },
261
+ ];
262
+
263
+ if (this._selectedType !== undefined) {
264
+ propertyCriteria.push({
265
+ propertyName: 'Type',
266
+ comparisonOperator: ReadSelectedComparisonOperator.Equal,
267
+ valueType: ReadSelectedPropertyType.String,
268
+ value: this._selectedType,
269
+ } as ReadSelectedSearchPropertyDefinitionDto<TDto, keyof TDto>);
270
+ }
271
+
272
+ definition.searchDefinition = {
273
+ logicalOperator: ReadSelectedLogicalOperator.And,
274
+ propertyCriteria: propertyCriteria,
275
+ } as ReadSelectedSearchDefinitionDto<TDto>;
276
+
277
+ return definition;
278
+ }
279
+
280
+ private _sortByName(array: TDto[]): TDto[] {
281
+ return array.sort((a, b) => {
282
+ const nameA = (a as any).name?.toLowerCase() || '';
283
+ const nameB = (b as any).name?.toLowerCase() || '';
284
+ return nameA.localeCompare(nameB);
285
+ });
286
+ }
287
+
288
+ //#endregion
289
+ }
@@ -1 +1,2 @@
1
1
  export * from './CollectionViewAdapter';
2
+ export * from './SearchAdapter';
@@ -87,7 +87,7 @@ describe('CollectionViewAdapter', () => {
87
87
  };
88
88
 
89
89
  const callback = jest.fn();
90
- const adapter = new CollectionViewAdapter<number, TestDto>('ControllerName', callback, options);
90
+ const adapter = new CollectionViewAdapter<number, TestDto, TestDto>('ControllerName', callback, options);
91
91
 
92
92
  return { adapter, callback };
93
93
  };