tailjng 0.1.9 → 0.1.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.
@@ -35,13 +35,18 @@ import type {
35
35
  } from './dropdown-select.types';
36
36
  import {
37
37
  DROPDOWN_PANEL_DEFAULTS,
38
+ canResolveSelectedByFindOne,
39
+ extractOptionsFromResponse,
38
40
  filterOptionsBySearch,
41
+ getOptionValueFilterKey,
39
42
  mapObjectOptions,
40
43
  mapPrimitiveOptions,
44
+ mergeDropdownOptions,
45
+ optionValuesEqual,
41
46
  positionDropdownPanel,
47
+ readDropdownPaginationMeta,
42
48
  rebuildEndpointUrl,
43
49
  resetDropdownPanelStyles,
44
- resolveOptionLabel,
45
50
  } from './dropdown-select.util';
46
51
 
47
52
  export type {
@@ -55,14 +60,21 @@ export type {
55
60
  } from './dropdown-select.types';
56
61
  export {
57
62
  DROPDOWN_PANEL_DEFAULTS,
63
+ extractOptionsFromResponse,
58
64
  filterOptionsBySearch,
59
65
  getNestedValue,
60
66
  mapObjectOptions,
61
67
  mapPrimitiveOptions,
68
+ mergeDropdownOptions,
69
+ optionValuesEqual,
62
70
  positionDropdownPanel,
71
+ readDropdownPaginationMeta,
63
72
  rebuildEndpointUrl,
64
73
  resetDropdownPanelStyles,
65
74
  resolveOptionLabel,
75
+ resolveOptionLabelFromTemplate,
76
+ getOptionValueFilterKey,
77
+ canResolveSelectedByFindOne,
66
78
  } from './dropdown-select.util';
67
79
 
68
80
  /**
@@ -79,6 +91,16 @@ export {
79
91
  * optionValue="value"
80
92
  * [showClear]="true"
81
93
  * />
94
+ *
95
+ * <!-- Nested API payload: data.typeAccess.options[] -->
96
+ * <JDropdownSelect
97
+ * type="searchable"
98
+ * endpoint="enum/typeAccess"
99
+ * dataPath="typeAccess.options"
100
+ * optionLabel="label"
101
+ * optionValue="value"
102
+ * optionLabelTemplate="Acceso {label} (id {value})"
103
+ * />
82
104
  * ```
83
105
  *
84
106
  * Icons in template: `[icon]="Icons.ChevronDown"` (see `readonly Icons = Icons`).
@@ -132,12 +154,34 @@ export class JDropdownSelectComponent
132
154
  // Property name(s) used to build option text (supports dot paths)
133
155
  @Input() optionLabel: string | string[] = 'text';
134
156
 
135
- // Property name used as the option value
157
+ // Property name used as the option value (supports dot paths)
136
158
  @Input() optionValue = 'value';
137
159
 
138
160
  // Separator when `optionLabel` is an array of keys
139
161
  @Input() labelSeparator = ' ';
140
162
 
163
+ /**
164
+ * Custom label template per option. Use `{field}` placeholders with dot paths.
165
+ * Example: `El valor es {value} de {label}`.
166
+ * Takes precedence over `optionLabel` / `labelSeparator`.
167
+ */
168
+ @Input() optionLabelTemplate = '';
169
+
170
+ /**
171
+ * Optional formatter for full control over the displayed label.
172
+ * Takes precedence over `optionLabelTemplate` when provided.
173
+ */
174
+ @Input() optionLabelFn?: (option: Record<string, unknown>) => string;
175
+
176
+ /** @deprecated Alias of `dataPath` for a single top-level key in `response.data`. */
177
+ @Input() responseKey = '';
178
+
179
+ /**
180
+ * Dot path from `response.data` to the options array.
181
+ * Examples: `users`, `typeAccess.options`, `catalog.items`.
182
+ */
183
+ @Input() dataPath = '';
184
+
141
185
  // Prepends a synthetic “TODOS” option with `value: null` (searchable mode)
142
186
  @Input() showAllOption = false;
143
187
 
@@ -164,8 +208,20 @@ export class JDropdownSelectComponent
164
208
  // Searchable: sort order sent to the API
165
209
  @Input() sort: DropdownSortOrder = 'ASC';
166
210
 
167
- // Searchable: max records per request
168
- @Input() limit = 1000;
211
+ // Searchable: records per page (infinite scroll loads next pages)
212
+ @Input() limit = 30;
213
+
214
+ /** Appends next API pages when scrolling the options list (searchable mode). */
215
+ @Input() infiniteScroll = true;
216
+
217
+ /**
218
+ * Fetches the selected row when it is not in loaded pages (edit forms).
219
+ * Uses `findOne` for simple CRUD endpoints or `filter[optionValue]` as fallback.
220
+ */
221
+ @Input() resolveSelected = true;
222
+
223
+ /** Override filter key used to resolve the selected value (default: last segment of `optionValue`). */
224
+ @Input() resolveSelectedFilterKey = '';
169
225
 
170
226
  // Searchable panel: show inline search input
171
227
  @Input() isSearch = true;
@@ -211,6 +267,7 @@ export class JDropdownSelectComponent
211
267
 
212
268
  @ViewChild('selectButton') selectButton!: ElementRef<HTMLElement>;
213
269
  @ViewChild('dropdownPanel') dropdownPanel?: ElementRef<HTMLElement>;
270
+ @ViewChild('optionsList') optionsList?: ElementRef<HTMLElement>;
214
271
 
215
272
  isColumnSelectorOpen = false;
216
273
  selectedValue: unknown = null;
@@ -218,6 +275,9 @@ export class JDropdownSelectComponent
218
275
  internalOptions: DropdownOption[] = [];
219
276
  filteredOptions: DropdownOption[] = [];
220
277
 
278
+ isLoadingMore = false;
279
+ hasMorePages = false;
280
+
221
281
  searchTerm = '';
222
282
  dropdownWidth = 0;
223
283
 
@@ -231,8 +291,14 @@ export class JDropdownSelectComponent
231
291
 
232
292
  private readonly searchSubject = new Subject<string>();
233
293
  private searchSubscription?: Subscription;
294
+ private apiSubscription?: Subscription;
234
295
  private clickOutsideListener?: (event: MouseEvent) => void;
235
296
 
297
+ private currentPage = 1;
298
+ private totalPages = 1;
299
+ private resolvedSelectedOption: DropdownOption | null = null;
300
+ private isResolvingSelected = false;
301
+
236
302
  private onChange: (value: unknown) => void = () => {};
237
303
  private onTouched: () => void = () => {};
238
304
 
@@ -260,12 +326,12 @@ export class JDropdownSelectComponent
260
326
  .pipe(debounceTime(1000), distinctUntilChanged())
261
327
  .subscribe(() => {
262
328
  if (this.type === 'searchable') {
263
- this.loadData();
329
+ this.loadData(true);
264
330
  }
265
331
  });
266
332
 
267
333
  if (this.loadOnInit && this.type === 'searchable') {
268
- this.loadData();
334
+ this.loadData(true);
269
335
  }
270
336
 
271
337
  this.updateSelectedLabel();
@@ -290,6 +356,7 @@ export class JDropdownSelectComponent
290
356
  }
