runeforge 0.0.50 → 0.0.52

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.
@@ -139,10 +139,18 @@
139
139
  activeBulkAction = { action, items };
140
140
  }
141
141
 
142
+ let duplicateSeed = $state<Record<string, unknown> | undefined>(undefined);
143
+
142
144
  async function navList() {
145
+ duplicateSeed = undefined;
143
146
  await goto(lastListState.search || '?');
144
147
  }
145
148
  async function navCreate() {
149
+ duplicateSeed = undefined;
150
+ await goto('?view=create');
151
+ }
152
+ async function navDuplicate(record: Record<string, unknown>) {
153
+ duplicateSeed = record;
146
154
  await goto('?view=create');
147
155
  }
148
156
  async function navRead(item: T) {
@@ -337,6 +345,7 @@
337
345
  fields={resolvedFields}
338
346
  {creation}
339
347
  {serverError}
348
+ seed={duplicateSeed}
340
349
  onCancel={navList}
341
350
  onSuccess={navList}
342
351
  />
@@ -364,6 +373,7 @@
364
373
  onCancel={navList}
365
374
  onSuccess={navList}
366
375
  onContinue={navContinueEdit}
376
+ onDuplicate={navDuplicate}
367
377
  />
368
378
  {:else}
369
379
  <List
@@ -1,5 +1,6 @@
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>;
4
5
  export declare function formatEmbeddedFieldValue<T extends object = Record<string, unknown>>(f: FieldDefinition<T>, raw: unknown): string;
5
6
  export declare function defaultItemLabel<T extends object = Record<string, unknown>>(fields: FieldDefinition<T>[], item: Record<string, unknown>): string;
@@ -26,6 +26,19 @@ 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
+ }
29
42
  // Shared by defaultItemLabel and the CSV/XLSX export column expansion: renders
30
43
  // one sub-field's raw stored value as display text, resolving a select's
31
44
  // option label instead of its stored value. Booleans are left to the caller
@@ -1,4 +1,4 @@
1
1
  export declare const formatBoolean: (trueLabel?: string, falseLabel?: string) => () => (value: boolean) => string;
2
- export declare const formatDatetime: (format?: string) => (() => (value: Date) => string);
2
+ export declare const formatDatetime: (format?: string, timeZone?: string) => (() => (value: Date) => string);
3
3
  export declare function formatTruncateTextUpTo(maxLength: number): () => (value: string) => string;
4
4
  export declare function formatInstance<T extends Record<string, unknown>>(attribute: keyof T & string, instances: T[], urlPath: string, idKey?: keyof T & string): (value: unknown) => string;
@@ -8,7 +8,36 @@ const TOKENS = {
8
8
  MM: (d) => String(d.getMinutes()).padStart(2, '0'),
9
9
  ss: (d) => String(d.getSeconds()).padStart(2, '0')
10
10
  };
11
- export const formatDatetime = (format = 'dd/mm/YYYY HH:MM') => {
11
+ // Same tokens, read from Intl.DateTimeFormat parts instead of local Date
12
+ // getters, so the result depends only on `timeZone` — never on the
13
+ // environment (SSR host vs. browser) running the code.
14
+ function tokensFor(d, timeZone) {
15
+ const parts = new Intl.DateTimeFormat('en-US', {
16
+ timeZone,
17
+ year: 'numeric',
18
+ month: '2-digit',
19
+ day: '2-digit',
20
+ hour: '2-digit',
21
+ minute: '2-digit',
22
+ second: '2-digit',
23
+ hourCycle: 'h23'
24
+ }).formatToParts(d);
25
+ const get = (type) => parts.find((p) => p.type === type)?.value ?? '';
26
+ return {
27
+ dd: get('day'),
28
+ mm: get('month'),
29
+ YYYY: get('year'),
30
+ HH: get('hour'),
31
+ MM: get('minute'),
32
+ ss: get('second')
33
+ };
34
+ }
35
+ // `timeZone` is an IANA zone name (e.g. 'America/Argentina/Buenos_Aires').
36
+ // Omit it to keep formatting in whichever timezone the running environment
37
+ // is in — this differs between SSR (server) and CSR (browser) and will
38
+ // render the same instant differently depending on where it runs, so pass
39
+ // an explicit `timeZone` for any value that must display consistently.
40
+ export const formatDatetime = (format = 'dd/mm/YYYY HH:MM', timeZone) => {
12
41
  const fmt = (value) => {
13
42
  // `null`/`undefined`/`''` (an unset field) must render blank, not epoch 0
14
43
  // — `new Date(null)` is 1970-01-01, a "valid" Date whose getTime() isn't
@@ -18,7 +47,8 @@ export const formatDatetime = (format = 'dd/mm/YYYY HH:MM') => {
18
47
  const d = new Date(value);
19
48
  if (isNaN(d.getTime()))
20
49
  return '';
21
- return format.replace(/dd|mm|YYYY|HH|MM|ss/g, (token) => TOKENS[token](d));
50
+ const tokens = timeZone ? tokensFor(d, timeZone) : null;
51
+ return format.replace(/dd|mm|YYYY|HH|MM|ss/g, (token) => tokens ? tokens[token] : TOKENS[token](d));
22
52
  };
23
53
  return () => fmt;
24
54
  };
@@ -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 { 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'));
@@ -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 { 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?.(record);
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: "";
@@ -81,11 +81,15 @@ export interface ActionConfiguration<T extends object = Record<string, unknown>>
81
81
  * Falls back to reloading the current instance when there is no next
82
82
  * one. Disabled by default — pass `{ enabled: true }` to show it. */
83
83
  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. */
84
+ /** Shows a "Duplicate" button alongside Save/Cancel.
85
+ * - Create form: submits to the same `endpoint`, but — unlike "Save and
86
+ * continue", which blanks the form — leaves the just-submitted values in
87
+ * place so the user can tweak a few fields and save again as a new
88
+ * record.
89
+ * - Update form: submits the edit to the same `endpoint`, then opens the
90
+ * create form pre-filled with the just-saved instance's values, so a new
91
+ * record can be started from it without retyping everything.
92
+ * Disabled by default — pass `{ enabled: true }` to show it. */
89
93
  duplication?: CreateFormButtonConfiguration;
90
94
  }
91
95
  export interface CustomAction<T extends object = Record<string, unknown>> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runeforge",
3
- "version": "0.0.50",
3
+ "version": "0.0.52",
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",