gg-wf-scripts 1.0.0 → 2.5.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
  });
@@ -78,6 +86,25 @@ Display data from your queries in the DOM.
78
86
 
79
87
  `gg-field` supports dot-paths for nested data (e.g. `author.name`).
80
88
 
89
+ #### Passing data to web components / React
90
+
91
+ For elements that manage their own DOM (custom elements wrapping React, Lit, etc.), use `gg-data-key` to receive a JSON-serialized value as a `gg-data-value` attribute. The component listens for attribute changes and updates its own state.
92
+
93
+ ```html
94
+ <div gg-data="post_form">
95
+ <!-- record.schools is e.g. [{ id, name }, ...] -->
96
+ <my-select gg-data-key="schools"></my-select>
97
+ </div>
98
+ ```
99
+
100
+ After the query runs, the engine resolves the dot-path against the record and writes the result:
101
+
102
+ ```html
103
+ <my-select gg-data-key="schools" gg-data-value='[{"id":1,"name":"Acme"}]'></my-select>
104
+ ```
105
+
106
+ The lookup pierces shadow roots, so the marker can live inside a component's shadow DOM. Leaving `gg-data-key=""` passes the entire record. Note: if the component renders after the query runs, it won't be found — re-run the query (e.g. via `gg-data-on`) once the component has mounted, or have the component pull from `host.__ggRecord` on connect.
107
+
81
108
  #### Re-running on URL changes
82
109
 
83
110
  Add `gg-data-on` to re-run a query when specific URL params change:
@@ -100,6 +127,25 @@ Read and write URL query params declaratively.
100
127
  <button gg-query-remove="modal,id">Close</button>
101
128
  ```
102
129
 
130
+ #### Two-way input binding
131
+
132
+ 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.
133
+
134
+ ```html
135
+ <!-- Bind to ?q=... with a 300ms debounce -->
136
+ <input gg-query-bind="q" gg-query-debounce="300" placeholder="Search..." />
137
+ ```
138
+
139
+ Combine with `gg-data-on` to re-run a query as the user types:
140
+
141
+ ```html
142
+ <input gg-query-bind="q" gg-query-debounce="300" />
143
+
144
+ <ul gg-data-list="search_posts" gg-data-on="q">
145
+ <li gg-list-template><span gg-field="title"></span></li>
146
+ </ul>
147
+ ```
148
+
103
149
  ### Content switcher
104
150
 
105
151
  Show/hide children based on a state value.
@@ -139,7 +185,7 @@ Setting `?modal=...` opens the dialog. Removing it (or pressing Escape, or click
139
185
 
140
186
  ### Auth and role gating
141
187
 
142
- Show or hide elements based on Supabase auth state.
188
+ Show or hide elements based on auth state. You provide the auth adapter, so any backend works.
143
189
 
144
190
  ```html
145
191
  <a href="/login" gg-auth="false">Log in</a>
@@ -149,6 +195,81 @@ Show or hide elements based on Supabase auth state.
149
195
 
150
196
  `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
197
 
198
+ ### Form actions
199
+
200
+ Override a form's submit to run a registered handler instead of the browser default. The handler receives the form's `FormData` directly.
201
+
202
+ ```html
203
+ <form gg-form-action="create_post">
204
+ <input name="title" required />
205
+ <textarea name="body"></textarea>
206
+ <button type="submit">Save</button>
207
+ </form>
208
+ ```
209
+
210
+ ```js
211
+ app.addFormAction("create_post", async ({ sb }, formData) => {
212
+ const { error } = await sb.from("posts").insert({
213
+ title: formData.get("title"),
214
+ body: formData.get("body"),
215
+ });
216
+ return error ? { ok: false, error } : { ok: true };
217
+ });
218
+ ```
219
+
220
+ 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 }`.
221
+
222
+ #### Validation errors
223
+
224
+ Form actions can return validation errors and the engine will display them via attributes on your markup.
225
+
226
+ ```js
227
+ app.addFormAction("create_post", async ({ sb }, formData) => {
228
+ const title = formData.get("title");
229
+ if (!title) {
230
+ return {
231
+ ok: false,
232
+ field_errors: [{ name: "title", message: "Title is required" }],
233
+ };
234
+ }
235
+ const { error } = await sb.from("posts").insert({ title });
236
+ return error
237
+ ? { ok: false, error: "Could not save — please try again." }
238
+ : { ok: true };
239
+ });
240
+ ```
241
+
242
+ Markup:
243
+
244
+ ```html
245
+ <form gg-form-action="create_post">
246
+ <input name="title" />
247
+ <p gg-form-field-error="title"></p>
248
+
249
+ <textarea name="body"></textarea>
250
+ <p gg-form-field-error="body"></p>
251
+
252
+ <!-- Optional: list every field error in one place -->
253
+ <ul gg-form-error-list>
254
+ <li gg-list-template>
255
+ <strong gg-field="name"></strong>: <span gg-field="message"></span>
256
+ </li>
257
+ </ul>
258
+
259
+ <!-- Optional: form-level error (the result.error string) -->
260
+ <p gg-form-error></p>
261
+
262
+ <button type="submit">Save</button>
263
+ </form>
264
+ ```
265
+
266
+ What the engine does:
267
+ - Sets `gg-form-field-invalid="true"` on each invalid input — target with CSS like `input[gg-form-field-invalid="true"] { border-color: red; }`.
268
+ - Sets the `textContent` of `[gg-form-field-error="<name>"]` elements to the matching message.
269
+ - Populates `[gg-form-error-list]` using the same template pattern as `gg-data-list` (clones `[gg-list-template]`, applies `gg-field` bindings).
270
+ - Sets the `textContent` of `[gg-form-error]` to the top-level `error` string.
271
+ - 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.
272
+
152
273
  ### Form visibility
