gg-wf-scripts 1.0.0 → 2.4.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/README.md +132 -19
- package/package.json +2 -5
- package/src/action-engine.js +14 -8
- package/src/actions.js +4 -3
- package/src/auth.js +9 -9
- package/src/data-engine.js +64 -58
- package/src/form-action-engine.js +149 -0
- package/src/form-actions.js +21 -0
- package/src/helpers/log.js +34 -0
- package/src/index.js +19 -11
- package/src/queries.js +4 -2
- package/src/query-params.js +67 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# gg-wf-scripts
|
|
2
2
|
|
|
3
|
-
A declarative attribute engine for Webflow sites
|
|
3
|
+
A declarative attribute engine for Webflow sites. Add `gg-*` attributes to your markup and the library handles data binding, URL-driven state, dialogs, auth gating, form visibility, and user actions. Backend-agnostic — bring your own client (Supabase, fetch, anything).
|
|
4
4
|
|
|
5
5
|
## Install
|
|
6
6
|
|
|
@@ -14,21 +14,29 @@ Create a site-specific entry file, register your queries and actions, then bundl
|
|
|
14
14
|
|
|
15
15
|
```js
|
|
16
16
|
import { init } from "gg-wf-scripts";
|
|
17
|
+
import { createClient } from "@supabase/supabase-js";
|
|
18
|
+
|
|
19
|
+
const sb = createClient("https://your-project.supabase.co", "your-publishable-key");
|
|
17
20
|
|
|
18
21
|
const app = init({
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
context: { sb },
|
|
23
|
+
auth: {
|
|
24
|
+
getUser: async () => (await sb.auth.getUser()).data.user?.id ?? null,
|
|
25
|
+
onChange: (cb) => sb.auth.onAuthStateChange((_e, session) => cb(session?.user?.id ?? null)),
|
|
26
|
+
},
|
|
21
27
|
});
|
|
22
28
|
|
|
23
|
-
app.addQuery("posts_list", async (sb) => {
|
|
29
|
+
app.addQuery("posts_list", async ({ sb }, params) => {
|
|
30
|
+
const q = params.get("q") ?? "";
|
|
24
31
|
const { data } = await sb
|
|
25
32
|
.from("posts")
|
|
26
33
|
.select("*")
|
|
34
|
+
.ilike("title", `%${q}%`)
|
|
27
35
|
.order("created_at", { ascending: false });
|
|
28
36
|
return data ?? [];
|
|
29
37
|
});
|
|
30
38
|
|
|
31
|
-
app.addAction("delete_post", async (sb, { id }) => {
|
|
39
|
+
app.addAction("delete_post", async ({ sb }, { id }) => {
|
|
32
40
|
const { error } = await sb.from("posts").delete().eq("id", id);
|
|
33
41
|
return error ? { ok: false, error } : { ok: true };
|
|
34
42
|
});
|
|
@@ -100,6 +108,25 @@ Read and write URL query params declaratively.
|
|
|
100
108
|
<button gg-query-remove="modal,id">Close</button>
|
|
101
109
|
```
|
|
102
110
|
|
|
111
|
+
#### Two-way input binding
|
|
112
|
+
|
|
113
|
+
Mirror an input's value into a URL param as the user types. Empty value removes the param. The input is also populated from the URL on load and back/forward navigation.
|
|
114
|
+
|
|
115
|
+
```html
|
|
116
|
+
<!-- Bind to ?q=... with a 300ms debounce -->
|
|
117
|
+
<input gg-query-bind="q" gg-query-debounce="300" placeholder="Search..." />
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Combine with `gg-data-on` to re-run a query as the user types:
|
|
121
|
+
|
|
122
|
+
```html
|
|
123
|
+
<input gg-query-bind="q" gg-query-debounce="300" />
|
|
124
|
+
|
|
125
|
+
<ul gg-data-list="search_posts" gg-data-on="q">
|
|
126
|
+
<li gg-list-template><span gg-field="title"></span></li>
|
|
127
|
+
</ul>
|
|
128
|
+
```
|
|
129
|
+
|
|
103
130
|
### Content switcher
|
|
104
131
|
|
|
105
132
|
Show/hide children based on a state value.
|
|
@@ -139,7 +166,7 @@ Setting `?modal=...` opens the dialog. Removing it (or pressing Escape, or click
|
|
|
139
166
|
|
|
140
167
|
### Auth and role gating
|
|
141
168
|
|
|
142
|
-
Show or hide elements based on
|
|
169
|
+
Show or hide elements based on auth state. You provide the auth adapter, so any backend works.
|
|
143
170
|
|
|
144
171
|
```html
|
|
145
172
|
<a href="/login" gg-auth="false">Log in</a>
|
|
@@ -149,6 +176,81 @@ Show or hide elements based on Supabase auth state.
|
|
|
149
176
|
|
|
150
177
|
`gg-auth` is set on `<body>` as `"true"` or `"false"`. `gg-role` is set if you provide a `roleQuery` (see [Auth config](#auth-config)).
|
|
151
178
|
|
|
179
|
+
### Form actions
|
|
180
|
+
|
|
181
|
+
Override a form's submit to run a registered handler instead of the browser default. The handler receives the form's `FormData` directly.
|
|
182
|
+
|
|
183
|
+
```html
|
|
184
|
+
<form gg-form-action="create_post">
|
|
185
|
+
<input name="title" required />
|
|
186
|
+
<textarea name="body"></textarea>
|
|
187
|
+
<button type="submit">Save</button>
|
|
188
|
+
</form>
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
```js
|
|
192
|
+
app.addFormAction("create_post", async ({ sb }, formData) => {
|
|
193
|
+
const { error } = await sb.from("posts").insert({
|
|
194
|
+
title: formData.get("title"),
|
|
195
|
+
body: formData.get("body"),
|
|
196
|
+
});
|
|
197
|
+
return error ? { ok: false, error } : { ok: true };
|
|
198
|
+
});
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The handler receives `(context, formData, params)`. `preventDefault` is called automatically — the form will not submit to its `action` URL. Return `{ ok: true }` or `{ ok: false, error }`.
|
|
202
|
+
|
|
203
|
+
#### Validation errors
|
|
204
|
+
|
|
205
|
+
Form actions can return validation errors and the engine will display them via attributes on your markup.
|
|
206
|
+
|
|
207
|
+
```js
|
|
208
|
+
app.addFormAction("create_post", async ({ sb }, formData) => {
|
|
209
|
+
const title = formData.get("title");
|
|
210
|
+
if (!title) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
field_errors: [{ name: "title", message: "Title is required" }],
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
const { error } = await sb.from("posts").insert({ title });
|
|
217
|
+
return error
|
|
218
|
+
? { ok: false, error: "Could not save — please try again." }
|
|
219
|
+
: { ok: true };
|
|
220
|
+
});
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
Markup:
|
|
224
|
+
|
|
225
|
+
```html
|
|
226
|
+
<form gg-form-action="create_post">
|
|
227
|
+
<input name="title" />
|
|
228
|
+
<p gg-form-field-error="title"></p>
|
|
229
|
+
|
|
230
|
+
<textarea name="body"></textarea>
|
|
231
|
+
<p gg-form-field-error="body"></p>
|
|
232
|
+
|
|
233
|
+
<!-- Optional: list every field error in one place -->
|
|
234
|
+
<ul gg-form-error-list>
|
|
235
|
+
<li gg-list-template>
|
|
236
|
+
<strong gg-field="name"></strong>: <span gg-field="message"></span>
|
|
237
|
+
</li>
|
|
238
|
+
</ul>
|
|
239
|
+
|
|
240
|
+
<!-- Optional: form-level error (the result.error string) -->
|
|
241
|
+
<p gg-form-error></p>
|
|
242
|
+
|
|
243
|
+
<button type="submit">Save</button>
|
|
244
|
+
</form>
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
What the engine does:
|
|
248
|
+
- Sets `gg-form-field-invalid="true"` on each invalid input — target with CSS like `input[gg-form-field-invalid="true"] { border-color: red; }`.
|
|
249
|
+
- Sets the `textContent` of `[gg-form-field-error="<name>"]` elements to the matching message.
|
|
250
|
+
- Populates `[gg-form-error-list]` using the same template pattern as `gg-data-list` (clones `[gg-list-template]`, applies `gg-field` bindings).
|
|
251
|
+
- Sets the `textContent` of `[gg-form-error]` to the top-level `error` string.
|
|
252
|
+
- All errors are cleared at the start of each submit, and a field's invalid attr + message are cleared when the user types in that field.
|
|
253
|
+
|
|
152
254
|
### Form visibility
|
|
153
255
|
|
|
154
256
|
Conditionally show/hide elements based on form field values.
|
|
@@ -168,7 +270,7 @@ Hidden elements get `display: none`, `inert`, and `aria-hidden="true"`. Transiti
|
|
|
168
270
|
|
|
169
271
|
### Actions
|
|
170
272
|
|
|
171
|
-
Run mutations on click. Actions receive the
|
|
273
|
+
Run mutations on click. Actions receive the context object and a data object.
|
|
172
274
|
|
|
173
275
|
```html
|
|
174
276
|
<!-- Simple action, no data needed -->
|
|
@@ -191,7 +293,7 @@ When an action is inside a `gg-data` or `gg-data-list` container, it automatical
|
|
|
191
293
|
Action functions should return `{ ok: true }` or `{ ok: false, error }`:
|
|
192
294
|
|
|
193
295
|
```js
|
|
194
|
-
app.addAction("delete_post", async (sb, { id }) => {
|
|
296
|
+
app.addAction("delete_post", async ({ sb }, { id }) => {
|
|
195
297
|
const { error } = await sb.from("posts").delete().eq("id", id);
|
|
196
298
|
return error ? { ok: false, error } : { ok: true };
|
|
197
299
|
});
|
|
@@ -205,24 +307,31 @@ Returns an app instance with `addQuery`, `addAction`, and `start` methods.
|
|
|
205
307
|
|
|
206
308
|
| Option | Type | Required | Description |
|
|
207
309
|
|---|---|---|---|
|
|
208
|
-
| `
|
|
209
|
-
| `
|
|
210
|
-
| `
|
|
310
|
+
| `context` | `object` | No | Arbitrary object passed to every query and action. Put backend clients or anything else your handlers need on it. Defaults to `{}`. |
|
|
311
|
+
| `auth` | `object` | No | Auth adapter (see below). If omitted, `gg-auth`/`gg-role` attrs are never set. |
|
|
312
|
+
| `debug` | `boolean` | No | When `true`, every query and action is logged to the console (trigger/container, data, result, duration). Defaults to `false`. |
|
|
211
313
|
|
|
212
|
-
### Auth
|
|
314
|
+
### Auth adapter
|
|
213
315
|
|
|
214
316
|
| Option | Type | Description |
|
|
215
317
|
|---|---|---|
|
|
216
|
-
| `auth.
|
|
318
|
+
| `auth.getUser` | `() => string \| null \| Promise<string \| null>` | Returns the current user id, or `null` when signed out. Called once on start. |
|
|
319
|
+
| `auth.onChange` | `(cb: (userId: string \| null) => void) => void` | Subscribe to auth changes. Optional but recommended — without it, `gg-auth` won't update on sign-in/out. |
|
|
320
|
+
| `auth.roleQuery` | `async (context, userId) => string \| null` | Returns the user's role string. Called on every auth change. If omitted, `gg-role` is never set. |
|
|
217
321
|
|
|
218
|
-
Example:
|
|
322
|
+
Example with Supabase:
|
|
219
323
|
|
|
220
324
|
```js
|
|
325
|
+
import { createClient } from "@supabase/supabase-js";
|
|
326
|
+
|
|
327
|
+
const sb = createClient("...", "...");
|
|
328
|
+
|
|
221
329
|
const app = init({
|
|
222
|
-
|
|
223
|
-
supabaseKey: "...",
|
|
330
|
+
context: { sb },
|
|
224
331
|
auth: {
|
|
225
|
-
|
|
332
|
+
getUser: async () => (await sb.auth.getUser()).data.user?.id ?? null,
|
|
333
|
+
onChange: (cb) => sb.auth.onAuthStateChange((_e, session) => cb(session?.user?.id ?? null)),
|
|
334
|
+
roleQuery: async ({ sb }, userId) => {
|
|
226
335
|
const { data } = await sb
|
|
227
336
|
.from("user_roles")
|
|
228
337
|
.select("role")
|
|
@@ -236,13 +345,17 @@ const app = init({
|
|
|
236
345
|
|
|
237
346
|
### `app.addQuery(id, fn)`
|
|
238
347
|
|
|
239
|
-
Register a data query. `fn` receives `(
|
|
348
|
+
Register a data query. `fn` receives `(context, params)` where `params` is a `URLSearchParams` snapshot of the current URL query string. Use `params.get("id")` for single values or `params.getAll("tag")` for multi-value params. Return:
|
|
240
349
|
- A single object (or `null`) for use with `gg-data` or `gg-data-form`
|
|
241
350
|
- An array for use with `gg-data-list`
|
|
242
351
|
|
|
243
352
|
### `app.addAction(id, fn)`
|
|
244
353
|
|
|
245
|
-
Register an action. `fn` receives `(
|
|
354
|
+
Register an action. `fn` receives `(context, data, params)` where `params` is a `URLSearchParams` snapshot of the current URL query string. Return `{ ok: true }` or `{ ok: false, error }`.
|
|
355
|
+
|
|
356
|
+
### `app.addFormAction(id, fn)`
|
|
357
|
+
|
|
358
|
+
Register a form action. `fn` receives `(context, formData, params)` where `formData` is a `FormData` snapshot of the submitted form. The default submit is prevented automatically. Return `{ ok: true }` or `{ ok: false, error }`.
|
|
246
359
|
|
|
247
360
|
### `app.start()`
|
|
248
361
|
|
package/package.json
CHANGED
package/src/action-engine.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { actionRegistry } from "./actions.js";
|
|
2
|
+
import { withDebugLog } from "./helpers/log.js";
|
|
3
|
+
import { getParams } from "./query-params.js";
|
|
2
4
|
|
|
3
5
|
function parseActionData(el) {
|
|
4
6
|
const attr = el.getAttribute("gg-action-data");
|
|
@@ -23,7 +25,7 @@ function findRecord(el) {
|
|
|
23
25
|
return null;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
|
-
export function initActionEngine(
|
|
28
|
+
export function initActionEngine(context, { debug = false } = {}) {
|
|
27
29
|
async function handleAction(el) {
|
|
28
30
|
const id = el.getAttribute("gg-action");
|
|
29
31
|
const action = actionRegistry[id];
|
|
@@ -35,14 +37,18 @@ export function initActionEngine(sb) {
|
|
|
35
37
|
const record = findRecord(el);
|
|
36
38
|
const explicit = parseActionData(el);
|
|
37
39
|
const data = record ? { ...record, ...explicit } : explicit;
|
|
40
|
+
const params = getParams();
|
|
38
41
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
const result = await withDebugLog(
|
|
43
|
+
"[gg-action]",
|
|
44
|
+
id,
|
|
45
|
+
{ trigger: el, data, params: Object.fromEntries(params) },
|
|
46
|
+
debug,
|
|
47
|
+
() => action(context, data, params),
|
|
48
|
+
);
|
|
49
|
+
|
|
50
|
+
if (result?.ok === false) {
|
|
51
|
+
console.warn(`[gg-action] "${id}" failed:`, result.error ?? "unknown error");
|
|
46
52
|
}
|
|
47
53
|
}
|
|
48
54
|
|
package/src/actions.js
CHANGED
|
@@ -4,9 +4,10 @@ export const actionRegistry = {};
|
|
|
4
4
|
* Register an action triggered by gg-action="<id>" on click.
|
|
5
5
|
*
|
|
6
6
|
* @param {string} id - The action identifier, referenced by gg-action="<id>" in markup.
|
|
7
|
-
* @param {(
|
|
8
|
-
* Receives the
|
|
9
|
-
* and any explicit gg-action-data attribute)
|
|
7
|
+
* @param {(context: object, data: object, params: URLSearchParams) => Promise<{ok: boolean, error?: any}>} fn
|
|
8
|
+
* Receives the context object passed to init(), a data object (merged from the nearest
|
|
9
|
+
* gg-data record and any explicit gg-action-data attribute), and a URLSearchParams snapshot
|
|
10
|
+
* of the current URL query string. Return { ok: true } or { ok: false, error }.
|
|
10
11
|
*/
|
|
11
12
|
export function registerAction(id, fn) {
|
|
12
13
|
actionRegistry[id] = fn;
|
package/src/auth.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
export async function initAuth(
|
|
1
|
+
export async function initAuth(context, auth) {
|
|
2
|
+
const { getUser, onChange, roleQuery } = auth;
|
|
3
|
+
|
|
2
4
|
async function applyAuthAttrs(userId) {
|
|
3
5
|
const body = document.body;
|
|
4
6
|
if (!userId) {
|
|
@@ -8,7 +10,7 @@ export async function initAuth(sb, roleQuery) {
|
|
|
8
10
|
}
|
|
9
11
|
body.setAttribute("gg-auth", "true");
|
|
10
12
|
if (roleQuery) {
|
|
11
|
-
const role = await roleQuery(
|
|
13
|
+
const role = await roleQuery(context, userId);
|
|
12
14
|
if (role) {
|
|
13
15
|
body.setAttribute("gg-role", role);
|
|
14
16
|
} else {
|
|
@@ -17,12 +19,10 @@ export async function initAuth(sb, roleQuery) {
|
|
|
17
19
|
}
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
} = await sb.auth.getUser();
|
|
23
|
-
applyAuthAttrs(user?.id ?? null);
|
|
22
|
+
const userId = await getUser();
|
|
23
|
+
applyAuthAttrs(userId ?? null);
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
applyAuthAttrs(
|
|
27
|
-
}
|
|
25
|
+
if (onChange) {
|
|
26
|
+
onChange((userId) => applyAuthAttrs(userId ?? null));
|
|
27
|
+
}
|
|
28
28
|
}
|
package/src/data-engine.js
CHANGED
|
@@ -4,8 +4,9 @@ import {
|
|
|
4
4
|
setSwitchState,
|
|
5
5
|
applySwitchState,
|
|
6
6
|
} from "./helpers/dom.js";
|
|
7
|
+
import { withDebugLog } from "./helpers/log.js";
|
|
7
8
|
import { queryRegistry } from "./queries.js";
|
|
8
|
-
import { onQueryChanged } from "./query-params.js";
|
|
9
|
+
import { onQueryChanged, getParams } from "./query-params.js";
|
|
9
10
|
|
|
10
11
|
function applySwitchFields(root, record) {
|
|
11
12
|
root.querySelectorAll("[gg-switch-field]").forEach((el) => {
|
|
@@ -41,7 +42,7 @@ function populateFormFields(root, record) {
|
|
|
41
42
|
});
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
export function initDataEngine(
|
|
45
|
+
export function initDataEngine(context, { debug = false } = {}) {
|
|
45
46
|
async function runQuery(container) {
|
|
46
47
|
const isList = container.hasAttribute("gg-data-list");
|
|
47
48
|
const isForm = container.hasAttribute("gg-data-form");
|
|
@@ -54,66 +55,71 @@ export function initDataEngine(sb) {
|
|
|
54
55
|
return;
|
|
55
56
|
}
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
58
|
+
const params = getParams();
|
|
59
|
+
const result = await withDebugLog(
|
|
60
|
+
"[gg-data]",
|
|
61
|
+
id,
|
|
62
|
+
{ container, params: Object.fromEntries(params) },
|
|
63
|
+
debug,
|
|
64
|
+
() => query(context, params),
|
|
65
|
+
);
|
|
66
|
+
if (result === undefined) return;
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
68
|
+
if (isList) {
|
|
69
|
+
if (!Array.isArray(result)) {
|
|
70
|
+
console.warn(
|
|
71
|
+
`[gg-data-list] query "${id}" did not return an array`,
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
74
75
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
const template = container.querySelector("[gg-list-template]");
|
|
77
|
+
if (!template) {
|
|
78
|
+
console.warn(
|
|
79
|
+
`[gg-data-list] no [gg-list-template] inside "${id}"`,
|
|
80
|
+
);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
78
83
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
applySwitchFields(container, result);
|
|
84
|
+
Array.from(container.children).forEach((child) => {
|
|
85
|
+
if (child !== template) child.remove();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
result.forEach((record) => {
|
|
89
|
+
const clone = template.cloneNode(true);
|
|
90
|
+
clone.removeAttribute("gg-list-template");
|
|
91
|
+
clone.setAttribute(
|
|
92
|
+
"gg-query-set",
|
|
93
|
+
`modal:view,id:${record.id}`,
|
|
94
|
+
);
|
|
95
|
+
clone.style.display = "flex";
|
|
96
|
+
if (record?.id != null) clone.id = String(record.id);
|
|
97
|
+
clone.__ggRecord = record;
|
|
98
|
+
populateFields(clone, record);
|
|
99
|
+
applySwitchFields(clone, record);
|
|
100
|
+
container.appendChild(clone);
|
|
101
|
+
});
|
|
102
|
+
} else if (isForm) {
|
|
103
|
+
if (Array.isArray(result)) {
|
|
104
|
+
console.warn(
|
|
105
|
+
`[gg-data-form] query "${id}" returned an array; expected a single record`,
|
|
106
|
+
);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (!result) return;
|
|
110
|
+
container.__ggRecord = result;
|
|
111
|
+
populateFormFields(container, result);
|
|
112
|
+
} else {
|
|
113
|
+
if (Array.isArray(result)) {
|
|
114
|
+
console.warn(
|
|
115
|
+
`[gg-data] query "${id}" returned an array; use gg-data-list instead`,
|
|
116
|
+
);
|
|
117
|
+
return;
|
|
114
118
|
}
|
|
115
|
-
|
|
116
|
-
|
|
119
|
+
if (!result) return;
|
|
120
|
+
container.__ggRecord = result;
|
|
121
|
+
populateFields(container, result);
|
|
122
|
+
applySwitchFields(container, result);
|
|
117
123
|
}
|
|
118
124
|
}
|
|
119
125
|
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { formActionRegistry } from "./form-actions.js";
|
|
2
|
+
import { populateFields } from "./helpers/dom.js";
|
|
3
|
+
import { withDebugLog } from "./helpers/log.js";
|
|
4
|
+
import { getParams } from "./query-params.js";
|
|
5
|
+
|
|
6
|
+
function findInputs(form, name) {
|
|
7
|
+
const escaped = CSS.escape(name);
|
|
8
|
+
return form.querySelectorAll(
|
|
9
|
+
`input[name="${escaped}"], select[name="${escaped}"], textarea[name="${escaped}"]`,
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function clearFieldError(form, name) {
|
|
14
|
+
findInputs(form, name).forEach((el) => {
|
|
15
|
+
el.removeAttribute("gg-form-field-invalid");
|
|
16
|
+
});
|
|
17
|
+
const escaped = CSS.escape(name);
|
|
18
|
+
form
|
|
19
|
+
.querySelectorAll(`[gg-form-field-error="${escaped}"]`)
|
|
20
|
+
.forEach((el) => {
|
|
21
|
+
el.textContent = "";
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function clearFormErrors(form) {
|
|
26
|
+
form.querySelectorAll("[gg-form-field-invalid]").forEach((el) => {
|
|
27
|
+
el.removeAttribute("gg-form-field-invalid");
|
|
28
|
+
});
|
|
29
|
+
form.querySelectorAll("[gg-form-field-error]").forEach((el) => {
|
|
30
|
+
el.textContent = "";
|
|
31
|
+
});
|
|
32
|
+
form.querySelectorAll("[gg-form-error]").forEach((el) => {
|
|
33
|
+
el.textContent = "";
|
|
34
|
+
});
|
|
35
|
+
form.querySelectorAll("[gg-form-error-list]").forEach((list) => {
|
|
36
|
+
const template = list.querySelector("[gg-list-template]");
|
|
37
|
+
Array.from(list.children).forEach((child) => {
|
|
38
|
+
if (child !== template) child.remove();
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function applyFieldErrors(form, fieldErrors) {
|
|
44
|
+
fieldErrors.forEach(({ name, message }) => {
|
|
45
|
+
if (!name) return;
|
|
46
|
+
findInputs(form, name).forEach((el) => {
|
|
47
|
+
el.setAttribute("gg-form-field-invalid", "true");
|
|
48
|
+
});
|
|
49
|
+
const escaped = CSS.escape(name);
|
|
50
|
+
form
|
|
51
|
+
.querySelectorAll(`[gg-form-field-error="${escaped}"]`)
|
|
52
|
+
.forEach((el) => {
|
|
53
|
+
el.textContent = message ?? "";
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function applyErrorList(form, fieldErrors) {
|
|
59
|
+
form.querySelectorAll("[gg-form-error-list]").forEach((list) => {
|
|
60
|
+
const template = list.querySelector("[gg-list-template]");
|
|
61
|
+
if (!template) {
|
|
62
|
+
console.warn(
|
|
63
|
+
"[gg-form-error-list] no [gg-list-template] inside list:",
|
|
64
|
+
list,
|
|
65
|
+
);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
fieldErrors.forEach((record) => {
|
|
69
|
+
const clone = template.cloneNode(true);
|
|
70
|
+
clone.removeAttribute("gg-list-template");
|
|
71
|
+
clone.style.display = "";
|
|
72
|
+
populateFields(clone, record);
|
|
73
|
+
list.appendChild(clone);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function applyFormError(form, error) {
|
|
79
|
+
if (error == null) return;
|
|
80
|
+
const message = typeof error === "string" ? error : String(error);
|
|
81
|
+
form.querySelectorAll("[gg-form-error]").forEach((el) => {
|
|
82
|
+
el.textContent = message;
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function bindFieldClearOnInput(form) {
|
|
87
|
+
if (form.__ggFormErrorBound) return;
|
|
88
|
+
form.__ggFormErrorBound = true;
|
|
89
|
+
form.addEventListener("input", (e) => {
|
|
90
|
+
const name = e.target?.getAttribute?.("name");
|
|
91
|
+
if (!name) return;
|
|
92
|
+
if (e.target.hasAttribute("gg-form-field-invalid")) {
|
|
93
|
+
clearFieldError(form, name);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function initFormActionEngine(context, { debug = false } = {}) {
|
|
99
|
+
async function handleSubmit(form, event) {
|
|
100
|
+
const id = form.getAttribute("gg-form-action");
|
|
101
|
+
const action = formActionRegistry[id];
|
|
102
|
+
if (!action) {
|
|
103
|
+
console.warn(`[gg-form-action] no form action registered for "${id}"`);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
event.preventDefault();
|
|
108
|
+
clearFormErrors(form);
|
|
109
|
+
|
|
110
|
+
const formData = new FormData(form);
|
|
111
|
+
const params = getParams();
|
|
112
|
+
|
|
113
|
+
const result = await withDebugLog(
|
|
114
|
+
"[gg-form-action]",
|
|
115
|
+
id,
|
|
116
|
+
{
|
|
117
|
+
form,
|
|
118
|
+
formData: Object.fromEntries(formData),
|
|
119
|
+
params: Object.fromEntries(params),
|
|
120
|
+
},
|
|
121
|
+
debug,
|
|
122
|
+
() => action(context, formData, params),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
if (result?.ok === false) {
|
|
126
|
+
const fieldErrors = Array.isArray(result.field_errors)
|
|
127
|
+
? result.field_errors
|
|
128
|
+
: [];
|
|
129
|
+
applyFieldErrors(form, fieldErrors);
|
|
130
|
+
applyErrorList(form, fieldErrors);
|
|
131
|
+
applyFormError(form, result.error);
|
|
132
|
+
if (!fieldErrors.length && result.error == null) {
|
|
133
|
+
console.warn(
|
|
134
|
+
`[gg-form-action] "${id}" returned ok:false with no error or field_errors`,
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
document.querySelectorAll("form[gg-form-action]").forEach(bindFieldClearOnInput);
|
|
141
|
+
|
|
142
|
+
document.addEventListener("submit", (e) => {
|
|
143
|
+
const form = e.target;
|
|
144
|
+
if (form?.tagName === "FORM" && form.hasAttribute("gg-form-action")) {
|
|
145
|
+
bindFieldClearOnInput(form);
|
|
146
|
+
handleSubmit(form, e);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const formActionRegistry = {};
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Register a form action triggered by submitting a form with gg-form-action="<id>".
|
|
5
|
+
*
|
|
6
|
+
* @param {string} id - The form action identifier, referenced by gg-form-action="<id>" on a form.
|
|
7
|
+
* @param {(context: object, formData: FormData, params: URLSearchParams) => Promise<{
|
|
8
|
+
* ok: boolean,
|
|
9
|
+
* error?: any,
|
|
10
|
+
* field_errors?: Array<{ name: string, message: string }>,
|
|
11
|
+
* }>} fn
|
|
12
|
+
* Receives the context object passed to init(), a FormData snapshot of the submitted form,
|
|
13
|
+
* and a URLSearchParams snapshot of the current URL query string. Return:
|
|
14
|
+
* - { ok: true } on success
|
|
15
|
+
* - { ok: false, field_errors: [{ name, message }, ...] } for validation errors
|
|
16
|
+
* - { ok: false, error: "..." } for a single form-level error
|
|
17
|
+
* - Both field_errors and error may be present together.
|
|
18
|
+
*/
|
|
19
|
+
export function registerFormAction(id, fn) {
|
|
20
|
+
formActionRegistry[id] = fn;
|
|
21
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wrap an async handler with optional debug logging and uniform throw handling.
|
|
3
|
+
*
|
|
4
|
+
* On success, returns the handler's resolved value. On throw, logs the error and
|
|
5
|
+
* returns `undefined` so callers can branch on it.
|
|
6
|
+
*
|
|
7
|
+
* @param {string} prefix - Log prefix, e.g. "[gg-action]".
|
|
8
|
+
* @param {string} id - Handler identifier shown in the log group.
|
|
9
|
+
* @param {Record<string, any>} fields - Key/value pairs logged when debug is on.
|
|
10
|
+
* @param {boolean} debug - Whether to emit grouped debug logs.
|
|
11
|
+
* @param {() => Promise<any>} run - The handler invocation.
|
|
12
|
+
*/
|
|
13
|
+
export async function withDebugLog(prefix, id, fields, debug, run) {
|
|
14
|
+
if (debug) {
|
|
15
|
+
console.groupCollapsed(`${prefix} "${id}"`);
|
|
16
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
17
|
+
console.log(`${key}:`, value);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
const startedAt = debug ? performance.now() : 0;
|
|
21
|
+
try {
|
|
22
|
+
const result = await run();
|
|
23
|
+
if (debug) {
|
|
24
|
+
const ms = (performance.now() - startedAt).toFixed(1);
|
|
25
|
+
console.log(`result (${ms}ms):`, result);
|
|
26
|
+
}
|
|
27
|
+
return result;
|
|
28
|
+
} catch (err) {
|
|
29
|
+
console.error(`${prefix} "${id}" threw:`, err);
|
|
30
|
+
return undefined;
|
|
31
|
+
} finally {
|
|
32
|
+
if (debug) console.groupEnd();
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createClient } from "@supabase/supabase-js";
|
|
2
1
|
import { registerQuery } from "./queries.js";
|
|
3
2
|
import { registerAction } from "./actions.js";
|
|
3
|
+
import { registerFormAction } from "./form-actions.js";
|
|
4
4
|
import { setSwitchState, applySwitchState, populateFields } from "./helpers/dom.js";
|
|
5
5
|
import { setQueryParams, removeQueryParams, initQueryParams } from "./query-params.js";
|
|
6
6
|
import { initAuth } from "./auth.js";
|
|
@@ -10,6 +10,7 @@ import { initBridges } from "./bridges.js";
|
|
|
10
10
|
import { initFormVisibility } from "./form-visibility.js";
|
|
11
11
|
import { initDataEngine } from "./data-engine.js";
|
|
12
12
|
import { initActionEngine } from "./action-engine.js";
|
|
13
|
+
import { initFormActionEngine } from "./form-action-engine.js";
|
|
13
14
|
|
|
14
15
|
export { setSwitchState, applySwitchState, populateFields, setQueryParams, removeQueryParams };
|
|
15
16
|
export { getPath } from "./helpers/path.js";
|
|
@@ -17,29 +18,36 @@ export { getPath } from "./helpers/path.js";
|
|
|
17
18
|
/**
|
|
18
19
|
* Create a gg-scripts app instance.
|
|
19
20
|
*
|
|
20
|
-
* @param {object} options
|
|
21
|
-
* @param {
|
|
22
|
-
*
|
|
23
|
-
* @param {object} [options.auth] - Auth
|
|
24
|
-
* @param {(
|
|
21
|
+
* @param {object} [options]
|
|
22
|
+
* @param {object} [options.context] - Arbitrary object passed to every query and action.
|
|
23
|
+
* Put backend clients (Supabase, fetch wrappers, etc.) or anything else your queries need on it.
|
|
24
|
+
* @param {object} [options.auth] - Auth adapter. If omitted, gg-auth/gg-role attrs are never set.
|
|
25
|
+
* @param {() => (string|null) | Promise<string|null>} options.auth.getUser
|
|
26
|
+
* Returns the current user id, or null when signed out.
|
|
27
|
+
* @param {(cb: (userId: string|null) => void) => void} [options.auth.onChange]
|
|
28
|
+
* Subscribe to auth changes. Called with the new user id (or null) whenever it changes.
|
|
29
|
+
* @param {(context: object, userId: string) => Promise<string|null>} [options.auth.roleQuery]
|
|
25
30
|
* Returns the user's role string for gg-role gating. If omitted, gg-role is never set.
|
|
31
|
+
* @param {boolean} [options.debug=false] - When true, every query and action is logged to the
|
|
32
|
+
* console with its trigger/container, data, result, and duration.
|
|
26
33
|
* @returns {{ addQuery: function, addAction: function, start: function }}
|
|
27
34
|
*/
|
|
28
|
-
export function init({
|
|
35
|
+
export function init({ context = {}, auth, debug = false } = {}) {
|
|
29
36
|
return {
|
|
30
37
|
addQuery: registerQuery,
|
|
31
38
|
addAction: registerAction,
|
|
39
|
+
addFormAction: registerFormAction,
|
|
32
40
|
start() {
|
|
33
41
|
function run() {
|
|
34
|
-
|
|
35
|
-
initAuth(sb, auth?.roleQuery);
|
|
42
|
+
if (auth) initAuth(context, auth);
|
|
36
43
|
initSwitchEngine();
|
|
37
44
|
initQueryParams();
|
|
38
45
|
initDialog();
|
|
39
46
|
initBridges();
|
|
40
47
|
initFormVisibility();
|
|
41
|
-
initDataEngine(
|
|
42
|
-
initActionEngine(
|
|
48
|
+
initDataEngine(context, { debug });
|
|
49
|
+
initActionEngine(context, { debug });
|
|
50
|
+
initFormActionEngine(context, { debug });
|
|
43
51
|
}
|
|
44
52
|
|
|
45
53
|
if (document.readyState === "loading") {
|
package/src/queries.js
CHANGED
|
@@ -4,8 +4,10 @@ export const queryRegistry = {};
|
|
|
4
4
|
* Register a data query for use with gg-data, gg-data-form, or gg-data-list.
|
|
5
5
|
*
|
|
6
6
|
* @param {string} id - The query identifier, referenced by gg-data="<id>" in markup.
|
|
7
|
-
* @param {(
|
|
8
|
-
*
|
|
7
|
+
* @param {(context: object, params: URLSearchParams) => Promise<object|object[]|null>} fn
|
|
8
|
+
* Receives the context object passed to init() and a URLSearchParams snapshot of the
|
|
9
|
+
* current URL query string. Return a single object (or null) for gg-data/gg-data-form,
|
|
10
|
+
* or an array for gg-data-list.
|
|
9
11
|
*/
|
|
10
12
|
export function registerQuery(id, fn) {
|
|
11
13
|
queryRegistry[id] = fn;
|
package/src/query-params.js
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
const subscribers = [];
|
|
2
2
|
|
|
3
|
+
/**
|
|
4
|
+
* Snapshot of the current URL query string as a URLSearchParams instance.
|
|
5
|
+
*/
|
|
6
|
+
export function getParams() {
|
|
7
|
+
return new URL(window.location).searchParams;
|
|
8
|
+
}
|
|
9
|
+
|
|
3
10
|
export function onQueryChanged(callback) {
|
|
4
11
|
subscribers.push(callback);
|
|
5
12
|
return () => {
|
|
@@ -74,4 +81,64 @@ export function initQueryParams() {
|
|
|
74
81
|
document.addEventListener("gg:shadow:click", (e) =>
|
|
75
82
|
handleQueryClick(e.detail.target),
|
|
76
83
|
);
|
|
84
|
+
|
|
85
|
+
initQueryBindings();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ---- gg-query-bind: input/textarea/select <-> URL param ----
|
|
89
|
+
|
|
90
|
+
function syncBindInputFromUrl(el) {
|
|
91
|
+
const key = el.getAttribute("gg-query-bind");
|
|
92
|
+
if (!key) return;
|
|
93
|
+
const value = new URL(window.location).searchParams.get(key) ?? "";
|
|
94
|
+
if (el.value !== value) el.value = value;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function setupQueryBindInput(el) {
|
|
98
|
+
const key = el.getAttribute("gg-query-bind");
|
|
99
|
+
if (!key) return;
|
|
100
|
+
const debounceMs = parseInt(el.getAttribute("gg-query-debounce") ?? "0", 10) || 0;
|
|
101
|
+
|
|
102
|
+
syncBindInputFromUrl(el);
|
|
103
|
+
|
|
104
|
+
let timer;
|
|
105
|
+
let suppress = false;
|
|
106
|
+
el.addEventListener("input", () => {
|
|
107
|
+
if (suppress) return;
|
|
108
|
+
clearTimeout(timer);
|
|
109
|
+
const fire = () => {
|
|
110
|
+
const value = el.value;
|
|
111
|
+
if (value === "") {
|
|
112
|
+
removeQueryParams([key]);
|
|
113
|
+
} else {
|
|
114
|
+
setQueryParams([{ key, value }]);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
if (debounceMs > 0) {
|
|
118
|
+
timer = setTimeout(fire, debounceMs);
|
|
119
|
+
} else {
|
|
120
|
+
fire();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// When the URL changes from elsewhere (back button, programmatic), mirror
|
|
125
|
+
// it into the input without re-firing the input listener.
|
|
126
|
+
onQueryChanged((changedKey, value) => {
|
|
127
|
+
if (changedKey !== key) return;
|
|
128
|
+
const next = value ?? "";
|
|
129
|
+
if (el.value === next) return;
|
|
130
|
+
suppress = true;
|
|
131
|
+
el.value = next;
|
|
132
|
+
suppress = false;
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function initQueryBindings() {
|
|
137
|
+
document.querySelectorAll("[gg-query-bind]").forEach(setupQueryBindInput);
|
|
138
|
+
|
|
139
|
+
// Back/forward navigation doesn't fire pushState notifications, so
|
|
140
|
+
// re-sync all bound inputs from the URL on popstate.
|
|
141
|
+
window.addEventListener("popstate", () => {
|
|
142
|
+
document.querySelectorAll("[gg-query-bind]").forEach(syncBindInputFromUrl);
|
|
143
|
+
});
|
|
77
144
|
}
|