runeforge 0.0.51 → 0.0.53

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.
@@ -8,6 +8,7 @@
8
8
  import { AUTO_EXCLUDED } from './utils/constants.js';
9
9
  import {
10
10
  resolveFormatter,
11
+ truncateFormatter,
11
12
  inferType,
12
13
  buildFieldDefinitions
13
14
  } from './utils/resolution.js';
@@ -139,10 +140,18 @@
139
140
  activeBulkAction = { action, items };
140
141
  }
141
142
 
143
+ let duplicateSeed = $state<Record<string, unknown> | undefined>(undefined);
144
+
142
145
  async function navList() {
146
+ duplicateSeed = undefined;
143
147
  await goto(lastListState.search || '?');
144
148
  }
145
149
  async function navCreate() {
150
+ duplicateSeed = undefined;
151
+ await goto('?view=create');
152
+ }
153
+ async function navDuplicate(record: Record<string, unknown>) {
154
+ duplicateSeed = record;
146
155
  await goto('?view=create');
147
156
  }
148
157
  async function navRead(item: T) {
@@ -190,7 +199,7 @@
190
199
  // embedded column without an explicit formatter falls back to
191
200
  // joining each item's label (itemLabel, or the same
192
201
  // sub-field-joining summary the embedded form list uses).
193
- const formatter =
202
+ const resolvedFormatter =
194
203
  resolveFormatter(m, data) ??
195
204
  (embeddedFields
196
205
  ? (value: unknown) =>
@@ -204,6 +213,10 @@
204
213
  .join(', ')
205
214
  : ''
206
215
  : undefined);
216
+ const formatter =
217
+ m.truncateUpTo != null
218
+ ? truncateFormatter(resolvedFormatter, m.truncateUpTo)
219
+ : resolvedFormatter;
207
220
  return {
208
221
  attribute: k as keyof T & string,
209
222
  title: m.label ?? k,
@@ -337,6 +350,7 @@
337
350
  fields={resolvedFields}
338
351
  {creation}
339
352
  {serverError}
353
+ seed={duplicateSeed}
340
354
  onCancel={navList}
341
355
  onSuccess={navList}
342
356
  />
@@ -364,6 +378,7 @@
364
378
  onCancel={navList}
365
379
  onSuccess={navList}
366
380
  onContinue={navContinueEdit}
381
+ onDuplicate={navDuplicate}
367
382
  />
368
383
  {:else}
369
384
  <List
@@ -1,5 +1,7 @@
1
1
  import type { FieldDefinition } from '../../../types/crud.js';
2
2
  export declare function seedField<T extends object = Record<string, unknown>>(f: FieldDefinition<T>, raw: unknown): unknown;
3
3
  export declare function emptyRecord<T extends object = Record<string, unknown>>(fields: FieldDefinition<T>[]): Record<string, unknown>;
4
+ export declare function seedRecord<T extends object = Record<string, unknown>>(fields: FieldDefinition<T>[], source: Record<string, unknown>): Record<string, unknown>;
5
+ export declare function applyDuplicateOmit<T extends object = Record<string, unknown>>(fields: FieldDefinition<T>[], record: Record<string, unknown>, omit?: string[]): Record<string, unknown>;
4
6
  export declare function formatEmbeddedFieldValue<T extends object = Record<string, unknown>>(f: FieldDefinition<T>, raw: unknown): string;
5
7
  export declare function defaultItemLabel<T extends object = Record<string, unknown>>(fields: FieldDefinition<T>[], item: Record<string, unknown>): string;
@@ -26,6 +26,33 @@ export function seedField(f, raw) {
26
26
  export function emptyRecord(fields) {
27
27
  return Object.fromEntries(fields.map((f) => [f.attribute, seedField(f, f.default)]));
28
28
  }
29
+ // Shared by Update.svelte (loading the instance being edited) and Create.svelte
30
+ // (pre-filling a new record from a duplicated instance): builds a draft record
31
+ // from an arbitrary source object, running each field's `seed` resolver
32
+ // (falling back to the source's raw value) through the same seedField
33
+ // normalization as emptyRecord.
34
+ export function seedRecord(fields, source) {
35
+ const seeded = { ...source };
36
+ for (const f of fields) {
37
+ const raw = f.seed ? f.seed(source) : source[f.attribute];
38
+ seeded[f.attribute] = seedField(f, raw);
39
+ }
40
+ return seeded;
41
+ }
42
+ // Shared by Update.svelte and Create.svelte's "Duplicate" handling: returns a
43
+ // copy of the just-saved record with `omit`-listed attributes reset to their
44
+ // field default, so the new draft doesn't inherit values that only made sense
45
+ // for the original instance (e.g. a publication date).
46
+ export function applyDuplicateOmit(fields, record, omit = []) {
47
+ if (!omit.length)
48
+ return record;
49
+ const result = { ...record };
50
+ for (const f of fields) {
51
+ if (omit.includes(f.attribute))
52
+ result[f.attribute] = seedField(f, f.default);
53
+ }
54
+ return result;
55
+ }
29
56
  // Shared by defaultItemLabel and the CSV/XLSX export column expansion: renders
30
57
  // one sub-field's raw stored value as display text, resolving a select's
31
58
  // option label instead of its stored value. Booleans are left to the caller
@@ -1,4 +1,3 @@
1
1
  export declare const formatBoolean: (trueLabel?: string, falseLabel?: string) => () => (value: boolean) => string;
2
2
  export declare const formatDatetime: (format?: string, timeZone?: string) => (() => (value: Date) => string);
3
- export declare function formatTruncateTextUpTo(maxLength: number): () => (value: string) => string;
4
3
  export declare function formatInstance<T extends Record<string, unknown>>(attribute: keyof T & string, instances: T[], urlPath: string, idKey?: keyof T & string): (value: unknown) => string;
@@ -52,12 +52,6 @@ export const formatDatetime = (format = 'dd/mm/YYYY HH:MM', timeZone) => {
52
52
  };
53
53
  return () => fmt;
54
54
  };
55
- export function formatTruncateTextUpTo(maxLength) {
56
- return () => (value) => {
57
- const str = String(value ?? '');
58
- return str.length > maxLength ? str.slice(0, maxLength) + '…' : str;
59
- };
60
- }
61
55
  function escapeHtml(str) {
62
56
  return str
63
57
  .replace(/&/g, '&amp;')
@@ -3,5 +3,6 @@ import type { FieldDefinition } from '../../../types/crud.js';
3
3
  export declare function resolveOptions(m: AttributeMetadata, d: unknown): SelectOption[] | undefined;
4
4
  export declare function resolveDefault(m: AttributeMetadata, d: unknown): unknown;
5
5
  export declare function resolveFormatter(m: AttributeMetadata, d: unknown): import("../../../index.ts").CellFormatter<any, any> | undefined;
6
+ export declare function truncateFormatter<T extends object = Record<string, unknown>, V = unknown>(formatter: ((value: V, row: T) => string) | undefined, maxLength: number): (value: V, row: T) => string;
6
7
  export declare function inferType(key: string, value: unknown): AttributeType;
7
8
  export declare function buildFieldDefinitions<T extends object = Record<string, unknown>>(meta: Partial<Record<string, AttributeMetadata>>, data: unknown, excludedFlag: 'excludedFromCreate' | 'excludedFromRead' | 'excludedFromUpdate' | 'excludedFromList', excluded: Set<string>): FieldDefinition<T>[];
@@ -9,6 +9,17 @@ export function resolveDefault(m, d) {
9
9
  export function resolveFormatter(m, d) {
10
10
  return m.formatter?.(d);
11
11
  }
12
+ // List column only (GenericCRUD's resolvedColumns): wraps `formatter` (or,
13
+ // absent one, the same raw String(value) TableBody falls back to) so a column
14
+ // that needs to stay narrow can truncate its cell without that truncation
15
+ // leaking into the Read view or the Create/Update forms, which call
16
+ // `formatter` directly and never go through this wrapper.
17
+ export function truncateFormatter(formatter, maxLength) {
18
+ return (value, row) => {
19
+ const str = formatter ? formatter(value, row) : String(value ?? '');
20
+ return str.length > maxLength ? str.slice(0, maxLength) + '…' : str;
21
+ };
22
+ }
12
23
  export function inferType(key, value) {
13
24
  if (typeof value === 'boolean')
14
25
  return 'boolean';
@@ -8,7 +8,7 @@
8
8
  import { defaultIconSet } from '../../../icons/sets/default.js';
9
9
  import { validateAll } from '../utils/validation.js';
10
10
  import { groupFields } from '../utils/grouping.js';
11
- import { emptyRecord } from '../utils/embedded.js';
11
+ import { applyDuplicateOmit, emptyRecord, seedRecord } from '../utils/embedded.js';
12
12
  import type { ActionConfiguration, FieldDefinition } from '../../../types/crud.js';
13
13
  import { getStrings } from '../../../i18n/context.js';
14
14
 
@@ -21,6 +21,7 @@
21
21
  fields = [] as FieldDefinition<T>[],
22
22
  creation = {} as ActionConfiguration<T>,
23
23
  serverError = '',
24
+ seed = undefined as Record<string, unknown> | undefined,
24
25
  onCancel,
25
26
  onSuccess,
26
27
  }: {
@@ -31,6 +32,9 @@
31
32
  fields?: FieldDefinition<T>[];
32
33
  creation?: ActionConfiguration<T>;
33
34
  serverError?: string;
35
+ /** Pre-fills the form from another instance's values — used when the
36
+ * create view is reached via an Update form's "Duplicate" button. */
37
+ seed?: Record<string, unknown>;
34
38
  onCancel?: () => void;
35
39
  onSuccess?: () => void;
36
40
  } = $props();
@@ -51,7 +55,9 @@
51
55
  successTimeout = setTimeout(() => (successMessage = ''), 4000);
52
56
  }
53
57
 
54
- let record = $state<Record<string, unknown>>(untrack(() => emptyRecord(fields)));
58
+ let record = $state<Record<string, unknown>>(
59
+ untrack(() => (seed ? seedRecord(fields, seed) : emptyRecord(fields)))
60
+ );
55
61
 
56
62
  const groups = $derived(groupFields(fields));
57
63
  const hasFileField = $derived(fields.some((f) => f.type === 'file'));
@@ -130,6 +136,7 @@
130
136
  fieldErrors = {};
131
137
  internalError = '';
132
138
  flashSuccess();
139
+ record = applyDuplicateOmit(fields, record, creation.duplication?.omit);
133
140
  } else {
134
141
  onSuccess?.();
135
142
  }
@@ -7,6 +7,9 @@ declare function $$render<T extends object = Record<string, unknown>>(): {
7
7
  fields?: FieldDefinition<T>[];
8
8
  creation?: ActionConfiguration<T>;
9
9
  serverError?: string;
10
+ /** Pre-fills the form from another instance's values — used when the
11
+ * create view is reached via an Update form's "Duplicate" button. */
12
+ seed?: Record<string, unknown>;
10
13
  onCancel?: () => void;
11
14
  onSuccess?: () => void;
12
15
  };
@@ -8,7 +8,7 @@
8
8
  import { defaultIconSet } from '../../../icons/sets/default.js';
9
9
  import { validateAll } from '../utils/validation.js';
10
10
  import { groupFields } from '../utils/grouping.js';
11
- import { seedField } from '../utils/embedded.js';
11
+ import { applyDuplicateOmit, seedRecord } from '../utils/embedded.js';
12
12
  import type { ActionConfiguration, FieldDefinition } from '../../../types/crud.js';
13
13
  import { getStrings } from '../../../i18n/context.js';
14
14
 
@@ -26,6 +26,7 @@
26
26
  onCancel,
27
27
  onSuccess,
28
28
  onContinue,
29
+ onDuplicate,
29
30
  }: {
30
31
  labelOne?: string;
31
32
  labelMany?: string;
@@ -39,6 +40,7 @@
39
40
  onCancel?: () => void;
40
41
  onSuccess?: () => void;
41
42
  onContinue?: () => void;
43
+ onDuplicate?: (record: Record<string, unknown>) => void;
42
44
  } = $props();
43
45
 
44
46
  const icons = $derived(getIconSet() ?? defaultIconSet);
@@ -47,24 +49,16 @@
47
49
  let fieldErrors = $state<Record<string, string>>({});
48
50
  let internalError = $state('');
49
51
  let continuing = $state(false);
50
-
51
- function seedFromInstance(inst: Record<string, unknown>): Record<string, unknown> {
52
- const seeded: Record<string, unknown> = { ...inst };
53
- for (const f of fields) {
54
- const raw = f.seed ? f.seed(inst) : inst[f.attribute];
55
- seeded[f.attribute] = seedField(f, raw);
56
- }
57
- return seeded;
58
- }
52
+ let duplicating = $state(false);
59
53
 
60
54
  let record = $state<Record<string, unknown>>(
61
- untrack(() => seedFromInstance(instance as Record<string, unknown>))
55
+ untrack(() => seedRecord(fields, instance as Record<string, unknown>))
62
56
  );
63
57
 
64
58
  $effect(() => {
65
59
  const id = (instance as Record<string, unknown>)[idKey];
66
60
  if (!id) return;
67
- untrack(() => { record = seedFromInstance(instance as Record<string, unknown>); });
61
+ untrack(() => { record = seedRecord(fields, instance as Record<string, unknown>); });
68
62
  });
69
63
 
70
64
  const groups = $derived(groupFields(fields));
@@ -74,6 +68,10 @@
74
68
  const continueLabel = $derived(update.continue?.label ?? strings.saveAndContinue);
75
69
  const continueClass = $derived(update.continue?.class ?? '');
76
70
 
71
+ const duplicationEnabled = $derived(update.duplication?.enabled ?? false);
72
+ const duplicationLabel = $derived(update.duplication?.label ?? strings.duplicate);
73
+ const duplicationClass = $derived(update.duplication?.class ?? '');
74
+
77
75
  const errorEntries = $derived([
78
76
  ...((serverError || internalError) ? [['_global', internalError || serverError] as [string, string]] : []),
79
77
  ...Object.entries(fieldErrors),
@@ -123,6 +121,9 @@
123
121
  if (continuing) {
124
122
  continuing = false;
125
123
  onContinue?.();
124
+ } else if (duplicating) {
125
+ duplicating = false;
126
+ onDuplicate?.(applyDuplicateOmit(fields, record, update.duplication?.omit));
126
127
  } else {
127
128
  onSuccess?.();
128
129
  }
@@ -173,17 +174,27 @@
173
174
  <Button variant="ghost" onclick={() => onCancel?.()}>
174
175
  {strings.cancel}
175
176
  </Button>
177
+ {#if duplicationEnabled}
178
+ <Button
179
+ type="submit"
180
+ variant="warning"
181
+ class={duplicationClass}
182
+ onclick={() => { continuing = false; duplicating = true; }}
183
+ >
184
+ {duplicationLabel}
185
+ </Button>
186
+ {/if}
176
187
  {#if continueEnabled}
177
188
  <Button
178
189
  type="submit"
179
190
  variant="secondary"
180
191
  class={continueClass}
181
- onclick={() => { continuing = true; }}
192
+ onclick={() => { continuing = true; duplicating = false; }}
182
193
  >
183
194
  {continueLabel}
184
195
  </Button>
185
196
  {/if}
186
- <Button type="submit" variant="primary" onclick={() => { continuing = false; }}>
197
+ <Button type="submit" variant="primary" onclick={() => { continuing = false; duplicating = false; }}>
187
198
  {strings.save}
188
199
  </Button>
189
200
  </div>
@@ -12,6 +12,7 @@ declare function $$render<T extends object = Record<string, unknown>>(): {
12
12
  onCancel?: () => void;
13
13
  onSuccess?: () => void;
14
14
  onContinue?: () => void;
15
+ onDuplicate?: (record: Record<string, unknown>) => void;
15
16
  };
16
17
  exports: {};
17
18
  bindings: "";
package/dist/index.d.ts CHANGED
@@ -44,9 +44,9 @@ export { default as CRUDRead } from './components/crud/views/Read.svelte';
44
44
  export { default as CRUDUpdate } from './components/crud/views/Update.svelte';
45
45
  export { default as SearchInput } from './components/crud/SearchInput.svelte';
46
46
  export { AUTO_EXCLUDED } from './components/crud/utils/constants.js';
47
- export { resolveOptions, resolveFormatter, inferType, buildFieldDefinitions } from './components/crud/utils/resolution.js';
47
+ export { resolveOptions, resolveFormatter, truncateFormatter, inferType, buildFieldDefinitions } from './components/crud/utils/resolution.js';
48
48
  export { fieldLabel, initials } from './components/crud/utils/misc.js';
49
- export { formatBoolean, formatDatetime, formatTruncateTextUpTo, formatInstance } from './components/crud/utils/formatters.js';
49
+ export { formatBoolean, formatDatetime, formatInstance } from './components/crud/utils/formatters.js';
50
50
  export { groupFields } from './components/crud/utils/grouping.js';
51
51
  export type { FieldGroup } from './components/crud/utils/grouping.js';
52
52
  export { validateAll } from './components/crud/utils/validation.js';
package/dist/index.js CHANGED
@@ -42,9 +42,9 @@ export { default as CRUDUpdate } from './components/crud/views/Update.svelte';
42
42
  export { default as SearchInput } from './components/crud/SearchInput.svelte';
43
43
  // CRUD utilities
44
44
  export { AUTO_EXCLUDED } from './components/crud/utils/constants.js';
45
- export { resolveOptions, resolveFormatter, inferType, buildFieldDefinitions } from './components/crud/utils/resolution.js';
45
+ export { resolveOptions, resolveFormatter, truncateFormatter, inferType, buildFieldDefinitions } from './components/crud/utils/resolution.js';
46
46
  export { fieldLabel, initials } from './components/crud/utils/misc.js';
47
- export { formatBoolean, formatDatetime, formatTruncateTextUpTo, formatInstance } from './components/crud/utils/formatters.js';
47
+ export { formatBoolean, formatDatetime, formatInstance } from './components/crud/utils/formatters.js';
48
48
  export { groupFields } from './components/crud/utils/grouping.js';
49
49
  export { validateAll } from './components/crud/utils/validation.js';
50
50
  export { emptyRecord, defaultItemLabel } from './components/crud/utils/embedded.js';
@@ -51,7 +51,15 @@ export type AttributeMetadata = {
51
51
  hidden?: boolean | HiddenResolver;
52
52
  seed?: SeedResolver;
53
53
  component?: CellComponent<any, any>;
54
+ /** Formats the raw value everywhere it's displayed read-only: the list
55
+ * column's cell, the Read view's field, and any other readonly Field. */
54
56
  formatter?: FormatterResolver;
57
+ /** List column only: truncates the cell's text to this many characters,
58
+ * appending an ellipsis, applied to `formatter`'s output (or, absent one,
59
+ * the raw stringified value) — the Read view and Create/Update forms
60
+ * always show the untruncated value. Handy for a long free-text column
61
+ * that would otherwise blow out the table's width. */
62
+ truncateUpTo?: number;
55
63
  /** Pass a function to require a field only in certain conditions, e.g. a
56
64
  * quantity that only applies to some of a select's options. */
57
65
  required?: boolean | RequiredResolver;
@@ -65,6 +65,14 @@ export interface CreateFormButtonConfiguration {
65
65
  label?: string;
66
66
  class?: string;
67
67
  }
68
+ /** The "Duplicate" button's configuration — a `CreateFormButtonConfiguration`
69
+ * plus which attributes to leave out of the duplicated draft. */
70
+ export interface DuplicationButtonConfiguration extends CreateFormButtonConfiguration {
71
+ /** Attribute names reset to the field's `default` instead of copying the
72
+ * source instance's value — e.g. a publication date that should depend on
73
+ * when the copy is itself published, not on when the original was. */
74
+ omit?: string[];
75
+ }
68
76
  export interface ActionConfiguration<T extends object = Record<string, unknown>> {
69
77
  enabled?: boolean;
70
78
  label?: string;
@@ -81,12 +89,16 @@ export interface ActionConfiguration<T extends object = Record<string, unknown>>
81
89
  * Falls back to reloading the current instance when there is no next
82
90
  * one. Disabled by default — pass `{ enabled: true }` to show it. */
83
91
  continue?: CreateFormButtonConfiguration;
84
- /** Create form only: shows a "Duplicate" button alongside Save/Cancel that
85
- * submits to the same `endpoint`, but — unlike "Save and continue", which
86
- * blanks the form — leaves the just-submitted values in place so the user
87
- * can tweak a few fields and save again as a new record. Disabled by
88
- * default — pass `{ enabled: true }` to show it. */
89
- duplication?: CreateFormButtonConfiguration;
92
+ /** Shows a "Duplicate" button alongside Save/Cancel.
93
+ * - Create form: submits to the same `endpoint`, but — unlike "Save and
94
+ * continue", which blanks the form — leaves the just-submitted values in
95
+ * place so the user can tweak a few fields and save again as a new
96
+ * record.
97
+ * - Update form: submits the edit to the same `endpoint`, then opens the
98
+ * create form pre-filled with the just-saved instance's values, so a new
99
+ * record can be started from it without retyping everything.
100
+ * Disabled by default — pass `{ enabled: true }` to show it. */
101
+ duplication?: DuplicationButtonConfiguration;
90
102
  }
91
103
  export interface CustomAction<T extends object = Record<string, unknown>> {
92
104
  label: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runeforge",
3
- "version": "0.0.51",
3
+ "version": "0.0.53",
4
4
  "description": "SvelteKit toolkit for building metadata-driven CRUD interfaces with tables, forms, and actions",
5
5
  "license": "MIT",
6
6
  "author": "Ezequiel Puerta",