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 +151 -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 +84 -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
|
});
|
|
@@ -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
|
|
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
|
|
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
|
-
| `
|
|
209
|
-
| `
|
|
210
|
-
| `
|
|
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
|
|
333
|
+
### Auth adapter
|
|
213
334
|
|
|
214
335
|
| Option | Type | Description |
|
|
215
336
|
|---|---|---|
|
|
216
|
-
| `auth.
|
|
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
|
-
|
|
223
|
-
supabaseKey: "...",
|
|
349
|
+
context: { sb },
|
|
224
350
|
auth: {
|
|
225
|
-
|
|
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 `(
|
|
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 `(
|
|
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
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) => {
|
|
@@ -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(
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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 {
|
|
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
|
}
|