@drax/crud-vue 3.18.1 → 3.20.0
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/package.json +6 -6
- package/src/components/Crud.vue +24 -7
- package/src/components/CrudActiveFilters.vue +19 -0
- package/src/components/CrudFieldRange.vue +212 -0
- package/src/components/CrudFilters.vue +19 -1
- package/src/components/CrudFormField.vue +0 -1
- package/src/components/CrudList.vue +5 -5
- package/src/components/CrudListGallery.vue +11 -5
- package/src/components/CrudListTable.vue +13 -5
- package/src/components/buttons/CrudSavedQueriesButton.vue +346 -0
- package/src/composables/UseCrud.ts +103 -5
- package/src/composables/UseCrudColumns.ts +11 -0
- package/src/composables/UseDynamicFilters.ts +3 -0
- package/src/composables/UseFilterIcon.ts +2 -0
- package/src/cruds/EntityCrud.ts +11 -2
- package/src/helpers/CrudRangeFilters.test.ts +52 -0
- package/src/helpers/CrudRangeFilters.ts +84 -0
- package/src/index.ts +4 -0
- package/src/stores/UseCrudStore.ts +4 -0
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
"publishConfig": {
|
|
4
4
|
"access": "public"
|
|
5
5
|
},
|
|
6
|
-
"version": "3.
|
|
6
|
+
"version": "3.20.0",
|
|
7
7
|
"type": "module",
|
|
8
8
|
"main": "./src/index.ts",
|
|
9
9
|
"module": "./src/index.ts",
|
|
@@ -24,10 +24,10 @@
|
|
|
24
24
|
"format": "prettier --write src/"
|
|
25
25
|
},
|
|
26
26
|
"dependencies": {
|
|
27
|
-
"@drax/common-front": "^3.
|
|
28
|
-
"@drax/crud-front": "^3.
|
|
29
|
-
"@drax/crud-share": "^3.
|
|
30
|
-
"@drax/media-vue": "^3.
|
|
27
|
+
"@drax/common-front": "^3.19.0",
|
|
28
|
+
"@drax/crud-front": "^3.20.0",
|
|
29
|
+
"@drax/crud-share": "^3.20.0",
|
|
30
|
+
"@drax/media-vue": "^3.20.0"
|
|
31
31
|
},
|
|
32
32
|
"peerDependencies": {
|
|
33
33
|
"pinia": "^3.0.4",
|
|
@@ -50,5 +50,5 @@
|
|
|
50
50
|
"vue-tsc": "^3.2.4",
|
|
51
51
|
"vuetify": "^3.11.8"
|
|
52
52
|
},
|
|
53
|
-
"gitHead": "
|
|
53
|
+
"gitHead": "6d4aea4d05133be679166e398ec6a3ae61503d9e"
|
|
54
54
|
}
|
package/src/components/Crud.vue
CHANGED
|
@@ -17,9 +17,10 @@ const {entity} = defineProps({
|
|
|
17
17
|
})
|
|
18
18
|
|
|
19
19
|
const {
|
|
20
|
-
|
|
20
|
+
onCreate, onEditAt, onDeleteAt, resetCrudStore,
|
|
21
21
|
operation, dialog, notify, message, doExport, doImport,
|
|
22
|
-
prepareFilters, prepareSort, form
|
|
22
|
+
prepareFilters, prepareSort, form,
|
|
23
|
+
onViewAt, navigateView, canNavigateItems, canNavigatePrev, canNavigateNext
|
|
23
24
|
} = useCrud(entity);
|
|
24
25
|
|
|
25
26
|
const {hasPermission} = useAuth()
|
|
@@ -70,11 +71,11 @@ watch(dialog, (value) => {
|
|
|
70
71
|
:is="listComponent"
|
|
71
72
|
:entity="entity"
|
|
72
73
|
@create="onCreate"
|
|
73
|
-
@edit="
|
|
74
|
-
@delete="
|
|
74
|
+
@edit="onEditAt"
|
|
75
|
+
@delete="onDeleteAt"
|
|
75
76
|
@export="doExport"
|
|
76
77
|
@import="doImport"
|
|
77
|
-
@view="
|
|
78
|
+
@view="onViewAt"
|
|
78
79
|
>
|
|
79
80
|
|
|
80
81
|
<template v-slot:toolbar-left>
|
|
@@ -120,8 +121,8 @@ watch(dialog, (value) => {
|
|
|
120
121
|
</template>
|
|
121
122
|
|
|
122
123
|
|
|
123
|
-
<template v-slot:item.actions="{item}">
|
|
124
|
-
<slot name="item.actions" v-bind="{item}">
|
|
124
|
+
<template v-slot:item.actions="{item, index}">
|
|
125
|
+
<slot name="item.actions" v-bind="{item, index}">
|
|
125
126
|
</slot>
|
|
126
127
|
</template>
|
|
127
128
|
|
|
@@ -144,6 +145,22 @@ watch(dialog, (value) => {
|
|
|
144
145
|
:operation="operation"
|
|
145
146
|
>
|
|
146
147
|
<template #toolbar-actions>
|
|
148
|
+
<v-btn
|
|
149
|
+
v-if="canNavigateItems"
|
|
150
|
+
icon="mdi-chevron-left"
|
|
151
|
+
variant="text"
|
|
152
|
+
:disabled="!canNavigatePrev"
|
|
153
|
+
@click="navigateView(-1)"
|
|
154
|
+
/>
|
|
155
|
+
|
|
156
|
+
<v-btn
|
|
157
|
+
v-if="canNavigateItems"
|
|
158
|
+
icon="mdi-chevron-right"
|
|
159
|
+
variant="text"
|
|
160
|
+
:disabled="!canNavigateNext"
|
|
161
|
+
@click="navigateView(1)"
|
|
162
|
+
/>
|
|
163
|
+
|
|
147
164
|
<crud-ai-button
|
|
148
165
|
v-if="entity.isAiAssistable && ['create', 'edit'].includes(operation) && hasPermission('ai:promptCrud')"
|
|
149
166
|
:entity="entity"
|
|
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
|
|
|
6
6
|
import { useFilterIcon } from '../composables/UseFilterIcon'
|
|
7
7
|
import CrudRefDisplay from "./CrudRefDisplay.vue";
|
|
8
8
|
import {formatDate} from "@drax/common-front"
|
|
9
|
+
import {isRangeOperator, normalizeDateRangeValue} from "../helpers/CrudRangeFilters";
|
|
9
10
|
|
|
10
11
|
const { t} = useI18n()
|
|
11
12
|
|
|
@@ -36,6 +37,13 @@ const activeFilters = computed<any[]>(() => {
|
|
|
36
37
|
return null
|
|
37
38
|
}
|
|
38
39
|
|
|
40
|
+
if (isRangeOperator(filterDef)) {
|
|
41
|
+
const rangeValue = normalizeDateRangeValue(filter.value)
|
|
42
|
+
if (!rangeValue.from && !rangeValue.to) {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
39
47
|
return {
|
|
40
48
|
...filterDef,
|
|
41
49
|
value: filter.value,
|
|
@@ -50,9 +58,20 @@ const getFilterLabel = (filter: any) => {
|
|
|
50
58
|
return label
|
|
51
59
|
}
|
|
52
60
|
|
|
61
|
+
const formatRangeDate = (value: string | Date) => {
|
|
62
|
+
return formatDate(value instanceof Date ? value.toISOString() : value)
|
|
63
|
+
}
|
|
64
|
+
|
|
53
65
|
|
|
54
66
|
|
|
55
67
|
const getFilterValue = (filter: any) => {
|
|
68
|
+
if (isRangeOperator(filter)) {
|
|
69
|
+
const rangeValue = normalizeDateRangeValue(filter.value)
|
|
70
|
+
const from = rangeValue.from ? formatRangeDate(rangeValue.from) : t('crud.from')
|
|
71
|
+
const to = rangeValue.to ? formatRangeDate(rangeValue.to) : t('crud.to')
|
|
72
|
+
return `${from} - ${to}`
|
|
73
|
+
}
|
|
74
|
+
|
|
56
75
|
switch (filter.type) {
|
|
57
76
|
case 'date':
|
|
58
77
|
return formatDate(filter.value)
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {computed, type PropType} from "vue";
|
|
3
|
+
import type {ValidationRule} from "vuetify";
|
|
4
|
+
import {VDateInput} from "vuetify/labs/VDateInput";
|
|
5
|
+
import type {IEntityCrud, IEntityCrudFilter} from "@drax/crud-share";
|
|
6
|
+
import {useI18n} from "vue-i18n";
|
|
7
|
+
import {normalizeDateRangeValue} from "../helpers/CrudRangeFilters";
|
|
8
|
+
|
|
9
|
+
const {t} = useI18n()
|
|
10
|
+
|
|
11
|
+
const valueModel = defineModel<any>({type: [Object], default: null})
|
|
12
|
+
|
|
13
|
+
const {
|
|
14
|
+
name,
|
|
15
|
+
label,
|
|
16
|
+
field,
|
|
17
|
+
readonly,
|
|
18
|
+
errorMessages,
|
|
19
|
+
rules,
|
|
20
|
+
density,
|
|
21
|
+
variant,
|
|
22
|
+
clearable,
|
|
23
|
+
hideDetails,
|
|
24
|
+
singleLine,
|
|
25
|
+
hint,
|
|
26
|
+
persistentHint,
|
|
27
|
+
placeholder,
|
|
28
|
+
persistentPlaceholder,
|
|
29
|
+
prependIcon,
|
|
30
|
+
appendIcon,
|
|
31
|
+
prependInnerIcon,
|
|
32
|
+
appendInnerIcon,
|
|
33
|
+
onInput
|
|
34
|
+
} = defineProps({
|
|
35
|
+
name: {type: String, required: true},
|
|
36
|
+
label: {type: String, required: true},
|
|
37
|
+
entity: {type: Object as PropType<IEntityCrud>, required: true},
|
|
38
|
+
field: {type: Object as PropType<IEntityCrudFilter>, required: true},
|
|
39
|
+
readonly: {type: Boolean, default: false},
|
|
40
|
+
hideDetails: {type: Boolean, default: false},
|
|
41
|
+
hint: {type: String, required: false},
|
|
42
|
+
persistentHint: {type: Boolean, default: false},
|
|
43
|
+
placeholder: {type: String, required: false},
|
|
44
|
+
persistentPlaceholder: {type: Boolean, default: false},
|
|
45
|
+
errorMessages: {type: Array as PropType<string[]>, default: null, required: false},
|
|
46
|
+
onInput: {type: Function as PropType<Function>, required: false},
|
|
47
|
+
rules: {type: Array as PropType<ValidationRule[]>, required: false},
|
|
48
|
+
density: {type: String as PropType<'comfortable' | 'compact' | 'default'>, default: 'default'},
|
|
49
|
+
variant: {
|
|
50
|
+
type: String as PropType<'underlined' | 'outlined' | 'filled' | 'solo' | 'solo-inverted' | 'solo-filled' | 'plain'>,
|
|
51
|
+
default: 'filled'
|
|
52
|
+
},
|
|
53
|
+
singleLine: {type: Boolean, default: false},
|
|
54
|
+
clearable: {type: Boolean, default: false},
|
|
55
|
+
prependIcon: {type: String, default: ''},
|
|
56
|
+
prependInnerIcon: {type: String, default: ''},
|
|
57
|
+
appendIcon: {type: String, default: ''},
|
|
58
|
+
appendInnerIcon: {type: String, default: ''},
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
const emit = defineEmits(['updateValue'])
|
|
62
|
+
|
|
63
|
+
const rangeValueModel = computed({
|
|
64
|
+
get() {
|
|
65
|
+
return normalizeDateRangeValue(valueModel.value)
|
|
66
|
+
},
|
|
67
|
+
set(value) {
|
|
68
|
+
valueModel.value = normalizeDateRangeValue(value)
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
function normalizeDateValue(value: any, endOfDay = false) {
|
|
73
|
+
if (!value) {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const date = value instanceof Date ? new Date(value) : new Date(value)
|
|
78
|
+
|
|
79
|
+
if (Number.isNaN(date.getTime())) {
|
|
80
|
+
return null
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (endOfDay) {
|
|
84
|
+
date.setHours(23, 59, 59, 0)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return date
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function updateRangeValue(bound: 'from' | 'to', rawValue: any) {
|
|
91
|
+
const nextValue = {
|
|
92
|
+
...normalizeDateRangeValue(rangeValueModel.value),
|
|
93
|
+
[bound]: normalizeDateValue(rawValue, bound === 'to' && !!field.endOfDay)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const fromDate = normalizeDateValue(nextValue.from)
|
|
97
|
+
const toDate = normalizeDateValue(nextValue.to)
|
|
98
|
+
|
|
99
|
+
if (fromDate && toDate && fromDate > toDate) {
|
|
100
|
+
if (bound === 'from') {
|
|
101
|
+
nextValue.to = normalizeDateValue(nextValue.from, !!field.endOfDay)
|
|
102
|
+
} else {
|
|
103
|
+
nextValue.from = normalizeDateValue(nextValue.to)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
rangeValueModel.value = nextValue
|
|
108
|
+
|
|
109
|
+
if(onInput && typeof onInput === 'function'){
|
|
110
|
+
onInput(nextValue)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
emit('updateValue')
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const rangeFromModel = computed({
|
|
117
|
+
get() {
|
|
118
|
+
return rangeValueModel.value.from
|
|
119
|
+
},
|
|
120
|
+
set(value) {
|
|
121
|
+
updateRangeValue('from', value)
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
const rangeToModel = computed({
|
|
126
|
+
get() {
|
|
127
|
+
return rangeValueModel.value.to
|
|
128
|
+
},
|
|
129
|
+
set(value) {
|
|
130
|
+
updateRangeValue('to', value)
|
|
131
|
+
}
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
const rangeFromMax = computed(() => {
|
|
135
|
+
return rangeValueModel.value.to ?? field.max
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
const rangeToMin = computed(() => {
|
|
139
|
+
return rangeValueModel.value.from ?? undefined
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const fromInnerIcon = computed(() => {
|
|
143
|
+
return prependInnerIcon || 'mdi-calendar-start'
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
const toInnerIcon = computed(() => {
|
|
147
|
+
return appendInnerIcon || 'mdi-calendar-end'
|
|
148
|
+
})
|
|
149
|
+
</script>
|
|
150
|
+
|
|
151
|
+
<template>
|
|
152
|
+
<v-row dense>
|
|
153
|
+
<v-col cols="12" sm="6">
|
|
154
|
+
<v-date-input
|
|
155
|
+
:name="`${name}_from`"
|
|
156
|
+
:label="`${label} ${t('crud.from')}`"
|
|
157
|
+
:hint="hint ?? field.hint"
|
|
158
|
+
:persistent-hint="persistentHint ?? field.persistentHint"
|
|
159
|
+
:placeholder="placeholder ?? field.placeholder"
|
|
160
|
+
:persistent-placeholder="persistentPlaceholder ?? field.persistentPlaceholder"
|
|
161
|
+
v-model="rangeFromModel"
|
|
162
|
+
:readonly="readonly"
|
|
163
|
+
:error-messages="errorMessages"
|
|
164
|
+
:rules="rules"
|
|
165
|
+
:density="density"
|
|
166
|
+
:variant="variant"
|
|
167
|
+
:clearable="clearable"
|
|
168
|
+
:hide-details="hideDetails"
|
|
169
|
+
:single-line="singleLine"
|
|
170
|
+
@click:clear="() => updateRangeValue('from', null)"
|
|
171
|
+
:prepend-icon="prependIcon"
|
|
172
|
+
:append-icon="appendIcon"
|
|
173
|
+
:prepend-inner-icon="fromInnerIcon"
|
|
174
|
+
:append-inner-icon="appendInnerIcon"
|
|
175
|
+
:max="rangeFromMax"
|
|
176
|
+
@input="onInput"
|
|
177
|
+
/>
|
|
178
|
+
</v-col>
|
|
179
|
+
|
|
180
|
+
<v-col cols="12" sm="6">
|
|
181
|
+
<v-date-input
|
|
182
|
+
:name="`${name}_to`"
|
|
183
|
+
:label="`${label} ${t('crud.to')}`"
|
|
184
|
+
:hint="hint ?? field.hint"
|
|
185
|
+
:persistent-hint="persistentHint ?? field.persistentHint"
|
|
186
|
+
:placeholder="placeholder ?? field.placeholder"
|
|
187
|
+
:persistent-placeholder="persistentPlaceholder ?? field.persistentPlaceholder"
|
|
188
|
+
v-model="rangeToModel"
|
|
189
|
+
:readonly="readonly"
|
|
190
|
+
:error-messages="errorMessages"
|
|
191
|
+
:rules="rules"
|
|
192
|
+
:density="density"
|
|
193
|
+
:variant="variant"
|
|
194
|
+
:clearable="clearable"
|
|
195
|
+
:hide-details="hideDetails"
|
|
196
|
+
:single-line="singleLine"
|
|
197
|
+
@click:clear="() => updateRangeValue('to', null)"
|
|
198
|
+
:prepend-icon="prependIcon"
|
|
199
|
+
:append-icon="appendIcon"
|
|
200
|
+
:prepend-inner-icon="toInnerIcon"
|
|
201
|
+
:append-inner-icon="appendInnerIcon"
|
|
202
|
+
:min="rangeToMin"
|
|
203
|
+
:max="field.max"
|
|
204
|
+
@input="onInput"
|
|
205
|
+
>
|
|
206
|
+
<template v-if="field.endOfDay && field.showEndOfDayChip !== false" v-slot:append-inner>
|
|
207
|
+
<v-chip size="small">23:59</v-chip>
|
|
208
|
+
</template>
|
|
209
|
+
</v-date-input>
|
|
210
|
+
</v-col>
|
|
211
|
+
</v-row>
|
|
212
|
+
</template>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import {computed, type PropType} from "vue";
|
|
3
3
|
import CrudFormField from "./CrudFormField.vue";
|
|
4
|
+
import CrudFieldRange from "./CrudFieldRange.vue";
|
|
4
5
|
import type {IEntityCrud, IEntityCrudFilter} from "@drax/crud-share";
|
|
5
6
|
import {useAuth} from "@drax/identity-vue";
|
|
6
7
|
import {useFilterIcon} from "../composables/UseFilterIcon";
|
|
@@ -18,6 +19,10 @@ const aFields = computed(() => {
|
|
|
18
19
|
return entity.filters.filter((field:IEntityCrudFilter) => !field.permission || hasPermission(field.permission))
|
|
19
20
|
})
|
|
20
21
|
|
|
22
|
+
function isRangeFilter(filter: IEntityCrudFilter) {
|
|
23
|
+
return filter.type === 'date' && filter.operator === 'range'
|
|
24
|
+
}
|
|
25
|
+
|
|
21
26
|
function filter() {
|
|
22
27
|
emit('applyFilter')
|
|
23
28
|
}
|
|
@@ -51,8 +56,21 @@ const emit = defineEmits(['applyFilter', 'clearFilter'])
|
|
|
51
56
|
>
|
|
52
57
|
|
|
53
58
|
<slot :name="`filter.${filter.name}`" v-bind="{filter, filterIndex: index}">
|
|
59
|
+
<crud-field-range
|
|
60
|
+
v-if="filter && valueModel[index] !== undefined && isRangeFilter(filter)"
|
|
61
|
+
:name="filter.name"
|
|
62
|
+
:label="filter.label"
|
|
63
|
+
:entity="entity"
|
|
64
|
+
:field="filter"
|
|
65
|
+
v-model="valueModel[index].value"
|
|
66
|
+
:clearable="true"
|
|
67
|
+
density="compact"
|
|
68
|
+
variant="outlined"
|
|
69
|
+
hide-details
|
|
70
|
+
@updateValue="onUpdateValue"
|
|
71
|
+
/>
|
|
54
72
|
<crud-form-field
|
|
55
|
-
v-if="filter && valueModel[index] !== undefined"
|
|
73
|
+
v-else-if="filter && valueModel[index] !== undefined"
|
|
56
74
|
:field="filter"
|
|
57
75
|
:entity="entity"
|
|
58
76
|
v-model="valueModel[index].value"
|
|
@@ -206,24 +206,24 @@ defineEmits(['import', 'export', 'create', 'update', 'delete', 'view', 'edit'])
|
|
|
206
206
|
</template>
|
|
207
207
|
|
|
208
208
|
|
|
209
|
-
<template v-slot:item.actions="{item}">
|
|
209
|
+
<template v-slot:item.actions="{item, index}">
|
|
210
210
|
|
|
211
|
-
<slot name="item.actions" v-bind="{item}">
|
|
211
|
+
<slot name="item.actions" v-bind="{item, index}">
|
|
212
212
|
</slot>
|
|
213
213
|
|
|
214
214
|
<crud-view-button
|
|
215
215
|
v-if="entity.isViewable && hasPermission(entity.permissions.view)"
|
|
216
|
-
@click="$emit('view', item)"
|
|
216
|
+
@click="$emit('view', item, index)"
|
|
217
217
|
/>
|
|
218
218
|
|
|
219
219
|
<crud-update-button
|
|
220
220
|
v-if="entity.isEditable && entity.isItemEditable(item) && hasPermission(entity.permissions?.update)"
|
|
221
|
-
@click="$emit('edit', item)"
|
|
221
|
+
@click="$emit('edit', item, index)"
|
|
222
222
|
/>
|
|
223
223
|
|
|
224
224
|
<crud-delete-button
|
|
225
225
|
v-if="entity.isDeletable && hasPermission(entity.permissions?.delete)"
|
|
226
|
-
@click="$emit('delete', item)"
|
|
226
|
+
@click="$emit('delete', item, index)"
|
|
227
227
|
/>
|
|
228
228
|
|
|
229
229
|
</template>
|
|
@@ -21,6 +21,7 @@ import {useCrudColumns} from "../composables/UseCrudColumns";
|
|
|
21
21
|
import CrudFiltersDynamic from "./CrudFiltersDynamic.vue";
|
|
22
22
|
import CrudFiltersAction from "./CrudFiltersAction.vue";
|
|
23
23
|
import CrudFilterButton from "./buttons/CrudFilterButton.vue";
|
|
24
|
+
import CrudSavedQueriesButton from "./buttons/CrudSavedQueriesButton.vue";
|
|
24
25
|
|
|
25
26
|
const {t, te} = useI18n()
|
|
26
27
|
const {hasPermission} = useAuth()
|
|
@@ -89,6 +90,11 @@ onMounted(() => {
|
|
|
89
90
|
:entity="entity"
|
|
90
91
|
/>
|
|
91
92
|
|
|
93
|
+
<crud-saved-queries-button
|
|
94
|
+
v-if="entity.isSavedQueriesEnabled"
|
|
95
|
+
:entity="entity"
|
|
96
|
+
/>
|
|
97
|
+
|
|
92
98
|
<slot name="toolbar">
|
|
93
99
|
</slot>
|
|
94
100
|
|
|
@@ -199,7 +205,7 @@ onMounted(() => {
|
|
|
199
205
|
|
|
200
206
|
<!-- GALLERY GRIDS -->
|
|
201
207
|
<v-row v-if="items.length > 0">
|
|
202
|
-
<v-col v-for="item in items" :key="item.id || item.uuid || item.name || Math.random()" cols="12" sm="6" md="4"
|
|
208
|
+
<v-col v-for="(item, index) in items" :key="item.id || item.uuid || item.name || Math.random()" cols="12" sm="6" md="4"
|
|
203
209
|
xl="3">
|
|
204
210
|
|
|
205
211
|
|
|
@@ -231,22 +237,22 @@ onMounted(() => {
|
|
|
231
237
|
<v-divider></v-divider>
|
|
232
238
|
|
|
233
239
|
<v-card-actions class="bg-grey-lighten-4 py-2 px-4 d-flex justify-end flex-wrap gap-2">
|
|
234
|
-
<slot name="item.actions" v-bind="{item}">
|
|
240
|
+
<slot name="item.actions" v-bind="{item, index}">
|
|
235
241
|
</slot>
|
|
236
242
|
|
|
237
243
|
<crud-view-button
|
|
238
244
|
v-if="entity.isViewable && hasPermission(entity.permissions.view)"
|
|
239
|
-
@click="$emit('view', item)"
|
|
245
|
+
@click="$emit('view', item, index)"
|
|
240
246
|
/>
|
|
241
247
|
|
|
242
248
|
<crud-update-button
|
|
243
249
|
v-if="entity.isEditable && entity.isItemEditable(item) && hasPermission(entity.permissions?.update)"
|
|
244
|
-
@click="$emit('edit', item)"
|
|
250
|
+
@click="$emit('edit', item, index)"
|
|
245
251
|
/>
|
|
246
252
|
|
|
247
253
|
<crud-delete-button
|
|
248
254
|
v-if="entity.isDeletable && hasPermission(entity.permissions?.delete)"
|
|
249
|
-
@click="$emit('delete', item)"
|
|
255
|
+
@click="$emit('delete', item, index)"
|
|
250
256
|
/>
|
|
251
257
|
</v-card-actions>
|
|
252
258
|
</v-card>
|
|
@@ -20,6 +20,7 @@ import { useCrudColumns } from "../composables/UseCrudColumns";
|
|
|
20
20
|
import CrudFiltersDynamic from "./CrudFiltersDynamic.vue";
|
|
21
21
|
import CrudFiltersAction from "./CrudFiltersAction.vue";
|
|
22
22
|
import CrudFilterButton from "./buttons/CrudFilterButton.vue";
|
|
23
|
+
import CrudSavedQueriesButton from "./buttons/CrudSavedQueriesButton.vue";
|
|
23
24
|
|
|
24
25
|
const {t, te} = useI18n()
|
|
25
26
|
const {hasPermission} = useAuth()
|
|
@@ -96,6 +97,12 @@ defineEmits(['import', 'export', 'create', 'update', 'delete', 'view', 'edit'])
|
|
|
96
97
|
|
|
97
98
|
</slot>
|
|
98
99
|
|
|
100
|
+
<crud-saved-queries-button
|
|
101
|
+
v-if="entity.isSavedQueriesEnabled"
|
|
102
|
+
:entity="entity"
|
|
103
|
+
/>
|
|
104
|
+
|
|
105
|
+
|
|
99
106
|
<crud-import-button
|
|
100
107
|
:entity="entity"
|
|
101
108
|
@import="(file:any, format:any) => $emit('import', file, format)"
|
|
@@ -119,6 +126,7 @@ defineEmits(['import', 'export', 'create', 'update', 'delete', 'view', 'edit'])
|
|
|
119
126
|
:entity="entity"
|
|
120
127
|
/>
|
|
121
128
|
|
|
129
|
+
|
|
122
130
|
<slot name="toolbar">
|
|
123
131
|
|
|
124
132
|
</slot>
|
|
@@ -218,24 +226,24 @@ defineEmits(['import', 'export', 'create', 'update', 'delete', 'view', 'edit'])
|
|
|
218
226
|
</template>
|
|
219
227
|
|
|
220
228
|
|
|
221
|
-
<template v-slot:item.actions="{item}">
|
|
229
|
+
<template v-slot:item.actions="{item, index}">
|
|
222
230
|
|
|
223
|
-
<slot name="item.actions" v-bind="{item}">
|
|
231
|
+
<slot name="item.actions" v-bind="{item, index}">
|
|
224
232
|
</slot>
|
|
225
233
|
|
|
226
234
|
<crud-view-button
|
|
227
235
|
v-if="entity.isViewable && hasPermission(entity.permissions.view)"
|
|
228
|
-
@click="$emit('view', item)"
|
|
236
|
+
@click="$emit('view', item, index)"
|
|
229
237
|
/>
|
|
230
238
|
|
|
231
239
|
<crud-update-button
|
|
232
240
|
v-if="entity.isEditable && entity.isItemEditable(item) && hasPermission(entity.permissions?.update)"
|
|
233
|
-
@click="$emit('edit', item)"
|
|
241
|
+
@click="$emit('edit', item, index)"
|
|
234
242
|
/>
|
|
235
243
|
|
|
236
244
|
<crud-delete-button
|
|
237
245
|
v-if="entity.isDeletable && hasPermission(entity.permissions?.delete)"
|
|
238
|
-
@click="$emit('delete', item)"
|
|
246
|
+
@click="$emit('delete', item, index)"
|
|
239
247
|
/>
|
|
240
248
|
|
|
241
249
|
</template>
|
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {computed, ref, type PropType} from "vue";
|
|
3
|
+
import type {ICrudSavedQuery, IEntityCrud, IEntityCrudFilter, IDraxFieldFilter} from "@drax/crud-share";
|
|
4
|
+
import {useI18n} from "vue-i18n";
|
|
5
|
+
import {useCrudStore} from "../../stores/UseCrudStore";
|
|
6
|
+
import {useCrud} from "../../composables/UseCrud";
|
|
7
|
+
import {useCrudColumns} from "../../composables/UseCrudColumns";
|
|
8
|
+
import {CrudSavedQueryProvider} from "@drax/crud-front";
|
|
9
|
+
import {useAuth, useAuthStore} from "@drax/identity-vue";
|
|
10
|
+
import {createCrudFilterValue} from "../../helpers/CrudRangeFilters";
|
|
11
|
+
|
|
12
|
+
const {t, te} = useI18n();
|
|
13
|
+
const {hasPermission} = useAuth();
|
|
14
|
+
const authStore = useAuthStore();
|
|
15
|
+
|
|
16
|
+
const props = defineProps({
|
|
17
|
+
entity: {type: Object as PropType<IEntityCrud>, required: true},
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const store = useCrudStore(props.entity.name);
|
|
21
|
+
const {doPaginate} = useCrud(props.entity);
|
|
22
|
+
const {setVisibleColumns} = useCrudColumns(props.entity);
|
|
23
|
+
|
|
24
|
+
const menu = ref(false);
|
|
25
|
+
const saveDialog = ref(false);
|
|
26
|
+
const deleteDialog = ref(false);
|
|
27
|
+
const loading = ref(false);
|
|
28
|
+
const saving = ref(false);
|
|
29
|
+
const deleting = ref(false);
|
|
30
|
+
const savedQueries = ref<ICrudSavedQuery[]>([]);
|
|
31
|
+
const queryToDelete = ref<ICrudSavedQuery | null>(null);
|
|
32
|
+
const form = ref({
|
|
33
|
+
name: "",
|
|
34
|
+
shared: false,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
type UserIdLike = {
|
|
38
|
+
id?: string;
|
|
39
|
+
_id?: string;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const title = computed(() => {
|
|
43
|
+
const key = "crud.savedQueries.title";
|
|
44
|
+
return te(key) ? t(key) : "Saved queries";
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const saveTitle = computed(() => {
|
|
48
|
+
const key = "crud.savedQueries.save";
|
|
49
|
+
return te(key) ? t(key) : "Save query";
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const noQueriesText = computed(() => {
|
|
53
|
+
const key = "crud.savedQueries.empty";
|
|
54
|
+
return te(key) ? t(key) : "No saved queries";
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const deleteTitle = computed(() => {
|
|
58
|
+
const key = "crud.savedQueries.delete";
|
|
59
|
+
return te(key) ? t(key) : "Delete saved query";
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const deleteConfirmText = computed(() => {
|
|
63
|
+
const key = "crud.savedQueries.deleteConfirm";
|
|
64
|
+
return te(key) ? t(key, {name: queryToDelete.value?.name || ""}) : `Delete "${queryToDelete.value?.name || ""}"?`;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const canViewSavedQueries = computed(() => hasPermission("crudSavedQuery:view"));
|
|
68
|
+
const canCreateSavedQueries = computed(() => hasPermission("crudSavedQuery:create"));
|
|
69
|
+
const canDeleteOwnSavedQueries = computed(() => hasPermission("crudSavedQuery:delete"));
|
|
70
|
+
const canDeleteAllSavedQueries = computed(() => hasPermission("crudSavedQuery:all") || hasPermission("crudSavedQuery:deleteAll"));
|
|
71
|
+
|
|
72
|
+
function clone<T>(value: T): T {
|
|
73
|
+
return JSON.parse(JSON.stringify(value));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function entityFilter(): IDraxFieldFilter[] {
|
|
77
|
+
return [{field: "entity", operator: "eq", value: props.entity.name}];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function definedDynamicFilters(): IEntityCrudFilter[] {
|
|
81
|
+
return clone(store.dynamicFilters.filter((filter: IEntityCrudFilter) => filter.name));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function buildStaticFilters(savedFilters: IDraxFieldFilter[] = []): IDraxFieldFilter[] {
|
|
85
|
+
const savedByField = new Map(savedFilters.map(filter => [filter.field, filter]));
|
|
86
|
+
|
|
87
|
+
return props.entity.filters.map(filter => {
|
|
88
|
+
const savedFilter = savedByField.get(filter.name);
|
|
89
|
+
return {
|
|
90
|
+
field: filter.name,
|
|
91
|
+
operator: savedFilter?.operator || filter.operator || "eq",
|
|
92
|
+
value: savedFilter ? savedFilter.value : createCrudFilterValue(filter)
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function queryUserId(query: ICrudSavedQuery): string | undefined {
|
|
98
|
+
const user = query.user;
|
|
99
|
+
if (!user) {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
if (typeof user === "string") {
|
|
103
|
+
return user;
|
|
104
|
+
}
|
|
105
|
+
const id = user._id || user.id;
|
|
106
|
+
return id ? String(id) : undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function currentUserId(): string | undefined {
|
|
110
|
+
const authUser = authStore.authUser as UserIdLike | null;
|
|
111
|
+
const id = authUser?.id || authUser?._id;
|
|
112
|
+
return id ? String(id) : undefined;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function canDeleteQuery(query: ICrudSavedQuery): boolean {
|
|
116
|
+
if (canDeleteAllSavedQueries.value) {
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
if (!canDeleteOwnSavedQueries.value) {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const ownerId = queryUserId(query);
|
|
124
|
+
return !ownerId || ownerId === currentUserId();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
async function loadQueries() {
|
|
128
|
+
loading.value = true;
|
|
129
|
+
try {
|
|
130
|
+
savedQueries.value = await CrudSavedQueryProvider.instance.find({
|
|
131
|
+
limit: 100,
|
|
132
|
+
orderBy: "name",
|
|
133
|
+
order: "asc",
|
|
134
|
+
filters: entityFilter()
|
|
135
|
+
});
|
|
136
|
+
} catch (error) {
|
|
137
|
+
console.error("Error loading saved queries", error);
|
|
138
|
+
savedQueries.value = [];
|
|
139
|
+
} finally {
|
|
140
|
+
loading.value = false;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function openSaveDialog() {
|
|
145
|
+
form.value = {name: "", shared: false};
|
|
146
|
+
saveDialog.value = true;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function saveQuery() {
|
|
150
|
+
if (!form.value.name.trim()) {
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
saving.value = true;
|
|
155
|
+
try {
|
|
156
|
+
await CrudSavedQueryProvider.instance.create({
|
|
157
|
+
entity: props.entity.name,
|
|
158
|
+
name: form.value.name.trim(),
|
|
159
|
+
shared: form.value.shared,
|
|
160
|
+
columns: clone(store.visibleColumns),
|
|
161
|
+
staticFilters: clone(store.filters),
|
|
162
|
+
dynamicFilters: definedDynamicFilters(),
|
|
163
|
+
});
|
|
164
|
+
saveDialog.value = false;
|
|
165
|
+
await loadQueries();
|
|
166
|
+
} catch (error) {
|
|
167
|
+
console.error("Error saving query", error);
|
|
168
|
+
} finally {
|
|
169
|
+
saving.value = false;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function openDeleteDialog(query: ICrudSavedQuery) {
|
|
174
|
+
queryToDelete.value = query;
|
|
175
|
+
deleteDialog.value = true;
|
|
176
|
+
menu.value = false;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function deleteQuery() {
|
|
180
|
+
if (!queryToDelete.value) {
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
deleting.value = true;
|
|
185
|
+
try {
|
|
186
|
+
await CrudSavedQueryProvider.instance.delete(queryToDelete.value._id);
|
|
187
|
+
deleteDialog.value = false;
|
|
188
|
+
queryToDelete.value = null;
|
|
189
|
+
await loadQueries();
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.error("Error deleting saved query", error);
|
|
192
|
+
} finally {
|
|
193
|
+
deleting.value = false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function applyQuery(query: ICrudSavedQuery) {
|
|
198
|
+
setVisibleColumns(query.columns || []);
|
|
199
|
+
store.setFilters(buildStaticFilters(clone(query.staticFilters || [])));
|
|
200
|
+
const dynamicFilters = clone(query.dynamicFilters || []);
|
|
201
|
+
store.setDynamicFilters(dynamicFilters);
|
|
202
|
+
store.setDynamicFiltersEnable(dynamicFilters.length > 0);
|
|
203
|
+
store.setPage(1);
|
|
204
|
+
menu.value = false;
|
|
205
|
+
await doPaginate();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function onMenuUpdate(value: boolean) {
|
|
209
|
+
menu.value = value;
|
|
210
|
+
if (value) {
|
|
211
|
+
loadQueries();
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
</script>
|
|
215
|
+
|
|
216
|
+
<template>
|
|
217
|
+
<v-menu
|
|
218
|
+
v-if="canViewSavedQueries"
|
|
219
|
+
:model-value="menu"
|
|
220
|
+
offset-y
|
|
221
|
+
:close-on-content-click="false"
|
|
222
|
+
@update:model-value="onMenuUpdate"
|
|
223
|
+
>
|
|
224
|
+
<template #activator="{ props: activatorProps }">
|
|
225
|
+
<v-btn
|
|
226
|
+
v-bind="activatorProps"
|
|
227
|
+
icon
|
|
228
|
+
variant="text"
|
|
229
|
+
>
|
|
230
|
+
<v-icon>mdi-content-save-cog</v-icon>
|
|
231
|
+
<v-tooltip activator="parent" location="bottom">
|
|
232
|
+
{{ title }}
|
|
233
|
+
</v-tooltip>
|
|
234
|
+
</v-btn>
|
|
235
|
+
</template>
|
|
236
|
+
|
|
237
|
+
<v-list min-width="280">
|
|
238
|
+
<v-list-subheader>{{ title }}</v-list-subheader>
|
|
239
|
+
|
|
240
|
+
<v-list-item
|
|
241
|
+
v-if="canCreateSavedQueries"
|
|
242
|
+
@click="openSaveDialog"
|
|
243
|
+
>
|
|
244
|
+
<template #prepend>
|
|
245
|
+
<v-icon>mdi-content-save-outline</v-icon>
|
|
246
|
+
</template>
|
|
247
|
+
<v-list-item-title>{{ saveTitle }}</v-list-item-title>
|
|
248
|
+
</v-list-item>
|
|
249
|
+
|
|
250
|
+
<v-divider />
|
|
251
|
+
|
|
252
|
+
<v-list-item v-if="loading">
|
|
253
|
+
<v-progress-linear indeterminate />
|
|
254
|
+
</v-list-item>
|
|
255
|
+
|
|
256
|
+
<v-list-item v-else-if="savedQueries.length === 0">
|
|
257
|
+
<v-list-item-title class="text-medium-emphasis">{{ noQueriesText }}</v-list-item-title>
|
|
258
|
+
</v-list-item>
|
|
259
|
+
|
|
260
|
+
<v-list-item
|
|
261
|
+
v-for="query in savedQueries"
|
|
262
|
+
:key="query._id"
|
|
263
|
+
@click="applyQuery(query)"
|
|
264
|
+
>
|
|
265
|
+
<template #prepend>
|
|
266
|
+
<v-icon>{{ query.shared ? "mdi-account-group-outline" : "mdi-account-outline" }}</v-icon>
|
|
267
|
+
</template>
|
|
268
|
+
<v-list-item-title>{{ query.name }}</v-list-item-title>
|
|
269
|
+
<template #append>
|
|
270
|
+
<v-btn
|
|
271
|
+
v-if="canDeleteQuery(query)"
|
|
272
|
+
icon
|
|
273
|
+
variant="text"
|
|
274
|
+
color="red"
|
|
275
|
+
size="small"
|
|
276
|
+
@click.stop="openDeleteDialog(query)"
|
|
277
|
+
>
|
|
278
|
+
<v-icon>mdi-delete</v-icon>
|
|
279
|
+
<v-tooltip activator="parent" location="bottom">
|
|
280
|
+
{{ te('action.delete') ? t('action.delete') : 'Delete' }}
|
|
281
|
+
</v-tooltip>
|
|
282
|
+
</v-btn>
|
|
283
|
+
</template>
|
|
284
|
+
</v-list-item>
|
|
285
|
+
</v-list>
|
|
286
|
+
</v-menu>
|
|
287
|
+
|
|
288
|
+
<v-dialog v-model="saveDialog" max-width="460">
|
|
289
|
+
<v-card>
|
|
290
|
+
<v-card-title>{{ saveTitle }}</v-card-title>
|
|
291
|
+
<v-card-text>
|
|
292
|
+
<v-text-field
|
|
293
|
+
v-model="form.name"
|
|
294
|
+
:label="te('crud.savedQueries.name') ? t('crud.savedQueries.name') : 'Name'"
|
|
295
|
+
density="compact"
|
|
296
|
+
variant="outlined"
|
|
297
|
+
autofocus
|
|
298
|
+
/>
|
|
299
|
+
<v-switch
|
|
300
|
+
v-model="form.shared"
|
|
301
|
+
:label="te('crud.savedQueries.shared') ? t('crud.savedQueries.shared') : 'Shared'"
|
|
302
|
+
color="primary"
|
|
303
|
+
hide-details
|
|
304
|
+
/>
|
|
305
|
+
</v-card-text>
|
|
306
|
+
<v-card-actions>
|
|
307
|
+
<v-spacer />
|
|
308
|
+
<v-btn variant="text" @click="saveDialog = false">
|
|
309
|
+
{{ te('action.cancel') ? t('action.cancel') : 'Cancel' }}
|
|
310
|
+
</v-btn>
|
|
311
|
+
<v-btn
|
|
312
|
+
color="primary"
|
|
313
|
+
variant="flat"
|
|
314
|
+
:loading="saving"
|
|
315
|
+
:disabled="!form.name.trim()"
|
|
316
|
+
@click="saveQuery"
|
|
317
|
+
>
|
|
318
|
+
{{ te('action.save') ? t('action.save') : 'Save' }}
|
|
319
|
+
</v-btn>
|
|
320
|
+
</v-card-actions>
|
|
321
|
+
</v-card>
|
|
322
|
+
</v-dialog>
|
|
323
|
+
|
|
324
|
+
<v-dialog v-model="deleteDialog" max-width="460">
|
|
325
|
+
<v-card>
|
|
326
|
+
<v-card-title>{{ deleteTitle }}</v-card-title>
|
|
327
|
+
<v-card-text>
|
|
328
|
+
{{ deleteConfirmText }}
|
|
329
|
+
</v-card-text>
|
|
330
|
+
<v-card-actions>
|
|
331
|
+
<v-spacer />
|
|
332
|
+
<v-btn variant="text" @click="deleteDialog = false">
|
|
333
|
+
{{ te('action.cancel') ? t('action.cancel') : 'Cancel' }}
|
|
334
|
+
</v-btn>
|
|
335
|
+
<v-btn
|
|
336
|
+
color="red"
|
|
337
|
+
variant="flat"
|
|
338
|
+
:loading="deleting"
|
|
339
|
+
@click="deleteQuery"
|
|
340
|
+
>
|
|
341
|
+
{{ te('action.delete') ? t('action.delete') : 'Delete' }}
|
|
342
|
+
</v-btn>
|
|
343
|
+
</v-card-actions>
|
|
344
|
+
</v-card>
|
|
345
|
+
</v-dialog>
|
|
346
|
+
</template>
|
|
@@ -6,15 +6,19 @@ import type {
|
|
|
6
6
|
IEntityCrudOperation
|
|
7
7
|
} from "@drax/crud-share";
|
|
8
8
|
import {useCrudStore} from "../stores/UseCrudStore";
|
|
9
|
-
import {computed, nextTick, toRaw} from "vue";
|
|
9
|
+
import {computed, nextTick, toRaw, watch} from "vue";
|
|
10
10
|
import getItemId from "../helpers/getItemId";
|
|
11
11
|
import {useI18n} from "vue-i18n";
|
|
12
12
|
import {useRouter} from "vue-router";
|
|
13
|
+
import {createCrudFilterValue, expandRangeFilters} from "../helpers/CrudRangeFilters";
|
|
13
14
|
|
|
14
15
|
|
|
15
16
|
export function useCrud(entity: IEntityCrud) {
|
|
16
17
|
|
|
17
18
|
const store = useCrudStore(entity?.name)
|
|
19
|
+
const navigationOperations = (entity as IEntityCrud & {
|
|
20
|
+
navigationOperations?: Exclude<IEntityCrudOperation, null>[]
|
|
21
|
+
}).navigationOperations ?? ['view']
|
|
18
22
|
|
|
19
23
|
const router = useRouter();
|
|
20
24
|
|
|
@@ -48,6 +52,18 @@ export function useCrud(entity: IEntityCrud) {
|
|
|
48
52
|
}
|
|
49
53
|
})
|
|
50
54
|
|
|
55
|
+
const currentViewIndex = computed({
|
|
56
|
+
get() {
|
|
57
|
+
return store.currentViewIndex
|
|
58
|
+
}, set(value) {
|
|
59
|
+
store.setCurrentViewIndex(value)
|
|
60
|
+
}
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
const canNavigateItems = computed(() => {
|
|
64
|
+
return operation.value !== null && navigationOperations.includes(operation.value)
|
|
65
|
+
})
|
|
66
|
+
|
|
51
67
|
const operation = computed({
|
|
52
68
|
get() {
|
|
53
69
|
return store.operation
|
|
@@ -233,23 +249,97 @@ export function useCrud(entity: IEntityCrud) {
|
|
|
233
249
|
})
|
|
234
250
|
|
|
235
251
|
const prepareDynamicFilters = computed(() => {
|
|
236
|
-
return store.dynamicFilters.map((filter: IEntityCrudFilter) => {
|
|
252
|
+
return expandRangeFilters(store.dynamicFilters.map((filter: IEntityCrudFilter) => {
|
|
237
253
|
return {
|
|
238
254
|
field: filter.name,
|
|
239
255
|
operator: filter.operator,
|
|
240
256
|
value: filter.value
|
|
241
257
|
}
|
|
242
|
-
}) as IDraxFieldFilter[]
|
|
258
|
+
})) as IDraxFieldFilter[]
|
|
243
259
|
})
|
|
244
260
|
|
|
245
261
|
|
|
246
262
|
const getAllFilters = computed(() =>{
|
|
247
263
|
return [
|
|
248
|
-
...store.filters,
|
|
264
|
+
...expandRangeFilters(store.filters),
|
|
249
265
|
...prepareDynamicFilters.value
|
|
250
266
|
] as IDraxFieldFilter[]
|
|
251
267
|
})
|
|
252
268
|
|
|
269
|
+
function resolveViewIndex(item: Record<string, any>, index?: number | null) {
|
|
270
|
+
if (typeof index === 'number' && index >= 0) {
|
|
271
|
+
return index
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const itemIdentifier = entity.identifier ? item?.[entity.identifier] : getItemId(item)
|
|
275
|
+
|
|
276
|
+
if (itemIdentifier !== undefined && itemIdentifier !== null) {
|
|
277
|
+
return items.value.findIndex((currentItem: Record<string, any>) => {
|
|
278
|
+
const currentIdentifier = entity.identifier ? currentItem?.[entity.identifier] : getItemId(currentItem)
|
|
279
|
+
return currentIdentifier === itemIdentifier
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
return items.value.findIndex((currentItem: Record<string, any>) => currentItem === item)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function openItemAt(operation: Exclude<IEntityCrudOperation, null>, item: Record<string, any>, index?: number) {
|
|
287
|
+
const resolvedIndex = resolveViewIndex(item, index)
|
|
288
|
+
currentViewIndex.value = resolvedIndex >= 0 ? resolvedIndex : null
|
|
289
|
+
|
|
290
|
+
switch (operation) {
|
|
291
|
+
case 'view':
|
|
292
|
+
onView(item)
|
|
293
|
+
return
|
|
294
|
+
case 'edit':
|
|
295
|
+
onEdit(item)
|
|
296
|
+
return
|
|
297
|
+
case 'delete':
|
|
298
|
+
onDelete(item)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function onViewAt(item: Record<string, any>, index?: number) {
|
|
304
|
+
openItemAt('view', item, index)
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function onEditAt(item: Record<string, any>, index?: number) {
|
|
308
|
+
openItemAt('edit', item, index)
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function onDeleteAt(item: Record<string, any>, index?: number) {
|
|
312
|
+
openItemAt('delete', item, index)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const canNavigatePrev = computed(() => {
|
|
316
|
+
return canNavigateItems.value
|
|
317
|
+
&& currentViewIndex.value !== null
|
|
318
|
+
&& currentViewIndex.value > 0
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
const canNavigateNext = computed(() => {
|
|
322
|
+
return canNavigateItems.value
|
|
323
|
+
&& currentViewIndex.value !== null
|
|
324
|
+
&& currentViewIndex.value < items.value.length - 1
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
function navigateView(direction: -1 | 1) {
|
|
328
|
+
if (currentViewIndex.value === null || operation.value === null || !canNavigateItems.value) {
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
const nextIndex = currentViewIndex.value + direction
|
|
333
|
+
const nextItem = items.value[nextIndex]
|
|
334
|
+
|
|
335
|
+
if (!nextItem) {
|
|
336
|
+
return
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
currentViewIndex.value = nextIndex
|
|
340
|
+
openItemAt(operation.value, nextItem, nextIndex)
|
|
341
|
+
}
|
|
342
|
+
|
|
253
343
|
|
|
254
344
|
async function doPaginate() {
|
|
255
345
|
store.setLoading(true)
|
|
@@ -524,7 +614,7 @@ export function useCrud(entity: IEntityCrud) {
|
|
|
524
614
|
(filter: IEntityCrudFilter) =>
|
|
525
615
|
({
|
|
526
616
|
field: filter.name,
|
|
527
|
-
value: filter
|
|
617
|
+
value: createCrudFilterValue(filter),
|
|
528
618
|
operator: (filter.operator ? filter.operator : 'eq')
|
|
529
619
|
})
|
|
530
620
|
) as IDraxFieldFilter[]
|
|
@@ -552,13 +642,21 @@ export function useCrud(entity: IEntityCrud) {
|
|
|
552
642
|
await doPaginate()
|
|
553
643
|
}
|
|
554
644
|
|
|
645
|
+
watch(dialog, (value) => {
|
|
646
|
+
if (!value) {
|
|
647
|
+
currentViewIndex.value = null
|
|
648
|
+
}
|
|
649
|
+
})
|
|
650
|
+
|
|
555
651
|
|
|
556
652
|
return {
|
|
557
653
|
doPaginate, doExport, doImport, doUpdate, doCreate, doDelete,
|
|
558
654
|
onView, onCreate, onEdit, onDelete, onCancel, onSubmit, resetCrudStore,
|
|
655
|
+
onViewAt, onEditAt, onDeleteAt, navigateView, resolveViewIndex, openItemAt,
|
|
559
656
|
dialog, notify, error, paginationError, message, formValid,
|
|
560
657
|
form, getForm, setForm,
|
|
561
658
|
operation, getOperation, setOperation,
|
|
659
|
+
currentViewIndex, canNavigateItems, canNavigatePrev, canNavigateNext,
|
|
562
660
|
loading, itemsPerPage, page, sortBy, search, totalItems, items,
|
|
563
661
|
prepareFilters, filters, clearFilters, applyFilters, prepareSort,
|
|
564
662
|
exportFiles, importFiles, exportLoading, importLoading, exportListVisible, importListVisible, exportError, importError,
|
|
@@ -158,6 +158,16 @@ export function useCrudColumns(entity: IEntityCrud) {
|
|
|
158
158
|
saveColumnsToStorage(defaultColumns)
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
const setVisibleColumns = (columns: string[]) => {
|
|
162
|
+
const availableHeaders = entity.headers
|
|
163
|
+
.filter(header => !header.permission || hasPermission(header.permission))
|
|
164
|
+
.map(header => header.key)
|
|
165
|
+
|
|
166
|
+
const validColumns = columns.filter(key => availableHeaders.includes(key))
|
|
167
|
+
crudStore.setVisibleColumns(validColumns)
|
|
168
|
+
saveColumnsToStorage(validColumns)
|
|
169
|
+
}
|
|
170
|
+
|
|
161
171
|
return {
|
|
162
172
|
visibleColumns,
|
|
163
173
|
translatedHeaders,
|
|
@@ -170,5 +180,6 @@ export function useCrudColumns(entity: IEntityCrud) {
|
|
|
170
180
|
selectAll,
|
|
171
181
|
deselectAll,
|
|
172
182
|
resetToDefault,
|
|
183
|
+
setVisibleColumns,
|
|
173
184
|
}
|
|
174
185
|
}
|
|
@@ -171,6 +171,9 @@ export function useDynamicFilters(
|
|
|
171
171
|
if (['ref','array.ref'].includes(filter.type) && ['gt', 'gte', 'lt', 'lte', 'like'].includes(op.value)) {
|
|
172
172
|
return false
|
|
173
173
|
}
|
|
174
|
+
if (['date'].includes(filter.type) && ['in', 'nin', 'like'].includes(op.value)) {
|
|
175
|
+
return false
|
|
176
|
+
}
|
|
174
177
|
return true
|
|
175
178
|
})
|
|
176
179
|
}
|
package/src/cruds/EntityCrud.ts
CHANGED
|
@@ -2,8 +2,9 @@ import type {
|
|
|
2
2
|
IEntityCrud, IEntityCrudForm, IEntityCrudHeader, IEntityCrudRefs,
|
|
3
3
|
IEntityCrudRules, IEntityCrudField, IEntityCrudPermissions,
|
|
4
4
|
IDraxCrudProvider, IEntityCrudFilter, IEntityCrudFieldVariant, IDraxFieldFilter,
|
|
5
|
-
IEntityCrudOnInput
|
|
5
|
+
IEntityCrudOnInput, IEntityCrudOperation
|
|
6
6
|
} from "@drax/crud-share";
|
|
7
|
+
import {createCrudFilterValue} from "../helpers/CrudRangeFilters";
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
|
|
@@ -138,7 +139,7 @@ class EntityCrud implements IEntityCrud {
|
|
|
138
139
|
get formFilters(): IDraxFieldFilter[] {
|
|
139
140
|
return this.filters.map(
|
|
140
141
|
(filter: IEntityCrudFilter) =>
|
|
141
|
-
({field: filter.name, value: filter
|
|
142
|
+
({field: filter.name, value: createCrudFilterValue(filter), operator: (filter.operator ? filter.operator : 'eq')})
|
|
142
143
|
) as IDraxFieldFilter[]
|
|
143
144
|
}
|
|
144
145
|
|
|
@@ -224,6 +225,10 @@ class EntityCrud implements IEntityCrud {
|
|
|
224
225
|
return true
|
|
225
226
|
}
|
|
226
227
|
|
|
228
|
+
get isSavedQueriesEnabled(){
|
|
229
|
+
return false
|
|
230
|
+
}
|
|
231
|
+
|
|
227
232
|
get isGroupable() {
|
|
228
233
|
return false
|
|
229
234
|
}
|
|
@@ -346,6 +351,10 @@ class EntityCrud implements IEntityCrud {
|
|
|
346
351
|
return false
|
|
347
352
|
}
|
|
348
353
|
|
|
354
|
+
get navigationOperations(): IEntityCrudOperation[] {
|
|
355
|
+
return ['view']
|
|
356
|
+
}
|
|
357
|
+
|
|
349
358
|
}
|
|
350
359
|
|
|
351
360
|
export default EntityCrud;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {describe, expect, it} from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
createCrudFilterValue,
|
|
4
|
+
createEmptyDateRangeValue,
|
|
5
|
+
expandRangeFilters,
|
|
6
|
+
normalizeDateRangeValue
|
|
7
|
+
} from "./CrudRangeFilters";
|
|
8
|
+
|
|
9
|
+
describe('CrudRangeFilters', () => {
|
|
10
|
+
it('creates an empty range value by default', () => {
|
|
11
|
+
expect(createEmptyDateRangeValue()).toEqual({from: null, to: null})
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('normalizes invalid range values to an empty range', () => {
|
|
15
|
+
expect(normalizeDateRangeValue(null)).toEqual({from: null, to: null})
|
|
16
|
+
expect(normalizeDateRangeValue('2026-01-01')).toEqual({from: null, to: null})
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('uses an empty object for range filters without default', () => {
|
|
20
|
+
expect(createCrudFilterValue({
|
|
21
|
+
name: 'birthdate',
|
|
22
|
+
type: 'date',
|
|
23
|
+
label: 'birthdate',
|
|
24
|
+
default: undefined,
|
|
25
|
+
operator: 'range'
|
|
26
|
+
})).toEqual({from: null, to: null})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('expands range filters into gte and lte filters', () => {
|
|
30
|
+
const from = new Date('2026-01-10T00:00:00.000Z')
|
|
31
|
+
const to = new Date('2026-01-20T00:00:00.000Z')
|
|
32
|
+
|
|
33
|
+
expect(expandRangeFilters([{
|
|
34
|
+
field: 'birthdate',
|
|
35
|
+
operator: 'range',
|
|
36
|
+
value: {from, to}
|
|
37
|
+
}])).toEqual([
|
|
38
|
+
{field: 'birthdate', operator: 'gte', value: from},
|
|
39
|
+
{field: 'birthdate', operator: 'lte', value: to}
|
|
40
|
+
])
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
it('omits empty range bounds when expanding', () => {
|
|
44
|
+
expect(expandRangeFilters([{
|
|
45
|
+
field: 'birthdate',
|
|
46
|
+
operator: 'range',
|
|
47
|
+
value: {from: null, to: '2026-01-20'}
|
|
48
|
+
}])).toEqual([
|
|
49
|
+
{field: 'birthdate', operator: 'lte', value: '2026-01-20'}
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type {IDraxFieldFilter, IEntityCrudFilter} from "@drax/crud-share";
|
|
2
|
+
|
|
3
|
+
interface ICrudDateRangeValue {
|
|
4
|
+
from: Date | string | null
|
|
5
|
+
to: Date | string | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function createEmptyDateRangeValue(): ICrudDateRangeValue {
|
|
9
|
+
return {
|
|
10
|
+
from: null,
|
|
11
|
+
to: null
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isRangeOperator(filter?: Pick<IEntityCrudFilter, 'operator'> | Pick<IDraxFieldFilter, 'operator'> | null): boolean {
|
|
16
|
+
return filter?.operator === 'range'
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeDateRangeValue(value: any): ICrudDateRangeValue {
|
|
20
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
21
|
+
return createEmptyDateRangeValue()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
from: value.from ?? null,
|
|
26
|
+
to: value.to ?? null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function createCrudFilterValue(filter: IEntityCrudFilter) {
|
|
31
|
+
if (filter.operator === 'range') {
|
|
32
|
+
if (filter.default === undefined) {
|
|
33
|
+
return createEmptyDateRangeValue()
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return normalizeDateRangeValue(filter.default)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return filter.default !== undefined ? filter.default : null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function expandRangeFilters(filters: Array<IDraxFieldFilter | IEntityCrudFilter>): IDraxFieldFilter[] {
|
|
43
|
+
return filters.flatMap((filter) => {
|
|
44
|
+
if (!isRangeOperator(filter)) {
|
|
45
|
+
return [{
|
|
46
|
+
field: 'field' in filter ? filter.field : filter.name,
|
|
47
|
+
operator: filter.operator ? filter.operator : 'eq',
|
|
48
|
+
value: filter.value
|
|
49
|
+
}]
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const rangeValue = normalizeDateRangeValue(filter.value)
|
|
53
|
+
const fieldName = 'field' in filter ? filter.field : filter.name
|
|
54
|
+
const expandedFilters: IDraxFieldFilter[] = []
|
|
55
|
+
|
|
56
|
+
if (rangeValue.from !== null && rangeValue.from !== undefined && rangeValue.from !== '') {
|
|
57
|
+
expandedFilters.push({
|
|
58
|
+
field: fieldName,
|
|
59
|
+
operator: 'gte',
|
|
60
|
+
value: rangeValue.from
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (rangeValue.to !== null && rangeValue.to !== undefined && rangeValue.to !== '') {
|
|
65
|
+
expandedFilters.push({
|
|
66
|
+
field: fieldName,
|
|
67
|
+
operator: 'lte',
|
|
68
|
+
value: rangeValue.to
|
|
69
|
+
})
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return expandedFilters
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export {
|
|
77
|
+
createCrudFilterValue,
|
|
78
|
+
createEmptyDateRangeValue,
|
|
79
|
+
expandRangeFilters,
|
|
80
|
+
isRangeOperator,
|
|
81
|
+
normalizeDateRangeValue
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type {ICrudDateRangeValue}
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@ import Crud from "./components/Crud.vue";
|
|
|
2
2
|
import CrudDialog from "./components/CrudDialog.vue";
|
|
3
3
|
import CrudForm from "./components/CrudForm.vue";
|
|
4
4
|
import CrudFormField from "./components/CrudFormField.vue";
|
|
5
|
+
import CrudFieldRange from "./components/CrudFieldRange.vue";
|
|
5
6
|
import CrudFormList from "./components/CrudFormList.vue";
|
|
6
7
|
import CrudList from "./components/CrudList.vue";
|
|
7
8
|
import CrudListTable from "./components/CrudListTable.vue";
|
|
@@ -11,6 +12,7 @@ import CrudFiltersAction from "./components/CrudFiltersAction.vue";
|
|
|
11
12
|
import CrudNotify from "./components/CrudNotify.vue";
|
|
12
13
|
import CrudSearch from "./components/CrudSearch.vue";
|
|
13
14
|
import CrudAutocomplete from "./components/CrudAutocomplete.vue";
|
|
15
|
+
import CrudSavedQueriesButton from "./components/buttons/CrudSavedQueriesButton.vue";
|
|
14
16
|
import EntityCombobox from "./components/combobox/EntityCombobox.vue";
|
|
15
17
|
import {useCrudStore} from "./stores/UseCrudStore";
|
|
16
18
|
import {useEntityStore} from "./stores/UseEntityStore";
|
|
@@ -26,6 +28,7 @@ export {
|
|
|
26
28
|
CrudDialog,
|
|
27
29
|
CrudForm,
|
|
28
30
|
CrudFormField,
|
|
31
|
+
CrudFieldRange,
|
|
29
32
|
CrudFormList,
|
|
30
33
|
CrudList,
|
|
31
34
|
CrudListTable,
|
|
@@ -33,6 +36,7 @@ export {
|
|
|
33
36
|
CrudNotify,
|
|
34
37
|
CrudSearch,
|
|
35
38
|
CrudAutocomplete,
|
|
39
|
+
CrudSavedQueriesButton,
|
|
36
40
|
CrudFilters,
|
|
37
41
|
CrudFiltersAction,
|
|
38
42
|
useCrud,
|
|
@@ -5,6 +5,7 @@ export const useCrudStore = (id: string = 'entity') => defineStore('CrudStore'+i
|
|
|
5
5
|
state: () => (
|
|
6
6
|
{
|
|
7
7
|
operation: null as IEntityCrudOperation,
|
|
8
|
+
currentViewIndex: null as number | null,
|
|
8
9
|
dialog: false as boolean,
|
|
9
10
|
form: {} as any,
|
|
10
11
|
formValid: {} as any,
|
|
@@ -79,6 +80,9 @@ export const useCrudStore = (id: string = 'entity') => defineStore('CrudStore'+i
|
|
|
79
80
|
setOperation(operation: IEntityCrudOperation) {
|
|
80
81
|
this.operation = operation
|
|
81
82
|
},
|
|
83
|
+
setCurrentViewIndex(index: number | null) {
|
|
84
|
+
this.currentViewIndex = index
|
|
85
|
+
},
|
|
82
86
|
setDialog(dialog: boolean) {
|
|
83
87
|
this.dialog = dialog
|
|
84
88
|
},
|