firstly 0.5.1 → 0.6.0
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/CHANGELOG.md +20 -0
- package/esm/core/FF_Filter.d.ts +2 -2
- package/esm/core/FF_Filter.js +2 -2
- package/esm/core/FF_Validators.d.ts +2 -0
- package/esm/core/FF_Validators.js +8 -10
- package/esm/core/containsWords.d.ts +2 -2
- package/esm/core/containsWords.js +2 -2
- package/esm/core/tailwind.d.ts +3 -4
- package/esm/core/tailwind.js +3 -4
- package/esm/svelte/DemoForm.svelte +121 -0
- package/esm/svelte/DemoForm.svelte.d.ts +42 -0
- package/esm/svelte/DemoGrid.svelte +146 -55
- package/esm/svelte/DemoGrid.svelte.d.ts +10 -1
- package/esm/svelte/DialogOpenTest.svelte +10 -0
- package/esm/svelte/DialogOpenTest.svelte.d.ts +8 -0
- package/esm/svelte/FF_Config.svelte +13 -0
- package/esm/svelte/FF_Config.svelte.d.ts +3 -0
- package/esm/svelte/FF_Config.svelte.js +38 -0
- package/esm/svelte/FF_DialogManager.svelte +251 -0
- package/esm/svelte/FF_DialogManager.svelte.d.ts +13 -0
- package/esm/svelte/FF_PromptDefault.svelte +85 -0
- package/esm/svelte/FF_PromptDefault.svelte.d.ts +9 -0
- package/esm/svelte/FF_ToastHtml.svelte +9 -0
- package/esm/svelte/FF_ToastHtml.svelte.d.ts +6 -0
- package/esm/svelte/FF_ToastManager.svelte +22 -0
- package/esm/svelte/FF_ToastManager.svelte.d.ts +4 -0
- package/esm/svelte/dialog.svelte.d.ts +209 -0
- package/esm/svelte/dialog.svelte.js +243 -0
- package/esm/svelte/ff.svelte.d.ts +294 -0
- package/esm/svelte/ff.svelte.js +599 -0
- package/esm/svelte/index.d.ts +13 -2
- package/esm/svelte/index.js +8 -1
- package/esm/svelte/infiniteScroll.d.ts +1 -1
- package/esm/svelte/infiniteScroll.js +1 -1
- package/esm/svelte/toast.d.ts +59 -0
- package/esm/svelte/toast.js +92 -0
- package/esm/virtual/StateDemoEnum.js +1 -1
- package/package.json +2 -1
- package/esm/svelte/FF_Repo.svelte.d.ts +0 -198
- package/esm/svelte/FF_Repo.svelte.js +0 -305
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# firstly
|
|
2
2
|
|
|
3
|
+
## 0.6.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#286](https://github.com/jycouet/firstly/pull/286) [`0de3038`](https://github.com/jycouet/firstly/commit/0de3038769bd30f7449f3185d9556bb5966eea6f) Thanks [@jycouet](https://github.com/jycouet)! - Add `<FF_Config>` (`firstly/svelte`): an SSR-safe, context-scoped provider for app-wide UI config, read by firstly components during init. First consumer is the dialog: set default `confirm` / `cancel` / `ok` labels once and your `shell` / `confirm` / `prompt` snippets in one place, instead of passing them on every `dialog.confirm(...)` / `<FF_DialogManager>`.
|
|
8
|
+
|
|
9
|
+
Precedence is explicit prop > `<FF_Config>` > built-in. Pass message **functions** (paraglide / i18next) for labels and they re-resolve on every render, so locale changes are picked up for free. `dialog.confirm` / `dialog.prompt` no longer bake `'Confirm'` / `'Cancel'` / `'OK'` at call time - omitted labels resolve at render via the nearest `<FF_Config>` (then the built-in). Also exports `ffConfig()` (read) and `setFFConfig()` (advanced).
|
|
10
|
+
|
|
11
|
+
- [#286](https://github.com/jycouet/firstly/pull/286) [`d364fe2`](https://github.com/jycouet/firstly/commit/d364fe22211a2d7a7acbb58afc367bae1a7e911d) Thanks [@jycouet](https://github.com/jycouet)! - Add a headless async `dialog` layer (`firstly/svelte`): `dialog.show(body)`, `dialog.confirm(message)`, `dialog.prompt(opts)`, rendered through a single `<FF_DialogManager>` you mount once. Built-in defaults are theme-adaptive via semantic Tailwind tokens; pass `shell` / `confirm` / `prompt` snippets to fully restyle. Ships `ffAutofocus`, Escape/scroll-lock/stacking, and a `LocalizedMessage` (string or fn) for labels. `dialog.open(Component, { props })` opens a dialog from a component + props, inferring the result type from the component's `close: DialogClose<T>` prop (no call-site generic).
|
|
12
|
+
|
|
13
|
+
One result contract for all three: they resolve a `DialogResult` (`{ ok: true, data } | { ok: false }`). `confirm` carries no `data` (read `.ok`); `prompt`'s `data` is the trimmed string (so cancel vs empty-string is unambiguous - `{ ok: false }` vs `{ ok: true, data: '' }`); `show<T>` carries `T`. See `/docs/svelte/dialog`.
|
|
14
|
+
|
|
15
|
+
- [#286](https://github.com/jycouet/firstly/pull/286) [`fcafe26`](https://github.com/jycouet/firstly/commit/fcafe26833a018aea9e5fe2313120173a112eb80) Thanks [@jycouet](https://github.com/jycouet)! - Replace `ffRepo` with a cleaner `ff` surface: `ff(E).many(getter, strategy?)` (a list + editing draft + writes) and `ff(E).one(getter)` (a single bound record). `load`/`listen`/`paginate` are now the `strategy`, not separate verbs. Imperative work moves to remult's `repo(E)` (no `.repo` on the handle); `.meta` stays. Adds an exported `DemoForm` alongside `DemoGrid`.
|
|
16
|
+
|
|
17
|
+
### Patch Changes
|
|
18
|
+
|
|
19
|
+
- [#288](https://github.com/jycouet/firstly/pull/288) [`7133d3c`](https://github.com/jycouet/firstly/commit/7133d3c31c276f2932a8c7efeaca3cd5e05a51b5) Thanks [@jycouet](https://github.com/jycouet)! - Add `many` action+confirm orchestration and a `toast` (`firstly/svelte`):
|
|
20
|
+
- `ff(E).many().confirmRemove(row, opts?)` (confirm → remove → auto error-toast, never re-throws), `editInDialog(row, body, opts?)` and `createInDialog(body, opts?)` (seed draft → `dialog.show` → cancel on close).
|
|
21
|
+
- `toast` + `<FF_ToastManager>` - a `LocalizedMessage`-aware wrapper over svelte-sonner (a new direct dependency). First arg is the **description** (HTML allowed); the bold **title** moves to `opts.title` and defaults per kind, localizable via `<FF_Config messages.toast>`. See `/docs/svelte/toast`.
|
|
22
|
+
|
|
3
23
|
## 0.5.1
|
|
4
24
|
|
|
5
25
|
### Patch Changes
|
package/esm/core/FF_Filter.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `owner` / `ownerOr` are prefilters (for `apiPrefilter`, `backendPrefilter`) -
|
|
5
5
|
* pair with `FF_Allow` (the equivalent for `allowApi*` row checks). `containsWords`
|
|
6
|
-
* is a search-box helper (pairs with `
|
|
6
|
+
* is a search-box helper (pairs with `ff(...).many`).
|
|
7
7
|
*
|
|
8
8
|
* Pass the entity type as a generic (`FF_Filter.owner<Task>('userId')`) to get
|
|
9
9
|
* autocompletion and type-safety on the column name. Without a generic the
|
|
@@ -61,7 +61,7 @@ export declare const FF_Filter: {
|
|
|
61
61
|
*
|
|
62
62
|
* @example
|
|
63
63
|
* ```ts
|
|
64
|
-
* const r =
|
|
64
|
+
* const r = ff(User).many(() => ({
|
|
65
65
|
* where: FF_Filter.containsWords([repo(User).fields.name, repo(User).fields.sesa], q),
|
|
66
66
|
* }))
|
|
67
67
|
* ```
|
package/esm/core/FF_Filter.js
CHANGED
|
@@ -5,7 +5,7 @@ import { containsWords } from './containsWords.js';
|
|
|
5
5
|
*
|
|
6
6
|
* `owner` / `ownerOr` are prefilters (for `apiPrefilter`, `backendPrefilter`) -
|
|
7
7
|
* pair with `FF_Allow` (the equivalent for `allowApi*` row checks). `containsWords`
|
|
8
|
-
* is a search-box helper (pairs with `
|
|
8
|
+
* is a search-box helper (pairs with `ff(...).many`).
|
|
9
9
|
*
|
|
10
10
|
* Pass the entity type as a generic (`FF_Filter.owner<Task>('userId')`) to get
|
|
11
11
|
* autocompletion and type-safety on the column name. Without a generic the
|
|
@@ -64,7 +64,7 @@ export const FF_Filter = {
|
|
|
64
64
|
*
|
|
65
65
|
* @example
|
|
66
66
|
* ```ts
|
|
67
|
-
* const r =
|
|
67
|
+
* const r = ff(User).many(() => ({
|
|
68
68
|
* where: FF_Filter.containsWords([repo(User).fields.name, repo(User).fields.sesa], q),
|
|
69
69
|
* }))
|
|
70
70
|
* ```
|
|
@@ -18,6 +18,8 @@ export type EmailMessages = {
|
|
|
18
18
|
export type ValidatorMessages = {
|
|
19
19
|
email?: EmailMessages;
|
|
20
20
|
};
|
|
21
|
+
/** Resolve a `LocalizedMessage` (literal, or a paraglide/i18next/lingui message fn) to its current string value. */
|
|
22
|
+
export declare const resolveMessage: (m: LocalizedMessage) => string;
|
|
21
23
|
/**
|
|
22
24
|
* Build a project-localized set of validators. Override any subset of
|
|
23
25
|
* messages; defaults are English.
|
|
@@ -24,10 +24,8 @@ const DEFAULT_EMAIL_MESSAGES = {
|
|
|
24
24
|
blockedDomain: 'Test/example email not accepted',
|
|
25
25
|
blockedTld: 'Test/example TLD not accepted',
|
|
26
26
|
};
|
|
27
|
-
/** Resolve a `LocalizedMessage` to its current string value. */
|
|
28
|
-
function
|
|
29
|
-
return typeof m === 'function' ? m() : m;
|
|
30
|
-
}
|
|
27
|
+
/** Resolve a `LocalizedMessage` (literal, or a paraglide/i18next/lingui message fn) to its current string value. */
|
|
28
|
+
export const resolveMessage = (m) => (typeof m === 'function' ? m() : m);
|
|
31
29
|
/**
|
|
32
30
|
* Build a project-localized set of validators. Override any subset of
|
|
33
31
|
* messages; defaults are English.
|
|
@@ -66,26 +64,26 @@ export function createValidators(messages = {}) {
|
|
|
66
64
|
if (value === '')
|
|
67
65
|
return true;
|
|
68
66
|
if (!EMAIL_SHAPE_RE.test(value))
|
|
69
|
-
return
|
|
67
|
+
return resolveMessage(m.invalid);
|
|
70
68
|
const at = value.lastIndexOf('@');
|
|
71
69
|
const domain = value.slice(at + 1).toLowerCase();
|
|
72
70
|
if (!domain || domain.includes('..') || domain.startsWith('.') || domain.endsWith('.')) {
|
|
73
|
-
return
|
|
71
|
+
return resolveMessage(m.invalidDomain);
|
|
74
72
|
}
|
|
75
73
|
if (BLOCKED_EMAIL_DOMAINS.has(domain))
|
|
76
|
-
return
|
|
74
|
+
return resolveMessage(m.blockedDomain);
|
|
77
75
|
const tld = domain.split('.').pop() ?? '';
|
|
78
76
|
if (BLOCKED_EMAIL_TLDS.has(tld))
|
|
79
|
-
return
|
|
77
|
+
return resolveMessage(m.blockedTld);
|
|
80
78
|
if (!domain.includes('.') || tld.length < 2)
|
|
81
|
-
return
|
|
79
|
+
return resolveMessage(m.invalidDomain);
|
|
82
80
|
return true;
|
|
83
81
|
}
|
|
84
82
|
return {
|
|
85
83
|
checkEmail,
|
|
86
84
|
// Pass a function so the per-request locale is resolved when the
|
|
87
85
|
// validator actually fires, not when the validator is built.
|
|
88
|
-
email: createValueValidator(checkEmail, () =>
|
|
86
|
+
email: createValueValidator(checkEmail, () => resolveMessage(m.invalid)),
|
|
89
87
|
};
|
|
90
88
|
}
|
|
91
89
|
/**
|
|
@@ -7,7 +7,7 @@ import type { EntityFilter, FieldMetadata } from 'remult';
|
|
|
7
7
|
* (name~dupont OR sesa~dupont) AND (name~marie OR sesa~marie)
|
|
8
8
|
*
|
|
9
9
|
* So word order and which field holds which word don't matter - handy for
|
|
10
|
-
* "NOM Prénom" style search across several columns. Pairs with `
|
|
11
|
-
* `
|
|
10
|
+
* "NOM Prénom" style search across several columns. Pairs with `ff`:
|
|
11
|
+
* `ff(User).many(() => ({ where: containsWords([f.name, f.sesa], q) }))`.
|
|
12
12
|
*/
|
|
13
13
|
export declare const containsWords: <Entity>(fields: FieldMetadata<unknown, Entity>[], search: string) => EntityFilter<Entity>;
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* (name~dupont OR sesa~dupont) AND (name~marie OR sesa~marie)
|
|
7
7
|
*
|
|
8
8
|
* So word order and which field holds which word don't matter - handy for
|
|
9
|
-
* "NOM Prénom" style search across several columns. Pairs with `
|
|
10
|
-
* `
|
|
9
|
+
* "NOM Prénom" style search across several columns. Pairs with `ff`:
|
|
10
|
+
* `ff(User).many(() => ({ where: containsWords([f.name, f.sesa], q) }))`.
|
|
11
11
|
*/
|
|
12
12
|
export const containsWords = (fields, search) => {
|
|
13
13
|
const words = (search ?? '').split(' ').filter((s) => s.length > 0);
|
package/esm/core/tailwind.d.ts
CHANGED
|
@@ -11,11 +11,10 @@ import { type ClassValue } from 'clsx';
|
|
|
11
11
|
* import { tw } from './tailwind'
|
|
12
12
|
*
|
|
13
13
|
* const buttonClasses = tw(
|
|
14
|
-
* '
|
|
15
|
-
* isDisabled && '
|
|
16
|
-
* size === 'lg' && '
|
|
14
|
+
* 'px-4 py-2 bg-primary text-primary-foreground',
|
|
15
|
+
* isDisabled && 'opacity-50 pointer-events-none',
|
|
16
|
+
* size === 'lg' && 'px-6 text-lg'
|
|
17
17
|
* )
|
|
18
|
-
* // Result: "btn btn-primary btn-disabled" (if isDisabled is true)
|
|
19
18
|
* ```
|
|
20
19
|
*/
|
|
21
20
|
export declare const tw: (...inputs: ClassValue[]) => string;
|
package/esm/core/tailwind.js
CHANGED
|
@@ -12,11 +12,10 @@ import { twMerge } from 'tailwind-merge';
|
|
|
12
12
|
* import { tw } from './tailwind'
|
|
13
13
|
*
|
|
14
14
|
* const buttonClasses = tw(
|
|
15
|
-
* '
|
|
16
|
-
* isDisabled && '
|
|
17
|
-
* size === 'lg' && '
|
|
15
|
+
* 'px-4 py-2 bg-primary text-primary-foreground',
|
|
16
|
+
* isDisabled && 'opacity-50 pointer-events-none',
|
|
17
|
+
* size === 'lg' && 'px-6 text-lg'
|
|
18
18
|
* )
|
|
19
|
-
* // Result: "btn btn-primary btn-disabled" (if isDisabled is true)
|
|
20
19
|
* ```
|
|
21
20
|
*/
|
|
22
21
|
export const tw = (...inputs) => twMerge(clsx(...inputs));
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
<script lang="ts" generics="T extends { id: string }">
|
|
2
|
+
import type { ClassType, EntityFilter } from 'remult'
|
|
3
|
+
|
|
4
|
+
import { ff } from './ff.svelte.js'
|
|
5
|
+
|
|
6
|
+
type Props = {
|
|
7
|
+
/** The remult entity class. */
|
|
8
|
+
entity: ClassType<T>
|
|
9
|
+
/** Fields to edit. Labels come from each field's `caption`. */
|
|
10
|
+
fields: (keyof T & string)[]
|
|
11
|
+
/** Which record (remult `EntityFilter`). Default: findFirst by `defaultOrderBy` (the latest). */
|
|
12
|
+
where?: EntityFilter<T>
|
|
13
|
+
}
|
|
14
|
+
let { entity, fields, where }: Props = $props()
|
|
15
|
+
|
|
16
|
+
// A single bound record: `where` (or findFirst by the entity's defaultOrderBy - the latest row).
|
|
17
|
+
const r = ff(entity).one(() => ({ where }))
|
|
18
|
+
|
|
19
|
+
const get = (row: T, f: keyof T & string) => (row as Record<string, unknown>)[f]
|
|
20
|
+
function setField(f: keyof T & string, value: string) {
|
|
21
|
+
if (r.item) (r.item as Record<string, unknown>)[f] = value
|
|
22
|
+
}
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<div class="form">
|
|
26
|
+
{#if r.loading.init}
|
|
27
|
+
<p class="muted">Loading…</p>
|
|
28
|
+
{:else if r.item}
|
|
29
|
+
{#each fields as f (f)}
|
|
30
|
+
<label>
|
|
31
|
+
<span>{r.meta.fields.find(f)?.caption ?? f}</span>
|
|
32
|
+
<input
|
|
33
|
+
value={String(get(r.item, f) ?? '')}
|
|
34
|
+
oninput={(e) => setField(f, e.currentTarget.value)}
|
|
35
|
+
/>
|
|
36
|
+
</label>
|
|
37
|
+
{/each}
|
|
38
|
+
<div class="actions">
|
|
39
|
+
<button
|
|
40
|
+
disabled={r.isWriting ||
|
|
41
|
+
(r.item.id ? !r.meta.apiUpdateAllowed(r.item) : !r.meta.apiInsertAllowed(r.item))}
|
|
42
|
+
onclick={() => r.save()}
|
|
43
|
+
>
|
|
44
|
+
Save
|
|
45
|
+
</button>
|
|
46
|
+
{#if r.isWriting}<span class="busy">saving…</span>{/if}
|
|
47
|
+
</div>
|
|
48
|
+
{#if r.error}<p class="err">{r.error}</p>{/if}
|
|
49
|
+
{:else}
|
|
50
|
+
<p class="muted">No record yet.</p>
|
|
51
|
+
<button disabled={!r.meta.apiInsertAllowed()} onclick={() => r.create()}>+ New</button>
|
|
52
|
+
{/if}
|
|
53
|
+
</div>
|
|
54
|
+
|
|
55
|
+
<style>
|
|
56
|
+
.form {
|
|
57
|
+
display: flex;
|
|
58
|
+
flex-direction: column;
|
|
59
|
+
gap: 12px;
|
|
60
|
+
align-items: stretch;
|
|
61
|
+
font-size: 14px;
|
|
62
|
+
}
|
|
63
|
+
label {
|
|
64
|
+
display: flex;
|
|
65
|
+
flex-direction: column;
|
|
66
|
+
gap: 4px;
|
|
67
|
+
}
|
|
68
|
+
label span {
|
|
69
|
+
font-size: 12px;
|
|
70
|
+
opacity: 0.65;
|
|
71
|
+
}
|
|
72
|
+
input {
|
|
73
|
+
width: 100%;
|
|
74
|
+
box-sizing: border-box;
|
|
75
|
+
padding: 7px 10px;
|
|
76
|
+
font: inherit;
|
|
77
|
+
color: inherit;
|
|
78
|
+
background: transparent;
|
|
79
|
+
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
|
|
80
|
+
border-radius: 8px;
|
|
81
|
+
}
|
|
82
|
+
input:focus-visible {
|
|
83
|
+
outline: none;
|
|
84
|
+
border-color: color-mix(in srgb, currentColor 55%, transparent);
|
|
85
|
+
}
|
|
86
|
+
.actions {
|
|
87
|
+
display: flex;
|
|
88
|
+
gap: 10px;
|
|
89
|
+
align-items: center;
|
|
90
|
+
}
|
|
91
|
+
.busy {
|
|
92
|
+
color: #d97706;
|
|
93
|
+
font-weight: 600;
|
|
94
|
+
font-size: 12px;
|
|
95
|
+
}
|
|
96
|
+
.err {
|
|
97
|
+
color: #dc2626;
|
|
98
|
+
margin: 0;
|
|
99
|
+
font-size: 13px;
|
|
100
|
+
}
|
|
101
|
+
button {
|
|
102
|
+
cursor: pointer;
|
|
103
|
+
font: inherit;
|
|
104
|
+
padding: 6px 14px;
|
|
105
|
+
color: inherit;
|
|
106
|
+
background: color-mix(in srgb, currentColor 8%, transparent);
|
|
107
|
+
border: 1px solid color-mix(in srgb, currentColor 20%, transparent);
|
|
108
|
+
border-radius: 8px;
|
|
109
|
+
transition: background 0.14s ease;
|
|
110
|
+
}
|
|
111
|
+
button:hover {
|
|
112
|
+
background: color-mix(in srgb, currentColor 15%, transparent);
|
|
113
|
+
}
|
|
114
|
+
button:disabled {
|
|
115
|
+
opacity: 0.45;
|
|
116
|
+
cursor: not-allowed;
|
|
117
|
+
}
|
|
118
|
+
.muted {
|
|
119
|
+
opacity: 0.6;
|
|
120
|
+
}
|
|
121
|
+
</style>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { ClassType, EntityFilter } from 'remult';
|
|
2
|
+
declare function $$render<T extends {
|
|
3
|
+
id: string;
|
|
4
|
+
}>(): {
|
|
5
|
+
props: {
|
|
6
|
+
/** The remult entity class. */
|
|
7
|
+
entity: ClassType<T>;
|
|
8
|
+
/** Fields to edit. Labels come from each field's `caption`. */
|
|
9
|
+
fields: (keyof T & string)[];
|
|
10
|
+
/** Which record (remult `EntityFilter`). Default: findFirst by `defaultOrderBy` (the latest). */
|
|
11
|
+
where?: EntityFilter<T>;
|
|
12
|
+
};
|
|
13
|
+
exports: {};
|
|
14
|
+
bindings: "";
|
|
15
|
+
slots: {};
|
|
16
|
+
events: {};
|
|
17
|
+
};
|
|
18
|
+
declare class __sveltets_Render<T extends {
|
|
19
|
+
id: string;
|
|
20
|
+
}> {
|
|
21
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
22
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
23
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
24
|
+
bindings(): "";
|
|
25
|
+
exports(): {};
|
|
26
|
+
}
|
|
27
|
+
interface $$IsomorphicComponent {
|
|
28
|
+
new <T extends {
|
|
29
|
+
id: string;
|
|
30
|
+
}>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
31
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
32
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
33
|
+
<T extends {
|
|
34
|
+
id: string;
|
|
35
|
+
}>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
36
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
37
|
+
}
|
|
38
|
+
declare const DemoForm: $$IsomorphicComponent;
|
|
39
|
+
type DemoForm<T extends {
|
|
40
|
+
id: string;
|
|
41
|
+
}> = InstanceType<typeof DemoForm<T>>;
|
|
42
|
+
export default DemoForm;
|
|
@@ -1,50 +1,48 @@
|
|
|
1
1
|
<script lang="ts" generics="T extends { id: string }">
|
|
2
|
-
import
|
|
2
|
+
import { untrack } from 'svelte'
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import type { ClassType, EntityFilter } from 'remult'
|
|
5
|
+
|
|
6
|
+
import { ff, type FF_Many, type ManyStrategy } from './ff.svelte.js'
|
|
5
7
|
|
|
6
8
|
type Props = {
|
|
7
9
|
/** The remult entity class to CRUD. */
|
|
8
10
|
entity: ClassType<T>
|
|
9
11
|
/** Fields to show as columns / edit inputs. Headers come from each field's `caption`. */
|
|
10
12
|
fields: (keyof T & string)[]
|
|
13
|
+
/** Filter the list (remult `EntityFilter`). Reactive: change it to re-fetch. */
|
|
14
|
+
where?: EntityFilter<T>
|
|
15
|
+
/** Fetch strategy: `paginate` (default), `listen` (live), or `load` (static one-shot). */
|
|
16
|
+
strategy?: ManyStrategy
|
|
17
|
+
/** When false the list query is skipped (no auto-load) until it flips true. */
|
|
18
|
+
enabled?: boolean
|
|
19
|
+
/** Rows per page (paginate strategy). */
|
|
20
|
+
pageSize?: number
|
|
11
21
|
}
|
|
12
|
-
let {
|
|
22
|
+
let {
|
|
23
|
+
entity,
|
|
24
|
+
fields,
|
|
25
|
+
where,
|
|
26
|
+
strategy = 'paginate',
|
|
27
|
+
enabled = true,
|
|
28
|
+
pageSize = 25,
|
|
29
|
+
}: Props = $props()
|
|
13
30
|
|
|
14
|
-
//
|
|
15
|
-
|
|
31
|
+
// ONE handle does it all: list (per strategy) + editing draft + writes.
|
|
32
|
+
// `entity` + `strategy` are init-only - the handle builds its `$effect` once; the
|
|
33
|
+
// reactive bits live in the getter (`where`/`enabled`/`pageSize`, read on each run).
|
|
34
|
+
// `untrack` says "read these once on purpose" so Svelte doesn't flag a stale capture.
|
|
35
|
+
// Cast to the paginate view so `more()`/`aggregates`/`refresh` are reachable; guarded below.
|
|
36
|
+
const m = untrack(() =>
|
|
37
|
+
ff(entity).many(() => ({ where, enabled, pageSize }), strategy),
|
|
38
|
+
) as unknown as FF_Many<T, 'paginate'>
|
|
16
39
|
|
|
17
|
-
|
|
18
|
-
let editingId = $state<string | null>(null)
|
|
19
|
-
const editor = ffRepo(entity).one(() => ({
|
|
20
|
-
where: { id: editingId ?? '' } as EntityFilter<T>,
|
|
21
|
-
enabled: !!editingId,
|
|
22
|
-
}))
|
|
23
|
-
const creating = $derived(!!editor.item && !editor.item.id)
|
|
40
|
+
const creating = $derived(!!m.draft && !m.draft.id)
|
|
24
41
|
|
|
25
42
|
// dynamic read/write of a field by key (v1: inputs are text)
|
|
26
43
|
const get = (row: T, f: keyof T & string) => (row as Record<string, unknown>)[f]
|
|
27
44
|
function setField(f: keyof T & string, value: string) {
|
|
28
|
-
if (
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function edit(id: string) {
|
|
32
|
-
editingId = id // → editor.item loads that row
|
|
33
|
-
}
|
|
34
|
-
function add() {
|
|
35
|
-
editingId = null
|
|
36
|
-
editor.create() // blank draft into editor.item
|
|
37
|
-
}
|
|
38
|
-
function cancel() {
|
|
39
|
-
editingId = null
|
|
40
|
-
editor.item = undefined // drop the draft / stop editing
|
|
41
|
-
}
|
|
42
|
-
async function save() {
|
|
43
|
-
await editor.save() // insert (a draft) or update (the loaded row); the live list self-syncs
|
|
44
|
-
cancel()
|
|
45
|
-
}
|
|
46
|
-
async function remove(row: T) {
|
|
47
|
-
await list.repo.delete(row as Partial<MembersOnly<T>>) // raw delete via .repo; the live list drops the row
|
|
45
|
+
if (m.draft) (m.draft as Record<string, unknown>)[f] = value
|
|
48
46
|
}
|
|
49
47
|
</script>
|
|
50
48
|
|
|
@@ -52,51 +50,98 @@
|
|
|
52
50
|
{#each fields as f (f)}
|
|
53
51
|
<td>
|
|
54
52
|
<input
|
|
55
|
-
placeholder={
|
|
53
|
+
placeholder={m.meta.fields.find(f)?.caption ?? f}
|
|
56
54
|
value={String(get(draft, f) ?? '')}
|
|
57
55
|
oninput={(e) => setField(f, e.currentTarget.value)}
|
|
58
56
|
/>
|
|
59
57
|
</td>
|
|
60
58
|
{/each}
|
|
61
59
|
<td class="actions">
|
|
62
|
-
<button
|
|
63
|
-
|
|
60
|
+
<button
|
|
61
|
+
disabled={m.isWriting ||
|
|
62
|
+
(draft.id ? !m.meta.apiUpdateAllowed(draft) : !m.meta.apiInsertAllowed(draft))}
|
|
63
|
+
onclick={() => m.save()}
|
|
64
|
+
>
|
|
65
|
+
Save
|
|
66
|
+
</button>
|
|
67
|
+
<button onclick={() => m.cancel()}>Cancel</button>
|
|
64
68
|
</td>
|
|
65
69
|
{/snippet}
|
|
66
70
|
|
|
67
71
|
<div class="crud">
|
|
68
|
-
<
|
|
72
|
+
<div class="bar">
|
|
73
|
+
<button disabled={!m.meta.apiInsertAllowed()} onclick={() => m.create()}>+ New</button>
|
|
74
|
+
{#if strategy !== 'listen'}
|
|
75
|
+
<button onclick={() => m.refresh()}>Refresh</button>
|
|
76
|
+
{/if}
|
|
77
|
+
{#if strategy === 'paginate' && m.aggregates}<span class="count">{m.aggregates.$count} rows</span
|
|
78
|
+
>{/if}
|
|
79
|
+
{#if m.isBusy}<span class="busy">busy…</span>{/if}
|
|
80
|
+
</div>
|
|
81
|
+
|
|
82
|
+
{#if m.error}<p class="err">{m.error}</p>{/if}
|
|
69
83
|
|
|
70
84
|
<table>
|
|
71
85
|
<thead>
|
|
72
86
|
<tr>
|
|
73
|
-
{#each fields as f (f)}<th>{
|
|
87
|
+
{#each fields as f (f)}<th>{m.meta.fields.find(f)?.caption ?? f}</th>{/each}
|
|
74
88
|
<th></th>
|
|
75
89
|
</tr>
|
|
76
90
|
</thead>
|
|
77
91
|
<tbody>
|
|
78
|
-
{#if creating &&
|
|
79
|
-
<tr class="editing">{@render editRow(
|
|
92
|
+
{#if creating && m.draft}
|
|
93
|
+
<tr class="editing">{@render editRow(m.draft)}</tr>
|
|
94
|
+
{/if}
|
|
95
|
+
{#if m.loading.init}
|
|
96
|
+
<!--
|
|
97
|
+
First-load placeholder. We know `fields`, so we render one shimmer cell per column
|
|
98
|
+
plus a button-sized cell in the actions column - that keeps each skeleton row the
|
|
99
|
+
same height as a real row, so the table doesn't jump when the data arrives.
|
|
100
|
+
-->
|
|
101
|
+
{#each Array(2) as _, i (i)}
|
|
102
|
+
<tr>
|
|
103
|
+
{#each fields as f (f)}<td><span class="sk"></span></td>{/each}
|
|
104
|
+
<td class="actions"><span class="sk sk-btn"></span></td>
|
|
105
|
+
</tr>
|
|
106
|
+
{/each}
|
|
107
|
+
{:else}
|
|
108
|
+
{#each m.items as row (row.id)}
|
|
109
|
+
<tr class:editing={m.draft?.id === row.id}>
|
|
110
|
+
{#if m.draft && m.draft.id === row.id}
|
|
111
|
+
{@render editRow(m.draft)}
|
|
112
|
+
{:else}
|
|
113
|
+
{#each fields as f (f)}<td>{get(row, f)}</td>{/each}
|
|
114
|
+
<td class="actions">
|
|
115
|
+
<button disabled={!m.meta.apiUpdateAllowed(row)} onclick={() => m.edit(row)}> Edit </button>
|
|
116
|
+
<button disabled={!m.meta.apiDeleteAllowed(row)} onclick={() => m.remove(row)}>
|
|
117
|
+
Delete
|
|
118
|
+
</button>
|
|
119
|
+
</td>
|
|
120
|
+
{/if}
|
|
121
|
+
</tr>
|
|
122
|
+
{/each}
|
|
80
123
|
{/if}
|
|
81
|
-
{#each list.items as row (row.id)}
|
|
82
|
-
<tr class:editing={editingId === row.id}>
|
|
83
|
-
{#if editingId === row.id && editor.item}
|
|
84
|
-
{@render editRow(editor.item)}
|
|
85
|
-
{:else}
|
|
86
|
-
{#each fields as f (f)}<td>{get(row, f)}</td>{/each}
|
|
87
|
-
<td class="actions">
|
|
88
|
-
<button onclick={() => edit(row.id)}>Edit</button>
|
|
89
|
-
<button onclick={() => remove(row)}>Delete</button>
|
|
90
|
-
</td>
|
|
91
|
-
{/if}
|
|
92
|
-
</tr>
|
|
93
|
-
{/each}
|
|
94
124
|
</tbody>
|
|
95
125
|
</table>
|
|
96
126
|
|
|
97
|
-
{#if
|
|
98
|
-
|
|
99
|
-
|
|
127
|
+
{#if strategy === 'paginate' && m.hasNextPage}
|
|
128
|
+
<!--
|
|
129
|
+
Manual "More". To auto-load on scroll instead, use the `infiniteScroll` attachment
|
|
130
|
+
(also from 'firstly/svelte') on a bottom sentinel:
|
|
131
|
+
<div {@attach infiniteScroll({
|
|
132
|
+
hasMore: () => m.hasNextPage,
|
|
133
|
+
loading: () => m.loading.more,
|
|
134
|
+
onMore: () => m.more(),
|
|
135
|
+
})}></div>
|
|
136
|
+
-->
|
|
137
|
+
<button disabled={m.loading.more} onclick={() => m.more()}>
|
|
138
|
+
{m.loading.more ? 'Loading…' : 'More'}
|
|
139
|
+
</button>
|
|
140
|
+
{/if}
|
|
141
|
+
|
|
142
|
+
{#if !enabled}
|
|
143
|
+
<p class="muted">Not loaded (<code>enabled: false</code>) - flip it to fetch.</p>
|
|
144
|
+
{:else if !m.loading.init && m.items.length === 0 && !creating}
|
|
100
145
|
<p class="muted">Nothing yet - hit “+ New”.</p>
|
|
101
146
|
{/if}
|
|
102
147
|
</div>
|
|
@@ -109,6 +154,27 @@
|
|
|
109
154
|
gap: 10px;
|
|
110
155
|
align-items: start;
|
|
111
156
|
}
|
|
157
|
+
.bar {
|
|
158
|
+
display: flex;
|
|
159
|
+
gap: 10px;
|
|
160
|
+
align-items: center;
|
|
161
|
+
flex-wrap: wrap;
|
|
162
|
+
width: 100%;
|
|
163
|
+
}
|
|
164
|
+
.count {
|
|
165
|
+
font-size: 12px;
|
|
166
|
+
font-variant-numeric: tabular-nums;
|
|
167
|
+
opacity: 0.85;
|
|
168
|
+
}
|
|
169
|
+
.busy {
|
|
170
|
+
font-size: 12px;
|
|
171
|
+
color: #f59e0b;
|
|
172
|
+
font-weight: 600;
|
|
173
|
+
}
|
|
174
|
+
.err {
|
|
175
|
+
color: #ef4444;
|
|
176
|
+
margin: 0;
|
|
177
|
+
}
|
|
112
178
|
table {
|
|
113
179
|
border-collapse: collapse;
|
|
114
180
|
width: 100%;
|
|
@@ -164,4 +230,29 @@
|
|
|
164
230
|
.muted {
|
|
165
231
|
opacity: 0.6;
|
|
166
232
|
}
|
|
233
|
+
.sk {
|
|
234
|
+
display: inline-block;
|
|
235
|
+
width: 70%;
|
|
236
|
+
height: 0.8em;
|
|
237
|
+
border-radius: 4px;
|
|
238
|
+
background: color-mix(in srgb, currentColor 16%, transparent);
|
|
239
|
+
animation: sk-pulse 1.2s ease-in-out infinite;
|
|
240
|
+
}
|
|
241
|
+
/* button-sized so a skeleton row matches a real (button-bearing) row's height */
|
|
242
|
+
.sk-btn {
|
|
243
|
+
width: 3.4em;
|
|
244
|
+
height: 1.9em;
|
|
245
|
+
}
|
|
246
|
+
@keyframes sk-pulse {
|
|
247
|
+
0%,
|
|
248
|
+
100% {
|
|
249
|
+
opacity: 0.45;
|
|
250
|
+
}
|
|
251
|
+
50% {
|
|
252
|
+
opacity: 0.85;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
code {
|
|
256
|
+
font-size: 12px;
|
|
257
|
+
}
|
|
167
258
|
</style>
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import type { ClassType } from 'remult';
|
|
1
|
+
import type { ClassType, EntityFilter } from 'remult';
|
|
2
|
+
import { type ManyStrategy } from './ff.svelte.js';
|
|
2
3
|
declare function $$render<T extends {
|
|
3
4
|
id: string;
|
|
4
5
|
}>(): {
|
|
@@ -7,6 +8,14 @@ declare function $$render<T extends {
|
|
|
7
8
|
entity: ClassType<T>;
|
|
8
9
|
/** Fields to show as columns / edit inputs. Headers come from each field's `caption`. */
|
|
9
10
|
fields: (keyof T & string)[];
|
|
11
|
+
/** Filter the list (remult `EntityFilter`). Reactive: change it to re-fetch. */
|
|
12
|
+
where?: EntityFilter<T>;
|
|
13
|
+
/** Fetch strategy: `paginate` (default), `listen` (live), or `load` (static one-shot). */
|
|
14
|
+
strategy?: ManyStrategy;
|
|
15
|
+
/** When false the list query is skipped (no auto-load) until it flips true. */
|
|
16
|
+
enabled?: boolean;
|
|
17
|
+
/** Rows per page (paginate strategy). */
|
|
18
|
+
pageSize?: number;
|
|
10
19
|
};
|
|
11
20
|
exports: {};
|
|
12
21
|
bindings: "";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { DialogClose } from './dialog.svelte.js'
|
|
3
|
+
|
|
4
|
+
// `label` proves props pass through; `close` is injected by the manager and typed,
|
|
5
|
+
// so `open(DialogOpenTest)` resolves `DialogResult<number>`.
|
|
6
|
+
let { label, close }: { label: string; close: DialogClose<number> } = $props()
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<p data-testid="open-label">{label}</p>
|
|
10
|
+
<button data-testid="open-ok" onclick={() => close({ ok: true, data: 7 })}>ok</button>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { DialogClose } from './dialog.svelte.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
label: string;
|
|
4
|
+
close: DialogClose<number>;
|
|
5
|
+
};
|
|
6
|
+
declare const DialogOpenTest: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
7
|
+
type DialogOpenTest = ReturnType<typeof DialogOpenTest>;
|
|
8
|
+
export default DialogOpenTest;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from 'svelte'
|
|
3
|
+
|
|
4
|
+
import { setFFConfig, type FF_ConfigValue } from './FF_Config.svelte.js'
|
|
5
|
+
|
|
6
|
+
let { children, messages, dialog, toast }: { children: Snippet } & FF_ConfigValue = $props()
|
|
7
|
+
|
|
8
|
+
// Hand context a getter over our (reactive) props, not a snapshot - so descendants re-read
|
|
9
|
+
// the latest values and locale-aware message functions resolve fresh on each render.
|
|
10
|
+
setFFConfig(() => ({ messages, dialog, toast }))
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
{@render children()}
|