@afeefa/vue-app 0.0.344 → 0.0.346

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.344
1
+ 0.0.346
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@afeefa/vue-app",
3
- "version": "0.0.344",
3
+ "version": "0.0.346",
4
4
  "description": "",
5
5
  "author": "Afeefa Kollektiv <kollektiv@afeefa.de>",
6
6
  "license": "MIT",
@@ -1,6 +1,7 @@
1
1
  import { NextRouteFilterSource } from '@a-vue/components/list/NextRouteFilterSource'
2
2
  import { AlertEvent } from '@a-vue/events'
3
3
  import { eventBus } from '@a-vue/plugins/event-bus/EventBus'
4
+ import { StartFilterStorage } from '@a-vue/services/start-filter/StartFilterStorage'
4
5
  import { ListViewModel } from '@afeefa/api-resources-client'
5
6
 
6
7
  import { ApiAction } from './ApiAction'
@@ -10,7 +11,18 @@ export class ListAction extends ApiAction {
10
11
  return this.execute()
11
12
  }
12
13
 
13
- initFiltersForRoute (route) {
14
+ initFiltersForRoute (route, { startFilterKey } = {}) {
15
+ // Startfilter anwenden, wenn die URL keine Filter vorgibt: synthetisches
16
+ // Route-Objekt mit der gespeicherten Query (kein Router-Eingriff — die
17
+ // Liste pusht die Filter anschließend selbst in die URL). Bei vorhandener
18
+ // Session-History bleibt die injizierte Query wirkungslos (History gewinnt).
19
+ if (startFilterKey && !Object.keys(route.query).length) {
20
+ const startFilterQuery = StartFilterStorage.load(startFilterKey)
21
+ if (startFilterQuery) {
22
+ route = { ...route, query: startFilterQuery }
23
+ }
24
+ }
25
+
14
26
  const request = new ListViewModel(this)
15
27
  // read from next route query string, but do not push
16
28
  // list component will be init with used_filters
@@ -123,7 +123,6 @@
123
123
  v-if="activeTab === 'search'"
124
124
  ref="list"
125
125
  :items="displayItems"
126
- :stickyCount="isDynamic ? topSpecialCount : 0"
127
126
  :selection="activeSelection"
128
127
  :getTitle="getTitle"
129
128
  :getSubtitle="getSubtitle"
@@ -422,14 +421,14 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
422
421
  // wenn die ganze Liste auf einer Seite liegt — sonst (Pagination greift,
423
422
  // `hasMore`) würden sie zwischen den bisher geladenen Treffern und den noch
424
423
  // nachkommenden Seiten hängen, also faktisch mitten in der Liste. In dem Fall
425
- // ziehen wir alle Sonder-Items nach oben (sticky), damit sie sichtbar bleiben.
424
+ // ziehen wir alle Sonder-Items nach oben.
426
425
  get collapseSpecialToTop () {
427
426
  return this.isDynamic && this.hasMore
428
427
  }
429
428
 
430
- // Was die Liste anzeigt: Sonder-Items oben (vor den Treffern, bei dynamischer
431
- // Liste angepinnt), dann die Treffer, dann Sonder-Items unten — Letzteres nur,
432
- // solange die Liste auf einer Seite passt (§5, siehe collapseSpecialToTop).
429
+ // Was die Liste anzeigt: Sonder-Items oben (vor den Treffern), dann die
430
+ // Treffer, dann Sonder-Items unten — Letzteres nur, solange die Liste auf
431
+ // eine Seite passt (§5, siehe collapseSpecialToTop).
433
432
  get displayItems () {
434
433
  if (this.collapseSpecialToTop) {
435
434
  return [
@@ -445,15 +444,6 @@ export default class ASelect2 extends Mixins(ComponentWidthMixin, UsesPositionSe
445
444
  ]
446
445
  }
447
446
 
448
- // Anzahl der oben angepinnten Sonder-Items (für sticky-Rendering in Select2List).
449
- // Bei collapseSpecialToTop sind auch die unteren Sonder-Items mit oben.
450
- get topSpecialCount () {
451
- const top = this.matchingSpecialItems('top').length
452
- return this.collapseSpecialToTop
453
- ? top + this.matchingSpecialItems('bottom').length
454
- : top
455
- }
456
-
457
447
  // --- Laden (nur dynamisch) -----------------------------------------------
458
448
 
459
449
  async loadResults ({ reset = false } = {}) {
@@ -29,8 +29,7 @@
29
29
  :class="['row', 'row-' + index, {
30
30
  included: polarityOf(model) === 'include',
31
31
  excluded: polarityOf(model) === 'exclude',
32
- active: activeIndex === index,
33
- sticky: index < stickyCount
32
+ active: activeIndex === index
34
33
  }]"
35
34
  @click="onRowClick(model)"
36
35
  >
@@ -62,7 +61,12 @@
62
61
  class="flex-grow-1"
63
62
  style="min-width: 0;"
64
63
  >
65
- <div class="rowTitle">
64
+ <!-- Nativer Tooltip immer mit vollem Titel — lange Titel werden
65
+ per Ellipsis gekappt (und beim Hover vom nicht-Button überdeckt). -->
66
+ <div
67
+ class="rowTitle"
68
+ :title="title(model)"
69
+ >
66
70
  {{ title(model) }}
67
71
  </div>
68
72
  <div
@@ -144,10 +148,7 @@ import { Component, Vue, Watch } from '@a-vue'
144
148
  // Zustands — die Polarität (include/exclude) zeigt weiter der Nicht-Button.
145
149
  showCheckbox: false,
146
150
  isLoading: false,
147
- hasMore: false,
148
- // Anzahl der oben angepinnten Sonder-Items (§5): die ersten N Zeilen
149
- // bleiben beim Scrollen sichtbar (position: sticky).
150
- stickyCount: 0
151
+ hasMore: false
151
152
  }
152
153
  ]
153
154
  })
