firstly 0.5.0 → 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 +28 -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 +258 -0
- package/esm/svelte/DemoGrid.svelte.d.ts +49 -0
- 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 +14 -2
- package/esm/svelte/index.js +9 -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 +4 -3
- package/esm/svelte/FF_Repo.svelte.d.ts +0 -191
- package/esm/svelte/FF_Repo.svelte.js +0 -312
|
@@ -0,0 +1,599 @@
|
|
|
1
|
+
import { repo as remultRepo, } from 'remult';
|
|
2
|
+
import { dialog } from './dialog.svelte.js';
|
|
3
|
+
/**
|
|
4
|
+
* The reactive handle implementation (internal). `ff().one()` returns an Omit'd view of this
|
|
5
|
+
* (`FF_One`); `ff().many()` wraps a list handle + a `.syncs`-linked one in `FF_ManyHandle`
|
|
6
|
+
* (exposed as `FF_Many`). The per-mode aliases below stay internal to this module.
|
|
7
|
+
*/
|
|
8
|
+
class FF_RepoHandle {
|
|
9
|
+
#repo;
|
|
10
|
+
#opts;
|
|
11
|
+
#mode;
|
|
12
|
+
#defaultOrderBy;
|
|
13
|
+
#paginator;
|
|
14
|
+
#seq = 0;
|
|
15
|
+
#syncTargets = [];
|
|
16
|
+
items = $state([]);
|
|
17
|
+
/** Single-record slot: the loaded row in `one` mode, or a create/edit draft (see `create`). */
|
|
18
|
+
item = $state(undefined);
|
|
19
|
+
loading = $state({
|
|
20
|
+
init: true,
|
|
21
|
+
fetching: false,
|
|
22
|
+
more: false,
|
|
23
|
+
saving: false,
|
|
24
|
+
deleting: false,
|
|
25
|
+
});
|
|
26
|
+
error = $state(undefined);
|
|
27
|
+
hasNextPage = $state(false);
|
|
28
|
+
/** Aggregations for the whole query (paginate mode). `aggregates.$count` is the total row count. */
|
|
29
|
+
aggregates = $state(undefined);
|
|
30
|
+
/** Any read or write currently in flight (init/fetching/more/saving/deleting). */
|
|
31
|
+
get isBusy() {
|
|
32
|
+
const l = this.loading;
|
|
33
|
+
return l.init || l.fetching || l.more || l.saving || l.deleting;
|
|
34
|
+
}
|
|
35
|
+
/** A write (insert/update/delete) in flight. */
|
|
36
|
+
get isWriting() {
|
|
37
|
+
return this.loading.saving || this.loading.deleting;
|
|
38
|
+
}
|
|
39
|
+
constructor(r, opts, mode) {
|
|
40
|
+
this.#repo = r;
|
|
41
|
+
this.#opts = opts;
|
|
42
|
+
this.#mode = mode;
|
|
43
|
+
this.#defaultOrderBy = r.metadata.options.defaultOrderBy;
|
|
44
|
+
$effect(() => {
|
|
45
|
+
const o = this.#resolve();
|
|
46
|
+
if (o.enabled === false) {
|
|
47
|
+
this.loading.init = false;
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (mode === 'live') {
|
|
51
|
+
// Pass orderBy so liveQuery re-sorts incrementally-added rows too;
|
|
52
|
+
// without it a freshly inserted row is appended and `items[0]` (the latest) goes stale.
|
|
53
|
+
const unsub = this.#repo
|
|
54
|
+
.liveQuery({ where: o.where, orderBy: o.orderBy, limit: o.limit, include: o.include })
|
|
55
|
+
.subscribe({
|
|
56
|
+
next: (info) => {
|
|
57
|
+
this.items = info.items;
|
|
58
|
+
this.loading.init = false;
|
|
59
|
+
},
|
|
60
|
+
error: (e) => {
|
|
61
|
+
this.error = e instanceof Error ? e.message : String(e);
|
|
62
|
+
this.loading.init = false;
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
return () => unsub();
|
|
66
|
+
}
|
|
67
|
+
// load | paginate | one: (re)fetch; a newer opts() invalidates older responses.
|
|
68
|
+
void this.#load(o, ++this.#seq);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
#resolve() {
|
|
72
|
+
const o = this.#opts();
|
|
73
|
+
return { ...o, orderBy: o.orderBy ?? this.#defaultOrderBy };
|
|
74
|
+
}
|
|
75
|
+
async #load(o, seq, keepCount) {
|
|
76
|
+
this.loading.fetching = true;
|
|
77
|
+
this.error = undefined;
|
|
78
|
+
try {
|
|
79
|
+
if (this.#mode === 'paginate') {
|
|
80
|
+
// One request returns the page AND the aggregates ($count always, plus any
|
|
81
|
+
// requested): on the client REST proxy remult fetches both together. It sets
|
|
82
|
+
// `hasNextPage` from whether a full page came back (no count probe); `more()`
|
|
83
|
+
// then fetches the next page via a keyset cursor (orderBy + PK).
|
|
84
|
+
const p = await this.#repo
|
|
85
|
+
.query({
|
|
86
|
+
where: o.where,
|
|
87
|
+
orderBy: o.orderBy,
|
|
88
|
+
pageSize: keepCount ?? o.pageSize ?? 25,
|
|
89
|
+
include: o.include,
|
|
90
|
+
aggregate: { ...o.aggregate },
|
|
91
|
+
})
|
|
92
|
+
.paginator();
|
|
93
|
+
if (seq !== this.#seq)
|
|
94
|
+
return;
|
|
95
|
+
this.#paginator = p;
|
|
96
|
+
this.items = p.items;
|
|
97
|
+
this.hasNextPage = p.hasNextPage;
|
|
98
|
+
// `aggregates` is only on the paginator type when the aggregate is non-empty,
|
|
99
|
+
// but remult returns `$count` for the empty case too - so read it through a cast.
|
|
100
|
+
this.aggregates = p.aggregates;
|
|
101
|
+
}
|
|
102
|
+
else if (this.#mode === 'one') {
|
|
103
|
+
const found = await this.#repo.findFirst(o.where, {
|
|
104
|
+
orderBy: o.orderBy,
|
|
105
|
+
include: o.include,
|
|
106
|
+
});
|
|
107
|
+
if (seq !== this.#seq)
|
|
108
|
+
return;
|
|
109
|
+
this.item = found ?? undefined;
|
|
110
|
+
this.items = found ? [found] : [];
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
const items = await this.#repo.find({
|
|
114
|
+
where: o.where,
|
|
115
|
+
orderBy: o.orderBy,
|
|
116
|
+
limit: o.limit,
|
|
117
|
+
include: o.include,
|
|
118
|
+
});
|
|
119
|
+
if (seq !== this.#seq)
|
|
120
|
+
return;
|
|
121
|
+
this.items = items;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
if (seq === this.#seq)
|
|
126
|
+
this.error = e instanceof Error ? e.message : String(e);
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
if (seq === this.#seq) {
|
|
130
|
+
this.loading.init = false;
|
|
131
|
+
this.loading.fetching = false;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/** Re-run the current query (load/paginate/one), back to the first page. */
|
|
136
|
+
async refresh() {
|
|
137
|
+
if (this.#mode === 'live')
|
|
138
|
+
throw new Error('FF_Repo: refresh() is not available in live mode');
|
|
139
|
+
await this.#load(this.#resolve(), ++this.#seq);
|
|
140
|
+
}
|
|
141
|
+
/** Load and append the next page (paginate mode). */
|
|
142
|
+
async more() {
|
|
143
|
+
if (this.#mode !== 'paginate')
|
|
144
|
+
throw new Error('FF_Repo: more() requires paginate mode');
|
|
145
|
+
if (!this.#paginator || this.loading.more || !this.hasNextPage)
|
|
146
|
+
return;
|
|
147
|
+
const seq = this.#seq;
|
|
148
|
+
this.loading.more = true;
|
|
149
|
+
try {
|
|
150
|
+
const next = await this.#paginator.nextPage();
|
|
151
|
+
// A newer query (where/orderBy/pageSize changed) ran while this page was in flight:
|
|
152
|
+
// drop the stale page rather than appending it to the new result.
|
|
153
|
+
if (seq !== this.#seq)
|
|
154
|
+
return;
|
|
155
|
+
this.#paginator = next;
|
|
156
|
+
this.items = [...this.items, ...next.items];
|
|
157
|
+
this.hasNextPage = next.hasNextPage;
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
this.loading.more = false;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Run `fn` once - the first time a row exists (`items[0]`).
|
|
165
|
+
*
|
|
166
|
+
* The point: seed editable UI state from the latest row WITHOUT a live query
|
|
167
|
+
* clobbering in-progress edits. It fires a single time, on the first non-empty
|
|
168
|
+
* result, and never again - later ticks (an edit, a delete, a re-sort) are
|
|
169
|
+
* ignored. Empty snapshots are skipped (a liveQuery often emits one before the
|
|
170
|
+
* data lands; there is nothing to seed from an empty result).
|
|
171
|
+
*
|
|
172
|
+
* For pure derived state prefer `$derived`; reach for `onFirst` only when the
|
|
173
|
+
* seed must become independently editable (a draft the user then mutates).
|
|
174
|
+
*
|
|
175
|
+
* ```svelte
|
|
176
|
+
* const list = ff(Plan).many(() => ({ where: { ownerDid } }), 'listen')
|
|
177
|
+
* let draft = $state({ title: '' })
|
|
178
|
+
* list.onFirst((latest) => (draft.title = latest.title)) // seed once, then edit freely
|
|
179
|
+
* ```
|
|
180
|
+
*/
|
|
181
|
+
onFirst(fn) {
|
|
182
|
+
let done = false;
|
|
183
|
+
$effect(() => {
|
|
184
|
+
if (done)
|
|
185
|
+
return;
|
|
186
|
+
const latest = this.items[0];
|
|
187
|
+
if (latest == null)
|
|
188
|
+
return;
|
|
189
|
+
fn(latest);
|
|
190
|
+
done = true;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
/** Create a new unsaved entity into the `item` slot (for an edit form). */
|
|
194
|
+
create(...args) {
|
|
195
|
+
this.item = this.#repo.create(...args);
|
|
196
|
+
return this.item;
|
|
197
|
+
}
|
|
198
|
+
// Mutations: run `op`, then the post-write sync on SUCCESS only (a failed write
|
|
199
|
+
// leaves the result untouched). On failure we fill `error` AND re-throw - the
|
|
200
|
+
// caller still gets the rejection (not silenced); `error` is for a reactive
|
|
201
|
+
// UI that wants it. `finally` only flips `loading` (no `await`, which would
|
|
202
|
+
// mask the original error).
|
|
203
|
+
async #write(flag, op, after) {
|
|
204
|
+
this.loading[flag] = true;
|
|
205
|
+
for (const t of this.#syncTargets)
|
|
206
|
+
t.loading[flag] = true;
|
|
207
|
+
this.error = undefined;
|
|
208
|
+
try {
|
|
209
|
+
const res = await op();
|
|
210
|
+
await after();
|
|
211
|
+
return res;
|
|
212
|
+
}
|
|
213
|
+
catch (e) {
|
|
214
|
+
this.error = e instanceof Error ? e.message : String(e);
|
|
215
|
+
throw e;
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
this.loading[flag] = false;
|
|
219
|
+
for (const t of this.#syncTargets)
|
|
220
|
+
t.loading[flag] = false;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** Save the current `item` (from `one` / `create()`). To save a specific row, use remult `repo(E).save(row)`. */
|
|
224
|
+
async save() {
|
|
225
|
+
const saved = await this.#write('saving', () => this.#repo.save(this.#requireItem()), () => this.#resync());
|
|
226
|
+
this.item = saved;
|
|
227
|
+
for (const t of this.#syncTargets)
|
|
228
|
+
t.reconcile(saved);
|
|
229
|
+
return saved;
|
|
230
|
+
}
|
|
231
|
+
/** Delete the current `item`. To delete a specific row/id, use remult `repo(E).delete(idOrRow)`. */
|
|
232
|
+
async delete() {
|
|
233
|
+
const target = this.#requireItem();
|
|
234
|
+
const res = await this.#write('deleting', () => this.#repo.delete(target), () => {
|
|
235
|
+
// live: liveQuery removes it. one: re-fetch (likely empty now).
|
|
236
|
+
// load/paginate: drop it locally (no refetch).
|
|
237
|
+
if (this.#mode === 'live')
|
|
238
|
+
return;
|
|
239
|
+
if (this.#mode === 'one')
|
|
240
|
+
return this.#resync();
|
|
241
|
+
this.#removeLocal(target);
|
|
242
|
+
});
|
|
243
|
+
for (const t of this.#syncTargets)
|
|
244
|
+
t.removeItem(target);
|
|
245
|
+
return res;
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Save a specific `row` through this list handle's loading/error machinery (mirrors the
|
|
249
|
+
* argless `save()`), then reconcile the list (sorted upsert / paginate refresh). Used by
|
|
250
|
+
* `FF_ManyHandle.save(target)` so a targeted write flips `loading.saving` and fills `error`.
|
|
251
|
+
*/
|
|
252
|
+
async saveRow(row) {
|
|
253
|
+
let saved;
|
|
254
|
+
await this.#write('saving', async () => (saved = await this.#repo.save(row)), () => this.reconcile(saved));
|
|
255
|
+
return saved;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Delete a specific `row` through this list handle's loading/error machinery (mirrors the
|
|
259
|
+
* argless `delete()`), then drop it from the list. Used by `FF_ManyHandle.remove(target)`.
|
|
260
|
+
*/
|
|
261
|
+
async deleteRow(row) {
|
|
262
|
+
await this.#write('deleting', () => this.#repo.delete(row), () => this.removeItem(row));
|
|
263
|
+
}
|
|
264
|
+
/**
|
|
265
|
+
* Link this record handle (`one`) to one or more list handles. On `save()` /
|
|
266
|
+
* `delete()` the lists are reconciled (sorted upsert / remove) and share the
|
|
267
|
+
* write-loading flag, so the list area shows "busy" during the write too. A live
|
|
268
|
+
* list reconcile is a no-op (its liveQuery already syncs). Returns `this`.
|
|
269
|
+
*/
|
|
270
|
+
syncs(...targets) {
|
|
271
|
+
this.#syncTargets = targets;
|
|
272
|
+
return this;
|
|
273
|
+
}
|
|
274
|
+
/** The current `item` (or throw) - backs the argless `save()`/`delete()`. */
|
|
275
|
+
#requireItem() {
|
|
276
|
+
if (this.item === undefined)
|
|
277
|
+
throw new Error('FF_Repo: no `item` to save/delete - load one first (`one` mode or `create()`), or write a specific row through remult `repo(E)`.');
|
|
278
|
+
return this.item;
|
|
279
|
+
}
|
|
280
|
+
// Client-side list reconcilers (no server I/O) - reflect a change you made
|
|
281
|
+
// elsewhere (e.g. via remult `repo(E)`) in the reactive `items`. `load`/`paginate` only;
|
|
282
|
+
// `listen` reconciles itself via the liveQuery. `add`/`remove` also adjust
|
|
283
|
+
// `aggregates.$count` (not the other aggregates). For authoritative state, call
|
|
284
|
+
// `refresh()` (it re-pulls and, for paginate, resets to the first page).
|
|
285
|
+
/** Insert into `items` at `top` (default) / `bottom` / an index (`-1` = last). +1 to `$count`. */
|
|
286
|
+
addItem(item, options) {
|
|
287
|
+
const at = options?.at ?? 'top';
|
|
288
|
+
const list = this.items;
|
|
289
|
+
const idx = at === 'top'
|
|
290
|
+
? 0
|
|
291
|
+
: at === 'bottom'
|
|
292
|
+
? list.length
|
|
293
|
+
: at < 0
|
|
294
|
+
? Math.max(0, list.length + at + 1)
|
|
295
|
+
: Math.min(at, list.length);
|
|
296
|
+
this.items = [...list.slice(0, idx), item, ...list.slice(idx)];
|
|
297
|
+
if (this.aggregates)
|
|
298
|
+
this.aggregates.$count += 1;
|
|
299
|
+
}
|
|
300
|
+
/** Replace the row whose id matches `item`'s id (no `$count` change). */
|
|
301
|
+
updateItem(item) {
|
|
302
|
+
const id = this.#repo.metadata.idMetadata.getId(item);
|
|
303
|
+
this.items = this.items.map((x) => (this.#repo.metadata.idMetadata.getId(x) === id ? item : x));
|
|
304
|
+
}
|
|
305
|
+
/** Drop the matching row (pass an id or the item). -1 to `$count`. */
|
|
306
|
+
removeItem(idOrItem) {
|
|
307
|
+
if (this.#mode === 'live')
|
|
308
|
+
return;
|
|
309
|
+
this.#removeLocal(idOrItem);
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Insert-or-update `item` at its SORTED position (load mode), recomputing the index
|
|
313
|
+
* from this handle's `orderBy` plus the entity id as tiebreak. Paginate re-fetches
|
|
314
|
+
* (a row may belong to an unloaded page); live is a no-op (the liveQuery syncs).
|
|
315
|
+
*/
|
|
316
|
+
reconcile(item) {
|
|
317
|
+
if (this.#mode === 'live')
|
|
318
|
+
return;
|
|
319
|
+
if (this.#mode === 'paginate') {
|
|
320
|
+
void this.refresh();
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const id = this.#repo.metadata.idMetadata.getId(item);
|
|
324
|
+
const without = this.items.filter((x) => this.#repo.metadata.idMetadata.getId(x) !== id);
|
|
325
|
+
const cmp = this.#comparator(this.#resolve().orderBy);
|
|
326
|
+
let idx = without.findIndex((x) => cmp(item, x) < 0);
|
|
327
|
+
if (idx < 0)
|
|
328
|
+
idx = without.length;
|
|
329
|
+
this.items = [...without.slice(0, idx), item, ...without.slice(idx)];
|
|
330
|
+
}
|
|
331
|
+
// A comparator from an EntityOrderBy, with the entity id appended as a stable
|
|
332
|
+
// tiebreak (remult does the same so keyset paging is deterministic).
|
|
333
|
+
#comparator(orderBy) {
|
|
334
|
+
const entries = Object.entries(orderBy ?? {});
|
|
335
|
+
const idOf = (e) => this.#repo.metadata.idMetadata.getId(e);
|
|
336
|
+
return (a, b) => {
|
|
337
|
+
for (const [field, dir] of entries) {
|
|
338
|
+
const av = a[field];
|
|
339
|
+
const bv = b[field];
|
|
340
|
+
if (av < bv)
|
|
341
|
+
return dir === 'desc' ? 1 : -1;
|
|
342
|
+
if (av > bv)
|
|
343
|
+
return dir === 'desc' ? -1 : 1;
|
|
344
|
+
}
|
|
345
|
+
const ai = idOf(a);
|
|
346
|
+
const bi = idOf(b);
|
|
347
|
+
return ai < bi ? -1 : ai > bi ? 1 : 0;
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
#removeLocal(idOrItem) {
|
|
351
|
+
const id = idOrItem != null && typeof idOrItem === 'object'
|
|
352
|
+
? this.#repo.metadata.idMetadata.getId(idOrItem)
|
|
353
|
+
: idOrItem;
|
|
354
|
+
this.items = this.items.filter((i) => this.#repo.metadata.idMetadata.getId(i) !== id);
|
|
355
|
+
if (this.aggregates)
|
|
356
|
+
this.aggregates.$count = Math.max(0, this.aggregates.$count - 1);
|
|
357
|
+
}
|
|
358
|
+
/** After insert/update (or a `one` delete) in a non-live mode, re-fetch keeping the current count. */
|
|
359
|
+
async #resync() {
|
|
360
|
+
if (this.#mode === 'live')
|
|
361
|
+
return;
|
|
362
|
+
const keepCount = this.#mode === 'paginate' ? this.items.length || undefined : undefined;
|
|
363
|
+
await this.#load(this.#resolve(), ++this.#seq, keepCount);
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* The entity's remult metadata - the single escape hatch for everything not on
|
|
367
|
+
* this handle: permissions (`apiInsertAllowed()`, `apiUpdateAllowed(item)`,
|
|
368
|
+
* `apiDeleteAllowed(item)`, `apiReadAllowed`), `fields`, `idMetadata`, `options`,
|
|
369
|
+
* `key`. Reflects the current `remult.user`.
|
|
370
|
+
*/
|
|
371
|
+
get meta() {
|
|
372
|
+
return this.#repo.metadata;
|
|
373
|
+
}
|
|
374
|
+
/** Escape hatch to the underlying repo (count, findId, upsert, projections, ...). */
|
|
375
|
+
get repo() {
|
|
376
|
+
return this.#repo;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* Style 1 - unified composite. One handle owning a list (load/listen/paginate) AND
|
|
381
|
+
* the current editing `draft`, pre-wired: the draft handle `.syncs(list)`, so saving
|
|
382
|
+
* or deleting reconciles the list (sorted upsert / remove) and loading/error are
|
|
383
|
+
* merged across both. Proves the styles share internals: this is just a list handle
|
|
384
|
+
* plus a `.syncs()`-linked `one` handle.
|
|
385
|
+
*
|
|
386
|
+
* const t = ff(Task).many(() => ({ where }), 'load')
|
|
387
|
+
* t.edit(row) / t.create() / t.save() / t.remove(row) / t.cancel()
|
|
388
|
+
* markup reads t.items, t.draft, t.loading, t.isBusy, t.error
|
|
389
|
+
*/
|
|
390
|
+
class FF_ManyHandle {
|
|
391
|
+
#repo;
|
|
392
|
+
#idKey;
|
|
393
|
+
#list;
|
|
394
|
+
#editor;
|
|
395
|
+
#editingId = $state(null);
|
|
396
|
+
constructor(r, opts, strategy) {
|
|
397
|
+
this.#repo = r;
|
|
398
|
+
this.#idKey = r.metadata.idMetadata.field.key;
|
|
399
|
+
this.#list = new FF_RepoHandle(r, opts, strategy === 'listen' ? 'live' : strategy);
|
|
400
|
+
this.#editor = new FF_RepoHandle(r, (() => ({
|
|
401
|
+
where: { [this.#idKey]: this.#editingId ?? '' },
|
|
402
|
+
enabled: this.#editingId !== null,
|
|
403
|
+
})), 'one');
|
|
404
|
+
this.#editor.syncs(this.#list);
|
|
405
|
+
}
|
|
406
|
+
get items() {
|
|
407
|
+
return this.#list.items;
|
|
408
|
+
}
|
|
409
|
+
get draft() {
|
|
410
|
+
return this.#editor.item;
|
|
411
|
+
}
|
|
412
|
+
set draft(v) {
|
|
413
|
+
this.#editor.item = v;
|
|
414
|
+
}
|
|
415
|
+
get error() {
|
|
416
|
+
return this.#editor.error ?? this.#list.error;
|
|
417
|
+
}
|
|
418
|
+
get hasNextPage() {
|
|
419
|
+
return this.#list.hasNextPage;
|
|
420
|
+
}
|
|
421
|
+
get aggregates() {
|
|
422
|
+
return this.#list.aggregates;
|
|
423
|
+
}
|
|
424
|
+
/** Merged loading: list reads + draft writes. */
|
|
425
|
+
get loading() {
|
|
426
|
+
const l = this.#list.loading;
|
|
427
|
+
const e = this.#editor.loading;
|
|
428
|
+
return {
|
|
429
|
+
init: l.init,
|
|
430
|
+
fetching: l.fetching || e.fetching,
|
|
431
|
+
more: l.more,
|
|
432
|
+
// Targeted writes (`save(row)`/`remove(row)`) flip the list flags; argless draft
|
|
433
|
+
// writes flip the editor flags (and propagate to the list via `.syncs`). Merge both.
|
|
434
|
+
saving: e.saving || l.saving,
|
|
435
|
+
deleting: e.deleting || l.deleting,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
get isBusy() {
|
|
439
|
+
const l = this.loading;
|
|
440
|
+
return l.init || l.fetching || l.more || l.saving || l.deleting;
|
|
441
|
+
}
|
|
442
|
+
get isWriting() {
|
|
443
|
+
return this.loading.saving || this.loading.deleting;
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Load `row` into `draft` for editing. Pass the row itself (works with any PK,
|
|
447
|
+
* single or composite - the id is read off it).
|
|
448
|
+
*
|
|
449
|
+
* Default (no fetch): edits an isolated **clone** of `row` - instant, no flicker,
|
|
450
|
+
* and saving updates the original (the clone keeps remult's existing-row state).
|
|
451
|
+
* Cancelling just drops the clone, so the list row is untouched until save.
|
|
452
|
+
*
|
|
453
|
+
* `{ refetch: true }`: optimistic - put `row`'s structure into `draft` **immediately**
|
|
454
|
+
* (so a form renders at full size, no open-then-grow flicker), then re-read the row
|
|
455
|
+
* fresh from the data source and swap it in. The optimistic draft is marked as an
|
|
456
|
+
* **existing** row (rebuilt via json), so it works whether `row` is a tracked entity,
|
|
457
|
+
* a plain spread/`$state` object, or an id-only stub - and saving updates, never inserts.
|
|
458
|
+
* Use it when the list row may be stale or you only hold its id.
|
|
459
|
+
*/
|
|
460
|
+
edit(row, opts) {
|
|
461
|
+
if (opts?.refetch) {
|
|
462
|
+
this.#editor.item = this.#repo.fromJson(this.#repo.toJson(this.#repo.create(row)), false);
|
|
463
|
+
this.#editingId = this.#repo.metadata.idMetadata.getId(row);
|
|
464
|
+
}
|
|
465
|
+
else {
|
|
466
|
+
this.#editingId = null; // keep the editor's query disabled; the clone is the draft
|
|
467
|
+
this.#editor.item = this.#repo.getEntityRef(row).clone();
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
/** Start a blank `draft` (insert). */
|
|
471
|
+
create(...args) {
|
|
472
|
+
this.#editingId = null;
|
|
473
|
+
return this.#editor.create(...args);
|
|
474
|
+
}
|
|
475
|
+
/** Drop the draft / stop editing, and clear any pending error. */
|
|
476
|
+
cancel() {
|
|
477
|
+
this.#editingId = null;
|
|
478
|
+
this.#editor.item = undefined;
|
|
479
|
+
this.#editor.error = undefined;
|
|
480
|
+
}
|
|
481
|
+
/** Save `target` (any row) or, argless, the current `draft`; reconciles the list. */
|
|
482
|
+
async save(target) {
|
|
483
|
+
if (target !== undefined) {
|
|
484
|
+
// Route through the list handle's loading/error machinery (same as the argless path),
|
|
485
|
+
// then reconcile - so a targeted save flips `loading.saving` and fills/clears `error`.
|
|
486
|
+
return this.#list.saveRow(target);
|
|
487
|
+
}
|
|
488
|
+
const saved = await this.#editor.save();
|
|
489
|
+
this.cancel();
|
|
490
|
+
return saved;
|
|
491
|
+
}
|
|
492
|
+
/** Delete `target` (any row) or, argless, the current `draft`; reconciles the list. */
|
|
493
|
+
async remove(target) {
|
|
494
|
+
if (target !== undefined) {
|
|
495
|
+
// Route through the list handle's loading/error machinery (same as the argless path),
|
|
496
|
+
// then drop the row - so a targeted remove flips `loading.deleting` and fills/clears `error`.
|
|
497
|
+
await this.#list.deleteRow(target);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
await this.#editor.delete();
|
|
501
|
+
this.cancel();
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Confirm, then remove `row`. Resolves `{ ok: true }` when removed, `{ ok: false }` when the
|
|
505
|
+
* user cancels OR the delete fails (a failure also fills `error` and, unless `toast: false`,
|
|
506
|
+
* shows `toast.fromError`). Never re-throws - safe for `onclick={() => list.confirmRemove(row)}`.
|
|
507
|
+
*/
|
|
508
|
+
async confirmRemove(row, opts) {
|
|
509
|
+
const c = await dialog.confirm(opts?.message ?? 'Delete this item?', {
|
|
510
|
+
title: opts?.title,
|
|
511
|
+
confirmLabel: opts?.confirmLabel,
|
|
512
|
+
cancelLabel: opts?.cancelLabel,
|
|
513
|
+
danger: opts?.danger ?? true,
|
|
514
|
+
});
|
|
515
|
+
if (!c.ok)
|
|
516
|
+
return { ok: false };
|
|
517
|
+
try {
|
|
518
|
+
await this.remove(row);
|
|
519
|
+
return { ok: true, data: undefined };
|
|
520
|
+
}
|
|
521
|
+
catch (e) {
|
|
522
|
+
if (opts?.toast !== false) {
|
|
523
|
+
// Lazy import keeps `ff` users who never toast from pulling svelte-sonner.
|
|
524
|
+
const { toast } = await import('./toast.js');
|
|
525
|
+
toast.fromError(e);
|
|
526
|
+
}
|
|
527
|
+
return { ok: false };
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Edit `row` in a dialog: seed `draft` (a clone, or `{ refetch: true }` to re-read fresh),
|
|
532
|
+
* open `body`, and always `cancel()` on close. The `body` snippet binds `draft` and calls
|
|
533
|
+
* `save()` itself (so a failed/validation save keeps the dialog open via `error`); this method
|
|
534
|
+
* owns only the seed + cleanup. Resolves the dialog's `DialogResult`.
|
|
535
|
+
*/
|
|
536
|
+
async editInDialog(row, body, opts) {
|
|
537
|
+
this.edit(row, { refetch: opts?.refetch });
|
|
538
|
+
try {
|
|
539
|
+
return await dialog.show(body, opts);
|
|
540
|
+
}
|
|
541
|
+
finally {
|
|
542
|
+
this.cancel();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Create in a dialog: start a blank `draft` (optionally seeded with `defaults`), open `body`,
|
|
547
|
+
* and always `cancel()` on close. The `body` binds `draft` and calls `save()` itself.
|
|
548
|
+
*/
|
|
549
|
+
async createInDialog(body, opts) {
|
|
550
|
+
this.create(opts?.defaults);
|
|
551
|
+
try {
|
|
552
|
+
return await dialog.show(body, opts);
|
|
553
|
+
}
|
|
554
|
+
finally {
|
|
555
|
+
this.cancel();
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
more() {
|
|
559
|
+
return this.#list.more();
|
|
560
|
+
}
|
|
561
|
+
refresh() {
|
|
562
|
+
return this.#list.refresh();
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* Seed editable state once, from the latest row (`items[0]`), the first time one
|
|
566
|
+
* lands - then never again, so a later live tick can't clobber an in-progress edit.
|
|
567
|
+
* Use it when the seed must become independently editable (a separate `$state` /
|
|
568
|
+
* `$bindable` the user then mutates), not the draft; for pure display prefer
|
|
569
|
+
* `$derived(handle.items[0])`. Delegates to the list handle (see `onFirst` there).
|
|
570
|
+
*/
|
|
571
|
+
onFirst(fn) {
|
|
572
|
+
this.#list.onFirst(fn);
|
|
573
|
+
}
|
|
574
|
+
get meta() {
|
|
575
|
+
return this.#repo.metadata;
|
|
576
|
+
}
|
|
577
|
+
get repo() {
|
|
578
|
+
return this.#repo;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* `ff(E)` - the firstly reactive layer. `ff(E).many(getter, strategy?)` for a list+edit
|
|
583
|
+
* composite, `ff(E).one(getter)` for a single record. Everything imperative stays on
|
|
584
|
+
* remult's `repo(E)`.
|
|
585
|
+
*/
|
|
586
|
+
export function ff(entity) {
|
|
587
|
+
const r = remultRepo(entity);
|
|
588
|
+
return {
|
|
589
|
+
many(o, strategy) {
|
|
590
|
+
return new FF_ManyHandle(r, o, strategy ?? 'paginate');
|
|
591
|
+
},
|
|
592
|
+
one(o) {
|
|
593
|
+
return new FF_RepoHandle(r, o, 'one');
|
|
594
|
+
},
|
|
595
|
+
get meta() {
|
|
596
|
+
return r.metadata;
|
|
597
|
+
},
|
|
598
|
+
};
|
|
599
|
+
}
|
package/esm/svelte/index.d.ts
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export type {
|
|
1
|
+
export { ff } from './ff.svelte.js';
|
|
2
|
+
export type { FF_Many, FF_One, FF_Builder, FF_RepoOptions, FF_RepoLoading, ManyStrategy, AggregateOptions, QueryOptionsHelper, } from './ff.svelte.js';
|
|
3
3
|
export { infiniteScroll } from './infiniteScroll.js';
|
|
4
4
|
export type { InfiniteScrollOptions } from './infiniteScroll.js';
|
|
5
|
+
export { dialog, ffAutofocus, resolveMessage } from './dialog.svelte.js';
|
|
6
|
+
export type { DialogResult, DialogClose, DialogOptions, DialogItem, DialogRender, ConfirmItem, PromptItem, DialogShellArgs, DialogConfirmArgs, DialogPromptArgs, } from './dialog.svelte.js';
|
|
7
|
+
export type { LocalizedMessage } from '../core/FF_Validators.js';
|
|
8
|
+
export { default as FF_DialogManager } from './FF_DialogManager.svelte';
|
|
9
|
+
export { default as FF_Config } from './FF_Config.svelte';
|
|
10
|
+
export { ffConfig, setFFConfig } from './FF_Config.svelte.js';
|
|
11
|
+
export type { FF_ConfigValue } from './FF_Config.svelte.js';
|
|
12
|
+
export { toast } from './toast.js';
|
|
13
|
+
export type { ToastKind, ToastOptions } from './toast.js';
|
|
14
|
+
export { default as FF_ToastManager } from './FF_ToastManager.svelte';
|
|
5
15
|
export { SP } from './class/SP.svelte';
|
|
6
16
|
export type { ParamDefinition } from './class/SP.svelte';
|
|
7
17
|
export { initRemultSvelteReactivity } from './initRemultSvelteReactivity';
|
|
18
|
+
export { default as DemoGrid } from './DemoGrid.svelte';
|
|
19
|
+
export { default as DemoForm } from './DemoForm.svelte';
|
|
8
20
|
export { default as Icon } from './ui/Icon.svelte';
|
|
9
21
|
export { LibIcon_Empty, LibIcon_Forbidden, LibIcon_ChevronDown, LibIcon_ChevronUp, LibIcon_ChevronLeft, LibIcon_ChevronRight, LibIcon_Search, LibIcon_Check, LibIcon_MultiCheck, LibIcon_Add, LibIcon_MultiAdd, LibIcon_Edit, LibIcon_Eye, LibIcon_EyeOff, LibIcon_Delete, LibIcon_Cross, LibIcon_Save, LibIcon_Man, LibIcon_Woman, LibIcon_Send, LibIcon_Load, LibIcon_Settings, LibIcon_Sort, LibIcon_SortAsc, LibIcon_SortDesc, } from './ui/LibIcon.js';
|
package/esm/svelte/index.js
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export { ff } from './ff.svelte.js';
|
|
2
2
|
export { infiniteScroll } from './infiniteScroll.js';
|
|
3
|
+
export { dialog, ffAutofocus, resolveMessage } from './dialog.svelte.js';
|
|
4
|
+
export { default as FF_DialogManager } from './FF_DialogManager.svelte';
|
|
5
|
+
export { default as FF_Config } from './FF_Config.svelte';
|
|
6
|
+
export { ffConfig, setFFConfig } from './FF_Config.svelte.js';
|
|
7
|
+
export { toast } from './toast.js';
|
|
8
|
+
export { default as FF_ToastManager } from './FF_ToastManager.svelte';
|
|
3
9
|
export { SP } from './class/SP.svelte';
|
|
4
10
|
export { initRemultSvelteReactivity } from './initRemultSvelteReactivity';
|
|
11
|
+
export { default as DemoGrid } from './DemoGrid.svelte';
|
|
12
|
+
export { default as DemoForm } from './DemoForm.svelte';
|
|
5
13
|
export { default as Icon } from './ui/Icon.svelte';
|
|
6
14
|
export { LibIcon_Empty, LibIcon_Forbidden, LibIcon_ChevronDown, LibIcon_ChevronUp, LibIcon_ChevronLeft, LibIcon_ChevronRight, LibIcon_Search, LibIcon_Check, LibIcon_MultiCheck, LibIcon_Add, LibIcon_MultiAdd, LibIcon_Edit, LibIcon_Eye, LibIcon_EyeOff, LibIcon_Delete, LibIcon_Cross, LibIcon_Save, LibIcon_Man, LibIcon_Woman, LibIcon_Send, LibIcon_Load, LibIcon_Settings, LibIcon_Sort, LibIcon_SortAsc, LibIcon_SortDesc, } from './ui/LibIcon.js';
|
|
@@ -18,7 +18,7 @@ export type InfiniteScrollOptions = {
|
|
|
18
18
|
* observes geometry it never misfires on mount: a full first page leaves the
|
|
19
19
|
* sentinel far below the root.
|
|
20
20
|
*
|
|
21
|
-
* Pairs with `
|
|
21
|
+
* Pairs with `ff(E).many(..., 'paginate')`:
|
|
22
22
|
*
|
|
23
23
|
* ```svelte
|
|
24
24
|
* <div {@attach infiniteScroll({
|
|
@@ -18,7 +18,7 @@ function scrollParent(el) {
|
|
|
18
18
|
* observes geometry it never misfires on mount: a full first page leaves the
|
|
19
19
|
* sentinel far below the root.
|
|
20
20
|
*
|
|
21
|
-
* Pairs with `
|
|
21
|
+
* Pairs with `ff(E).many(..., 'paginate')`:
|
|
22
22
|
*
|
|
23
23
|
* ```svelte
|
|
24
24
|
* <div {@attach infiniteScroll({
|