@smallpearl/ngx-helper 0.32.9 → 0.32.11

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.
@@ -1,21 +1,24 @@
1
1
  import { coerceBooleanProperty } from '@angular/cdk/coercion';
2
2
  import * as i1 from '@angular/common';
3
3
  import { CommonModule, NgTemplateOutlet } from '@angular/common';
4
- import { HttpContextToken, HttpClient, HttpParams, HttpContext } from '@angular/common/http';
4
+ import { HttpContextToken, HttpContext } from '@angular/common/http';
5
5
  import * as i0 from '@angular/core';
6
- import { input, EventEmitter, computed, inject, ChangeDetectorRef, ElementRef, Component, ChangeDetectionStrategy, ViewChild, Input, Output, HostBinding } from '@angular/core';
6
+ import { input, EventEmitter, computed, viewChild, inject, ChangeDetectorRef, ElementRef, Component, ChangeDetectionStrategy, Output, HostBinding, Input } from '@angular/core';
7
7
  import * as i2 from '@angular/forms';
8
8
  import { NgControl, Validators, FormsModule, ReactiveFormsModule } from '@angular/forms';
9
+ import { MatButtonModule } from '@angular/material/button';
9
10
  import { MAT_FORM_FIELD, MatFormFieldControl } from '@angular/material/form-field';
11
+ import { MatIconModule } from '@angular/material/icon';
10
12
  import * as i3 from '@angular/material/select';
11
13
  import { MatSelect, MatSelectModule } from '@angular/material/select';
12
14
  import * as i5 from '@jsverse/transloco';
13
15
  import { TranslocoService, provideTranslocoScope, TranslocoModule } from '@jsverse/transloco';
14
- import { camelCase } from 'lodash';
16
+ import { selectAllEntities, upsertEntities, hasEntity, getEntity } from '@ngneat/elf-entities';
17
+ import { SPPagedEntityLoader } from '@smallpearl/ngx-helper/entities';
18
+ import { MatSelectInfiniteScrollDirective } from '@smallpearl/ngx-helper/mat-select-infinite-scroll';
15
19
  import * as i6 from 'ngx-mat-select-search';
16
20
  import { NgxMatSelectSearchModule } from 'ngx-mat-select-search';
17
- import { plural } from 'pluralize';
18
- import { Subject, BehaviorSubject, combineLatest, debounceTime, takeUntil, switchMap, of, tap } from 'rxjs';
21
+ import { Subject, startWith, distinctUntilChanged, debounceTime, tap, combineLatest, takeUntil } from 'rxjs';
19
22
  import * as i4 from '@angular/material/core';
20
23
 