291
357
 
292
358
  this.searchSubscription?.unsubscribe();
359
+ this.apiSubscription?.unsubscribe();
293
360
  }
294
361
 
295
362
  // =====================================================
@@ -301,15 +368,21 @@ export class JDropdownSelectComponent
301
368
  * @param value Selected option value or `null`.
302
369
  */
303
370
  writeValue(value: unknown): void {
371
+ if (!optionValuesEqual(this.selectedValue, value)) {
372
+ if (
373
+ value == null ||
374
+ !this.resolvedSelectedOption ||
375
+ !optionValuesEqual(this.resolvedSelectedOption.value, value)
376
+ ) {
377
+ this.resolvedSelectedOption = null;
378
+ }
379
+ }
380
+
304
381
  this.selectedValue = value;
382
+ this.updateSelectedLabel();
305
383
 
306
- if (this.internalOptions.length > 0) {
307
- this.updateSelectedLabel();
308
- } else {
309
- setTimeout(() => {
310
- this.updateSelectedLabel();
311
- this.cdr.detectChanges();
312
- });
384
+ if (value != null && this.type === 'searchable' && !this.findSelectedOption()) {
385
+ this.resolveSelectedOption();
313
386
  }
314
387
 
315
388
  this.cdr.markForCheck();
@@ -344,12 +417,7 @@ export class JDropdownSelectComponent
344
417
  if (this.options?.length > 0 && typeof this.options[0] !== 'object') {
345
418
  this.internalOptions = mapPrimitiveOptions(this.options);
346
419
  } else if (this.options?.length > 0) {
347
- this.internalOptions = mapObjectOptions(
348
- this.options as Record<string, unknown>[],
349
- this.optionLabel,
350
- this.optionValue,
351
- this.labelSeparator,
352
- );
420
+ this.internalOptions = this.mapOptions(this.options as Record<string, unknown>[]);
353
421
  }
354
422
 
355
423
  this.filteredOptions = [...this.internalOptions];
@@ -357,6 +425,26 @@ export class JDropdownSelectComponent
357
425
  this.cdr.detectChanges();
358
426
  }
359
427
 
428
+ /** Maps raw API/static objects to normalized panel options. */
429
+ private mapOptions(options: Record<string, unknown>[]): DropdownOption[] {
430
+ const mapped = mapObjectOptions(
431
+ options,
432
+ this.optionLabel,
433
+ this.optionValue,
434
+ this.labelSeparator,
435
+ this.optionLabelFn ? undefined : this.optionLabelTemplate || undefined,
436
+ );
437
+
438
+ if (!this.optionLabelFn) {
439
+ return mapped;
440
+ }
441
+
442
+ return mapped.map((option) => ({
443
+ ...option,
444
+ text: this.optionLabelFn!(option.original as Record<string, unknown>),
445
+ }));
446
+ }
447
+
360
448
  /**
361
449
  * Selects an option, updates the form model and closes the panel.
362
450
  * @param option Normalized option from the panel list.
@@ -364,6 +452,7 @@ export class JDropdownSelectComponent
364
452
  selectOption(option: DropdownOption): void {
365
453
  this.selectedValue = option.value;
366
454
  this.selectedLabel = option.text;
455
+ this.resolvedSelectedOption = option;
367
456
  this.onChange(this.selectedValue);
368
457
  this.selectionChange.emit(option.original ?? option.value);
369
458
  this.closePanel();
@@ -375,11 +464,21 @@ export class JDropdownSelectComponent
375
464
  */
376
465
  clearSelection(event: Event): void {
377
466
  event.stopPropagation();
467
+ this.resolvedSelectedOption = null;
378
468
  this.writeValue(null);
379
469
  this.onChange(null);
380
470
  this.selectionChange.emit(null);
381
471
  }
382
472
 
473
+ /** Whether an option value matches the current selection. */
474
+ isOptionSelected(value: unknown): boolean {
475
+ return optionValuesEqual(this.selectedValue, value);
476
+ }
477
+
478
+ private findSelectedOption(): DropdownOption | undefined {
479
+ return this.internalOptions.find((option) => this.isOptionSelected(option.value));
480
+ }
481
+
383
482
  /**
384
483
  * Syncs `selectedLabel` with `selectedValue` and `internalOptions`.
385
484
  */
@@ -389,8 +488,62 @@ export class JDropdownSelectComponent
389
488
  return;
390
489
  }
