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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # gg-wf-scripts
2
2
 
3
- A declarative attribute engine for Webflow sites backed by Supabase. Add `gg-*` attributes to your markup and the library handles data binding, URL-driven state, dialogs, auth gating, form visibility, and user actions.
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
- supabaseUrl: "https://your-project.supabase.co",
20
- supabaseKey: "your-publishable-key",
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 Supabase auth state.
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 Supabase client and a data object.
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
- | `supabaseUrl` | `string` | Yes | Your Supabase project URL |
209
- | `supabaseKey` | `string` | Yes | Your Supabase publishable (anon) key |
210
- | `auth` | `object` | No | Auth configuration (see below) |
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 config
314
+ ### Auth adapter
213
315
 
214
316
  | Option | Type | Description |
215
317
  |---|---|---|
216
- | `auth.roleQuery` | `async (sb, userId) => string \| null` | Returns the user's role string. Called on auth state change. If omitted, `gg-auth` still works but `gg-role` is never set. |
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
- supabaseUrl: "...",
223
- supabaseKey: "...",
330
+ context: { sb },
224
331
  auth: {
225
- roleQuery: async (sb, userId) => {
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 `(sb)` and should return:
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 `(sb, data)` and should return `{ ok: true }` or `{ ok: false, error }`.
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
@@ -1,9 +1,6 @@
1
1
  {
2
2
  "name": "gg-wf-scripts",
3
- "version": "1.0.0",
3
+ "version": "2.4.0",
4
4
  "type": "module",
5
- "exports": "./src/index.js",
6
- "dependencies": {
7
- "@supabase/supabase-js": "^2"
8
- }
5
+ "exports": "./src/index.js"
9
6
  }
@@ -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(sb) {
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
- try {
40
- const result = await action(sb, data);
41
- if (result?.ok === false) {
42
- console.warn(`[gg-action] "${id}" failed:`, result.error ?? "unknown error");
43
- }
44
- } catch (err) {
45
- console.error(`[gg-action] "${id}" threw:`, err);
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 {(sb: SupabaseClient, data: object) => Promise<{ok: boolean, error?: any}>} fn
8
- * Receives the Supabase client and a data object (merged from the nearest gg-data record
9
- * and any explicit gg-action-data attribute). Return { ok: true } or { ok: false, error }.
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(sb, roleQuery) {
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(sb, userId);
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
- data: { user },
22
- } = await sb.auth.getUser();
23
- applyAuthAttrs(user?.id ?? null);
22
+ const userId = await getUser();
23
+ applyAuthAttrs(userId ?? null);
24
24
 
25
- sb.auth.onAuthStateChange((_event, session) => {
26
- applyAuthAttrs(session?.user?.id ?? null);
27
- });
25
+ if (onChange) {
26
+ onChange((userId) => applyAuthAttrs(userId ?? null));
27
+ }
28
28
  }
@@ -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(sb) {
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
- try {
58
- const result = await query(sb);
59
- if (isList) {
60
- if (!Array.isArray(result)) {
61
- console.warn(
62
- `[gg-data-list] query "${id}" did not return an array`,
63
- );
64
- return;
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
- const template = container.querySelector("[gg-list-template]");
68
- if (!template) {
69
- console.warn(
70
- `[gg-data-list] no [gg-list-template] inside "${id}"`,
71
- );
72
- return;
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
- Array.from(container.children).forEach((child) => {
76
- if (child !== template) child.remove();
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
- result.forEach((record) => {
80
- const clone = template.cloneNode(true);
81
- clone.removeAttribute("gg-list-template");
82
- clone.setAttribute(
83
- "gg-query-set",
84
- `modal:view,id:${record.id}`,
85
- );
86
- clone.style.display = "flex";
87
- if (record?.id != null) clone.id = String(record.id);
88
- clone.__ggRecord = record;
89
- populateFields(clone, record);
90
- applySwitchFields(clone, record);
91
- container.appendChild(clone);
92
- });
93
- } else if (isForm) {
94
- if (Array.isArray(result)) {
95
- console.warn(
96
- `[gg-data-form] query "${id}" returned an array; expected a single record`,
97
- );
98
- return;
99
- }
100
- if (!result) return;
101
- container.__ggRecord = result;
102
- populateFormFields(container, result);
103
- } else {
104
- if (Array.isArray(result)) {
105
- console.warn(
106
- `[gg-data] query "${id}" returned an array; use gg-data-list instead`,
107
- );
108
- return;
109
- }
110
- if (!result) return;
111
- container.__ggRecord = result;
112
- populateFields(container, result);
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
- } catch (err) {
116
- console.error(`[gg-data] query "${id}" failed:`, err);
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 {string} options.supabaseUrl - Your Supabase project URL.
22
- * @param {string} options.supabaseKey - Your Supabase publishable (anon) key.
23
- * @param {object} [options.auth] - Auth configuration.
24
- * @param {(sb: SupabaseClient, userId: string) => Promise<string|null>} [options.auth.roleQuery]
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({ supabaseUrl, supabaseKey, auth }) {
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
- const sb = createClient(supabaseUrl, supabaseKey);
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(sb);
42
- initActionEngine(sb);
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 {(sb: SupabaseClient) => Promise<object|object[]|null>} fn
8
- * Return a single object (or null) for gg-data/gg-data-form, or an array for gg-data-list.
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;
@@ -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
  }