153
274
 
154
275
  Conditionally show/hide elements based on form field values.
@@ -168,7 +289,7 @@ Hidden elements get `display: none`, `inert`, and `aria-hidden="true"`. Transiti
168
289
 
169
290
  ### Actions
170
291
 
171
- Run mutations on click. Actions receive the Supabase client and a data object.
292
+ Run mutations on click. Actions receive the context object and a data object.
172
293
 
173
294
  ```html
174
295
  <!-- Simple action, no data needed -->
@@ -191,7 +312,7 @@ When an action is inside a `gg-data` or `gg-data-list` container, it automatical
191
312
  Action functions should return `{ ok: true }` or `{ ok: false, error }`:
192
313
 
193
314
  ```js
194
- app.addAction("delete_post", async (sb, { id }) => {
315
+ app.addAction("delete_post", async ({ sb }, { id }) => {
195
316
  const { error } = await sb.from("posts").delete().eq("id", id);
196
317
  return error ? { ok: false, error } : { ok: true };
197
318
  });
@@ -205,24 +326,31 @@ Returns an app instance with `addQuery`, `addAction`, and `start` methods.
205
326
 
206
327
  | Option | Type | Required | Description |
207
328
  |---|---|---|---|
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) |
329
+ | `context` | `object` | No | Arbitrary object passed to every query and action. Put backend clients or anything else your handlers need on it. Defaults to `{}`. |
330
+ | `auth` | `object` | No | Auth adapter (see below). If omitted, `gg-auth`/`gg-role` attrs are never set. |
331
+ | `debug` | `boolean` | No | When `true`, every query and action is logged to the console (trigger/container, data, result, duration). Defaults to `false`. |
211
332
 
212
- ### Auth config
333
+ ### Auth adapter
213
334
 
214
335
  | Option | Type | Description |
215
336
  |---|---|---|
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. |
337
+ | `auth.getUser` | `() => string \| null \| Promise<string \| null>` | Returns the current user id, or `null` when signed out. Called once on start. |
338
+ | `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. |
339
+ | `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
340
 
218
- Example:
341
+ Example with Supabase:
219
342
 
220
343
  ```js
344
+ import { createClient } from "@supabase/supabase-js";
345
+
346
+ const sb = createClient("...", "...");
347
+
221
348
  const app = init({
222
- supabaseUrl: "...",
223
- supabaseKey: "...",
349
+ context: { sb },
224
350
  auth: {
225
- roleQuery: async (sb, userId) => {
351
+ getUser: async () => (await sb.auth.getUser()).data.user?.id ?? null,
352
+ onChange: (cb) => sb.auth.onAuthStateChange((_e, session) => cb(session?.user?.id ?? null)),
353
+ roleQuery: async ({ sb }, userId) => {
226
354
  const { data } = await sb
227
355
  .from("user_roles")
228
356
  .select("role")
@@ -236,13 +364,17 @@ const app = init({
236
364
 
237
365
  ### `app.addQuery(id, fn)`
238
366
 
239
- Register a data query. `fn` receives `(sb)` and should return:
367
+ 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
368
  - A single object (or `null`) for use with `gg-data` or `gg-data-form`
241
369
  - An array for use with `gg-data-list`
242
370
 
243
371
  ### `app.addAction(id, fn)`
244
372
 
245
- Register an action. `fn` receives `(sb, data)` and should return `{ ok: true }` or `{ ok: false, error }`.
373
+ 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 }`.
374
+
375
+ ### `app.addFormAction(id, fn)`
376
+
377
+ 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
378
 
247
379
  ### `app.start()`
248
380
 
package/package.json CHANGED
@@ -1,9 +1,6 @@
1
1
  {
2
2
  "name": "gg-wf-scripts",
3
- "version": "1.0.0",
3
+ "version": "2.5.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) => {
@@ -16,6 +17,23 @@ function applySwitchFields(root, record) {
16
17
  });
17
18
  }
