@wishbone-media/spark 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
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
+ * Restore params from route query to sparkTable
208
+ */
209
+ const restoreFromRoute = () => {
210
+ if (useFlatUrls) {
211
+ // Flat mode: read any query param that looks like a table param
212
+ Object.keys(route.query).forEach((key) => {
213
+ if (isTableParam(key)) {
214
+ sparkTable.params[key] = route.query[key]
215
+ }
216
+ })
217
+ } else {
218
+ // Namespaced mode: extract params with namespace prefix
219
+ const prefix = `${namespace}[`
220
+
221
+ Object.keys(route.query).forEach((key) => {
222
+ if (key.startsWith(prefix)) {
223
+ // Remove namespace prefix and trailing bracket
224
+ // e.g., "table[selectFilters[is_quote]]" => "selectFilters[is_quote]"
225
+ // e.g., "table[page]" => "page"
226
+ const paramKey = key.slice(prefix.length, -1)
227
+ sparkTable.params[paramKey] = route.query[key]
228
+ }
229
+ })
230
+ }
231
+ }
232
+
233
+ // Watch sparkTable.params and sync to route/storage
234
+ watch(
235
+ () => sparkTable.params,
236
+ () => {
237
+ if (syncToRoute) {
238
+ syncParamsToRoute()
239
+ }
240
+ if (persistToStorage) {
241
+ saveToStorage()
242
+ }
243
+ },
244
+ { deep: true },
245
+ )
246
+
247
+ // Restore from route/storage on mount if enabled
248
+ // Priority: URL params > localStorage > defaults
249
+ if (restoreOnMount) {
250
+ onMounted(() => {
251
+ let hasUrlParams = false
252
+
253
+ // First, try to restore from URL
254
+ if (syncToRoute) {
255
+ let hasRelevantParams = false
256
+
257
+ if (useFlatUrls) {
258
+ // Flat mode: check if any query param looks like a table param
259
+ hasRelevantParams = Object.keys(route.query).some((key) => isTableParam(key))
260
+ } else {
261
+ // Namespaced mode: check for namespace prefix
262
+ const prefix = `${namespace}[`
263
+ hasRelevantParams = Object.keys(route.query).some((key) =>
264
+ key.startsWith(prefix)
265
+ )
266
+ }
267
+
268
+ if (hasRelevantParams) {
269
+ hasUrlParams = true
270
+ restoreFromRoute()
271
+ }
272
+ }
273
+
274
+ // If no URL params, try localStorage
275
+ if (!hasUrlParams && persistToStorage) {
276
+ const storedParams = loadFromStorage()
277
+ if (storedParams && Object.keys(storedParams).length > 0) {
278
+ Object.assign(sparkTable.params, storedParams)
279
+ }
280
+ }
281
+ })
282
+ }
283
+
284
+ return {
285
+ syncToRoute: syncParamsToRoute,
286
+ restoreFromRoute,
287
+ saveToStorage,
288
+ loadFromStorage,
289
+ clearStorage,
290
+ }
291
+ }
@@ -117,7 +117,7 @@
117
117
 
118
118
  <slot name="header-center">
119
119
  <div
120
- v-if="appStore.state.showBrandSelector"
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 Customer feedback',
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.id && brand.name && brand.logo
20
+ const isValid = brand.name && brand.logo
21
21
  if (!isValid) {
22
22
  console.warn('useSparkBrandFilterStore: Invalid brand object', brand)
23
23
  }
@@ -14,6 +14,7 @@ export const useSparkNavStore = defineStore('sparkNav', () => {
14
14
 
15
15
  const initialize = (menuConfig = []) => {
16
16
  state.menu = menuConfig
17
+ syncWithRoute()
17
18
  }
18
19
 
19
20
  const findMenuItemByRoute = (items, route) => {