@afeefa/vue-app 0.0.342 → 0.0.343

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 +1 @@
1
- 0.0.342
1
+ 0.0.343
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afeefa/vue-app",
3
- "version": "0.0.342",
3
+ "version": "0.0.343",
4
4
  "description": "",
5
5
  "author": "Afeefa Kollektiv <kollektiv@afeefa.de>",
6
6
  "license": "MIT",
@@ -14,6 +14,7 @@
14
14
  <v-select
15
15
  ref="field"
16
16
  class="field"
17
+ :title="committedTooltip"
17
18
  :value="committedModels"
18
19
  :items="committedModels"
19
20
  multiple
@@ -52,19 +53,26 @@
52
53
  </template>
53
54
  </v-select>
54
55
 
55
- <!-- z-index bewusst unter Vuetify-Dialogen (~200+), damit die
56
- Verwerfen-Rückfrage über dem Popup liegt (siehe close()). -->
56
+ <!-- z-index wie ASearchSelect (Overlay 299, Popup 300): über dem
57
+ flying-context (200), damit das Popup nicht dahinter verschwindet. -->
57
58
  <v-overlay
58
59
  :value="isOpen"
59
- :z-index="190"
60
+ :z-index="299"
60
61
  :opacity="0"
61
62
  />
62
63
 
63
64
  <!-- Ein-Popup: ein Panel mit (optionalem) Tabs + Suchfeld + Trefferliste -->
64
65
  <div :class="panelCssClass">
66
+ <!-- v-menu__content--active markiert das Popup als Stack-Teilnehmer für
67
+ Vuetify: dessen getMaxZIndex() (mixins/stackable) scannt nur Elemente
68
+ mit dieser bzw. der v-dialog-Klasse und liest deren z-index. Da das
69
+ Popup ein eigenes div (kein echtes v-menu) ist, „faken" wir die Klasse
70
+ hier — sie hat in Vuetify keine weitere Wirkung außer dem Stack-Scan.
71
+ Folge: ein aus dem Popup geöffneter Dialog bekommt max(200, 300)+2 =
72
+ 302 und liegt korrekt ÜBER dem Popup (wie ein Dialog aus EditModal). -->
65
73
  <div
66
74
  v-if="isOpen"
67
- :class="['popup elevation-6', 'open-' + openDirection]"
75
+ :class="['popup elevation-6 v-menu__content--active', 'open-' + openDirection]"
68
76
  :style="cwm_popupWidthStyle"
69
77
  >
70
78
  <!-- Linearer Lade-Spinner am oberen Popup-Rand (wie Select1), während
@@ -91,7 +99,6 @@
91
99
  @input="onSearchInput"
92
100
  @focus="activeTab = 'search'"
93
101
  @keydown.native.down.prevent="focusList"
94
- @keydown.native.esc.prevent="onSearchEsc"
95
102
  @keydown.native.enter.ctrl.prevent="applyIfMultiple"
96
103
  >
97
104
  <!-- Anzahl-Chip innen rechts im Suchfeld: schaltet auf die
@@ -123,6 +130,7 @@
123
130
  :getIcon="getIcon"
124
131
  :isItemDisabled="isItemDisabled"
125
132
  :allowExclude="allowExclude"
133
+ :showCheckbox="multiple && !behavesSingle"
126
134
  :isLoading="isLoading"
127
135
  :hasMore="hasMore"
128
136
  @toggle="onRowClick"
@@ -154,6 +162,7 @@
154
162
  :getSubtitle="getSubtitle"
155
163
  :getIcon="getIcon"
156
164
  :allowExclude="allowExclude"
165
+ :showCheckbox="multiple && !behavesSingle"
157
166
  @toggle="onRowClick"
158
167
  @exclude="onExcludeClick"
159
168
  >
@@ -167,19 +176,28 @@
167
176
  </template>
168
177
  </select2-list>
169
178
 
170
- <!-- Footer (nur Mehrfachauswahl): Übernehmen / Verwerfen -->
179
+ <!-- Footer (nur echte Mehrfachauswahl): Übernehmen / Verwerfen. Entfällt
180
+ bei behavesSingle (nur 1 Eintrag) — dann committet der Klick sofort.
181
+ Erst NACH dem Laden zeigen: vorher steht nicht fest, ob es bei 1 Eintrag
182
+ bleibt (behavesSingle) — sonst flackerte der Footer beim Öffnen. -->
171
183
  <div
172
- v-if="multiple"
184
+ v-if="showFooter"
173
185
  class="footer"
174
186
  >
