firstly 0.4.5 → 0.5.1

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 CHANGED
@@ -1,5 +1,31 @@
1
1
  # firstly
2
2
 
3
+ ## 0.5.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [#282](https://github.com/jycouet/firstly/pull/282) [`a93d4c8`](https://github.com/jycouet/firstly/commit/a93d4c8815da30f2c4d701ddd7e3d8cdf39fd8f5) Thanks [@jycouet](https://github.com/jycouet)! - ffRepo (svelte): leaner surface. Rename `firstOnce` → `onFirst`; remove `draft`, `first`, `insert`, `update`, `deleteMany`. List handles (`load`/`listen`/`paginate`) are now read-only - write via `.repo` (+ `addItem`/`updateItem`/`removeItem` to reconcile). Editing lives on `one`/`create()` with argless `save()`/`delete()`.
8
+
9
+ New `DemoGrid` (from `firstly/svelte`): a generic inline-CRUD table over any entity - props `entity` + `fields`, headers/placeholders from each field's `caption`.
10
+
11
+ ## 0.5.0
12
+
13
+ ### Minor Changes
14
+
15
+ - [#274](https://github.com/jycouet/firstly/pull/274) [`6114148`](https://github.com/jycouet/firstly/commit/61141482a06f0a006ae148f0645146c073a2fb3c) Thanks [@jycouet](https://github.com/jycouet)! - **BREAKING (svelte): `FF_Repo` class -> `ffRepo()` factory.** The reactive repo wrapper now takes a reactive options getter and a mode (`load` / `listen` / `paginate` / `one`), with built-in mutations (no-arg `save()`/`delete()` target the loaded `item`), client-side list reconcilers (`addItem`/`updateItem`/`removeItem`, with `addItem` positioning), `firstOnce`/`draft`, and permissions via `r.meta`. The old `new FF_Repo(E, { findOptions })` class is removed.
16
+
17
+ One rule: anything not under `.repo` is reactive; every imperative read/write lives on `.repo` (the plain remult repo). The builder no longer hoists `findFirst`/`findId`/`insert`/... - use `ffRepo(E).repo.*`.
18
+
19
+ Also new: `infiniteScroll` (svelte attachment, pairs with `paginate`), `stackHttpClient`/`withHeader` (core), `FF_Filter.containsWords` (multi-field search filter), `splitTrim` (formats), and exported types including the umbrella `FF_Repo` handle plus `FF_RepoBuilder`/`FF_RepoLoad`/`FF_RepoLive`/`FF_RepoPaginate`/`FF_RepoOne`/`QueryOptionsHelper`/`AggregateOptions`.
20
+
21
+ Migration (see `/docs/svelte/ff-repo`):
22
+ - `new FF_Repo(E, { findOptions: { where } })` -> `ffRepo(E).load(() => ({ where }))`
23
+ - `new FF_Repo(E, { queryOptions })` + `.query()/.queryMore()/.queryRefresh()` -> `ffRepo(E).paginate(() => ({ ... }))` + `.more()/.refresh()`
24
+ - `r.globalError` -> `r.error`
25
+ - `r.fields` -> `r.meta.fields`; `r.metadata.apiInsertAllowed()` -> `r.meta.apiInsertAllowed()`
26
+ - `repo(r.ent).update(...)` / `.insert(...)` -> `r.update(...)` / `r.insert(...)`
27
+ - `r.aggregates.$count` unchanged; `skipAutoFetch` -> `enabled: false`
28
+
3
29
  ## 0.4.5
4
30
 
5
31
  ### Patch Changes
@@ -1,7 +1,9 @@
1
1
  /**
2
- * Prefilter helpers (for `apiPrefilter`, `backendPrefilter`).
2
+ * Filter helpers that build a remult `EntityFilter`.
3
3
  *
4
- * Pair with `FF_Allow` (the equivalent for `allowApi*` row checks).
4
+ * `owner` / `ownerOr` are prefilters (for `apiPrefilter`, `backendPrefilter`) -
5
+ * pair with `FF_Allow` (the equivalent for `allowApi*` row checks). `containsWords`
6
+ * is a search-box helper (pairs with `ffRepo(...).load/paginate`).
5
7
  *
6
8
  * Pass the entity type as a generic (`FF_Filter.owner<Task>('userId')`) to get
7
9
  * autocompletion and type-safety on the column name. Without a generic the
@@ -52,4 +54,17 @@ export declare const FF_Filter: {
52
54
  col?: keyof T & string;
53
55
  roles: string[];
54
56
  }) => any;
57
+ /**
58
+ * Build a search filter where every word must match (AND) and each word may
59
+ * match any of the given fields (OR), case-insensitive `$contains`. Word order
60
+ * and which field holds which word don't matter - handy for "NOM Prénom" search.
61
+ *
62
+ * @example
63
+ * ```ts
64
+ * const r = ffRepo(User).load(() => ({
65
+ * where: FF_Filter.containsWords([repo(User).fields.name, repo(User).fields.sesa], q),
66
+ * }))
67
+ * ```
68
+ */
69
+ containsWords: <Entity>(fields: import("remult").FieldMetadata<unknown, Entity>[], search: string) => import("remult").EntityFilter<Entity>;
55
70
  };
