@fy-/fws-vue 2.2.64 → 2.2.66
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/components/fws/DataTable.vue +261 -49
- package/components/fws/FilterData.vue +172 -99
- package/components/fws/UserFlow.vue +13 -17
- package/package.json +1 -1
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
import {
|
|
3
3
|
ArrowDownIcon,
|
|
4
4
|
ArrowDownTrayIcon,
|
|
5
|
+
ArrowsUpDownIcon,
|
|
5
6
|
ArrowUpIcon,
|
|
7
|
+
MagnifyingGlassIcon,
|
|
8
|
+
XCircleIcon,
|
|
6
9
|
} from '@heroicons/vue/24/solid'
|
|
7
10
|
import { useStorage } from '@vueuse/core'
|
|
8
|
-
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
|
11
|
+
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
9
12
|
import { useRoute } from 'vue-router'
|
|
10
13
|
import { useEventBus } from '../../composables/event-bus'
|
|
11
14
|
import { useRest } from '../../composables/rest'
|
|
@@ -30,6 +33,8 @@ const currentPage = ref<number>(1)
|
|
|
30
33
|
const route = useRoute()
|
|
31
34
|
const data = ref<any[]>([])
|
|
32
35
|
const paging = ref<any>(undefined)
|
|
36
|
+
const searchTerm = ref<string>('')
|
|
37
|
+
const isLoading = ref<boolean>(false)
|
|
33
38
|
const perPageOptions = [
|
|
34
39
|
['10', '10'],
|
|
35
40
|
['25', '25'],
|
|
@@ -68,7 +73,14 @@ const currentSort = useStorage<SortingField>(
|
|
|
68
73
|
`${props.id}CurrentSort`,
|
|
69
74
|
props.defaultSort,
|
|
70
75
|
)
|
|
76
|
+
|
|
77
|
+
// Computed properties for better reactivity
|
|
78
|
+
const hasData = computed(() => data.value && data.value.length > 0)
|
|
79
|
+
const hasExportableColumns = computed(() => props.exportableColumns.length > 0)
|
|
80
|
+
const hasPaging = computed(() => paging.value && paging.value.page_max > 1 && paging.value.page_no)
|
|
81
|
+
|
|
71
82
|
async function getData(page: number = 1) {
|
|
83
|
+
isLoading.value = true
|
|
72
84
|
eventBus.emit('main-loading', true)
|
|
73
85
|
if (route.query.page) page = Number.parseInt(route.query.page.toString())
|
|
74
86
|
const sort: any = {}
|
|
@@ -78,20 +90,30 @@ async function getData(page: number = 1) {
|
|
|
78
90
|
sort,
|
|
79
91
|
results_per_page: perPage.value,
|
|
80
92
|
page_no: page,
|
|
93
|
+
search: searchTerm.value || undefined, // Only include if not empty
|
|
94
|
+
}
|
|
95
|
+
try {
|
|
96
|
+
const r = await restFunction(props.apiPath, 'GET', requestParams, {
|
|
97
|
+
getBody: true,
|
|
98
|
+
})
|
|
99
|
+
currentPage.value = page
|
|
100
|
+
data.value = []
|
|
101
|
+
paging.value = undefined
|
|
102
|
+
if (r && r.result === 'success') {
|
|
103
|
+
data.value = r.data
|
|
104
|
+
paging.value = r.paging
|
|
105
|
+
eventBus.emit(`${props.id}NewData`, data.value)
|
|
106
|
+
}
|
|
81
107
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
if (r && r.result === 'success') {
|
|
89
|
-
data.value = r.data
|
|
90
|
-
paging.value = r.paging
|
|
91
|
-
eventBus.emit(`${props.id}NewData`, data.value)
|
|
108
|
+
catch (error) {
|
|
109
|
+
console.error('Error fetching data:', error)
|
|
110
|
+
}
|
|
111
|
+
finally {
|
|
112
|
+
isLoading.value = false
|
|
113
|
+
eventBus.emit('main-loading', false)
|
|
92
114
|
}
|
|
93
|
-
eventBus.emit('main-loading', false)
|
|
94
115
|
}
|
|
116
|
+
|
|
95
117
|
function sortData(key: string) {
|
|
96
118
|
if (!props.sortables[key]) return
|
|
97
119
|
const newSort: SortingField = {
|
|
@@ -112,7 +134,10 @@ function sortData(key: string) {
|
|
|
112
134
|
}
|
|
113
135
|
currentSort.value = { ...newSort }
|
|
114
136
|
}
|
|
137
|
+
|
|
115
138
|
function exportToCsv() {
|
|
139
|
+
if (!hasData.value || !hasExportableColumns.value) return
|
|
140
|
+
|
|
116
141
|
const header = props.exportableColumns
|
|
117
142
|
.map(column => props.headers[column] ?? column)
|
|
118
143
|
.join(',')
|
|
@@ -149,6 +174,27 @@ function exportToCsv() {
|
|
|
149
174
|
link.click()
|
|
150
175
|
document.body.removeChild(link)
|
|
151
176
|
}
|
|
177
|
+
|
|
178
|
+
function handleSearch() {
|
|
179
|
+
getData(1) // Reset to page 1 when searching
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function clearSearch() {
|
|
183
|
+
searchTerm.value = ''
|
|
184
|
+
getData(1)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Debounced search
|
|
188
|
+
let searchTimeout: number | null = null
|
|
189
|
+
function debouncedSearch() {
|
|
190
|
+
if (searchTimeout) {
|
|
191
|
+
clearTimeout(searchTimeout)
|
|
192
|
+
}
|
|
193
|
+
searchTimeout = window.setTimeout(() => {
|
|
194
|
+
handleSearch()
|
|
195
|
+
}, 400)
|
|
196
|
+
}
|
|
197
|
+
|
|
152
198
|
watch(perPage, () => {
|
|
153
199
|
getData()
|
|
154
200
|
})
|
|
@@ -181,47 +227,94 @@ onUnmounted(() => {
|
|
|
181
227
|
</script>
|
|
182
228
|
|
|
183
229
|
<template>
|
|
184
|
-
<div>
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
230
|
+
<div class="data-table-container bg-white dark:bg-fv-neutral-900 rounded-lg shadow-sm hover:shadow-md transition-all duration-300 overflow-hidden">
|
|
231
|
+
<!-- Table header with controls -->
|
|
232
|
+
<div class="table-controls p-4 border-b border-fv-neutral-200 dark:border-fv-neutral-800">
|
|
233
|
+
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
234
|
+
<!-- Search input -->
|
|
235
|
+
<div class="search-container relative flex-grow max-w-md">
|
|
236
|
+
<div class="relative">
|
|
237
|
+
<input
|
|
238
|
+
v-model="searchTerm"
|
|
239
|
+
type="search"
|
|
240
|
+
class="w-full pl-10 pr-4 py-2 text-sm rounded-lg border border-fv-neutral-300 dark:border-fv-neutral-700 bg-white dark:bg-fv-neutral-800 text-fv-neutral-900 dark:text-white focus:ring-2 focus:ring-fv-primary-500 dark:focus:ring-fv-primary-600 focus:border-fv-primary-500 dark:focus:border-fv-primary-600 transition-colors duration-200"
|
|
241
|
+
:placeholder="$t('search_placeholder')"
|
|
242
|
+
@input="debouncedSearch"
|
|
243
|
+
@keyup.enter="handleSearch"
|
|
244
|
+
>
|
|
245
|
+
<div class="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none">
|
|
246
|
+
<MagnifyingGlassIcon class="w-4 h-4 text-fv-neutral-500 dark:text-fv-neutral-400" aria-hidden="true" />
|
|
247
|
+
</div>
|
|
248
|
+
<button
|
|
249
|
+
v-if="searchTerm"
|
|
250
|
+
type="button"
|
|
251
|
+
class="absolute inset-y-0 right-0 flex items-center pr-3 text-fv-neutral-500 hover:text-fv-neutral-700 dark:text-fv-neutral-400 dark:hover:text-fv-neutral-200"
|
|
252
|
+
aria-label="Clear search"
|
|
253
|
+
@click="clearSearch"
|
|
254
|
+
>
|
|
255
|
+
<XCircleIcon class="w-5 h-5" aria-hidden="true" />
|
|
256
|
+
</button>
|
|
257
|
+
</div>
|
|
258
|
+
</div>
|
|
259
|
+
|
|
260
|
+
<!-- Export and per page settings -->
|
|
261
|
+
<div class="flex items-center gap-2">
|
|
262
|
+
<button
|
|
263
|
+
v-if="hasExportableColumns && hasData"
|
|
264
|
+
class="export-btn flex items-center justify-center gap-2 px-3 py-2 bg-fv-neutral-100 hover:bg-fv-neutral-200 text-fv-neutral-800 rounded-lg text-sm transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-fv-neutral-400 dark:bg-fv-neutral-800 dark:hover:bg-fv-neutral-700 dark:text-fv-neutral-200"
|
|
265
|
+
@click="exportToCsv"
|
|
266
|
+
>
|
|
267
|
+
<ArrowDownTrayIcon class="w-4 h-4" aria-hidden="true" />
|
|
268
|
+
<span class="hidden sm:inline">{{ $t("global_table_export") }}</span>
|
|
269
|
+
</button>
|
|
270
|
+
|
|
271
|
+
<div class="flex items-center gap-2">
|
|
272
|
+
<label for="perPageSelect" class="text-sm font-medium text-fv-neutral-600 dark:text-fv-neutral-400 whitespace-nowrap hidden sm:block">
|
|
273
|
+
{{ $t("per_page") }}:
|
|
274
|
+
</label>
|
|
275
|
+
<DefaultInput
|
|
276
|
+
id="perPageSelect"
|
|
277
|
+
v-model="perPage"
|
|
278
|
+
:options="perPageOptions"
|
|
279
|
+
:show-label="false"
|
|
280
|
+
type="select"
|
|
281
|
+
class="w-20"
|
|
282
|
+
/>
|
|
283
|
+
</div>
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
|
|
287
|
+
<!-- Pagination - top -->
|
|
288
|
+
<div v-if="hasPaging" class="mt-3">
|
|
289
|
+
<DefaultPaging :id="`${props.id}Pages`" :items="paging" />
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
<!-- Loading state -->
|
|
294
|
+
<div v-if="isLoading" class="flex justify-center items-center py-12">
|
|
295
|
+
<div class="loading-spinner w-8 h-8 border-4 border-fv-neutral-200 dark:border-fv-neutral-700 border-t-fv-primary-600 dark:border-t-fv-primary-500 rounded-full animate-spin" />
|
|
204
296
|
</div>
|
|
205
297
|
|
|
298
|
+
<!-- Table view -->
|
|
206
299
|
<div
|
|
207
|
-
v-if="
|
|
208
|
-
class="
|
|
300
|
+
v-else-if="hasData"
|
|
301
|
+
class="overflow-x-auto"
|
|
209
302
|
>
|
|
210
303
|
<table
|
|
211
|
-
class="w-full text-sm text-left
|
|
304
|
+
class="w-full text-sm text-left"
|
|
212
305
|
>
|
|
213
306
|
<thead
|
|
214
307
|
v-if="showHeaders"
|
|
215
|
-
class="text-xs
|
|
308
|
+
class="text-xs uppercase bg-fv-neutral-50 dark:bg-fv-neutral-800 text-fv-neutral-700 dark:text-fv-neutral-300"
|
|
216
309
|
>
|
|
217
310
|
<tr>
|
|
218
311
|
<th
|
|
219
312
|
v-for="(header, key) in headers"
|
|
220
313
|
:key="key"
|
|
221
314
|
scope="col"
|
|
222
|
-
class="px-6 py-
|
|
315
|
+
class="px-6 py-4 whitespace-nowrap font-semibold"
|
|
223
316
|
:class="{
|
|
224
|
-
'cursor-pointer': sortables[key],
|
|
317
|
+
'cursor-pointer hover:bg-fv-neutral-100 dark:hover:bg-fv-neutral-750 transition-colors duration-200': sortables[key],
|
|
225
318
|
}"
|
|
226
319
|
@click="
|
|
227
320
|
() => {
|
|
@@ -231,14 +324,22 @@ onUnmounted(() => {
|
|
|
231
324
|
}
|
|
232
325
|
"
|
|
233
326
|
>
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
<
|
|
237
|
-
v-if="currentSort.
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
327
|
+
<div class="flex items-center gap-1">
|
|
328
|
+
{{ header }}
|
|
329
|
+
<span v-if="sortables[key]" class="inline-flex">
|
|
330
|
+
<ArrowsUpDownIcon v-if="currentSort.field !== key" class="w-4 h-4 text-fv-neutral-400 dark:text-fv-neutral-500" aria-hidden="true" />
|
|
331
|
+
<ArrowUpIcon
|
|
332
|
+
v-else-if="currentSort.direction === 'asc'"
|
|
333
|
+
class="w-4 h-4 text-fv-primary-600 dark:text-fv-primary-400"
|
|
334
|
+
aria-hidden="true"
|
|
335
|
+
/>
|
|
336
|
+
<ArrowDownIcon
|
|
337
|
+
v-else
|
|
338
|
+
class="w-4 h-4 text-fv-primary-600 dark:text-fv-primary-400"
|
|
339
|
+
aria-hidden="true"
|
|
340
|
+
/>
|
|
341
|
+
</span>
|
|
342
|
+
</div>
|
|
242
343
|
</th>
|
|
243
344
|
</tr>
|
|
244
345
|
</thead>
|
|
@@ -246,15 +347,19 @@ onUnmounted(() => {
|
|
|
246
347
|
<tr
|
|
247
348
|
v-for="(row, index) in data"
|
|
248
349
|
:key="index"
|
|
249
|
-
class="bg-white border-b dark:bg-fv-neutral-900 dark:border-fv-neutral-800 hover:bg-fv-neutral-50 dark:hover:bg-fv-neutral-
|
|
350
|
+
class="bg-white border-b dark:bg-fv-neutral-900 dark:border-fv-neutral-800 hover:bg-fv-neutral-50 dark:hover:bg-fv-neutral-850 transition-colors duration-200"
|
|
250
351
|
>
|
|
251
|
-
<td
|
|
352
|
+
<td
|
|
353
|
+
v-for="(header, key) in headers"
|
|
354
|
+
:key="key"
|
|
355
|
+
class="px-6 py-4 align-middle"
|
|
356
|
+
>
|
|
252
357
|
<slot :name="key" :value="row">
|
|
253
358
|
<template v-if="row[key]">
|
|
254
359
|
{{ row[key] }}
|
|
255
360
|
</template>
|
|
256
361
|
<template v-else>
|
|
257
|
-
{{ $t("global_table_empty_cell") }}
|
|
362
|
+
<span class="text-fv-neutral-400 dark:text-fv-neutral-600">{{ $t("global_table_empty_cell") }}</span>
|
|
258
363
|
</template>
|
|
259
364
|
</slot>
|
|
260
365
|
</td>
|
|
@@ -262,5 +367,112 @@ onUnmounted(() => {
|
|
|
262
367
|
</tbody>
|
|
263
368
|
</table>
|
|
264
369
|
</div>
|
|
370
|
+
|
|
371
|
+
<!-- Empty state -->
|
|
372
|
+
<div v-else class="py-12 px-4 text-center">
|
|
373
|
+
<div class="empty-state flex flex-col items-center justify-center">
|
|
374
|
+
<svg class="w-16 h-16 text-fv-neutral-300 dark:text-fv-neutral-700 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
|
375
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M4 6h16M4 10h16M4 14h16M4 18h16" />
|
|
376
|
+
</svg>
|
|
377
|
+
<p class="text-lg font-medium text-fv-neutral-700 dark:text-fv-neutral-300">
|
|
378
|
+
{{ $t("no_data_found") }}
|
|
379
|
+
</p>
|
|
380
|
+
<p class="text-sm text-fv-neutral-500 dark:text-fv-neutral-400 max-w-md mt-1">
|
|
381
|
+
{{ $t("try_another_search") }}
|
|
382
|
+
</p>
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
|
|
386
|
+
<!-- Pagination - bottom -->
|
|
387
|
+
<div v-if="hasPaging" class="px-4 py-3 border-t border-fv-neutral-200 dark:border-fv-neutral-800">
|
|
388
|
+
<DefaultPaging :id="`${props.id}Pages`" :items="paging" />
|
|
389
|
+
</div>
|
|
265
390
|
</div>
|
|
266
391
|
</template>
|
|
392
|
+
|
|
393
|
+
<style scoped>
|
|
394
|
+
.data-table-container {
|
|
395
|
+
@apply transition-all duration-300;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* Responsive styles */
|
|
399
|
+
@media (max-width: 640px) {
|
|
400
|
+
.table-controls {
|
|
401
|
+
@apply p-3;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
td, th {
|
|
405
|
+
@apply px-3 py-3;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/* Loading spinner animation */
|
|
410
|
+
@keyframes spin {
|
|
411
|
+
to {
|
|
412
|
+
transform: rotate(360deg);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
.loading-spinner {
|
|
417
|
+
animation: spin 1s linear infinite;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/* Fade in animation for rows */
|
|
421
|
+
tbody tr {
|
|
422
|
+
animation: fadeIn 0.2s ease-out forwards;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
@keyframes fadeIn {
|
|
426
|
+
from { opacity: 0.5; }
|
|
427
|
+
to { opacity: 1; }
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/* Improved hover states for better interactivity */
|
|
431
|
+
th, td {
|
|
432
|
+
@apply transition-colors duration-200;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/* Zebra striping for better readability */
|
|
436
|
+
@media (prefers-reduced-motion: no-preference) {
|
|
437
|
+
tbody tr:nth-child(odd) {
|
|
438
|
+
@apply bg-fv-neutral-50 dark:bg-fv-neutral-850;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
tbody tr:nth-child(odd):hover {
|
|
442
|
+
@apply bg-fv-neutral-100 dark:bg-fv-neutral-800;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/* Accessible focus styles */
|
|
447
|
+
button:focus-visible,
|
|
448
|
+
a:focus-visible {
|
|
449
|
+
@apply outline-none ring-2 ring-fv-primary-500 ring-offset-2 dark:ring-offset-fv-neutral-900;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/* Export button hover effect */
|
|
453
|
+
.export-btn {
|
|
454
|
+
@apply relative overflow-hidden;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
.export-btn::after {
|
|
458
|
+
content: '';
|
|
459
|
+
@apply absolute inset-0 opacity-0 transition-opacity duration-200;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
.export-btn:hover::after {
|
|
463
|
+
@apply opacity-10 bg-black dark:bg-white;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/* Additional dark mode color for better contrast */
|
|
467
|
+
.dark .data-table-container {
|
|
468
|
+
@apply bg-fv-neutral-900;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
.dark tbody tr:nth-child(odd) {
|
|
472
|
+
@apply bg-fv-neutral-850;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
.dark tbody tr:hover {
|
|
476
|
+
@apply bg-fv-neutral-800;
|
|
477
|
+
}
|
|
478
|
+
</style>
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import type { FilterDataItems } from '../../types'
|
|
3
|
+
import { AdjustmentsHorizontalIcon, ArrowPathIcon, FunnelIcon, XMarkIcon } from '@heroicons/vue/24/solid'
|
|
3
4
|
import useVuelidate from '@vuelidate/core'
|
|
4
5
|
import { onMounted, onUnmounted, reactive, ref } from 'vue'
|
|
5
6
|
import { useEventBus } from '../../composables/event-bus'
|
|
@@ -125,115 +126,187 @@ onUnmounted(() => {
|
|
|
125
126
|
</script>
|
|
126
127
|
|
|
127
128
|
<template>
|
|
128
|
-
<
|
|
129
|
-
<
|
|
130
|
-
<div
|
|
131
|
-
<
|
|
132
|
-
<
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
"
|
|
158
|
-
@change="
|
|
159
|
-
(ev: any) => {
|
|
160
|
-
if (f.onChangeValue) {
|
|
161
|
-
f.onChangeValue(state.formData, ev);
|
|
129
|
+
<div class="filter-data-wrapper mb-6">
|
|
130
|
+
<form v-if="!hidden" class="filter-data-form bg-white dark:bg-fv-neutral-900 rounded-lg border border-fv-neutral-200 dark:border-fv-neutral-800 shadow-sm hover:shadow-md transition-all duration-300 p-4" @submit.prevent="() => submitForm()">
|
|
131
|
+
<div class="flex items-center justify-between mb-4 border-b border-fv-neutral-200 dark:border-fv-neutral-800 pb-3">
|
|
132
|
+
<h3 class="text-lg font-medium text-fv-neutral-900 dark:text-white flex items-center">
|
|
133
|
+
<FunnelIcon class="w-5 h-5 mr-2 text-fv-primary-600 dark:text-fv-primary-400" aria-hidden="true" />
|
|
134
|
+
{{ $t("filters_title") }}
|
|
135
|
+
</h3>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div :class="`${css} grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4`">
|
|
139
|
+
<div v-for="(g, i) in data" :key="`index_${i}`" class="relative">
|
|
140
|
+
<template v-for="f in g" :key="f.uid">
|
|
141
|
+
<template v-if="!f.isHidden">
|
|
142
|
+
<DefaultInput
|
|
143
|
+
v-if="
|
|
144
|
+
['text', 'select', 'date', 'email', 'autocomplete'].includes(
|
|
145
|
+
f.type,
|
|
146
|
+
)
|
|
147
|
+
"
|
|
148
|
+
:id="f.uid"
|
|
149
|
+
v-model="state.formData[f.uid]"
|
|
150
|
+
:type="f.type === 'autocomplete' ? 'text' : f.type"
|
|
151
|
+
:label="f.label"
|
|
152
|
+
:options="f.options ? f.options : [[]]"
|
|
153
|
+
:error-vuelidate="v$.formData[f.uid].$errors"
|
|
154
|
+
@focus="
|
|
155
|
+
() => {
|
|
156
|
+
f.focused = true;
|
|
157
|
+
$eventBus.emit('focusInput', true);
|
|
162
158
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
fDynamicOptions = [];
|
|
169
|
-
f.autocomplete(v).then((r:any) => {
|
|
170
|
-
fDynamicOptions = r;
|
|
171
|
-
});
|
|
159
|
+
"
|
|
160
|
+
@blur="
|
|
161
|
+
() => {
|
|
162
|
+
f.focused = false;
|
|
163
|
+
$eventBus.emit('focusInput', false);
|
|
172
164
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
:
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
state.formData[f.uid] = o[0];
|
|
165
|
+
"
|
|
166
|
+
@change="
|
|
167
|
+
(ev: any) => {
|
|
168
|
+
if (f.onChangeValue) {
|
|
169
|
+
f.onChangeValue(state.formData, ev);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
"
|
|
173
|
+
@update:model-value="
|
|
174
|
+
(v:any) => {
|
|
175
|
+
if (f.autocomplete && v.length >= 2) {
|
|
176
|
+
fDynamicOptions = [];
|
|
177
|
+
f.autocomplete(v).then((r:any) => {
|
|
178
|
+
fDynamicOptions = r;
|
|
179
|
+
});
|
|
189
180
|
}
|
|
190
|
-
|
|
181
|
+
}
|
|
182
|
+
"
|
|
183
|
+
>
|
|
184
|
+
<div
|
|
185
|
+
v-if="f.type === 'autocomplete' && f.focused && fDynamicOptions.length > 0"
|
|
186
|
+
class="absolute flex flex-col gap-2 p-2 bottom-0 translate-y-full inset-x-0 bg-white dark:bg-fv-neutral-800 border border-fv-neutral-200 dark:border-fv-neutral-700 z-10 rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
|
191
187
|
>
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
188
|
+
<button
|
|
189
|
+
v-for="o in fDynamicOptions"
|
|
190
|
+
:key="o[0]"
|
|
191
|
+
class="flex items-center justify-between p-2 text-fv-neutral-800 dark:text-fv-neutral-200 hover:bg-fv-neutral-100 dark:hover:bg-fv-neutral-700 rounded transition-colors duration-200"
|
|
192
|
+
type="button"
|
|
193
|
+
@click.prevent="
|
|
194
|
+
() => {
|
|
195
|
+
f.focused = false;
|
|
196
|
+
state.formData[f.uid] = o[0];
|
|
197
|
+
}
|
|
198
|
+
"
|
|
199
|
+
>
|
|
200
|
+
<span class="font-medium">{{ o[1] }}</span>
|
|
201
|
+
<small v-if="o[0] !== ''" class="text-fv-neutral-500 dark:text-fv-neutral-400 ml-2">({{ o[0] }})</small>
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
</DefaultInput>
|
|
205
|
+
|
|
206
|
+
<DefaultDateSelection
|
|
207
|
+
v-if="f.type === 'range'"
|
|
208
|
+
:id="f.uid"
|
|
209
|
+
v-model="state.formData[f.uid]"
|
|
210
|
+
:label="f.label"
|
|
211
|
+
mode="interval"
|
|
212
|
+
/>
|
|
213
|
+
</template>
|
|
205
214
|
</template>
|
|
206
|
-
</
|
|
215
|
+
</div>
|
|
207
216
|
</div>
|
|
208
|
-
</div>
|
|
209
217
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
218
|
+
<div class="flex flex-wrap justify-between items-center mt-6 gap-2">
|
|
219
|
+
<div class="flex flex-wrap gap-2">
|
|
220
|
+
<button
|
|
221
|
+
type="submit"
|
|
222
|
+
class="btn-filter flex items-center justify-center gap-2 px-4 py-2 bg-fv-primary-600 hover:bg-fv-primary-700 text-white font-medium rounded-lg text-sm transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-fv-primary-500 focus:ring-offset-2 dark:focus:ring-offset-fv-neutral-900"
|
|
223
|
+
>
|
|
224
|
+
<AdjustmentsHorizontalIcon class="w-4 h-4" aria-hidden="true" />
|
|
225
|
+
{{ $t("filters_search_cta") }}
|
|
226
|
+
</button>
|
|
227
|
+
|
|
228
|
+
<button
|
|
229
|
+
type="reset"
|
|
230
|
+
class="btn-reset flex items-center justify-center gap-2 px-4 py-2 bg-fv-neutral-100 hover:bg-fv-neutral-200 text-fv-neutral-800 font-medium rounded-lg text-sm transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-fv-neutral-400 focus:ring-offset-2 dark:bg-fv-neutral-800 dark:hover:bg-fv-neutral-700 dark:text-fv-neutral-200 dark:focus:ring-offset-fv-neutral-900"
|
|
231
|
+
@click.prevent="() => { resetForm(); }"
|
|
232
|
+
>
|
|
233
|
+
<ArrowPathIcon class="w-4 h-4" aria-hidden="true" />
|
|
234
|
+
{{ $t("filters_clear_cta") }}
|
|
235
|
+
</button>
|
|
236
|
+
</div>
|
|
237
|
+
|
|
238
|
+
<button
|
|
239
|
+
type="button"
|
|
240
|
+
class="btn-hide flex items-center justify-center gap-2 px-4 py-2 border border-fv-neutral-300 dark:border-fv-neutral-700 bg-white hover:bg-fv-neutral-50 text-fv-neutral-700 font-medium rounded-lg text-sm transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-fv-neutral-400 focus:ring-offset-2 dark:bg-fv-neutral-900 dark:hover:bg-fv-neutral-800 dark:text-fv-neutral-300 dark:focus:ring-offset-fv-neutral-900"
|
|
241
|
+
@click="hidden = true"
|
|
242
|
+
>
|
|
243
|
+
<XMarkIcon class="w-4 h-4" aria-hidden="true" />
|
|
244
|
+
{{ $t("hide_filters_cta") }}
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
</form>
|
|
248
|
+
|
|
249
|
+
<div v-else class="text-center">
|
|
217
250
|
<button
|
|
218
|
-
type="
|
|
219
|
-
class="
|
|
220
|
-
@click
|
|
221
|
-
() => {
|
|
222
|
-
resetForm();
|
|
223
|
-
}
|
|
224
|
-
"
|
|
251
|
+
type="button"
|
|
252
|
+
class="inline-flex items-center justify-center gap-2 px-4 py-2 bg-fv-primary-100 hover:bg-fv-primary-200 text-fv-primary-800 font-medium rounded-lg text-sm transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-fv-primary-500 focus:ring-offset-2 dark:bg-fv-primary-900 dark:hover:bg-fv-primary-800 dark:text-fv-primary-200 dark:focus:ring-offset-fv-neutral-900"
|
|
253
|
+
@click="hidden = false"
|
|
225
254
|
>
|
|
226
|
-
|
|
255
|
+
<FunnelIcon class="w-4 h-4" aria-hidden="true" />
|
|
256
|
+
{{ $t("show_filters_cta") }}
|
|
227
257
|
</button>
|
|
228
258
|
</div>
|
|
229
|
-
</form>
|
|
230
|
-
<div v-else class="flex justify-between mt-2 gap-x-2">
|
|
231
|
-
<button
|
|
232
|
-
type="button"
|
|
233
|
-
class="btn defaults primary !w-full flex-1 !text-center !items-center"
|
|
234
|
-
@click="hidden = false"
|
|
235
|
-
>
|
|
236
|
-
{{ $t("show_filters_cta") }}
|
|
237
|
-
</button>
|
|
238
259
|
</div>
|
|
239
260
|
</template>
|
|
261
|
+
|
|
262
|
+
<style scoped>
|
|
263
|
+
.filter-data-wrapper {
|
|
264
|
+
@apply transition-all duration-300;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
.filter-data-form {
|
|
268
|
+
@apply animate-fadeIn;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
@keyframes fadeIn {
|
|
272
|
+
from { opacity: 0; transform: translateY(-10px); }
|
|
273
|
+
to { opacity: 1; transform: translateY(0); }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.animate-fadeIn {
|
|
277
|
+
animation: fadeIn 0.3s ease-out forwards;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/* Responsive styles */
|
|
281
|
+
@media (max-width: 640px) {
|
|
282
|
+
.filter-data-form {
|
|
283
|
+
@apply p-3;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* Button hover effects */
|
|
288
|
+
.btn-filter, .btn-reset, .btn-hide {
|
|
289
|
+
@apply relative overflow-hidden;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
.btn-filter::after,
|
|
293
|
+
.btn-reset::after,
|
|
294
|
+
.btn-hide::after {
|
|
295
|
+
content: '';
|
|
296
|
+
@apply absolute inset-0 opacity-0 transition-opacity duration-200;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
.btn-filter:hover::after {
|
|
300
|
+
@apply opacity-10 bg-white;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.btn-reset:hover::after,
|
|
304
|
+
.btn-hide:hover::after {
|
|
305
|
+
@apply opacity-10 bg-black;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/* Improved focus styles for accessibility */
|
|
309
|
+
button:focus-visible {
|
|
310
|
+
@apply outline-none ring-2 ring-fv-primary-500 ring-offset-2 dark:ring-offset-fv-neutral-900;
|
|
311
|
+
}
|
|
312
|
+
</style>
|
|
@@ -237,25 +237,19 @@ onMounted(async () => {
|
|
|
237
237
|
|
|
238
238
|
<!-- OAuth providers section -->
|
|
239
239
|
<div v-if="hasOauth && !showEmail" class="fws-login__oauth space-y-3">
|
|
240
|
-
<
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
>
|
|
245
|
-
<button
|
|
246
|
-
type="button"
|
|
240
|
+
<template v-for="field of responseFields" :key="field.id">
|
|
241
|
+
<a
|
|
242
|
+
v-if="field.type && field.type === 'oauth2' && field.button"
|
|
243
|
+
href="javascript:void(0);"
|
|
247
244
|
class="flex w-full items-center justify-start gap-3 px-4 py-2.5 rounded-lg border border-fv-neutral-200 dark:border-fv-neutral-700
|
|
248
245
|
transition-all duration-200 hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-fv-neutral-800
|
|
249
246
|
focus:ring-fv-primary-500 dark:focus:ring-fv-primary-600"
|
|
250
|
-
:style="
|
|
251
|
-
field.button['background-color']
|
|
252
|
-
|
|
253
|
-
field.button['background-color'],
|
|
254
|
-
)}`"
|
|
255
|
-
:aria-label="`${$t('user_flow_signin_with', { provider: field.name })}`"
|
|
247
|
+
:style="field.button && field.button['background-color']
|
|
248
|
+
? `background: ${field.button['background-color']}; color: ${$getContrastingTextColor(field.button['background-color'])}`
|
|
249
|
+
: ''"
|
|
256
250
|
@click="
|
|
257
251
|
() => {
|
|
258
|
-
if (field.info.Button_Extra
|
|
252
|
+
if (field.info && field.info.Button_Extra && field.info.Button_Extra.trigger) {
|
|
259
253
|
doTrigger(field);
|
|
260
254
|
}
|
|
261
255
|
else {
|
|
@@ -265,16 +259,17 @@ onMounted(async () => {
|
|
|
265
259
|
"
|
|
266
260
|
>
|
|
267
261
|
<img
|
|
262
|
+
v-if="field.button && field.button.logo"
|
|
268
263
|
:key="`${field.label}oauth`"
|
|
269
264
|
class="h-6 w-6 flex-shrink-0"
|
|
270
|
-
:alt="field.info.Name"
|
|
265
|
+
:alt="field.info && field.info.Name ? field.info.Name : ''"
|
|
271
266
|
:src="field.button.logo"
|
|
272
267
|
>
|
|
273
268
|
<span class="text-base font-medium">
|
|
274
269
|
{{ $t("user_flow_signin_with", { provider: field.name }) }}
|
|
275
270
|
</span>
|
|
276
|
-
</
|
|
277
|
-
</
|
|
271
|
+
</a>
|
|
272
|
+
</template>
|
|
278
273
|
|
|
279
274
|
<div class="relative my-6">
|
|
280
275
|
<div class="absolute inset-0 flex items-center">
|
|
@@ -429,6 +424,7 @@ onMounted(async () => {
|
|
|
429
424
|
@apply transition-all duration-300;
|
|
430
425
|
}
|
|
431
426
|
|
|
427
|
+
.fws-login__oauth a,
|
|
432
428
|
.fws-login__oauth button,
|
|
433
429
|
.fws-login__form button[type="submit"] {
|
|
434
430
|
@apply transition-all duration-200;
|