runeforge 0.0.20 → 0.0.22
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 +12 -6
- package/dist/components/crud/GenericCRUD.svelte +15 -56
- 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 +31 -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 +19 -15
- package/dist/components/form/Select.svelte +63 -14
- package/dist/components/form/Select.svelte.d.ts +3 -0
- package/dist/i18n/en.js +8 -0
- package/dist/i18n/es.js +8 -0
- package/dist/i18n/types.d.ts +8 -0
- package/dist/index.d.ts +4 -1
- package/dist/index.js +3 -1
- package/dist/types/attribute.d.ts +12 -0
- package/dist/types/crud.d.ts +15 -2
- package/package.json +1 -1
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
const avatarInitials = $derived(initials(record.firstName as string, record.lastName as string));
|
|
45
45
|
const displayValue = $derived(saved == null ? '' : String(saved));
|
|
46
46
|
const selectOptions = $derived(field.dependentOptions ? field.dependentOptions(record) : (field.options ?? []));
|
|
47
|
-
const
|
|
47
|
+
const fieldDisabled = $derived(field.disabled ? field.disabled(record) : false);
|
|
48
48
|
|
|
49
49
|
$effect(() => {
|
|
50
50
|
if (!field.dependentOptions) return;
|
|
@@ -76,7 +76,8 @@
|
|
|
76
76
|
{name}
|
|
77
77
|
class="toggle toggle-primary"
|
|
78
78
|
checked={!!saved}
|
|
79
|
-
disabled={readonly}
|
|
79
|
+
disabled={readonly || fieldDisabled}
|
|
80
|
+
onchange={(e) => { record[field.attribute] = (e.currentTarget as HTMLInputElement).checked; }}
|
|
80
81
|
/>
|
|
81
82
|
{:else if field.type === 'file'}
|
|
82
83
|
{#if !readonly}
|
|
@@ -86,6 +87,7 @@
|
|
|
86
87
|
{name}
|
|
87
88
|
class="file-input file-input-bordered w-full"
|
|
88
89
|
class:file-input-error={!!error}
|
|
90
|
+
disabled={fieldDisabled}
|
|
89
91
|
onchange={onFileChange}
|
|
90
92
|
/>
|
|
91
93
|
{/if}
|
|
@@ -104,8 +106,9 @@
|
|
|
104
106
|
name={field.attribute}
|
|
105
107
|
bind:value={record[field.attribute] as string}
|
|
106
108
|
options={selectOptions}
|
|
109
|
+
search={field.search}
|
|
107
110
|
placeholder={field.placeholder}
|
|
108
|
-
disabled={
|
|
111
|
+
disabled={fieldDisabled}
|
|
109
112
|
{error}
|
|
110
113
|
/>
|
|
111
114
|
{/if}
|
|
@@ -119,11 +122,11 @@
|
|
|
119
122
|
disabled
|
|
120
123
|
/>
|
|
121
124
|
{:else}
|
|
122
|
-
<input type="hidden" {name} value={String(record[field.attribute] ?? '')} />
|
|
125
|
+
<input type="hidden" {name} value={String(record[field.attribute] ?? '')} disabled={fieldDisabled} />
|
|
123
126
|
<calendar-date
|
|
124
|
-
class="cally rounded-box border border-base-300 bg-base-100 shadow-sm"
|
|
127
|
+
class="cally rounded-box border border-base-300 bg-base-100 shadow-sm {fieldDisabled ? 'pointer-events-none opacity-50' : ''}"
|
|
125
128
|
value={String(record[field.attribute] ?? '')}
|
|
126
|
-
onchange={(e: Event) => { record[field.attribute] = (e.currentTarget as HTMLElement & { value: string }).value; }}
|
|
129
|
+
onchange={(e: Event) => { if (fieldDisabled) return; record[field.attribute] = (e.currentTarget as HTMLElement & { value: string }).value; }}
|
|
127
130
|
>
|
|
128
131
|
<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>
|
|
129
132
|
<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>
|
|
@@ -141,6 +144,7 @@
|
|
|
141
144
|
bind:value={record[field.attribute]}
|
|
142
145
|
class="textarea textarea-bordered bg-base-100 w-full"
|
|
143
146
|
class:textarea-error={!!error}
|
|
147
|
+
disabled={fieldDisabled}
|
|
144
148
|
></textarea>
|
|
145
149
|
{/if}
|
|
146
150
|
{:else if readonly}
|
|
@@ -159,8 +163,10 @@
|
|
|
159
163
|
placeholder={field.placeholder ?? ''}
|
|
160
164
|
bind:value={record[field.attribute]}
|
|
161
165
|
autocomplete={field.autocomplete}
|
|
166
|
+
step={field.type === 'number' ? 'any' : undefined}
|
|
162
167
|
class="input input-bordered w-full"
|
|
163
168
|
class:input-error={!!error}
|
|
169
|
+
disabled={fieldDisabled}
|
|
164
170
|
/>
|
|
165
171
|
{/if}
|
|
166
172
|
|
|
@@ -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';
|
|
@@ -121,6 +121,15 @@
|
|
|
121
121
|
|
|
122
122
|
let activeAction = $state<{ action: CustomAction<T>; item: T } | null>(null);
|
|
123
123
|
|
|
124
|
+
async function runAction(action: CustomAction<T>, item: T) {
|
|
125
|
+
if (!(action.condition?.(item) ?? true)) return;
|
|
126
|
+
if (action.href) {
|
|
127
|
+
await goto(action.href(item));
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
activeAction = { action, item };
|
|
131
|
+
}
|
|
132
|
+
|
|
124
133
|
async function navList() {
|
|
125
134
|
await goto('?');
|
|
126
135
|
}
|
|
@@ -226,23 +235,7 @@
|
|
|
226
235
|
const resolvedFields: FieldDefinition<T>[] = $derived(
|
|
227
236
|
fields ??
|
|
228
237
|
(meta
|
|
229
|
-
? (
|
|
230
|
-
.filter(([k, m]) => !excluded.has(k) && !m.excludedFromCreate)
|
|
231
|
-
.map(([k, m]) => ({
|
|
232
|
-
attribute: k as keyof T & string,
|
|
233
|
-
title: m.label,
|
|
234
|
-
type: m.type ?? inferType(k, undefined),
|
|
235
|
-
required: m.required,
|
|
236
|
-
autocomplete: m.autocomplete,
|
|
237
|
-
placeholder: m.placeholder,
|
|
238
|
-
default: m.default,
|
|
239
|
-
options: resolveOptions(m, data),
|
|
240
|
-
dependentOptions: m.dependentOptions
|
|
241
|
-
? (record: Record<string, unknown>) => m.dependentOptions!(data, record)
|
|
242
|
-
: undefined,
|
|
243
|
-
disabled: m.disabled,
|
|
244
|
-
seed: m.seed
|
|
245
|
-
}))
|
|
238
|
+
? buildFieldDefinitions<T>(meta, data, 'excludedFromCreate', excluded)
|
|
246
239
|
: entityData.length > 0
|
|
247
240
|
? (Object.entries(entityData[0]) as [string, unknown][])
|
|
248
241
|
.filter(([k]) => !excluded.has(k))
|
|
@@ -253,23 +246,7 @@
|
|
|
253
246
|
const resolvedReadFields: FieldDefinition<T>[] = $derived(
|
|
254
247
|
fields ??
|
|
255
248
|
(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
|
-
dependentOptions: m.dependentOptions
|
|
268
|
-
? (record: Record<string, unknown>) => m.dependentOptions!(data, record)
|
|
269
|
-
: undefined,
|
|
270
|
-
disabled: m.disabled,
|
|
271
|
-
seed: m.seed
|
|
272
|
-
}))
|
|
249
|
+
? buildFieldDefinitions<T>(meta, data, 'excludedFromRead', excluded)
|
|
273
250
|
: entityData.length > 0
|
|
274
251
|
? (Object.entries(entityData[0]) as [string, unknown][])
|
|
275
252
|
.filter(([k]) => !excluded.has(k))
|
|
@@ -280,23 +257,7 @@
|
|
|
280
257
|
const resolvedUpdateFields: FieldDefinition<T>[] = $derived(
|
|
281
258
|
fields ??
|
|
282
259
|
(meta
|
|
283
|
-
? (
|
|
284
|
-
.filter(([k, m]) => !excluded.has(k) && !m.excludedFromUpdate)
|
|
285
|
-
.map(([k, m]) => ({
|
|
286
|
-
attribute: k as keyof T & string,
|
|
287
|
-
title: m.label,
|
|
288
|
-
type: m.type ?? inferType(k, undefined),
|
|
289
|
-
required: m.required,
|
|
290
|
-
autocomplete: m.autocomplete,
|
|
291
|
-
placeholder: m.placeholder,
|
|
292
|
-
default: m.default,
|
|
293
|
-
options: resolveOptions(m, data),
|
|
294
|
-
dependentOptions: m.dependentOptions
|
|
295
|
-
? (record: Record<string, unknown>) => m.dependentOptions!(data, record)
|
|
296
|
-
: undefined,
|
|
297
|
-
disabled: m.disabled,
|
|
298
|
-
seed: m.seed
|
|
299
|
-
}))
|
|
260
|
+
? buildFieldDefinitions<T>(meta, data, 'excludedFromUpdate', excluded)
|
|
300
261
|
: entityData.length > 0
|
|
301
262
|
? (Object.entries(entityData[0]) as [string, unknown][])
|
|
302
263
|
.filter(([k]) => !excluded.has(k))
|
|
@@ -370,9 +331,7 @@
|
|
|
370
331
|
onCreate={navCreate}
|
|
371
332
|
onEdit={navEdit}
|
|
372
333
|
onView={navRead}
|
|
373
|
-
onAction={
|
|
374
|
-
if (action.condition?.(item) ?? true) activeAction = { action, item };
|
|
375
|
-
}}
|
|
334
|
+
onAction={runAction}
|
|
376
335
|
/>
|
|
377
336
|
{/if}
|
|
378
337
|
|
|
@@ -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,34 @@ 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
|
+
search: m.search,
|
|
43
|
+
disabled: m.disabled,
|
|
44
|
+
seed: m.seed,
|
|
45
|
+
groupedAs: m.groupedAs,
|
|
46
|
+
min: m.min,
|
|
47
|
+
max: m.max,
|
|
48
|
+
integer: m.integer,
|
|
49
|
+
minLength: m.minLength,
|
|
50
|
+
maxLength: m.maxLength,
|
|
51
|
+
pattern: m.pattern
|
|
52
|
+
}));
|
|
53
|
+
}
|
|
@@ -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
|
|
|
@@ -64,17 +65,7 @@
|
|
|
64
65
|
untrack(() => { record = seedFromInstance(instance as Record<string, unknown>); });
|
|
65
66
|
});
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
const errs: Record<string, string> = {};
|
|
69
|
-
for (const field of fields) {
|
|
70
|
-
if (field.required) {
|
|
71
|
-
const val = String(formData.get(field.attribute) ?? '').trim();
|
|
72
|
-
if (!val) errs[field.attribute] = strings.required(fieldLabel(field));
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
return errs;
|
|
76
|
-
}
|
|
77
|
-
|
|
68
|
+
const groups = $derived(groupFields(fields));
|
|
78
69
|
const hasFileField = $derived(fields.some((f) => f.type === 'file'));
|
|
79
70
|
|
|
80
71
|
const errorEntries = $derived([
|
|
@@ -114,7 +105,7 @@
|
|
|
114
105
|
use:enhance={({ formData, cancel }) => {
|
|
115
106
|
fieldErrors = {};
|
|
116
107
|
internalError = '';
|
|
117
|
-
const errs = validateAll(formData);
|
|
108
|
+
const errs = validateAll(fields, formData, strings);
|
|
118
109
|
if (Object.keys(errs).length > 0) {
|
|
119
110
|
fieldErrors = errs;
|
|
120
111
|
cancel();
|
|
@@ -134,8 +125,21 @@
|
|
|
134
125
|
>
|
|
135
126
|
<input type="hidden" name="id" value={String(record[idKey] ?? '')} />
|
|
136
127
|
|
|
137
|
-
{#each
|
|
138
|
-
|
|
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}
|
|
139
143
|
{/each}
|
|
140
144
|
|
|
141
145
|
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { getStrings } from '../../i18n/context.js';
|
|
3
|
+
import type { SearchResolver } from '../../types/attribute.js';
|
|
3
4
|
|
|
4
5
|
const strings = getStrings();
|
|
5
6
|
|
|
@@ -7,6 +8,8 @@
|
|
|
7
8
|
name,
|
|
8
9
|
value = $bindable(''),
|
|
9
10
|
options = [],
|
|
11
|
+
search: searchFn,
|
|
12
|
+
searchDebounceMs = 300,
|
|
10
13
|
placeholder = strings.selectPlaceholder,
|
|
11
14
|
error = '',
|
|
12
15
|
disabled = false,
|
|
@@ -14,41 +17,84 @@
|
|
|
14
17
|
name?: string;
|
|
15
18
|
value?: string;
|
|
16
19
|
options?: { value: string; label: string }[];
|
|
20
|
+
search?: SearchResolver;
|
|
21
|
+
searchDebounceMs?: number;
|
|
17
22
|
placeholder?: string;
|
|
18
23
|
error?: string;
|
|
19
24
|
disabled?: boolean;
|
|
20
25
|
} = $props();
|
|
21
26
|
|
|
22
27
|
let open = $state(false);
|
|
23
|
-
let
|
|
28
|
+
let query = $state('');
|
|
24
29
|
let container: HTMLDivElement;
|
|
25
30
|
|
|
31
|
+
// Options resolved by `searchFn` for the current query; null while no
|
|
32
|
+
// server search has run yet (e.g. box just opened, query still empty).
|
|
33
|
+
let remoteResults = $state<{ value: string; label: string }[] | null>(null);
|
|
34
|
+
let searching = $state(false);
|
|
35
|
+
// Label of whatever was last picked from `remoteResults`, kept around so the
|
|
36
|
+
// closed-state button can still show it even though it isn't in `options`.
|
|
37
|
+
let pickedLabel = $state<string | null>(null);
|
|
38
|
+
|
|
39
|
+
let searchToken = 0;
|
|
40
|
+
$effect(() => {
|
|
41
|
+
if (!searchFn) return;
|
|
42
|
+
const q = query.trim();
|
|
43
|
+
if (!q) {
|
|
44
|
+
remoteResults = null;
|
|
45
|
+
searching = false;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const token = ++searchToken;
|
|
49
|
+
searching = true;
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
searchFn(q).then((results) => {
|
|
52
|
+
if (token !== searchToken) return; // stale response, a newer query took over
|
|
53
|
+
remoteResults = results;
|
|
54
|
+
searching = false;
|
|
55
|
+
});
|
|
56
|
+
}, searchDebounceMs);
|
|
57
|
+
return () => clearTimeout(timer);
|
|
58
|
+
});
|
|
59
|
+
|
|
26
60
|
const filtered = $derived(
|
|
27
|
-
|
|
28
|
-
?
|
|
29
|
-
:
|
|
61
|
+
searchFn
|
|
62
|
+
? (remoteResults ?? options)
|
|
63
|
+
: query.trim()
|
|
64
|
+
? options.filter((o) => o.label.toLowerCase().includes(query.toLowerCase()))
|
|
65
|
+
: options
|
|
30
66
|
);
|
|
31
67
|
|
|
32
68
|
const selectedLabel = $derived(
|
|
33
|
-
|
|
69
|
+
value === ''
|
|
70
|
+
? placeholder
|
|
71
|
+
: (options.find((o) => o.value === value)?.label ?? pickedLabel ?? placeholder)
|
|
34
72
|
);
|
|
35
73
|
|
|
36
74
|
function toggle() {
|
|
37
75
|
if (disabled) return;
|
|
38
76
|
open = !open;
|
|
39
|
-
if (!open)
|
|
77
|
+
if (!open) query = '';
|
|
40
78
|
}
|
|
41
79
|
|
|
42
|
-
function pick(
|
|
43
|
-
value =
|
|
80
|
+
function pick(option: { value: string; label: string }) {
|
|
81
|
+
value = option.value;
|
|
82
|
+
pickedLabel = option.label;
|
|
44
83
|
open = false;
|
|
45
|
-
|
|
84
|
+
query = '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function clear() {
|
|
88
|
+
value = '';
|
|
89
|
+
pickedLabel = null;
|
|
90
|
+
open = false;
|
|
91
|
+
query = '';
|
|
46
92
|
}
|
|
47
93
|
|
|
48
94
|
function onWindowClick(e: MouseEvent) {
|
|
49
95
|
if (open && !container.contains(e.target as Node)) {
|
|
50
96
|
open = false;
|
|
51
|
-
|
|
97
|
+
query = '';
|
|
52
98
|
}
|
|
53
99
|
}
|
|
54
100
|
</script>
|
|
@@ -78,7 +124,7 @@
|
|
|
78
124
|
type="text"
|
|
79
125
|
class="input input-bordered input-sm w-full"
|
|
80
126
|
placeholder={strings.selectSearch}
|
|
81
|
-
bind:value={
|
|
127
|
+
bind:value={query}
|
|
82
128
|
autocomplete="off"
|
|
83
129
|
/>
|
|
84
130
|
</div>
|
|
@@ -87,11 +133,14 @@
|
|
|
87
133
|
<button
|
|
88
134
|
type="button"
|
|
89
135
|
class="w-full rounded-btn px-3 py-2 text-left text-sm text-base-content/40 hover:bg-base-200"
|
|
90
|
-
onclick={
|
|
136
|
+
onclick={clear}
|
|
91
137
|
>
|
|
92
138
|
{placeholder}
|
|
93
139
|
</button>
|
|
94
140
|
</li>
|
|
141
|
+
{#if searching}
|
|
142
|
+
<li class="px-3 py-2 text-sm text-base-content/40">{strings.selectSearching}</li>
|
|
143
|
+
{/if}
|
|
95
144
|
{#each filtered as option (option.value)}
|
|
96
145
|
<li>
|
|
97
146
|
<button
|
|
@@ -99,13 +148,13 @@
|
|
|
99
148
|
class="w-full rounded-btn px-3 py-2 text-left text-sm hover:bg-base-200"
|
|
100
149
|
class:bg-primary={value === option.value}
|
|
101
150
|
class:text-primary-content={value === option.value}
|
|
102
|
-
onclick={() => pick(option
|
|
151
|
+
onclick={() => pick(option)}
|
|
103
152
|
>
|
|
104
153
|
{option.label}
|
|
105
154
|
</button>
|
|
106
155
|
</li>
|
|
107
156
|
{/each}
|
|
108
|
-
{#if filtered.length === 0}
|
|
157
|
+
{#if !searching && filtered.length === 0}
|
|
109
158
|
<li class="px-3 py-2 text-sm text-base-content/40">{strings.selectNoResults}</li>
|
|
110
159
|
{/if}
|
|
111
160
|
</ul>
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { SearchResolver } from '../../types/attribute.js';
|
|
1
2
|
type $$ComponentProps = {
|
|
2
3
|
name?: string;
|
|
3
4
|
value?: string;
|
|
@@ -5,6 +6,8 @@ type $$ComponentProps = {
|
|
|
5
6
|
value: string;
|
|
6
7
|
label: string;
|
|
7
8
|
}[];
|
|
9
|
+
search?: SearchResolver;
|
|
10
|
+
searchDebounceMs?: number;
|
|
8
11
|
placeholder?: string;
|
|
9
12
|
error?: string;
|
|
10
13
|
disabled?: boolean;
|
package/dist/i18n/en.js
CHANGED
|
@@ -10,6 +10,7 @@ export const en = {
|
|
|
10
10
|
next: 'Next',
|
|
11
11
|
selectPlaceholder: 'Select an option',
|
|
12
12
|
selectSearch: 'Search...',
|
|
13
|
+
selectSearching: 'Searching...',
|
|
13
14
|
selectNoResults: 'No results',
|
|
14
15
|
view: 'View',
|
|
15
16
|
edit: 'Edit',
|
|
@@ -26,5 +27,12 @@ export const en = {
|
|
|
26
27
|
confirm: 'Confirm',
|
|
27
28
|
deleteConfirm: (count, actionLabel) => `Are you sure you want to ${String(actionLabel).toLowerCase()} ${count} item${count === 1 ? '' : 's'}?`,
|
|
28
29
|
required: (field) => `${field} is required`,
|
|
30
|
+
invalidNumber: (field) => `${field} must be a number`,
|
|
31
|
+
integer: (field) => `${field} must be a whole number`,
|
|
32
|
+
min: (field, min) => `${field} must be greater than or equal to ${min}`,
|
|
33
|
+
max: (field, max) => `${field} must be less than or equal to ${max}`,
|
|
34
|
+
minLength: (field, min) => `${field} must be at least ${min} characters`,
|
|
35
|
+
maxLength: (field, max) => `${field} must be at most ${max} characters`,
|
|
36
|
+
pattern: (field) => `${field} has an invalid format`,
|
|
29
37
|
serverError: 'Unexpected server error.'
|
|
30
38
|
};
|
package/dist/i18n/es.js
CHANGED
|
@@ -10,6 +10,7 @@ export const es = {
|
|
|
10
10
|
next: 'Siguiente',
|
|
11
11
|
selectPlaceholder: 'Seleccioná una opción',
|
|
12
12
|
selectSearch: 'Buscar...',
|
|
13
|
+
selectSearching: 'Buscando...',
|
|
13
14
|
selectNoResults: 'Sin resultados',
|
|
14
15
|
view: 'Ver',
|
|
15
16
|
edit: 'Editar',
|
|
@@ -26,5 +27,12 @@ export const es = {
|
|
|
26
27
|
confirm: 'Confirmar',
|
|
27
28
|
deleteConfirm: (count, actionLabel) => `¿Seguro que querés ${String(actionLabel).toLowerCase()} ${count} elemento${count === 1 ? '' : 's'}?`,
|
|
28
29
|
required: (field) => `${field} es requerido`,
|
|
30
|
+
invalidNumber: (field) => `${field} debe ser un número`,
|
|
31
|
+
integer: (field) => `${field} debe ser un número entero`,
|
|
32
|
+
min: (field, min) => `${field} debe ser mayor o igual a ${min}`,
|
|
33
|
+
max: (field, max) => `${field} debe ser menor o igual a ${max}`,
|
|
34
|
+
minLength: (field, min) => `${field} debe tener al menos ${min} caracteres`,
|
|
35
|
+
maxLength: (field, max) => `${field} debe tener como máximo ${max} caracteres`,
|
|
36
|
+
pattern: (field) => `${field} tiene un formato inválido`,
|
|
29
37
|
serverError: 'Error inesperado del servidor.'
|
|
30
38
|
};
|
package/dist/i18n/types.d.ts
CHANGED
|
@@ -10,6 +10,7 @@ export interface RuneforgeStrings {
|
|
|
10
10
|
next: string;
|
|
11
11
|
selectPlaceholder: string;
|
|
12
12
|
selectSearch: string;
|
|
13
|
+
selectSearching: string;
|
|
13
14
|
selectNoResults: string;
|
|
14
15
|
view: string;
|
|
15
16
|
edit: string;
|
|
@@ -26,5 +27,12 @@ export interface RuneforgeStrings {
|
|
|
26
27
|
confirm: string;
|
|
27
28
|
deleteConfirm: (count: number, actionLabel: string) => string;
|
|
28
29
|
required: (field: string) => string;
|
|
30
|
+
invalidNumber: (field: string) => string;
|
|
31
|
+
integer: (field: string) => string;
|
|
32
|
+
min: (field: string, min: number) => string;
|
|
33
|
+
max: (field: string, max: number) => string;
|
|
34
|
+
minLength: (field: string, min: number) => string;
|
|
35
|
+
maxLength: (field: string, max: number) => string;
|
|
36
|
+
pattern: (field: string) => string;
|
|
29
37
|
serverError: string;
|
|
30
38
|
}
|
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';
|
|
@@ -20,6 +20,7 @@ export type SelectOption = {
|
|
|
20
20
|
export type OptionsResolver = SelectOption[] | ((data: any) => SelectOption[]);
|
|
21
21
|
export type FormatterResolver = (data?: any) => CellFormatter<any, any>;
|
|
22
22
|
export type DependentOptionsResolver = (data: any, record: Record<string, unknown>) => SelectOption[];
|
|
23
|
+
export type SearchResolver = (query: string) => Promise<SelectOption[]>;
|
|
23
24
|
export type DisabledResolver = (record: Record<string, unknown>) => boolean;
|
|
24
25
|
export type SeedResolver = (instance: any) => unknown;
|
|
25
26
|
export type AttributeMetadata = {
|
|
@@ -27,6 +28,10 @@ export type AttributeMetadata = {
|
|
|
27
28
|
type?: AttributeType;
|
|
28
29
|
options?: OptionsResolver;
|
|
29
30
|
dependentOptions?: DependentOptionsResolver;
|
|
31
|
+
/** Select fields only: fetch options matching what the user typed (e.g. a
|
|
32
|
+
* server-side search) instead of filtering the (possibly partial) `options`
|
|
33
|
+
* list in memory. Leave unset to keep the default in-memory filtering. */
|
|
34
|
+
search?: SearchResolver;
|
|
30
35
|
disabled?: DisabledResolver;
|
|
31
36
|
seed?: SeedResolver;
|
|
32
37
|
component?: CellComponent<any, any>;
|
|
@@ -41,4 +46,11 @@ export type AttributeMetadata = {
|
|
|
41
46
|
excludedFromRead?: boolean;
|
|
42
47
|
sortable?: boolean;
|
|
43
48
|
filterable?: boolean;
|
|
49
|
+
groupedAs?: string;
|
|
50
|
+
min?: number;
|
|
51
|
+
max?: number;
|
|
52
|
+
integer?: boolean;
|
|
53
|
+
minLength?: number;
|
|
54
|
+
maxLength?: number;
|
|
55
|
+
pattern?: string;
|
|
44
56
|
};
|
package/dist/types/crud.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Component } from 'svelte';
|
|
2
2
|
import type { FullAutoFill } from 'svelte/elements';
|
|
3
|
-
import type { AttributeType } from './attribute.js';
|
|
3
|
+
import type { AttributeType, SearchResolver } from './attribute.js';
|
|
4
4
|
import type { CellComponent, CellFormatter } from './table.js';
|
|
5
5
|
export type ColumnDefinition<T extends object = Record<string, unknown>> = {
|
|
6
6
|
[K in keyof T & string]: {
|
|
@@ -29,8 +29,16 @@ export interface FieldDefinition<T extends object = Record<string, unknown>> {
|
|
|
29
29
|
value: string;
|
|
30
30
|
label: string;
|
|
31
31
|
}[];
|
|
32
|
+
search?: SearchResolver;
|
|
32
33
|
disabled?: (record: Record<string, unknown>) => boolean;
|
|
33
34
|
seed?: (instance: any) => unknown;
|
|
35
|
+
groupedAs?: string;
|
|
36
|
+
min?: number;
|
|
37
|
+
max?: number;
|
|
38
|
+
integer?: boolean;
|
|
39
|
+
minLength?: number;
|
|
40
|
+
maxLength?: number;
|
|
41
|
+
pattern?: string;
|
|
34
42
|
}
|
|
35
43
|
export interface ActionConfiguration<T extends object = Record<string, unknown>> {
|
|
36
44
|
enabled?: boolean;
|
|
@@ -44,7 +52,12 @@ export interface CustomAction<T extends object = Record<string, unknown>> {
|
|
|
44
52
|
icon: any;
|
|
45
53
|
endpoint?: string;
|
|
46
54
|
condition?: (item: T) => boolean;
|
|
47
|
-
|
|
55
|
+
/** Renders as a modal-like panel when the action runs. Mutually exclusive
|
|
56
|
+
* with `href` — provide exactly one of the two. */
|
|
57
|
+
view?: Component<any>;
|
|
58
|
+
/** Navigates to the given URL instead of opening `view`. Takes priority
|
|
59
|
+
* over `view` if both are somehow set. */
|
|
60
|
+
href?: (item: T) => string;
|
|
48
61
|
}
|
|
49
62
|
export interface RowAction<T extends object = Record<string, unknown>> {
|
|
50
63
|
label: string;
|