@@ -1,8 +1,11 @@
1
1
  import { remult } from 'remult';
2
+ import { containsWords } from './containsWords.js';
2
3
  /**
3
- * Prefilter helpers (for `apiPrefilter`, `backendPrefilter`).
4
+ * Filter helpers that build a remult `EntityFilter`.
4
5
  *
5
- * Pair with `FF_Allow` (the equivalent for `allowApi*` row checks).
6
+ * `owner` / `ownerOr` are prefilters (for `apiPrefilter`, `backendPrefilter`) -
7
+ * pair with `FF_Allow` (the equivalent for `allowApi*` row checks). `containsWords`
8
+ * is a search-box helper (pairs with `ffRepo(...).load/paginate`).
6
9
  *
7
10
  * Pass the entity type as a generic (`FF_Filter.owner<Task>('userId')`) to get
8
11
  * autocompletion and type-safety on the column name. Without a generic the
@@ -54,4 +57,17 @@ export const FF_Filter = {
54
57
  return {};
55
58
  return { [col]: [remult.user?.id] };
56
59
  },
60
+ /**
61
+ * Build a search filter where every word must match (AND) and each word may
62
+ * match any of the given fields (OR), case-insensitive `$contains`. Word order
63
+ * and which field holds which word don't matter - handy for "NOM Prénom" search.
64
+ *
65
+ * @example
66
+ * ```ts
67
+ * const r = ffRepo(User).load(() => ({
68
+ * where: FF_Filter.containsWords([repo(User).fields.name, repo(User).fields.sesa], q),
69
+ * }))
70
+ * ```
71
+ */
72
+ containsWords,
57
73
  };
