@wishbone-media/spark 0.26.0 → 0.28.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/formkit.theme.mjs CHANGED
@@ -216,6 +216,9 @@ const classes = {
216
216
  "group-data-[invalid]:border-red-500": true,
217
217
  "group-data-[invalid]:ring-1": true,
218
218
  "group-data-[invalid]:ring-red-500": true,
219
+ "group-data-[errors]:border-red-500": true,
220
+ "group-data-[errors]:ring-1": true,
221
+ "group-data-[errors]:ring-red-500": true,
219
222
  "group-data-[disabled]:bg-gray-100": true,
220
223
  "group-data-[disabled]:!cursor-not-allowed": true,
221
224
  "shadow-xs": true,
@@ -225,7 +228,9 @@ const classes = {
225
228
  "dark:border-gray-700": true,
226
229
  "dark:group-data-[disabled]:bg-gray-800/5": true,
227
230
  "dark:group-data-[invalid]:border-red-500": true,
228
- "dark:group-data-[invalid]:ring-red-500": true
231
+ "dark:group-data-[invalid]:ring-red-500": true,
232
+ "dark:group-data-[errors]:border-red-500": true,
233
+ "dark:group-data-[errors]:ring-red-500": true
229
234
  },
230
235
  "family:text__input": {
231
236
  "appearance-none": true,
@@ -293,6 +298,9 @@ const classes = {
293
298
  "group-data-[invalid]:border-red-500": true,
294
299
  "group-data-[invalid]:ring-1": true,
295
300
  "group-data-[invalid]:ring-red-500": true,
301
+ "group-data-[errors]:border-red-500": true,
302
+ "group-data-[errors]:ring-1": true,
303
+ "group-data-[errors]:ring-red-500": true,
296
304
  "group-data-[disabled]:bg-gray-100": true,
297
305
  "group-data-[disabled]:!cursor-not-allowed": true,
298
306
  "shadow-xs": true,
@@ -302,7 +310,9 @@ const classes = {
302
310
  "dark:border-gray-700": true,
303
311
  "dark:group-data-[disabled]:bg-gray-700/40": true,
304
312
  "dark:group-data-[invalid]:border-red-500": true,
305
- "dark:group-data-[invalid]:ring-red-500": true
313
+ "dark:group-data-[invalid]:ring-red-500": true,
314
+ "dark:group-data-[errors]:border-red-500": true,
315
+ "dark:group-data-[errors]:ring-red-500": true
306
316
  },
307
317
  "family:dropdown__input": {
308
318
  "appearance-none": true,
@@ -791,7 +801,7 @@ const classes = {
791
801
  "dark:text-gray-300": true
792
802
  },
793
803
  "form__messages": {
794
- "": true
804
+ "hidden": true,
795
805
  },
796
806
  "form__message": {
797
807
  "text-red-600": true,
@@ -895,6 +905,9 @@ const classes = {
895
905
  "group-data-[invalid]:border-red-500": true,
896
906
  "group-data-[invalid]:ring-1": true,
897
907
  "group-data-[invalid]:ring-red-500": true,
908
+ "group-data-[errors]:border-red-500": true,
909
+ "group-data-[errors]:ring-1": true,
910
+ "group-data-[errors]:ring-red-500": true,
898
911
  "group-data-[disabled]:bg-gray-100": true,
899
912
  "group-data-[disabled]:!cursor-not-allowed": true,
900
913
  "shadow-xs": true,
@@ -905,7 +918,9 @@ const classes = {
905
918
  "dark:border-gray-700": true,
906
919
  "dark:group-data-[disabled]:bg-gray-800/5": true,
907
920
  "dark:group-data-[invalid]:border-red-500": true,
908
- "dark:group-data-[invalid]:ring-red-500": true
921
+ "dark:group-data-[invalid]:ring-red-500": true,
922
+ "dark:group-data-[errors]:border-red-500": true,
923
+ "dark:group-data-[errors]:ring-red-500": true
909
924
  },
910
925
  "select__input": {
911
926
  "appearance-none": true,
@@ -1084,6 +1099,9 @@ const classes = {
1084
1099
  "group-data-[invalid]:border-red-500": true,
1085
1100
  "group-data-[invalid]:ring-1": true,
1086
1101
  "group-data-[invalid]:ring-red-500": true,
1102
+ "group-data-[errors]:border-red-500": true,
1103
+ "group-data-[errors]:ring-1": true,
1104
+ "group-data-[errors]:ring-red-500": true,
1087
1105
  "group-data-[disabled]:bg-gray-100": true,
1088
1106
  "shadow-xs": true,
1089
1107
  "group-[&]/repeater:shadow-none": true,
@@ -1092,6 +1110,8 @@ const classes = {
1092
1110
  "dark:group-data-[disabled]:bg-gray-800/5": true,
1093
1111
  "dark:group-data-[invalid]:border-red-500": true,
1094
1112
  "dark:group-data-[invalid]:ring-red-500": true,
1113
+ "dark:group-data-[errors]:border-red-500": true,
1114
+ "dark:group-data-[errors]:ring-red-500": true,
1095
1115
  "dark:bg-transparent": true
1096
1116
  },
1097
1117
  "textarea__input": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wishbone-media/spark",
3
- "version": "0.26.0",
3
+ "version": "0.28.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
@@ -0,0 +1,202 @@
1
+ <template>
2
+ <div>
3
+ <label v-if="label" class="block text-sm font-medium text-gray-700 mb-2">{{ label }}</label>
4
+ <div v-if="modelValue" class="mb-2 relative inline-block">
5
+ <a :href="modelValue" target="_blank" rel="noopener noreferrer">
6
+ <img
7
+ :src="modelValue"
8
+ :alt="label || 'Image preview'"
9
+ :class="[height, 'w-auto object-contain border border-gray-300 rounded-md cursor-pointer hover:opacity-80 transition-opacity']"
10
+ />
11
+ </a>
12
+ <button
13
+ type="button"
14
+ class="absolute -top-2 -right-2 w-5 h-5 bg-white/80 hover:bg-white text-gray-500 hover:text-gray-700 rounded-full flex items-center justify-center shadow-sm border border-gray-200"
15
+ :disabled="disabled"
16
+ @click="clearImage"
17
+ >
18
+ <font-awesome-icon :icon="Icons.farXmark" class="text-xs" />
19
+ </button>
20
+ </div>
21
+ <div
22
+ v-if="!modelValue"
23
+ class="relative rounded-md transition-colors"
24
+ :class="{
25
+ 'ring-2 ring-blue-500 ring-offset-2 bg-blue-50': isDragging,
26
+ }"
27
+ @dragover.prevent="onDragOver"
28
+ @dragenter.prevent="onDragOver"
29
+ @dragleave.prevent="onDragLeave"
30
+ @drop.prevent="onDrop"
31
+ >
32
+ <FormKit
33
+ type="file"
34
+ :name="name"
35
+ :accept="accept"
36
+ :disabled="disabled || uploading"
37
+ @input="handleFormKitInput"
38
+ />
39
+ <div
40
+ v-if="isDragging"
41
+ class="absolute inset-0 flex items-center justify-center bg-blue-50/80 rounded-md pointer-events-none"
42
+ >
43
+ <span class="text-sm text-blue-600 font-medium">Drop image here</span>
44
+ </div>
45
+ </div>
46
+ <p v-if="uploading" class="text-sm text-gray-500 mt-1">Uploading...</p>
47
+ </div>
48
+ </template>
49
+
50
+ <script setup>
51
+ import { ref, inject } from 'vue'
52
+ import { Icons } from '@/plugins/fontawesome'
53
+ import { sparkNotificationService } from '@/composables/sparkNotificationService'
54
+
55
+ const props = defineProps({
56
+ modelValue: {
57
+ type: String,
58
+ default: '',
59
+ },
60
+ uploadUrl: {
61
+ type: String,
62
+ required: true,
63
+ },
64
+ fieldName: {
65
+ type: String,
66
+ default: 'file',
67
+ },
68
+ extraFields: {
69
+ type: Object,
70
+ default: () => ({}),
71
+ },
72
+ label: {
73
+ type: String,
74
+ default: '',
75
+ },
76
+ name: {
77
+ type: String,
78
+ default: 'image_file',
79
+ },
80
+ height: {
81
+ type: String,
82
+ default: 'h-16',
83
+ },
84
+ accept: {
85
+ type: String,
86
+ default: 'image/*',
87
+ },
88
+ disabled: {
89
+ type: Boolean,
90
+ default: false,
91
+ },
92
+ responseParser: {
93
+ type: Function,
94
+ default: (response) => response.data.url || response.data,
95
+ },
96
+ })
97
+
98
+ const emit = defineEmits(['update:modelValue', 'upload-start', 'upload-success', 'upload-error'])
99
+
100
+ const axios = inject('axios')
101
+ const uploading = ref(false)
102
+ const isDragging = ref(false)
103
+
104
+ function clearImage() {
105
+ emit('update:modelValue', '')
106
+ }
107
+
108
+ function onDragOver() {
109
+ if (props.disabled || uploading.value) return
110
+ isDragging.value = true
111
+ }
112
+
113
+ function onDragLeave() {
114
+ isDragging.value = false
115
+ }
116
+
117
+ function onDrop(event) {
118
+ isDragging.value = false
119
+ if (props.disabled || uploading.value) return
120
+
121
+ const file = event.dataTransfer?.files?.[0]
122
+ if (!file) return
123
+
124
+ // Validate file type against accept prop
125
+ if (!isFileTypeAccepted(file)) {
126
+ sparkNotificationService.show({
127
+ type: 'danger',
128
+ message: 'File type not accepted.',
129
+ })
130
+ return
131
+ }
132
+
133
+ uploadFile(file)
134
+ }
135
+
136
+ function isFileTypeAccepted(file) {
137
+ // Parse accept prop (e.g., "image/*", "image/png,image/jpeg", ".png,.jpg")
138
+ const acceptTypes = props.accept.split(',').map((t) => t.trim())
139
+
140
+ for (const acceptType of acceptTypes) {
141
+ if (acceptType === '*/*') return true
142
+ if (acceptType.endsWith('/*')) {
143
+ // Wildcard like "image/*"
144
+ const category = acceptType.slice(0, -2)
145
+ if (file.type.startsWith(category + '/')) return true
146
+ } else if (acceptType.startsWith('.')) {
147
+ // Extension like ".png"
148
+ if (file.name.toLowerCase().endsWith(acceptType.toLowerCase())) return true
149
+ } else {
150
+ // Exact MIME type like "image/png"
151
+ if (file.type === acceptType) return true
152
+ }
153
+ }
154
+
155
+ return false
156
+ }
157
+
158
+ // Handle FormKit file input
159
+ function handleFormKitInput(files) {
160
+ if (!files || files.length === 0) return
161
+ const file = files[0].file
162
+ if (!file) return
163
+ uploadFile(file)
164
+ }
165
+
166
+ // Core upload logic
167
+ async function uploadFile(file) {
168
+ uploading.value = true
169
+ emit('upload-start')
170
+
171
+ try {
172
+ const formData = new FormData()
173
+ formData.append(props.fieldName, file)
174
+
175
+ // Add any extra fields
176
+ Object.entries(props.extraFields).forEach(([key, value]) => {
177
+ formData.append(key, value)
178
+ })
179
+
180
+ const response = await axios.post(props.uploadUrl, formData, {
181
+ headers: { 'Content-Type': 'multipart/form-data' },
182
+ })
183
+
184
+ const url = props.responseParser(response)
185
+ emit('update:modelValue', url)
186
+ emit('upload-success', response)
187
+
188
+ sparkNotificationService.show({
189
+ type: 'success',
190
+ message: 'Image uploaded!',
191
+ })
192
+ } catch (error) {
193
+ emit('upload-error', error)
194
+ sparkNotificationService.show({
195
+ type: 'danger',
196
+ message: error.response?.data?.message || 'Failed to upload image.',
197
+ })
198
+ } finally {
199
+ uploading.value = false
200
+ }
201
+ }
202
+ </script>
@@ -6,6 +6,7 @@ export { default as SparkBrandSelector } from './SparkBrandSelector.vue'
6
6
  export { default as SparkButton } from './SparkButton.vue'
7
7
  export { default as SparkButtonGroup } from './SparkButtonGroup.vue'
8
8
  export { default as SparkCard } from './SparkCard.vue'
9
+ export { default as SparkImageUpload } from './SparkImageUpload.vue'
9
10
  export { default as SparkModalContainer } from './SparkModalContainer.vue'
10
11
  export { default as SparkModalDialog } from './SparkModalDialog.vue'
11
12
  export { default as SparkOverlay } from './SparkOverlay.vue'
@@ -2,4 +2,5 @@ export { sparkModalService } from './sparkModalService.js'
2
2
  export { sparkNotificationService } from './sparkNotificationService.js'
3
3
  export { sparkOverlayService } from './sparkOverlayService.js'
4
4
  export { useSparkOverlay } from './useSparkOverlay.js'
5
- export { useSparkTableRouteSync } from './useSparkTableRouteSync.js'
5
+ export { useSparkTableRouteSync } from './useSparkTableRouteSync.js'
6
+ export { useFormSubmission } from './useFormSubmission.js'
@@ -0,0 +1,160 @@
1
+ import { ref } from 'vue'
2
+ import { useRouter } from 'vue-router'
3
+ import { sparkNotificationService } from './sparkNotificationService.js'
4
+ import { getAxiosInstance } from '../plugins/axios.js'
5
+ import { parseLaravelErrors, getFormLevelMessage, isValidationError } from '../utils/index.js'
6
+
7
+ /**
8
+ * Composable for handling form submissions with Laravel backend
9
+ * Automatically handles loading states, notifications, validation errors, and redirects
10
+ *
11
+ * @param {Object} options - Configuration options
12
+ * @param {string} options.successMessage - Message to show on success (default: 'Saved successfully!')
13
+ * @param {string} options.createMessage - Message to show on create (default: 'Created successfully!')
14
+ * @param {string} options.updateMessage - Message to show on update (default: 'Updated successfully!')
15
+ * @param {string|Object|Function} options.redirectTo - Vue Router location to redirect after create (optional)
16
+ * Can be a function that receives response data
17
+ * @param {Function} options.onSuccess - Callback after successful submission (receives response data)
18
+ * @param {Function} options.onError - Callback after error (receives error, can return true to skip default handling)
19
+ * @param {boolean} options.showNotification - Whether to show success notification (default: true)
20
+ * @param {boolean} options.setFieldErrors - Whether to set field-level errors on 422 (default: true)
21
+ * @param {boolean} options.setFormErrors - Whether to show form-level error notification on 422 (default: false)
22
+ *
23
+ * @returns {Object} Form submission helpers
24
+ */
25
+ export function useFormSubmission(options = {}) {
26
+ const {
27
+ successMessage = 'Saved successfully!',
28
+ createMessage = 'Created successfully!',
29
+ updateMessage = 'Updated successfully!',
30
+ redirectTo = null,
31
+ onSuccess = null,
32
+ onError = null,
33
+ showNotification = true,
34
+ setFieldErrors = true,
35
+ setFormErrors = false,
36
+ } = options
37
+
38
+ const router = useRouter()
39
+ const submitting = ref(false)
40
+
41
+ /**
42
+ * Execute the form submission
43
+ *
44
+ * @param {Object} config - Submission configuration
45
+ * @param {Function} config.submitFn - Async function that makes the API call
46
+ * @param {string} config.method - 'create' | 'update' (determines success message)
47
+ * @param {Object} config.node - FormKit node (second param from @submit handler)
48
+ * @param {boolean} config.isEditMode - Whether this is an update operation
49
+ *
50
+ * @returns {Promise<{success: boolean, data: any, error: any}>}
51
+ */
52
+ async function submit(config) {
53
+ const { submitFn, method = 'create', node = null, isEditMode = false } = config
54
+
55
+ submitting.value = true
56
+
57
+ // Clear previous FormKit errors
58
+ if (node) {
59
+ node.clearErrors()
60
+ }
61
+
62
+ try {
63
+ const response = await submitFn()
64
+ const data = response.data
65
+
66
+ // Show success notification
67
+ if (showNotification) {
68
+ const message = isEditMode
69
+ ? updateMessage
70
+ : method === 'create'
71
+ ? createMessage
72
+ : successMessage
73
+
74
+ sparkNotificationService.show({
75
+ type: 'success',
76
+ message,
77
+ })
78
+ }
79
+
80
+ // Execute success callback
81
+ if (onSuccess) {
82
+ await onSuccess(data)
83
+ }
84
+
85
+ // Redirect after create (if specified and not in edit mode)
86
+ if (!isEditMode && redirectTo) {
87
+ const location = typeof redirectTo === 'function'
88
+ ? redirectTo(data)
89
+ : redirectTo
90
+ await router.push(location)
91
+ }
92
+
93
+ return { success: true, data, error: null }
94
+ } catch (error) {
95
+ const response = error.response
96
+
97
+ // Allow custom error handling to intercept
98
+ if (onError) {
99
+ const handled = await onError(error)
100
+ if (handled === true) {
101
+ return { success: false, data: null, error }
102
+ }
103
+ }
104
+
105
+ // Handle validation errors (422)
106
+ if (isValidationError(response)) {
107
+ const fieldErrors = parseLaravelErrors(response)
108
+
109
+ if (fieldErrors && node && setFieldErrors) {
110
+ node.setErrors([], fieldErrors)
111
+ }
112
+
113
+ if (setFormErrors) {
114
+ sparkNotificationService.show({
115
+ type: 'danger',
116
+ message: getFormLevelMessage(response),
117
+ })
118
+ }
119
+ } else {
120
+ // Handle non-validation errors
121
+ sparkNotificationService.show({
122
+ type: 'danger',
123
+ message: getFormLevelMessage(response),
124
+ })
125
+ }
126
+
127
+ return { success: false, data: null, error }
128
+ } finally {
129
+ submitting.value = false
130
+ }
131
+ }
132
+
133
+ /**
134
+ * Convenience method for simple POST/PUT submissions
135
+ *
136
+ * @param {Object} config - Submission configuration
137
+ * @param {string} config.url - API endpoint
138
+ * @param {Object} config.payload - Request payload
139
+ * @param {string} config.method - 'post' | 'put' (default: 'post')
140
+ * @param {Object} config.node - FormKit node
141
+ * @param {boolean} config.isEditMode - Whether this is an update
142
+ */
143
+ async function submitToApi(config) {
144
+ const { url, payload, method = 'post', node, isEditMode } = config
145
+ const axios = getAxiosInstance()
146
+
147
+ return submit({
148
+ submitFn: () => axios[method](url, payload),
149
+ method: isEditMode ? 'update' : 'create',
150
+ node,
151
+ isEditMode,
152
+ })
153
+ }
154
+
155
+ return {
156
+ submitting,
157
+ submit,
158
+ submitToApi,
159
+ }
160
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Parse Laravel validation errors from API response
3
+ * Laravel returns: { errors: { field: ["error1", "error2"] } }
4
+ * FormKit accepts: { field: ["error1", "error2"] } (compatible)
5
+ *
6
+ * @param {Object} response - Axios error.response object
7
+ * @returns {Object|null} - Field errors object or null if not validation error
8
+ */
9
+ export function parseLaravelErrors(response) {
10
+ if (response?.status !== 422) return null
11
+ return response.data?.errors || null
12
+ }
13
+
14
+ /**
15
+ * Get a form-level error message from Laravel response
16
+ *
17
+ * @param {Object} response - Axios error.response object
18
+ * @returns {string} - Error message for display
19
+ */
20
+ export function getFormLevelMessage(response) {
21
+ if (response?.status === 422) {
22
+ return response.data?.message || 'Please fix the validation errors.'
23
+ }
24
+ return response?.data?.message || 'An error occurred.'
25
+ }
26
+
27
+ /**
28
+ * Check if the error response is a validation error (422)
29
+ *
30
+ * @param {Object} response - Axios error.response object
31
+ * @returns {boolean}
32
+ */
33
+ export function isValidationError(response) {
34
+ return response?.status === 422
35
+ }
@@ -3,3 +3,4 @@ export { customiseHeader } from './sparkTable/header.js'
3
3
  export { renderHeaderTitle } from './sparkTable/header-title.js'
4
4
  export { updateRow } from './sparkTable/update-row.js'
5
5
  export { formatTemporal, parseDatetime } from './formatTemporal.js'
6
+ export { parseLaravelErrors, getFormLevelMessage, isValidationError } from './formErrors.js'