@sourceloop/search-client 1.1.2

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,313 @@
1
+ import {
2
+ Component,
3
+ ElementRef,
4
+ EventEmitter,
5
+ Inject,
6
+ Input,
7
+ OnDestroy,
8
+ OnInit,
9
+ Output,
10
+ PLATFORM_ID,
11
+ ViewChild,
12
+ } from '@angular/core';
13
+ import {Configuration} from '../lib-configuration';
14
+ import {Subject} from 'rxjs';
15
+ import {debounceTime, tap} from 'rxjs/operators';
16
+ import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '@angular/forms';
17
+ import {
18
+ ISearchService,
19
+ IModel,
20
+ ISearchQuery,
21
+ SEARCH_SERVICE_TOKEN,
22
+ DEBOUNCE_TIME,
23
+ DEFAULT_LIMIT,
24
+ DEFAULT_LIMIT_TYPE,
25
+ DEFAULT_OFFSET,
26
+ DEFAULT_SAVE_IN_RECENTS,
27
+ DEFAULT_ORDER,
28
+ IReturnType,
29
+ RecentSearchEvent,
30
+ TypeEvent,
31
+ ItemClickedEvent,
32
+ } from '../types';
33
+ import {isPlatformBrowser} from '@angular/common';
34
+
35
+ @Component({
36
+ selector: 'sourceloop-search',
37
+ templateUrl: './search.component.html',
38
+ styleUrls: ['./search.component.scss'],
39
+ providers: [
40
+ {
41
+ provide: NG_VALUE_ACCESSOR,
42
+ useExisting: SearchComponent,
43
+ multi: true,
44
+ },
45
+ ],
46
+ })
47
+ export class SearchComponent<T extends IReturnType>
48
+ implements OnInit, OnDestroy, ControlValueAccessor
49
+ {
50
+ searchBoxInput = '';
51
+ suggestionsDisplay = false;
52
+ categoryDisplay = false;
53
+ suggestions: T[] = [];
54
+ relevantSuggestions: T[] = [];
55
+ recentSearches: ISearchQuery[] = [];
56
+ category: IModel | 'All' = 'All';
57
+ observableForSearchRequest = new Subject<{input: string; event: Event}>();
58
+ @Input() config!: Configuration<T>;
59
+ // emitted when user clicks one of the suggested results (including recent search sugestions)
60
+ @Output() clicked = new EventEmitter<ItemClickedEvent<T>>();
61
+ @Output() searched = new EventEmitter<RecentSearchEvent>();
62
+ /* emitted when user makes search request (including recent search requests & requests made on change in category from dropdown)
63
+ In case of recent search Array of recent Search request result is emitted */
64
+
65
+ onChange!: (value: string | undefined) => void;
66
+ onTouched!: () => void;
67
+ disabled = false;
68
+
69
+ @ViewChild('searchInput') public searchInputElement!: ElementRef;
70
+
71
+ constructor(
72
+ @Inject(SEARCH_SERVICE_TOKEN)
73
+ private readonly searchService: ISearchService<T>,
74
+ // tslint:disable-next-line:ban-types
75
+ @Inject(PLATFORM_ID) private readonly platformId: Object,
76
+ ) {}
77
+
78
+ ngOnInit(): void {
79
+ this.observableForSearchRequest
80
+ .pipe(
81
+ tap(v => (this.suggestions = [])),
82
+ debounceTime(DEBOUNCE_TIME),
83
+ )
84
+ .subscribe((value: TypeEvent) => {
85
+ this.searched.emit({
86
+ event: value.event,
87
+ keyword: value.input,
88
+ category: this.category,
89
+ });
90
+ this.getSuggestions(value);
91
+ });
92
+ }
93
+
94
+ // ControlValueAccessor Implementation
95
+ writeValue(value: string): void {
96
+ this.searchBoxInput = value;
97
+ }
98
+ // When the value in the UI is changed, this method will invoke a callback function
99
+ registerOnChange(fn: (value: string | undefined) => void): void {
100
+ this.onChange = fn;
101
+ }
102
+ registerOnTouched(fn: () => void): void {
103
+ this.onTouched = fn;
104
+ }
105
+ setDisabledState?(isDisabled: boolean): void {
106
+ this.disabled = isDisabled;
107
+ }
108
+
109
+ getSuggestions(eventValue: TypeEvent) {
110
+ const order = this.config.order ?? DEFAULT_ORDER;
111
+ let orderString = '';
112
+ order.forEach(preference => (orderString = `${orderString}${preference} `));
113
+
114
+ let saveInRecents = this.config.saveInRecents ?? DEFAULT_SAVE_IN_RECENTS;
115
+ if (this.config.saveInRecents && this.config.saveInRecentsOnlyOnEnter) {
116
+ if (
117
+ (eventValue.event instanceof KeyboardEvent &&
118
+ eventValue.event.key === 'Enter') ||
119
+ (eventValue.event instanceof Event &&
120
+ eventValue.event.type === 'change')
121
+ ) {
122
+ saveInRecents = true; // save in recents only on enter or change in category
123
+ } else {
124
+ // do not save in recent search on typing
125
+ saveInRecents = false;
126
+ }
127
+ }
128
+ /* need to put default value here and not in contructor
129
+ because sonar was giving code smell with definite assertion as all these parameters are optional */
130
+ const requestParameters: ISearchQuery = {
131
+ match: eventValue.input,
132
+ sources: this._categoryToSourceName(this.category),
133
+ limit: this.config.limit ?? DEFAULT_LIMIT,
134
+ limitByType: this.config.limitByType ?? DEFAULT_LIMIT_TYPE,
135
+ order: orderString,
136
+ offset: this.config.offset ?? DEFAULT_OFFSET,
137
+ };
138
+
139
+ this.searchService
140
+ .searchApiRequest(requestParameters, saveInRecents)
141
+ .subscribe(
142
+ (value: T[]) => {
143
+ this.suggestions = value;
144
+ },
145
+ (_error: Error) => {
146
+ this.suggestions = [];
147
+ },
148
+ );
149
+ }
150
+ getRecentSearches() {
151
+ if (
152
+ !this.config.hideRecentSearch &&
153
+ this.searchService.recentSearchApiRequest
154
+ ) {
155
+ this.searchService.recentSearchApiRequest().subscribe(
156
+ (value: ISearchQuery[]) => {
157
+ this.recentSearches = value;
158
+ },
159
+ (_error: Error) => {
160
+ this.recentSearches = [];
161
+ },
162
+ );
163
+ }
164
+ }
165
+
166
+ // event can be KeyBoardEvent or Event of type 'change' fired on change in value of drop down for category
167
+ hitSearchApi(event: Event) {
168
+ // this will happen only in case user searches something and then erases it, we need to update recent search
169
+ if (!this.searchBoxInput) {
170
+ this.suggestions = [];
171
+ this.getRecentSearches();
172
+ return;
173
+ }
174
+
175
+ // no debounce time needed in case of searchOnlyOnEnter
176
+ if (this.config.searchOnlyOnEnter) {
177
+ if (
178
+ (event instanceof KeyboardEvent && event.key === 'Enter') ||
179
+ (event instanceof Event && event.type === 'change')
180
+ ) {
181
+ this.getSuggestions({input: this.searchBoxInput, event});
182
+ }
183
+ return;
184
+ }
185
+
186
+ // no debounce time needed in case of change in category
187
+ if (event instanceof KeyboardEvent === false && event.type === 'change') {
188
+ this.getSuggestions({input: this.searchBoxInput, event});
189
+ return;
190
+ }
191
+
192
+ this.observableForSearchRequest.next({
193
+ input: this.searchBoxInput,
194
+ event,
195
+ });
196
+ }
197
+
198
+ populateValue(suggestion: T, event: MouseEvent) {
199
+ const value = suggestion[
200
+ this.config.displayPropertyName
201
+ ] as unknown as string; // converted to string to assign value to searchBoxInput
202
+ this.searchBoxInput = value;
203
+ this.suggestionsDisplay = false;
204
+ // ngModelChange doesn't detect change in value when populated from outside, hence calling manually
205
+ this.onChange(this.searchBoxInput);
206
+ // need to do this to show more search options for selected suggestion - just in case user reopens search input
207
+ this.getSuggestions({input: this.searchBoxInput, event});
208
+ this.clicked.emit({item: suggestion, event});
209
+ }
210
+ populateValueRecentSearch(recentSearch: ISearchQuery, event: MouseEvent) {
211
+ event.stopPropagation();
212
+ event.preventDefault();
213
+ const value = recentSearch['match'];
214
+ this.searchBoxInput = value;
215
+ this.suggestionsDisplay = false;
216
+ this.onChange(this.searchBoxInput);
217
+ // need to do this to show more search options for selected suggestion - just in case user reopens search input
218
+ this.getSuggestions({input: this.searchBoxInput, event});
219
+ this.focusInput();
220
+ this.showSuggestions();
221
+ }
222
+
223
+ fetchModelImageUrlFromSuggestion(suggestion: T) {
224
+ const modelName = suggestion[
225
+ 'source' as unknown as keyof T
226
+ ] as unknown as string;
227
+ let url: string | undefined;
228
+ this.config.models.forEach((model, i) => {
229
+ if (model.name === modelName && model.imageUrl) {
230
+ url = model.imageUrl;
231
+ }
232
+ });
233
+ return url;
234
+ }
235
+
236
+ // also returns true if there are any suggestions related to the model
237
+ getSuggestionsFromModelName(modelName: string) {
238
+ this.relevantSuggestions = [];
239
+ this.suggestions.forEach(suggestion => {
240
+ const sourceModelName = suggestion[
241
+ 'source' as keyof T
242
+ ] as unknown as string;
243
+ if (sourceModelName === modelName) {
244
+ this.relevantSuggestions.push(suggestion);
245
+ }
246
+ });
247
+ if (this.relevantSuggestions.length) {
248
+ return true;
249
+ } else {
250
+ return false;
251
+ }
252
+ }
253
+
254
+ boldString(str: T[keyof T] | string, substr: string) {
255
+ const strRegExp = new RegExp(`(${substr})`, 'gi');
256
+ const stringToMakeBold: string = str as unknown as string;
257
+ return stringToMakeBold.replace(strRegExp, `<b>$1</b>`);
258
+ }
259
+
260
+ hideSuggestions() {
261
+ this.suggestionsDisplay = false;
262
+ this.onTouched();
263
+ }
264
+
265
+ showSuggestions() {
266
+ this.suggestionsDisplay = true;
267
+ this.getRecentSearches();
268
+ }
269
+
270
+ focusInput() {
271
+ if (isPlatformBrowser(this.platformId)) {
272
+ this.searchInputElement.nativeElement.focus();
273
+ }
274
+ }
275
+
276
+ setCategory(category: 'All' | IModel, event: MouseEvent) {
277
+ this.category = category;
278
+ this.categoryDisplay = false;
279
+ if (this.searchBoxInput) {
280
+ this.hitSearchApi(event);
281
+ this.focusInput();
282
+ this.showSuggestions();
283
+ }
284
+ }
285
+
286
+ showCategory() {
287
+ this.categoryDisplay = !this.categoryDisplay;
288
+ }
289
+
290
+ hideCategory() {
291
+ this.categoryDisplay = false;
292
+ }
293
+
294
+ resetInput() {
295
+ this.searchBoxInput = '';
296
+ this.suggestionsDisplay = true;
297
+ this.focusInput();
298
+ // ngModelChange doesn't detect change in value when populated from outside, hence calling manually
299
+ this.onChange(this.searchBoxInput);
300
+ this.getRecentSearches();
301
+ }
302
+ ngOnDestroy() {
303
+ this.observableForSearchRequest.unsubscribe();
304
+ }
305
+
306
+ _categoryToSourceName(category: 'All' | IModel) {
307
+ if (category === 'All') {
308
+ return [];
309
+ } else {
310
+ return [category.name];
311
+ }
312
+ }
313
+ }
@@ -0,0 +1,12 @@
1
+ import {NgModule} from '@angular/core';
2
+ import {SearchComponent} from './search/search.component';
3
+ import {CommonModule} from '@angular/common';
4
+ import {FormsModule} from '@angular/forms';
5
+ import {HttpClientModule} from '@angular/common/http';
6
+
7
+ @NgModule({
8
+ declarations: [SearchComponent],
9
+ imports: [CommonModule, FormsModule, HttpClientModule],
10
+ exports: [SearchComponent],
11
+ })
12
+ export class SearchLibModule {}
@@ -0,0 +1,59 @@
1
+ import {InjectionToken} from '@angular/core';
2
+ import {Observable} from 'rxjs';
3
+
4
+ export interface ISearchQuery {
5
+ match: string;
6
+ limit: number | null;
7
+ order: string | null;
8
+ limitByType: boolean | null;
9
+ offset: number | null;
10
+ sources: string[] | null;
11
+ }
12
+ export interface IModel {
13
+ name: string;
14
+ displayName: string;
15
+ imageUrl?: string;
16
+ }
17
+ export interface IReturnType {
18
+ rank: number;
19
+ source: string;
20
+ }
21
+ export interface IDefaultReturnType extends IReturnType {
22
+ name: string;
23
+ description: string;
24
+ }
25
+
26
+ export interface ISearchService<T extends IReturnType> {
27
+ searchApiRequest(
28
+ requestParameters: ISearchQuery,
29
+ saveInRecents: boolean,
30
+ ): Observable<T[]>;
31
+ recentSearchApiRequest?(): Observable<ISearchQuery[]>;
32
+ }
33
+
34
+ // cant use T extends IReturnType here
35
+ export const SEARCH_SERVICE_TOKEN: InjectionToken<ISearchService<IReturnType>> =
36
+ new InjectionToken<ISearchService<IReturnType>>('Search_Service_Token');
37
+
38
+ export type RecentSearchEvent = {
39
+ event: KeyboardEvent | Event;
40
+ keyword: string;
41
+ category: 'All' | IModel;
42
+ };
43
+
44
+ export type ItemClickedEvent<T> = {
45
+ event: MouseEvent;
46
+ item: T;
47
+ };
48
+
49
+ export type TypeEvent = {
50
+ event: Event;
51
+ input: string;
52
+ };
53
+ // IRequestParameters default values
54
+ export const DEFAULT_LIMIT = 20;
55
+ export const DEFAULT_LIMIT_TYPE = false;
56
+ export const DEFAULT_ORDER = [];
57
+ export const DEBOUNCE_TIME = 1000;
58
+ export const DEFAULT_OFFSET = 0;
59
+ export const DEFAULT_SAVE_IN_RECENTS = true;
@@ -0,0 +1,8 @@
1
+ /*
2
+ * Public API Surface of my-lib
3
+ */
4
+
5
+ export * from './lib/search-lib.module';
6
+ export * from './lib/search/search.component';
7
+ export * from './lib/lib-configuration';
8
+ export * from './lib/types';
@@ -0,0 +1,34 @@
1
+ // This file is required by karma.conf.js and loads recursively all the .spec and framework files
2
+ import 'zone.js/dist/zone';
3
+ import 'zone.js/dist/long-stack-trace-zone';
4
+ import 'zone.js/dist/proxy';
5
+ import 'zone.js/dist/sync-test';
6
+ import 'zone.js/dist/jasmine-patch';
7
+ import 'zone.js/dist/async-test';
8
+ import 'zone.js/dist/fake-async-test';
9
+ import {getTestBed} from '@angular/core/testing';
10
+ import {
11
+ BrowserDynamicTestingModule,
12
+ platformBrowserDynamicTesting,
13
+ } from '@angular/platform-browser-dynamic/testing'; // NOSONAR
14
+
15
+ declare const require: {
16
+ context(
17
+ path: string,
18
+ deep?: boolean,
19
+ filter?: RegExp,
20
+ ): {
21
+ keys(): string[];
22
+ <T>(id: string): T;
23
+ };
24
+ };
25
+
26
+ // First, initialize the Angular testing environment.
27
+ getTestBed().initTestEnvironment(
28
+ BrowserDynamicTestingModule,
29
+ platformBrowserDynamicTesting(),
30
+ );
31
+ // Then we find all the tests.
32
+ const context = require.context('./', true, /\.spec\.ts$/);
33
+ // And load the modules.
34
+ context.keys().forEach(context);
@@ -0,0 +1,20 @@
1
+ /* To learn more about this file see: https://angular.io/config/tsconfig. */
2
+ {
3
+ "extends": "../../tsconfig.json",
4
+ "compilerOptions": {
5
+ "outDir": "../../out-tsc/lib",
6
+ "target": "es2015",
7
+ "declaration": true,
8
+ "declarationMap": true,
9
+ "inlineSources": true,
10
+ "types": [],
11
+ "lib": [
12
+ "dom",
13
+ "es2018"
14
+ ]
15
+ },
16
+ "exclude": [
17
+ "src/test.ts",
18
+ "**/*.spec.ts"
19
+ ]
20
+ }
@@ -0,0 +1,11 @@
1
+ /* To learn more about this file see: https://angular.io/config/tsconfig. */
2
+ {
3
+ "extends": "./tsconfig.lib.json",
4
+ "compilerOptions": {
5
+ "declarationMap": false
6
+ },
7
+ "angularCompilerOptions": {
8
+ "compilationMode": "partial",
9
+ "enableIvy": false
10
+ }
11
+ }
@@ -0,0 +1,17 @@
1
+ /* To learn more about this file see: https://angular.io/config/tsconfig. */
2
+ {
3
+ "extends": "../../tsconfig.json",
4
+ "compilerOptions": {
5
+ "outDir": "../../out-tsc/spec",
6
+ "types": [
7
+ "jasmine"
8
+ ]
9
+ },
10
+ "files": [
11
+ "src/test.ts"
12
+ ],
13
+ "include": [
14
+ "**/*.spec.ts",
15
+ "**/*.d.ts"
16
+ ]
17
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,36 @@
1
+ /* To learn more about this file see: https://angular.io/config/tsconfig. */
2
+ {
3
+ "compileOnSave": false,
4
+ "compilerOptions": {
5
+ "baseUrl": "./",
6
+ "outDir": "./dist/out-tsc",
7
+ "forceConsistentCasingInFileNames": true,
8
+ "strict": true,
9
+ "noImplicitReturns": true,
10
+ "noFallthroughCasesInSwitch": true,
11
+ "sourceMap": true,
12
+ "declaration": false,
13
+ "downlevelIteration": true,
14
+ "paths": {
15
+ "search-lib": [
16
+ "dist/search-lib/search-lib",
17
+ "dist/search-lib"
18
+ ]
19
+ },
20
+ "experimentalDecorators": true,
21
+ "moduleResolution": "node",
22
+ "importHelpers": true,
23
+ "target": "es2017",
24
+ "module": "es2020",
25
+ "lib": [
26
+ "es2018",
27
+ "dom"
28
+ ]
29
+ },
30
+ "angularCompilerOptions": {
31
+ "enableI18nLegacyMessageIdFormat": false,
32
+ "strictInjectionParameters": true,
33
+ "strictInputAccessModifiers": true,
34
+ "strictTemplates": true
35
+ }
36
+ }
package/tslint.json ADDED
@@ -0,0 +1,163 @@
1
+ {
2
+ "extends": "tslint:recommended",
3
+ "rules": {
4
+ "align": {
5
+ "options": [
6
+ "parameters",
7
+ "statements"
8
+ ]
9
+ },
10
+ "array-type": false,
11
+ "arrow-parens": false,
12
+ "arrow-return-shorthand": true,
13
+ "deprecation": {
14
+ "severity": "warning"
15
+ },
16
+ "component-class-suffix": true,
17
+ "contextual-lifecycle": true,
18
+ "curly": true,
19
+ "directive-class-suffix": true,
20
+ "directive-selector": [
21
+ true,
22
+ "attribute",
23
+ "camelCase"
24
+ ],
25
+ "no-string-literal": false,
26
+ "component-selector": [
27
+ true,
28
+ "element",
29
+ "kebab-case"
30
+ ],
31
+ "eofline": true,
32
+ "import-blacklist": [
33
+ true,
34
+ "rxjs/Rx"
35
+ ],
36
+ "import-spacing": true,
37
+ "indent": {
38
+ "options": [
39
+ "spaces"
40
+ ]
41
+ },
42
+ "interface-name": false,
43
+ "max-classes-per-file": false,
44
+ "max-line-length": [
45
+ true,
46
+ 140
47
+ ],
48
+ "member-access": false,
49
+ "member-ordering": [
50
+ true,
51
+ {
52
+ "order": [
53
+ "static-field",
54
+ "instance-field",
55
+ "static-method",
56
+ "instance-method"
57
+ ]
58
+ }
59
+ ],
60
+ "no-consecutive-blank-lines": false,
61
+ "no-any": false,
62
+ "no-console": [
63
+ true,
64
+ "debug",
65
+ "info",
66
+ "time",
67
+ "timeEnd",
68
+ "trace"
69
+ ],
70
+ "no-empty": false,
71
+ "no-inferrable-types": [
72
+ true,
73
+ "ignore-params"
74
+ ],
75
+ "no-non-null-assertion": true,
76
+ "no-redundant-jsdoc": true,
77
+ "no-switch-case-fall-through": true,
78
+ "no-var-requires": false,
79
+ "object-literal-key-quotes": [
80
+ true,
81
+ "as-needed"
82
+ ],
83
+ "object-literal-sort-keys": false,
84
+ "ordered-imports": false,
85
+ "quotemark": [
86
+ true,
87
+ "single",
88
+ "avoid-escape"
89
+ ],
90
+ "trailing-comma": false,
91
+ "no-conflicting-lifecycle": true,
92
+ "no-host-metadata-property": true,
93
+ "no-input-rename": true,
94
+ "no-inputs-metadata-property": true,
95
+ "no-output-native": true,
96
+ "no-output-on-prefix": false,
97
+ "no-output-rename": true,
98
+ "semicolon": {
99
+ "options": [
100
+ "always"
101
+ ]
102
+ },
103
+ "space-before-function-paren": {
104
+ "options": {
105
+ "anonymous": "never",
106
+ "asyncArrow": "always",
107
+ "constructor": "never",
108
+ "method": "never",
109
+ "named": "never"
110
+ }
111
+ },
112
+ "no-outputs-metadata-property": true,
113
+ "template-banana-in-box": true,
114
+ "template-no-negated-async": true,
115
+ "typedef-whitespace": {
116
+ "options": [
117
+ {
118
+ "call-signature": "nospace",
119
+ "index-signature": "nospace",
120
+ "parameter": "nospace",
121
+ "property-declaration": "nospace",
122
+ "variable-declaration": "nospace"
123
+ },
124
+ {
125
+ "call-signature": "onespace",
126
+ "index-signature": "onespace",
127
+ "parameter": "onespace",
128
+ "property-declaration": "onespace",
129
+ "variable-declaration": "onespace"
130
+ }
131
+ ]
132
+ },
133
+ "use-lifecycle-interface": false,
134
+ "use-pipe-transform-interface": true,
135
+ "one-variable-per-declaration": false,
136
+ "variable-name": {
137
+ "options": [
138
+ "ban-keywords",
139
+ "check-format",
140
+ "allow-pascal-case",
141
+ "allow-leading-underscore"
142
+ ]
143
+ },
144
+ "whitespace": {
145
+ "options": [
146
+ "check-branch",
147
+ "check-decl",
148
+ "check-operator",
149
+ "check-separator",
150
+ "check-type",
151
+ "check-typecast"
152
+ ]
153
+ }
154
+ },
155
+ "rulesDirectory": [
156
+ "codelyzer"
157
+ ],
158
+ "linterOptions":{
159
+ "exclude":[
160
+ "**/theme/tools/**"
161
+ ]
162
+ }
163
+ }