@@ -0,0 +1,13 @@
1
+ import type { EntityFilter, FieldMetadata } from 'remult';
2
+ /**
3
+ * Builds a filter where every word in `search` must match (AND), and each word
4
+ * may match any of the given fields (OR), using case-insensitive `$contains`.
5
+ *
6
+ * E.g. "dupont marie" over [name, sesa] =>
7
+ * (name~dupont OR sesa~dupont) AND (name~marie OR sesa~marie)
8
+ *
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 `ffRepo`:
11
+ * `ffRepo(User).load(() => ({ where: containsWords([f.name, f.sesa], q) }))`.
12
+ */
13
+ export declare const containsWords: <Entity>(fields: FieldMetadata<unknown, Entity>[], search: string) => EntityFilter<Entity>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Builds a filter where every word in `search` must match (AND), and each word
3
+ * may match any of the given fields (OR), using case-insensitive `$contains`.
4
+ *
5
+ * E.g. "dupont marie" over [name, sesa] =>
6
+ * (name~dupont OR sesa~dupont) AND (name~marie OR sesa~marie)
7
+ *
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 `ffRepo`:
10
+ * `ffRepo(User).load(() => ({ where: containsWords([f.name, f.sesa], q) }))`.
11
+ */
12
+ export const containsWords = (fields, search) => {
13
+ const words = (search ?? '').split(' ').filter((s) => s.length > 0);
14
+ if (words.length === 0 || fields.length === 0) {
15
+ return {};
16
+ }
17
+ if (fields.length === 1) {
18
+ return {
19
+ $and: words.map((s) => ({ [fields[0].key]: { $contains: s } })),
20
+ };
21
+ }
22
+ return {
23
+ $and: words.map((s) => ({
24
+ $or: fields.map((f) => ({ [f.key]: { $contains: s } })),
25
+ })),
26
+ };
27
+ };
@@ -0,0 +1,18 @@
1
+ export type HttpClientFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
2
+ export type HttpClientMiddleware = (next: HttpClientFetch) => HttpClientFetch;
3
+ /**
4
+ * Compose `fetch` middlewares into a single `fetch`-shaped function. Middlewares
5
+ * run outermost-first (the first argument wraps the rest). Useful to inject auth
6
+ * headers, correlation ids, retries, logging, ... around a base `fetch`.
7
+ *
8
+ * ```ts
9
+ * const client = stackHttpClient(
10
+ * withHeader('x-correlation-id', () => crypto.randomUUID()),
11
+ * withHeader('authorization', () => `Bearer ${token}`),
12
+ * )
13
+ * await client('/api/...')
14
+ * ```
15
+ */
16
+ export declare function stackHttpClient(...middlewares: HttpClientMiddleware[]): HttpClientFetch;
17
+ /** Middleware that sets a request header from `getValue()` when it returns a value. */
18
+ export declare function withHeader(name: string, getValue: () => string | undefined | null): HttpClientMiddleware;
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Compose `fetch` middlewares into a single `fetch`-shaped function. Middlewares
3
+ * run outermost-first (the first argument wraps the rest). Useful to inject auth
4
+ * headers, correlation ids, retries, logging, ... around a base `fetch`.
5
+ *
6
+ * ```ts
7
+ * const client = stackHttpClient(
8
+ * withHeader('x-correlation-id', () => crypto.randomUUID()),
9
+ * withHeader('authorization', () => `Bearer ${token}`),
10
+ * )
11
+ * await client('/api/...')
12
+ * ```
13
+ */
14
+ export function stackHttpClient(...middlewares) {
15
+ return middlewares.reduceRight((next, mw) => mw(next), (input, init) => fetch(input, init));
16
+ }
17
+ /** Middleware that sets a request header from `getValue()` when it returns a value. */
18
+ export function withHeader(name, getValue) {
19
+ return (next) => async (input, init) => {
20
+ const headers = new Headers(init?.headers);
21
+ const value = getValue();
22
+ if (value)
23
+ headers.set(name, value);
24
+ return next(input, { ...init, headers });
25
+ };
26
+ }
@@ -1,4 +1,4 @@
1
1
  export { displayCurrency, displayCurrencyK, displayPercent, displayCurrencyWOSuffix, } from './numbers.js';
2
- export { formatNumber, extractMailInfo, slugify, nameify, displayPhone, suffixWithS, arrToStr, mask, toTitleCase, } from './strings.js';
2
+ export { formatNumber, extractMailInfo, slugify, nameify, displayPhone, suffixWithS, arrToStr, mask, toTitleCase, splitTrim, } from './strings.js';
3
3
  export { offsetedToPlainDate, plainDateCompare, isBetween, dateISOToPlainDate } from './dates.js';
4
4
  export type { KitPlainDateRange } from './dates.js';
@@ -1,3 +1,3 @@
1
1
  export { displayCurrency, displayCurrencyK, displayPercent, displayCurrencyWOSuffix, } from './numbers.js';
2
- export { formatNumber, extractMailInfo, slugify, nameify, displayPhone, suffixWithS, arrToStr, mask, toTitleCase, } from './strings.js';
2
+ export { formatNumber, extractMailInfo, slugify, nameify, displayPhone, suffixWithS, arrToStr, mask, toTitleCase, splitTrim, } from './strings.js';
3
3
  export { offsetedToPlainDate, plainDateCompare, isBetween, dateISOToPlainDate } from './dates.js';
@@ -1,3 +1,10 @@
1
+ /**
2
+ * Split a string into a flat list of trimmed, non-empty tokens. The default
3
+ * separator handles comma- and newline-separated input.
4
+ *
5
+ * `"a, b\n,c"` -> `["a", "b", "c"]`
6
+ */
7
+ export declare const splitTrim: (input?: string | null, separator?: string | RegExp) => string[];
1
8
  export declare const formatNumber: (number: number, digitNumber?: number) => string;
2
9
  export declare function displayPhone(_entity: any, value: string | undefined): string;
