@rokkit/forms 1.0.0-next.136 → 1.0.0-next.138

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.
@@ -1,3 +1,5 @@
1
+ import { SvelteMap } from 'svelte/reactivity'
2
+
1
3
  /**
2
4
  * @typedef {Object} LookupConfig
3
5
  * @property {string} [url] - URL template with optional placeholders (e.g., '/api/cities?country={country}')
@@ -29,7 +31,7 @@
29
31
  const DEFAULT_CACHE_TIME = 5 * 60 * 1000 // 5 minutes
30
32
 
31
33
  /** @type {Map<string, CacheEntry>} */
32
- const cache = new Map()
34
+ const cache = new SvelteMap()
33
35
 
34
36
  /**
35
37
  * Interpolates URL template with values
@@ -44,15 +46,6 @@ function interpolateUrl(template, values) {
44
46
  })
45
47
  }
46
48
 
47
- /**
48
- * Generates a cache key from URL and parameters
49
- * @param {string} url - The resolved URL
50
- * @returns {string}
51
- */
52
- function getCacheKey(url) {
53
- return url
54
- }
55
-
56
49
  /**
57
50
  * Checks if a cache entry is still valid
58
51
  * @param {CacheEntry} entry
@@ -64,265 +57,270 @@ function isCacheValid(entry, cacheTime) {
64
57
  }
65
58
 
66
59
  /**
67
- * Creates a lookup provider for a field
68
- * @param {LookupConfig} config - Lookup configuration
69
- * @returns {Object} Lookup provider with reactive state
60
+ * Extract first matching array from common API response envelope keys
61
+ * @private
70
62
  */
