@drax/crud-vue 3.18.1 → 3.19.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 CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "3.18.1",
6
+ "version": "3.19.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.11.0",
27
+ "@drax/common-front": "^3.19.0",
28
28
  "@drax/crud-front": "^3.11.0",
29
- "@drax/crud-share": "^3.17.0",
30
- "@drax/media-vue": "^3.18.1"
29
+ "@drax/crud-share": "^3.19.0",
30
+ "@drax/media-vue": "^3.19.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": "241f90094f420bdbe8c998d0ccb89aff433db022"
53
+ "gitHead": "bf445c10758ee014b45c2d76d6fddd5c578c738f"
54
54
  }
@@ -17,9 +17,10 @@ const {entity} = defineProps({
17
17
  })
18
18
 
19
19
  const {
20
- onView, onCreate, onEdit, onDelete, resetCrudStore,
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="onEdit"
74
- @delete="onDelete"
74
+ @edit="onEditAt"
75
+ @delete="onDeleteAt"
75
76
  @export="doExport"
76
77
  @import="doImport"
77
- @view="onView"
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"
@@ -82,7 +82,6 @@ const inputErrors = computed(() => {
82
82
 
83
83
  defineEmits(['updateValue'])
84
84
 
85
-
86
85
  const hasHideDetails = computed(() => {
87
86
  if (readonly) {
88
87
  return true
@@ -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>
@@ -199,7 +199,7 @@ onMounted(() => {
199
199
 
200
200
  <!-- GALLERY GRIDS -->
201
201
  <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"
202
+ <v-col v-for="(item, index) in items" :key="item.id || item.uuid || item.name || Math.random()" cols="12" sm="6" md="4"
203
203
  xl="3">
204
204
 
205
205
 
@@ -231,22 +231,22 @@ onMounted(() => {
231
231
  <v-divider></v-divider>
232
232
 
233
233
  <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}">
234
+ <slot name="item.actions" v-bind="{item, index}">
235
235
  </slot>
236
236
 
237
237
  <crud-view-button
238
238
  v-if="entity.isViewable && hasPermission(entity.permissions.view)"
239
- @click="$emit('view', item)"
239
+ @click="$emit('view', item, index)"
240
240
  />
241
241
 
242
242
  <crud-update-button
243
243
  v-if="entity.isEditable && entity.isItemEditable(item) && hasPermission(entity.permissions?.update)"
244
- @click="$emit('edit', item)"
244
+ @click="$emit('edit', item, index)"
245
245
  />
246
246
 
247
247
  <crud-delete-button
248
248
  v-if="entity.isDeletable && hasPermission(entity.permissions?.delete)"
249
- @click="$emit('delete', item)"
249
+ @click="$emit('delete', item, index)"
250
250
  />
251
251
  </v-card-actions>
252
252
  </v-card>
@@ -218,24 +218,24 @@ defineEmits(['import', 'export', 'create', 'update', 'delete', 'view', 'edit'])
218
218
  </template>
219
219
 
220
220
 
221
- <template v-slot:item.actions="{item}">
221
+ <template v-slot:item.actions="{item, index}">
222
222
 
223
- <slot name="item.actions" v-bind="{item}">
223
+ <slot name="item.actions" v-bind="{item, index}">
224
224
  </slot>
225
225
 
226
226
  <crud-view-button
227
227
  v-if="entity.isViewable && hasPermission(entity.permissions.view)"
228
- @click="$emit('view', item)"
228
+ @click="$emit('view', item, index)"
229
229
  />
230
230
 
231
231
  <crud-update-button
232
232
  v-if="entity.isEditable && entity.isItemEditable(item) && hasPermission(entity.permissions?.update)"
233
- @click="$emit('edit', item)"
233
+ @click="$emit('edit', item, index)"
234
234
  />
235
235
 
236
236
  <crud-delete-button
237
237
  v-if="entity.isDeletable && hasPermission(entity.permissions?.delete)"
238
- @click="$emit('delete', item)"
238
+ @click="$emit('delete', item, index)"
239
239
  />
240
240
 
241
241
  </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.default ? filter.default : null,
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,
@@ -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
  }
@@ -24,6 +24,8 @@ export function useFilterIcon() {
24
24
  return 'mdi-not-equal'
25
25
  case 'like':
26
26
  return 'mdi-contain'
27
+ case 'range':
28
+ return 'mdi-arrow-expand-horizontal'
27
29
  default:
28
30
  return 'mdi-equal'
29
31
  }
@@ -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.default ? filter.default : null, operator: (filter.operator ? filter.operator : 'eq')})
142
+ ({field: filter.name, value: createCrudFilterValue(filter), operator: (filter.operator ? filter.operator : 'eq')})
142
143
  ) as IDraxFieldFilter[]
143
144
  }
144
145
 
@@ -346,6 +347,10 @@ class EntityCrud implements IEntityCrud {
346
347
  return false
347
348
  }
348
349
 
350
+ get navigationOperations(): IEntityCrudOperation[] {
351
+ return ['view']
352
+ }
353
+
349
354
  }
350
355
 
351
356
  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";
@@ -26,6 +27,7 @@ export {
26
27
  CrudDialog,
27
28
  CrudForm,
28
29
  CrudFormField,
30
+ CrudFieldRange,
29
31
  CrudFormList,
30
32
  CrudList,
31
33
  CrudListTable,
@@ -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
  },