runeforge 0.0.61 → 0.0.63
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/LICENSE +21 -21
- package/README.md +1410 -1407
- package/dist/components/Avatar.svelte +31 -31
- package/dist/components/IconRenderer.svelte +73 -73
- package/dist/components/Modal.svelte +75 -75
- package/dist/components/common/Header.svelte +40 -40
- package/dist/components/crud/EmbeddedField.svelte +203 -203
- package/dist/components/crud/Field.svelte +122 -122
- package/dist/components/crud/GenericCRUD.svelte +426 -426
- package/dist/components/crud/SearchInput.svelte +53 -53
- package/dist/components/crud/columns/Avatar.svelte +15 -15
- package/dist/components/crud/columns/Icon.svelte +8 -8
- package/dist/components/crud/fields/BooleanField.svelte +38 -38
- package/dist/components/crud/fields/DatetimeField.svelte +250 -250
- package/dist/components/crud/fields/DefaultField.svelte +67 -67
- package/dist/components/crud/fields/FileField.svelte +70 -70
- package/dist/components/crud/fields/MultiSelectField.svelte +70 -70
- package/dist/components/crud/fields/SelectField.svelte +67 -67
- package/dist/components/crud/fields/TextareaField.svelte +55 -55
- package/dist/components/crud/fields/TreeField.svelte +1 -0
- package/dist/components/crud/utils/resolution.js +1 -0
- package/dist/components/crud/utils/tree.d.ts +5 -0
- package/dist/components/crud/utils/tree.js +29 -0
- package/dist/components/crud/views/Create.svelte +239 -239
- package/dist/components/crud/views/Read.svelte +131 -131
- package/dist/components/crud/views/Update.svelte +215 -215
- package/dist/components/crud/views/list/List.svelte +291 -291
- package/dist/components/crud/views/list/Modals.svelte +56 -56
- package/dist/components/crud/views/list/Table.svelte +173 -173
- package/dist/components/crud/views/list/Toolbar.svelte +340 -340
- package/dist/components/form/Button.svelte +27 -27
- package/dist/components/form/Label.svelte +37 -37
- package/dist/components/form/MultiSelect.svelte +248 -248
- package/dist/components/form/PasswordInput.svelte +68 -68
- package/dist/components/form/Required.svelte +1 -1
- package/dist/components/form/Select.svelte +209 -209
- package/dist/components/form/Tree.svelte +43 -4
- package/dist/components/form/Tree.svelte.d.ts +1 -0
- package/dist/components/form/TreeNode.svelte +31 -2
- package/dist/components/form/TreeNode.svelte.d.ts +2 -0
- package/dist/components/navigation/Breadcrumbs.svelte +111 -111
- package/dist/components/table/ColumnFilter.svelte +168 -168
- package/dist/components/table/PaginatedTable.svelte +542 -542
- package/dist/components/table/Paginator.svelte +113 -113
- package/dist/components/table/SortHeader.svelte +43 -43
- package/dist/components/table/TableBody.svelte +150 -150
- package/dist/components/table/TableHeader.svelte +88 -88
- package/dist/components/table/state.svelte.js +7 -2
- package/dist/icons/defaults/Clear.svelte +6 -6
- package/dist/icons/defaults/Create.svelte +6 -6
- package/dist/icons/defaults/Delete.svelte +6 -6
- package/dist/icons/defaults/Download.svelte +7 -7
- package/dist/icons/defaults/Edit.svelte +7 -7
- package/dist/icons/defaults/Filter.svelte +6 -6
- package/dist/icons/defaults/FilterActive.svelte +6 -6
- package/dist/icons/defaults/Folder.svelte +6 -6
- package/dist/icons/defaults/Grip.svelte +7 -7
- package/dist/icons/defaults/Home.svelte +6 -6
- package/dist/icons/defaults/PasswordHide.svelte +9 -9
- package/dist/icons/defaults/PasswordShow.svelte +7 -7
- package/dist/icons/defaults/SortAsc.svelte +6 -6
- package/dist/icons/defaults/SortDesc.svelte +6 -6
- package/dist/icons/defaults/SortNone.svelte +6 -6
- package/dist/icons/defaults/View.svelte +7 -7
- package/dist/types/attribute.d.ts +3 -0
- package/dist/types/crud/fields.d.ts +3 -0
- package/package.json +1 -1
|
@@ -1,203 +1,203 @@
|
|
|
1
|
-
<script lang="ts" generics="T extends object = Record<string, unknown>">
|
|
2
|
-
import { untrack } from 'svelte';
|
|
3
|
-
import Field from './Field.svelte';
|
|
4
|
-
import Button from '../form/Button.svelte';
|
|
5
|
-
import Modal from '../Modal.svelte';
|
|
6
|
-
import { emptyRecord, seedField, defaultItemLabel } from './utils/embedded.js';
|
|
7
|
-
import { fieldLabel } from './utils/misc.js';
|
|
8
|
-
import { validateAll } from './utils/validation.js';
|
|
9
|
-
import { groupFields } from './utils/grouping.js';
|
|
10
|
-
import type { FieldDefinition } from '../../types/crud/fields.js';
|
|
11
|
-
import { getStrings } from '../../i18n/context.js';
|
|
12
|
-
|
|
13
|
-
const strings = getStrings();
|
|
14
|
-
|
|
15
|
-
let {
|
|
16
|
-
field,
|
|
17
|
-
record = $bindable({} as Record<string, unknown>),
|
|
18
|
-
readonly = false
|
|
19
|
-
}: {
|
|
20
|
-
field: FieldDefinition<T>;
|
|
21
|
-
/** The record that owns this embedded field (the form record for a
|
|
22
|
-
* top-level embedded field, or the outer item's draft for one nested
|
|
23
|
-
* inside another embedded field) — passed through to each sub-field's
|
|
24
|
-
* `hidden`/`disabled`/`required`/`dependentOptions` as their `parent`
|
|
25
|
-
* argument, and to `field.revalidate` as its own parent argument. */
|
|
26
|
-
record?: Record<string, unknown>;
|
|
27
|
-
readonly?: boolean;
|
|
28
|
-
} = $props();
|
|
29
|
-
|
|
30
|
-
const subFields = $derived(field.fields ?? []);
|
|
31
|
-
const subGroups = $derived(groupFields(subFields));
|
|
32
|
-
const items = $derived((record[field.attribute] as Record<string, unknown>[] | undefined) ?? []);
|
|
33
|
-
|
|
34
|
-
// Re-derives the embedded list right after `field.dependsOn` changes value
|
|
35
|
-
// on the parent record — e.g. dropping items that no longer apply once a
|
|
36
|
-
// sibling "owner kind" select flips — instead of leaving them stale until
|
|
37
|
-
// the user happens to touch this field again. Scoped to exactly that one
|
|
38
|
-
// parent attribute: reading it is this effect's only tracked dependency,
|
|
39
|
-
// and everything else (including its own write to `record[field.attribute]`)
|
|
40
|
-
// runs inside `untrack` so the effect never re-triggers itself.
|
|
41
|
-
$effect(() => {
|
|
42
|
-
if (!field.dependsOn || !field.revalidate) return;
|
|
43
|
-
// Read (only) to register as this effect's sole tracked dependency —
|
|
44
|
-
// everything that actually acts on it happens inside untrack() below,
|
|
45
|
-
// so the effect reruns exactly when this one value changes and never
|
|
46
|
-
// because of its own write to record[field.attribute].
|
|
47
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
48
|
-
const dependencyValue = record[field.dependsOn];
|
|
49
|
-
untrack(() => {
|
|
50
|
-
const current = (record[field.attribute] as Record<string, unknown>[] | undefined) ?? [];
|
|
51
|
-
const next = field.revalidate!(current, record);
|
|
52
|
-
if (next !== current) record[field.attribute] = next;
|
|
53
|
-
});
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
let modalOpen = $state(false);
|
|
57
|
-
// null while adding a new item; the item's index while editing an existing
|
|
58
|
-
// one, so saveItem knows whether to append or replace in place.
|
|
59
|
-
let editingIndex = $state<number | null>(null);
|
|
60
|
-
let draft = $state<Record<string, unknown>>({});
|
|
61
|
-
let draftErrors = $state<Record<string, string>>({});
|
|
62
|
-
|
|
63
|
-
function openCreateModal() {
|
|
64
|
-
editingIndex = null;
|
|
65
|
-
draft = emptyRecord(subFields);
|
|
66
|
-
draftErrors = {};
|
|
67
|
-
modalOpen = true;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
function openEditModal(index: number) {
|
|
71
|
-
const item = items[index];
|
|
72
|
-
editingIndex = index;
|
|
73
|
-
draft = Object.fromEntries(
|
|
74
|
-
subFields.map((f) => [f.attribute, seedField(f, f.seed ? f.seed(item) : item[f.attribute])])
|
|
75
|
-
);
|
|
76
|
-
draftErrors = {};
|
|
77
|
-
modalOpen = true;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function closeModal() {
|
|
81
|
-
modalOpen = false;
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function coerce(f: FieldDefinition, raw: unknown): unknown {
|
|
85
|
-
if (f.type === 'number') return raw === '' || raw == null ? null : Number(raw);
|
|
86
|
-
return raw;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function saveItem() {
|
|
90
|
-
const fd = new FormData();
|
|
91
|
-
for (const f of subFields) fd.set(f.attribute, String(draft[f.attribute] ?? ''));
|
|
92
|
-
const errs = validateAll(subFields, fd, strings);
|
|
93
|
-
if (Object.keys(errs).length > 0) {
|
|
94
|
-
draftErrors = errs;
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
97
|
-
const item = Object.fromEntries(
|
|
98
|
-
subFields.map((f) => [f.attribute, coerce(f, draft[f.attribute])])
|
|
99
|
-
);
|
|
100
|
-
record[field.attribute] =
|
|
101
|
-
editingIndex == null
|
|
102
|
-
? [...items, item]
|
|
103
|
-
: items.map((existing, i) => (i === editingIndex ? item : existing));
|
|
104
|
-
modalOpen = false;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
function removeItem(index: number) {
|
|
108
|
-
record[field.attribute] = items.filter((_, i) => i !== index);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function itemLabel(item: Record<string, unknown>): string {
|
|
112
|
-
return field.itemLabel ? field.itemLabel(item) : defaultItemLabel(subFields, item);
|
|
113
|
-
}
|
|
114
|
-
</script>
|
|
115
|
-
|
|
116
|
-
<div class="flex flex-col gap-2">
|
|
117
|
-
{#if !readonly}
|
|
118
|
-
<input type="hidden" name={field.attribute} value={JSON.stringify(items)} />
|
|
119
|
-
{/if}
|
|
120
|
-
|
|
121
|
-
{#if items.length === 0}
|
|
122
|
-
<p class="text-sm text-base-content/50">{strings.noItems}</p>
|
|
123
|
-
{:else}
|
|
124
|
-
<ul class="flex flex-col gap-1">
|
|
125
|
-
{#each items as item, i (i)}
|
|
126
|
-
<li
|
|
127
|
-
class="flex items-center justify-between gap-2 rounded-box border border-base-300 px-3 py-2 text-sm"
|
|
128
|
-
>
|
|
129
|
-
{#if readonly}
|
|
130
|
-
<span>{itemLabel(item)}</span>
|
|
131
|
-
{:else}
|
|
132
|
-
<button
|
|
133
|
-
type="button"
|
|
134
|
-
class="flex-1 text-left hover:underline"
|
|
135
|
-
onclick={() => openEditModal(i)}
|
|
136
|
-
>
|
|
137
|
-
{itemLabel(item)}
|
|
138
|
-
</button>
|
|
139
|
-
<Button
|
|
140
|
-
variant="ghost"
|
|
141
|
-
class="btn-xs btn-circle"
|
|
142
|
-
aria-label={strings.remove}
|
|
143
|
-
onclick={() => removeItem(i)}
|
|
144
|
-
>
|
|
145
|
-
✕
|
|
146
|
-
</Button>
|
|
147
|
-
{/if}
|
|
148
|
-
</li>
|
|
149
|
-
{/each}
|
|
150
|
-
</ul>
|
|
151
|
-
{/if}
|
|
152
|
-
|
|
153
|
-
{#if !readonly}
|
|
154
|
-
<Button variant="outline" class="btn-sm self-start" onclick={openCreateModal}>
|
|
155
|
-
+ {strings.add}
|
|
156
|
-
</Button>
|
|
157
|
-
{/if}
|
|
158
|
-
</div>
|
|
159
|
-
|
|
160
|
-
{#if modalOpen}
|
|
161
|
-
<Modal title={fieldLabel(field)} onClose={closeModal}>
|
|
162
|
-
<div class="flex flex-col gap-4">
|
|
163
|
-
{#each subGroups as group, i (group.title ?? `_ungrouped_${i}`)}
|
|
164
|
-
{#if group.title}
|
|
165
|
-
<fieldset class="fieldset border border-base-300 rounded-box p-4">
|
|
166
|
-
<legend class="fieldset-legend px-2">{group.title}</legend>
|
|
167
|
-
<div class="flex flex-col gap-4">
|
|
168
|
-
{#each group.rows as row (row.map((f) => f.attribute).join('|'))}
|
|
169
|
-
{#if row.length > 1}
|
|
170
|
-
<div class="flex flex-col gap-4 md:flex-row">
|
|
171
|
-
{#each row as f (f.attribute)}
|
|
172
|
-
<Field field={f} bind:record={draft} parentRecord={record} error={draftErrors[f.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
|
|
173
|
-
{/each}
|
|
174
|
-
</div>
|
|
175
|
-
{:else}
|
|
176
|
-
<Field field={row[0]} bind:record={draft} parentRecord={record} error={draftErrors[row[0].attribute] ?? ''} />
|
|
177
|
-
{/if}
|
|
178
|
-
{/each}
|
|
179
|
-
</div>
|
|
180
|
-
</fieldset>
|
|
181
|
-
{:else}
|
|
182
|
-
{#each group.rows as row (row.map((f) => f.attribute).join('|'))}
|
|
183
|
-
{#if row.length > 1}
|
|
184
|
-
<div class="flex flex-col gap-4 md:flex-row">
|
|
185
|
-
{#each row as f (f.attribute)}
|
|
186
|
-
<Field field={f} bind:record={draft} parentRecord={record} error={draftErrors[f.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
|
|
187
|
-
{/each}
|
|
188
|
-
</div>
|
|
189
|
-
{:else}
|
|
190
|
-
<Field field={row[0]} bind:record={draft} parentRecord={record} error={draftErrors[row[0].attribute] ?? ''} />
|
|
191
|
-
{/if}
|
|
192
|
-
{/each}
|
|
193
|
-
{/if}
|
|
194
|
-
{/each}
|
|
195
|
-
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
|
196
|
-
<Button variant="ghost" onclick={closeModal}>{strings.cancel}</Button>
|
|
197
|
-
<Button variant="primary" onclick={saveItem}>
|
|
198
|
-
{editingIndex == null ? strings.add : strings.edit}
|
|
199
|
-
</Button>
|
|
200
|
-
</div>
|
|
201
|
-
</div>
|
|
202
|
-
</Modal>
|
|
203
|
-
{/if}
|
|
1
|
+
<script lang="ts" generics="T extends object = Record<string, unknown>">
|
|
2
|
+
import { untrack } from 'svelte';
|
|
3
|
+
import Field from './Field.svelte';
|
|
4
|
+
import Button from '../form/Button.svelte';
|
|
5
|
+
import Modal from '../Modal.svelte';
|
|
6
|
+
import { emptyRecord, seedField, defaultItemLabel } from './utils/embedded.js';
|
|
7
|
+
import { fieldLabel } from './utils/misc.js';
|
|
8
|
+
import { validateAll } from './utils/validation.js';
|
|
9
|
+
import { groupFields } from './utils/grouping.js';
|
|
10
|
+
import type { FieldDefinition } from '../../types/crud/fields.js';
|
|
11
|
+
import { getStrings } from '../../i18n/context.js';
|
|
12
|
+
|
|
13
|
+
const strings = getStrings();
|
|
14
|
+
|
|
15
|
+
let {
|
|
16
|
+
field,
|
|
17
|
+
record = $bindable({} as Record<string, unknown>),
|
|
18
|
+
readonly = false
|
|
19
|
+
}: {
|
|
20
|
+
field: FieldDefinition<T>;
|
|
21
|
+
/** The record that owns this embedded field (the form record for a
|
|
22
|
+
* top-level embedded field, or the outer item's draft for one nested
|
|
23
|
+
* inside another embedded field) — passed through to each sub-field's
|
|
24
|
+
* `hidden`/`disabled`/`required`/`dependentOptions` as their `parent`
|
|
25
|
+
* argument, and to `field.revalidate` as its own parent argument. */
|
|
26
|
+
record?: Record<string, unknown>;
|
|
27
|
+
readonly?: boolean;
|
|
28
|
+
} = $props();
|
|
29
|
+
|
|
30
|
+
const subFields = $derived(field.fields ?? []);
|
|
31
|
+
const subGroups = $derived(groupFields(subFields));
|
|
32
|
+
const items = $derived((record[field.attribute] as Record<string, unknown>[] | undefined) ?? []);
|
|
33
|
+
|
|
34
|
+
// Re-derives the embedded list right after `field.dependsOn` changes value
|
|
35
|
+
// on the parent record — e.g. dropping items that no longer apply once a
|
|
36
|
+
// sibling "owner kind" select flips — instead of leaving them stale until
|
|
37
|
+
// the user happens to touch this field again. Scoped to exactly that one
|
|
38
|
+
// parent attribute: reading it is this effect's only tracked dependency,
|
|
39
|
+
// and everything else (including its own write to `record[field.attribute]`)
|
|
40
|
+
// runs inside `untrack` so the effect never re-triggers itself.
|
|
41
|
+
$effect(() => {
|
|
42
|
+
if (!field.dependsOn || !field.revalidate) return;
|
|
43
|
+
// Read (only) to register as this effect's sole tracked dependency —
|
|
44
|
+
// everything that actually acts on it happens inside untrack() below,
|
|
45
|
+
// so the effect reruns exactly when this one value changes and never
|
|
46
|
+
// because of its own write to record[field.attribute].
|
|
47
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
48
|
+
const dependencyValue = record[field.dependsOn];
|
|
49
|
+
untrack(() => {
|
|
50
|
+
const current = (record[field.attribute] as Record<string, unknown>[] | undefined) ?? [];
|
|
51
|
+
const next = field.revalidate!(current, record);
|
|
52
|
+
if (next !== current) record[field.attribute] = next;
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
let modalOpen = $state(false);
|
|
57
|
+
// null while adding a new item; the item's index while editing an existing
|
|
58
|
+
// one, so saveItem knows whether to append or replace in place.
|
|
59
|
+
let editingIndex = $state<number | null>(null);
|
|
60
|
+
let draft = $state<Record<string, unknown>>({});
|
|
61
|
+
let draftErrors = $state<Record<string, string>>({});
|
|
62
|
+
|
|
63
|
+
function openCreateModal() {
|
|
64
|
+
editingIndex = null;
|
|
65
|
+
draft = emptyRecord(subFields);
|
|
66
|
+
draftErrors = {};
|
|
67
|
+
modalOpen = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function openEditModal(index: number) {
|
|
71
|
+
const item = items[index];
|
|
72
|
+
editingIndex = index;
|
|
73
|
+
draft = Object.fromEntries(
|
|
74
|
+
subFields.map((f) => [f.attribute, seedField(f, f.seed ? f.seed(item) : item[f.attribute])])
|
|
75
|
+
);
|
|
76
|
+
draftErrors = {};
|
|
77
|
+
modalOpen = true;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function closeModal() {
|
|
81
|
+
modalOpen = false;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function coerce(f: FieldDefinition, raw: unknown): unknown {
|
|
85
|
+
if (f.type === 'number') return raw === '' || raw == null ? null : Number(raw);
|
|
86
|
+
return raw;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function saveItem() {
|
|
90
|
+
const fd = new FormData();
|
|
91
|
+
for (const f of subFields) fd.set(f.attribute, String(draft[f.attribute] ?? ''));
|
|
92
|
+
const errs = validateAll(subFields, fd, strings);
|
|
93
|
+
if (Object.keys(errs).length > 0) {
|
|
94
|
+
draftErrors = errs;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const item = Object.fromEntries(
|
|
98
|
+
subFields.map((f) => [f.attribute, coerce(f, draft[f.attribute])])
|
|
99
|
+
);
|
|
100
|
+
record[field.attribute] =
|
|
101
|
+
editingIndex == null
|
|
102
|
+
? [...items, item]
|
|
103
|
+
: items.map((existing, i) => (i === editingIndex ? item : existing));
|
|
104
|
+
modalOpen = false;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function removeItem(index: number) {
|
|
108
|
+
record[field.attribute] = items.filter((_, i) => i !== index);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function itemLabel(item: Record<string, unknown>): string {
|
|
112
|
+
return field.itemLabel ? field.itemLabel(item) : defaultItemLabel(subFields, item);
|
|
113
|
+
}
|
|
114
|
+
</script>
|
|
115
|
+
|
|
116
|
+
<div class="flex flex-col gap-2">
|
|
117
|
+
{#if !readonly}
|
|
118
|
+
<input type="hidden" name={field.attribute} value={JSON.stringify(items)} />
|
|
119
|
+
{/if}
|
|
120
|
+
|
|
121
|
+
{#if items.length === 0}
|
|
122
|
+
<p class="text-sm text-base-content/50">{strings.noItems}</p>
|
|
123
|
+
{:else}
|
|
124
|
+
<ul class="flex flex-col gap-1">
|
|
125
|
+
{#each items as item, i (i)}
|
|
126
|
+
<li
|
|
127
|
+
class="flex items-center justify-between gap-2 rounded-box border border-base-300 px-3 py-2 text-sm"
|
|
128
|
+
>
|
|
129
|
+
{#if readonly}
|
|
130
|
+
<span>{itemLabel(item)}</span>
|
|
131
|
+
{:else}
|
|
132
|
+
<button
|
|
133
|
+
type="button"
|
|
134
|
+
class="flex-1 text-left hover:underline"
|
|
135
|
+
onclick={() => openEditModal(i)}
|
|
136
|
+
>
|
|
137
|
+
{itemLabel(item)}
|
|
138
|
+
</button>
|
|
139
|
+
<Button
|
|
140
|
+
variant="ghost"
|
|
141
|
+
class="btn-xs btn-circle"
|
|
142
|
+
aria-label={strings.remove}
|
|
143
|
+
onclick={() => removeItem(i)}
|
|
144
|
+
>
|
|
145
|
+
✕
|
|
146
|
+
</Button>
|
|
147
|
+
{/if}
|
|
148
|
+
</li>
|
|
149
|
+
{/each}
|
|
150
|
+
</ul>
|
|
151
|
+
{/if}
|
|
152
|
+
|
|
153
|
+
{#if !readonly}
|
|
154
|
+
<Button variant="outline" class="btn-sm self-start" onclick={openCreateModal}>
|
|
155
|
+
+ {strings.add}
|
|
156
|
+
</Button>
|
|
157
|
+
{/if}
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
{#if modalOpen}
|
|
161
|
+
<Modal title={fieldLabel(field)} onClose={closeModal}>
|
|
162
|
+
<div class="flex flex-col gap-4">
|
|
163
|
+
{#each subGroups as group, i (group.title ?? `_ungrouped_${i}`)}
|
|
164
|
+
{#if group.title}
|
|
165
|
+
<fieldset class="fieldset border border-base-300 rounded-box p-4">
|
|
166
|
+
<legend class="fieldset-legend px-2">{group.title}</legend>
|
|
167
|
+
<div class="flex flex-col gap-4">
|
|
168
|
+
{#each group.rows as row (row.map((f) => f.attribute).join('|'))}
|
|
169
|
+
{#if row.length > 1}
|
|
170
|
+
<div class="flex flex-col gap-4 md:flex-row">
|
|
171
|
+
{#each row as f (f.attribute)}
|
|
172
|
+
<Field field={f} bind:record={draft} parentRecord={record} error={draftErrors[f.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
|
|
173
|
+
{/each}
|
|
174
|
+
</div>
|
|
175
|
+
{:else}
|
|
176
|
+
<Field field={row[0]} bind:record={draft} parentRecord={record} error={draftErrors[row[0].attribute] ?? ''} />
|
|
177
|
+
{/if}
|
|
178
|
+
{/each}
|
|
179
|
+
</div>
|
|
180
|
+
</fieldset>
|
|
181
|
+
{:else}
|
|
182
|
+
{#each group.rows as row (row.map((f) => f.attribute).join('|'))}
|
|
183
|
+
{#if row.length > 1}
|
|
184
|
+
<div class="flex flex-col gap-4 md:flex-row">
|
|
185
|
+
{#each row as f (f.attribute)}
|
|
186
|
+
<Field field={f} bind:record={draft} parentRecord={record} error={draftErrors[f.attribute] ?? ''} class="md:min-w-0 md:flex-1" />
|
|
187
|
+
{/each}
|
|
188
|
+
</div>
|
|
189
|
+
{:else}
|
|
190
|
+
<Field field={row[0]} bind:record={draft} parentRecord={record} error={draftErrors[row[0].attribute] ?? ''} />
|
|
191
|
+
{/if}
|
|
192
|
+
{/each}
|
|
193
|
+
{/if}
|
|
194
|
+
{/each}
|
|
195
|
+
<div class="flex flex-col-reverse gap-2 pt-2 sm:flex-row sm:justify-end">
|
|
196
|
+
<Button variant="ghost" onclick={closeModal}>{strings.cancel}</Button>
|
|
197
|
+
<Button variant="primary" onclick={saveItem}>
|
|
198
|
+
{editingIndex == null ? strings.add : strings.edit}
|
|
199
|
+
</Button>
|
|
200
|
+
</div>
|
|
201
|
+
</div>
|
|
202
|
+
</Modal>
|
|
203
|
+
{/if}
|
|
@@ -1,122 +1,122 @@
|
|
|
1
|
-
<script lang="ts" generics="T extends object = Record<string, unknown>">
|
|
2
|
-
import Button from '../form/Button.svelte';
|
|
3
|
-
import Label from '../form/Label.svelte';
|
|
4
|
-
import EmbeddedField from './EmbeddedField.svelte';
|
|
5
|
-
import BooleanField from './fields/BooleanField.svelte';
|
|
6
|
-
import FileField from './fields/FileField.svelte';
|
|
7
|
-
import SelectField from './fields/SelectField.svelte';
|
|
8
|
-
import MultiSelectField from './fields/MultiSelectField.svelte';
|
|
9
|
-
import TreeField from './fields/TreeField.svelte';
|
|
10
|
-
import DatetimeField from './fields/DatetimeField.svelte';
|
|
11
|
-
import TextareaField from './fields/TextareaField.svelte';
|
|
12
|
-
import DefaultField from './fields/DefaultField.svelte';
|
|
13
|
-
import { fieldLabel } from './utils/misc.js';
|
|
14
|
-
import type { FieldDefinition } from '../../types/crud/fields.js';
|
|
15
|
-
|
|
16
|
-
let {
|
|
17
|
-
field,
|
|
18
|
-
record = $bindable({} as Record<string, unknown>),
|
|
19
|
-
parentRecord = undefined,
|
|
20
|
-
error = '',
|
|
21
|
-
readonly = false,
|
|
22
|
-
class: className = ''
|
|
23
|
-
}: {
|
|
24
|
-
field: FieldDefinition<T>;
|
|
25
|
-
record?: Record<string, unknown>;
|
|
26
|
-
/** Set only when this Field renders a sub-field of an `embedded` field
|
|
27
|
-
* — the outer form's own record, passed through to `hidden`/`disabled`/
|
|
28
|
-
* `required`/`dependentOptions` as their second argument. */
|
|
29
|
-
parentRecord?: Record<string, unknown>;
|
|
30
|
-
error?: string;
|
|
31
|
-
readonly?: boolean;
|
|
32
|
-
class?: string;
|
|
33
|
-
} = $props();
|
|
34
|
-
|
|
35
|
-
const labelText = $derived(fieldLabel(field));
|
|
36
|
-
const fieldRequired = $derived(
|
|
37
|
-
typeof field.required === 'function' ? field.required(record, parentRecord) : !!field.required
|
|
38
|
-
);
|
|
39
|
-
const fieldHidden = $derived(
|
|
40
|
-
typeof field.hidden === 'function' ? field.hidden(record, parentRecord) : !!field.hidden
|
|
41
|
-
);
|
|
42
|
-
const fieldActions = $derived(
|
|
43
|
-
(field.actions ?? []).filter((action) => action.condition?.(record) ?? true)
|
|
44
|
-
);
|
|
45
|
-
</script>
|
|
46
|
-
|
|
47
|
-
<!-- Shared by every field type below, always rendered in the same spot
|
|
48
|
-
(right after an optional type-specific lead-in, e.g. FileField's avatar
|
|
49
|
-
preview) so each type-specific component just brackets its own markup
|
|
50
|
-
with these two instead of re-declaring the label/error chrome itself. -->
|
|
51
|
-
{#snippet labelRow()}
|
|
52
|
-
<div class="flex items-center justify-between gap-2">
|
|
53
|
-
<Label
|
|
54
|
-
text={labelText}
|
|
55
|
-
for={field.attribute}
|
|
56
|
-
capitalize={true}
|
|
57
|
-
required={fieldRequired && !readonly}
|
|
58
|
-
/>
|
|
59
|
-
{#if !readonly && fieldActions.length > 0}
|
|
60
|
-
<div class="flex items-center gap-1">
|
|
61
|
-
{#each fieldActions as action (action.label)}
|
|
62
|
-
<Button
|
|
63
|
-
type="button"
|
|
64
|
-
variant="ghost"
|
|
65
|
-
class={['btn-xs', action.class]}
|
|
66
|
-
title={action.label}
|
|
67
|
-
onclick={() =>
|
|
68
|
-
action.run(record[field.attribute], record, (attribute, value) => {
|
|
69
|
-
record[attribute] = value;
|
|
70
|
-
})}
|
|
71
|
-
>
|
|
72
|
-
{#if action.icon}
|
|
73
|
-
{@const Icon = action.icon}
|
|
74
|
-
<Icon class="size-4" />
|
|
75
|
-
{/if}
|
|
76
|
-
{action.label}
|
|
77
|
-
</Button>
|
|
78
|
-
{/each}
|
|
79
|
-
</div>
|
|
80
|
-
{/if}
|
|
81
|
-
</div>
|
|
82
|
-
{/snippet}
|
|
83
|
-
|
|
84
|
-
{#snippet errorRow()}
|
|
85
|
-
{#if error}
|
|
86
|
-
<span class="text-error text-xs">{error}</span>
|
|
87
|
-
{/if}
|
|
88
|
-
{/snippet}
|
|
89
|
-
|
|
90
|
-
{#if !fieldHidden}
|
|
91
|
-
<div class="flex flex-col gap-1 {className}">
|
|
92
|
-
{#if field.type === 'boolean'}
|
|
93
|
-
<BooleanField {field} bind:record {parentRecord} {readonly} {labelRow} {errorRow} />
|
|
94
|
-
{:else if field.type === 'file'}
|
|
95
|
-
<FileField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
|
|
96
|
-
{:else if field.type === 'select'}
|
|
97
|
-
<SelectField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
|
|
98
|
-
{:else if field.type === 'datetime'}
|
|
99
|
-
<DatetimeField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
|
|
100
|
-
{:else if field.type === 'textarea'}
|
|
101
|
-
<TextareaField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
|
|
102
|
-
{:else if field.type === 'embedded'}
|
|
103
|
-
{@render labelRow()}
|
|
104
|
-
<EmbeddedField {field} bind:record {readonly} />
|
|
105
|
-
{@render errorRow()}
|
|
106
|
-
{:else if field.type === 'multiselect'}
|
|
107
|
-
<MultiSelectField
|
|
108
|
-
{field}
|
|
109
|
-
bind:record
|
|
110
|
-
{parentRecord}
|
|
111
|
-
{error}
|
|
112
|
-
{readonly}
|
|
113
|
-
{labelRow}
|
|
114
|
-
{errorRow}
|
|
115
|
-
/>
|
|
116
|
-
{:else if field.type === 'tree'}
|
|
117
|
-
<TreeField {field} bind:record {parentRecord} {readonly} {labelRow} {errorRow} />
|
|
118
|
-
{:else}
|
|
119
|
-
<DefaultField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
|
|
120
|
-
{/if}
|
|
121
|
-
</div>
|
|
122
|
-
{/if}
|
|
1
|
+
<script lang="ts" generics="T extends object = Record<string, unknown>">
|
|
2
|
+
import Button from '../form/Button.svelte';
|
|
3
|
+
import Label from '../form/Label.svelte';
|
|
4
|
+
import EmbeddedField from './EmbeddedField.svelte';
|
|
5
|
+
import BooleanField from './fields/BooleanField.svelte';
|
|
6
|
+
import FileField from './fields/FileField.svelte';
|
|
7
|
+
import SelectField from './fields/SelectField.svelte';
|
|
8
|
+
import MultiSelectField from './fields/MultiSelectField.svelte';
|
|
9
|
+
import TreeField from './fields/TreeField.svelte';
|
|
10
|
+
import DatetimeField from './fields/DatetimeField.svelte';
|
|
11
|
+
import TextareaField from './fields/TextareaField.svelte';
|
|
12
|
+
import DefaultField from './fields/DefaultField.svelte';
|
|
13
|
+
import { fieldLabel } from './utils/misc.js';
|
|
14
|
+
import type { FieldDefinition } from '../../types/crud/fields.js';
|
|
15
|
+
|
|
16
|
+
let {
|
|
17
|
+
field,
|
|
18
|
+
record = $bindable({} as Record<string, unknown>),
|
|
19
|
+
parentRecord = undefined,
|
|
20
|
+
error = '',
|
|
21
|
+
readonly = false,
|
|
22
|
+
class: className = ''
|
|
23
|
+
}: {
|
|
24
|
+
field: FieldDefinition<T>;
|
|
25
|
+
record?: Record<string, unknown>;
|
|
26
|
+
/** Set only when this Field renders a sub-field of an `embedded` field
|
|
27
|
+
* — the outer form's own record, passed through to `hidden`/`disabled`/
|
|
28
|
+
* `required`/`dependentOptions` as their second argument. */
|
|
29
|
+
parentRecord?: Record<string, unknown>;
|
|
30
|
+
error?: string;
|
|
31
|
+
readonly?: boolean;
|
|
32
|
+
class?: string;
|
|
33
|
+
} = $props();
|
|
34
|
+
|
|
35
|
+
const labelText = $derived(fieldLabel(field));
|
|
36
|
+
const fieldRequired = $derived(
|
|
37
|
+
typeof field.required === 'function' ? field.required(record, parentRecord) : !!field.required
|
|
38
|
+
);
|
|
39
|
+
const fieldHidden = $derived(
|
|
40
|
+
typeof field.hidden === 'function' ? field.hidden(record, parentRecord) : !!field.hidden
|
|
41
|
+
);
|
|
42
|
+
const fieldActions = $derived(
|
|
43
|
+
(field.actions ?? []).filter((action) => action.condition?.(record) ?? true)
|
|
44
|
+
);
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<!-- Shared by every field type below, always rendered in the same spot
|
|
48
|
+
(right after an optional type-specific lead-in, e.g. FileField's avatar
|
|
49
|
+
preview) so each type-specific component just brackets its own markup
|
|
50
|
+
with these two instead of re-declaring the label/error chrome itself. -->
|
|
51
|
+
{#snippet labelRow()}
|
|
52
|
+
<div class="flex items-center justify-between gap-2">
|
|
53
|
+
<Label
|
|
54
|
+
text={labelText}
|
|
55
|
+
for={field.attribute}
|
|
56
|
+
capitalize={true}
|
|
57
|
+
required={fieldRequired && !readonly}
|
|
58
|
+
/>
|
|
59
|
+
{#if !readonly && fieldActions.length > 0}
|
|
60
|
+
<div class="flex items-center gap-1">
|
|
61
|
+
{#each fieldActions as action (action.label)}
|
|
62
|
+
<Button
|
|
63
|
+
type="button"
|
|
64
|
+
variant="ghost"
|
|
65
|
+
class={['btn-xs', action.class]}
|
|
66
|
+
title={action.label}
|
|
67
|
+
onclick={() =>
|
|
68
|
+
action.run(record[field.attribute], record, (attribute, value) => {
|
|
69
|
+
record[attribute] = value;
|
|
70
|
+
})}
|
|
71
|
+
>
|
|
72
|
+
{#if action.icon}
|
|
73
|
+
{@const Icon = action.icon}
|
|
74
|
+
<Icon class="size-4" />
|
|
75
|
+
{/if}
|
|
76
|
+
{action.label}
|
|
77
|
+
</Button>
|
|
78
|
+
{/each}
|
|
79
|
+
</div>
|
|
80
|
+
{/if}
|
|
81
|
+
</div>
|
|
82
|
+
{/snippet}
|
|
83
|
+
|
|
84
|
+
{#snippet errorRow()}
|
|
85
|
+
{#if error}
|
|
86
|
+
<span class="text-error text-xs">{error}</span>
|
|
87
|
+
{/if}
|
|
88
|
+
{/snippet}
|
|
89
|
+
|
|
90
|
+
{#if !fieldHidden}
|
|
91
|
+
<div class="flex flex-col gap-1 {className}">
|
|
92
|
+
{#if field.type === 'boolean'}
|
|
93
|
+
<BooleanField {field} bind:record {parentRecord} {readonly} {labelRow} {errorRow} />
|
|
94
|
+
{:else if field.type === 'file'}
|
|
95
|
+
<FileField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
|
|
96
|
+
{:else if field.type === 'select'}
|
|
97
|
+
<SelectField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
|
|
98
|
+
{:else if field.type === 'datetime'}
|
|
99
|
+
<DatetimeField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
|
|
100
|
+
{:else if field.type === 'textarea'}
|
|
101
|
+
<TextareaField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
|
|
102
|
+
{:else if field.type === 'embedded'}
|
|
103
|
+
{@render labelRow()}
|
|
104
|
+
<EmbeddedField {field} bind:record {readonly} />
|
|
105
|
+
{@render errorRow()}
|
|
106
|
+
{:else if field.type === 'multiselect'}
|
|
107
|
+
<MultiSelectField
|
|
108
|
+
{field}
|
|
109
|
+
bind:record
|
|
110
|
+
{parentRecord}
|
|
111
|
+
{error}
|
|
112
|
+
{readonly}
|
|
113
|
+
{labelRow}
|
|
114
|
+
{errorRow}
|
|
115
|
+
/>
|
|
116
|
+
{:else if field.type === 'tree'}
|
|
117
|
+
<TreeField {field} bind:record {parentRecord} {readonly} {labelRow} {errorRow} />
|
|
118
|
+
{:else}
|
|
119
|
+
<DefaultField {field} bind:record {parentRecord} {error} {readonly} {labelRow} {errorRow} />
|
|
120
|
+
{/if}
|
|
121
|
+
</div>
|
|
122
|
+
{/if}
|