391
490
 
392
- const selectedOption = this.internalOptions.find((option) => option.value === this.selectedValue);
393
- this.selectedLabel = selectedOption ? selectedOption.text : this.placeholder;
491
+ const selectedOption = this.findSelectedOption();
492
+ if (selectedOption) {
493
+ this.selectedLabel = selectedOption.text;
494
+ this.resolvedSelectedOption = selectedOption;
495
+ return;
496
+ }
497
+
498
+ if (
499
+ this.resolvedSelectedOption &&
500
+ optionValuesEqual(this.resolvedSelectedOption.value, this.selectedValue)
501
+ ) {
502
+ this.selectedLabel = this.resolvedSelectedOption.text;
503
+ this.ensureSelectedOptionPinned();
504
+ return;
505
+ }
506
+
507
+ if (this.type === 'searchable' && this.resolveSelected) {
508
+ this.resolveSelectedOption();
509
+ }
510
+
511
+ if (!this.selectedLabel || this.selectedLabel === this.placeholder) {
512
+ this.selectedLabel = this.placeholder;
513
+ }
514
+ }
515
+
516
+ /** Keeps a resolved selection visible even if it is not in the current page. */
517
+ private ensureSelectedOptionPinned(): void {
518
+ if (!this.resolvedSelectedOption) {
519
+ return;
520
+ }
521
+
522
+ const exists = this.internalOptions.some((option) =>
523
+ optionValuesEqual(option.value, this.resolvedSelectedOption!.value),
524
+ );
525
+
526
+ if (!exists) {
527
+ const leading = this.showAllOption ? [{ value: null, text: 'TODOS' }] : [];
528
+ this.internalOptions = mergeDropdownOptions(
529
+ this.internalOptions,
530
+ [this.resolvedSelectedOption],
531
+ leading,
532
+ );
533
+ this.filteredOptions = [...this.internalOptions];
534
+ }
535
+ }
536
+
537
+ private applyResolvedRecord(record: Record<string, unknown>): void {
538
+ const [option] = this.mapOptions([record]);
539
+ if (!option) {
540
+ return;
541
+ }
542
+
543
+ this.resolvedSelectedOption = option;
544
+ this.selectedLabel = option.text;
545
+ this.ensureSelectedOptionPinned();
546
+ this.cdr.detectChanges();
394
547
  }