187
+ <!-- Kompakt (passt auch ins schmale Popup, §1): Verwerfen = X-Icon,
188
+ Übernehmen = "OK". -->
175
189
  <v-btn
176
190
  x-small
177
191
  text
192
+ icon
178
193
  class="footerBtn"
179
194
  :tabindex="-1"
195
+ title="Verwerfen"
180
196
  @click="discardAndClose"
181
197
  >
182
- Verwerfen
198
+ <v-icon small>
199
+ $closeIcon
200
+ </v-icon>
183
201
  </v-btn>
184
202
  <v-btn
185
203
  x-small
@@ -187,13 +205,7 @@
187
205
  color="green white--text"
188
206
  @click="apply"
189
207
  >
190
- <v-icon
191
- left
192
- class="mr-1"
193
- >
194
- $checkIcon
195
- </v-icon>
196
- Übernehmen
208
+ OK
197
209
  </v-btn>
198
210
  </div>
199
211
  </div>
@@ -259,7 +271,7 @@ import Select2List from './select2/Select2List'
259
271
  // auf [popupMinWidth, popupMaxWidth] (px) begrenzt. null = keine Grenze.
260
272
  // Eigene Namen statt minWidth/maxWidth, weil ComponentWidthMixin maxWidth
261
273
  // bereits für die FELD-Breite belegt (Kollision vermeiden).
262
- // Default 200px Mindestbreite, damit schmale Filter-Felder kein Mini-Popup
274
+ // Default 150px Mindestbreite, damit schmale Filter-Felder kein Mini-Popup
263
275
  // bekommen.
264
276
  popupMinWidth: 150,
265
277
  popupMaxWidth: null
@@ -285,13 +297,16 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
285
297
  // Mehrfachauswahl-Tabs (Etappe 3)
286
298
  activeTab = 'search'
287
299
 
288
- // Dynamische Suche (Etappe 2)
289
- search = ''
300
+ // Dynamische Suche (Etappe 2). Leer = `null` (nicht ''), siehe onSearchInput.
301
+ search = null
290
302
  searchResults = []
291
303
  page = 1
292
304
  hasMore = false
293
305
  count = null
294
- // Wurde mindestens eine Seite geladen? Erst danach ist `hasMore` aussagekräftig
306
+ // Gesamtzahl der ungefilterten Liste (Trefferzahl ohne Suchbegriff) steuert
307
+ // showSearchField stabil, unabhängig vom aktuellen Such-`count`.
308
+ totalCount = null
309
+ // Wurde mindestens eine Seite geladen? Erst danach ist `totalCount` bekannt
295
310
  // (steuert, ob das Suchfeld bei einseitigen Listen entfällt — siehe showSearchField).
296
311
  loaded = false
297
312
  isLoading = false
@@ -351,18 +366,22 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
351
366
  // Liste passt nicht auf eine Seite (`hasMore`). Bei einer einseitigen Liste
352
367
  // (z.B. 8 Auftragsstatus) ist Suchen sinnlos → kein Suchfeld.
353
368
  //
354
- // Zwei Sonderfälle, die das Feld trotzdem sichtbar halten müssen:
355
- // - Schon getippt (`search`): nach dem Tippen kann die Liste auf 1 Seite
356
- // schrumpfen (`hasMore` false) das Feld muss bleiben, sonst lässt sich die
357
- // Suche nicht mehr ändern/leeren.
358
- // - Noch nichts geladen (`!loaded`): `hasMore` ist erst nach der ersten Seite
359
- // bekannt. Bis dahin Feld zeigen, statt es kurz zu verstecken und nachträglich
360
- // aufpoppen zu lassen.
369
+ // Maßstab ist die GESAMTzahl der ungefilterten Liste (`totalCount`), nicht der
370
+ // aktuelle Such-`count` und nicht `hasMore`: beide fallen während einer Suche
371
+ // (0 Treffer → hasMore false) und flackern beim Reload. `totalCount` bleibt
372
+ // stabil. Zwei Sonderfälle halten das Feld zusätzlich sichtbar:
373
+ // - Schon getippt (`search`): das Feld muss bleiben, sonst lässt sich die Suche
374
+ // nicht mehr ändern/leeren.
375
+ // - Noch nichts geladen (`!loaded`): `totalCount` ist erst nach der ersten Seite
376
+ // bekannt. Bis dahin Feld zeigen, statt es nachträglich aufpoppen zu lassen.
361
377
  get showSearchField () {
362
378
  if (!this.isDynamic || !this.hasSearch) {
363
379
  return false
364
380
  }
365
- return this.hasMore || !!this.search || !this.loaded
381
+ if (!this.loaded) {
382
+ return true
383
+ }
384
+ return !!this.search || this.totalCount > this.pageSize
366
385
  }
