@poirazis/supercomponents-shared 1.0.8 → 1.0.11

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poirazis/supercomponents-shared",
3
- "version": "1.0.8",
3
+ "version": "1.0.11",
4
4
  "description": "Shared Svelte components library",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -37,6 +37,7 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@sveltejs/svelte-virtual-list": "^3.0.1",
40
+ "@budibase/types": "^2.33.14",
40
41
  "date-picker-svelte": "^2.16.0",
41
42
  "svelte-dnd-action": "^0.9.61",
42
43
  "svelte-fsm": "^1.2.0",
package/src/index.js CHANGED
@@ -5,6 +5,7 @@ export { default as SuperButton } from "./lib/SuperButton/SuperButton.svelte";
5
5
 
6
6
  // Form components
7
7
  export { default as SuperField } from "./lib/SuperField/SuperField.svelte";
8
+ export { default as SuperForm } from "./lib/SuperForm/SuperForm.svelte";
8
9
 
9
10
  // List components
10
11
  export { default as SuperList } from "./lib/SuperList/SuperList.svelte";
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export { default as SuperButton } from "./lib/SuperButton/SuperButton.svelte";
5
5
 
6
6
  // Form components
7
7
  export { default as SuperField } from "./lib/SuperField/SuperField.svelte";
8
+ export { default as SuperForm } from "./lib/SuperForm/SuperForm.svelte";
8
9
 
9
10
  // List components
10
11
  export { default as SuperList } from "./lib/SuperList/SuperList.svelte";
