@afurgeri/crud-vue 0.1.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 +46 -0
- package/src/__test__/CrudCards.test.ts +118 -0
- package/src/__test__/CrudEmptyState.test.ts +82 -0
- package/src/__test__/CrudForm.test.ts +101 -0
- package/src/__test__/CrudShow.test.ts +135 -0
- package/src/__test__/CrudTable.test.ts +111 -0
- package/src/__test__/CrudToolbar.test.ts +102 -0
- package/src/__test__/setup.ts +43 -0
- package/src/__test__/useCrud.test.ts +349 -0
- package/src/components/CrudCards.vue +105 -0
- package/src/components/CrudDeleteDialog.vue +80 -0
- package/src/components/CrudEmptyState.vue +58 -0
- package/src/components/CrudFilters.vue +194 -0
- package/src/components/CrudForm.vue +232 -0
- package/src/components/CrudPage.vue +206 -0
- package/src/components/CrudPagination.vue +130 -0
- package/src/components/CrudSearch.vue +42 -0
- package/src/components/CrudShow.vue +216 -0
- package/src/components/CrudTable.vue +146 -0
- package/src/components/CrudToolbar.vue +86 -0
- package/src/components/InputError.vue +13 -0
- package/src/components/ui/button/Button.vue +27 -0
- package/src/components/ui/button/index.ts +36 -0
- package/src/components/ui/card/Card.vue +22 -0
- package/src/components/ui/card/CardAction.vue +17 -0
- package/src/components/ui/card/CardContent.vue +17 -0
- package/src/components/ui/card/CardDescription.vue +17 -0
- package/src/components/ui/card/CardFooter.vue +17 -0
- package/src/components/ui/card/CardHeader.vue +17 -0
- package/src/components/ui/card/CardTitle.vue +17 -0
- package/src/components/ui/card/index.ts +7 -0
- package/src/components/ui/checkbox/Checkbox.vue +37 -0
- package/src/components/ui/checkbox/index.ts +1 -0
- package/src/components/ui/combobox/ComboboxInput.vue +83 -0
- package/src/components/ui/combobox/index.ts +1 -0
- package/src/components/ui/dialog/Dialog.vue +17 -0
- package/src/components/ui/dialog/DialogClose.vue +14 -0
- package/src/components/ui/dialog/DialogContent.vue +49 -0
- package/src/components/ui/dialog/DialogDescription.vue +25 -0
- package/src/components/ui/dialog/DialogFooter.vue +15 -0
- package/src/components/ui/dialog/DialogHeader.vue +17 -0
- package/src/components/ui/dialog/DialogOverlay.vue +23 -0
- package/src/components/ui/dialog/DialogScrollContent.vue +59 -0
- package/src/components/ui/dialog/DialogTitle.vue +25 -0
- package/src/components/ui/dialog/DialogTrigger.vue +14 -0
- package/src/components/ui/dialog/index.ts +10 -0
- package/src/components/ui/dropdown-menu/DropdownMenu.vue +17 -0
- package/src/components/ui/dropdown-menu/DropdownMenuCheckboxItem.vue +41 -0
- package/src/components/ui/dropdown-menu/DropdownMenuContent.vue +39 -0
- package/src/components/ui/dropdown-menu/DropdownMenuGroup.vue +14 -0
- package/src/components/ui/dropdown-menu/DropdownMenuItem.vue +30 -0
- package/src/components/ui/dropdown-menu/DropdownMenuLabel.vue +22 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioGroup.vue +22 -0
- package/src/components/ui/dropdown-menu/DropdownMenuRadioItem.vue +42 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSeparator.vue +26 -0
- package/src/components/ui/dropdown-menu/DropdownMenuShortcut.vue +17 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSub.vue +19 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubContent.vue +31 -0
- package/src/components/ui/dropdown-menu/DropdownMenuSubTrigger.vue +30 -0
- package/src/components/ui/dropdown-menu/DropdownMenuTrigger.vue +16 -0
- package/src/components/ui/dropdown-menu/index.ts +16 -0
- package/src/components/ui/input/Input.vue +33 -0
- package/src/components/ui/input/index.ts +1 -0
- package/src/components/ui/label/Label.vue +28 -0
- package/src/components/ui/label/index.ts +1 -0
- package/src/components/ui/select/Select.vue +15 -0
- package/src/components/ui/select/SelectContent.vue +49 -0
- package/src/components/ui/select/SelectGroup.vue +17 -0
- package/src/components/ui/select/SelectItem.vue +41 -0
- package/src/components/ui/select/SelectItemText.vue +12 -0
- package/src/components/ui/select/SelectLabel.vue +14 -0
- package/src/components/ui/select/SelectScrollDownButton.vue +22 -0
- package/src/components/ui/select/SelectScrollUpButton.vue +22 -0
- package/src/components/ui/select/SelectSeparator.vue +15 -0
- package/src/components/ui/select/SelectTrigger.vue +29 -0
- package/src/components/ui/select/SelectValue.vue +12 -0
- package/src/components/ui/select/index.ts +11 -0
- package/src/components/ui/separator/Separator.vue +28 -0
- package/src/components/ui/separator/index.ts +1 -0
- package/src/components/ui/spinner/Spinner.vue +17 -0
- package/src/components/ui/spinner/index.ts +1 -0
- package/src/components/ui/table/Table.vue +16 -0
- package/src/components/ui/table/TableBody.vue +14 -0
- package/src/components/ui/table/TableCaption.vue +14 -0
- package/src/components/ui/table/TableCell.vue +21 -0
- package/src/components/ui/table/TableEmpty.vue +34 -0
- package/src/components/ui/table/TableFooter.vue +14 -0
- package/src/components/ui/table/TableHead.vue +14 -0
- package/src/components/ui/table/TableHeader.vue +14 -0
- package/src/components/ui/table/TableRow.vue +14 -0
- package/src/components/ui/table/index.ts +9 -0
- package/src/composables/useCrud.ts +328 -0
- package/src/index.ts +33 -0
- package/src/lib/utils.ts +18 -0
- package/src/types/crud.ts +133 -0
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Button } from './ui/button';
|
|
3
|
+
import { FileX, Plus, SearchX } from 'lucide-vue-next';
|
|
4
|
+
import { router } from '@inertiajs/vue3';
|
|
5
|
+
import type { CrudFeatureCreate } from '../types/crud';
|
|
6
|
+
|
|
7
|
+
defineProps<{
|
|
8
|
+
hasFilters: boolean;
|
|
9
|
+
create?: boolean | CrudFeatureCreate;
|
|
10
|
+
}>();
|
|
11
|
+
|
|
12
|
+
defineEmits<{
|
|
13
|
+
clearFilters: [];
|
|
14
|
+
}>();
|
|
15
|
+
</script>
|
|
16
|
+
|
|
17
|
+
<template>
|
|
18
|
+
<div class="flex flex-col items-center justify-center px-6 py-16 text-center">
|
|
19
|
+
<slot name="icon">
|
|
20
|
+
<div class="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-muted">
|
|
21
|
+
<FileX v-if="!hasFilters" class="h-8 w-8 text-muted-foreground" />
|
|
22
|
+
<SearchX v-else class="h-8 w-8 text-muted-foreground" />
|
|
23
|
+
</div>
|
|
24
|
+
</slot>
|
|
25
|
+
|
|
26
|
+
<slot name="message">
|
|
27
|
+
<h3 v-if="!hasFilters" class="text-lg font-semibold text-foreground">No records yet</h3>
|
|
28
|
+
<h3 v-else class="text-lg font-semibold text-foreground">No results found</h3>
|
|
29
|
+
|
|
30
|
+
<p v-if="!hasFilters" class="mt-2 max-w-sm text-sm text-muted-foreground">
|
|
31
|
+
Get started by creating your first record.
|
|
32
|
+
</p>
|
|
33
|
+
<p v-else class="mt-2 max-w-sm text-sm text-muted-foreground">
|
|
34
|
+
Try adjusting your search or filter criteria to find what you're looking for.
|
|
35
|
+
</p>
|
|
36
|
+
</slot>
|
|
37
|
+
|
|
38
|
+
<slot name="actions">
|
|
39
|
+
<div class="mt-6 flex gap-3">
|
|
40
|
+
<Button
|
|
41
|
+
v-if="hasFilters"
|
|
42
|
+
variant="outline"
|
|
43
|
+
@click="$emit('clearFilters')"
|
|
44
|
+
>
|
|
45
|
+
Clear Filters
|
|
46
|
+
</Button>
|
|
47
|
+
|
|
48
|
+
<Button
|
|
49
|
+
v-if="!hasFilters && create && typeof create !== 'boolean'"
|
|
50
|
+
@click="router.get(create.url)"
|
|
51
|
+
>
|
|
52
|
+
<Plus class="mr-2 h-4 w-4" />
|
|
53
|
+
{{ create.label ?? 'Create' }}
|
|
54
|
+
</Button>
|
|
55
|
+
</div>
|
|
56
|
+
</slot>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { Button } from './ui/button';
|
|
3
|
+
import { Input } from './ui/input';
|
|
4
|
+
import { Label } from './ui/label';
|
|
5
|
+
import {
|
|
6
|
+
Select,
|
|
7
|
+
SelectContent,
|
|
8
|
+
SelectItem,
|
|
9
|
+
SelectTrigger,
|
|
10
|
+
SelectValue,
|
|
11
|
+
} from './ui/select';
|
|
12
|
+
import { Separator } from './ui/separator';
|
|
13
|
+
import type { CrudFilter } from '../types/crud';
|
|
14
|
+
import { RotateCw, X } from 'lucide-vue-next';
|
|
15
|
+
import { ref } from 'vue';
|
|
16
|
+
|
|
17
|
+
const props = defineProps<{
|
|
18
|
+
filters: CrudFilter[];
|
|
19
|
+
modelValue: Record<string, unknown>;
|
|
20
|
+
visible: boolean;
|
|
21
|
+
}>();
|
|
22
|
+
|
|
23
|
+
const emit = defineEmits<{
|
|
24
|
+
'update:modelValue': [value: Record<string, unknown>];
|
|
25
|
+
apply: [];
|
|
26
|
+
reset: [];
|
|
27
|
+
}>();
|
|
28
|
+
|
|
29
|
+
// Local copy for editing
|
|
30
|
+
const localFilters = ref<Record<string, unknown>>({ ...props.modelValue });
|
|
31
|
+
|
|
32
|
+
function getFieldState(field: string): unknown {
|
|
33
|
+
return localFilters.value[field];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function setFieldState(field: string, value: unknown) {
|
|
37
|
+
localFilters.value[field] = value;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function setFieldSubState(field: string, subField: string, value: unknown) {
|
|
41
|
+
if (!localFilters.value[field] || typeof localFilters.value[field] !== 'object') {
|
|
42
|
+
localFilters.value[field] = {};
|
|
43
|
+
}
|
|
44
|
+
(localFilters.value[field] as Record<string, unknown>)[subField] = value;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getFieldSubState(field: string, subField: string): unknown {
|
|
48
|
+
const parent = localFilters.value[field];
|
|
49
|
+
if (!parent || typeof parent !== 'object') return '';
|
|
50
|
+
return (parent as Record<string, unknown>)[subField] ?? '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function onApply() {
|
|
54
|
+
emit('update:modelValue', { ...localFilters.value });
|
|
55
|
+
emit('apply');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function onReset() {
|
|
59
|
+
localFilters.value = {};
|
|
60
|
+
emit('update:modelValue', {});
|
|
61
|
+
emit('reset');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getNumberValue(field: string, sub: 'min' | 'max'): string {
|
|
65
|
+
const val = getFieldSubState(field, sub);
|
|
66
|
+
return val !== undefined && val !== null && val !== '' ? String(val) : '';
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function getDateValue(field: string, sub: 'from' | 'to'): string {
|
|
70
|
+
const val = getFieldSubState(field, sub);
|
|
71
|
+
return val !== undefined && val !== null && val !== '' ? String(val) : '';
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<Transition
|
|
77
|
+
enter-active-class="transition-all duration-200 ease-out"
|
|
78
|
+
enter-from-class="max-h-0 opacity-0"
|
|
79
|
+
enter-to-class="max-h-96 opacity-100"
|
|
80
|
+
leave-active-class="transition-all duration-150 ease-in"
|
|
81
|
+
leave-from-class="max-h-96 opacity-100"
|
|
82
|
+
leave-to-class="max-h-0 opacity-0"
|
|
83
|
+
>
|
|
84
|
+
<div v-if="visible" class="overflow-hidden border-b">
|
|
85
|
+
<div class="space-y-4 px-6 py-4">
|
|
86
|
+
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
|
87
|
+
<template v-for="filter in filters" :key="filter.field">
|
|
88
|
+
<!-- Text filter -->
|
|
89
|
+
<div v-if="filter.type === 'text'" class="space-y-1.5">
|
|
90
|
+
<Label class="text-xs font-medium text-muted-foreground">
|
|
91
|
+
{{ filter.label }}
|
|
92
|
+
</Label>
|
|
93
|
+
<Input
|
|
94
|
+
:model-value="getFieldState(filter.field) ?? ''"
|
|
95
|
+
:placeholder="filter.placeholder ?? `Filter by ${filter.label.toLowerCase()}...`"
|
|
96
|
+
class="h-9 text-sm"
|
|
97
|
+
@update:model-value="setFieldState(filter.field, $event)"
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<!-- Select filter -->
|
|
102
|
+
<div v-else-if="filter.type === 'select'" class="space-y-1.5">
|
|
103
|
+
<Label class="text-xs font-medium text-muted-foreground">
|
|
104
|
+
{{ filter.label }}
|
|
105
|
+
</Label>
|
|
106
|
+
<div class="relative">
|
|
107
|
+
<Select
|
|
108
|
+
:model-value="(getFieldState(filter.field) as string) ?? ''"
|
|
109
|
+
@update:model-value="setFieldState(filter.field, $event)"
|
|
110
|
+
>
|
|
111
|
+
<SelectTrigger class="h-9 pr-8 text-sm">
|
|
112
|
+
<SelectValue :placeholder="filter.placeholder ?? 'All'" />
|
|
113
|
+
</SelectTrigger>
|
|
114
|
+
<SelectContent>
|
|
115
|
+
<SelectItem
|
|
116
|
+
v-for="opt in filter.options"
|
|
117
|
+
:key="String(opt.value)"
|
|
118
|
+
:value="String(opt.value)"
|
|
119
|
+
>
|
|
120
|
+
{{ opt.label }}
|
|
121
|
+
</SelectItem>
|
|
122
|
+
</SelectContent>
|
|
123
|
+
</Select>
|
|
124
|
+
<button
|
|
125
|
+
v-if="getFieldState(filter.field)"
|
|
126
|
+
class="absolute right-8 top-1/2 z-10 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
127
|
+
@click="setFieldState(filter.field, '')"
|
|
128
|
+
>
|
|
129
|
+
<X class="h-3.5 w-3.5" />
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<!-- Number range filter -->
|
|
135
|
+
<div v-else-if="filter.type === 'number'" class="space-y-1.5">
|
|
136
|
+
<Label class="text-xs font-medium text-muted-foreground">
|
|
137
|
+
{{ filter.label }}
|
|
138
|
+
</Label>
|
|
139
|
+
<div class="flex items-center gap-2">
|
|
140
|
+
<Input
|
|
141
|
+
type="number"
|
|
142
|
+
:model-value="getNumberValue(filter.field, 'min')"
|
|
143
|
+
:placeholder="'Min'"
|
|
144
|
+
class="h-9 text-sm"
|
|
145
|
+
@update:model-value="setFieldSubState(filter.field, 'min', $event)"
|
|
146
|
+
/>
|
|
147
|
+
<span class="text-xs text-muted-foreground">–</span>
|
|
148
|
+
<Input
|
|
149
|
+
type="number"
|
|
150
|
+
:model-value="getNumberValue(filter.field, 'max')"
|
|
151
|
+
:placeholder="'Max'"
|
|
152
|
+
class="h-9 text-sm"
|
|
153
|
+
@update:model-value="setFieldSubState(filter.field, 'max', $event)"
|
|
154
|
+
/>
|
|
155
|
+
</div>
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<!-- Date range filter -->
|
|
159
|
+
<div v-else-if="filter.type === 'date'" class="space-y-1.5">
|
|
160
|
+
<Label class="text-xs font-medium text-muted-foreground">
|
|
161
|
+
{{ filter.label }}
|
|
162
|
+
</Label>
|
|
163
|
+
<div class="flex items-center gap-2">
|
|
164
|
+
<Input
|
|
165
|
+
type="date"
|
|
166
|
+
:model-value="getDateValue(filter.field, 'from')"
|
|
167
|
+
class="h-9 text-sm"
|
|
168
|
+
@update:model-value="setFieldSubState(filter.field, 'from', $event)"
|
|
169
|
+
/>
|
|
170
|
+
<span class="text-xs text-muted-foreground">–</span>
|
|
171
|
+
<Input
|
|
172
|
+
type="date"
|
|
173
|
+
:model-value="getDateValue(filter.field, 'to')"
|
|
174
|
+
class="h-9 text-sm"
|
|
175
|
+
@update:model-value="setFieldSubState(filter.field, 'to', $event)"
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</template>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<Separator />
|
|
183
|
+
|
|
184
|
+
<div class="flex items-center justify-end gap-2">
|
|
185
|
+
<Button variant="outline" size="sm" @click="onReset">
|
|
186
|
+
<RotateCw class="mr-1.5 h-3.5 w-3.5" />
|
|
187
|
+
Reset
|
|
188
|
+
</Button>
|
|
189
|
+
<Button size="sm" @click="onApply"> Apply Filters </Button>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</Transition>
|
|
194
|
+
</template>
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import InputError from './InputError.vue';
|
|
3
|
+
import { Button } from './ui/button';
|
|
4
|
+
import { Card, CardContent, CardHeader, CardDescription, CardTitle } from './ui/card';
|
|
5
|
+
import { Checkbox } from './ui/checkbox';
|
|
6
|
+
import { Input } from './ui/input';
|
|
7
|
+
import { Label } from './ui/label';
|
|
8
|
+
import {
|
|
9
|
+
Select,
|
|
10
|
+
SelectContent,
|
|
11
|
+
SelectItem,
|
|
12
|
+
SelectTrigger,
|
|
13
|
+
SelectValue,
|
|
14
|
+
} from './ui/select';
|
|
15
|
+
import { Spinner } from './ui/spinner';
|
|
16
|
+
import { ComboboxInput } from './ui/combobox';
|
|
17
|
+
import type { CrudFormField } from '../types/crud';
|
|
18
|
+
import { Link, useForm, router } from '@inertiajs/vue3';
|
|
19
|
+
import { ArrowLeft, Save } from 'lucide-vue-next';
|
|
20
|
+
import { computed } from 'vue';
|
|
21
|
+
|
|
22
|
+
const props = defineProps<{
|
|
23
|
+
title: string;
|
|
24
|
+
description?: string;
|
|
25
|
+
fields: CrudFormField[];
|
|
26
|
+
action: string;
|
|
27
|
+
method: 'post' | 'put' | 'patch';
|
|
28
|
+
initialData: Record<string, unknown>;
|
|
29
|
+
backRoute: string;
|
|
30
|
+
submitLabel?: string;
|
|
31
|
+
}>();
|
|
32
|
+
|
|
33
|
+
const form = useForm(props.initialData);
|
|
34
|
+
|
|
35
|
+
function submit() {
|
|
36
|
+
const methodFn = props.method === 'put' ? form.put : props.method === 'patch' ? form.patch : form.post;
|
|
37
|
+
methodFn.call(form, props.action);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const gridCols = computed(() =>
|
|
41
|
+
props.fields.some((f) => f.span === 2) ? 'lg:grid-cols-2' : 'lg:grid-cols-2',
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
function hasValue(name: string): boolean {
|
|
45
|
+
const val = form[name as keyof typeof form];
|
|
46
|
+
return val !== undefined && val !== null && val !== '';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getFieldValue(name: string): unknown {
|
|
50
|
+
return form[name as keyof typeof form];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function setFieldValue(name: string, value: unknown) {
|
|
54
|
+
(form as Record<string, unknown>)[name] = value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function getSelectedOption(name: string, options?: { label: string; value: string | number }[]): { label: string; value: string | number } | undefined {
|
|
58
|
+
const val = getFieldValue(name);
|
|
59
|
+
if (val === undefined || val === null || val === '') return undefined;
|
|
60
|
+
return options?.find((opt) => String(opt.value) === String(val));
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<template>
|
|
65
|
+
<div class="mx-auto w-full max-w-4xl px-4 py-6 sm:px-6 lg:px-8">
|
|
66
|
+
<Card class="overflow-hidden border shadow-sm">
|
|
67
|
+
<CardHeader class="border-b px-6 py-5">
|
|
68
|
+
<div class="flex items-center justify-between">
|
|
69
|
+
<div>
|
|
70
|
+
<CardTitle class="text-xl font-bold">{{ title }}</CardTitle>
|
|
71
|
+
<CardDescription v-if="description" class="mt-1">
|
|
72
|
+
{{ description }}
|
|
73
|
+
</CardDescription>
|
|
74
|
+
</div>
|
|
75
|
+
<Button variant="outline" size="sm" @click="router.get(backRoute)">
|
|
76
|
+
<ArrowLeft class="mr-1.5 h-4 w-4" />
|
|
77
|
+
Back
|
|
78
|
+
</Button>
|
|
79
|
+
</div>
|
|
80
|
+
</CardHeader>
|
|
81
|
+
|
|
82
|
+
<CardContent class="px-6 py-6">
|
|
83
|
+
<form @submit.prevent="submit">
|
|
84
|
+
<div class="grid grid-cols-1 gap-6" :class="gridCols">
|
|
85
|
+
<template v-for="(field, idx) in fields" :key="field.name">
|
|
86
|
+
<slot :name="`field-${field.name}`" :field="field" :form="form">
|
|
87
|
+
<!-- Text / Email / Tel / Number / Password / Date -->
|
|
88
|
+
<div
|
|
89
|
+
v-if="['text', 'email', 'tel', 'number', 'password', 'date'].includes(field.type)"
|
|
90
|
+
class="grid gap-2"
|
|
91
|
+
:class="field.span === 2 ? 'lg:col-span-2' : ''"
|
|
92
|
+
>
|
|
93
|
+
<Label :for="field.name" class="text-sm font-medium">
|
|
94
|
+
{{ field.label }}
|
|
95
|
+
<span v-if="field.required" class="text-destructive">*</span>
|
|
96
|
+
</Label>
|
|
97
|
+
<Input
|
|
98
|
+
:id="field.name"
|
|
99
|
+
:type="field.type"
|
|
100
|
+
:model-value="getFieldValue(field.name)"
|
|
101
|
+
:placeholder="field.placeholder"
|
|
102
|
+
:required="field.required"
|
|
103
|
+
:disabled="field.disabled || form.processing"
|
|
104
|
+
:tabindex="field.tabindex ?? idx + 1"
|
|
105
|
+
class="h-10"
|
|
106
|
+
@update:model-value="setFieldValue(field.name, $event)"
|
|
107
|
+
/>
|
|
108
|
+
<InputError :message="(form.errors as Record<string, string>)[field.name]" />
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<!-- Select -->
|
|
112
|
+
<div
|
|
113
|
+
v-else-if="field.type === 'select'"
|
|
114
|
+
class="grid gap-2"
|
|
115
|
+
:class="field.span === 2 ? 'lg:col-span-2' : ''"
|
|
116
|
+
>
|
|
117
|
+
<Label :for="field.name" class="text-sm font-medium">
|
|
118
|
+
{{ field.label }}
|
|
119
|
+
<span v-if="field.required" class="text-destructive">*</span>
|
|
120
|
+
</Label>
|
|
121
|
+
<Select
|
|
122
|
+
:model-value="getFieldValue(field.name)"
|
|
123
|
+
:disabled="field.disabled || form.processing"
|
|
124
|
+
@update:model-value="setFieldValue(field.name, $event)"
|
|
125
|
+
>
|
|
126
|
+
<SelectTrigger :id="field.name" class="h-10">
|
|
127
|
+
<SelectValue :placeholder="field.placeholder ?? 'Select...'" />
|
|
128
|
+
</SelectTrigger>
|
|
129
|
+
<SelectContent>
|
|
130
|
+
<SelectItem
|
|
131
|
+
v-for="opt in field.options"
|
|
132
|
+
:key="String(opt.value)"
|
|
133
|
+
:value="opt.value"
|
|
134
|
+
>
|
|
135
|
+
{{ opt.label }}
|
|
136
|
+
</SelectItem>
|
|
137
|
+
</SelectContent>
|
|
138
|
+
</Select>
|
|
139
|
+
<InputError :message="(form.errors as Record<string, string>)[field.name]" />
|
|
140
|
+
</div>
|
|
141
|
+
|
|
142
|
+
<!-- Combobox (searchable select) -->
|
|
143
|
+
<div
|
|
144
|
+
v-else-if="field.type === 'combobox'"
|
|
145
|
+
class="grid gap-2"
|
|
146
|
+
:class="field.span === 2 ? 'lg:col-span-2' : ''"
|
|
147
|
+
>
|
|
148
|
+
<Label :for="field.name" class="text-sm font-medium">
|
|
149
|
+
{{ field.label }}
|
|
150
|
+
<span v-if="field.required" class="text-destructive">*</span>
|
|
151
|
+
</Label>
|
|
152
|
+
<ComboboxInput
|
|
153
|
+
:model-value="getSelectedOption(field.name, field.options)"
|
|
154
|
+
:options="field.options ?? []"
|
|
155
|
+
:placeholder="field.placeholder ?? 'Search...'"
|
|
156
|
+
:disabled="field.disabled || form.processing"
|
|
157
|
+
@update:model-value="(val: unknown) => setFieldValue(field.name, (val as any)?.value ?? '')"
|
|
158
|
+
/>
|
|
159
|
+
<InputError :message="(form.errors as Record<string, string>)[field.name]" />
|
|
160
|
+
</div>
|
|
161
|
+
|
|
162
|
+
<!-- Textarea -->
|
|
163
|
+
<div
|
|
164
|
+
v-else-if="field.type === 'textarea'"
|
|
165
|
+
class="grid gap-2"
|
|
166
|
+
:class="field.span === 2 ? 'lg:col-span-2' : ''"
|
|
167
|
+
>
|
|
168
|
+
<Label :for="field.name" class="text-sm font-medium">
|
|
169
|
+
{{ field.label }}
|
|
170
|
+
<span v-if="field.required" class="text-destructive">*</span>
|
|
171
|
+
</Label>
|
|
172
|
+
<textarea
|
|
173
|
+
:id="field.name"
|
|
174
|
+
:value="getFieldValue(field.name)"
|
|
175
|
+
:placeholder="field.placeholder"
|
|
176
|
+
:required="field.required"
|
|
177
|
+
:disabled="field.disabled || form.processing"
|
|
178
|
+
:tabindex="field.tabindex ?? idx + 1"
|
|
179
|
+
rows="3"
|
|
180
|
+
class="border-input placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex min-h-[80px] w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50"
|
|
181
|
+
@input="setFieldValue(field.name, ($event.target as HTMLTextAreaElement).value)"
|
|
182
|
+
/>
|
|
183
|
+
<InputError :message="(form.errors as Record<string, string>)[field.name]" />
|
|
184
|
+
</div>
|
|
185
|
+
|
|
186
|
+
<!-- Switch / Boolean -->
|
|
187
|
+
<div
|
|
188
|
+
v-else-if="field.type === 'switch'"
|
|
189
|
+
class="flex items-center gap-3"
|
|
190
|
+
:class="field.span === 2 ? 'lg:col-span-2' : ''"
|
|
191
|
+
>
|
|
192
|
+
<Checkbox
|
|
193
|
+
:id="field.name"
|
|
194
|
+
:model-value="!!getFieldValue(field.name)"
|
|
195
|
+
:disabled="field.disabled || form.processing"
|
|
196
|
+
@update:model-value="setFieldValue(field.name, $event)"
|
|
197
|
+
/>
|
|
198
|
+
<Label :for="field.name" class="text-sm font-medium cursor-pointer">
|
|
199
|
+
{{ field.label }}
|
|
200
|
+
</Label>
|
|
201
|
+
<InputError :message="(form.errors as Record<string, string>)[field.name]" />
|
|
202
|
+
</div>
|
|
203
|
+
</slot>
|
|
204
|
+
</template>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
<!-- Submit buttons -->
|
|
208
|
+
<div class="mt-8 flex flex-row-reverse flex-wrap gap-3">
|
|
209
|
+
<Button
|
|
210
|
+
type="submit"
|
|
211
|
+
class="h-11 flex-1 sm:flex-none sm:min-w-[160px]"
|
|
212
|
+
:disabled="form.processing"
|
|
213
|
+
>
|
|
214
|
+
<Spinner v-if="form.processing" class="mr-2" />
|
|
215
|
+
<Save v-else class="mr-1.5 h-4 w-4" />
|
|
216
|
+
{{ submitLabel ?? 'Save' }}
|
|
217
|
+
</Button>
|
|
218
|
+
|
|
219
|
+
<Button
|
|
220
|
+
variant="outline"
|
|
221
|
+
class="h-11 flex-1 sm:flex-none"
|
|
222
|
+
:disabled="form.processing"
|
|
223
|
+
@click="router.get(backRoute)"
|
|
224
|
+
>
|
|
225
|
+
Cancel
|
|
226
|
+
</Button>
|
|
227
|
+
</div>
|
|
228
|
+
</form>
|
|
229
|
+
</CardContent>
|
|
230
|
+
</Card>
|
|
231
|
+
</div>
|
|
232
|
+
</template>
|