@@ -310,6 +311,9 @@ export default class Select2List extends Vue {
310
311
  min-width: 0;
311
312
  padding-right: 0;
312
313
  cursor: pointer;
314
+
315
+ // Anker für den absolut gelegten nicht-Button.
316
+ position: relative;
313
317
  }
314
318
 
315
319
  :deep(.a-table-row.included) {
@@ -336,9 +340,46 @@ export default class Select2List extends Vue {
336
340
 
337
341
  // Nicht-Button nur bei Zeilen-Hover sichtbar (auch der aktive). Der
338
342
  // Ausschluss-Zustand bleibt an der roten/durchgestrichenen Zeile erkennbar.
343
+ // Absolut über dem Textende statt als eigene Flex-Spalte: reserviert keine
344
+ // Breite (auch unsichtbar belegte er sonst Platz und kappte den Titel),
345
+ // sondern überdeckt den Titel nur während des Hovers.
339
346
 
340
347
  .excludeBtn {
341
348
  visibility: hidden;
349
+ position: absolute;
350
+ right: .5rem;
351
+ top: 50%;
352
+ transform: translateY(-50%);
353
+
354
+ // Liegt über dem Text — zwei Ränder heben ihn ab: innen eine dezente Linie
355
+ // (gegen den Zeilen-Hover-Hintergrund), außen ein 5px-Ring in der
356
+ // Hover-Hintergrundfarbe (#F4F4F4, ATableRow), der Luft zum überdeckten
357
+ // Text schafft (Button wirkt „freigestellt"). Ring als outline, NICHT als
358
+ // box-shadow — das globale `.v-btn { box-shadow: none !important }` würde
359
+ // einen Shadow-Ring schlucken. Die Border braucht !important, weil Vuetifys
360
+ // Farbklasse (.grey.lighten-3 bzw. .error) border-color per !important
361
+ // plattzieht.
362
+ border: 1px solid rgba(0, 0, 0, .1) !important;
363
+ outline: 5px solid #F4F4F4;
364
+
365
+ &.active {
366
+ border-color: transparent !important;
367
+ }
368
+
369
+ // Vuetifys x-small-Button bringt min-width 36px + breites Padding mit —
370
+ // über dem Text soll er so schmal wie möglich sein.
371
+
372
+ &.v-btn {
373
+ min-width: 0;
374
+ padding: 0 .25rem;
375
+ }
376
+ }
377
+
378
+ // Tastatur-aktive Zeile hat einen anderen Hintergrund (#EEEEFF, ATableRow) —
379
+ // der Freisteller-Ring zieht mit.
380
+
381
+ :deep(.a-table-row.active) .excludeBtn {
382
+ outline-color: #EEEEFF;
342
383
  }
343
384
 
344
385
  :deep(.a-table-row:hover) .excludeBtn {
@@ -360,16 +401,6 @@ export default class Select2List extends Vue {
360
401
  }
361
402
  }
362
403
 
363
- // Oben angepinnte Sonder-Items (§5): bleiben beim Scrollen sichtbar. Weißer
364
- // Hintergrund, damit durchscrollende Treffer nicht durchscheinen.
365
-
366
- :deep(.a-table-row.sticky) {
367
- position: sticky;
368
- top: 0;
369
- z-index: 1;
370
- background: white;
371
- }
372
-
373
404
  // Endlos-Scroll-Ziel: braucht Höhe, damit der IntersectionObserver feuert.
374
405
 
375
406
  .sentinel {
@@ -0,0 +1,51 @@
1
+ // Speicherung des persönlichen Startfilters einer Liste im localStorage.
2
+ //
3
+ // Gespeichert wird die Filterbelegung in Query-Form (wie sie auch in der URL
4
+ // stünde), pro Liste über deren storageKey. Genutzt vom ListStartFilterButton
5
+ // (speichern/löschen/anwenden) und von ListAction.initFiltersForRoute
6
+ // (Anwenden beim Listen-Start).
7
+
8
+ const KEY_PREFIX = 'start-filters-'
9
+
10
+ export const StartFilterStorage = {
11
+ load (storageKey) {
12
+ if (!storageKey) {
13
+ return null
14
+ }
15
+ try {
16
+ const storageItem = localStorage.getItem(KEY_PREFIX + storageKey)
17
+ if (storageItem) {
18
+ return JSON.parse(storageItem)
19
+ }
20
+ } catch (error) {
21
+ console.warn('Failed to load start filter from localStorage:', error)
22
+ }
23
+ return null
24
+ },
25
+
26
+ save (storageKey, query) {
27
+ if (!storageKey) {
28
+ return
29
+ }
30
+ try {
31
+ localStorage.setItem(KEY_PREFIX + storageKey, JSON.stringify(query))
32
+ } catch (error) {
33
+ console.warn('Failed to save start filter to localStorage:', error)
34
+ }
35
+ },
36
+
37
+ clear (storageKey) {
38
+ if (!storageKey) {
39
+ return
40
+ }
41
+ try {
42
+ localStorage.removeItem(KEY_PREFIX + storageKey)
43
+ } catch (error) {
44
+ console.warn('Failed to clear start filter from localStorage:', error)
45
+ }
46
+ },
47
+
48
+ has (storageKey) {
49
+ return this.load(storageKey) !== null
50
+ }
51
+ }
@@ -19,6 +19,7 @@ import HSeparator from './HSeparator'
19
19
  import ListColumnHeader from './list/ListColumnHeader'
20
20
  import ListColumnSelector from './list/ListColumnSelector'
21
21
  import ListConfig from './list/ListConfig'
22
+ import ListStartFilterButton from './list/ListStartFilterButton'
22
23
  import ListView from './list/ListView'
23
24
  import ModelCount from './model/ModelCount'
24
25
  import ModelIcon from './model/ModelIcon'
@@ -32,6 +33,7 @@ Vue.component('ListColumnHeader', ListColumnHeader)
32
33
  Vue.component('ListConfig', ListConfig)
33
34
  Vue.component('ListView', ListView)
34
35
  Vue.component('ListColumnSelector', ListColumnSelector)
36
+ Vue.component('ListStartFilterButton', ListStartFilterButton)
35
37
 
36
38
  Vue.component('EditPage', EditPage)
37
39
  Vue.component('EditFormButtons', EditFormButtons)
@@ -0,0 +1,123 @@
1
+ <template>
2
+ <div>
3
+ <!-- ohne gespeicherten Startfilter: Klick speichert direkt -->
4
+ <v-btn
5
+ v-if="!hasStartFilter"
6
+ class="startFilterButton"
7
+ title="Aktuelle Filter als Startfilter speichern"
8
+ small
9
+ @click="saveStartFilter"
10
+ >
11
+ <v-icon
12
+ color="#999999"
13
+ size="1rem"
14
+ class="mr-1"
15
+ >
16
+ {{ mdiPinOutline }}
17
+ </v-icon>
18
+ Filter
19
+ </v-btn>
20
+
21
+ <!-- mit gespeichertem Startfilter: Kontext-Menü -->
22
+ <a-context-menu v-else>
23
+ <template #activator>
24
+ <v-btn
25
+ class="startFilterButton"
26
+ title="Startfilter gespeichert"
27
+ small
28
+ >
29
+ <v-icon
30
+ color="#999999"
31
+ size="1rem"
32
+ class="mr-1"
33
+ >
34
+ {{ mdiPin }}
35
+ </v-icon>
36
+ Filter
37
+ </v-btn>
38
+ </template>
39
+
40
+ <a-context-menu-item @click="applyStartFilter">
41
+ <v-icon>
42
+ {{ mdiPin }}
43
+ </v-icon>
44
+ Liste auf Startfilter setzen
45
+ </a-context-menu-item>
46
+
47
+ <a-context-menu-item @click="saveStartFilter">
48
+ <v-icon>
49
+ {{ mdiContentSaveOutline }}
50
+ </v-icon>
51
+ Startfilter ersetzen
52
+ </a-context-menu-item>
53
+
54
+ <a-context-menu-item @click="removeStartFilter">
55
+ <v-icon>
56
+ {{ mdiTrashCanOutline }}
57
+ </v-icon>
58
+ Startfilter löschen
59
+ </a-context-menu-item>
60
+ </a-context-menu>
61
+ </div>
62
+ </template>
63
+
64
+ <script>
65
+ import { Component, Vue } from '@a-vue'
66
+ import { StartFilterStorage } from '@a-vue/services/start-filter/StartFilterStorage'
67
+ import { mdiContentSaveOutline, mdiPin, mdiPinOutline, mdiTrashCanOutline } from '@mdi/js'
68
+
69
+ // Filter, die nicht zum Startfilter gehören:
70
+ // Seitenzahl und Suchfeld (zählen auch in der Filterleiste nicht als gesetzte Filter)
71
+ const EXCLUDED_FILTERS = ['page', 'q', 'qfield']
72
+
73
+ @Component({
74
+ props: ['filters', 'storageKey']
75
+ })
76
+ export default class ListStartFilterButton extends Vue {
77
+ mdiPin = mdiPin
78
+ mdiPinOutline = mdiPinOutline
79
+ mdiContentSaveOutline = mdiContentSaveOutline
80
+ mdiTrashCanOutline = mdiTrashCanOutline
81
+
82
+ hasStartFilter = false
83
+
84
+ created () {
85
+ this.hasStartFilter = StartFilterStorage.has(this.storageKey)
86
+ }
87
+
88
+ saveStartFilter () {
89
+ StartFilterStorage.save(this.storageKey, this.getCurrentFilterQuery())
90
+ this.hasStartFilter = true
91
+ }
92
+
93
+ /**
94
+ * Wendet den gespeicherten Startfilter auf die lebende Ansicht an:
95
+ * Query pushen, der $route.query-Watcher der Liste übernimmt den Rest
96
+ * (Filter ohne Query-Wert fallen auf Default zurück, inkl. Suche/Seite).
97
+ */
98
+ applyStartFilter () {
99
+ const query = StartFilterStorage.load(this.storageKey) || {}
100
+ // Duplicate-Push vermeiden (JSON-Vergleich ist key-Reihenfolge-sensitiv,
101
+ // daher zusätzlich catch gegen NavigationDuplicated)
102
+ if (JSON.stringify(this.$router.currentRoute.query) !== JSON.stringify(query)) {
103
+ this.$router.push({ query }).catch(() => {})
104
+ }
105
+ }
106
+
107
+ removeStartFilter () {
108
+ StartFilterStorage.clear(this.storageKey)
109
+ this.hasStartFilter = false
110
+ }
111
+
112
+ getCurrentFilterQuery () {
113
+ let query = {}
114
+ for (const filter of Object.values(this.filters)) {
115
+ if (EXCLUDED_FILTERS.includes(filter.name)) {
116
+ continue
117
+ }
118
+ query = { ...query, ...filter.toQuerySource() }
119
+ }
120
+ return query
121
+ }
122
+ }
123
+ </script>