runeforge 0.0.57 → 0.0.58
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/README.md +51 -0
- package/dist/components/crud/Field.svelte +34 -6
- package/dist/components/crud/utils/resolution.js +3 -1
- package/dist/components/crud/utils/validation.js +10 -0
- package/dist/components/crud/views/Create.svelte +35 -29
- package/dist/components/crud/views/Read.svelte +13 -7
- package/dist/components/crud/views/Update.svelte +26 -20
- package/dist/types/attribute.d.ts +31 -0
- package/dist/types/crud.d.ts +5 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -164,9 +164,12 @@ Responsive overrides work too:
|
|
|
164
164
|
| `--runeforge-breadcrumb-font-size` | `0.875rem` | Breadcrumb label text size |
|
|
165
165
|
| `--runeforge-breadcrumb-icon-size` | `1rem` | Breadcrumb icon width and height |
|
|
166
166
|
| `--runeforge-tree-max-height` | `24rem` | Max height of a `tree` field before it scrolls internally |
|
|
167
|
+
| `--runeforge-sticky-header-top` | `0` | Offset of the sticky title/breadcrumbs/alert block in Create, Update, and Read views — raise it if your app already has its own sticky top bar taking up space |
|
|
167
168
|
|
|
168
169
|
Modal sizing (see [Shared Components](#shared-components)) is set per-instance via props rather than a CSS variable.
|
|
169
170
|
|
|
171
|
+
The title, breadcrumbs and any error/success alert at the top of the Create, Update and Read views stay pinned (`position: sticky`) to the top of the nearest scrolling ancestor, so they — and a validation error that just appeared — stay visible while a long form scrolls underneath. Set `--runeforge-sticky-header-top` if that ancestor already has its own fixed/sticky bar above this block.
|
|
172
|
+
|
|
170
173
|
---
|
|
171
174
|
|
|
172
175
|
## Configuration
|
|
@@ -364,6 +367,8 @@ Every entry in an `InterfaceMetadata<T>` object is an `AttributeMetadata` — a
|
|
|
364
367
|
| `integer` | `boolean` | `number` | Rejects non-whole numbers |
|
|
365
368
|
| `minLength` / `maxLength` | `number` | text-like | Character-count validation |
|
|
366
369
|
| `pattern` | `string` | text-like | Regex the value must match (`new RegExp(pattern)`) |
|
|
370
|
+
| `validate` | `(value, record) => string \| undefined` | all | Custom validation, including cross-field rules — see [Validation](#validation) |
|
|
371
|
+
| `actions` | `FieldButtonAction[]` | all | Extra buttons rendered next to the field's label — see [Field actions](#field-actions) |
|
|
367
372
|
| `disabled` | `(record) => boolean` | all | Conditionally disables the input — see [Conditional fields](#conditional-fields) |
|
|
368
373
|
| `hidden` | `boolean \| (record) => boolean` | all | Conditionally removes the field from the form entirely — not rendered, not validated, not submitted — see [Conditional fields](#conditional-fields) |
|
|
369
374
|
| `groupedAs` | `string` | all | Visually groups fields under a titled section — see [Field grouping](#field-grouping) |
|
|
@@ -428,6 +433,27 @@ quantity: {
|
|
|
428
433
|
|
|
429
434
|
The label's required marker and the submit-time check both re-evaluate the same way `disabled` does — see [Conditional fields](#conditional-fields).
|
|
430
435
|
|
|
436
|
+
For anything the built-in rules above don't cover — including a rule that depends on another field, not just this one — pass `validate`. It runs after this field's built-in rules pass (and is skipped if one of them already failed), and receives both the field's own value and the full draft record, so the same hook covers a lone-field rule and a cross-field one alike:
|
|
437
|
+
|
|
438
|
+
```ts
|
|
439
|
+
submissionDate: {
|
|
440
|
+
label: 'Submission date',
|
|
441
|
+
type: AttributeType.datetime,
|
|
442
|
+
},
|
|
443
|
+
extensionDate: {
|
|
444
|
+
label: 'Extension date',
|
|
445
|
+
type: AttributeType.datetime,
|
|
446
|
+
validate: (value, record) =>
|
|
447
|
+
typeof value === 'string' &&
|
|
448
|
+
typeof record.submissionDate === 'string' &&
|
|
449
|
+
value <= record.submissionDate
|
|
450
|
+
? 'Extension date must be later than the submission date'
|
|
451
|
+
: undefined,
|
|
452
|
+
},
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
There's deliberately no separate, form-wide validation hook — every error belongs to the field whose value is wrong, even when the rule reads a sibling's value to decide that, so a per-field `validate` is all that's needed.
|
|
456
|
+
|
|
431
457
|
> [!TIP]
|
|
432
458
|
> Client-side validation is a UX nicety, not a security boundary — always re-validate in your form actions.
|
|
433
459
|
|
|
@@ -476,6 +502,31 @@ cardExpiry: {
|
|
|
476
502
|
|
|
477
503
|
Switching `paymentMethod` between `card` and `cash` swaps which fields are present, live, in the same create/edit view — no separate step or modal needed to collect the payment-specific details.
|
|
478
504
|
|
|
505
|
+
### Field actions
|
|
506
|
+
|
|
507
|
+
`actions` renders one or more buttons next to a field's label, for a client-side transform that doesn't belong to any field of its own — nothing is submitted, nothing hits the server, the button just runs synchronously and updates the form. Useful for things like normalizing free text as the user types it:
|
|
508
|
+
|
|
509
|
+
```ts
|
|
510
|
+
import Edit from './icons/Edit.svelte';
|
|
511
|
+
|
|
512
|
+
description: {
|
|
513
|
+
label: 'Description',
|
|
514
|
+
type: AttributeType.textarea,
|
|
515
|
+
actions: [
|
|
516
|
+
{
|
|
517
|
+
label: 'Capitalize',
|
|
518
|
+
icon: Edit,
|
|
519
|
+
run: (value, record, setField) => {
|
|
520
|
+
const str = String(value ?? '');
|
|
521
|
+
setField('description', str ? str[0].toUpperCase() + str.slice(1).toLowerCase() : str);
|
|
522
|
+
},
|
|
523
|
+
},
|
|
524
|
+
],
|
|
525
|
+
},
|
|
526
|
+
```
|
|
527
|
+
|
|
528
|
+
`run` receives the field's current value, the form's full draft record, and a `setField(attribute, value)` setter — which can just as well target a sibling attribute instead of the field the button sits next to. `icon` is a Svelte component, same convention as [custom row actions](#custom-row-actions). `condition` hides the button in certain conditions, re-evaluated live like `disabled`. Actions never render in the read-only view.
|
|
529
|
+
|
|
479
530
|
### Field grouping
|
|
480
531
|
|
|
481
532
|
Fields sharing the same `groupedAs` string render together inside a titled `fieldset`, at the position of the group's first field. Fields without `groupedAs` keep the original flat layout.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script lang="ts" generics="T extends object = Record<string, unknown>">
|
|
2
2
|
import { onMount } from 'svelte';
|
|
3
3
|
import Avatar from '../Avatar.svelte';
|
|
4
|
+
import Button from '../form/Button.svelte';
|
|
4
5
|
import Label from '../form/Label.svelte';
|
|
5
6
|
import Select from '../form/Select.svelte';
|
|
6
7
|
import MultiSelect from '../form/MultiSelect.svelte';
|
|
@@ -72,6 +73,9 @@
|
|
|
72
73
|
typeof field.hidden === 'function' ? field.hidden(record) : !!field.hidden
|
|
73
74
|
);
|
|
74
75
|
const isMultiValued = $derived(field.type === 'multiselect' || field.type === 'tree');
|
|
76
|
+
const fieldActions = $derived(
|
|
77
|
+
(field.actions ?? []).filter((action) => action.condition?.(record) ?? true)
|
|
78
|
+
);
|
|
75
79
|
|
|
76
80
|
// cally's `change` event doesn't bubble, so the usual `onchange={...}` prop
|
|
77
81
|
// never fires — Svelte 5 delegates events like `change` to a listener on
|
|
@@ -224,12 +228,36 @@
|
|
|
224
228
|
</div>
|
|
225
229
|
{/if}
|
|
226
230
|
|
|
227
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
231
|
+
<div class="flex items-center justify-between gap-2">
|
|
232
|
+
<Label
|
|
233
|
+
text={labelText}
|
|
234
|
+
for={field.attribute}
|
|
235
|
+
capitalize={true}
|
|
236
|
+
required={fieldRequired && !readonly}
|
|
237
|
+
/>
|
|
238
|
+
{#if !readonly && fieldActions.length > 0}
|
|
239
|
+
<div class="flex items-center gap-1">
|
|
240
|
+
{#each fieldActions as action (action.label)}
|
|
241
|
+
<Button
|
|
242
|
+
type="button"
|
|
243
|
+
variant="ghost"
|
|
244
|
+
class={['btn-xs', action.class]}
|
|
245
|
+
title={action.label}
|
|
246
|
+
onclick={() =>
|
|
247
|
+
action.run(record[field.attribute], record, (attribute, value) => {
|
|
248
|
+
record[attribute] = value;
|
|
249
|
+
})}
|
|
250
|
+
>
|
|
251
|
+
{#if action.icon}
|
|
252
|
+
{@const Icon = action.icon}
|
|
253
|
+
<Icon class="size-4" />
|
|
254
|
+
{/if}
|
|
255
|
+
{action.label}
|
|
256
|
+
</Button>
|
|
257
|
+
{/each}
|
|
258
|
+
</div>
|
|
259
|
+
{/if}
|
|
260
|
+
</div>
|
|
233
261
|
|
|
234
262
|
{#if field.type === 'boolean'}
|
|
235
263
|
<input
|
|
@@ -68,10 +68,12 @@ export function buildFieldDefinitions(meta, data, excludedFlag, excluded) {
|
|
|
68
68
|
minLength: m.minLength,
|
|
69
69
|
maxLength: m.maxLength,
|
|
70
70
|
pattern: m.pattern,
|
|
71
|
+
validate: m.validate,
|
|
71
72
|
rows: m.rows,
|
|
72
73
|
fields: m.fields ? buildFieldDefinitions(m.fields, data, excludedFlag, new Set()) : undefined,
|
|
73
74
|
itemLabel: m.itemLabel,
|
|
74
75
|
defaultExpanded: m.defaultExpanded,
|
|
75
|
-
row: m.row
|
|
76
|
+
row: m.row,
|
|
77
|
+
actions: m.actions
|
|
76
78
|
}));
|
|
77
79
|
}
|
|
@@ -22,6 +22,11 @@ export function validateAll(fields, formData, strings) {
|
|
|
22
22
|
if (required && (!Array.isArray(items) || items.length === 0)) {
|
|
23
23
|
errors[field.attribute] = strings.required(fieldLabel(field));
|
|
24
24
|
}
|
|
25
|
+
if (!errors[field.attribute] && field.validate) {
|
|
26
|
+
const message = field.validate(items, record);
|
|
27
|
+
if (message)
|
|
28
|
+
errors[field.attribute] = message;
|
|
29
|
+
}
|
|
25
30
|
continue;
|
|
26
31
|
}
|
|
27
32
|
const val = String(formData.get(field.attribute) ?? '').trim();
|
|
@@ -57,6 +62,11 @@ export function validateAll(fields, formData, strings) {
|
|
|
57
62
|
errors[field.attribute] = strings.pattern(fieldLabel(field));
|
|
58
63
|
}
|
|
59
64
|
}
|
|
65
|
+
if (!errors[field.attribute] && field.validate) {
|
|
66
|
+
const message = field.validate(val, record);
|
|
67
|
+
if (message)
|
|
68
|
+
errors[field.attribute] = message;
|
|
69
|
+
}
|
|
60
70
|
}
|
|
61
71
|
return errors;
|
|
62
72
|
}
|
|
@@ -78,35 +78,37 @@
|
|
|
78
78
|
|
|
79
79
|
<div class="flex flex-col gap-6">
|
|
80
80
|
|
|
81
|
-
<
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
{
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
<
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
<
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
81
|
+
<div class="sticky-header sticky z-10 flex flex-col gap-6 bg-base-100 pb-2">
|
|
82
|
+
<Header
|
|
83
|
+
title={labelMany}
|
|
84
|
+
breadcrumbs={[
|
|
85
|
+
{ label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
|
|
86
|
+
{ label: creation.label ?? labelOne, icon: icons.create },
|
|
87
|
+
]}
|
|
88
|
+
/>
|
|
89
|
+
|
|
90
|
+
{#if successMessage}
|
|
91
|
+
<div role="alert" class="alert alert-success">
|
|
92
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
93
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
|
|
94
|
+
</svg>
|
|
95
|
+
<span class="text-sm">{successMessage}</span>
|
|
96
|
+
</div>
|
|
97
|
+
{/if}
|
|
98
|
+
|
|
99
|
+
{#if errorEntries.length > 0}
|
|
100
|
+
<div role="alert" class="alert alert-error">
|
|
101
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
102
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
103
|
+
</svg>
|
|
104
|
+
<ul class="list-disc list-inside text-sm">
|
|
105
|
+
{#each errorEntries as [key, msg] (key)}
|
|
106
|
+
<li>{msg}</li>
|
|
107
|
+
{/each}
|
|
108
|
+
</ul>
|
|
109
|
+
</div>
|
|
110
|
+
{/if}
|
|
111
|
+
</div>
|
|
110
112
|
|
|
111
113
|
<form
|
|
112
114
|
method="POST"
|
|
@@ -229,4 +231,8 @@
|
|
|
229
231
|
form {
|
|
230
232
|
max-width: var(--runeforge-form-max-width, 32rem);
|
|
231
233
|
}
|
|
234
|
+
|
|
235
|
+
.sticky-header {
|
|
236
|
+
top: var(--runeforge-sticky-header-top, 0);
|
|
237
|
+
}
|
|
232
238
|
</style>
|
|
@@ -67,13 +67,15 @@
|
|
|
67
67
|
|
|
68
68
|
<div class="flex flex-col gap-6">
|
|
69
69
|
|
|
70
|
-
<
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
{
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
70
|
+
<div class="sticky-header sticky z-10 bg-base-100 pb-2">
|
|
71
|
+
<Header
|
|
72
|
+
title={labelMany}
|
|
73
|
+
breadcrumbs={[
|
|
74
|
+
{ label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
|
|
75
|
+
{ label: read.label ?? labelOne, icon: icons.view },
|
|
76
|
+
]}
|
|
77
|
+
/>
|
|
78
|
+
</div>
|
|
77
79
|
|
|
78
80
|
<div class="fields-panel mx-auto flex w-full flex-col gap-4 px-4">
|
|
79
81
|
{#each groups as group, i (group.title ?? `_ungrouped_${i}`)}
|
|
@@ -121,4 +123,8 @@
|
|
|
121
123
|
.fields-panel {
|
|
122
124
|
max-width: var(--runeforge-form-max-width, 32rem);
|
|
123
125
|
}
|
|
126
|
+
|
|
127
|
+
.sticky-header {
|
|
128
|
+
top: var(--runeforge-sticky-header-top, 0);
|
|
129
|
+
}
|
|
124
130
|
</style>
|
|
@@ -80,26 +80,28 @@
|
|
|
80
80
|
|
|
81
81
|
<div class="flex flex-col gap-6">
|
|
82
82
|
|
|
83
|
-
<
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
{
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
<
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
83
|
+
<div class="sticky-header sticky z-10 flex flex-col gap-6 bg-base-100 pb-2">
|
|
84
|
+
<Header
|
|
85
|
+
title={labelMany}
|
|
86
|
+
breadcrumbs={[
|
|
87
|
+
{ label: labelMany, icon: entityIcon, link: { href: '#', onclick: (e) => { e.preventDefault(); onCancel?.(); } }, prominent: true },
|
|
88
|
+
{ label: update.label ?? labelOne, icon: icons.edit },
|
|
89
|
+
]}
|
|
90
|
+
/>
|
|
91
|
+
|
|
92
|
+
{#if errorEntries.length > 0}
|
|
93
|
+
<div role="alert" class="alert alert-error">
|
|
94
|
+
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
95
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
96
|
+
</svg>
|
|
97
|
+
<ul class="list-disc list-inside text-sm">
|
|
98
|
+
{#each errorEntries as [key, msg] (key)}
|
|
99
|
+
<li>{msg}</li>
|
|
100
|
+
{/each}
|
|
101
|
+
</ul>
|
|
102
|
+
</div>
|
|
103
|
+
{/if}
|
|
104
|
+
</div>
|
|
103
105
|
|
|
104
106
|
<form
|
|
105
107
|
method="POST"
|
|
@@ -205,4 +207,8 @@
|
|
|
205
207
|
form {
|
|
206
208
|
max-width: var(--runeforge-form-max-width, 32rem);
|
|
207
209
|
}
|
|
210
|
+
|
|
211
|
+
.sticky-header {
|
|
212
|
+
top: var(--runeforge-sticky-header-top, 0);
|
|
213
|
+
}
|
|
208
214
|
</style>
|
|
@@ -34,6 +34,24 @@ export type SeedResolver = (instance: any) => unknown;
|
|
|
34
34
|
/** Embedded fields only: renders a short summary for one item in the list.
|
|
35
35
|
* Falls back to a dash-joined summary of the item's sub-field values. */
|
|
36
36
|
export type EmbeddedItemLabelResolver = (item: Record<string, unknown>) => string;
|
|
37
|
+
export type ValidateResolver = (value: unknown, record: Record<string, unknown>) => string | undefined;
|
|
38
|
+
/** Sets a field's value from within a `FieldButtonAction.run` callback —
|
|
39
|
+
* usually the same field the button sits next to, but any sibling attribute
|
|
40
|
+
* works too. */
|
|
41
|
+
export type FieldSetter = (attribute: string, value: unknown) => void;
|
|
42
|
+
/** A button rendered next to a field's label that runs entirely client-side
|
|
43
|
+
* — no submit, no request — instead of persisting a model attribute of its
|
|
44
|
+
* own. See `AttributeMetadata.actions`. */
|
|
45
|
+
export type FieldButtonAction = {
|
|
46
|
+
label: string;
|
|
47
|
+
icon?: any;
|
|
48
|
+
class?: string;
|
|
49
|
+
/** Hide the button in certain conditions. Re-evaluated live, same as `disabled`. */
|
|
50
|
+
condition?: (record: Record<string, unknown>) => boolean;
|
|
51
|
+
/** Receives the field's current value, the form's full draft record, and a
|
|
52
|
+
* setter to write back into this field (or a sibling one). */
|
|
53
|
+
run: (value: unknown, record: Record<string, unknown>, setField: FieldSetter) => void;
|
|
54
|
+
};
|
|
37
55
|
export type AttributeMetadata = {
|
|
38
56
|
label?: string;
|
|
39
57
|
type?: AttributeType;
|
|
@@ -91,6 +109,15 @@ export type AttributeMetadata = {
|
|
|
91
109
|
minLength?: number;
|
|
92
110
|
maxLength?: number;
|
|
93
111
|
pattern?: string;
|
|
112
|
+
/** Custom validation, run after this field's built-in rules
|
|
113
|
+
* (required/min/max/minLength/maxLength/pattern) pass, and only when none
|
|
114
|
+
* of them already failed. Receives the field's submitted value and the
|
|
115
|
+
* full draft record (including sibling fields currently in the form), so
|
|
116
|
+
* the same hook covers a lone-field rule and a cross-field rule alike —
|
|
117
|
+
* e.g. an `extension_date` that must be later than the record's
|
|
118
|
+
* `submission_date`. Return an error message, or `undefined` when the
|
|
119
|
+
* value is valid. */
|
|
120
|
+
validate?: ValidateResolver;
|
|
94
121
|
/** Textarea fields only: the HTML `rows` attribute, controlling height. */
|
|
95
122
|
rows?: number;
|
|
96
123
|
/** Embedded fields only: schema for each item added through the "+" modal. */
|
|
@@ -103,4 +130,8 @@ export type AttributeMetadata = {
|
|
|
103
130
|
* stacked on mobile — see the Field rows section. Only merges fields that
|
|
104
131
|
* are also in the same `groupedAs` bucket (or both ungrouped). */
|
|
105
132
|
row?: string;
|
|
133
|
+
/** Extra buttons rendered next to this field's label — e.g. a "Capitalize"
|
|
134
|
+
* button that transforms the field's own value client-side. See "Field
|
|
135
|
+
* actions" in the README. */
|
|
136
|
+
actions?: FieldButtonAction[];
|
|
106
137
|
};
|
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, SearchResolver, RequiredResolver, SelectOption } from './attribute.js';
|
|
3
|
+
import type { AttributeType, SearchResolver, RequiredResolver, SelectOption, FieldButtonAction } from './attribute.js';
|
|
4
4
|
import type { CellComponent, CellFormatter, SortableModule, TableQuery } from './table.js';
|
|
5
5
|
import type { XlsxModule } from '../components/table/export.js';
|
|
6
6
|
export type ColumnDefinition<T extends object = Record<string, unknown>> = {
|
|
@@ -46,6 +46,8 @@ export interface FieldDefinition<T extends object = Record<string, unknown>> {
|
|
|
46
46
|
minLength?: number;
|
|
47
47
|
maxLength?: number;
|
|
48
48
|
pattern?: string;
|
|
49
|
+
/** See `AttributeMetadata.validate`. */
|
|
50
|
+
validate?: (value: unknown, record: Record<string, unknown>) => string | undefined;
|
|
49
51
|
/** Textarea fields only: the HTML `rows` attribute, controlling height. */
|
|
50
52
|
rows?: number;
|
|
51
53
|
/** Embedded fields only: sub-field definitions for each item, built from
|
|
@@ -57,6 +59,8 @@ export interface FieldDefinition<T extends object = Record<string, unknown>> {
|
|
|
57
59
|
defaultExpanded?: boolean;
|
|
58
60
|
/** Fields sharing the same `row` string render side by side. */
|
|
59
61
|
row?: string;
|
|
62
|
+
/** See `AttributeMetadata.actions`. */
|
|
63
|
+
actions?: FieldButtonAction[];
|
|
60
64
|
}
|
|
61
65
|
/** A create-form button whose visibility, label and styling can all be
|
|
62
66
|
* overridden — `enabled` falls back to the button's own default (see
|