@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.
- package/.afeefa/package/release/version.txt +1 -1
- package/package.json +1 -1
- package/src/api-resources/ListAction.js +13 -1
- package/src/components/ASelect2.vue +4 -14
- package/src/components/select2/Select2List.vue +48 -17
- package/src/services/start-filter/StartFilterStorage.js +51 -0
- package/src-admin/components/index.js +2 -0
- package/src-admin/components/list/ListStartFilterButton.vue +123 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
0.0.
|
|
1
|
+
0.0.346
|
package/package.json
CHANGED
|
@@ -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
|
|
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,
|
|
431
|
-
//
|
|
432
|
-
//
|
|
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
|
-
|
|
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>
|