3
10
  export declare const arrToStr: (arr: string | undefined | (string | undefined)[]) => string;
@@ -1,3 +1,15 @@
1
+ /** Default separator for `splitTrim`: any run of commas and/or newlines. */
2
+ const COMMA_OR_NEWLINE = /[,\n]+/;
3
+ /**
4
+ * Split a string into a flat list of trimmed, non-empty tokens. The default
5
+ * separator handles comma- and newline-separated input.
6
+ *
7
+ * `"a, b\n,c"` -> `["a", "b", "c"]`
8
+ */
9
+ export const splitTrim = (input, separator = COMMA_OR_NEWLINE) => (input ?? '')
10
+ .split(separator)
11
+ .map((s) => s.trim())
12
+ .filter(Boolean);
1
13
  export const formatNumber = (number, digitNumber = 2) => {
2
14
  if (number === undefined || number === null || isNaN(number)) {
3
15
  const value = 0;
package/esm/index.d.ts CHANGED
@@ -15,6 +15,8 @@ export { errorMessage, isError } from './core/helper.js';
15
15
  export { tryCatch, tryCatchSync } from './core/tryCatch.js';
16
16
  export type { ResolvedType, UnArray, RecursivePartial } from './core/types.js';
17
17
  export { tw } from './core/tailwind.js';
18
+ export { stackHttpClient, withHeader } from './core/httpClientStack.js';
19
+ export type { HttpClientFetch, HttpClientMiddleware } from './core/httpClientStack.js';
18
20
  export { FF_LogToConsole } from './SqlDatabase/FF_LogToConsole.js';
19
21
  export { FilterEntity } from './virtual/FilterEntity.js';
20
22
  export { UIEntity } from './virtual/UIEntity.js';
package/esm/index.js CHANGED
@@ -15,6 +15,7 @@ export { FF_Validators, createValidators } from './core/FF_Validators.js';
15
15
  export { errorMessage, isError } from './core/helper.js';
16
16
  export { tryCatch, tryCatchSync } from './core/tryCatch.js';
17
17
  export { tw } from './core/tailwind.js';
18
+ export { stackHttpClient, withHeader } from './core/httpClientStack.js';
18
19
  // Misc primitives still exposed from the root.
19
20
  export { FF_LogToConsole } from './SqlDatabase/FF_LogToConsole.js';
20
21
  export { FilterEntity } from './virtual/FilterEntity.js';
@@ -0,0 +1,167 @@
1
+ <script lang="ts" generics="T extends { id: string }">
2
+ import type { ClassType, EntityFilter, MembersOnly } from 'remult'
3
+
4
+ import { ffRepo } from './FF_Repo.svelte.js'
5
+
6
+ type Props = {
7
+ /** The remult entity class to CRUD. */
8
+ entity: ClassType<T>
9
+ /** Fields to show as columns / edit inputs. Headers come from each field's `caption`. */
10
+ fields: (keyof T & string)[]
11
+ }
12
+ let { entity, fields }: Props = $props()
13
+
14
+ // live list - any write re-emits it, so no reconcile code (entity is static config)
15
+ const list = ffRepo(entity).listen(() => ({}))
16
+
17
+ // the row being edited (by id), or a fresh draft for "new" - one reactive slot
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)
24
+
25
+ // dynamic read/write of a field by key (v1: inputs are text)
26
+ const get = (row: T, f: keyof T & string) => (row as Record<string, unknown>)[f]
27
+ function setField(f: keyof T & string, value: string) {
28
+ if (editor.item) (editor.item as Record<string, unknown>)[f] = value
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
48
+ }
49
+ </script>
50
+
51
+ {#snippet editRow(draft: T)}
52
+ {#each fields as f (f)}
53
+ <td>
54
+ <input
55
+ placeholder={list.meta.fields.find(f)?.caption ?? f}
56
+ value={String(get(draft, f) ?? '')}
57
+ oninput={(e) => setField(f, e.currentTarget.value)}
58
+ />
59
+ </td>
60
+ {/each}
61
+ <td class="actions">
62
+ <button disabled={editor.loading.saving} onclick={save}>Save</button>
63
+ <button onclick={cancel}>Cancel</button>
64
+ </td>
65
+ {/snippet}
66
+
67
+ <div class="crud">
68
+ <button class="new" onclick={add}>+ New</button>
69
+
70
+ <table>
71
+ <thead>
72
+ <tr>
73
+ {#each fields as f (f)}<th>{list.meta.fields.find(f)?.caption ?? f}</th>{/each}
74
+ <th></th>
75
+ </tr>
76
+ </thead>
77
+ <tbody>
78
+ {#if creating && editor.item}
79
+ <tr class="editing">{@render editRow(editor.item)}</tr>
80
+ {/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
+ </tbody>
95
+ </table>
96
+
97
+ {#if list.loading.init}
98
+ <p class="muted">Loading…</p>
99
+ {:else if list.items.length === 0 && !creating}
100
+ <p class="muted">Nothing yet - hit “+ New”.</p>
101
+ {/if}
102
+ </div>
103
+
104
+ <style>
105
+ .crud {
106
+ font-size: 14px;
107
+ display: flex;
108
+ flex-direction: column;
109
+ gap: 10px;
110
+ align-items: start;
111
+ }
112
+ table {
113
+ border-collapse: collapse;
114
+ width: 100%;
115
+ }
116
+ th,
117
+ td {
118
+ border: 1px solid color-mix(in srgb, currentColor 18%, transparent);
119
+ padding: 7px 10px;
120
+ text-align: left;
121
+ vertical-align: middle;
122
+ }
123
+ th {
124
+ font-weight: 600;
125
+ background: color-mix(in srgb, currentColor 7%, transparent);
126
+ }
127
+ tr.editing {
128
+ background: color-mix(in srgb, currentColor 5%, transparent);
129
+ }
130
+ td.actions {
131
+ white-space: nowrap;
132
+ text-align: right;
133
+ }
134
+ td.actions button + button {
135
+ margin-left: 6px;
136
+ }
137
+ input {
138
+ width: 100%;
139
+ box-sizing: border-box;
140
+ padding: 5px 7px;
141
+ font: inherit;
142
+ color: inherit;
143
+ background: transparent;
144
+ border: 1px solid color-mix(in srgb, currentColor 30%, transparent);
145
+ border-radius: 6px;
146
+ }
147
+ button {
148
+ cursor: pointer;
149
+ font: inherit;
150
+ padding: 4px 11px;
151
+ color: inherit;
152
+ background: color-mix(in srgb, currentColor 6%, transparent);
153
+ border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
154
+ border-radius: 6px;
155
+ transition: background 0.12s ease;
156
+ }
157
+ button:hover {
158
+ background: color-mix(in srgb, currentColor 14%, transparent);
159
+ }
160
+ button:disabled {
161
+ opacity: 0.45;
162
+ cursor: not-allowed;
163
+ }
164
+ .muted {
165
+ opacity: 0.6;
166
+ }
167
+ </style>
@@ -0,0 +1,40 @@
1
+ import type { ClassType } from 'remult';
2
+ declare function $$render<T extends {
3
+ id: string;
4
+ }>(): {
5
+ props: {
6
+ /** The remult entity class to CRUD. */
7
+ entity: ClassType<T>;
8
+ /** Fields to show as columns / edit inputs. Headers come from each field's `caption`. */
9
+ fields: (keyof T & string)[];
10
+ };
11
+ exports: {};
12
+ bindings: "";
13
+ slots: {};
14
+ events: {};
15
+ };
16
+ declare class __sveltets_Render<T extends {
17
+ id: string;
18
+ }> {
19
+ props(): ReturnType<typeof $$render<T>>['props'];
20
+ events(): ReturnType<typeof $$render<T>>['events'];
21
+ slots(): ReturnType<typeof $$render<T>>['slots'];
22
+ bindings(): "";
23
+ exports(): {};
24
+ }
25
+ interface $$IsomorphicComponent {
26
+ new <T extends {
27
+ id: string;
28
+ }>(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']>> & {
29
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
30
+ } & ReturnType<__sveltets_Render<T>['exports']>;
31
+ <T extends {
32
+ id: string;
33
+ }>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
34
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
35
+ }
36
+ declare const DemoGrid: $$IsomorphicComponent;
37
+ type DemoGrid<T extends {
38
+ id: string;
39
+ }> = InstanceType<typeof DemoGrid<T>>;
40
+ export default DemoGrid;