18
19
 
20
+ function queryDeep(root, selector) {
21
+ const results = [...root.querySelectorAll(selector)];
22
+ root.querySelectorAll("*").forEach((el) => {
23
+ if (el.shadowRoot) results.push(...queryDeep(el.shadowRoot, selector));
24
+ });
25
+ return results;
26
+ }
27
+
28
+ function applyDataValues(root, record) {
29
+ queryDeep(root, "[gg-data-key]").forEach((el) => {
30
+ const path = el.getAttribute("gg-data-key");
31
+ const value = path ? getPath(record, path) : record;
32
+ if (value === undefined) return;
33
+ el.setAttribute("gg-data-value", JSON.stringify(value));
34
+ });
35
+ }
36
+
19
37
  function populateFormFields(root, record) {
20
38
  root.querySelectorAll(
21
39
  "input[name], select[name], textarea[name]",
@@ -41,7 +59,7 @@ function populateFormFields(root, record) {
41
59
  });
42
60
  }
43
61
 
44
- export function initDataEngine(sb) {
62
+ export function initDataEngine(context, { debug = false } = {}) {
45
63
  async function runQuery(container) {
46
64
  const isList = container.hasAttribute("gg-data-list");
47
65
  const isForm = container.hasAttribute("gg-data-form");
@@ -54,66 +72,74 @@ export function initDataEngine(sb) {
54
72
  return;
55
73
  }
56
74
 
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
- }
75
+ const params = getParams();
76
+ const result = await withDebugLog(
77
+ "[gg-data]",
78
+ id,
79
+ { container, params: Object.fromEntries(params) },
80
+ debug,
81
+ () => query(context, params),
82
+ );
83
+ if (result === undefined) return;
66
84
 
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
- }
85
+ if (isList) {
86
+ if (!Array.isArray(result)) {
87
+ console.warn(
88
+ `[gg-data-list] query "${id}" did not return an array`,
89
+ );
90
+ return;
91
+ }
74
92
 
75
- Array.from(container.children).forEach((child) => {
76
- if (child !== template) child.remove();
77
- });
93
+ const template = container.querySelector("[gg-list-template]");
94
+ if (!template) {
95
+ console.warn(
96
+ `[gg-data-list] no [gg-list-template] inside "${id}"`,
97
+ );
98
+ return;
99
+ }
78
100
 
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);
101
+ Array.from(container.children).forEach((child) => {
102
+ if (child !== template) child.remove();
103
+ });
104
+
105
+ result.forEach((record) => {
106
+ const clone = template.cloneNode(true);
107
+ clone.removeAttribute("gg-list-template");
108
+ clone.setAttribute(
109
+ "gg-query-set",
110
+ `modal:view,id:${record.id}`,
111
+ );
112
+ clone.style.display = "flex";
113
+ if (record?.id != null) clone.id = String(record.id);
114
+ clone.__ggRecord = record;
115
+ populateFields(clone, record);
116
+ applySwitchFields(clone, record);
117
+ applyDataValues(clone, record);
118
+ container.appendChild(clone);
119
+ });
120
+ } else if (isForm) {
121
+ if (Array.isArray(result)) {
122
+ console.warn(
123
+ `[gg-data-form] query "${id}" returned an array; expected a single record`,
124
+ );
125
+ return;
126
+ }
127
+ if (!result) return;
128
+ container.__ggRecord = result;
129
+ populateFormFields(container, result);
130
+ applyDataValues(container, result);
131
+ } else {
132
+ if (Array.isArray(result)) {
133
+ console.warn(
134
+ `[gg-data] query "${id}" returned an array; use gg-data-list instead`,
135
+ );
136
+ return;
114
137
  }
115
- } catch (err) {
116
- console.error(`[gg-data] query "${id}" failed:`, err);
138
+ if (!result) return;
139
+ container.__ggRecord = result;
140
+ populateFields(container, result);
141
+ applySwitchFields(container, result);
142
+ applyDataValues(container, result);
117
143
  }
118
144
  }
119
145
 
@@ -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
  }