runeforge 0.0.23 → 0.0.25
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 +469 -21
- package/dist/components/Modal.svelte +26 -1
- package/dist/components/Modal.svelte.d.ts +9 -0
- package/dist/components/crud/EmbeddedField.svelte +144 -0
- package/dist/components/crud/EmbeddedField.svelte.d.ts +29 -0
- package/dist/components/crud/Field.svelte +5 -1
- package/dist/components/crud/utils/embedded.d.ts +4 -0
- package/dist/components/crud/utils/embedded.js +40 -0
- package/dist/components/crud/utils/resolution.js +5 -1
- package/dist/components/crud/utils/validation.js +16 -1
- package/dist/components/crud/views/Create.svelte +3 -13
- package/dist/components/crud/views/Update.svelte +3 -4
- package/dist/components/form/Select.svelte +55 -56
- package/dist/i18n/en.js +3 -0
- package/dist/i18n/es.js +3 -0
- package/dist/i18n/types.d.ts +3 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -0
- package/dist/types/attribute.d.ts +13 -2
- package/dist/types/attribute.js +1 -0
- package/dist/types/crud.d.ts +7 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -29,8 +29,21 @@ A SvelteKit toolkit that forges forms, tables, actions, and CRUD workflows from
|
|
|
29
29
|
- [2. Create the model](#2-create-the-model)
|
|
30
30
|
- [3. Set up the server](#3-set-up-the-server)
|
|
31
31
|
- [4. Add the page component](#4-add-the-page-component)
|
|
32
|
+
- [Field System](#field-system)
|
|
33
|
+
- [Attribute reference](#attribute-reference)
|
|
34
|
+
- [Validation](#validation)
|
|
35
|
+
- [Conditional fields](#conditional-fields)
|
|
36
|
+
- [Field grouping](#field-grouping)
|
|
37
|
+
- [Default values](#default-values)
|
|
38
|
+
- [Select options](#select-options)
|
|
39
|
+
- [Embedded fields (sub-documents)](#embedded-fields-sub-documents)
|
|
32
40
|
- [Components](#components)
|
|
33
41
|
- [GenericCRUD](#genericcrud)
|
|
42
|
+
- [Free-text search](#free-text-search)
|
|
43
|
+
- [Custom row actions](#custom-row-actions)
|
|
44
|
+
- [Custom bulk actions](#custom-bulk-actions)
|
|
45
|
+
- [Exporting data (CSV/XLSX)](#exporting-data-csvxlsx)
|
|
46
|
+
- [Server-side pagination, sorting & filtering](#server-side-pagination-sorting--filtering)
|
|
34
47
|
- [PaginatedTable](#paginatedtable)
|
|
35
48
|
- [Form Components](#form-components)
|
|
36
49
|
- [Shared Components](#shared-components)
|
|
@@ -70,14 +83,22 @@ Runeforge provides a set of composable, metadata-driven components for building
|
|
|
70
83
|
- Tailwind CSS 4
|
|
71
84
|
- DaisyUI 5
|
|
72
85
|
- Cally
|
|
86
|
+
- `xlsx` (optional, only if you enable Excel export)
|
|
73
87
|
|
|
74
88
|
---
|
|
75
89
|
|
|
76
90
|
## Key Features
|
|
77
91
|
|
|
78
92
|
- **GenericCRUD** — a single orchestrator component that wires together list, create, read, and update views from field and column definitions.
|
|
79
|
-
- **PaginatedTable** — a full-featured table with sorting, filtering, pagination, and row selection.
|
|
80
|
-
- **Field system** — declarative field definitions that drive both form rendering and display, supporting text, email, password, number, boolean, textarea, file, select, and
|
|
93
|
+
- **PaginatedTable** — a full-featured table with sorting, filtering, pagination, and row selection, usable either fully client-side or driven by a server-paginated backend.
|
|
94
|
+
- **Field system** — declarative field definitions that drive both form rendering and display, supporting text, email, password, number, boolean, textarea, file, select, datetime, and embedded (sub-document list) types.
|
|
95
|
+
- **Validation** — built-in `required`, `min`/`max`, `integer`, `minLength`/`maxLength`, and `pattern` rules, checked client-side before submit with consistent, translatable error messages.
|
|
96
|
+
- **Conditional fields & field grouping** — disable a field based on the current values of others in the same form, and visually group related fields under a titled `fieldset`.
|
|
97
|
+
- **Smart select fields** — options can be static, computed from page data, dependent on another field's value, or resolved live from the server as the user types.
|
|
98
|
+
- **Embedded fields** — model one-to-many sub-documents (e.g. line items, adjustments) as an in-form add/edit list backed by a single JSON field.
|
|
99
|
+
- **Custom row & bulk actions** — add entity-specific actions (in a panel or via redirect) alongside the built-in view/edit/delete, and bulk actions that operate on the current selection.
|
|
100
|
+
- **CSV/XLSX export** — one-click export of the current table view, with optional Excel support via the `xlsx` package.
|
|
101
|
+
- **Server-side pagination, sorting & filtering** — point `GenericCRUD`/`PaginatedTable` at a paginated envelope and it drives page/sort/filter state through the URL for you.
|
|
81
102
|
- **Pluggable icon system** — swap the default icon set or use the included Bootstrap Icons alternative via `setIconSet`.
|
|
82
103
|
- **Standalone components** — table, form, and navigation components can be used independently without the full CRUD orchestrator.
|
|
83
104
|
|
|
@@ -136,13 +157,15 @@ Responsive overrides work too:
|
|
|
136
157
|
| `--runeforge-breadcrumb-font-size` | `0.875rem` | Breadcrumb label text size |
|
|
137
158
|
| `--runeforge-breadcrumb-icon-size` | `1rem` | Breadcrumb icon width and height |
|
|
138
159
|
|
|
160
|
+
Modal sizing (see [Shared Components](#shared-components)) is set per-instance via props rather than a CSS variable.
|
|
161
|
+
|
|
139
162
|
---
|
|
140
163
|
|
|
141
164
|
## Configuration
|
|
142
165
|
|
|
143
166
|
Global settings are applied once in your root layout via `setConfig`. This avoids passing the same prop to every CRUD component.
|
|
144
167
|
|
|
145
|
-
```
|
|
168
|
+
```ts
|
|
146
169
|
<!-- +layout.svelte -->
|
|
147
170
|
<script>
|
|
148
171
|
import { setConfig } from 'runeforge';
|
|
@@ -204,7 +227,7 @@ export const articleMeta = {
|
|
|
204
227
|
} satisfies InterfaceMetadata<IArticle>;
|
|
205
228
|
```
|
|
206
229
|
|
|
207
|
-
Each metadata entry drives both the table column and the form field for that attribute. You can use `excludedFromList`, `excludedFromCreate`, `excludedFromRead`, or `excludedFromUpdate` to hide a field from specific views.
|
|
230
|
+
Each metadata entry drives both the table column and the form field for that attribute. You can use `excludedFromList`, `excludedFromCreate`, `excludedFromRead`, or `excludedFromUpdate` to hide a field from specific views. The [Field System](#field-system) section below covers the full set of options — validation, conditional/grouped fields, smart selects, and embedded sub-documents.
|
|
208
231
|
|
|
209
232
|
### 2. Create the model
|
|
210
233
|
|
|
@@ -307,7 +330,7 @@ The `load` function returns a single record when `?id=` is present (used by the
|
|
|
307
330
|
|
|
308
331
|
If your records use a different identifier field than `_id` (e.g. a plain `id`), pass the `idKey` prop:
|
|
309
332
|
|
|
310
|
-
```
|
|
333
|
+
```ts
|
|
311
334
|
<GenericCRUD idKey="id" ... />
|
|
312
335
|
```
|
|
313
336
|
|
|
@@ -315,6 +338,250 @@ This propagates to navigation URLs, form submissions, deletion calls, and the au
|
|
|
315
338
|
|
|
316
339
|
---
|
|
317
340
|
|
|
341
|
+
## Field System
|
|
342
|
+
|
|
343
|
+
Every entry in an `InterfaceMetadata<T>` object is an `AttributeMetadata` — a superset of what drives the table column, the form input, and its validation. This section documents every option beyond the basics shown above.
|
|
344
|
+
|
|
345
|
+
### Attribute reference
|
|
346
|
+
|
|
347
|
+
| Option | Type | Applies to | Description |
|
|
348
|
+
| --- | --- | --- | --- |
|
|
349
|
+
| `label` | `string` | all | Column header, form label, and the field name used in validation messages |
|
|
350
|
+
| `type` | `AttributeType` | all | `text` \| `email` \| `password` \| `number` \| `boolean` \| `textarea` \| `file` \| `select` \| `datetime` \| `embedded` |
|
|
351
|
+
| `required` | `boolean \| (record) => boolean` | all | Marks the label and enforces a non-empty value on submit. The function form re-evaluates against the other fields' current values — see [Validation](#validation) |
|
|
352
|
+
| `autocomplete` | `FullAutoFill` | text-like | Native `autocomplete` attribute |
|
|
353
|
+
| `placeholder` | `string` | text-like, select | Placeholder text |
|
|
354
|
+
| `default` | `value \| (data) => value` | all | Initial value on the create form — see [Default values](#default-values) |
|
|
355
|
+
| `min` / `max` | `number` | `number` | Numeric range validation |
|
|
356
|
+
| `integer` | `boolean` | `number` | Rejects non-whole numbers |
|
|
357
|
+
| `minLength` / `maxLength` | `number` | text-like | Character-count validation |
|
|
358
|
+
| `pattern` | `string` | text-like | Regex the value must match (`new RegExp(pattern)`) |
|
|
359
|
+
| `disabled` | `(record) => boolean` | all | Conditionally disables the input — see [Conditional fields](#conditional-fields) |
|
|
360
|
+
| `groupedAs` | `string` | all | Visually groups fields under a titled section — see [Field grouping](#field-grouping) |
|
|
361
|
+
| `options` | `SelectOption[] \| (data) => SelectOption[]` | `select` | Static or computed option list — see [Select options](#select-options) |
|
|
362
|
+
| `dependentOptions` | `(data, record) => SelectOption[]` | `select` | Options derived from other fields' current values |
|
|
363
|
+
| `search` | `(query) => Promise<SelectOption[]>` | `select` | Server-side option search as the user types |
|
|
364
|
+
| `seed` | `(instance) => unknown` | all | Overrides how the update form seeds this field from the loaded record |
|
|
365
|
+
| `fields` | `InterfaceMetadata<any>` | `embedded` | Sub-field schema for each item — see [Embedded fields](#embedded-fields-sub-documents) |
|
|
366
|
+
| `itemLabel` | `(item) => string` | `embedded` | Summary label for an item in the embedded list |
|
|
367
|
+
| `component` | `CellComponent` | all | Custom cell renderer — see [Custom Cell Components](#custom-cell-components) |
|
|
368
|
+
| `formatter` | `(data) => (value, row) => string` | all | Custom cell text — see [Formatters](#formatters) |
|
|
369
|
+
| `excludedFromList/Create/Read/Update` | `boolean` | all | Hides the field from that specific view |
|
|
370
|
+
| `sortable` / `filterable` | `boolean` | all | Table column controls |
|
|
371
|
+
|
|
372
|
+
### Validation
|
|
373
|
+
|
|
374
|
+
`required`, `min`/`max`, `integer`, `minLength`/`maxLength`, and `pattern` are checked client-side on submit, before the request hits your form action. Every failure is surfaced through the same field-level error UI (and the same translatable strings) regardless of which rule failed, so your server-side checks and Runeforge's checks look identical to the user.
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
code: {
|
|
378
|
+
label: 'Code',
|
|
379
|
+
type: AttributeType.text,
|
|
380
|
+
required: true,
|
|
381
|
+
pattern: '^[A-Z0-9]{3,8}$',
|
|
382
|
+
},
|
|
383
|
+
quantity: {
|
|
384
|
+
label: 'Quantity',
|
|
385
|
+
type: AttributeType.number,
|
|
386
|
+
min: 1,
|
|
387
|
+
max: 100,
|
|
388
|
+
integer: true,
|
|
389
|
+
},
|
|
390
|
+
notes: {
|
|
391
|
+
label: 'Notes',
|
|
392
|
+
type: AttributeType.textarea,
|
|
393
|
+
minLength: 3,
|
|
394
|
+
maxLength: 200,
|
|
395
|
+
},
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
`required` also accepts a function of the other fields' current values, for when whether a field is mandatory depends on the rest of the form rather than being fixed:
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
formula: {
|
|
402
|
+
label: 'Formula',
|
|
403
|
+
type: AttributeType.select,
|
|
404
|
+
options: [
|
|
405
|
+
{ value: 'benchmark', label: 'Benchmark' },
|
|
406
|
+
{ value: 'max', label: 'Max' },
|
|
407
|
+
],
|
|
408
|
+
},
|
|
409
|
+
quantity: {
|
|
410
|
+
label: 'Quantity',
|
|
411
|
+
type: AttributeType.number,
|
|
412
|
+
// Not required for the "benchmark" formula, mandatory for every other one.
|
|
413
|
+
required: (record) => record.formula !== 'benchmark',
|
|
414
|
+
},
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
The label's required marker and the submit-time check both re-evaluate the same way `disabled` does — see [Conditional fields](#conditional-fields).
|
|
418
|
+
|
|
419
|
+
> [!TIP]
|
|
420
|
+
> Client-side validation is a UX nicety, not a security boundary — always re-validate in your form actions.
|
|
421
|
+
|
|
422
|
+
### Conditional fields
|
|
423
|
+
|
|
424
|
+
`disabled` receives the form's current draft record (including in-progress edits to sibling fields) and returns whether the input should be disabled. It re-evaluates as the user types. `required` (see [Validation](#validation)) follows the same pattern for making a field mandatory only in certain conditions.
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
unlimited: {
|
|
428
|
+
label: 'Unlimited quantity',
|
|
429
|
+
type: AttributeType.boolean,
|
|
430
|
+
default: false,
|
|
431
|
+
},
|
|
432
|
+
quantity: {
|
|
433
|
+
label: 'Quantity',
|
|
434
|
+
type: AttributeType.number,
|
|
435
|
+
min: 1,
|
|
436
|
+
disabled: (record) => !!record.unlimited,
|
|
437
|
+
},
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
### Field grouping
|
|
441
|
+
|
|
442
|
+
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.
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
code: {
|
|
446
|
+
label: 'Code',
|
|
447
|
+
type: AttributeType.text,
|
|
448
|
+
groupedAs: 'Identification',
|
|
449
|
+
},
|
|
450
|
+
sku: {
|
|
451
|
+
label: 'SKU',
|
|
452
|
+
type: AttributeType.text,
|
|
453
|
+
groupedAs: 'Identification',
|
|
454
|
+
},
|
|
455
|
+
```
|
|
456
|
+
|
|
457
|
+
### Default values
|
|
458
|
+
|
|
459
|
+
`default` can be a plain value or a function of the page `data` object, evaluated once when the create form's fields are resolved — handy for defaulting a select to something derived from prefetched data.
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
published: {
|
|
463
|
+
label: 'Published',
|
|
464
|
+
type: AttributeType.boolean,
|
|
465
|
+
default: false,
|
|
466
|
+
},
|
|
467
|
+
assignedTo: {
|
|
468
|
+
label: 'Assigned to',
|
|
469
|
+
type: AttributeType.select,
|
|
470
|
+
options: (data: { users?: IUser[] }) => (data.users ?? []).map((u) => ({ value: u._id, label: u.name })),
|
|
471
|
+
default: (data: { currentUserId?: string }) => data.currentUserId ?? '',
|
|
472
|
+
},
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
### Select options
|
|
476
|
+
|
|
477
|
+
`select` fields support four ways of resolving their options, which can be combined as needed:
|
|
478
|
+
|
|
479
|
+
- **Static** — a plain `SelectOption[]` array.
|
|
480
|
+
- **Computed from page data** — a function of the page `data` object, useful for prefetched, related records (see `formatInstance` in [Formatters](#formatters) for rendering the resolved link back).
|
|
481
|
+
- **Dependent** — `dependentOptions(data, record)` recomputes the option list from the *current draft record*, so one field's choices can depend on another's value. If the currently selected value is no longer in the recomputed list, it's cleared automatically.
|
|
482
|
+
- **Server search** — `search(query)` is called (debounced) as the user types, instead of filtering the (possibly partial) `options` list in memory. Combine it with `options` to keep a usable list before the user starts typing.
|
|
483
|
+
|
|
484
|
+
```ts
|
|
485
|
+
// Dependent options: narrow "city" choices by the selected "country"
|
|
486
|
+
country: {
|
|
487
|
+
label: 'Country',
|
|
488
|
+
type: AttributeType.select,
|
|
489
|
+
options: [{ value: 'ar', label: 'Argentina' }, { value: 'uy', label: 'Uruguay' }],
|
|
490
|
+
},
|
|
491
|
+
city: {
|
|
492
|
+
label: 'City',
|
|
493
|
+
type: AttributeType.select,
|
|
494
|
+
dependentOptions: (data, record) => CITIES_BY_COUNTRY[record.country as string] ?? [],
|
|
495
|
+
},
|
|
496
|
+
|
|
497
|
+
// Server-aware search: fall back to a prefetched slice, but query the
|
|
498
|
+
// server for anything outside it.
|
|
499
|
+
owner: {
|
|
500
|
+
label: 'Owner',
|
|
501
|
+
type: AttributeType.select,
|
|
502
|
+
placeholder: 'Choose an owner',
|
|
503
|
+
options: (data: { owners?: IOwner[] }) => (data.owners ?? []).map((o) => ({ value: o.id, label: o.name })),
|
|
504
|
+
search: async (query) => {
|
|
505
|
+
const fd = new FormData();
|
|
506
|
+
fd.set('query', query);
|
|
507
|
+
const res = await fetch('?/searchOwners', { method: 'POST', body: fd });
|
|
508
|
+
const result = deserialize(await res.text());
|
|
509
|
+
if (result.type !== 'success') return [];
|
|
510
|
+
return (result.data.owners ?? []).map((o: IOwner) => ({ value: o.id, label: o.name }));
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
```ts
|
|
516
|
+
// +page.server.ts
|
|
517
|
+
export const actions: Actions = {
|
|
518
|
+
// ...create/update/delete
|
|
519
|
+
searchOwners: async ({ request }) => {
|
|
520
|
+
const data = await request.formData();
|
|
521
|
+
const query = String(data.get('query') ?? '');
|
|
522
|
+
return { owners: await Owner.find({ name: { $regex: query, $options: 'i' } }).limit(20).lean() };
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
```
|
|
526
|
+
|
|
527
|
+
### Embedded fields (sub-documents)
|
|
528
|
+
|
|
529
|
+
`AttributeType.embedded` models a one-to-many list of sub-records — line items, adjustments, contacts, anything you'd otherwise store as an array of objects — entirely within one form field. It renders as a list with an "+ Add" button; each item is added/edited through a modal built from the `fields` sub-schema, and removed with a single click. The whole list is serialized to JSON and submitted as a single hidden form field.
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
export interface IAdjustment {
|
|
533
|
+
kind: string;
|
|
534
|
+
amount: number;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
export interface IWidget {
|
|
538
|
+
_id: string;
|
|
539
|
+
name: string;
|
|
540
|
+
adjustments: IAdjustment[];
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export const widgetMeta = {
|
|
544
|
+
name: { label: 'Name', type: AttributeType.text, required: true },
|
|
545
|
+
adjustments: {
|
|
546
|
+
label: 'Adjustments',
|
|
547
|
+
type: AttributeType.embedded,
|
|
548
|
+
// Arrays of objects have no sensible plain-text table cell.
|
|
549
|
+
excludedFromList: true,
|
|
550
|
+
fields: {
|
|
551
|
+
kind: {
|
|
552
|
+
label: 'Kind',
|
|
553
|
+
type: AttributeType.select,
|
|
554
|
+
required: true,
|
|
555
|
+
options: [
|
|
556
|
+
{ value: 'bonus', label: 'Bonus' },
|
|
557
|
+
{ value: 'penalty', label: 'Penalty' },
|
|
558
|
+
],
|
|
559
|
+
},
|
|
560
|
+
amount: { label: 'Amount', type: AttributeType.number, required: true, min: 0 },
|
|
561
|
+
},
|
|
562
|
+
itemLabel: (item) => `${item.kind === 'bonus' ? 'Bonus' : 'Penalty'}: ${item.amount}`,
|
|
563
|
+
},
|
|
564
|
+
} satisfies InterfaceMetadata<IWidget>;
|
|
565
|
+
```
|
|
566
|
+
|
|
567
|
+
On the server, parse the field back out of `FormData` as JSON:
|
|
568
|
+
|
|
569
|
+
```ts
|
|
570
|
+
function widgetFromFormData(data: FormData) {
|
|
571
|
+
let adjustments: IAdjustment[];
|
|
572
|
+
try {
|
|
573
|
+
adjustments = JSON.parse(String(data.get('adjustments') ?? '[]'));
|
|
574
|
+
} catch {
|
|
575
|
+
adjustments = [];
|
|
576
|
+
}
|
|
577
|
+
return { name: String(data.get('name') ?? '').trim(), adjustments };
|
|
578
|
+
}
|
|
579
|
+
```
|
|
580
|
+
|
|
581
|
+
Sub-fields support the same validation rules as top-level fields (`required`, `min`/`max`, `pattern`, etc.), checked when an item is added or edited in the modal. `itemLabel` controls how each item summarizes itself in the list; without it, Runeforge joins the resolved display value of every sub-field with `·`.
|
|
582
|
+
|
|
583
|
+
---
|
|
584
|
+
|
|
318
585
|
## Components
|
|
319
586
|
|
|
320
587
|
### GenericCRUD
|
|
@@ -323,17 +590,183 @@ The main CRUD orchestrator. It manages navigation between List, Create, Read, an
|
|
|
323
590
|
|
|
324
591
|
Key props:
|
|
325
592
|
|
|
326
|
-
- `data` / `dataKey` — the record array and its primary key field
|
|
593
|
+
- `data` / `dataKey` — the record array (or [server-paginated envelope](#server-side-pagination-sorting--filtering)) and its primary key field
|
|
327
594
|
- `labelOne` / `labelMany` — singular and plural names for the entity
|
|
328
595
|
- `columns` — `ColumnDefinition[]` for the table view
|
|
329
596
|
- `fields` — `FieldDefinition[]` for form views
|
|
330
597
|
- `creation`, `update`, `read`, `deletion` — `ActionConfiguration` objects that define handlers and permissions for each operation. Set `confirm: true` on `deletion` to show a confirmation dialog before any delete (single row or batch)
|
|
598
|
+
- `actions` — `CustomAction[]`, extra per-row actions — see [Custom row actions](#custom-row-actions)
|
|
599
|
+
- `customBulkActions` — `CustomBulkAction[]`, extra actions on the current selection — see [Custom bulk actions](#custom-bulk-actions)
|
|
600
|
+
- `search` — `SearchConfiguration`, shows a free-text search box — see [Free-text search](#free-text-search)
|
|
601
|
+
- `enableExport`, `onExport`, `xlsx` — CSV/Excel export — see [Exporting data](#exporting-data-csvxlsx)
|
|
331
602
|
|
|
332
|
-
|
|
603
|
+
#### Free-text search
|
|
604
|
+
|
|
605
|
+
Passing `search` renders a debounced search box in the header. Typing updates a URL search param (`?search=...` by default), resets pagination and any open create/read/edit view, and leaves interpreting the term entirely to your `load` function — it's the same mechanism server-side pagination uses, so it composes naturally with it.
|
|
333
606
|
|
|
334
|
-
|
|
607
|
+
```ts
|
|
608
|
+
<GenericCRUD
|
|
609
|
+
...
|
|
610
|
+
search={{ param: 'q', placeholder: 'Search tasks...', debounceMs: 300 }}
|
|
611
|
+
/>
|
|
612
|
+
```
|
|
613
|
+
|
|
614
|
+
| Option | Default | Description |
|
|
615
|
+
| --- | --- | --- |
|
|
616
|
+
| `param` | `'search'` | Query-string parameter name |
|
|
617
|
+
| `placeholder` | `strings.searchPlaceholder` | Input placeholder |
|
|
618
|
+
| `debounceMs` | `300` | Delay before the URL updates |
|
|
619
|
+
|
|
620
|
+
#### Custom row actions
|
|
621
|
+
|
|
622
|
+
`actions` adds entries to the per-row action menu, alongside the built-in view/edit/delete. Each `CustomAction` resolves in one of two ways — provide exactly one of `view` or `href`:
|
|
623
|
+
|
|
624
|
+
- `href(item)` — plain navigation, e.g. deep-linking into another CRUD's filtered list.
|
|
625
|
+
- `view` — a Svelte component of your own that `GenericCRUD` mounts directly (no wrapper) when the action runs. Since you own the whole component, you decide how it presents itself — typically as a modal built on the exported `Modal` component, sized however that action needs via `Modal`'s `class`/`width`/`maxWidth`/`height`/`maxHeight` props (see [Shared Components](#shared-components)).
|
|
626
|
+
|
|
627
|
+
```ts
|
|
628
|
+
import ArchiveIcon from './icons/Archive.svelte';
|
|
629
|
+
import ArchiveForm from './ArchiveForm.svelte';
|
|
630
|
+
|
|
631
|
+
const actions: CustomAction<IWidget>[] = [
|
|
632
|
+
{
|
|
633
|
+
label: 'Archive',
|
|
634
|
+
icon: ArchiveIcon,
|
|
635
|
+
endpoint: '?/archive',
|
|
636
|
+
view: ArchiveForm,
|
|
637
|
+
condition: (item) => !item.archived,
|
|
638
|
+
},
|
|
639
|
+
{
|
|
640
|
+
label: 'Open in new tab',
|
|
641
|
+
icon: ExternalLinkIcon,
|
|
642
|
+
href: (item) => `/widgets/${item._id}`,
|
|
643
|
+
},
|
|
644
|
+
];
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
```svelte
|
|
648
|
+
<GenericCRUD ... {actions} />
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
A `view` component receives `instance`, `label`, `endpoint`, `serverError`, `onCancel`, and `onSuccess` — the same shape Create/Update use internally — so it can reuse `enhance`-based form submission while rendering as a parametrized modal:
|
|
335
652
|
|
|
336
653
|
```svelte
|
|
654
|
+
<!-- ArchiveForm.svelte -->
|
|
655
|
+
<script lang="ts">
|
|
656
|
+
import { enhance } from '$app/forms';
|
|
657
|
+
import { Modal } from 'runeforge';
|
|
658
|
+
|
|
659
|
+
let { instance, label, endpoint, serverError, onCancel, onSuccess } = $props();
|
|
660
|
+
</script>
|
|
661
|
+
|
|
662
|
+
<Modal title={label} onClose={onCancel} maxWidth="28rem">
|
|
663
|
+
<form
|
|
664
|
+
method="POST"
|
|
665
|
+
action={endpoint}
|
|
666
|
+
use:enhance={() => async ({ result, update }) => {
|
|
667
|
+
await update({ reset: false });
|
|
668
|
+
if (result.type === 'success') onSuccess();
|
|
669
|
+
}}
|
|
670
|
+
>
|
|
671
|
+
<input type="hidden" name="id" value={instance._id} />
|
|
672
|
+
{#if serverError}<p class="text-error">{serverError}</p>{/if}
|
|
673
|
+
<div class="flex justify-end gap-2 mt-4">
|
|
674
|
+
<button type="button" onclick={onCancel}>Cancel</button>
|
|
675
|
+
<button type="submit">{label}</button>
|
|
676
|
+
</div>
|
|
677
|
+
</form>
|
|
678
|
+
</Modal>
|
|
679
|
+
```
|
|
680
|
+
|
|
681
|
+
#### Custom bulk actions
|
|
682
|
+
|
|
683
|
+
`customBulkActions` adds buttons next to the built-in Delete button in the header, operating on the current row selection. Each one is disabled until at least one row is selected, and (like deletion) can require confirmation.
|
|
684
|
+
|
|
685
|
+
```ts
|
|
686
|
+
<GenericCRUD
|
|
687
|
+
...
|
|
688
|
+
customBulkActions={[
|
|
689
|
+
{ label: 'Complete', icon: CheckIcon, endpoint: '?/complete' },
|
|
690
|
+
{ label: 'Mark pending', icon: UndoIcon, endpoint: '?/incomplete', variant: 'error', confirm: true },
|
|
691
|
+
]}
|
|
692
|
+
/>
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
`endpoint` is called once per selected row (`POST` with an `id` field), then the list is refreshed. `variant` matches DaisyUI's `btn-*` modifiers (`'primary'`, `'error'`, `'ghost'`, ...). `condition(selectedItems)` can hide the action entirely based on the current selection.
|
|
696
|
+
|
|
697
|
+
#### Exporting data (CSV/XLSX)
|
|
698
|
+
|
|
699
|
+
`enableExport` adds an export button to the header offering CSV (always) and Excel (when an `xlsx` module is supplied). Runeforge never bundles `xlsx` itself — install it separately and pass the resolved module in, so the dependency stays fully optional:
|
|
700
|
+
|
|
701
|
+
```bash
|
|
702
|
+
pnpm add xlsx
|
|
703
|
+
```
|
|
704
|
+
|
|
705
|
+
```ts
|
|
706
|
+
<script>
|
|
707
|
+
import { GenericCRUD } from 'runeforge';
|
|
708
|
+
import * as xlsx from 'xlsx';
|
|
709
|
+
</script>
|
|
710
|
+
|
|
711
|
+
<GenericCRUD ... enableExport {xlsx} />
|
|
712
|
+
```
|
|
713
|
+
|
|
714
|
+
In client-pagination mode, export includes every row currently matching the table's filters (not just the visible page). In [server-pagination mode](#server-side-pagination-sorting--filtering), pass `onExport` to fetch the full, unpaginated result set for the current query — without it, export falls back to just the currently loaded page:
|
|
715
|
+
|
|
716
|
+
```ts
|
|
717
|
+
<GenericCRUD
|
|
718
|
+
...
|
|
719
|
+
enableExport
|
|
720
|
+
onExport={async (query) => {
|
|
721
|
+
const params = new URLSearchParams();
|
|
722
|
+
if (query.ordering) params.set('ordering', query.ordering);
|
|
723
|
+
// ...translate query.filters into your API's params
|
|
724
|
+
const res = await fetch(`/api/widgets/export?${params}`);
|
|
725
|
+
return res.json();
|
|
726
|
+
}}
|
|
727
|
+
/>
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
#### Server-side pagination, sorting & filtering
|
|
731
|
+
|
|
732
|
+
By default, `GenericCRUD` and `PaginatedTable` paginate, sort, and filter the full `data` array in the browser. For large datasets, return a `PaginatedEnvelope<T>` from your `load` function instead — `{ results, count, page, pageSize }` — and Runeforge switches to server mode automatically: it drives `page`, `ordering`, and per-column filter values through the URL, and expects your `load` function to read them back.
|
|
733
|
+
|
|
734
|
+
```ts
|
|
735
|
+
// +page.server.ts
|
|
736
|
+
export const load: PageServerLoad = ({ url }) => {
|
|
737
|
+
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
|
|
738
|
+
const ordering = url.searchParams.get('ordering');
|
|
739
|
+
const name = url.searchParams.get('name'); // per-column text filter
|
|
740
|
+
|
|
741
|
+
let rows = [...allWidgets];
|
|
742
|
+
if (name) rows = rows.filter((w) => w.name.toLowerCase().includes(name.toLowerCase()));
|
|
743
|
+
if (ordering) {
|
|
744
|
+
const desc = ordering.startsWith('-');
|
|
745
|
+
const field = desc ? ordering.slice(1) : ordering;
|
|
746
|
+
rows = [...rows].sort((a, b) => (desc ? -1 : 1) * compare(a[field], b[field]));
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const pageSize = 20;
|
|
750
|
+
const start = (page - 1) * pageSize;
|
|
751
|
+
return { widgets: { results: rows.slice(start, start + pageSize), count: rows.length, page, pageSize } };
|
|
752
|
+
};
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
```ts
|
|
756
|
+
<GenericCRUD
|
|
757
|
+
...
|
|
758
|
+
data={{ widgets: data.widgets }}
|
|
759
|
+
dataKey="widgets"
|
|
760
|
+
/>
|
|
761
|
+
```
|
|
762
|
+
|
|
763
|
+
No other prop changes are needed — column sorting/filtering UI, the paginator, and (with `onExport`) export all keep working the same way, just backed by the server instead of the in-memory array. Boolean-column filters send comma-separated values (`?active=true,false`); date-range filters send `<attribute>_from`/`<attribute>_to`.
|
|
764
|
+
|
|
765
|
+
### PaginatedTable
|
|
766
|
+
|
|
767
|
+
A standalone table component with built-in sort, filter, and pagination — the same engine `GenericCRUD` uses internally.
|
|
768
|
+
|
|
769
|
+
```ts
|
|
337
770
|
<script>
|
|
338
771
|
import { PaginatedTable } from 'runeforge';
|
|
339
772
|
</script>
|
|
@@ -341,7 +774,7 @@ A standalone table component with built-in sort, filter, and pagination.
|
|
|
341
774
|
<PaginatedTable {data} {columns} />
|
|
342
775
|
```
|
|
343
776
|
|
|
344
|
-
Sort and filter state can be managed externally via the exported `SortState` and `FilterState` classes.
|
|
777
|
+
Sort and filter state can be managed externally via the exported `SortState` and `FilterState` classes. Pass a `pagination` prop (`ServerPagination`) plus `onPaginationChange` to opt into the same [server-driven mode](#server-side-pagination-sorting--filtering) `GenericCRUD` uses. `bind:visibleRows` and `bind:query` expose the currently filtered/sorted rows and query snapshot, useful for building your own export UI on top of the raw table.
|
|
345
778
|
|
|
346
779
|
### Form Components
|
|
347
780
|
|
|
@@ -349,13 +782,13 @@ Individual form primitives styled with DaisyUI:
|
|
|
349
782
|
|
|
350
783
|
- `Button` — styled action button
|
|
351
784
|
- `Label` — form label with optional required marker
|
|
352
|
-
- `Select` — dropdown with option group support
|
|
353
|
-
- `PasswordInput` — password field with show/hide toggle
|
|
785
|
+
- `Select` — dropdown with option group support, optional in-memory filtering, and an optional `search` prop for server-resolved options (see [Select options](#select-options))
|
|
786
|
+
- `PasswordInput` — password field with show/hide toggle; `labelClass`, `inputClass`, and `buttonClass` props let you restyle the wrapper, input, and toggle button independently
|
|
354
787
|
|
|
355
788
|
### Shared Components
|
|
356
789
|
|
|
357
790
|
- `Avatar` — user avatar display
|
|
358
|
-
- `Modal` — DaisyUI modal wrapper
|
|
791
|
+
- `Modal` — DaisyUI modal wrapper. Size it with Tailwind utility classes via `class` (e.g. `class="max-w-4xl"`), or with explicit `width`/`maxWidth`/`height`/`maxHeight` CSS lengths, which are applied as inline styles and take priority over `class`
|
|
359
792
|
- `Breadcrumbs` — navigation breadcrumb trail
|
|
360
793
|
- `IconRenderer` — renders icons from the active icon set
|
|
361
794
|
|
|
@@ -369,7 +802,7 @@ Formatters are functions you attach to a metadata field to control how its value
|
|
|
369
802
|
|
|
370
803
|
Converts a boolean to a readable label.
|
|
371
804
|
|
|
372
|
-
> [!
|
|
805
|
+
> [!NOTE]
|
|
373
806
|
> Defaults to `Sí` / `No` because this was created at Argentina papá! 🇦🇷.
|
|
374
807
|
|
|
375
808
|
```ts
|
|
@@ -388,7 +821,7 @@ isActive: {
|
|
|
388
821
|
|
|
389
822
|
Formats a `Date` value using the tokens `dd`, `mm`, `YYYY`, `HH`, `MM`, `ss`.
|
|
390
823
|
|
|
391
|
-
> [!
|
|
824
|
+
> [!NOTE]
|
|
392
825
|
> Defaults to `'dd/mm/YYYY HH:MM'`.
|
|
393
826
|
|
|
394
827
|
```ts
|
|
@@ -462,7 +895,7 @@ interface CellProps<T extends object, V> {
|
|
|
462
895
|
|
|
463
896
|
The following renders a user photo with a fallback to initials, using data from sibling fields on the row:
|
|
464
897
|
|
|
465
|
-
```
|
|
898
|
+
```ts
|
|
466
899
|
<!-- components/UserAvatar.svelte -->
|
|
467
900
|
<script lang="ts">
|
|
468
901
|
import { Avatar } from 'runeforge';
|
|
@@ -500,7 +933,7 @@ export const userMeta = {
|
|
|
500
933
|
|
|
501
934
|
A simpler case — render a Bootstrap icon by name stored as a plain string:
|
|
502
935
|
|
|
503
|
-
```
|
|
936
|
+
```ts
|
|
504
937
|
<!-- components/IconCell.svelte -->
|
|
505
938
|
<script lang="ts">
|
|
506
939
|
import { IconRenderer } from 'runeforge';
|
|
@@ -538,7 +971,7 @@ All UI strings default to **Spanish** (Argentina). To switch to another language
|
|
|
538
971
|
|
|
539
972
|
### Switch to English
|
|
540
973
|
|
|
541
|
-
```
|
|
974
|
+
```ts
|
|
542
975
|
<!-- +layout.svelte -->
|
|
543
976
|
<script>
|
|
544
977
|
import { setStrings, en } from 'runeforge';
|
|
@@ -549,7 +982,7 @@ All UI strings default to **Spanish** (Argentina). To switch to another language
|
|
|
549
982
|
|
|
550
983
|
### Override individual strings
|
|
551
984
|
|
|
552
|
-
```
|
|
985
|
+
```ts
|
|
553
986
|
<script>
|
|
554
987
|
import { setStrings } from 'runeforge';
|
|
555
988
|
|
|
@@ -576,21 +1009,36 @@ All UI strings default to **Spanish** (Argentina). To switch to another language
|
|
|
576
1009
|
| `next` | `string` | `Siguiente` |
|
|
577
1010
|
| `selectPlaceholder` | `string` | `Seleccioná una opción` |
|
|
578
1011
|
| `selectSearch` | `string` | `Buscar...` |
|
|
1012
|
+
| `selectSearching` | `string` | `Buscando...` |
|
|
579
1013
|
| `selectNoResults` | `string` | `Sin resultados` |
|
|
580
1014
|
| `view` | `string` | `Ver` |
|
|
581
1015
|
| `edit` | `string` | `Editar` |
|
|
582
1016
|
| `delete` | `string` | `Eliminar` |
|
|
583
1017
|
| `create` | `string` | `Crear` |
|
|
1018
|
+
| `searchPlaceholder` | `string` | `Buscar...` |
|
|
1019
|
+
| `export` | `string` | `Exportar` |
|
|
1020
|
+
| `exportCsv` | `string` | `Exportar a CSV` |
|
|
1021
|
+
| `exportExcel` | `string` | `Exportar a Excel` |
|
|
584
1022
|
| `save` | `string` | `Guardar` |
|
|
585
1023
|
| `saveAndContinue` | `string` | `Guardar y continuar` |
|
|
586
1024
|
| `cancel` | `string` | `Cancelar` |
|
|
587
1025
|
| `back` | `string` | `Volver` |
|
|
1026
|
+
| `add` | `string` | `Agregar` |
|
|
1027
|
+
| `remove` | `string` | `Quitar` |
|
|
1028
|
+
| `noItems` | `string` | `Sin elementos agregados` |
|
|
588
1029
|
| `confirm` | `string` | `Confirmar` |
|
|
589
|
-
| `deleteConfirm` | `(count) => string` | `¿Seguro que querés eliminar 3 elementos?` |
|
|
1030
|
+
| `deleteConfirm` | `(count, actionLabel) => string` | `¿Seguro que querés eliminar 3 elementos?` |
|
|
590
1031
|
| `required` | `(field) => string` | `Título es requerido` |
|
|
1032
|
+
| `invalidNumber` | `(field) => string` | `Cantidad debe ser un número` |
|
|
1033
|
+
| `integer` | `(field) => string` | `Cantidad debe ser un número entero` |
|
|
1034
|
+
| `min` | `(field, min) => string` | `Cantidad debe ser mayor o igual a 1` |
|
|
1035
|
+
| `max` | `(field, max) => string` | `Cantidad debe ser menor o igual a 100` |
|
|
1036
|
+
| `minLength` | `(field, min) => string` | `Notas debe tener al menos 3 caracteres` |
|
|
1037
|
+
| `maxLength` | `(field, max) => string` | `Notas debe tener como máximo 200 caracteres` |
|
|
1038
|
+
| `pattern` | `(field) => string` | `Código tiene un formato inválido` |
|
|
591
1039
|
| `serverError` | `string` | `Error inesperado del servidor.` |
|
|
592
1040
|
|
|
593
|
-
> [!
|
|
1041
|
+
> [!NOTE]
|
|
594
1042
|
> Defaults to Spanish because this was built in Argentina! 🇦🇷
|
|
595
1043
|
|
|
596
1044
|
### Bundled locales
|
|
@@ -606,7 +1054,7 @@ All UI strings default to **Spanish** (Argentina). To switch to another language
|
|
|
606
1054
|
|
|
607
1055
|
Runeforge ships with a default icon set. To use Bootstrap Icons instead:
|
|
608
1056
|
|
|
609
|
-
```
|
|
1057
|
+
```ts
|
|
610
1058
|
<script>
|
|
611
1059
|
import { setIconSet, bootstrapIcons } from 'runeforge';
|
|
612
1060
|
|
|
@@ -6,15 +6,40 @@
|
|
|
6
6
|
title = '',
|
|
7
7
|
onClose,
|
|
8
8
|
children,
|
|
9
|
+
class: additionalClass,
|
|
10
|
+
width,
|
|
11
|
+
maxWidth,
|
|
12
|
+
height,
|
|
13
|
+
maxHeight,
|
|
9
14
|
}: {
|
|
10
15
|
title?: string;
|
|
11
16
|
onClose?: () => void;
|
|
12
17
|
children: Snippet;
|
|
18
|
+
/** Extra classes merged onto the modal box, e.g. Tailwind size utilities
|
|
19
|
+
* like `max-w-4xl` or `w-11/12`. */
|
|
20
|
+
class?: string;
|
|
21
|
+
/** Explicit size overrides (any valid CSS length, e.g. '600px', '90vw').
|
|
22
|
+
* Applied as inline styles, so they take priority over `class` utilities. */
|
|
23
|
+
width?: string;
|
|
24
|
+
maxWidth?: string;
|
|
25
|
+
height?: string;
|
|
26
|
+
maxHeight?: string;
|
|
13
27
|
} = $props();
|
|
28
|
+
|
|
29
|
+
const boxStyle = $derived(
|
|
30
|
+
[
|
|
31
|
+
width ? `width:${width}` : '',
|
|
32
|
+
maxWidth ? `max-width:${maxWidth}` : '',
|
|
33
|
+
height ? `height:${height}` : '',
|
|
34
|
+
maxHeight ? `max-height:${maxHeight}` : '',
|
|
35
|
+
]
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.join(';')
|
|
38
|
+
);
|
|
14
39
|
</script>
|
|
15
40
|
|
|
16
41
|
<dialog class="modal" open>
|
|
17
|
-
<div class=
|
|
42
|
+
<div class={['modal-box', additionalClass]} style={boxStyle}>
|
|
18
43
|
<div class="flex items-center justify-between gap-4">
|
|
19
44
|
<h3 class="text-lg font-bold">{title}</h3>
|
|
20
45
|
{#if onClose}
|