367
386
 
368
387
  // Such-Label wie bei ASearchSelect: "Suche <label>" (z.B. "Suche SpraMi").
@@ -469,6 +488,13 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
469
488
  this.searchResults = reset ? models : [...this.searchResults, ...models]
470
489
  this.count = meta.count_search != null ? meta.count_search : this.searchResults.length
471
490
  this.hasMore = this.searchResults.length < this.count
491
+ // Gesamtzahl OHNE Suchbegriff (aber mit aktiven Filtern) — steht in jeder
492
+ // Antwort als `count_filter`, auch während einer 0-Treffer-Suche. Steuert
493
+ // stabil, ob die Liste ein Suchfeld braucht (anders als `count`, das bei einer
494
+ // Suche auf die Trefferzahl fällt). Fallback auf count_all bzw. count.
495
+ this.totalCount = meta.count_filter != null
496
+ ? meta.count_filter
497
+ : (meta.count_all != null ? meta.count_all : this.count)
472
498
  this.loaded = true
473
499
 
474
500
  this.isLoading = false
@@ -502,7 +528,9 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
502
528
  // Eintrag zeigen. Bezieht Felder/Filter/Suche/Seite ein.
503
529
  cacheKey (page) {
504
530
  return buildRequestCacheKey(this.optionsRequest(), {
505
- q: this.search,
531
+ // Leere Suche als '' (nicht null) in den Key — sonst zeigt der Lookup auf
532
+ // einen anderen Eintrag als die main.js-Vorwärmung (die mit q:'' primt).
533
+ q: this.search || '',
506
534
  page,
507
535
  pageSize: this.pageSize
508
536
  })
@@ -510,7 +538,7 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
510
538
 
511
539
  // Suche + Seite als Filter (für Request und Cache-Key gleichermaßen).
512
540
  pageFilters (page) {
513
- return { q: this.search, page, page_size: this.pageSize }
541
+ return { q: this.search || '', page, page_size: this.pageSize }
514
542
  }
515
543
 
516
544
  // Baut die ListAction der Datenquelle und setzt Suche + Seite als Filter.
@@ -524,11 +552,28 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
524
552
 
525
553
  // --- Auswahl-Helfer ------------------------------------------------------
526
554
 
527
- // Die im Popup aktive Auswahl: Mehrfach Draft (Arbeitskopie), Einfach
528
- // committed (Klick committet sofort, kein Draft nötig). Liste + Chips lesen
529
- // hierüber, damit beide Modi denselben Code teilen.
555
+ // Mehrfachauswahl, die sich aber wie Single bedient: wenn nur EIN Eintrag
556
+ // wählbar ist (vollständig geladen, `displayItems.length === 1`), ist eine
557
+ // Draft-/Übernehmen-Bar sinnlos ein Klick wählt und schließt sofort, kein
558
+ // Footer. Das **Wertformat** bleibt am Prop `multiple` hängen (toOuter/fromOuter
559
+ // unverändert, sonst bräche die Filter-Hülle, die ein Array erwartet) — nur das
560
+ // Bedien-VERHALTEN kippt.
561
+ get behavesSingle () {
562
+ return this.multiple && !this.hasMore && this.displayItems.length === 1
563
+ }
564
+
565
+ // Footer (X/OK) nur bei echter Mehrfachauswahl UND erst, wenn die Items geladen
566
+ // sind: bei dynamischer Quelle vor dem ersten Laden noch nicht (da ist weder die
567
+ // Trefferliste noch behavesSingle entschieden) — bei fester Liste sofort.
568
+ get showFooter () {
569
+ return this.multiple && !this.behavesSingle && (!this.isDynamic || this.loaded)
570
+ }
571
+
572
+ // Die im Popup aktive Auswahl: Mehrfach → Draft (Arbeitskopie), Einfach (auch
573
+ // behavesSingle) → committed (Klick committet sofort, kein Draft nötig). Liste +
574
+ // Chips lesen hierüber, damit beide Modi denselben Code teilen.
530
575
  get activeSelection () {
531
- return this.multiple ? this.draft : this.committed
576
+ return this.multiple && !this.behavesSingle ? this.draft : this.committed
532
577
  }
533
578
 
534
579
  get sortedSelection () {
@@ -543,6 +588,18 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
543
588
  return this.sortedSelection.map(e => e.model)
544
589
  }
545
590
 
591
+ // Hover-Titel (natives title-Attribut) am Feld: ALLE gewählten Items, eins pro
592
+ // Zeile (das Feld selbst zeigt nur den ersten Namen + "+N"). Ausschluss mit
593
+ // "nicht " markiert, wie im Feld-Text. Leer → kein Tooltip.
594
+ get committedTooltip () {
595
+ if (!this.sortedSelection.length) {
596
+ return null
597
+ }
598
+ return this.sortedSelection
599
+ .map(e => (e.polarity === 'exclude' ? 'nicht ' : '') + this.itemTitle(e.model))
600
+ .join('\n')
601
+ }
602
+
546
603
  // Polarität aus der committed-Auswahl (Feld-Chips zeigen den bestätigten Stand,
547
604
  // nicht den Popup-Draft — anders als polarityOf, das den aktiven Draft liest).
548
605
  committedPolarityOf (model) {
@@ -576,9 +633,10 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
576
633
 
577
634
  // --- Tabs / Auswahl-Tab --------------------------------------------------
578
635
 
579
- // Es gibt etwas Auswählbares zum Umschalten (Auswahl-Chip sichtbar).
636
+ // Es gibt etwas Auswählbares zum Umschalten (Auswahl-Chip sichtbar). Nicht bei
637
+ // behavesSingle (nur 1 Eintrag, kein Mehrfach-Bedienmodell).
580
638
  get showTabs () {
581
- return this.multiple && this.activeSelection.length > 0
639
+ return this.multiple && !this.behavesSingle && this.activeSelection.length > 0
582
640
  }
583
641
 
584
642
  // Models der aktuellen Auswahl (für die Auswahl-Ansicht).
@@ -628,15 +686,17 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
628
686
  }
629
687
 
630
688
  // Schreibt die neue Auswahl: Mehrfach → in den Draft, Popup bleibt offen;
631
- // Einfach → sofort committen und schließen.
689
+ // Einfach (auch behavesSingle: nur 1 Eintrag) → sofort committen und schließen.
690
+ // Fokus zurück aufs Feld (wie der apply-Pfad bei Mehrfach), damit nach Klick/Enter/
691
+ // Space die Komponente fokussiert bleibt und nicht auf body fällt.
632
692
  commitSelection (next) {
633
- if (this.multiple) {
693
+ if (this.multiple && !this.behavesSingle) {
634
694
  this.draft = next
635
695
  return
636
696
  }
637
697
  this.committed = next
638
698
  this.emitChange()
639
- this.close()
699
+ this.close({ returnFocus: true })
640
700
  }
641
701
 
642
702
  withItem (selection, model, polarity) {
@@ -675,20 +735,13 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
675
735
  // Tippen im Suchfeld wechselt zurück in die Trefferliste (falls gerade die
676
736
  // Auswahl-Ansicht aktiv ist) — sonst sucht man, sieht aber die Auswahl.
677
737
  onSearchInput (value) {
678
- this.search = value
738
+ // Leere Suche ist intern `null` (a-text-field mit `clearable` emittiert beim
739
+ // ×-Klick ohnehin `null`). Alle Lese-Stellen nutzen `!this.search` (truthy),
740
+ // sind also null-fest; `q` im Request fällt damit weg statt leerem String.
741
+ this.search = value || null
679
742
  this.activeTab = 'search'
680
743
  }
681
744
 
682
- // Esc im Suchfeld: mit Text → Suche leeren (offen bleiben); leer → schließen.
683
- onSearchEsc () {
684
- if (this.search) {
685
- this.search = ''
686
- return
687
- }
688
- // Esc = bewusst abbrechen, aber im Feld bleiben (Fokus zurück).
689
- this.close({ returnFocus: true })
690
- }
691
-
692
745
  applyIfMultiple () {
693
746
  if (this.multiple) {
694
747
  this.apply()
@@ -790,13 +843,13 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
790
843
  // wirkt (§4). Die committed-Auswahl bleibt unberührt; nur der flüchtige Such-
791
844
  // String/die Trefferliste werden neu aufgebaut. Der cacheResults-Cache greift
792
845
  // davon unabhängig weiter (gleicher Key → aus dem Cache).
793
- // search='' kann den @Watch('search') auslösen; der würde einen zweiten,
846
+ // search=null kann den @Watch('search') auslösen; der würde einen zweiten,
794
847
  // debounced Request anstoßen. Daher den Watcher für diesen einen Reset
795
848
  // unterdrücken (nur wenn search sich wirklich ändert) und EINMAL direkt laden.
796
849
  if (this.isDynamic) {
797
850
  if (this.search) {
798
851
  this.suppressSearchWatch = true
799
- this.search = ''
852
+ this.search = null
800
853
  }
801
854
  // Fokus erst NACH dem ersten Laden setzen: vorher ist `showSearchField`
802
855
  // noch unentschieden (loaded=false ⇒ true), und bei einer einseitigen Liste
@@ -859,8 +912,10 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
859
912
  return
860
913
  }
861
914
 
862
- // Mehrfachauswahl mit ungespeicherten Draft-Änderungen → rückfragen.
863
- if (!skipDiscardCheck && this.multiple && this.isDirty) {
915
+ // Mehrfachauswahl mit ungespeicherten Draft-Änderungen → rückfragen. Nicht bei
916
+ // behavesSingle (nur 1 Item): der Klick committet dort sofort, es gibt keinen
917
+ // Draft zu verwerfen.
918
+ if (!skipDiscardCheck && this.multiple && !this.behavesSingle && this.isDirty) {
864
919
  const result = await this.$events.dispatch(new DialogEvent(DialogEvent.SHOW, {
865
920
  title: 'Änderungen verwerfen?',
866
921
  message: 'Die Auswahl wurde geändert. Sollen die Änderungen verworfen werden?',
@@ -970,7 +1025,17 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
970
1025
  }
971
1026
 
972
1027
  coe_cancelOnEsc () {
973
- // Esc = bewusst abbrechen, aber im Feld bleiben (Fokus zurück).
1028
+ // EINZIGER Esc-Pfad (window-keyup, kaskadierend). Mit Suchtext: nur leeren,
1029
+ // Popup bleibt offen (§14). Sonst: schließen, Fokus zurück. Früher gab es
1030
+ // zusätzlich einen @keydown.esc am Suchfeld → Esc leerte UND schloss, weil
1031
+ // keydown (Feld) und keyup (dieser Mixin) zwei getrennte Events sind.
1032
+ if (this.search) {
1033
+ // Suche leeren — der @Watch('search') lädt (debounced) neu. Das Suchfeld
1034
+ // bleibt dabei stabil sichtbar, weil showSearchField an `totalCount` hängt
1035
+ // (Gesamtzahl ohne Suchbegriff), nicht am wegfallenden Such-`count`.
1036
+ this.search = null
1037
+ return false // stop esc propagation
1038
+ }
974
1039
  this.close({ returnFocus: true })
975
1040
  return false // stop esc propagation
976
1041
  }
@@ -1053,7 +1118,15 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
1053
1118
  [class*=" select2Panel-"] {
1054
1119
  .popup {
1055
1120
  position: absolute;
1056
- z-index: 191;
1121
+ z-index: 300;
1122
+
1123
+ // Das Popup trägt die Fake-Klasse v-menu__content--active (Template), damit
1124
+ // Vuetify es im z-index-Stack mitzählt. Diese Klasse bringt aus Vuetify
1125
+ // jedoch `pointer-events: none` mit (VMenu.sass) — hier zurücksetzen, sonst
1126
+ // wäre das Popup unklickbar. Höhere Spezifität (.select2Panel .popup)
1127
+ // schlägt Vuetifys Single-Class-Regel.
1128
+ pointer-events: auto;
1129
+
1057
1130
  background: white;
1058
1131
  max-height: 40vh;
1059
1132
  overflow: hidden;
@@ -1121,7 +1194,7 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
1121
1194
  display: flex;
1122
1195
  justify-content: flex-end;
1123
1196
  gap: .5rem;
1124
- margin-top: 1.5rem;
1197
+ margin-top: .5rem;
1125
1198
  }
1126
1199
 
1127
1200
  // Größe zwischen x-small (20px) und small (28px): Vuetify bietet dazwischen
@@ -113,6 +113,7 @@
113
113
  <select2-chip
114
114
  v-for="filter in selectedFilters"
115
115
  :key="filter.name"
116
+ :tooltip="filter.title"
116
117
  @remove="resetFilter(filter.name)"
117
118
  >
118
119
  {{ filter.label }}: <b>{{ filter.value }}</b>
@@ -186,11 +187,11 @@ export default class ListFilterBar extends Vue {
186
187
  return Object.values(this.filters).filter(f => !f.hasDefaultValueSet()).length - minus
187
188
  }
188
189
 
189
- listFilterChanged ({ payload: { name, label, value } }) {
190
- this.setSelectedFilter(name, label, value)
190
+ listFilterChanged ({ payload: { name, label, value, title } }) {
191
+ this.setSelectedFilter(name, label, value, title)
191
192
  }
192
193
 
193
- setSelectedFilter (name, label, value) {
194
+ setSelectedFilter (name, label, value, title = null) {
194
195
  const hasValue = !this.filters[name].hasDefaultValueSet()
195
196
 
196
197
  if (!hasValue) {
@@ -199,12 +200,12 @@ export default class ListFilterBar extends Vue {
199
200
  if (this.selectedFilters.some(f => f.name === name)) {
200
201
  this.selectedFilters = this.selectedFilters.map(f => {
201
202
  if (f.name === name) {
202
- return { name, label, value }
203
+ return { name, label, value, title }
203
204
  }
204
205
  return f
205
206
  })
206
207
  } else {
207
- this.selectedFilters.push({ name, label, value })
208
+ this.selectedFilters.push({ name, label, value, title })
208
209
  }
209
210
  }
210
211
  }
@@ -6,14 +6,22 @@ import { ListFilterEvent } from '@a-vue/events'
6
6
  })
7
7
  export class ListFilterMixin extends Vue {
8
8
  displayValue = null
9
+ // Optionaler vollständiger Tooltip-Text für den Bar-Chip (Hover) — der Chip
10
+ // selbst zeigt den gekürzten `displayValue`. Filter, die das nicht setzen,
11
+ // bekommen keinen Tooltip. Mehrere Einträge bricht man mit \n um.
12
+ displayTitle = null
9
13
  name_ = null
10
14
 
15
+ // Nur auf displayValue lauschen — displayTitle wird immer GEMEINSAM mit
16
+ // displayValue gesetzt (s. ListFilterSelect2), ein eigener Watcher darauf würde
17
+ // dasselbe Event nur ein zweites Mal feuern. Der title wird hier mitgelesen.
11
18
  @Watch('displayValue')
12
19
  displayValueChanged () {
13
20
  this.$events.dispatch(new ListFilterEvent(ListFilterEvent.CHANGE, {
14
21
  name: this.name,
15
22
  label: this.label,
16
- value: this.displayValue
23
+ value: this.displayValue,
24
+ title: this.displayTitle
17
25
  }))
18
26
  }
19
27
 
@@ -6,7 +6,7 @@
6
6
  :placeholder="placeholder"
7
7
  :getIcon="getIcon"
8
8
  :specialItems="specialItems"
9
- multiple
9
+ :multiple="filter.multiple"
10
10
  :allowExclude="allowExclude"
11
11
  clearable
12
12
  v-bind="$attrs"
@@ -59,14 +59,15 @@ export default class ListFilterSelect2 extends Mixins(ListFilterMixin) {
59
59
  }
60
60
  this.selectValue = await this.loadSelectValue(this.filter.value)
61
61
  // Bar-Chip (eingeklappte Leiste) auch beim initialen Laden mit lesbaren
62
- // Namen befüllen — nicht erst nach einer Änderung.
62
+ // Namen befüllen — nicht erst nach einer Änderung. displayTitle ZUERST, damit
63
+ // es beim displayValue-Watcher (ListFilterMixin) schon aktuell ist.
64
+ this.displayTitle = this.toDisplayTitle(this.selectValue)
63
65
  this.displayValue = this.toDisplayString(this.selectValue)
64
66
  }
65
67
 
66
68
  // Token-Array (Filterwert, z.B. ['2', 'n-5']) → {model, polarity}[] für ASelect2.
67
- // Lädt die Vorauswahl-Models über die `get`-Action je ID (Muster aus
68
- // ListFilterSearchSelect.createLoadSelectedItemRequest, auf N IDs erweitert)
69
- // die List-Action der Resource hat i.d.R. keinen id-Filter, daher get statt list.
69
+ // Echte Models werden GEBÜNDELT in EINEM list-Request geladen (filter[id]=[…],
70
+ // §4) statt je ID einzeln; Sonder-Items lokal aus den Filter-Options aufgelöst.
70
71
  async loadSelectValue (value) {
71
72
  const tokens = Array.isArray(value) ? value : []
72
73
  if (!tokens.length) {
@@ -75,29 +76,39 @@ export default class ListFilterSelect2 extends Mixins(ListFilterMixin) {
75
76
 
76
77
  const specials = this.specialItems
77
78
  const entries = tokens.map(t => this.parseToken(t))
78
- const loaded = await Promise.all(
79
- entries.map(async entry => {
80
- // Sonder-Items sind Pseudo-Werte (z.B. "none") ohne echte Resource —
81
- // lokal aus den Filter-Options auflösen, nicht per get-Action laden.
79
+
80
+ // Echte IDs (keine Sonder-Items) gebündelt nachladen, dann je Token zuordnen.
81
+ const realIds = entries
82
+ .filter(e => !specials.some(s => String(s.id) === String(e.id)))
83
+ .map(e => e.id)
84
+ const modelsById = await this.loadModelsByIds(realIds)
85
+
86
+ return entries
87
+ .map(entry => {
82
88
  const special = specials.find(s => String(s.id) === String(entry.id))
83
89
  if (special) {
84
90
  return { model: special, polarity: entry.polarity }
85
91
  }
86
- const model = await this.loadModelById(entry.id)
92
+ const model = modelsById[String(entry.id)]
87
93
  return model ? { model, polarity: entry.polarity } : null
88
94
  })
89
- )
90
- return loaded.filter(Boolean)
95
+ .filter(e => e)
91
96
  }
92
97
 
93
- async loadModelById (id) {
94
- const request = this.filter.createOptionsRequest()
95
- const getAction = this.$apiResources.getAction({
96
- resourceType: request.getAction().getResource().getType(),
97
- actionName: 'get'
98
- })
99
- request.action(getAction).params({ id })
100
- return ApiAction.fromRequest(request).hideError().execute()
98
+ // Lädt mehrere Models in EINEM list-Request über den server-seitigen id-Filter
99
+ // (whereIn). Rückgabe: Map id→Model. Setzt voraus, dass die Resource den
100
+ // id-Filter kennt (z.B. AppointmentTypeResource, AssignmentContextResource).
101
+ async loadModelsByIds (ids) {
102
+ if (!ids.length) {
103
+ return {}
104
+ }
105
+ const request = this.filter.createOptionsRequest().addFilters({ id: ids })
106
+ const result = await ApiAction.fromRequest(request).hideError().execute()
107
+ const models = (result && result.models) || []
108
+ return models.reduce((map, model) => {
109
+ map[String(model.id)] = model
110
+ return map
111
+ }, {})
101
112
  }
102
113
 
103
114
  optionsRequest () {
@@ -147,13 +158,17 @@ export default class ListFilterSelect2 extends Mixins(ListFilterMixin) {
147
158
  }
148
159
  }
149
160
 
150
- // {model, polarity}[] (von ASelect2) Token-Array (Filterwert).
161
+ // {model, polarity} (Einfach) bzw. {model, polarity}[] (Mehrfach) von ASelect2
162
+ // → Token-Array (Filterwert). Einfachauswahl emittiert KEIN Array (api §1), daher
163
+ // hier auf Array normalisieren — die Hülle arbeitet intern einheitlich mit Arrays.
151
164
  onChange (value) {
152
165
  this.filterChangedFromInside = true
153
166
 
154
- const tokens = (value || []).map(e => this.toToken(e.model.id, e.polarity))
167
+ const selection = this.toSelectionArray(value)
168
+ const tokens = selection.map(e => this.toToken(e.model.id, e.polarity))
155
169
  this.filter.value = tokens.length ? tokens : null
156
- this.selectValue = value || []
170
+ this.selectValue = selection
171
+ this.displayTitle = this.toDisplayTitle(this.selectValue)
157
172
  this.displayValue = this.toDisplayString(this.selectValue)
158
173
 
159
174
  this.$nextTick(() => {
@@ -161,6 +176,15 @@ export default class ListFilterSelect2 extends Mixins(ListFilterMixin) {
161
176
  })
162
177
  }
163
178
 
179
+ // ASelect2-Außenwert → immer Array: Mehrfach ist schon ein Array, Einfach ein
180
+ // einzelnes {model, polarity} (oder null bei leerer Auswahl).
181
+ toSelectionArray (value) {
182
+ if (!value) {
183
+ return []
184
+ }
185
+ return Array.isArray(value) ? value : [value]
186
+ }
187
+
164
188
  // 'n-5' → {id: '5', polarity: 'exclude'}; '2' → {id: '2', polarity: 'include'}.
165
189
  parseToken (token) {
166
190
  const str = String(token)
@@ -187,5 +211,17 @@ export default class ListFilterSelect2 extends Mixins(ListFilterMixin) {
187
211
  const rest = selection.length - 1
188
212
  return rest > 0 ? `${truncated} +${rest}` : truncated
189
213
  }
214
+
215
+ // Vollständige Liste für den Bar-Chip-Tooltip (Hover): ALLE Items, eins pro
216
+ // Zeile, Ausschluss mit "nicht " markiert. Der Chip selbst zeigt nur die kurze
217
+ // toDisplayString-Form.
218
+ toDisplayTitle (selection) {
219
+ if (!selection || !selection.length) {
220
+ return null
221
+ }
222
+ return selection
223
+ .map(e => (e.polarity === 'exclude' ? 'nicht ' : '') + e.model.getTitle())
224
+ .join('\n')
225
+ }
190
226
  }
191
227
  </script>
@@ -5,7 +5,7 @@
5
5
  :close="closable && !disabled"
6
6
  :color="chipColor"
7
7
  :text-color="chipTextColor"
8
- :title="title"
8
+ :title="tooltip || title"
9
9
  @click:close="$emit('remove')"
10
10
  >
11
11
  <span class="label">
@@ -28,6 +28,9 @@ import { Component, Vue } from '@a-vue'
28
28
  'getTitle',
29
29
  'color',
30
30
  'textColor',
31
+ // Expliziter Hover-Tooltip (Vorrang vor dem aus `item` abgeleiteten Titel) —
32
+ // z.B. der Bar-Chip zeigt darin alle gewählten Items, eins pro Zeile.
33
+ 'tooltip',
31
34
  {
32
35
  polarity: 'include',
33
36
  disabled: false,
@@ -8,6 +8,7 @@
8
8
  @keydown.up.prevent="moveActive(-1)"
9
9
  @keydown.enter.ctrl.prevent="$emit('apply')"
10
10
  @keydown.enter.exact.prevent="activateEnter"
11
+ @keydown.space.exact.prevent="activateEnter"
11
12
  @keydown.enter.shift.prevent="activateExclude"
12
13
  @keydown.tab.shift.prevent="$emit('backtab')"
13
14
  >
@@ -40,6 +41,18 @@
40
41
  :selected="isSelected(model)"
41
42
  :polarity="polarityOf(model)"
42
43
  >
44
+ <!-- Checkbox vorn (nur Mehrfachauswahl): zeigt den gewählt-Zustand.
45
+ Reine Anzeige — der Klick auf die ganze Zeile toggelt. -->
46
+ <v-simple-checkbox
47
+ v-if="showCheckbox"
48
+ class="rowCheckbox"
49
+ :value="isSelected(model)"
50
+ :disabled="rowDisabled(model)"
51
+ dense
52
+ hide-details
53
+ @input="onRowClick(model)"
54
+ />
55
+
43
56
  <a-icon
44
57
  v-if="getIcon"
45
58
  :icon="getIcon(model)"
@@ -126,6 +139,10 @@ import { Component, Vue, Watch } from '@a-vue'
126
139
  'isItemDisabled',
127
140
  {
128
141
  allowExclude: false,
142
+ // Checkbox vorn pro Zeile: macht den Auswahl-Zustand bei Mehrfachauswahl
143
+ // klar sichtbar (besonders bei nur 1 Eintrag). Nur Anzeige des „gewählt"-
144
+ // Zustands — die Polarität (include/exclude) zeigt weiter der Nicht-Button.
145
+ showCheckbox: false,
129
146
  isLoading: false,
130
147
  hasMore: false,
131
148
  // Anzahl der oben angepinnten Sonder-Items (§5): die ersten N Zeilen
@@ -328,6 +345,21 @@ export default class Select2List extends Vue {
328
345
  visibility: visible;
329
346
  }
330
347
 
348
+ // Checkbox vorn (Mehrfachauswahl): kompakt, kein Eigen-Margin (die Zeile bringt
349
+ // den gap mit). flex-shrink 0, damit sie bei langen Titeln nicht gequetscht wird.
350
+
351
+ .rowCheckbox {
352
+ flex: 0 0 auto;
353
+ margin: 0;
354
+
355
+ // Vuetify setzt margin-right: 8px aufs Selection-Control — die Zeile bringt
356
+ // den gap schon mit, daher hier weg.
357
+
358
+ :deep(.v-input--selection-controls__input) {
359
+ margin-right: 0;
360
+ }
361
+ }
362
+
331
363
  // Oben angepinnte Sonder-Items (§5): bleiben beim Scrollen sichtbar. Weißer
332
364
  // Hintergrund, damit durchscrollende Treffer nicht durchscheinen.
333
365
 
@@ -4,7 +4,8 @@
4
4
  // vorkommen, nur einmal laden statt pro Komponente. Singleton im Stil von
5
5
  // positionService — kein Vue-Plugin, keine Vue.prototype-Erweiterung.
6
6
  //
7
- // Nur sinnvoll für Listen ohne Suchbegriff (die ASelect2 nur dann cached).
7
+ // Bei cacheResults=true läuft JEDE Anfrage über den Cache jede Seite, auch mit
8
+ // Suchbegriff (q/page sind Teil des Keys, siehe buildRequestCacheKey).
8
9
  // Zeitbasierte Invalidierung (Default 30 Min), keine aktive Invalidierung.
9
10
  // inFlight-Dedup verhindert parallele Doppel-Requests auf denselben Key.
10
11