21
24
  const SP_MAT_SELECT_ENTITY_HTTP_CONTEXT = new HttpContextToken(() => ({
@@ -23,6 +26,68 @@ const SP_MAT_SELECT_ENTITY_HTTP_CONTEXT = new HttpContextToken(() => ({
23
26
  entityNamePlural: '',
24
27
  endpoint: '',
25
28
  }));
29
+ // Default paginator implementation. This can handle dynamic-rest and DRF
30
+ // native pagination schemes. It also has a fallback to handle response conists
31
+ // of an array of entities.
32
+ class DefaultPaginator {
33
+ getRequestPageParams(endpoint, page, pageSize) {
34
+ return {
35
+ page: page + 1,
36
+ pageSize,
37
+ };
38
+ }
39
+ parseRequestResponse(entityName, entityNamePlural, endpoint, params, resp) {
40
+ if (Array.isArray(resp)) {
41
+ return {
42
+ total: resp.length,
43
+ entities: resp,
44
+ };
45
+ }
46
+ if (typeof resp === 'object') {
47
+ const keys = Object.keys(resp);
48
+ // Handle dynamic-rest sideloaded response
49
+ // Rudimentary sideloaded response support. This should work for most
50
+ // of the sideloaded responses where the main entities are stored
51
+ // under the plural entity name key and resp['meta'] object contains
52
+ // the total count.
53
+ if (keys.includes(entityNamePlural) &&
54
+ Array.isArray(resp[entityNamePlural])) {
55
+ let total = resp[entityNamePlural].length;
56
+ if (keys.includes('meta') &&
57
+ typeof resp['meta'] === 'object' &&
58
+ typeof resp['meta']['total'] === 'number') {
59
+ total = resp['meta']['total'];
60
+ }
61
+ return {
62
+ total,
63
+ entities: resp[entityNamePlural],
64
+ };
65
+ }
66
+ // Handle django-rest-framework style response
67
+ if (keys.includes('results') && Array.isArray(resp['results'])) {
68
+ let total = resp['results'].length;
69
+ if (keys.includes('count') && typeof resp['count'] === 'number') {
70
+ total = resp['count'];
71
+ }
72
+ return {
73
+ total,
74
+ entities: resp['results'],
75
+ };
76
+ }
77
+ // Finally, look for "items" key
78
+ if (keys.includes('items') && Array.isArray(resp['items'])) {
79
+ return {
80
+ total: resp['items'].length,
81
+ entities: resp['items'],
82
+ };
83
+ }
84
+ }
85
+ return {
86
+ total: 0,
87
+ entities: [],
88
+ };
89
+ }
90
+ }
26
91
  /**
27
92
  * This is a generic component to display a <mat-select> for a FK field
28
93
  * where the select's options are dynamically loaded from the server using
@@ -32,7 +97,7 @@ const SP_MAT_SELECT_ENTITY_HTTP_CONTEXT = new HttpContextToken(() => ({
32
97
  * be set to the object's `id` property. By default 'id' is used as its id,
33
98
  * but this can be customized by specifying the `idKey' property value.
34
99
  */
35
- class SPMatSelectEntityComponent {
100
+ class SPMatSelectEntityComponent extends SPPagedEntityLoader {
36
101
  // We cache the entities that we fetch from remote here. Cache is indexed
37
102
  // by the endpoint. Each endpoint also keeps a refCount, which is incremented
38
103
  // for each instance of the component using the same endpoint. When this
@@ -40,104 +105,49 @@ class SPMatSelectEntityComponent {
40
105
  //
41
106
  // This mechanism is to suppress multiple fetches from the remote from the
42
107
  // same endpoint as that can occur if a form has multiple instances of
43
- // this component, with the same endpoint.
44
- static _entitiesCache = new Map();
45
- matSel;
46
- // REQUIRED PROPERTIES //
47
- /**
48
- * Entity label function. Given an entity return its natural label
49
- * to display to the user.
50
- */
51
- entityLabelFn;
52
- // OPTIONAL PROPERTIES //
53
- /**
54
- * Entity filter function - return a boolean if the entity is to be included
55
- * in the filtered entities list.
56
- * @param entity: TEntity object to test for 'search' string.
57
- * @param search - search string
58
- */
59
- entityFilterFn;
60
- /**
61
- * Entity idKey, if idKey is different from the default 'id'.
62
- */
63
- idKey = 'id';
64
- /**
65
- * URL of the remote from where entities are to be loaded.
66
- * This won't be used if `loadFromRemoteFn` is specified.
67
- */
68
- url;
69
- /**
70
- * Parameters to be added to the HTTP request to retrieve data from
71
- * remote. This won't be used if `loadFromRemoteFn` is specified.
72
- */
73
- httpParams;
74
- /**
75
- * Function to load entities from remote.
76
- */
77
- loadFromRemoteFn;
78
- inlineNew = false;
79
- /**
80
- * Entity name, that is used to form the "New { item }" menu item if
81
- * inlineNew=true. This is also used as the key of the object in GET response
82
- * if the reponse JSON is not an array and rather an object, where the values
83
- * are stored indexed by the server model name. For eg:-
84
- *
85
- * {
86
- * 'customers': [
87
- * {...},
88
- * {...},
89
- * {...},
90
- * ]
91
- * }
92
- */
93
- entityName;
108
+ // this component with the same url.
109
+ // static _entitiesCache = new Map<
110
+ // string,
111
+ // { refCount: number; entities: Array<any> }
112
+ // >();
113
+ //**** OPTIONAL ATTRIBUTES ****//
114
+ // Entity label function - function that takes an entity object and returns
115
+ // a string label for it. If not specified, a default label function is used
116
+ // that returns the value of 'name' or 'label' or 'title' property. If
117
+ // none of these properties are present, the entity's id is returned as
118
+ // string.
119
+ labelFn = input();
120
+ // Entity filter function - return a boolean if the entity is to be included
121
+ // in the filtered entities list.
122
+ filterFn = input();
123
+ // Set to true to show "Add { item }" option in the select dropdown.
124
+ // Selecting this option, will emit `createNewItemSelected` event.
125
+ inlineNew = input(false);
94
126
  // Set to true to allow multiple option selection. The returned value
95
127
  // would be an array of entity ids.
96
- multiple = false;
97
- /*
98
- Whether to group options using <mat-optgroup></mat-optgroup>.
99
- If set to true, the response from the server should be an array of
100
- groups of TEntity objects, where each object is of the form:
101
- [
102
- {
103
- id: <id>,
104
- name|label: <>,
105
- items|<plural_entityName>|<custom_key>: [
106
- TEntity,
107
- ...
108
- ]
109
- },
110
- ...
111
- ]
112
- */
113
- group = false;
128
+ multiple = input(false);
114
129
  /**
115
- * The group object key name under which options are stored. Defaults to
116
- * 'items' or pluralized 'entityName'. Ideally the client class should
117
- * explicitly set this property value.
130
+ * The entity key name that is used to classify entities into groups.
131
+ * Entities with the same key value will be grouped together. If this is
132
+ * specified, grouping will be enabled.
133
+ * @see groupByFn
118
134
  */
119
- groupOptionsKey;
135
+ groupOptionsKey = input();
120
136
  /**
121
- * If groupOptions = true, specify this to provide accurate label for each
122
- * group. If not specified, group label will be determined by looking up one
123
- * of the standard fields - name, label or description - whichever comes
124
- * first.
137
+ * A function that takes a TEntity and returns the group id (string)
138
+ * that the entity belongs to. If this is specified, grouping of entities
139
+ * in the select will be enabled. This takes precedence over
140
+ * `groupOptionsKey`.
141
+ * @see groupOptionsKey
125
142
  */
126
- groupLabelFn;
127
- /**
128
- * Sideload data key name.
129
- */
130
- sideloadDataKey = input();
131
- /**
132
- * Parser function to return the list of entities from the GET response.
133
- */
134
- responseParserFn = input();
143
+ groupByFn = input();
135
144
  selectionChange = new EventEmitter();
136
145
  createNewItemSelected = new EventEmitter();
137
- // allow per component customization
146
+ // i18n localization support toallow per component customization of
147
+ // some strings used.
138
148
  searchText = input();
139
149
  notFoundText = input();
140
- addItemText = input();
150
+ createNewText = input();
141
151
  controlType = 'sp-mat-select-entity';
142
152
  /**
143
153
  * Template for the option label. If not provided, the default label
@@ -149,7 +159,7 @@ class SPMatSelectEntityComponent {
149
159
  * ```
150
160
  * <sp-mat-select-entity
151
161
  * [url]="'/api/v1/customers/'"
152
- * [entityLabelFn]="entity => entity.name"
162
+ * [labelFn]="entity => entity.name"
153
163
  * [optionLabelTemplate]="optionLabelTemplate"
154
164
  * ></sp-mat-select-entity>
155
165
  * <ng-template #optionLabelTemplate let-entity>
@@ -158,25 +168,44 @@ class SPMatSelectEntityComponent {
158
168
  * ```
159
169
  */
160
170
  optionLabelTemplate = input();
171
+ // a computed version of labelFn that provides a default implementation
161
172
  _entityLabelFn = computed(() => {
162
- const fn = this.entityLabelFn;
173
+ const fn = this.labelFn();
163
174
  if (fn) {
164
175
  return fn;
165
176
  }
166
177
  return (entity) => {
167
178
  return (entity['name'] ||
168
179
  entity['label'] ||
169
- String(entity[this.idKey]));
180
+ entity['title'] ||
181
+ String(entity[this.idKey()]));
170
182
  };
171
183
  });
172
- _sideloadDataKey = computed(() => {
173
- if (this.sideloadDataKey()) {
174
- return this.sideloadDataKey();
175
- }
176
- return this.entityName ? plural(camelCase(this.entityName)) : 'results';
184
+ // Whether to group options. Grouping is enabled when either groupOptionsKey
185
+ // or groupByFn is specified.
186
+ _group = computed(() => {
187
+ return !!this.groupOptionsKey() || !!this.groupByFn();
177
188
  });
178
- _entities = new Map();
179
- _groupedEntities = new Array();
189
+ _groupEntitiesKey = computed(() => {
190
+ const groupOptionsKey = this.groupOptionsKey();
191
+ return groupOptionsKey
192
+ ? groupOptionsKey
193
+ : this.entityName()
194
+ ? this._pluralEntityName()
195
+ : 'items';
196
+ });
197
+ // For the global paginator. We'll abstract this into an independent
198
+ // configuration that can be shared across both mat-entity-list and
199
+ // mat-select-entity later.
200
+ // entityListConfig = getEntityListConfig();
201
+ // protected _paginator = computed<SPMatEntityListPaginator>(() => {
202
+ // const paginator = this.paginator();
203
+ // const entityListConfigPaginator = this.entityListConfig
204
+ // ?.paginator as SPMatEntityListPaginator;
205
+ // return paginator
206
+ // ? paginator
207
+ // : entityListConfigPaginator ?? new DefaultPaginator();
208
+ // });
180
209
  stateChanges = new Subject();
181
210
  focused = false;
182
211
  touched = false;
@@ -190,55 +219,110 @@ class SPMatSelectEntityComponent {
190
219
  lastSelectValue;
191
220
  searching = false;
192
221
  filterStr = '';
193
- filter$ = new BehaviorSubject('');
194
- // ControlValueAccessor callback
222
+ filter$ = new Subject();
223
+ // ControlValueAccessor callbacks
195
224
  onChanged = (_) => { };
196
225
  onTouched = () => { };
197
- matSelect;
226
+ // @ViewChild(MatSelect) matSelect!: MatSelect;
227
+ matSelect = viewChild(MatSelect);
198
228
  filteredValues = new Subject();
199
229
  filteredGroupedValues = new Subject();
200
230
  destroy = new Subject();
201
- loaded = false;
202
- load$ = new BehaviorSubject(false);
203
231
  static nextId = 0;
204
232
  id = `sp-select-entity-${SPMatSelectEntityComponent.nextId++}`;
205
233
  _placeholder;
206
- http = inject(HttpClient);
234
+ //protected http = inject(HttpClient);
207
235
  cdr = inject(ChangeDetectorRef);
208
236
  _elementRef = inject((ElementRef));
209
237
  _formField = inject(MAT_FORM_FIELD, { optional: true });
210
238
  ngControl = inject(NgControl, { optional: true });
211
239
  transloco = inject(TranslocoService);
240
+ // pagedEntityLoader!: SPPagedEntityLoader<TEntity, IdKey>;
212
241
  constructor() {
242
+ super();
213
243
  if (this.ngControl != null) {
214
244
  this.ngControl.valueAccessor = this;
215
245
  }
216
246
  }
247
+ /**
248
+ * Conditions for loading entities:
249
+ *
250
+ * 1. When the select is opened, if entities have not already been loaded.
251
+ * 2. When the search string changes.
252
+ * 3. When the scroll reaches the bottom and more entities are available
253
+ * to be loaded.
254
+ *
255
+ * We need to create an 'observer-loop' that can handle the above.
256
+ */
217
257
  ngOnInit() {
218
- combineLatest([this.filter$.pipe(debounceTime(400)), this.load$])
219
- .pipe(takeUntil(this.destroy), switchMap(([str, load]) => {
220
- if (load && !this.loaded) {
221
- this.searching = true;
222
- this.cdr.detectChanges();
223
- return this.loadFromRemote();
258
+ // this.pagedEntityLoader = new SPPagedEntityLoader<TEntity, IdKey>(
259
+ // this.entityName(),
260
+ // this.url(),
261
+ // this.http,
262
+ // this.pageSize(),
263
+ // this._paginator(),
264
+ // this.searchParamName(),
265
+ // this.idKey(),
266
+ // this._pluralEntityName(),
267
+ // undefined,
268
+ // this.httpParams()
269
+ // );
270
+ this.startLoader();
271
+ // A rudimentary mechanism to detect which of the two observables
272
+ // emitted the latest value. We reset this array to 'false' after
273
+ // processing every combined emission.
274
+ const emittedObservable = [false, false];
275
+ const store$ = this.store.pipe(selectAllEntities());
276
+ const filter$ = this.filter$.pipe(startWith(''), distinctUntilChanged(), debounceTime(400));
277
+ const emittedStatusObservable = (obs, index) => obs.pipe(tap(() => (emittedObservable[index] = true)));
278
+ // We need to determine if the emission is owing to a change in
279
+ // filterStr or a change in the entities in pagedEntityLoader.store$.
280
+ //
281
+ // 1. If entities in pagedEntityLoader.store$ have changed, we just need
282
+ // to filter the entities in local store using the current filterStr.
283
+ // 2. If filterStr has changed, there are two cases to handle:-
284
+ // a. If all entities have been loaded, we don't need to reload
285
+ // entities. Instead we just have to filter the entities in
286
+ // local store using the filterStr.
287
+ // b. If all entities have not been loaded, we trigger a server
288
+ // load with the new filterStr as the search param.
289
+ //
290
+ // The following logic implements the above.
291
+ combineLatest([
292
+ emittedStatusObservable(store$, 0),
293
+ emittedStatusObservable(filter$, 1),
294
+ ])
295
+ .pipe(takeUntil(this.destroy), tap(([entities, filterStr]) => {
296
+ if (emittedObservable.every((eo) => eo)) {
297
+ // initial emission. This can be combined with the case immediately
298
+ // below it. But we keep it separate for clarity.
299
+ emittedObservable[0] = emittedObservable[1] = false;
300
+ this.filterEntities(entities, filterStr);
224
301
  }
225
- else {
226
- return of(this.entities ?? []);
227
- }
228
- }), tap(() => {
229
- if (this.group) {
230
- this.filterGroupedValues(this.filterStr);
302
+ else if (emittedObservable[0]) {
303
+ emittedObservable[0] = false;
304
+ this.filterEntities(entities, filterStr);
231
305
  }
232
306
  else {
233
- this.filterValues(this.filterStr);
307
+ emittedObservable[1] = false;
308
+ if (this.allEntitiesLoaded()) {
309
+ this.filterEntities(entities, filterStr);
310
+ }
311
+ else {
312
+ this.setSearchParamValue(filterStr);
313
+ // This will cause an emission from store$ observable as the
314
+ // 'forceRefresh=true' arg causes the store to be reset.
315
+ this.loadNextPage(true);
316
+ }
234
317
  }
235
- this.cdr.detectChanges();
236
318
  }))
237
319
  .subscribe();
238
320
  }
239
321
  ngOnDestroy() {
240
322
  this.destroy.next();
241
- this.removeFromCache();
323
+ this.destroy.complete();
324
+ this.stopLoader();
325
+ // this.removeFromCache();
242
326
  this.stateChanges.complete();
243
327
  }
244
328
  ngAfterViewInit() {
@@ -250,11 +334,11 @@ class SPMatSelectEntityComponent {
250
334
  // this.required = true;
251
335
  // }
252
336
  // }
337
+ // load first page
338
+ // this.loadMoreEntities();
253
339
  }
254
340
  addEntity(entity) {
255
- this._entities.set(entity[this.idKey], entity);
256
- // So that the newly added entity will be added to the <mat-option> list.
257
- this.filterValues(this.filterStr);
341
+ this.store.update(upsertEntities(entity));
258
342
  this.cdr.detectChanges();
259
343
  }
260
344
  get selectTriggerValue() {
@@ -262,7 +346,7 @@ class SPMatSelectEntityComponent {
262
346
  const firstSelected = Array.isArray(this.selectValue)
263
347
  ? this.selectValue[0]
264
348
  : this.selectValue;
265
- const selectedEntity = this._entities.get(firstSelected);
349
+ const selectedEntity = this.getEntity(firstSelected);
266
350
  return selectedEntity ? this._entityLabelFn()(selectedEntity) : '';
267
351
  }
268
352
  return '';
@@ -273,14 +357,16 @@ class SPMatSelectEntityComponent {
273
357
  : [];
274
358
  }
275
359
  entityId(entity) {
276
- return entity[this.idKey];
360
+ return entity[this.idKey()];
277
361
  }
278
362
  writeValue(entityId) {
363
+ const store = this.store;
364
+ const entities = this.getEntities();
279
365
  if (Array.isArray(entityId)) {
280
- if (this.multiple) {
366
+ if (this.multiple()) {
281
367
  const selectedValues = [];
282
368
  entityId.forEach((id) => {
283
- if (this._entities.has(id)) {
369
+ if (store.query(hasEntity(id))) {
284
370
  selectedValues.push(id);
285
371
  }
286
372
  });
@@ -289,11 +375,12 @@ class SPMatSelectEntityComponent {
289
375
  }
290
376
  }
291
377
  else {
292
- if (this._entities.has(entityId)) {
378
+ if (store.query(hasEntity(entityId))) {
379
+ // if (this._entities.has(entityId)) {
293
380
  this.selectValue = entityId;
294
381
  if (this.filterStr) {
295
382
  this.filterStr = '';
296
- this.filterValues(this.filterStr);
383
+ // this.filterNonGroupedEntities(entities, this.filterStr);
297
384
  }
298
385
  this.cdr.detectChanges();
299
386
  }
@@ -306,25 +393,10 @@ class SPMatSelectEntityComponent {
306
393
  this.onTouched = fn;
307
394
  }
308
395
  get entities() {
309
- return Array.from(this._entities.values());
396
+ return this.getEntities();
310
397
  }
311
398
  set entities(items) {
312
- if (!this.group) {
313
- items.forEach((item) => {
314
- this._entities.set(item[this.idKey], item);
315
- });
316
- }
317
- else {
318
- this._groupedEntities = items;
319
- this._groupedEntities.forEach((group) => {
320
- const key = this.groupEntitiesKey();
321
- const groupEntities = group[key];
322
- group['__items__'] = groupEntities;
323
- groupEntities.forEach((item) => {
324
- this._entities.set(item[this.idKey], item);
325
- });
326
- });
327
- }
399
+ this.setEntities(items);
328
400
  }
329
401
  get value() {
330
402
  return this.selectValue;
@@ -395,8 +467,9 @@ class SPMatSelectEntityComponent {
395
467
  }
396
468
  setDisabledState(isDisabled) {
397
469
  this._disabled = isDisabled;
398
- if (this.matSelect) {
399
- this.matSelect.setDisabledState(isDisabled);
470
+ const matSelect = this.matSelect();
471
+ if (matSelect) {
472
+ matSelect.setDisabledState(isDisabled);
400
473
  this.cdr.detectChanges();
401
474
  }
402
475
  }
@@ -405,8 +478,9 @@ class SPMatSelectEntityComponent {
405
478
  // eventually selects 'New Item' option.
406
479
  this.lastSelectValue = this.selectValue;
407
480
  // If values have not been loaded from remote, trigger a load.
408
- if (!this.loaded) {
409
- this.load$.next(true); // this will trigger the loadFromRemote() call.
481
+ if (this.totalEntitiesAtRemote() === 0) {
482
+ // first load
483
+ this.loadNextPage();
410
484
  }
411
485
  }
412
486
  onSelectionChange(ev) {
@@ -415,7 +489,7 @@ class SPMatSelectEntityComponent {
415
489
  this.selectValue = ev.value;
416
490
  this.onTouched();
417
491
  this.onChanged(ev.value);
418
- const selectedEntities = ev.value.map((id) => this._entities.get(id));
492
+ const selectedEntities = ev.value.map((id) => this.store.query(getEntity(id)));
419
493
  this.selectionChange.emit(selectedEntities);
420
494
  }
421
495
  else {
@@ -423,7 +497,7 @@ class SPMatSelectEntityComponent {
423
497
  this.selectValue = ev.value;
424
498
  this.onTouched();
425
499
  this.onChanged(ev.value);
426
- this.selectionChange.emit(this._entities.get(ev.value));
500
+ this.selectionChange.emit(this.store.query(getEntity(ev.value)));
427
501
  }
428
502
  else {
429
503
  // New Item activated, return value to previous value. We track
@@ -438,23 +512,45 @@ class SPMatSelectEntityComponent {
438
512
  }
439
513
  }
440
514
  }
441
- filterValues(search) {
442
- const searchLwr = search.toLocaleLowerCase();
443
- const entities = this.entities;
444
- if (!entities) {
445
- return;
515
+ /**
516
+ * Wrapper to filter entities based on whether grouping is enabled or not.
517
+ * Calls one of the two filtering methods -- filterGroupedEntities() or
518
+ * filterNonGroupedEntities().
519
+ * @param entities
520
+ * @param filterStr
521
+ * @returns
522
+ */
523
+ filterEntities(entities, filterStr) {
524
+ this.searching = true;
525
+ let retval;
526
+ if (this._group()) {
527
+ this.filterGroupedEntities(entities, filterStr);
528
+ }
529
+ else {
530
+ this.filterNonGroupedEntities(entities, filterStr);
446
531
  }
532
+ this.searching = false;
533
+ }
534
+ /**
535
+ * Filters the entities based on the search string.
536
+ * @param search The search string to filter entities.
537
+ * @returns The number of entities in the filtered result set or undefined.
538
+ */
539
+ filterNonGroupedEntities(entities, search) {
540
+ const searchLwr = search.toLocaleLowerCase();
447
541
  if (!search) {
448
542
  this.filteredValues.next(entities.slice());
449
543
  }
450
544
  else {
451
- this.filteredValues.next(entities.filter((member) => {
452
- if (this.entityFilterFn) {
453
- return this.entityFilterFn(member, search);
545
+ const filteredEntities = entities.filter((member) => {
546
+ const filterFn = this.filterFn();
547
+ if (filterFn) {
548
+ return filterFn(member, search);
454
549
  }
455
550
  const labelFn = this._entityLabelFn();
456
551
  return labelFn(member).toLocaleLowerCase().includes(searchLwr);
457
- }));
552
+ });
553
+ this.filteredValues.next(filteredEntities);
458
554
  }
459
555
  }
460
556
  /**
@@ -464,205 +560,144 @@ class SPMatSelectEntityComponent {
464
560
  * groups are to be included and within those groups, only entities whose
465
561
  * label matches the search string are to be included in the result set.
466
562
  * @param search
467
- * @returns
563
+ * @returns number of groups in the filtered result set.
468
564
  */
469
- filterGroupedValues(search) {
565
+ filterGroupedEntities(entities, search) {
470
566
  const searchLwr = search.toLocaleLowerCase();
471
- const groups = this._groupedEntities;
472
- if (!groups) {
473
- return;
474
- }
567
+ // First filter entities by the search string, if it's specified
568
+ let filteredEntities;
475
569
  if (!search) {
476
- const groupsCopy = groups.slice();
477
- this.filteredGroupedValues.next(groupsCopy);
570
+ filteredEntities = entities;
478
571
  }
479
572
  else {
480
- const groupEntitiesKey = this.groupEntitiesKey();
481
- const groups = this._groupedEntities.map((ge) => {
482
- const label = this.groupLabel(ge);
483
- if (label.toLocaleLowerCase().includes(searchLwr)) {
484
- return { ...ge };
485
- }
486
- else {
487
- const groupEntities = ge.__items__?.filter((e) => this._entityLabelFn()(e).toLocaleLowerCase().includes(searchLwr));
488
- const ret = {
489
- ...ge,
490
- };
491
- ret['__items__'] = groupEntities ?? [];
492
- return ret;
573
+ filteredEntities = entities.filter((member) => {
574
+ const filterFn = this.filterFn();
575
+ if (filterFn) {
576
+ return filterFn(member, search);
493
577
  }
578
+ const labelFn = this._entityLabelFn();
579
+ return labelFn(member).toLocaleLowerCase().includes(searchLwr);
494
580
  });
495
- // filter out groups with no entities
496
- // console.log(`Groups: ${JSON.stringify(groups)}`);
497
- this.filteredGroupedValues.next(groups.filter((group) => Array.isArray(group[groupEntitiesKey]) &&
498
- group['__items__'].length > 0));
499
- }
500
- }
501
- loadFromRemote() {
502
- if (!this.url && !this.loadFromRemoteFn) {
503
- // If user had initialized entities, they will be dispalyed
504
- // in the options list. If not, options would be empty.
505
- return of(this.group ? this.groupEntities : this.entities);
506
- }
507
- let cacheKey;
508
- let obs;
509
- if (this.loadFromRemoteFn) {
510
- obs = this.loadFromRemoteFn();
511
- }
512
- else {
513
- let params;
514
- if (this.httpParams) {
515
- params = new HttpParams({
516
- fromString: this.httpParams.toString(),
517
- });
518
- }
519
- else {
520
- params = new HttpParams();
521
- }
522
- params = params.set('paginate', false);
523
- cacheKey = this.getCacheKey();
524
- if (this.existsInCache()) {
525
- obs = of(this.getFromCache());
526
- }
527
- else {
528
- obs = this.http.get(this.url, {
529
- context: this.getHttpReqContext(),
530
- params,
531
- });
532
- }
533
- }
534
- return obs.pipe(tap((entities) => {
535
- this.searching = false; // remote loading done, will hide the loading wheel
536
- // Handle DRF paginated response
537
- const responseParserFn = this.responseParserFn();
538
- if (responseParserFn) {
539
- entities = responseParserFn(entities);
540
- }
541
- else {
542
- if (!Array.isArray(entities) &&
543
- entities['results'] &&
544
- Array.isArray(entities['results'])) {
545
- entities = entities['results'];
546
- }
547
- else if (
548
- // sideloaded response, where entities are usually provided in 'entityName'
549
- this._sideloadDataKey() &&
550
- !Array.isArray(entities) &&
551
- typeof entities === 'object' &&
552
- entities[this._sideloadDataKey()] &&
553
- Array.isArray(entities[this._sideloadDataKey()])) {
554
- entities = entities[this._sideloadDataKey()];
555
- }
556
- }
557
- if (Array.isArray(entities)) {
558
- this.entities = entities;
559
- // if (this.group) {
560
- // this._groupedEntities = entities as EntityGroup<TEntity>[];
561
- // } else {
562
- // this.entities = entities;
563
- // }
564
- }
565
- this.loaded = true;
566
- this.addToCache(entities);
567
- this.cdr.detectChanges();
568
- }));
569
- }
570
- groupLabel(group) {
571
- if (this.groupLabelFn) {
572
- return this.groupLabelFn(group);
573
- }
574
- const standardLabelFields = ['name', 'label', 'desc', 'description'];
575
- for (let index = 0; index < standardLabelFields.length; index++) {
576
- const labelField = standardLabelFields[index];
577
- if (group[labelField]) {
578
- return group[labelField];
579
- }
580
- }
581
- return `Group ${String(group.id)}`;
582
- }
583
- groupEntities(group) {
584
- const key = this.groupEntitiesKey();
585
- console.log(`groupEntities - group: ${JSON.stringify(group)}, key: ${key}`);
586
- return group[this.groupEntitiesKey()] ?? [];
587
- }
588
- groupEntitiesKey() {
589
- return this.groupOptionsKey
590
- ? this.groupOptionsKey
591
- : this.entityName
592
- ? plural(this.entityName.toLocaleLowerCase())
593
- : 'items';
594
- }
595
- existsInCache() {
596
- const cacheKey = this.getCacheKey();
597
- if (cacheKey) {
598
- return SPMatSelectEntityComponent._entitiesCache.has(cacheKey);
599
- }
600
- return false;
601
- }
602
- getCacheKey() {
603
- if (!this.loadFromRemoteFn) {
604
- let params;
605
- if (this.httpParams) {
606
- params = new HttpParams({
607
- fromString: this.httpParams.toString(),
608
- });
609
- }
610
- else {
611
- params = new HttpParams();
612
- }
613
- // params = params.set('paginate', false)
614
- return `${this.url}?${params.toString()}`;
615
- }
616
- return ''; // empty string evalutes to boolean(false)
617
- }
618
- getFromCache() {
619
- const cacheKey = this.getCacheKey();
620
- if (cacheKey && SPMatSelectEntityComponent._entitiesCache.has(cacheKey)) {
621
- return SPMatSelectEntityComponent._entitiesCache.get(cacheKey)
622
- ?.entities;
623
- }
624
- return [];
625
- }
626
- addToCache(entities) {
627
- const cacheKey = this.getCacheKey();
628
- if (cacheKey) {
629
- if (!SPMatSelectEntityComponent._entitiesCache.has(cacheKey)) {
630
- SPMatSelectEntityComponent._entitiesCache.set(cacheKey, {
631
- refCount: 0,
632
- entities,
633
- });
634
- }
635
- const cacheEntry = SPMatSelectEntityComponent._entitiesCache.get(cacheKey);
636
- cacheEntry.refCount += 1;
637
581
  }
582
+ this.filteredGroupedValues.next(this.groupEntities(filteredEntities));
638
583
  }
639
- removeFromCache() {
640
- const cacheKey = this.getCacheKey();
641
- if (cacheKey) {
642
- const cacheEntry = SPMatSelectEntityComponent._entitiesCache.get(cacheKey);
643
- if (cacheEntry) {
644
- cacheEntry.refCount -= 1;
645
- if (cacheEntry.refCount <= 0) {
646
- SPMatSelectEntityComponent._entitiesCache.delete(cacheKey);
647
- }
584
+ /**
585
+ * Helper to arrange the given array of entities into groups based on the
586
+ * groupByFn or groupOptionsKey. groupByFn takes precedence over
587
+ * groupOptionsKey.
588
+ * @param entities
589
+ * @returns EntityGroup<TEntity>[]
590
+ */
591
+ groupEntities(entities) {
592
+ let groupByFn;
593
+ if (this.groupByFn()) {
594
+ groupByFn = this.groupByFn();
595
+ }
596
+ else if (this.groupOptionsKey()) {
597
+ groupByFn = (entity) => {
598
+ const key = this.groupOptionsKey();
599
+ return entity[key] ?? '???';
600
+ };
601
+ }
602
+ const groupedEntitiesMap = new Map();
603
+ entities.forEach((entity) => {
604
+ const groupId = groupByFn(entity);
605
+ if (!groupedEntitiesMap.has(groupId)) {
606
+ groupedEntitiesMap.set(groupId, []);
648
607
  }
649
- }
650
- }
608
+ groupedEntitiesMap.get(groupId).push(entity);
609
+ });
610
+ let entityGroups = [];
611
+ groupedEntitiesMap.forEach((entities, groupId) => {
612
+ entityGroups.push({
613
+ label: String(groupId),
614
+ entities,
615
+ });
616
+ });
617
+ return entityGroups;
618
+ }
619
+ // private existsInCache() {
620
+ // const cacheKey = this.getCacheKey();
621
+ // if (cacheKey) {
622
+ // return SPMatSelectEntityComponent._entitiesCache.has(cacheKey);
623
+ // }
624
+ // return false;
625
+ // }
626
+ // private getCacheKey() {
627
+ // if (typeof this.url() !== 'function') {
628
+ // let params!: HttpParams;
629
+ // if (this.httpParams) {
630
+ // params = new HttpParams({
631
+ // fromString: this.httpParams.toString(),
632
+ // });
633
+ // } else {
634
+ // params = new HttpParams();
635
+ // }
636
+ // // params = params.set('paginate', false)
637
+ // return `${this.url}?${params.toString()}`;
638
+ // }
639
+ // return ''; // empty string evalutes to boolean(false)
640
+ // }
641
+ // private getFromCache() {
642
+ // const cacheKey = this.getCacheKey();
643
+ // if (cacheKey && SPMatSelectEntityComponent._entitiesCache.has(cacheKey)) {
644
+ // return SPMatSelectEntityComponent._entitiesCache.get(cacheKey)
645
+ // ?.entities as TEntity[];
646
+ // }
647
+ // return [];
648
+ // }
649
+ // private addToCache(entities: TEntity[]) {
650
+ // const cacheKey = this.getCacheKey();
651
+ // if (cacheKey) {
652
+ // if (!SPMatSelectEntityComponent._entitiesCache.has(cacheKey)) {
653
+ // SPMatSelectEntityComponent._entitiesCache.set(cacheKey, {
654
+ // refCount: 0,
655
+ // entities,
656
+ // });
657
+ // }
658
+ // const cacheEntry =
659
+ // SPMatSelectEntityComponent._entitiesCache.get(cacheKey);
660
+ // cacheEntry!.refCount += 1;
661
+ // }
662
+ // }
663
+ // private removeFromCache() {
664
+ // const cacheKey = this.getCacheKey();
665
+ // if (cacheKey) {
666
+ // const cacheEntry =
667
+ // SPMatSelectEntityComponent._entitiesCache.get(cacheKey);
668
+ // if (cacheEntry) {
669
+ // cacheEntry!.refCount -= 1;
670
+ // if (cacheEntry.refCount <= 0) {
671
+ // SPMatSelectEntityComponent._entitiesCache.delete(cacheKey);
672
+ // }
673
+ // }
674
+ // }
675
+ // }
651
676
  getHttpReqContext() {
652
677
  const context = new HttpContext();
653
678
  const entityName = this.entityName;
654
679
  context.set(SP_MAT_SELECT_ENTITY_HTTP_CONTEXT, {
655
- entityName: this.entityName ?? '',
656
- entityNamePlural: this.entityName ? plural(this.entityName) : '',
657
- endpoint: this.url,
680
+ entityName: this.entityName(),
681
+ entityNamePlural: this._pluralEntityName(),
682
+ endpoint: this.url(),
658
683
  });
659
684
  return context;
660
685
  }
686
+ /**
687
+ * If more entities are available, load the next page of entities.
688
+ * This method is triggered when user scrolls to the bottom of the options
689
+ * list. Well almost to the bottom of the options list. :)
690
+ */
691
+ onInfiniteScroll() {
692
+ if (this.hasMore() && !this.loading()) {
693
+ this.loadNextPage();
694
+ }
695
+ }
661
696
  /** @nocollapse */ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: SPMatSelectEntityComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
662
- /** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.1.6", type: SPMatSelectEntityComponent, isStandalone: true, selector: "sp-mat-select-entity", inputs: { entityLabelFn: { classPropertyName: "entityLabelFn", publicName: "entityLabelFn", isSignal: false, isRequired: false, transformFunction: null }, entityFilterFn: { classPropertyName: "entityFilterFn", publicName: "entityFilterFn", isSignal: false, isRequired: false, transformFunction: null }, idKey: { classPropertyName: "idKey", publicName: "idKey", isSignal: false, isRequired: false, transformFunction: null }, url: { classPropertyName: "url", publicName: "url", isSignal: false, isRequired: false, transformFunction: null }, httpParams: { classPropertyName: "httpParams", publicName: "httpParams", isSignal: false, isRequired: false, transformFunction: null }, loadFromRemoteFn: { classPropertyName: "loadFromRemoteFn", publicName: "loadFromRemoteFn", isSignal: false, isRequired: false, transformFunction: null }, inlineNew: { classPropertyName: "inlineNew", publicName: "inlineNew", isSignal: false, isRequired: false, transformFunction: null }, entityName: { classPropertyName: "entityName", publicName: "entityName", isSignal: false, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: false, isRequired: false, transformFunction: null }, group: { classPropertyName: "group", publicName: "group", isSignal: false, isRequired: false, transformFunction: null }, groupOptionsKey: { classPropertyName: "groupOptionsKey", publicName: "groupOptionsKey", isSignal: false, isRequired: false, transformFunction: null }, groupLabelFn: { classPropertyName: "groupLabelFn", publicName: "groupLabelFn", isSignal: false, isRequired: false, transformFunction: null }, sideloadDataKey: { classPropertyName: "sideloadDataKey", publicName: "sideloadDataKey", isSignal: true, isRequired: false, transformFunction: null }, responseParserFn: { classPropertyName: "responseParserFn", publicName: "responseParserFn", isSignal: true, isRequired: false, transformFunction: null }, searchText: { classPropertyName: "searchText", publicName: "searchText", isSignal: true, isRequired: false, transformFunction: null }, notFoundText: { classPropertyName: "notFoundText", publicName: "notFoundText", isSignal: true, isRequired: false, transformFunction: null }, addItemText: { classPropertyName: "addItemText", publicName: "addItemText", isSignal: true, isRequired: false, transformFunction: null }, optionLabelTemplate: { classPropertyName: "optionLabelTemplate", publicName: "optionLabelTemplate", isSignal: true, isRequired: false, transformFunction: null }, entities: { classPropertyName: "entities", publicName: "entities", isSignal: false, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: false, isRequired: false, transformFunction: null }, userAriaDescribedBy: { classPropertyName: "userAriaDescribedBy", publicName: "aria-describedby", isSignal: false, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: false, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: false, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: false, isRequired: false, transformFunction: null } }, outputs: { selectionChange: "selectionChange", createNewItemSelected: "createNewItemSelected" }, host: { properties: { "id": "this.id" } }, providers: [
697
+ /** @nocollapse */ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "19.1.6", type: SPMatSelectEntityComponent, isStandalone: true, selector: "sp-mat-select-entity", inputs: { labelFn: { classPropertyName: "labelFn", publicName: "labelFn", isSignal: true, isRequired: false, transformFunction: null }, filterFn: { classPropertyName: "filterFn", publicName: "filterFn", isSignal: true, isRequired: false, transformFunction: null }, inlineNew: { classPropertyName: "inlineNew", publicName: "inlineNew", isSignal: true, isRequired: false, transformFunction: null }, multiple: { classPropertyName: "multiple", publicName: "multiple", isSignal: true, isRequired: false, transformFunction: null }, groupOptionsKey: { classPropertyName: "groupOptionsKey", publicName: "groupOptionsKey", isSignal: true, isRequired: false, transformFunction: null }, groupByFn: { classPropertyName: "groupByFn", publicName: "groupByFn", isSignal: true, isRequired: false, transformFunction: null }, searchText: { classPropertyName: "searchText", publicName: "searchText", isSignal: true, isRequired: false, transformFunction: null }, notFoundText: { classPropertyName: "notFoundText", publicName: "notFoundText", isSignal: true, isRequired: false, transformFunction: null }, createNewText: { classPropertyName: "createNewText", publicName: "createNewText", isSignal: true, isRequired: false, transformFunction: null }, optionLabelTemplate: { classPropertyName: "optionLabelTemplate", publicName: "optionLabelTemplate", isSignal: true, isRequired: false, transformFunction: null }, entities: { classPropertyName: "entities", publicName: "entities", isSignal: false, isRequired: false, transformFunction: null }, value: { classPropertyName: "value", publicName: "value", isSignal: false, isRequired: false, transformFunction: null }, userAriaDescribedBy: { classPropertyName: "userAriaDescribedBy", publicName: "aria-describedby", isSignal: false, isRequired: false, transformFunction: null }, placeholder: { classPropertyName: "placeholder", publicName: "placeholder", isSignal: false, isRequired: false, transformFunction: null }, required: { classPropertyName: "required", publicName: "required", isSignal: false, isRequired: false, transformFunction: null }, disabled: { classPropertyName: "disabled", publicName: "disabled", isSignal: false, isRequired: false, transformFunction: null } }, outputs: { selectionChange: "selectionChange", createNewItemSelected: "createNewItemSelected" }, host: { properties: { "id": "this.id" } }, providers: [
663
698
  provideTranslocoScope('sp-mat-select-entity'),
664
699
  { provide: MatFormFieldControl, useExisting: SPMatSelectEntityComponent },
665
- ], viewQueries: [{ propertyName: "matSel", first: true, predicate: MatSelect, descendants: true }, { propertyName: "matSelect", first: true, predicate: MatSelect, descendants: true }], ngImport: i0, template: `
700
+ ], viewQueries: [{ propertyName: "matSelect", first: true, predicate: MatSelect, descendants: true, isSignal: true }], usesInheritance: true, ngImport: i0, template: `
666
701
  <div
667
702
  *transloco="let t; scope: 'sp-mat-select-entity'"
668
703
  (focusin)="onFocusIn($event)"
@@ -674,8 +709,10 @@ class SPMatSelectEntityComponent {
674
709
  [placeholder]="placeholder"
675
710
  (opened)="onSelectOpened($event)"
676
711
  (selectionChange)="onSelectionChange($event)"
677
- [multiple]="multiple"
712
+ [multiple]="multiple()"
678
713
  [(ngModel)]="selectValue"
714
+ msInfiniteScroll
715
+ (infiniteScroll)="onInfiniteScroll()"
679
716
  >
680
717
  <mat-select-trigger>
681
718
  {{ selectTriggerValue }}
@@ -688,6 +725,7 @@ class SPMatSelectEntityComponent {
688
725
 
689
726
  <mat-option>
690
727
  <ngx-mat-select-search
728
+ class="flex-grow-1"
691
729
  [(ngModel)]="filterStr"
692
730
  (ngModelChange)="this.filter$.next($event)"
693
731
  [placeholderLabel]="
@@ -701,57 +739,58 @@ class SPMatSelectEntityComponent {
701
739
  </ngx-mat-select-search>
702
740
  </mat-option>
703
741
 
704
- <ng-container *ngIf="!group; else groupedOptions">
705
- <span *ngIf="filteredValues | async as entities">
706
- <ng-template #defaultOptionLabelTemplate let-entity>
707
- {{ _entityLabelFn()(entity) }}
708
- </ng-template>
709
- @for (entity of entities; track entityId(entity)) {
710
- <mat-option class="sel-entity-option" [value]="entityId(entity)">
711
- <ng-container
712
- *ngTemplateOutlet="
713
- optionLabelTemplate() || defaultOptionLabelTemplate;
714
- context: { $implicit: entity }
715
- "
716
- ></ng-container>
717
- </mat-option>
718
- }
719
-
720
- <!-- <mat-option class="sel-entity-option" *ngFor="let entity of entities" [value]="entityId(entity)">
721
- {{ _entityLabelFn()(entity) }}
722
- </mat-option> -->
723
- </span>
724
- </ng-container>
725
- <ng-template #groupedOptions>
726
- <span *ngIf="filteredGroupedValues | async as groups">
727
- @for (group of groups; track groupLabel(group)) {
728
- <mat-optgroup [label]="groupLabel(group)">
729
- @for (entity of group.__items__; track entityId(entity)) {
730
-
731
- <mat-option class="sel-entity-option" [value]="entityId(entity)">
732
- {{ _entityLabelFn()(entity) }}
733
- </mat-option>
734
- }
735
- </mat-optgroup>
736
- }
737
- </span>
742
+ <ng-template #defaultOptionLabelTemplate let-entity>
743
+ {{ _entityLabelFn()(entity) }}
738
744
  </ng-template>
745
+ @if (!_group()) { @if (filteredValues | async; as entities) { @for
746
+ (entity of entities; track entityId(entity)) {
747
+ <mat-option class="sel-entity-option" [value]="entityId(entity)">
748
+ <ng-container
749
+ *ngTemplateOutlet="
750
+ optionLabelTemplate() || defaultOptionLabelTemplate;
751
+ context: { $implicit: entity }
752
+ "
753
+ ></ng-container>
754
+ </mat-option>
755
+ } } } @else { @if (filteredGroupedValues | async; as groups) { @for
756
+ (group of groups; track group.label) {
757
+ <mat-optgroup [label]="group.label">
758
+ @for (entity of group.entities; track entityId(entity)) {
759
+ <mat-option class="sel-entity-option" [value]="entityId(entity)">
760
+ <ng-container
761
+ *ngTemplateOutlet="
762
+ optionLabelTemplate() || defaultOptionLabelTemplate;
763
+ context: { $implicit: entity }
764
+ "
765
+ ></ng-container>
766
+ </mat-option>
767
+ }
768
+ </mat-optgroup>
769
+ } } }
739
770
 
771
+ <!--
772
+ Create New option is displayed only if there is a filter string.
773
+ The logic behind this behavior being that user searches for a matching
774
+ item and when not finding one, would like to add a new one.
775
+ -->
776
+ @if (inlineNew() && filterStr.length > 0) {
740
777
  <mat-option
741
- *ngIf="!multiple && inlineNew"
742
778
  class="add-item-option"
743
779
  value="0"
744
780
  (click)="$event.stopPropagation()"
745
781
  >⊕
746
782
  {{
747
- this.addItemText()
748
- ? this.addItemText()
749
- : t('spMatSelectEntity.addItem', { item: this.entityName })
750
- }}</mat-option
751
- >
783
+ this.createNewText()
784
+ ? this.createNewText()
785
+ : t('spMatSelectEntity.createNew', {
786
+ item: this._capitalizedEntityName()
787
+ })
788
+ }}
789
+ </mat-option>
790
+ }
752
791
  </mat-select>
753
792
  </div>
754
- `, isInline: true, styles: [".add-item-option{padding-top:2px;border-top:1px solid gray}.addl-selection-count{opacity:.75;font-size:.8em}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgIf, selector: "[ngIf]", inputs: ["ngIf", "ngIfThen", "ngIfElse"] }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "pipe", type: i1.AsyncPipe, name: "async" }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i3.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "directive", type: i3.MatSelectTrigger, selector: "mat-select-trigger" }, { kind: "component", type: i4.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "component", type: i4.MatOptgroup, selector: "mat-optgroup", inputs: ["label", "disabled"], exportAs: ["matOptgroup"] }, { kind: "ngmodule", type: TranslocoModule }, { kind: "directive", type: i5.TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "ngmodule", type: NgxMatSelectSearchModule }, { kind: "component", type: i6.MatSelectSearchComponent, selector: "ngx-mat-select-search", inputs: ["placeholderLabel", "type", "closeIcon", "closeSvgIcon", "noEntriesFoundLabel", "clearSearchInput", "searching", "disableInitialFocus", "enableClearOnEscapePressed", "preventHomeEndKeyPropagation", "disableScrollToActiveOnOptionsChanged", "ariaLabel", "showToggleAllCheckbox", "toggleAllCheckboxChecked", "toggleAllCheckboxIndeterminate", "toggleAllCheckboxTooltipMessage", "toggleAllCheckboxTooltipPosition", "hideClearSearchButton", "alwaysRestoreSelectedOptionsMulti", "recreateValuesArray"], outputs: ["toggleAll"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
793
+ `, isInline: true, styles: [".add-item-option{padding-top:2px;border-top:1px solid var(--mat-sys-outline)}.addl-selection-count{opacity:.75;font-size:.8em}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1.NgTemplateOutlet, selector: "[ngTemplateOutlet]", inputs: ["ngTemplateOutletContext", "ngTemplateOutlet", "ngTemplateOutletInjector"] }, { kind: "pipe", type: i1.AsyncPipe, name: "async" }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i2.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i2.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ReactiveFormsModule }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i3.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "directive", type: i3.MatSelectTrigger, selector: "mat-select-trigger" }, { kind: "component", type: i4.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "component", type: i4.MatOptgroup, selector: "mat-optgroup", inputs: ["label", "disabled"], exportAs: ["matOptgroup"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "ngmodule", type: MatIconModule }, { kind: "ngmodule", type: TranslocoModule }, { kind: "directive", type: i5.TranslocoDirective, selector: "[transloco]", inputs: ["transloco", "translocoParams", "translocoScope", "translocoRead", "translocoPrefix", "translocoLang", "translocoLoadingTpl"] }, { kind: "ngmodule", type: NgxMatSelectSearchModule }, { kind: "component", type: i6.MatSelectSearchComponent, selector: "ngx-mat-select-search", inputs: ["placeholderLabel", "type", "closeIcon", "closeSvgIcon", "noEntriesFoundLabel", "clearSearchInput", "searching", "disableInitialFocus", "enableClearOnEscapePressed", "preventHomeEndKeyPropagation", "disableScrollToActiveOnOptionsChanged", "ariaLabel", "showToggleAllCheckbox", "toggleAllCheckboxChecked", "toggleAllCheckboxIndeterminate", "toggleAllCheckboxTooltipMessage", "toggleAllCheckboxTooltipPosition", "hideClearSearchButton", "alwaysRestoreSelectedOptionsMulti", "recreateValuesArray"], outputs: ["toggleAll"] }, { kind: "directive", type: MatSelectInfiniteScrollDirective, selector: "[msInfiniteScroll]", inputs: ["threshold", "debounceTime", "complete"], outputs: ["infiniteScroll"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
755
794
  }
756
795
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImport: i0, type: SPMatSelectEntityComponent, decorators: [{
757
796
  type: Component,
@@ -767,8 +806,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImpor
767
806
  [placeholder]="placeholder"
768
807
  (opened)="onSelectOpened($event)"
769
808
  (selectionChange)="onSelectionChange($event)"
770
- [multiple]="multiple"
809
+ [multiple]="multiple()"
771
810
  [(ngModel)]="selectValue"
811
+ msInfiniteScroll
812
+ (infiniteScroll)="onInfiniteScroll()"
772
813
  >
773
814
  <mat-select-trigger>
774
815
  {{ selectTriggerValue }}
@@ -781,6 +822,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImpor
781
822
 
782
823
  <mat-option>
783
824
  <ngx-mat-select-search
825
+ class="flex-grow-1"
784
826
  [(ngModel)]="filterStr"
785
827
  (ngModelChange)="this.filter$.next($event)"
786
828
  [placeholderLabel]="
@@ -794,54 +836,55 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImpor
794
836
  </ngx-mat-select-search>
795
837
  </mat-option>
796
838
 
797
- <ng-container *ngIf="!group; else groupedOptions">
798
- <span *ngIf="filteredValues | async as entities">
799
- <ng-template #defaultOptionLabelTemplate let-entity>
800
- {{ _entityLabelFn()(entity) }}
801
- </ng-template>
802
- @for (entity of entities; track entityId(entity)) {
803
- <mat-option class="sel-entity-option" [value]="entityId(entity)">
804
- <ng-container
805
- *ngTemplateOutlet="
806
- optionLabelTemplate() || defaultOptionLabelTemplate;
807
- context: { $implicit: entity }
808
- "
809
- ></ng-container>
810
- </mat-option>
811
- }
812
-
813
- <!-- <mat-option class="sel-entity-option" *ngFor="let entity of entities" [value]="entityId(entity)">
814
- {{ _entityLabelFn()(entity) }}
815
- </mat-option> -->
816
- </span>
817
- </ng-container>
818
- <ng-template #groupedOptions>
819
- <span *ngIf="filteredGroupedValues | async as groups">
820
- @for (group of groups; track groupLabel(group)) {
821
- <mat-optgroup [label]="groupLabel(group)">
822
- @for (entity of group.__items__; track entityId(entity)) {
823
-
824
- <mat-option class="sel-entity-option" [value]="entityId(entity)">
825
- {{ _entityLabelFn()(entity) }}
826
- </mat-option>
827
- }
828
- </mat-optgroup>
829
- }
830
- </span>
839
+ <ng-template #defaultOptionLabelTemplate let-entity>
840
+ {{ _entityLabelFn()(entity) }}
831
841
  </ng-template>
842
+ @if (!_group()) { @if (filteredValues | async; as entities) { @for
843
+ (entity of entities; track entityId(entity)) {
844
+ <mat-option class="sel-entity-option" [value]="entityId(entity)">
845
+ <ng-container
846
+ *ngTemplateOutlet="
847
+ optionLabelTemplate() || defaultOptionLabelTemplate;
848
+ context: { $implicit: entity }
849
+ "
850
+ ></ng-container>
851
+ </mat-option>
852
+ } } } @else { @if (filteredGroupedValues | async; as groups) { @for
853
+ (group of groups; track group.label) {
854
+ <mat-optgroup [label]="group.label">
855
+ @for (entity of group.entities; track entityId(entity)) {
856
+ <mat-option class="sel-entity-option" [value]="entityId(entity)">
857
+ <ng-container
858
+ *ngTemplateOutlet="
859
+ optionLabelTemplate() || defaultOptionLabelTemplate;
860
+ context: { $implicit: entity }
861
+ "
862
+ ></ng-container>
863
+ </mat-option>
864
+ }
865
+ </mat-optgroup>
866
+ } } }
832
867
 
868
+ <!--
869
+ Create New option is displayed only if there is a filter string.
870
+ The logic behind this behavior being that user searches for a matching
871
+ item and when not finding one, would like to add a new one.
872
+ -->
873
+ @if (inlineNew() && filterStr.length > 0) {
833
874
  <mat-option
834
- *ngIf="!multiple && inlineNew"
835
875
  class="add-item-option"
836
876
  value="0"
837
877
  (click)="$event.stopPropagation()"
838
878
  >⊕
839
879
  {{
840
- this.addItemText()
841
- ? this.addItemText()
842
- : t('spMatSelectEntity.addItem', { item: this.entityName })
843
- }}</mat-option
844
- >
880
+ this.createNewText()
881
+ ? this.createNewText()
882
+ : t('spMatSelectEntity.createNew', {
883
+ item: this._capitalizedEntityName()
884
+ })
885
+ }}
886
+ </mat-option>
887
+ }
845
888
  </mat-select>
846
889
  </div>
847
890
  `, changeDetection: ChangeDetectionStrategy.OnPush, imports: [
@@ -850,57 +893,19 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.1.6", ngImpor
850
893
  FormsModule,
851
894
  ReactiveFormsModule,
852
895
  MatSelectModule,
896
+ MatButtonModule,
897
+ MatIconModule,
853
898
  TranslocoModule,
854
899
  NgxMatSelectSearchModule,
900
+ MatSelectInfiniteScrollDirective,
855
901
  ], providers: [
856
902
  provideTranslocoScope('sp-mat-select-entity'),
857
903
  { provide: MatFormFieldControl, useExisting: SPMatSelectEntityComponent },
858
- ], styles: [".add-item-option{padding-top:2px;border-top:1px solid gray}.addl-selection-count{opacity:.75;font-size:.8em}\n"] }]
859
- }], ctorParameters: () => [], propDecorators: { matSel: [{
860
- type: ViewChild,
861
- args: [MatSelect]
862
- }], entityLabelFn: [{
863
- type: Input
864
- }], entityFilterFn: [{
865
- type: Input,
866
- args: [{ required: false }]
867
- }], idKey: [{
868
- type: Input,
869
- args: [{ required: false }]
870
- }], url: [{
871
- type: Input,
872
- args: [{ required: false }]
873
- }], httpParams: [{
874
- type: Input,
875
- args: [{ required: false }]
876
- }], loadFromRemoteFn: [{
877
- type: Input,
878
- args: [{ required: false }]
879
- }], inlineNew: [{
880
- type: Input,
881
- args: [{ required: false }]
882
- }], entityName: [{
883
- type: Input,
884
- args: [{ required: false }]
885
- }], multiple: [{
886
- type: Input,
887
- args: [{ required: false }]
888
- }], group: [{
889
- type: Input,
890
- args: [{ required: false }]
891
- }], groupOptionsKey: [{
892
- type: Input,
893
- args: [{ required: false }]
894
- }], groupLabelFn: [{
895
- type: Input,
896
- args: [{ required: false }]
897
- }], selectionChange: [{
904
+ ], styles: [".add-item-option{padding-top:2px;border-top:1px solid var(--mat-sys-outline)}.addl-selection-count{opacity:.75;font-size:.8em}\n"] }]
905
+ }], ctorParameters: () => [], propDecorators: { selectionChange: [{
898
906
  type: Output
899
907
  }], createNewItemSelected: [{
900
908
  type: Output
901
- }], matSelect: [{
902
- type: ViewChild,
903
- args: [MatSelect]
904
909
  }], id: [{
905
910
  type: HostBinding
906
911
  }], entities: [{