runeforge 0.0.19 → 0.0.21
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/components/crud/Field.svelte +22 -6
- package/dist/components/crud/GenericCRUD.svelte +5 -43
- package/dist/components/crud/GenericCRUD.svelte.d.ts +0 -5
- package/dist/components/crud/utils/grouping.d.ts +6 -0
- package/dist/components/crud/utils/grouping.js +23 -0
- package/dist/components/crud/utils/resolution.d.ts +2 -0
- package/dist/components/crud/utils/resolution.js +30 -0
- package/dist/components/crud/utils/validation.d.ts +3 -0
- package/dist/components/crud/utils/validation.js +44 -0
- package/dist/components/crud/views/Create.svelte +19 -15
- package/dist/components/crud/views/Read.svelte +18 -2
- package/dist/components/crud/views/Update.svelte +21 -16
- package/dist/i18n/en.js +7 -0
- package/dist/i18n/es.js +7 -0
- package/dist/i18n/types.d.ts +7 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/types/attribute.d.ts +13 -0
- package/dist/types/crud.d.ts +13 -0
- package/package.json +1 -1
|
@@ -43,6 +43,16 @@
|
|
|
43
43
|
const preview = $derived(filePreview ?? (typeof saved === 'string' && saved ? saved : null));
|
|
44
44
|
const avatarInitials = $derived(initials(record.firstName as string, record.lastName as string));
|
|
45
45
|
const displayValue = $derived(saved == null ? '' : String(saved));
|
|
46
|
+
const selectOptions = $derived(field.dependentOptions ? field.dependentOptions(record) : (field.options ?? []));
|
|
47
|
+
const fieldDisabled = $derived(field.disabled ? field.disabled(record) : false);
|
|
48
|
+
|
|
49
|
+
$effect(() => {
|
|
50
|
+
if (!field.dependentOptions) return;
|
|
51
|
+
const current = record[field.attribute];
|
|
52
|
+
if (current && !selectOptions.some((o) => o.value === String(current))) {
|
|
53
|
+
record[field.attribute] = '';
|
|
54
|
+
}
|
|
55
|
+
});
|
|
46
56
|
</script>
|
|
47
57
|
|
|
48
58
|
<div class="flex flex-col gap-1">
|
|
@@ -66,7 +76,8 @@
|
|
|
66
76
|
{name}
|
|
67
77
|
class="toggle toggle-primary"
|
|
68
78
|
checked={!!saved}
|
|
69
|
-
disabled={readonly}
|
|
79
|
+
disabled={readonly || fieldDisabled}
|
|
80
|
+
onchange={(e) => { record[field.attribute] = (e.currentTarget as HTMLInputElement).checked; }}
|
|
70
81
|
/>
|
|
71
82
|
{:else if field.type === 'file'}
|
|
72
83
|
{#if !readonly}
|
|
@@ -76,6 +87,7 @@
|
|
|
76
87
|
{name}
|
|
77
88
|
class="file-input file-input-bordered w-full"
|
|
78
89
|
class:file-input-error={!!error}
|
|
90
|
+
disabled={fieldDisabled}
|
|
79
91
|
onchange={onFileChange}
|
|
80
92
|
/>
|
|
81
93
|
{/if}
|
|
@@ -86,15 +98,16 @@
|
|
|
86
98
|
type="text"
|
|
87
99
|
id={field.attribute}
|
|
88
100
|
class="input input-bordered w-full"
|
|
89
|
-
value={
|
|
101
|
+
value={selectOptions.find((o) => o.value === String(saved))?.label ?? displayValue}
|
|
90
102
|
disabled
|
|
91
103
|
/>
|
|
92
104
|
{:else}
|
|
93
105
|
<Select
|
|
94
106
|
name={field.attribute}
|
|
95
107
|
bind:value={record[field.attribute] as string}
|
|
96
|
-
options={
|
|
108
|
+
options={selectOptions}
|
|
97
109
|
placeholder={field.placeholder}
|
|
110
|
+
disabled={fieldDisabled}
|
|
98
111
|
{error}
|
|
99
112
|
/>
|
|
100
113
|
{/if}
|
|
@@ -108,11 +121,11 @@
|
|
|
108
121
|
disabled
|
|
109
122
|
/>
|
|
110
123
|
{:else}
|
|
111
|
-
<input type="hidden" {name} value={String(record[field.attribute] ?? '')} />
|
|
124
|
+
<input type="hidden" {name} value={String(record[field.attribute] ?? '')} disabled={fieldDisabled} />
|
|
112
125
|
<calendar-date
|
|
113
|
-
class="cally rounded-box border border-base-300 bg-base-100 shadow-sm"
|
|
126
|
+
class="cally rounded-box border border-base-300 bg-base-100 shadow-sm {fieldDisabled ? 'pointer-events-none opacity-50' : ''}"
|
|
114
127
|
value={String(record[field.attribute] ?? '')}
|
|
115
|
-
onchange={(e: Event) => { record[field.attribute] = (e.currentTarget as HTMLElement & { value: string }).value; }}
|
|
128
|
+
onchange={(e: Event) => { if (fieldDisabled) return; record[field.attribute] = (e.currentTarget as HTMLElement & { value: string }).value; }}
|
|
116
129
|
>
|
|
117
130
|
<svg aria-label={strings.previous} class="fill-current size-4" {...{"slot": "previous"}} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="M15.75 19.5 8.25 12l7.5-7.5"/></svg>
|
|
118
131
|
<svg aria-label={strings.next} class="fill-current size-4" {...{"slot": "next"}} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path fill="currentColor" d="m8.25 4.5 7.5 7.5-7.5 7.5"/></svg>
|
|
@@ -130,6 +143,7 @@
|
|
|
130
143
|
bind:value={record[field.attribute]}
|
|
131
144
|
class="textarea textarea-bordered bg-base-100 w-full"
|
|
132
145
|
class:textarea-error={!!error}
|
|
146
|
+
disabled={fieldDisabled}
|
|
133
147
|
></textarea>
|
|
134
148
|
{/if}
|
|
135
149
|
{:else if readonly}
|
|
@@ -148,8 +162,10 @@
|
|
|
148
162
|
placeholder={field.placeholder ?? ''}
|
|
149
163
|
bind:value={record[field.attribute]}
|
|
150
164
|
autocomplete={field.autocomplete}
|
|
165
|
+
step={field.type === 'number' ? 'any' : undefined}
|
|
151
166
|
class="input input-bordered w-full"
|
|
152
167
|
class:input-error={!!error}
|
|
168
|
+
disabled={fieldDisabled}
|
|
153
169
|
/>
|
|
154
170
|
{/if}
|
|
155
171
|
|
|
@@ -7,9 +7,9 @@
|
|
|
7
7
|
import Update from './views/Update.svelte';
|
|
8
8
|
import { AUTO_EXCLUDED } from './utils/constants.js';
|
|
9
9
|
import {
|
|
10
|
-
resolveOptions,
|
|
11
10
|
resolveFormatter,
|
|
12
|
-
inferType
|
|
11
|
+
inferType,
|
|
12
|
+
buildFieldDefinitions
|
|
13
13
|
} from './utils/resolution.js';
|
|
14
14
|
import { isFilterable } from '../table/utils.js';
|
|
15
15
|
import type { XlsxModule } from '../table/export.js';
|
|
@@ -72,13 +72,8 @@
|
|
|
72
72
|
fields?: FieldDefinition<T>[];
|
|
73
73
|
meta?: Partial<Record<string, AttributeMetadata>>;
|
|
74
74
|
form?: { error?: string } | null;
|
|
75
|
-
/** Shows an icon-only export button (CSV, and Excel if `xlsx` is provided). */
|
|
76
75
|
enableExport?: boolean;
|
|
77
|
-
/** Server-pagination mode only: fetch all rows matching the current query
|
|
78
|
-
* (unpaginated) for export. Without it, export falls back to the loaded page. */
|
|
79
76
|
onExport?: (query: TableQuery) => Promise<T[]>;
|
|
80
|
-
/** Resolved `xlsx` (SheetJS) module, e.g. `import * as xlsx from 'xlsx'`.
|
|
81
|
-
* Enables the "Export as Excel" option; omit to only offer CSV. */
|
|
82
77
|
xlsx?: XlsxModule;
|
|
83
78
|
} = $props();
|
|
84
79
|
|
|
@@ -231,18 +226,7 @@
|
|
|
231
226
|
const resolvedFields: FieldDefinition<T>[] = $derived(
|
|
232
227
|
fields ??
|
|
233
228
|
(meta
|
|
234
|
-
? (
|
|
235
|
-
.filter(([k, m]) => !excluded.has(k) && !m.excludedFromCreate)
|
|
236
|
-
.map(([k, m]) => ({
|
|
237
|
-
attribute: k as keyof T & string,
|
|
238
|
-
title: m.label,
|
|
239
|
-
type: m.type ?? inferType(k, undefined),
|
|
240
|
-
required: m.required,
|
|
241
|
-
autocomplete: m.autocomplete,
|
|
242
|
-
placeholder: m.placeholder,
|
|
243
|
-
default: m.default,
|
|
244
|
-
options: resolveOptions(m, data)
|
|
245
|
-
}))
|
|
229
|
+
? buildFieldDefinitions<T>(meta, data, 'excludedFromCreate', excluded)
|
|
246
230
|
: entityData.length > 0
|
|
247
231
|
? (Object.entries(entityData[0]) as [string, unknown][])
|
|
248
232
|
.filter(([k]) => !excluded.has(k))
|
|
@@ -253,18 +237,7 @@
|
|
|
253
237
|
const resolvedReadFields: FieldDefinition<T>[] = $derived(
|
|
254
238
|
fields ??
|
|
255
239
|
(meta
|
|
256
|
-
? (
|
|
257
|
-
.filter(([k, m]) => !excluded.has(k) && !m.excludedFromRead)
|
|
258
|
-
.map(([k, m]) => ({
|
|
259
|
-
attribute: k as keyof T & string,
|
|
260
|
-
title: m.label,
|
|
261
|
-
type: m.type ?? inferType(k, undefined),
|
|
262
|
-
required: m.required,
|
|
263
|
-
autocomplete: m.autocomplete,
|
|
264
|
-
placeholder: m.placeholder,
|
|
265
|
-
default: m.default,
|
|
266
|
-
options: resolveOptions(m, data)
|
|
267
|
-
}))
|
|
240
|
+
? buildFieldDefinitions<T>(meta, data, 'excludedFromRead', excluded)
|
|
268
241
|
: entityData.length > 0
|
|
269
242
|
? (Object.entries(entityData[0]) as [string, unknown][])
|
|
270
243
|
.filter(([k]) => !excluded.has(k))
|
|
@@ -275,18 +248,7 @@
|
|
|
275
248
|
const resolvedUpdateFields: FieldDefinition<T>[] = $derived(
|
|
276
249
|
fields ??
|
|
277
250
|
(meta
|
|
278
|
-
? (
|
|
279
|
-
.filter(([k, m]) => !excluded.has(k) && !m.excludedFromUpdate)
|
|
280
|
-
.map(([k, m]) => ({
|
|
281
|
-
attribute: k as keyof T & string,
|
|
282
|
-
title: m.label,
|
|
283
|
-
type: m.type ?? inferType(k, undefined),
|
|
284
|
-
required: m.required,
|
|
285
|
-
autocomplete: m.autocomplete,
|
|
286
|
-
placeholder: m.placeholder,
|
|
287
|
-
default: m.default,
|
|
288
|
-
options: resolveOptions(m, data)
|
|
289
|
-
}))
|
|
251
|
+
? buildFieldDefinitions<T>(meta, data, 'excludedFromUpdate', excluded)
|
|
290
252
|
: entityData.length > 0
|
|
291
253
|
? (Object.entries(entityData[0]) as [string, unknown][])
|
|
292
254
|
.filter(([k]) => !excluded.has(k))
|
|
@@ -24,13 +24,8 @@ declare function $$render<T extends object = Record<string, unknown>>(): {
|
|
|
24
24
|
form?: {
|
|
25
25
|
error?: string;
|
|
26
26
|
} | null;
|
|
27
|
-
/** Shows an icon-only export button (CSV, and Excel if `xlsx` is provided). */
|
|
28
27
|
enableExport?: boolean;
|
|
29
|
-
/** Server-pagination mode only: fetch all rows matching the current query
|
|
30
|
-
* (unpaginated) for export. Without it, export falls back to the loaded page. */
|
|
31
28
|
onExport?: (query: TableQuery) => Promise<T[]>;
|
|
32
|
-
/** Resolved `xlsx` (SheetJS) module, e.g. `import * as xlsx from 'xlsx'`.
|
|
33
|
-
* Enables the "Export as Excel" option; omit to only offer CSV. */
|
|
34
29
|
xlsx?: XlsxModule;
|
|
35
30
|
};
|
|
36
31
|
exports: {};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { FieldDefinition } from '../../../types/crud.js';
|
|
2
|
+
export type FieldGroup<T extends object = Record<string, unknown>> = {
|
|
3
|
+
title?: string;
|
|
4
|
+
fields: FieldDefinition<T>[];
|
|
5
|
+
};
|
|
6
|
+
export declare function groupFields<T extends object = Record<string, unknown>>(fields: FieldDefinition<T>[]): FieldGroup<T>[];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Partitions a flat field list into visual groups for create/update/read
|
|
2
|
+
// views, based on `groupedAs`. Fields without a `groupedAs` render as their
|
|
3
|
+
// own single-field group (unchanged from today's flat layout). Fields sharing
|
|
4
|
+
// a `groupedAs` are collected together at the position of their first
|
|
5
|
+
// occurrence, preserving overall order.
|
|
6
|
+
export function groupFields(fields) {
|
|
7
|
+
const groups = [];
|
|
8
|
+
const byTitle = new Map();
|
|
9
|
+
for (const field of fields) {
|
|
10
|
+
if (!field.groupedAs) {
|
|
11
|
+
groups.push({ fields: [field] });
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
let group = byTitle.get(field.groupedAs);
|
|
15
|
+
if (!group) {
|
|
16
|
+
group = { title: field.groupedAs, fields: [] };
|
|
17
|
+
byTitle.set(field.groupedAs, group);
|
|
18
|
+
groups.push(group);
|
|
19
|
+
}
|
|
20
|
+
group.fields.push(field);
|
|
21
|
+
}
|
|
22
|
+
return groups;
|
|
23
|
+
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { AttributeMetadata, AttributeType, SelectOption } from '../../../types/attribute.js';
|
|
2
|
+
import type { FieldDefinition } from '../../../types/crud.js';
|
|
2
3
|
export declare function resolveOptions(m: AttributeMetadata, d: unknown): SelectOption[] | undefined;
|
|
3
4
|
export declare function resolveFormatter(m: AttributeMetadata, d: unknown): import("../../../index.ts").CellFormatter<any, any> | undefined;
|
|
4
5
|
export declare function inferType(key: string, value: unknown): AttributeType;
|
|
6
|
+
export declare function buildFieldDefinitions<T extends object = Record<string, unknown>>(meta: Partial<Record<string, AttributeMetadata>>, data: unknown, excludedFlag: 'excludedFromCreate' | 'excludedFromRead' | 'excludedFromUpdate', excluded: Set<string>): FieldDefinition<T>[];
|
|
@@ -20,3 +20,33 @@ export function inferType(key, value) {
|
|
|
20
20
|
return 'textarea';
|
|
21
21
|
return 'text';
|
|
22
22
|
}
|
|
23
|
+
// Shared by GenericCRUD's create/read/update field resolution: they only
|
|
24
|
+
// differ in which `excludedFromX` flag gates a field, so any metadata
|
|
25
|
+
// property added here is automatically threaded through all three views
|
|
26
|
+
// instead of needing to be copied into three near-identical blocks.
|
|
27
|
+
export function buildFieldDefinitions(meta, data, excludedFlag, excluded) {
|
|
28
|
+
return Object.entries(meta)
|
|
29
|
+
.filter(([k, m]) => !excluded.has(k) && !m[excludedFlag])
|
|
30
|
+
.map(([k, m]) => ({
|
|
31
|
+
attribute: k,
|
|
32
|
+
title: m.label,
|
|
33
|
+
type: m.type ?? inferType(k, undefined),
|
|
34
|
+
required: m.required,
|
|
35
|
+
autocomplete: m.autocomplete,
|
|
36
|
+
placeholder: m.placeholder,
|
|
37
|
+
default: m.default,
|
|
38
|
+
options: resolveOptions(m, data),
|
|
39
|
+
dependentOptions: m.dependentOptions
|
|
40
|
+
? (record) => m.dependentOptions(data, record)
|
|
41
|
+
: undefined,
|
|
42
|
+
disabled: m.disabled,
|
|
43
|
+
seed: m.seed,
|
|
44
|
+
groupedAs: m.groupedAs,
|
|
45
|
+
min: m.min,
|
|
46
|
+
max: m.max,
|
|
47
|
+
integer: m.integer,
|
|
48
|
+
minLength: m.minLength,
|
|
49
|
+
maxLength: m.maxLength,
|
|
50
|
+
pattern: m.pattern
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
import type { FieldDefinition } from '../../../types/crud.js';
|
|
2
|
+
import type { RuneforgeStrings } from '../../../i18n/types.js';
|
|
3
|
+
export declare function validateAll<T extends object = Record<string, unknown>>(fields: FieldDefinition<T>[], formData: FormData, strings: RuneforgeStrings): Record<string, string>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { fieldLabel } from './misc.js';
|
|
2
|
+
// Shared by Create.svelte/Update.svelte's client-side pre-submit check. Errors
|
|
3
|
+
// are surfaced through the same `fieldErrors` mechanism already used for
|
|
4
|
+
// `required`, not native HTML5 constraint validation, so every message is
|
|
5
|
+
// styled consistently regardless of which rule failed.
|
|
6
|
+
export function validateAll(fields, formData, strings) {
|
|
7
|
+
const errors = {};
|
|
8
|
+
for (const field of fields) {
|
|
9
|
+
const val = String(formData.get(field.attribute) ?? '').trim();
|
|
10
|
+
if (field.required && !val) {
|
|
11
|
+
errors[field.attribute] = strings.required(fieldLabel(field));
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (!val)
|
|
15
|
+
continue;
|
|
16
|
+
if (field.type === 'number') {
|
|
17
|
+
const num = Number(val);
|
|
18
|
+
if (Number.isNaN(num)) {
|
|
19
|
+
errors[field.attribute] = strings.invalidNumber(fieldLabel(field));
|
|
20
|
+
}
|
|
21
|
+
else if (field.integer && !Number.isInteger(num)) {
|
|
22
|
+
errors[field.attribute] = strings.integer(fieldLabel(field));
|
|
23
|
+
}
|
|
24
|
+
else if (field.min != null && num < field.min) {
|
|
25
|
+
errors[field.attribute] = strings.min(fieldLabel(field), field.min);
|
|
26
|
+
}
|
|
27
|
+
else if (field.max != null && num > field.max) {
|
|
28
|
+
errors[field.attribute] = strings.max(fieldLabel(field), field.max);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
if (field.minLength != null && val.length < field.minLength) {
|
|
33
|
+
errors[field.attribute] = strings.minLength(fieldLabel(field), field.minLength);
|
|
34
|
+
}
|
|
35
|
+
else if (field.maxLength != null && val.length > field.maxLength) {
|
|
36
|
+
errors[field.attribute] = strings.maxLength(fieldLabel(field), field.maxLength);
|
|
37
|
+
}
|
|
38
|
+
else if (field.pattern && !new RegExp(field.pattern).test(val)) {
|
|
39
|
+
errors[field.attribute] = strings.pattern(fieldLabel(field));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return errors;
|
|
44
|
+
}
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import Header from '../../common/Header.svelte';
|
|
7
7
|
import { getIconSet } from '../../../icons/context.js';
|
|
8
8
|
import { defaultIconSet } from '../../../icons/sets/default.js';
|
|
9
|
-
import {
|
|
9
|
+
import { validateAll } from '../utils/validation.js';
|
|
10
|
+
import { groupFields } from '../utils/grouping.js';
|
|
10
11
|
import type { ActionConfiguration, FieldDefinition } from '../../../types/crud.js';
|
|
11
12
|
import { getStrings } from '../../../i18n/context.js';
|
|
12
13
|
|
|
@@ -53,17 +54,7 @@
|
|
|
53
54
|
|
|
54
55
|
let record = $state<Record<string, unknown>>(untrack(() => emptyRecord()));
|
|
55
56
|
|
|
56
|
-
|
|
57
|
-
const errs: Record<string, string> = {};
|
|
58
|
-
for (const field of fields) {
|
|
59
|
-
if (field.required) {
|
|
60
|
-
const val = String(formData.get(field.attribute) ?? '').trim();
|
|
61
|
-
if (!val) errs[field.attribute] = strings.required(fieldLabel(field));
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
return errs;
|
|
65
|
-
}
|
|
66
|
-
|
|
57
|
+
const groups = $derived(groupFields(fields));
|
|
67
58
|
const hasFileField = $derived(fields.some((f) => f.type === 'file'));
|
|
68
59
|
|
|
69
60
|
const errorEntries = $derived([
|
|
@@ -103,7 +94,7 @@
|
|
|
103
94
|
use:enhance={({ formData, cancel }) => {
|
|
104
95
|
fieldErrors = {};
|
|
105
96
|
internalError = '';
|
|
106
|
-
const errs = validateAll(formData);
|
|
97
|
+
const errs = validateAll(fields, formData, strings);
|
|
107
98
|
if (Object.keys(errs).length > 0) {
|
|
108
99
|
fieldErrors = errs;
|
|
109
100
|
cancel();
|
|
@@ -127,8 +118,21 @@
|
|
|
127
118
|
};
|
|
128
119
|
}}
|
|
129
120
|
>
|
|
130
|
-
{#each
|
|
131
|
-
|
|
121
|
+
{#each groups as group, i (group.title ?? `_ungrouped_${i}`)}
|
|
122
|
+
{#if group.title}
|
|
123
|
+
<fieldset class="fieldset border border-base-300 rounded-box p-4">
|
|
124
|
+
<legend class="fieldset-legend px-2">{group.title}</legend>
|
|
125
|
+
<div class="flex flex-col gap-4">
|
|
126
|
+
{#each group.fields as field (field.attribute)}
|
|
127
|
+
<Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} />
|
|
128
|
+
{/each}
|
|
129
|
+
</div>
|
|
130
|
+
</fieldset>
|
|
131
|
+
{:else}
|
|
132
|
+
{#each group.fields as field (field.attribute)}
|
|
133
|
+
<Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} />
|
|
134
|
+
{/each}
|
|
135
|
+
{/if}
|
|
132
136
|
{/each}
|
|
133
137
|
|
|
134
138
|
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import Header from '../../common/Header.svelte';
|
|
7
7
|
import { getIconSet } from '../../../icons/context.js';
|
|
8
8
|
import { defaultIconSet } from '../../../icons/sets/default.js';
|
|
9
|
+
import { groupFields } from '../utils/grouping.js';
|
|
9
10
|
import type { ActionConfiguration, FieldDefinition } from '../../../types/crud.js';
|
|
10
11
|
import { getStrings } from '../../../i18n/context.js';
|
|
11
12
|
|
|
@@ -60,6 +61,8 @@
|
|
|
60
61
|
})();
|
|
61
62
|
return () => { cancelled = true; };
|
|
62
63
|
});
|
|
64
|
+
|
|
65
|
+
const groups = $derived(groupFields(fields));
|
|
63
66
|
</script>
|
|
64
67
|
|
|
65
68
|
<div class="flex flex-col gap-6">
|
|
@@ -73,8 +76,21 @@
|
|
|
73
76
|
/>
|
|
74
77
|
|
|
75
78
|
<div class="fields-panel mx-auto flex w-full flex-col gap-4 px-4">
|
|
76
|
-
{#each
|
|
77
|
-
|
|
79
|
+
{#each groups as group, i (group.title ?? `_ungrouped_${i}`)}
|
|
80
|
+
{#if group.title}
|
|
81
|
+
<fieldset class="fieldset border border-base-300 rounded-box p-4">
|
|
82
|
+
<legend class="fieldset-legend px-2">{group.title}</legend>
|
|
83
|
+
<div class="flex flex-col gap-4">
|
|
84
|
+
{#each group.fields as field (field.attribute)}
|
|
85
|
+
<Field {field} {record} readonly />
|
|
86
|
+
{/each}
|
|
87
|
+
</div>
|
|
88
|
+
</fieldset>
|
|
89
|
+
{:else}
|
|
90
|
+
{#each group.fields as field (field.attribute)}
|
|
91
|
+
<Field {field} {record} readonly />
|
|
92
|
+
{/each}
|
|
93
|
+
{/if}
|
|
78
94
|
{/each}
|
|
79
95
|
|
|
80
96
|
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import Header from '../../common/Header.svelte';
|
|
7
7
|
import { getIconSet } from '../../../icons/context.js';
|
|
8
8
|
import { defaultIconSet } from '../../../icons/sets/default.js';
|
|
9
|
-
import {
|
|
9
|
+
import { validateAll } from '../utils/validation.js';
|
|
10
|
+
import { groupFields } from '../utils/grouping.js';
|
|
10
11
|
import type { ActionConfiguration, FieldDefinition } from '../../../types/crud.js';
|
|
11
12
|
import { getStrings } from '../../../i18n/context.js';
|
|
12
13
|
|
|
@@ -47,7 +48,8 @@
|
|
|
47
48
|
const seeded: Record<string, unknown> = { ...inst };
|
|
48
49
|
for (const f of fields) {
|
|
49
50
|
if (f.type !== 'boolean' && f.type !== 'file') {
|
|
50
|
-
|
|
51
|
+
const raw = f.seed ? f.seed(inst) : inst[f.attribute];
|
|
52
|
+
seeded[f.attribute] = String(raw ?? '');
|
|
51
53
|
}
|
|
52
54
|
}
|
|
53
55
|
return seeded;
|
|
@@ -63,17 +65,7 @@
|
|
|
63
65
|
untrack(() => { record = seedFromInstance(instance as Record<string, unknown>); });
|
|
64
66
|
});
|
|
65
67
|
|
|
66
|
-
|
|
67
|
-
const errs: Record<string, string> = {};
|
|
68
|
-
for (const field of fields) {
|
|
69
|
-
if (field.required) {
|
|
70
|
-
const val = String(formData.get(field.attribute) ?? '').trim();
|
|
71
|
-
if (!val) errs[field.attribute] = strings.required(fieldLabel(field));
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return errs;
|
|
75
|
-
}
|
|
76
|
-
|
|
68
|
+
const groups = $derived(groupFields(fields));
|
|
77
69
|
const hasFileField = $derived(fields.some((f) => f.type === 'file'));
|
|
78
70
|
|
|
79
71
|
const errorEntries = $derived([
|
|
@@ -113,7 +105,7 @@
|
|
|
113
105
|
use:enhance={({ formData, cancel }) => {
|
|
114
106
|
fieldErrors = {};
|
|
115
107
|
internalError = '';
|
|
116
|
-
const errs = validateAll(formData);
|
|
108
|
+
const errs = validateAll(fields, formData, strings);
|
|
117
109
|
if (Object.keys(errs).length > 0) {
|
|
118
110
|
fieldErrors = errs;
|
|
119
111
|
cancel();
|
|
@@ -133,8 +125,21 @@
|
|
|
133
125
|
>
|
|
134
126
|
<input type="hidden" name="id" value={String(record[idKey] ?? '')} />
|
|
135
127
|
|
|
136
|
-
{#each
|
|
137
|
-
|
|
128
|
+
{#each groups as group, i (group.title ?? `_ungrouped_${i}`)}
|
|
129
|
+
{#if group.title}
|
|
130
|
+
<fieldset class="fieldset border border-base-300 rounded-box p-4">
|
|
131
|
+
<legend class="fieldset-legend px-2">{group.title}</legend>
|
|
132
|
+
<div class="flex flex-col gap-4">
|
|
133
|
+
{#each group.fields as field (field.attribute)}
|
|
134
|
+
<Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} />
|
|
135
|
+
{/each}
|
|
136
|
+
</div>
|
|
137
|
+
</fieldset>
|
|
138
|
+
{:else}
|
|
139
|
+
{#each group.fields as field (field.attribute)}
|
|
140
|
+
<Field {field} bind:record error={fieldErrors[field.attribute] ?? ''} />
|
|
141
|
+
{/each}
|
|
142
|
+
{/if}
|
|
138
143
|
{/each}
|
|
139
144
|
|
|
140
145
|
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
package/dist/i18n/en.js
CHANGED
|
@@ -26,5 +26,12 @@ export const en = {
|
|
|
26
26
|
confirm: 'Confirm',
|
|
27
27
|
deleteConfirm: (count, actionLabel) => `Are you sure you want to ${String(actionLabel).toLowerCase()} ${count} item${count === 1 ? '' : 's'}?`,
|
|
28
28
|
required: (field) => `${field} is required`,
|
|
29
|
+
invalidNumber: (field) => `${field} must be a number`,
|
|
30
|
+
integer: (field) => `${field} must be a whole number`,
|
|
31
|
+
min: (field, min) => `${field} must be greater than or equal to ${min}`,
|
|
32
|
+
max: (field, max) => `${field} must be less than or equal to ${max}`,
|
|
33
|
+
minLength: (field, min) => `${field} must be at least ${min} characters`,
|
|
34
|
+
maxLength: (field, max) => `${field} must be at most ${max} characters`,
|
|
35
|
+
pattern: (field) => `${field} has an invalid format`,
|
|
29
36
|
serverError: 'Unexpected server error.'
|
|
30
37
|
};
|
package/dist/i18n/es.js
CHANGED
|
@@ -26,5 +26,12 @@ export const es = {
|
|
|
26
26
|
confirm: 'Confirmar',
|
|
27
27
|
deleteConfirm: (count, actionLabel) => `¿Seguro que querés ${String(actionLabel).toLowerCase()} ${count} elemento${count === 1 ? '' : 's'}?`,
|
|
28
28
|
required: (field) => `${field} es requerido`,
|
|
29
|
+
invalidNumber: (field) => `${field} debe ser un número`,
|
|
30
|
+
integer: (field) => `${field} debe ser un número entero`,
|
|
31
|
+
min: (field, min) => `${field} debe ser mayor o igual a ${min}`,
|
|
32
|
+
max: (field, max) => `${field} debe ser menor o igual a ${max}`,
|
|
33
|
+
minLength: (field, min) => `${field} debe tener al menos ${min} caracteres`,
|
|
34
|
+
maxLength: (field, max) => `${field} debe tener como máximo ${max} caracteres`,
|
|
35
|
+
pattern: (field) => `${field} tiene un formato inválido`,
|
|
29
36
|
serverError: 'Error inesperado del servidor.'
|
|
30
37
|
};
|
package/dist/i18n/types.d.ts
CHANGED
|
@@ -26,5 +26,12 @@ export interface RuneforgeStrings {
|
|
|
26
26
|
confirm: string;
|
|
27
27
|
deleteConfirm: (count: number, actionLabel: string) => string;
|
|
28
28
|
required: (field: string) => string;
|
|
29
|
+
invalidNumber: (field: string) => string;
|
|
30
|
+
integer: (field: string) => string;
|
|
31
|
+
min: (field: string, min: number) => string;
|
|
32
|
+
max: (field: string, max: number) => string;
|
|
33
|
+
minLength: (field: string, min: number) => string;
|
|
34
|
+
maxLength: (field: string, max: number) => string;
|
|
35
|
+
pattern: (field: string) => string;
|
|
29
36
|
serverError: string;
|
|
30
37
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -41,6 +41,9 @@ export { default as CRUDRead } from './components/crud/views/Read.svelte';
|
|
|
41
41
|
export { default as CRUDUpdate } from './components/crud/views/Update.svelte';
|
|
42
42
|
export { default as SearchInput } from './components/crud/SearchInput.svelte';
|
|
43
43
|
export { AUTO_EXCLUDED } from './components/crud/utils/constants.js';
|
|
44
|
-
export { resolveOptions, resolveFormatter, inferType } from './components/crud/utils/resolution.js';
|
|
44
|
+
export { resolveOptions, resolveFormatter, inferType, buildFieldDefinitions } from './components/crud/utils/resolution.js';
|
|
45
45
|
export { fieldLabel, initials } from './components/crud/utils/misc.js';
|
|
46
46
|
export { formatBoolean, formatDatetime, formatTruncateTextUpTo, formatInstance } from './components/crud/utils/formatters.js';
|
|
47
|
+
export { groupFields } from './components/crud/utils/grouping.js';
|
|
48
|
+
export type { FieldGroup } from './components/crud/utils/grouping.js';
|
|
49
|
+
export { validateAll } from './components/crud/utils/validation.js';
|
package/dist/index.js
CHANGED
|
@@ -39,6 +39,8 @@ export { default as CRUDUpdate } from './components/crud/views/Update.svelte';
|
|
|
39
39
|
export { default as SearchInput } from './components/crud/SearchInput.svelte';
|
|
40
40
|
// CRUD utilities
|
|
41
41
|
export { AUTO_EXCLUDED } from './components/crud/utils/constants.js';
|
|
42
|
-
export { resolveOptions, resolveFormatter, inferType } from './components/crud/utils/resolution.js';
|
|
42
|
+
export { resolveOptions, resolveFormatter, inferType, buildFieldDefinitions } from './components/crud/utils/resolution.js';
|
|
43
43
|
export { fieldLabel, initials } from './components/crud/utils/misc.js';
|
|
44
44
|
export { formatBoolean, formatDatetime, formatTruncateTextUpTo, formatInstance } from './components/crud/utils/formatters.js';
|
|
45
|
+
export { groupFields } from './components/crud/utils/grouping.js';
|
|
46
|
+
export { validateAll } from './components/crud/utils/validation.js';
|
|
@@ -19,10 +19,16 @@ export type SelectOption = {
|
|
|
19
19
|
};
|
|
20
20
|
export type OptionsResolver = SelectOption[] | ((data: any) => SelectOption[]);
|
|
21
21
|
export type FormatterResolver = (data?: any) => CellFormatter<any, any>;
|
|
22
|
+
export type DependentOptionsResolver = (data: any, record: Record<string, unknown>) => SelectOption[];
|
|
23
|
+
export type DisabledResolver = (record: Record<string, unknown>) => boolean;
|
|
24
|
+
export type SeedResolver = (instance: any) => unknown;
|
|
22
25
|
export type AttributeMetadata = {
|
|
23
26
|
label?: string;
|
|
24
27
|
type?: AttributeType;
|
|
25
28
|
options?: OptionsResolver;
|
|
29
|
+
dependentOptions?: DependentOptionsResolver;
|
|
30
|
+
disabled?: DisabledResolver;
|
|
31
|
+
seed?: SeedResolver;
|
|
26
32
|
component?: CellComponent<any, any>;
|
|
27
33
|
formatter?: FormatterResolver;
|
|
28
34
|
required?: boolean;
|
|
@@ -35,4 +41,11 @@ export type AttributeMetadata = {
|
|
|
35
41
|
excludedFromRead?: boolean;
|
|
36
42
|
sortable?: boolean;
|
|
37
43
|
filterable?: boolean;
|
|
44
|
+
groupedAs?: string;
|
|
45
|
+
min?: number;
|
|
46
|
+
max?: number;
|
|
47
|
+
integer?: boolean;
|
|
48
|
+
minLength?: number;
|
|
49
|
+
maxLength?: number;
|
|
50
|
+
pattern?: string;
|
|
38
51
|
};
|
package/dist/types/crud.d.ts
CHANGED
|
@@ -25,6 +25,19 @@ export interface FieldDefinition<T extends object = Record<string, unknown>> {
|
|
|
25
25
|
value: string;
|
|
26
26
|
label: string;
|
|
27
27
|
}[];
|
|
28
|
+
dependentOptions?: (record: Record<string, unknown>) => {
|
|
29
|
+
value: string;
|
|
30
|
+
label: string;
|
|
31
|
+
}[];
|
|
32
|
+
disabled?: (record: Record<string, unknown>) => boolean;
|
|
33
|
+
seed?: (instance: any) => unknown;
|
|
34
|
+
groupedAs?: string;
|
|
35
|
+
min?: number;
|
|
36
|
+
max?: number;
|
|
37
|
+
integer?: boolean;
|
|
38
|
+
minLength?: number;
|
|
39
|
+
maxLength?: number;
|
|
40
|
+
pattern?: string;
|
|
28
41
|
}
|
|
29
42
|
export interface ActionConfiguration<T extends object = Record<string, unknown>> {
|
|
30
43
|
enabled?: boolean;
|