@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.
- package/dist/src/lib/builder.svelte.d.ts +0 -18
- package/dist/src/lib/lookup.svelte.d.ts +18 -0
- package/package.json +1 -1
- package/src/FormRenderer.svelte +26 -53
- package/src/display/DisplayCardGrid.svelte +21 -16
- package/src/display/DisplayTable.svelte +18 -22
- package/src/display/DisplayValue.svelte +10 -16
- package/src/input/InputRadio.svelte +2 -2
- package/src/lib/builder.svelte.js +346 -210
- package/src/lib/lookup.svelte.js +226 -228
- package/src/lib/validation.js +128 -98
package/src/lib/lookup.svelte.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
68
|
-
* @
|
|
69
|
-
* @returns {Object} Lookup provider with reactive state
|
|
60
|
+
* Extract first matching array from common API response envelope keys
|
|
61
|
+
* @private
|
|
70
62
|
*/
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
102
|
+
/**
|
|
103
|
+
* @typedef {{ fetchHook: Function, cacheKeyFn?: Function, cacheTime: number, transform?: Function }} HookConfig
|
|
104
|
+
*/
|
|
155
105
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
164
|
-
|
|
116
|
+
const cached = getCachedData(key, cacheTime)
|
|
117
|
+
if (cached) return cached
|
|
165
118
|
|
|
166
|
-
|
|
167
|
-
|
|
119
|
+
const rawData = await fetchHook(params)
|
|
120
|
+
const data = applyTransformAndNormalise(rawData, transform)
|
|
168
121
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
122
|
+
if (key !== undefined) cache.set(key, { data, timestamp: Date.now() })
|
|
123
|
+
return data
|
|
124
|
+
}
|
|
172
125
|
|
|
173
|
-
|
|
126
|
+
/**
|
|
127
|
+
* @typedef {{ url: string, cacheTime: number, transform?: Function }} UrlConfig
|
|
128
|
+
*/
|
|
174
129
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
data = data.data || data.items || data.results || []
|
|
183
|
-
}
|
|
140
|
+
const cached = getCachedData(resolvedUrl, cacheTime)
|
|
141
|
+
if (cached) return cached
|
|
184
142
|
|
|
185
|
-
|
|
186
|
-
cache.set(urlCacheKey, { data, timestamp: Date.now() })
|
|
143
|
+
const response = await globalThis.fetch(resolvedUrl)
|
|
187
144
|
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
226
|
-
},
|
|
227
|
-
get
|
|
228
|
-
|
|
229
|
-
},
|
|
230
|
-
|
|
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
|
-
*
|
|
250
|
-
* @
|
|
251
|
-
* @returns {Object} Lookup manager
|
|
200
|
+
* Clear URL-based cache entries for a given URL template
|
|
201
|
+
* @private
|
|
252
202
|
*/
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
|