@@ -0,0 +1,558 @@
1
+ <script lang="ts">
2
+ import { setContext, getContext } from "svelte";
3
+ import type { Readable, Writable } from "svelte/store";
4
+ import { derived, get, writable } from "svelte/store";
5
+ const { createValidatorFromConstraints } = getContext("sdk");
6
+ import type { FieldSchema, FieldType, UIFieldValidationRule } from "./types";
7
+
8
+ type FieldInfo<T = any> = {
9
+ name: string;
10
+ step: number;
11
+ type: FieldType;
12
+ fieldState: {
13
+ fieldId: string;
14
+ value: T;
15
+ defaultValue: T;
16
+ disabled: boolean;
17
+ readonly: boolean;
18
+ validator: ((_value: T) => string | null) | null;
19
+ error: string | null | undefined;
20
+ lastUpdate: number;
21
+ };
22
+ fieldApi: {
23
+ setValue(_value: T): void;
24
+ validate(): boolean;
25
+ reset(): void;
26
+ };
27
+ fieldSchema: FieldSchema | {};
28
+ };
29
+
30
+ export let dataSource: any | undefined = undefined;
31
+ export let disabled: boolean = false;
32
+ export let readonly: boolean = false;
33
+ export let initialValues: Record<string, any> | undefined = undefined;
34
+ export let size: "Medium" | "Large" | undefined = undefined;
35
+ export let schema: Record<string, FieldSchema> | undefined = undefined;
36
+ export let definition: any | undefined = undefined;
37
+ export let disableSchemaValidation: boolean = false;
38
+ export let editAutoColumns: boolean = false;
39
+
40
+ // For internal use only, to disable context when being used with standalone fields
41
+ export let provideContext: boolean = true;
42
+
43
+ // We export this store so that when we remount the inner form we can still
44
+ // persist what step we're on
45
+ export let currentStep: Writable<number>;
46
+
47
+ const component = getContext("component");
48
+ const { styleable, Provider, ActionTypes } = getContext("sdk");
49
+
50
+ let fields: Writable<FieldInfo>[] = [];
51
+ const formState = writable({
52
+ values: {},
53
+ errors: {},
54
+ valid: true,
55
+ dirty: false,
56
+ currentStep: get(currentStep),
57
+ });
58
+
59
+ // Reactive derived stores to derive form state from field array
60
+ $: values = deriveFieldProperty(fields, (f) => f.fieldState.value);
61
+ $: errors = deriveFieldProperty(fields, (f) => f.fieldState.error);
62
+ $: enrichments = deriveBindingEnrichments(fields);
63
+ $: valid = !Object.values($errors).some((error) => error != null);
64
+ // Track if any field has been modified from its default value
65
+ $: dirty = derived(
66
+ fields,
67
+ (fieldsValue) => fieldsValue.some((field) => {
68
+ const { value, defaultValue } = field.fieldState;
69
+ // Compare current value with default value to determine if field is dirty
70
+ // Using JSON.stringify for deep comparison of objects and arrays
71
+ return JSON.stringify(value) !== JSON.stringify(defaultValue);
72
+ })
73
+ );
74
+
75
+ // Derive whether the current form step is valid
76
+ $: currentStepValid = derived(
77
+ [currentStep, ...fields],
78
+ ([currentStepValue, ...fieldsValue]) => {
79
+ return !fieldsValue
80
+ .filter((f) => f.step === currentStepValue)
81
+ .some((f) => f.fieldState.error != null);
82
+ }
83
+ );
84
+
85
+ // Update form state store from derived stores
86
+ $: {
87
+ formState.set({
88
+ values: $values,
89
+ errors: $errors,
90
+ valid,
91
+ dirty: $dirty,
92
+ currentStep: $currentStep,
93
+ });
94
+ }
95
+
96
+ // Derive value of whole form
97
+ $: formValue = deriveFormValue(initialValues, $values, $enrichments);
98
+
99
+ // Create data context to provide
100
+ $: dataContext = {
101
+ ...formValue,
102
+
103
+ // These static values are prefixed to avoid clashes with actual columns
104
+ __value: formValue,
105
+ __valid: valid,
106
+ __dirty: $dirty,
107
+ __currentStep: $currentStep,
108
+ __currentStepValid: $currentStepValid,
109
+ };
110
+
111
+ // Generates a derived store from an array of fields, comprised of a map of
112
+ // extracted values from the field array
113
+ const deriveFieldProperty = (
114
+ fieldStores: Readable<FieldInfo>[],
115
+ getProp: (_field: FieldInfo) => any
116
+ ) => {
117
+ return derived(fieldStores, (fieldValues) => {
118
+ return fieldValues.reduce(
119
+ (map, field) => ({ ...map, [field.name]: getProp(field) }),
120
+ {}
121
+ );
122
+ });
123
+ };
124
+
125
+ // Derives any enrichments which need to be made so that bindings work for
126
+ // special data types like attachments
127
+ const deriveBindingEnrichments = (fieldStores: Readable<FieldInfo>[]) => {
128
+ return derived(fieldStores, (fieldValues) => {
129
+ const enrichments: Record<string, string> = {};
130
+ fieldValues.forEach((field) => {
131
+ if (field.type === "attachment") {
132
+ const value = field.fieldState.value;
133
+ let url = null;
134
+ if (Array.isArray(value) && value[0] != null) {
135
+ url = value[0].url;
136
+ }
137
+ enrichments[`${field.name}_first`] = url;
138
+ }
139
+ });
140
+ return enrichments;
141
+ });
142
+ };
143
+
144
+ // Derive the overall form value and deeply set all field paths so that we
145
+ // can support things like JSON fields.
146
+ const deriveFormValue = (
147
+ initialValues: Record<string, any> | undefined,
148
+ values: Record<string, any>,
149
+ enrichments: Record<string, string>
150
+ ) => {
151
+ let formValue = cloneDeep(initialValues || {});
152
+
153
+ // We need to sort the keys to avoid a JSON field overwriting a nested field
154
+ const sortedFields = Object.entries(values || {})
155
+ .map(([key, value]) => {
156
+ const field = getField(key);
157
+ return {
158
+ key,
159
+ value,
160
+ lastUpdate: get(field).fieldState?.lastUpdate || 0,
161
+ };
162
+ })
163
+ .sort((a, b) => {
164
+ return a.lastUpdate - b.lastUpdate;
165
+ });
166
+
167
+ // Merge all values and enrichments into a single value
168
+ sortedFields.forEach(({ key, value }) => {
169
+ deepSet(formValue, key, value);
170
+ });
171
+ Object.entries(enrichments || {}).forEach(([key, value]) => {
172
+ deepSet(formValue, key, value);
173
+ });
174
+ return formValue;
175
+ };
176
+
177
+ // Searches the field array for a certain field
178
+ const getField = (name: string) => {
179
+ return fields.find((field) => get(field).name === name)!;
180
+ };
181
+
182
+ // Sanitises a value by ensuring it doesn't contain any invalid data
183
+ const sanitiseValue = (
184
+ value: any,
185
+ schema: FieldSchema | undefined,
186
+ type: FieldType
187
+ ) => {
188
+ // Check arrays - remove any values not present in the field schema and
189
+ // convert any values supplied to strings
190
+ if (Array.isArray(value) && type === "array" && schema) {
191
+ const options = schema?.constraints?.inclusion || [];
192
+ return value
193
+ .map((opt) => String(opt))
194
+ .filter((opt) => options.includes(opt));
195
+ }
196
+ return value;
197
+ };
198
+
199
+ const formApi = {
200
+ registerField: (
201
+ field: string,
202
+ type: FieldType,
203
+ defaultValue: string | null = null,
204
+ fieldDisabled: boolean = false,
205
+ fieldReadOnly: boolean = false,
206
+ validationRules: UIFieldValidationRule[],
207
+ step: number = 1
208
+ ) => {
209
+ if (!field) {
210
+ return;
211
+ }
212
+ // Create validation function based on field schema
213
+ const schemaConstraints = disableSchemaValidation
214
+ ? null
215
+ : schema?.[field]?.constraints;
216
+ const validator = createValidatorFromConstraints(
217
+ schemaConstraints,
218
+ validationRules,
219
+ field,
220
+ definition
221
+ );
222
+
223
+ // Sanitise the default value to ensure it doesn't contain invalid data
224
+ defaultValue = sanitiseValue(defaultValue, schema?.[field], type);
225
+
226
+ // If we've already registered this field then keep some existing state
227
+ let initialValue = deepGet(initialValues, field) ?? defaultValue;
228
+ let initialError = null;
229
+ let fieldId = `id-${uuid()}`;
230
+ const existingField = getField(field);
231
+ if (existingField) {
232
+ const { fieldState } = get(existingField);
233
+ fieldId = fieldState.fieldId;
234
+
235
+ // Determine the initial value for this field, reusing the current
236
+ // value if one exists
237
+ if (fieldState.value != null && fieldState.value !== "") {
238
+ initialValue = fieldState.value;
239
+ }
240
+
241
+ // If this field has already been registered and we previously had an
242
+ // error set, then re-run the validator to see if we can unset it
243
+ if (fieldState.error) {
244
+ initialError = validator?.(initialValue);
245
+ }
246
+ }
247
+
248
+ // Auto columns are always disabled
249
+ const isAutoColumn = !!schema?.[field]?.autocolumn;
250
+
251
+ // Construct field info
252
+ const fieldInfo = writable<FieldInfo>({
253
+ name: field,
254
+ type,
255
+ step: step || 1,
256
+ fieldState: {
257
+ fieldId,
258
+ value: initialValue,
259
+ error: initialError,
260
+ disabled:
261
+ disabled || fieldDisabled || (isAutoColumn && !editAutoColumns),
262
+ readonly:
263
+ readonly || fieldReadOnly || (schema?.[field] as any)?.readonly,
264
+ defaultValue,
265
+ validator,
266
+ lastUpdate: Date.now(),
267
+ },
268
+ fieldApi: makeFieldApi(field),
269
+ fieldSchema: schema?.[field] ?? {},
270
+ });
271
+
272
+ // Add this field
273
+ if (existingField) {
274
+ const otherFields = fields.filter((info) => get(info).name !== field);
275
+ fields = [...otherFields, fieldInfo];
276
+ } else {
277
+ fields = [...fields, fieldInfo];
278
+ }
279
+
280
+ return fieldInfo;
281
+ },
282
+ validate: () => {
283
+ const stepFields = fields.filter(
284
+ (field) => get(field).step === get(currentStep)
285
+ );
286
+ // We want to validate every field (even if validation fails early) to
287
+ // ensure that all fields are populated with errors if invalid
288
+ let valid = true;
289
+ let hasScrolled = false;
290
+ stepFields.forEach((field) => {
291
+ const fieldValid = get(field).fieldApi.validate();
292
+ valid = valid && fieldValid;
293
+ if (!valid && !hasScrolled) {
294
+ handleScrollToField({ field: get(field) });
295
+ hasScrolled = true;
296
+ }
297
+ });
298
+
299
+ return valid;
300
+ },
301
+ reset: () => {
302
+ // Reset the form by resetting each individual field
303
+ fields.forEach((field) => {
304
+ get(field).fieldApi.reset();
305
+ });
306
+ },
307
+ changeStep: ({
308
+ type,
309
+ number,
310
+ }: {
311
+ type: "next" | "prev" | "first" | "specific";
312
+ number: any;
313
+ }) => {
314
+ if (type === "next") {
315
+ currentStep.update((step) => step + 1);
316
+ } else if (type === "prev") {
317
+ currentStep.update((step) => Math.max(1, step - 1));
318
+ } else if (type === "first") {
319
+ currentStep.set(1);
320
+ } else if (type === "specific" && number && !isNaN(number)) {
321
+ currentStep.set(parseInt(number));
322
+ }
323
+ },
324
+ setStep: (step: number) => {
325
+ if (step) {
326
+ currentStep.set(step);
327
+ }
328
+ },
329
+ setFieldValue: (fieldName: string, value: any) => {
330
+ const field = getField(fieldName);
331
+ if (!field) {
332
+ return;
333
+ }
334
+ const { fieldApi } = get(field);
335
+ fieldApi.setValue(value);
336
+ },
337
+ resetField: (fieldName: string) => {
338
+ const field = getField(fieldName);
339
+ if (!field) {
340
+ return;
341
+ }
342
+ const { fieldApi } = get(field);
343
+ fieldApi.reset();
344
+ },
345
+ };
346
+
347
+ // Creates an API for a specific field
348
+ const makeFieldApi = (field: string) => {
349
+ // Sets the value for a certain field and invokes validation
350
+ const setValue = (value: any, skipCheck = false) => {
351
+ const fieldInfo = getField(field);
352
+ const { fieldState } = get(fieldInfo);
353
+ const { validator } = fieldState;
354
+
355
+ // Skip if the value is the same
356
+ if (!skipCheck && fieldState.value === value) {
357
+ return false;
358
+ }
359
+
360
+ // Update field state
361
+ const error = validator?.(value);
362
+ fieldInfo.update((state) => {
363
+ state.fieldState.value = value;
364
+ state.fieldState.error = error;
365
+ state.fieldState.lastUpdate = Date.now();
366
+ return state;
367
+ });
368
+
369
+ return true;
370
+ };
371
+
372
+ // Clears the value of a certain field back to the default value
373
+ const reset = () => {
374
+ const fieldInfo = getField(field);
375
+ const { fieldState } = get(fieldInfo);
376
+ const newValue = fieldState.defaultValue;
377
+
378
+ // Update field state
379
+ fieldInfo.update((state) => {
380
+ state.fieldState.value = newValue;
381
+ state.fieldState.error = null;
382
+ state.fieldState.lastUpdate = Date.now();
383
+ return state;
384
+ });
385
+ };
386
+
387
+ // We don't want to actually remove the field state when deregistering, just
388
+ // remove any errors and validation
389
+ const deregister = () => {
390
+ const fieldInfo = getField(field);
391
+ fieldInfo.update((state) => {
392
+ state.fieldState.validator = null;
393
+ state.fieldState.error = null;
394
+ return state;
395
+ });
396
+ };
397
+
398
+ // Updates the disabled state of a certain field
399
+ const setDisabled = (fieldDisabled: boolean) => {
400
+ const fieldInfo = getField(field);
401
+
402
+ // Auto columns are always disabled
403
+ const isAutoColumn = !!schema?.[field]?.autocolumn;
404
+
405
+ // Update disabled state
406
+ fieldInfo.update((state) => {
407
+ state.fieldState.disabled = disabled || fieldDisabled || isAutoColumn;
408
+ return state;
409
+ });
410
+ };
411
+
412
+ return {
413
+ setValue,
414
+ reset,
415
+ setDisabled,
416
+ deregister,
417
+ validate: () => {
418
+ // Validate the field by force setting the same value again
419
+ const fieldInfo = getField(field);
420
+ setValue(get(fieldInfo).fieldState.value, true);
421
+ return !get(fieldInfo).fieldState.error;
422
+ },
423
+ };
424
+ };
425
+
426
+ // Provide form state and api for full control by children
427
+ setContext("form", {
428
+ formState,
429
+ formApi,
430
+
431
+ // Datasource is needed by attachment fields to be able to upload files
432
+ // to the correct table ID
433
+ dataSource,
434
+ });
435
+
436
+ // Provide form step context so that forms without any step components
437
+ // register their fields to step 1
438
+ setContext("form-step", writable(1));
439
+
440
+ const handleUpdateFieldValue = ({
441
+ type,
442
+ field,
443
+ value,
444
+ }: {
445
+ type: "set" | "reset";
446
+ field: string;
447
+ value: any;
448
+ }) => {
449
+ if (type === "set") {
450
+ formApi.setFieldValue(field, value);
451
+ } else {
452
+ formApi.resetField(field);
453
+ }
454
+ };
455
+
456
+ const handleScrollToField = (props: { field: FieldInfo | string }) => {
457
+ let field;
458
+ if (typeof props.field === "string") {
459
+ field = get(getField(props.field));
460
+ } else {
461
+ field = props.field;
462
+ }
463
+ const fieldId = field.fieldState.fieldId;
464
+ const fieldElement = document.getElementById(fieldId);
465
+ if (fieldElement) {
466
+ fieldElement.focus({ preventScroll: true });
467
+ }
468
+ const label = document.querySelector<HTMLElement>(
469
+ `label[for="${fieldId}"]`
470
+ );
471
+ if (label) {
472
+ label.style.scrollMargin = "100px";
473
+ label.scrollIntoView({ behavior: "smooth", block: "nearest" });
474
+ }
475
+ };
476
+
477
+ // Action context to pass to children
478
+ const actions = [
479
+ { type: ActionTypes.ValidateForm, callback: formApi.validate },
480
+ { type: ActionTypes.ClearForm, callback: formApi.reset },
481
+ { type: ActionTypes.ChangeFormStep, callback: formApi.changeStep },
482
+ { type: ActionTypes.UpdateFieldValue, callback: handleUpdateFieldValue },
483
+ { type: ActionTypes.ScrollTo, callback: handleScrollToField },
484
+ ];
485
+
486
+ // Helper functions
487
+ function uuid() {
488
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(
489
+ /[xy]/g,
490
+ function (c) {
491
+ var r = (Math.random() * 16) | 0,
492
+ v = c == "x" ? r : (r & 0x3) | 0x8;
493
+ return v.toString(16);
494
+ }
495
+ );
496
+ }
497
+
498
+ function cloneDeep(obj: any) {
499
+ if (obj === null || typeof obj !== "object") {
500
+ return obj;
501
+ }
502
+
503
+ if (Array.isArray(obj)) {
504
+ return obj.map((item) => cloneDeep(item));
505
+ }
506
+
507
+ const cloned: any = {};
508
+ for (const key in obj) {
509
+ if (Object.prototype.hasOwnProperty.call(obj, key)) {
510
+ cloned[key] = cloneDeep(obj[key]);
511
+ }
512
+ }
513
+ return cloned;
514
+ }
515
+
516
+ function deepGet(obj: any, path: string) {
517
+ if (!obj) return undefined;
518
+ const parts = path.split(".");
519
+ let current = obj;
520
+
521
+ for (let i = 0; i < parts.length; i++) {
522
+ if (current === null || current === undefined) {
523
+ return undefined;
524
+ }
525
+ current = current[parts[i]];
526
+ }
527
+
528
+ return current;
529
+ }
530
+
531
+ function deepSet(obj: any, path: string, value: any) {
532
+ if (!obj) return;
533
+ const parts = path.split(".");
534
+ let current = obj;
535
+
536
+ for (let i = 0; i < parts.length - 1; i++) {
537
+ const part = parts[i];
538
+ if (!current[part] || typeof current[part] !== "object") {
539
+ current[part] = {};
540
+ }
541
+ current = current[part];
542
+ }
543
+
544
+ current[parts[parts.length - 1]] = value;
545
+ }
546
+ </script>
547
+
548
+ {#if provideContext}
549
+ <Provider {actions} data={dataContext}>
550
+ <div class={size}>
551
+ <slot />
552
+ </div>
553
+ </Provider>
554
+ {:else}
555
+ <div class={size}>
556
+ <slot />
557
+ </div>
558
+ {/if}