@wishbone-media/spark 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.css +1 -1
- package/dist/index.js +1301 -1028
- package/formkit.theme.mjs +4 -4
- package/package.json +1 -1
- package/src/assets/css/index.css +1 -0
- package/src/assets/css/nprogress.css +6 -0
- package/src/assets/css/spark-table.css +31 -6
- package/src/assets/images/brands/mr-antenna.png +0 -0
- package/src/assets/images/brands/mr-group.png +0 -0
- package/src/assets/images/brands/mr-gutter-cleaning.png +0 -0
- package/src/assets/images/brands/mr-pest-controller.png +0 -0
- package/src/components/SparkBrandSelector.vue +9 -9
- package/src/components/SparkModalContainer.vue +1 -1
- package/src/components/SparkOverlay.vue +1 -1
- package/src/components/SparkTable.vue +145 -13
- package/src/components/SparkTablePaginationDetails.vue +1 -1
- package/src/components/SparkTableToolbar.vue +1 -1
- package/src/components/plugins/SparkTableFilterSelect.vue +8 -2
- package/src/components/plugins/SparkTableReset.vue +43 -14
- package/src/components/plugins/SparkTableSearch.vue +10 -5
- package/src/composables/index.js +2 -1
- package/src/composables/sparkModalService.js +49 -0
- package/src/composables/useSparkTableRouteSync.js +308 -0
- package/src/containers/SparkDefaultContainer.vue +11 -1
- package/src/plugins/fontawesome.js +5 -0
- package/src/stores/app-selector.js +8 -2
- package/src/stores/brand-filter.js +1 -1
- package/src/stores/navigation.js +1 -0
- package/src/utils/sparkTable/renderers/actions.js +27 -2
- package/src/utils/sparkTable/renderers/boolean.js +97 -0
- package/src/utils/sparkTable/renderers/index.js +3 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { reactive, markRaw } from 'vue'
|
|
2
|
+
import SparkModalDialog from '@/components/SparkModalDialog.vue'
|
|
2
3
|
|
|
3
4
|
class SparkModalService {
|
|
4
5
|
constructor() {
|
|
@@ -21,6 +22,54 @@ class SparkModalService {
|
|
|
21
22
|
this.state.isVisible = false
|
|
22
23
|
this.state.eventHandlers = {}
|
|
23
24
|
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Show a confirmation dialog and return a Promise
|
|
28
|
+
*
|
|
29
|
+
* @param {Object} options - Confirmation options
|
|
30
|
+
* @param {string} [options.title='Confirm'] - Dialog title
|
|
31
|
+
* @param {string} [options.message='Are you sure?'] - Dialog message
|
|
32
|
+
* @param {string} [options.type='warning'] - Dialog type (info, success, warning, danger)
|
|
33
|
+
* @param {string} [options.confirmText='Confirm'] - Confirm button text
|
|
34
|
+
* @param {string} [options.cancelText='Cancel'] - Cancel button text
|
|
35
|
+
* @param {string} [options.confirmVariant='primary'] - Confirm button variant
|
|
36
|
+
* @returns {Promise<boolean>} - Resolves to true if confirmed, false if cancelled
|
|
37
|
+
*/
|
|
38
|
+
confirm = (options = {}) => {
|
|
39
|
+
return new Promise((resolve) => {
|
|
40
|
+
const {
|
|
41
|
+
title = 'Confirm',
|
|
42
|
+
message = 'Are you sure?',
|
|
43
|
+
type = 'warning',
|
|
44
|
+
confirmText = 'Confirm',
|
|
45
|
+
cancelText = 'Cancel',
|
|
46
|
+
confirmVariant = 'primary',
|
|
47
|
+
} = options
|
|
48
|
+
|
|
49
|
+
this.show(
|
|
50
|
+
SparkModalDialog,
|
|
51
|
+
{
|
|
52
|
+
title,
|
|
53
|
+
message,
|
|
54
|
+
type,
|
|
55
|
+
buttons: [
|
|
56
|
+
{ text: confirmText, variant: confirmVariant, event: 'confirm' },
|
|
57
|
+
{ text: cancelText, variant: 'secondary', event: 'cancel' },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
confirm: () => {
|
|
62
|
+
this.hide()
|
|
63
|
+
resolve(true)
|
|
64
|
+
},
|
|
65
|
+
cancel: () => {
|
|
66
|
+
this.hide()
|
|
67
|
+
resolve(false)
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
24
73
|
}
|
|
25
74
|
|
|
26
75
|
export const sparkModalService = new SparkModalService()
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { watch, onMounted } from 'vue'
|
|
2
|
+
import { useRouter, useRoute } from 'vue-router'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Composable for syncing SparkTable params with URL query parameters and localStorage
|
|
6
|
+
*
|
|
7
|
+
* @param {Object} sparkTable - The sparkTable reactive object
|
|
8
|
+
* @param {Object} options - Configuration options
|
|
9
|
+
* @param {string|null} [options.namespace=null] - URL namespace for params. If null, uses flat URLs (?page=1). If string, uses namespaced URLs (?namespace[page]=1)
|
|
10
|
+
* @param {boolean} [options.syncToRoute=true] - Whether to sync params to URL
|
|
11
|
+
* @param {boolean} [options.persistToStorage=false] - Whether to persist params to localStorage
|
|
12
|
+
* @param {boolean} [options.restoreOnMount=true] - Whether to restore params on mount
|
|
13
|
+
* @param {number} [options.storageTTL=7] - Days to keep localStorage data (0 = no expiry)
|
|
14
|
+
* @returns {Object} Utility methods for manual control
|
|
15
|
+
*
|
|
16
|
+
* @example
|
|
17
|
+
* // Flat URL sync (clean URLs for single-table pages)
|
|
18
|
+
* useSparkTableRouteSync(sparkTable, { namespace: null })
|
|
19
|
+
* // Result: ?page=1&filter[status]=active
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* // Namespaced URL sync (for multi-table pages)
|
|
23
|
+
* useSparkTableRouteSync(sparkTable, { namespace: 'users' })
|
|
24
|
+
* // Result: ?users[page]=1&users[filter[status]]=active
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* // Both URL and localStorage (URL takes precedence)
|
|
28
|
+
* useSparkTableRouteSync(sparkTable, {
|
|
29
|
+
* namespace: null,
|
|
30
|
+
* syncToRoute: true,
|
|
31
|
+
* persistToStorage: true
|
|
32
|
+
* })
|
|
33
|
+
*/
|
|
34
|
+
export const useSparkTableRouteSync = (sparkTable, options = {}) => {
|
|
35
|
+
const router = useRouter()
|
|
36
|
+
const route = useRoute()
|
|
37
|
+
|
|
38
|
+
const namespace = options.namespace // null = flat URLs, string = namespaced URLs
|
|
39
|
+
const useFlatUrls = namespace === null
|
|
40
|
+
const syncToRoute = options.syncToRoute !== false
|
|
41
|
+
const persistToStorage = options.persistToStorage === true
|
|
42
|
+
const restoreOnMount = options.restoreOnMount !== false
|
|
43
|
+
const storageTTL = options.storageTTL || 7 // days
|
|
44
|
+
|
|
45
|
+
// localStorage key: use namespace if provided, otherwise use route path
|
|
46
|
+
const storageKey = useFlatUrls ? `spark-table:${route.path}` : `spark-table:${namespace}`
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Flatten nested params into query string format
|
|
50
|
+
* e.g., { filter: { role: 'admin' } } => { 'filter[role]': 'admin' }
|
|
51
|
+
*/
|
|
52
|
+
const flattenParams = (params, prefix = '') => {
|
|
53
|
+
const flattened = {}
|
|
54
|
+
|
|
55
|
+
Object.keys(params).forEach((key) => {
|
|
56
|
+
const value = params[key]
|
|
57
|
+
const queryKey = prefix ? `${prefix}[${key}]` : key
|
|
58
|
+
|
|
59
|
+
if (value !== null && value !== undefined) {
|
|
60
|
+
if (typeof value === 'object' && !Array.isArray(value)) {
|
|
61
|
+
// Recursively flatten nested objects
|
|
62
|
+
Object.assign(flattened, flattenParams(value, queryKey))
|
|
63
|
+
} else {
|
|
64
|
+
flattened[queryKey] = String(value)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
return flattened
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Unflatten query params back to nested structure
|
|
74
|
+
* e.g., { 'filter[role]': 'admin' } => { filter: { role: 'admin' } }
|
|
75
|
+
*/
|
|
76
|
+
const unflattenParams = (flatParams) => {
|
|
77
|
+
const unflattened = {}
|
|
78
|
+
|
|
79
|
+
Object.keys(flatParams).forEach((key) => {
|
|
80
|
+
const value = flatParams[key]
|
|
81
|
+
|
|
82
|
+
// Parse bracket notation: "filter[role]" => ["filter", "role"]
|
|
83
|
+
const matches = key.match(/([^\[]+)(?:\[([^\]]+)\])?/)
|
|
84
|
+
if (!matches) return
|
|
85
|
+
|
|
86
|
+
const [, rootKey, nestedKey] = matches
|
|
87
|
+
|
|
88
|
+
if (nestedKey) {
|
|
89
|
+
// Nested param
|
|
90
|
+
if (!unflattened[rootKey]) {
|
|
91
|
+
unflattened[rootKey] = {}
|
|
92
|
+
}
|
|
93
|
+
unflattened[rootKey][nestedKey] = value
|
|
94
|
+
} else {
|
|
95
|
+
// Top-level param
|
|
96
|
+
unflattened[rootKey] = value
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
return unflattened
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Save params to localStorage with timestamp
|
|
105
|
+
*/
|
|
106
|
+
const saveToStorage = () => {
|
|
107
|
+
if (!persistToStorage) return
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const data = {
|
|
111
|
+
params: sparkTable.params,
|
|
112
|
+
timestamp: Date.now(),
|
|
113
|
+
}
|
|
114
|
+
localStorage.setItem(storageKey, JSON.stringify(data))
|
|
115
|
+
} catch (error) {
|
|
116
|
+
console.warn('Failed to save table state to localStorage:', error)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Load params from localStorage (respecting TTL)
|
|
122
|
+
*/
|
|
123
|
+
const loadFromStorage = () => {
|
|
124
|
+
if (!persistToStorage) return null
|
|
125
|
+
|
|
126
|
+
try {
|
|
127
|
+
const stored = localStorage.getItem(storageKey)
|
|
128
|
+
if (!stored) return null
|
|
129
|
+
|
|
130
|
+
const data = JSON.parse(stored)
|
|
131
|
+
|
|
132
|
+
// Check TTL if configured
|
|
133
|
+
if (storageTTL > 0) {
|
|
134
|
+
const age = Date.now() - data.timestamp
|
|
135
|
+
const maxAge = storageTTL * 24 * 60 * 60 * 1000 // days to ms
|
|
136
|
+
|
|
137
|
+
if (age > maxAge) {
|
|
138
|
+
// Expired, clear it
|
|
139
|
+
localStorage.removeItem(storageKey)
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return data.params
|
|
145
|
+
} catch (error) {
|
|
146
|
+
console.warn('Failed to load table state from localStorage:', error)
|
|
147
|
+
return null
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Clear localStorage for this table
|
|
153
|
+
*/
|
|
154
|
+
const clearStorage = () => {
|
|
155
|
+
try {
|
|
156
|
+
localStorage.removeItem(storageKey)
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.warn('Failed to clear table state from localStorage:', error)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Sync current sparkTable.params to route query
|
|
164
|
+
*/
|
|
165
|
+
const syncParamsToRoute = () => {
|
|
166
|
+
if (!syncToRoute) return
|
|
167
|
+
const currentQuery = { ...route.query }
|
|
168
|
+
|
|
169
|
+
if (useFlatUrls) {
|
|
170
|
+
// Flat mode: remove ALL table-related params from current URL first
|
|
171
|
+
// This ensures params that were removed from sparkTable.params are also removed from URL
|
|
172
|
+
Object.keys(currentQuery).forEach((key) => {
|
|
173
|
+
if (isTableParam(key)) {
|
|
174
|
+
delete currentQuery[key]
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
// Then add back the current params
|
|
178
|
+
const flattenedParams = flattenParams(sparkTable.params)
|
|
179
|
+
Object.assign(currentQuery, flattenedParams)
|
|
180
|
+
} else {
|
|
181
|
+
// Namespaced mode: remove old namespace params
|
|
182
|
+
Object.keys(currentQuery).forEach((key) => {
|
|
183
|
+
if (key.startsWith(`${namespace}[`) || key === namespace) {
|
|
184
|
+
delete currentQuery[key]
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
// Flatten params with namespace prefix
|
|
188
|
+
const flattenedParams = flattenParams(sparkTable.params, namespace)
|
|
189
|
+
Object.assign(currentQuery, flattenedParams)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Use replace to avoid polluting history
|
|
193
|
+
router.replace({ query: currentQuery })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Helper to identify if a query param belongs to SparkTable
|
|
198
|
+
*/
|
|
199
|
+
const isTableParam = (key) => {
|
|
200
|
+
const tableParams = ['page', 'limit', 'search', 'orderBy', 'sortedBy']
|
|
201
|
+
if (tableParams.includes(key)) return true
|
|
202
|
+
if (key.includes('[')) return true // Any bracket notation is likely a filter
|
|
203
|
+
return false
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Coerce URL query param values to their proper types
|
|
208
|
+
* URL params are always strings, but page/limit should be integers
|
|
209
|
+
*/
|
|
210
|
+
const coerceParamValue = (key, value) => {
|
|
211
|
+
const numericParams = ['page', 'limit']
|
|
212
|
+
if (numericParams.includes(key) && value !== null && value !== undefined) {
|
|
213
|
+
const parsed = parseInt(value, 10)
|
|
214
|
+
return isNaN(parsed) ? value : parsed
|
|
215
|
+
}
|
|
216
|
+
return value
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Restore params from route query to sparkTable
|
|
221
|
+
*/
|
|
222
|
+
const restoreFromRoute = () => {
|
|
223
|
+
if (useFlatUrls) {
|
|
224
|
+
// Flat mode: read any query param that looks like a table param
|
|
225
|
+
Object.keys(route.query).forEach((key) => {
|
|
226
|
+
if (isTableParam(key)) {
|
|
227
|
+
sparkTable.params[key] = coerceParamValue(key, route.query[key])
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
} else {
|
|
231
|
+
// Namespaced mode: extract params with namespace prefix
|
|
232
|
+
const prefix = `${namespace}[`
|
|
233
|
+
|
|
234
|
+
Object.keys(route.query).forEach((key) => {
|
|
235
|
+
if (key.startsWith(prefix)) {
|
|
236
|
+
// Remove namespace prefix and trailing bracket
|
|
237
|
+
// e.g., "table[selectFilters[is_quote]]" => "selectFilters[is_quote]"
|
|
238
|
+
// e.g., "table[page]" => "page"
|
|
239
|
+
const paramKey = key.slice(prefix.length, -1)
|
|
240
|
+
sparkTable.params[paramKey] = coerceParamValue(paramKey, route.query[key])
|
|
241
|
+
}
|
|
242
|
+
})
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Watch sparkTable.params and sync to route/storage
|
|
247
|
+
watch(
|
|
248
|
+
() => sparkTable.params,
|
|
249
|
+
() => {
|
|
250
|
+
if (syncToRoute) {
|
|
251
|
+
syncParamsToRoute()
|
|
252
|
+
}
|
|
253
|
+
if (persistToStorage) {
|
|
254
|
+
saveToStorage()
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
{ deep: true },
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
// Restore from route/storage on mount if enabled
|
|
261
|
+
// Priority: URL params > localStorage > defaults
|
|
262
|
+
if (restoreOnMount) {
|
|
263
|
+
onMounted(() => {
|
|
264
|
+
let hasUrlParams = false
|
|
265
|
+
|
|
266
|
+
// First, try to restore from URL
|
|
267
|
+
if (syncToRoute) {
|
|
268
|
+
let hasRelevantParams = false
|
|
269
|
+
|
|
270
|
+
if (useFlatUrls) {
|
|
271
|
+
// Flat mode: check if any query param looks like a table param
|
|
272
|
+
hasRelevantParams = Object.keys(route.query).some((key) => isTableParam(key))
|
|
273
|
+
} else {
|
|
274
|
+
// Namespaced mode: check for namespace prefix
|
|
275
|
+
const prefix = `${namespace}[`
|
|
276
|
+
hasRelevantParams = Object.keys(route.query).some((key) =>
|
|
277
|
+
key.startsWith(prefix)
|
|
278
|
+
)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (hasRelevantParams) {
|
|
282
|
+
hasUrlParams = true
|
|
283
|
+
restoreFromRoute()
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// If no URL params, try localStorage
|
|
288
|
+
if (!hasUrlParams && persistToStorage) {
|
|
289
|
+
const storedParams = loadFromStorage()
|
|
290
|
+
if (storedParams && Object.keys(storedParams).length > 0) {
|
|
291
|
+
// Coerce numeric params in case localStorage has string values from previous buggy saves
|
|
292
|
+
Object.keys(storedParams).forEach((key) => {
|
|
293
|
+
storedParams[key] = coerceParamValue(key, storedParams[key])
|
|
294
|
+
})
|
|
295
|
+
Object.assign(sparkTable.params, storedParams)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
syncToRoute: syncParamsToRoute,
|
|
303
|
+
restoreFromRoute,
|
|
304
|
+
saveToStorage,
|
|
305
|
+
loadFromStorage,
|
|
306
|
+
clearStorage,
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -117,7 +117,7 @@
|
|
|
117
117
|
|
|
118
118
|
<slot name="header-center">
|
|
119
119
|
<div
|
|
120
|
-
v-if="
|
|
120
|
+
v-if="showBrandSelector"
|
|
121
121
|
class="absolute left-1/2 -translate-x-1/2 cursor-pointer h-9 flex items-center"
|
|
122
122
|
@click="toggleBrandSelector"
|
|
123
123
|
>
|
|
@@ -167,6 +167,7 @@
|
|
|
167
167
|
|
|
168
168
|
<script setup>
|
|
169
169
|
import { computed, h, useSlots } from 'vue'
|
|
170
|
+
import { useRoute } from 'vue-router'
|
|
170
171
|
import {
|
|
171
172
|
SparkOverlay,
|
|
172
173
|
SparkBrandSelector,
|
|
@@ -196,6 +197,7 @@ const props = defineProps({
|
|
|
196
197
|
const emit = defineEmits(['overlayClose'])
|
|
197
198
|
|
|
198
199
|
const slots = useSlots()
|
|
200
|
+
const route = useRoute()
|
|
199
201
|
|
|
200
202
|
const sparkBrandFilterStore = useSparkBrandFilterStore()
|
|
201
203
|
const sparkAppSelectorStore = useSparkAppSelectorStore()
|
|
@@ -204,6 +206,14 @@ const appIcon = computed(() => {
|
|
|
204
206
|
return sparkAppSelectorStore.getAppIcon(props.appStore.state.app)
|
|
205
207
|
})
|
|
206
208
|
|
|
209
|
+
const showBrandSelector = computed(() => {
|
|
210
|
+
// Route meta takes precedence, falls back to app store setting
|
|
211
|
+
if (route.meta.hideBrandSelector === true) {
|
|
212
|
+
return false
|
|
213
|
+
}
|
|
214
|
+
return props.appStore.state.showBrandSelector
|
|
215
|
+
})
|
|
216
|
+
|
|
207
217
|
const toggleAppSelector = () => {
|
|
208
218
|
// Create component wrappers for slots if they exist
|
|
209
219
|
const slotProps = {}
|
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
faSignOut,
|
|
41
41
|
faEye,
|
|
42
42
|
faUndo,
|
|
43
|
+
faBolt,
|
|
44
|
+
faCloudDownload,
|
|
43
45
|
} from '@fortawesome/pro-regular-svg-icons'
|
|
44
46
|
|
|
45
47
|
import {
|
|
@@ -87,6 +89,9 @@ export const Icons = {
|
|
|
87
89
|
farSignOut: faSignOut,
|
|
88
90
|
farEye: faEye,
|
|
89
91
|
farUndo: faUndo,
|
|
92
|
+
farBolt: faBolt,
|
|
93
|
+
farCloudDownload: faCloudDownload,
|
|
94
|
+
|
|
90
95
|
fadSort: faSortDuotone,
|
|
91
96
|
fadSortDown: faSortDownDuotone,
|
|
92
97
|
fadSortUp: faSortUpDuotone,
|
|
@@ -2,6 +2,12 @@ import { defineStore } from 'pinia'
|
|
|
2
2
|
import { computed, reactive } from 'vue'
|
|
3
3
|
|
|
4
4
|
const DEFAULT_APPS = [
|
|
5
|
+
{
|
|
6
|
+
name: 'Bolt',
|
|
7
|
+
description: 'Job management',
|
|
8
|
+
href: 'https://bolt-next.letsbolt.io',
|
|
9
|
+
icon: 'farBolt',
|
|
10
|
+
},
|
|
5
11
|
{
|
|
6
12
|
name: '3CX',
|
|
7
13
|
description: 'VOIP Phone',
|
|
@@ -34,13 +40,13 @@ const DEFAULT_APPS = [
|
|
|
34
40
|
},
|
|
35
41
|
{
|
|
36
42
|
name: 'ReVuze',
|
|
37
|
-
description: 'Get
|
|
43
|
+
description: 'Get customer feedback',
|
|
38
44
|
href: 'https://revuze-next.letsbolt.io',
|
|
39
45
|
icon: 'farComments',
|
|
40
46
|
},
|
|
41
47
|
{
|
|
42
48
|
name: 'Tabula',
|
|
43
|
-
description: 'Admin interface',
|
|
49
|
+
description: 'Admin interface template',
|
|
44
50
|
href: 'https://tabula.letsbolt.io',
|
|
45
51
|
icon: 'farCompass',
|
|
46
52
|
},
|
|
@@ -17,7 +17,7 @@ export const useSparkBrandFilterStore = defineStore(
|
|
|
17
17
|
|
|
18
18
|
// Validate brand structure
|
|
19
19
|
const validBrands = config.brands.filter((brand) => {
|
|
20
|
-
const isValid = brand.
|
|
20
|
+
const isValid = brand.name && brand.logo
|
|
21
21
|
if (!isValid) {
|
|
22
22
|
console.warn('useSparkBrandFilterStore: Invalid brand object', brand)
|
|
23
23
|
}
|
package/src/stores/navigation.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { icon } from '@fortawesome/fontawesome-svg-core'
|
|
2
|
+
import { sparkModalService } from '@/composables/sparkModalService'
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Spark Actions Renderer
|
|
@@ -25,6 +26,16 @@ import { icon } from '@fortawesome/fontawesome-svg-core'
|
|
|
25
26
|
* {
|
|
26
27
|
* icon: 'download',
|
|
27
28
|
* handler: (row) => downloadFile(row.file_url)
|
|
29
|
+
* },
|
|
30
|
+
* {
|
|
31
|
+
* icon: 'trash',
|
|
32
|
+
* label: 'Delete',
|
|
33
|
+
* event: 'deleteRow',
|
|
34
|
+
* confirm: true,
|
|
35
|
+
* confirmTitle: 'Delete Item',
|
|
36
|
+
* confirmType: 'danger',
|
|
37
|
+
* confirmText: 'Delete',
|
|
38
|
+
* cancelText: 'Keep'
|
|
28
39
|
* }
|
|
29
40
|
* ]
|
|
30
41
|
* }
|
|
@@ -37,6 +48,11 @@ import { icon } from '@fortawesome/fontawesome-svg-core'
|
|
|
37
48
|
* @property {string} [event] - Event name to emit (e.g., 'editRow')
|
|
38
49
|
* @property {Function} [handler] - Custom handler function (row) => void
|
|
39
50
|
* @property {boolean|string} [confirm] - Show confirmation dialog (true or custom message)
|
|
51
|
+
* @property {string} [confirmTitle] - Custom confirmation dialog title (default: 'Confirm')
|
|
52
|
+
* @property {string} [confirmType] - Confirmation dialog type: 'info', 'success', 'warning', 'danger' (default: 'warning')
|
|
53
|
+
* @property {string} [confirmText] - Confirm button text (default: 'Confirm')
|
|
54
|
+
* @property {string} [cancelText] - Cancel button text (default: 'Cancel')
|
|
55
|
+
* @property {string} [confirmVariant] - Confirm button variant (default: 'primary')
|
|
40
56
|
* @property {Function} [condition] - Conditional visibility (row) => boolean
|
|
41
57
|
*/
|
|
42
58
|
|
|
@@ -89,7 +105,7 @@ export const actionsRenderer = (sparkTable) => {
|
|
|
89
105
|
}
|
|
90
106
|
|
|
91
107
|
// Add click handler
|
|
92
|
-
button.addEventListener('click', (e) => {
|
|
108
|
+
button.addEventListener('click', async (e) => {
|
|
93
109
|
e.preventDefault()
|
|
94
110
|
e.stopPropagation()
|
|
95
111
|
|
|
@@ -100,7 +116,16 @@ export const actionsRenderer = (sparkTable) => {
|
|
|
100
116
|
? action.confirm
|
|
101
117
|
: `Are you sure you want to ${action.label?.toLowerCase() || 'perform this action'}?`
|
|
102
118
|
|
|
103
|
-
|
|
119
|
+
const confirmed = await sparkModalService.confirm({
|
|
120
|
+
title: action.confirmTitle,
|
|
121
|
+
message: confirmMessage,
|
|
122
|
+
type: action.confirmType,
|
|
123
|
+
confirmText: action.confirmText,
|
|
124
|
+
cancelText: action.cancelText,
|
|
125
|
+
confirmVariant: action.confirmVariant,
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
if (!confirmed) {
|
|
104
129
|
return
|
|
105
130
|
}
|
|
106
131
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { icon } from '@fortawesome/fontawesome-svg-core'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Spark Boolean Renderer
|
|
5
|
+
*
|
|
6
|
+
* Renders boolean-like values as check/times icons in a circular container
|
|
7
|
+
*
|
|
8
|
+
* Truthy values: true, 1, '1', 'yes', 'true'
|
|
9
|
+
* Falsy values: false, 0, '0', 'no', 'false', null, undefined, ''
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* {
|
|
13
|
+
* data: 'is_active',
|
|
14
|
+
* renderer: 'spark.boolean',
|
|
15
|
+
* rendererConfig: {
|
|
16
|
+
* trueIcon: 'check', // Default: 'check'
|
|
17
|
+
* falseIcon: 'xmark', // Default: 'xmark'
|
|
18
|
+
* trueColor: 'green', // Default: 'green'
|
|
19
|
+
* falseColor: 'red', // Default: 'red'
|
|
20
|
+
* size: 32, // Default: 32 (px)
|
|
21
|
+
* iconPrefix: 'far' // Default: 'far' (solid)
|
|
22
|
+
* }
|
|
23
|
+
* }
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const colorClasses = {
|
|
27
|
+
green: { bg: 'bg-green-100', text: 'text-green-500' },
|
|
28
|
+
red: { bg: 'bg-red-100', text: 'text-red-500' },
|
|
29
|
+
yellow: { bg: 'bg-yellow-100', text: 'text-yellow-500' },
|
|
30
|
+
blue: { bg: 'bg-blue-100', text: 'text-blue-500' },
|
|
31
|
+
gray: { bg: 'bg-gray-100', text: 'text-gray-500' },
|
|
32
|
+
purple: { bg: 'bg-purple-100', text: 'text-purple-500' },
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Determines if a value is truthy in the context of boolean display
|
|
37
|
+
* @param {*} value - The value to check
|
|
38
|
+
* @returns {boolean}
|
|
39
|
+
*/
|
|
40
|
+
const isTruthy = (value) => {
|
|
41
|
+
if (value === null || value === undefined || value === '') {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
if (typeof value === 'boolean') {
|
|
45
|
+
return value
|
|
46
|
+
}
|
|
47
|
+
if (typeof value === 'number') {
|
|
48
|
+
return value === 1
|
|
49
|
+
}
|
|
50
|
+
if (typeof value === 'string') {
|
|
51
|
+
const normalized = value.toLowerCase().trim()
|
|
52
|
+
return normalized === '1' || normalized === 'yes' || normalized === 'true'
|
|
53
|
+
}
|
|
54
|
+
return false
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const booleanRenderer = (sparkTable) => {
|
|
58
|
+
return (instance, td, row, col, prop, value, cellProperties) => {
|
|
59
|
+
// Clear cell
|
|
60
|
+
td.innerHTML = ''
|
|
61
|
+
td.classList.add('spark-table-cell-boolean')
|
|
62
|
+
|
|
63
|
+
const config = cellProperties.rendererConfig || {}
|
|
64
|
+
|
|
65
|
+
const truthy = isTruthy(value)
|
|
66
|
+
const iconName = truthy ? (config.trueIcon || 'check') : (config.falseIcon || 'xmark')
|
|
67
|
+
const colorName = truthy ? (config.trueColor || 'green') : (config.falseColor || 'red')
|
|
68
|
+
const size = config.size || 32
|
|
69
|
+
const iconPrefix = config.iconPrefix || 'far'
|
|
70
|
+
|
|
71
|
+
const colors = colorClasses[colorName] || colorClasses.gray
|
|
72
|
+
|
|
73
|
+
// Create circular container
|
|
74
|
+
const container = document.createElement('div')
|
|
75
|
+
container.classList.add(
|
|
76
|
+
'inline-flex',
|
|
77
|
+
'items-center',
|
|
78
|
+
'justify-center',
|
|
79
|
+
'rounded-full',
|
|
80
|
+
colors.bg,
|
|
81
|
+
colors.text,
|
|
82
|
+
)
|
|
83
|
+
container.style.width = `${size}px`
|
|
84
|
+
container.style.height = `${size}px`
|
|
85
|
+
|
|
86
|
+
// Create icon
|
|
87
|
+
const iconElement = document.createElement('span')
|
|
88
|
+
iconElement.innerHTML = icon({ prefix: iconPrefix, iconName }).html
|
|
89
|
+
iconElement.classList.add('flex', 'items-center', 'justify-center')
|
|
90
|
+
// Icon size is roughly 50% of container
|
|
91
|
+
const iconSize = Math.round(size * 0.5)
|
|
92
|
+
iconElement.style.fontSize = `${iconSize}px`
|
|
93
|
+
|
|
94
|
+
container.appendChild(iconElement)
|
|
95
|
+
td.appendChild(container)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { baseRenderer, registerRenderer } from 'handsontable/renderers'
|
|
2
2
|
import { actionsRenderer } from './actions.js'
|
|
3
3
|
import { badgeRenderer } from './badge.js'
|
|
4
|
+
import { booleanRenderer } from './boolean.js'
|
|
4
5
|
import { linkRenderer } from './link.js'
|
|
5
6
|
import { imageRenderer } from './image.js'
|
|
6
7
|
import { dateRenderer } from './date.js'
|
|
@@ -50,6 +51,7 @@ export const getRenderer = (key) => {
|
|
|
50
51
|
* Registers the following renderers:
|
|
51
52
|
* - spark.actions: Inline action buttons
|
|
52
53
|
* - spark.badge: Colored status badges
|
|
54
|
+
* - spark.boolean: Boolean indicators with check/times icons
|
|
53
55
|
* - spark.link: Clickable links (email, phone, custom)
|
|
54
56
|
* - spark.image: Image thumbnails
|
|
55
57
|
* - spark.date: Formatted dates
|
|
@@ -61,6 +63,7 @@ export const registerSparkRenderers = (sparkTable) => {
|
|
|
61
63
|
// Register Spark renderers
|
|
62
64
|
register('spark.actions', actionsRenderer(sparkTable))
|
|
63
65
|
register('spark.badge', badgeRenderer(sparkTable))
|
|
66
|
+
register('spark.boolean', booleanRenderer(sparkTable))
|
|
64
67
|
register('spark.link', linkRenderer(sparkTable))
|
|
65
68
|
register('spark.image', imageRenderer(sparkTable))
|
|
66
69
|
register('spark.date', dateRenderer(sparkTable))
|