395
548
 
396
549
  /**
@@ -410,17 +563,209 @@ export class JDropdownSelectComponent
410
563
 
411
564
  /**
412
565
  * Loads options from the CRUD API (searchable mode).
566
+ * @param reset When `true`, restarts from page 1 and replaces the list.
413
567
  */
414
- loadData(): void {
568
+ loadData(reset = false): void {
415
569
  if (!this.endpoint || !this.genericService) {
416
570
  return;
417
571
  }
418
572
 
419
- this.isLoading = true;
573
+ if (reset) {
574
+ this.currentPage = 1;
575
+ this.totalPages = 1;
576
+ this.hasMorePages = false;
577
+ this.apiSubscription?.unsubscribe();
578
+ } else if (!this.hasMorePages || this.isLoadingMore) {
579
+ return;
580
+ }
581
+
582
+ if (reset) {
583
+ this.isLoading = true;
584
+ } else {
585
+ this.isLoadingMore = true;
586
+ }
587
+
588
+ const params = this.buildListParams(this.currentPage);
589
+
590
+ this.apiSubscription = this.genericService
591
+ .findAll<Record<string, unknown>>({
592
+ endpoint: this._finalEndpoint,
593
+ params,
594
+ })
595
+ .subscribe({
596
+ next: (response) => {
597
+ const data = extractOptionsFromResponse(
598
+ response.data as Record<string, unknown>,
599
+ this.endpoint,
600
+ this.responseKey || undefined,
601
+ this.dataPath || undefined,
602
+ ) as Record<string, unknown>[];
603
+
604
+ const mapped = this.mapOptions(data);
605
+ const pagination = readDropdownPaginationMeta(response.meta);
606
+ this.currentPage = pagination.currentPage;
607
+ this.totalPages = pagination.totalPages;
608
+ this.hasMorePages = this.infiniteScroll && pagination.hasMore;
609
+
610
+ const leading = this.showAllOption ? [{ value: null, text: 'TODOS' as const }] : [];
611
+
612
+ if (reset) {
613
+ this.options = data;
614
+ this.internalOptions = mergeDropdownOptions([], mapped, leading);
615
+ } else {
616
+ this.options = [...(this.options as Record<string, unknown>[]), ...data];
617
+ this.internalOptions = mergeDropdownOptions(this.internalOptions, mapped, leading);
618
+ }
619
+
620
+ this.filteredOptions = [...this.internalOptions];
621
+ this.fullData.emit(this.options);
622
+ this.isLoading = false;
623
+ this.isLoadingMore = false;
624
+ this.ensureSelectedOptionPinned();
625
+ this.updateSelectedLabel();
626
+
627
+ if (this.selectedValue != null && !this.findSelectedOption()) {
628
+ this.resolveSelectedOption();
629
+ }
630
+
631
+ if (this.isColumnSelectorOpen) {
632
+ this.updateDropdownPosition();
633
+ }
634
+
635
+ this.cdr.detectChanges();
636
+ },
637
+ error: (error) => {
638
+ console.error('Error fetching data:', error);
639
+ this.isLoading = false;
640
+ this.isLoadingMore = false;
641
+ this.cdr.detectChanges();
642
+ },
643
+ });
644
+ }
645
+
646
+ /** Loads the next API page and appends options. */
647
+ loadMore(): void {
648
+ if (
649
+ this.type !== 'searchable' ||
650
+ !this.infiniteScroll ||
651
+ !this.hasMorePages ||
652
+ this.isLoading ||
653
+ this.isLoadingMore
654
+ ) {
655
+ return;
656
+ }
657
+
658
+ this.currentPage += 1;
659
+ this.loadData(false);
660
+ }
661
+
662
+ /** Infinite scroll handler for the options list. */
663
+ onOptionsListScroll(event: Event): void {
664
+ if (this.type !== 'searchable' || !this.infiniteScroll || !this.hasMorePages) {
665
+ return;
666
+ }
667
+
668
+ const element = event.target as HTMLElement;
669
+ const threshold = 48;
420
670
 
671
+ if (element.scrollTop + element.clientHeight >= element.scrollHeight - threshold) {
672
+ this.loadMore();
673
+ }
674
+ }
675
+
676
+ /** Fetches the selected row when editing and the value is outside loaded pages. */
677
+ private resolveSelectedOption(): void {
678
+ if (
679
+ !this.resolveSelected ||
680
+ this.selectedValue == null ||
681
+ this.type !== 'searchable' ||
682
+ !this.genericService ||
683
+ !this.endpoint ||
684
+ this.isResolvingSelected ||
685
+ this.findSelectedOption()
686
+ ) {
687
+ return;
688
+ }
689
+
690
+ this.isResolvingSelected = true;
691
+
692
+ if (
693
+ canResolveSelectedByFindOne(
694
+ this.endpoint,
695
+ this.dataPath,
696
+ this.responseKey,
697
+ this.selectedValue,
698
+ )
699
+ ) {
700
+ this.genericService
701
+ .findOne<Record<string, unknown>>({
702
+ endpoint: this._finalEndpoint,
703
+ id: Number(this.selectedValue),
704
+ })
705
+ .subscribe({
706
+ next: (record) => {
707
+ this.isResolvingSelected = false;
708
+ if (record) {
709
+ this.applyResolvedRecord(record);
710
+ } else {
711
+ this.resolveSelectedByFilter();
712
+ }
713
+ },
714
+ error: () => {
715
+ this.isResolvingSelected = false;
716
+ this.resolveSelectedByFilter();
717
+ },
718
+ });
719
+ return;
720
+ }
721
+
722
+ this.resolveSelectedByFilter();
723
+ }
724
+
725
+ private resolveSelectedByFilter(): void {
726
+ if (!this.genericService || this.selectedValue == null) {
727
+ this.isResolvingSelected = false;
728
+ return;
729
+ }
730
+
731
+ const filterKey =
732
+ this.resolveSelectedFilterKey.trim() || getOptionValueFilterKey(this.optionValue);
733
+ const params = this.buildListParams(1, {
734
+ [`filter[${filterKey}]`]: this.selectedValue,
735
+ });
736
+ params['limit'] = 1;
737
+
738
+ this.genericService
739
+ .findAll<Record<string, unknown>>({
740
+ endpoint: this._finalEndpoint,
741
+ params,
742
+ })
743
+ .subscribe({
744
+ next: (response) => {
745
+ this.isResolvingSelected = false;
746
+ const data = extractOptionsFromResponse(
747
+ response.data as Record<string, unknown>,
748
+ this.endpoint,
749
+ this.responseKey || undefined,
750
+ this.dataPath || undefined,
751
+ ) as Record<string, unknown>[];
752
+
753
+ if (data[0]) {
754
+ this.applyResolvedRecord(data[0]);
755
+ }
756
+ },
757
+ error: () => {
758
+ this.isResolvingSelected = false;
759
+ },
760
+ });
761
+ }
762
+
763
+ private buildListParams(page: number, extra: Record<string, unknown> = {}): Record<string, unknown> {
421
764
  const params: Record<string, unknown> = {
422
765
  sortOrder: this.sort,
423
766
  limit: this.limit,
767
+ page,
768
+ ...extra,
424
769
  };
425
770
 
426
771
  Object.keys(this.defaultFilters).forEach((key) => {
@@ -433,45 +778,7 @@ export class JDropdownSelectComponent
433
778
  params['searchFields'] = [...optionLabels, ...this.searchFields];
434
779
  }
435
780
 
436
- this.genericService.findAll<Record<string, unknown>>({
437
- endpoint: this._finalEndpoint,
438
- params,
439
- }).subscribe({
440
- next: (response) => {
441
- const data = (response.data[this.mainEndpoint] as Record<string, unknown>[]) || [];
442
- this.options = data;
443
-
444
- if (this.showAllOption) {
445
- this.internalOptions = [{ value: null, text: 'TODOS' }];
446
- this.internalOptions.push(
447
- ...mapObjectOptions(data, this.optionLabel, this.optionValue, this.labelSeparator),
448
- );
449
- } else {
450
- this.internalOptions = mapObjectOptions(
451
- data,
452
- this.optionLabel,
453
- this.optionValue,
454
- this.labelSeparator,
455
- );
456
- }
457
-
458
- this.filteredOptions = [...this.internalOptions];
459
- this.fullData.emit(this.options);
460
- this.isLoading = false;
461
- this.updateSelectedLabel();
462
-
463
- if (this.isColumnSelectorOpen) {
464
- this.updateDropdownPosition();
465
- }
466
-
467
- this.cdr.detectChanges();
468
- },
469
- error: (error) => {
470
- console.error('Error fetching data:', error);
471
- this.isLoading = false;
472
- this.cdr.detectChanges();
473
- },
474
- });
781
+ return params;
475
782
  }
476
783
 
477
784
  /**
@@ -513,11 +820,11 @@ export class JDropdownSelectComponent
513
820
  this.updateDropdownPosition();
514
821
 
515
822
  if (this.type === 'searchable' && this.loadOpen) {
516
- this.loadData();
823
+ this.loadData(true);
517
824
  }
518
825
 
519
826
  if (this.type === 'searchable' && !this.loadOnInit && this.shouldTriggerLoad()) {
520
- this.loadData();
827
+ this.loadData(true);
521
828
  }
522
829
 
523
830
  return;