71
- export function createLookup(config) {
72
- const {
73
- url,
74
- fetch: fetchHook,
75
- source,
76
- filter,
77
- cacheKey: cacheKeyFn,
78
- dependsOn = [],
79
- fields = {},
80
- cacheTime = DEFAULT_CACHE_TIME,
81
- transform
82
- } = config
83
-
84
- let options = $state([])
85
- let loading = $state(false)
86
- let error = $state(null)
87
- let disabled = $state(false)
88
-
89
- /**
90
- * Fetches options from the configured source (URL, fetch hook, or filter)
91
- * @param {Object} params - Parameter values for URL interpolation / filter context
92
- * @returns {Promise<any[]>}
93
- */
94
- async function fetch(params = {}) {
95
- // Check if all dependencies have values
96
- const missingDeps = dependsOn.filter((dep) => !params[dep] && params[dep] !== 0)
97
- if (missingDeps.length > 0) {
98
- options = []
99
- disabled = true
100
- return []
101
- }
63
+ function unwrapApiResponse(data) {
64
+ const ENVELOPE_KEYS = ['data', 'items', 'results']
65
+ for (const k of ENVELOPE_KEYS) {
66
+ if (data?.[k]) return data[k]
67
+ }
68
+ return []
69
+ }
102
70
 
103
- disabled = false
71
+ /**
72
+ * Normalise raw API data to an array
73
+ * @param {any} data
74
+ * @returns {any[]}
75
+ */
76
+ function normaliseToArray(data) {
77
+ if (Array.isArray(data)) return data
78
+ return unwrapApiResponse(data)
79
+ }
104
80
 
105
- // Branch: synchronous filter over pre-loaded source
106
- if (filter !== undefined && source !== undefined) {
107
- options = filter(source, params)
108
- return options
109
- }
81
+ /**
82
+ * Apply optional transform then normalise to array
83
+ * @param {any} data
84
+ * @param {((d: any) => any)|undefined} transform
85
+ * @returns {any[]}
86
+ */
87
+ function applyTransformAndNormalise(data, transform) {
88
+ const transformed = transform ? transform(data) : data
89
+ return normaliseToArray(transformed)
90
+ }
110
91
 
111
- // Branch: async fetch hook with optional caching
112
- if (fetchHook) {
113
- const key = cacheKeyFn?.(params)
114
-
115
- if (key !== undefined) {
116
- const cached = cache.get(key)
117
- if (cached && isCacheValid(cached, cacheTime)) {
118
- options = cached.data
119
- return cached.data
120
- }
121
- }
122
-
123
- loading = true
124
- error = null
125
-
126
- try {
127
- let data = await fetchHook(params)
128
-
129
- if (transform) {
130
- data = transform(data)
131
- }
132
-
133
- if (!Array.isArray(data)) {
134
- data = data?.data || data?.items || data?.results || []
135
- }
136
-
137
- if (key !== undefined) {
138
- cache.set(key, { data, timestamp: Date.now() })
139
- }
140
-
141
- options = data
142
- return data
143
- } catch (err) {
144
- error = err.message || 'Failed to load options'
145
- options = []
146
- return []
147
- } finally {
148
- loading = false
149
- }
150
- }
92
+ /**
93
+ * Try to return cached data for a given key
94
+ * @private
95
+ */
96
+ function getCachedData(key, cacheTime) {
97
+ if (key === undefined) return null
98
+ const cached = cache.get(key)
99
+ return cached && isCacheValid(cached, cacheTime) ? cached.data : null
100
+ }
151
101
 
152
- // Branch: URL-based fetch (original behavior)
153
- const resolvedUrl = interpolateUrl(url, params)
154
- const urlCacheKey = getCacheKey(resolvedUrl)
102
+ /**
103
+ * @typedef {{ fetchHook: Function, cacheKeyFn?: Function, cacheTime: number, transform?: Function }} HookConfig
104
+ */
155
105
 
156
- // Check cache first
157
- const cached = cache.get(urlCacheKey)
158
- if (cached && isCacheValid(cached, cacheTime)) {
159
- options = cached.data
160
- return cached.data
161
- }
106
+ /**
107
+ * Handle the async fetch-hook branch (with optional caching)
108
+ * @param {HookConfig} hookConfig
109
+ * @param {Object} params
110
+ * @private
111
+ */
112
+ async function fetchFromHook(hookConfig, params) {
113
+ const { fetchHook, cacheKeyFn, cacheTime, transform } = hookConfig
114
+ const key = cacheKeyFn?.(params)
162
115
 
163
- loading = true
164
- error = null
116
+ const cached = getCachedData(key, cacheTime)
117
+ if (cached) return cached
165
118
 
166
- try {
167
- const response = await globalThis.fetch(resolvedUrl)
119
+ const rawData = await fetchHook(params)
120
+ const data = applyTransformAndNormalise(rawData, transform)
168
121
 
169
- if (!response.ok) {
170
- throw new Error(`HTTP ${response.status}: ${response.statusText}`)
171
- }
122
+ if (key !== undefined) cache.set(key, { data, timestamp: Date.now() })
123
+ return data
124
+ }
172
125
 
173
- let data = await response.json()
126
+ /**
127
+ * @typedef {{ url: string, cacheTime: number, transform?: Function }} UrlConfig
128
+ */
174
129
 
175
- // Apply transform if provided
176
- if (transform) {
177
- data = transform(data)
178
- }
130
+ /**
131
+ * Handle the URL-based fetch branch (with caching)
132
+ * @param {UrlConfig} urlConfig
133
+ * @param {Object} params
134
+ * @private
135
+ */
136
+ async function fetchFromUrl(urlConfig, params) {
137
+ const { url, cacheTime, transform } = urlConfig
138
+ const resolvedUrl = interpolateUrl(url, params)
179
139
 
180
- // Ensure data is an array
181
- if (!Array.isArray(data)) {
182
- data = data.data || data.items || data.results || []
183
- }
140
+ const cached = getCachedData(resolvedUrl, cacheTime)
141
+ if (cached) return cached
184
142
 
185
- // Cache the result
186
- cache.set(urlCacheKey, { data, timestamp: Date.now() })
143
+ const response = await globalThis.fetch(resolvedUrl)
187
144
 
188
- options = data
189
- return data
190
- } catch (err) {
191
- error = err.message || 'Failed to load options'
192
- options = []
193
- return []
194
- } finally {
195
- loading = false
196
- }
145
+ if (!response.ok) {
146
+ throw new Error(`HTTP ${response.status}: ${response.statusText}`)
197
147
  }
198
148
 
199
- /**
200
- * Clears the cache for this lookup
201
- */
202
- function clearCache() {
203
- if (!url) return // fetch/filter hooks have no URL-keyed cache entries
204
- // Clear all cache entries that match this URL pattern
205
- const baseUrl = url.split('?')[0]
206
- for (const key of cache.keys()) {
207
- if (key.startsWith(baseUrl)) {
208
- cache.delete(key)
209
- }
210
- }
211
- }
149
+ const rawData = await response.json()
150
+ const data = applyTransformAndNormalise(rawData, transform)
151
+
152
+ cache.set(resolvedUrl, { data, timestamp: Date.now() })
153
+ return data
154
+ }
212
155
 
213
- /**
214
- * Resets the lookup state
215
- */
216
- function reset() {
217
- options = []
218
- loading = false
219
- error = null
220
- disabled = false
156
+ /**
157
+ * Execute an async fetch function and capture result/error into reactive state
158
+ * @private
159
+ */
160
+ async function runWithLoadingState(asyncFn, state) {
161
+ state.loading = true
162
+ state.error = null
163
+ try {
164
+ const data = await asyncFn()
165
+ state.options = data
166
+ return data
167
+ } catch (err) {
168
+ state.error = err.message || 'Failed to load options'
169
+ state.options = []
170
+ return []
171
+ } finally {
172
+ state.loading = false
221
173
  }
174
+ }
222
175
 
176
+ /**
177
+ * @typedef {{ state: Object, meta: Object, fetch: Function, clearCache: Function, reset: Function }} LookupApi
178
+ */
179
+
180
+ /**
181
+ * Build the public API object returned by createLookup
182
+ * @param {{ state: Object, meta: Object }} ctx
183
+ * @param {{ fetch: Function, clearCache: Function, reset: Function }} fns
184
+ * @private
185
+ */
186
+ function buildLookupApi(ctx, fns) {
187
+ const { state, meta } = ctx
223
188
  return {
224
- get options() {
225
- return options
226
- },
227
- get loading() {
228
- return loading
229
- },
230
- get error() {
231
- return error
232
- },
233
- get disabled() {
234
- return disabled
235
- },
236
- get dependsOn() {
237
- return dependsOn
238
- },
239
- get fields() {
240
- return fields
241
- },
242
- fetch,
243
- clearCache,
244
- reset
189
+ get options() { return state.options },
190
+ get loading() { return state.loading },
191
+ get error() { return state.error },
192
+ get disabled() { return state.disabled },
193
+ get dependsOn() { return meta.dependsOn },
194
+ get fields() { return meta.fields },
195
+ ...fns
245
196
  }
246
197
  }
247
198
 
248
199
  /**
249
- * Creates a lookup manager for a form with multiple lookups
250
- * @param {Object<string, LookupConfig>} lookupConfigs - Map of field paths to lookup configs
251
- * @returns {Object} Lookup manager
200
+ * Clear URL-based cache entries for a given URL template
201
+ * @private
252
202
  */
253
- export function createLookupManager(lookupConfigs) {
254
- /** @type {Map<string, ReturnType<typeof createLookup>>} */
255
- const lookups = new Map()
203
+ function clearUrlCache(url) {
204
+ if (!url) return
205
+ const baseUrl = url.split('?')[0]
206
+ for (const key of cache.keys()) {
207
+ if (key.startsWith(baseUrl)) cache.delete(key)
208
+ }
209
+ }
256
210
 
257
- // Create lookup providers for each configured field
258
- for (const [fieldPath, config] of Object.entries(lookupConfigs)) {
259
- lookups.set(fieldPath, createLookup(config))
211
+ /**
212
+ * Reset lookup state to initial values
213
+ * @private
214
+ */
215
+ function resetState(state) {
216
+ state.options = []
217
+ state.loading = false
218
+ state.error = null
219
+ state.disabled = false
220
+ }
221
+
222
+ /**
223
+ * Dispatch fetch to the appropriate strategy
224
+ * @private
225
+ */
226
+ function fetchDispatch(params, ctx) {
227
+ const { state, dependsOn, source, filter, fetchHook, cacheKeyFn, cacheTime, transform, url } = ctx
228
+
229
+ const missingDeps = dependsOn.filter((dep) => !params[dep] && params[dep] !== 0)
230
+ if (missingDeps.length > 0) {
231
+ state.options = []
232
+ state.disabled = true
233
+ return Promise.resolve([])
260
234
  }
261
235
 
262
- /**
263
- * Gets the lookup provider for a field
264
- * @param {string} fieldPath
265
- * @returns {ReturnType<typeof createLookup> | undefined}
266
- */
267
- function getLookup(fieldPath) {
268
- return lookups.get(fieldPath)
236
+ state.disabled = false
237
+
238
+ if (filter !== undefined && source !== undefined) {
239
+ state.options = filter(source, params)
240
+ return Promise.resolve(state.options)
269
241
  }
270
242
 
271
- /**
272
- * Checks if a field has a lookup configured
273
- * @param {string} fieldPath
274
- * @returns {boolean}
275
- */
276
- function hasLookup(fieldPath) {
277
- return lookups.has(fieldPath)
243
+ if (fetchHook) {
244
+ return runWithLoadingState(() => fetchFromHook({ fetchHook, cacheKeyFn, cacheTime, transform }, params), state)
278
245
  }
279
246
 
280
- /**
281
- * Handles a field value change and triggers dependent lookups
282
- * @param {string} changedField - The field that changed
283
- * @param {Object} formData - Current form data
284
- */
285
- async function handleFieldChange(changedField, formData) {
286
- // Find all lookups that depend on this field
287
- for (const [_fieldPath, lookup] of lookups) {
288
- if (lookup.dependsOn.includes(changedField)) {
289
- // Reset dependent field value and fetch new options
290
- await lookup.fetch(formData)
291
- }
247
+ return runWithLoadingState(() => fetchFromUrl({ url, cacheTime, transform }, params), state)
248
+ }
249
+
250
+ /**
251
+ * Creates a lookup provider for a field
252
+ * @param {LookupConfig} config - Lookup configuration
253
+ * @returns {Object} Lookup provider with reactive state
254
+ */
255
+ export function createLookup(config) {
256
+ const { url, fetch: fetchHook, source, filter, cacheKey: cacheKeyFn,
257
+ dependsOn = [], fields = {}, cacheTime = DEFAULT_CACHE_TIME, transform } = config
258
+
259
+ let state = $state({ options: [], loading: false, error: null, disabled: false })
260
+ const ctx = { state, dependsOn, source, filter, fetchHook, cacheKeyFn, cacheTime, transform, url }
261
+
262
+ return buildLookupApi(
263
+ { state, meta: { dependsOn, fields } },
264
+ {
265
+ fetch: (params = {}) => fetchDispatch(params, ctx),
266
+ clearCache: () => clearUrlCache(url),
267
+ reset: () => resetState(state)
292
268
  }
269
+ )
270
+ }
271
+
272
+ /**
273
+ * Initialize all lookups from the lookup map
274
+ * @private
275
+ */
276
+ async function initializeAllLookups(lookups, formData) {
277
+ const promises = []
278
+ for (const [_fieldPath, lookup] of lookups) {
279
+ promises.push(lookup.fetch(formData))
293
280
  }
281
+ await Promise.all(promises)
282
+ }
294
283
 
295
- /**
296
- * Initializes all lookups with current form data.
297
- * Calls fetch() for every lookup so disabled state is set correctly.
298
- * @param {Object} formData - Current form data
299
- */
300
- async function initialize(formData) {
301
- const promises = []
302
- for (const [_fieldPath, lookup] of lookups) {
303
- promises.push(lookup.fetch(formData))
284
+ /**
285
+ * Trigger all lookups that depend on the changed field
286
+ * @private
287
+ */
288
+ async function triggerDependentLookups(lookups, changedField, formData) {
289
+ for (const [_fieldPath, lookup] of lookups) {
290
+ if (lookup.dependsOn.includes(changedField)) {
291
+ await lookup.fetch(formData)
304
292
  }
305
- await Promise.all(promises)
306
293
  }
294
+ }
307
295
 
308
- /**
309
- * Clears all caches
310
- */
311
- function clearAllCaches() {
312
- for (const lookup of lookups.values()) {
313
- lookup.clearCache()
314
- }
296
+ /**
297
+ * Build a SvelteMap of fieldPath -> lookup from configs
298
+ * @private
299
+ */
300
+ function buildLookupMap(lookupConfigs) {
301
+ const lookups = new SvelteMap()
302
+ for (const [fieldPath, config] of Object.entries(lookupConfigs)) {
303
+ lookups.set(fieldPath, createLookup(config))
315
304
  }
305
+ return lookups
306
+ }
307
+
308
+ /**
309
+ * Creates a lookup manager for a form with multiple lookups
310
+ * @param {Object<string, LookupConfig>} lookupConfigs - Map of field paths to lookup configs
311
+ * @returns {Object} Lookup manager
312
+ */
313
+ export function createLookupManager(lookupConfigs) {
314
+ const lookups = buildLookupMap(lookupConfigs)
316
315
 
317
316
  return {
318
- getLookup,
319
- hasLookup,
320
- handleFieldChange,
321
- initialize,
322
- clearAllCaches,
323
- get lookups() {
324
- return lookups
325
- }
317
+ getLookup: (fieldPath) => lookups.get(fieldPath),
318
+ hasLookup: (fieldPath) => lookups.has(fieldPath),
319
+ handleFieldChange: (changedField, formData) =>
320
+ triggerDependentLookups(lookups, changedField, formData),
321
+ initialize: (formData) => initializeAllLookups(lookups, formData),
322
+ clearAllCaches: () => { for (const lookup of lookups.values()) lookup.clearCache() },
323
+ get lookups() { return lookups }
326
324
  }
327
325
  }
328
326