softr-vibe-coding 1.12.0 → 2.1.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/CHANGELOG.md +5 -0
- package/README.md +28 -20
- package/SKILL.md +32 -31
- package/datasources/fields.md +4 -4
- package/datasources/monday.md +8 -1
- package/datasources/multi-datasource.md +4 -2
- package/datasources/notion.md +2 -2
- package/datasources/overview.md +1 -1
- package/datasources/reading.md +26 -6
- package/datasources/rest-api.md +20 -1
- package/datasources/shared-patterns.md +1 -1
- package/datasources/softr-database.md +6 -6
- package/datasources/writing.md +166 -60
- package/package.json +1 -1
- package/references/airtable-automations.md +1 -1
- package/references/anti-patterns.md +11 -7
- package/references/common-patterns.md +1 -1
- package/references/helper-blocks.md +4 -2
- package/references/native-block-filters.md +1 -1
- package/references/quick-reference.md +41 -12
- package/references/softr-mcp.md +140 -0
- package/references/softr-database-mcp.md +0 -59
|
@@ -8,7 +8,7 @@ No setup needed. Softr Database is available by default in every Softr app. Crea
|
|
|
8
8
|
|
|
9
9
|
## AI-Assisted Workflows
|
|
10
10
|
|
|
11
|
-
Softr publishes an official MCP server (`https://mcp.softr.io/mcp`) that lets the AI read Softr DB schema and field IDs directly — eliminating the manual "paste `tablespace-with-tables` JSON" step.
|
|
11
|
+
Softr publishes an official MCP server (`https://mcp.softr.io/mcp`) that lets the AI read Softr DB schema and field IDs directly — eliminating the manual "paste `tablespace-with-tables` JSON" step. The same server can also browse connected Airtable / Google Sheets / Notion / Supabase integrations, and can create and deploy Vibe Coding blocks. For setup, permissions, and the full tool catalog, see [../references/softr-mcp.md](../references/softr-mcp.md).
|
|
12
12
|
|
|
13
13
|
## Vibe Coding Field IDs
|
|
14
14
|
Field IDs are short alphanumeric codes (e.g., `"xgETy"`, `"TLhWF"`). These codes are NOT human-readable names.
|
|
@@ -23,7 +23,7 @@ q.select({ name: "First Name" })
|
|
|
23
23
|
|
|
24
24
|
Find field IDs in this order of preference:
|
|
25
25
|
|
|
26
|
-
1. **Softr
|
|
26
|
+
1. **Softr MCP server** (recommended when working with an AI assistant) — the AI calls schema/list-fields tools directly. See [../references/softr-mcp.md](../references/softr-mcp.md).
|
|
27
27
|
2. **`get-softr-database` CLI script (bundled)** — a Python CLI bundled with this skill at `~/.claude/skills/softr-vibe-coding/tools/get-softr-database.py`. Exports the full schema (every table, field, dropdown option UUID) to `~/Desktop/softr-database-<id>-<timestamp>.json`. Stdlib only, no `pip install`. See [Bundled CLI script](#bundled-cli-script-get-softr-database) below.
|
|
28
28
|
3. **Network inspector** — DevTools -> Network -> filter `tablespace-with-tables` for the full schema including dropdown option UUIDs. Paste the JSON into chat to share with an AI when the MCP isn't installed.
|
|
29
29
|
4. **Inline in Studio** — click a field's name in the Data tab; the ID appears in the field-edit drawer.
|
|
@@ -86,11 +86,11 @@ After `source ~/.zshrc`, just run `get-softr-database <database_id>` from anywhe
|
|
|
86
86
|
|----------------|----------|-------|
|
|
87
87
|
| Text | Yes | |
|
|
88
88
|
| Number | Yes | |
|
|
89
|
-
| Date | Yes | |
|
|
89
|
+
| Date | Yes | Date-semantics fields take `"yyyy-MM-dd"`; timestamp fields take `new Date().toISOString()` (verified 2026-08-25) |
|
|
90
90
|
| File / Image | Yes | |
|
|
91
91
|
| Checkbox | Yes | |
|
|
92
|
-
| Dropdown | Yes | |
|
|
93
|
-
| Relationship | Yes | Linked records to other Softr Database tables |
|
|
92
|
+
| Dropdown | Yes | Write the option **LABEL string**, exactly matching a defined choice (verified 2026-08-25; supersedes the old option-UUID rule). See [writing.md](writing.md#dropdown--single-select-softr-database) |
|
|
93
|
+
| Relationship | Yes | Linked records to other Softr Database tables. Write as an **array of record-id strings**, e.g. `[recordId]` (verified 2026-08-25) |
|
|
94
94
|
| Formula | Read-only | Booleans return as strings: use `=== "1"` for true, `=== "0"` for false |
|
|
95
95
|
|
|
96
96
|
## Rate Limits
|
|
@@ -98,7 +98,7 @@ No API rate limits. Softr Database queries run internally without external API c
|
|
|
98
98
|
|
|
99
99
|
## Gotchas
|
|
100
100
|
- **Formula boolean values are strings.** A formula that evaluates to true returns `"1"`, not `true`. Always compare with `=== "1"` or `=== "0"`.
|
|
101
|
-
- **Field IDs are opaque codes.** You cannot guess them from column names.
|
|
101
|
+
- **Field IDs are opaque codes.** You cannot guess them from column names. Look them up via the ranked list above (MCP `list_fields` / bundled CLI / network inspector / Studio field drawer) — the generic Field Inspector block does NOT work for Softr Database.
|
|
102
102
|
- **Relationships** work similarly to linked records in Airtable but use Softr's internal record IDs.
|
|
103
103
|
|
|
104
104
|
## Best For
|
package/datasources/writing.md
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
# Writing Data
|
|
2
2
|
|
|
3
|
-
Record mutations, file uploads, linked record format, and cross-table operations.
|
|
3
|
+
Record mutations, sequential write queues, file uploads, linked record format, and cross-table operations.
|
|
4
4
|
|
|
5
5
|
## Table of Contents
|
|
6
6
|
|
|
7
7
|
- [How Actions Work (Studio's Actions Tab)](#how-actions-work-studios-actions-tab)
|
|
8
8
|
- [Record Mutations](#record-mutations)
|
|
9
|
+
- [Sequential Multi-Row Writes (mutateAsync)](#sequential-multi-row-writes-mutateasync)
|
|
9
10
|
- [File Uploads](#file-uploads)
|
|
10
11
|
- [Linked Record Format for Mutations](#linked-record-format-for-mutations)
|
|
11
12
|
- [Writing to Field Types](#writing-to-field-types)
|
|
@@ -23,6 +24,13 @@ Each Action's "FIELDS USED" list mirrors the aliases in your `q.select()` mappin
|
|
|
23
24
|
- Cosmetic AND structural code edits both update the Action automatically -- you do NOT need to re-prompt the AI assistant after editing code
|
|
24
25
|
- There is no manual delete control; to remove an Action, remove the mutation hook from the code
|
|
25
26
|
|
|
27
|
+
**⚠️ Every recompile resets Action permissions (verified live 2026-08-25).** Each code
|
|
28
|
+
recompile/redeploy re-registers the block's auto-derived Actions with **default permissions** —
|
|
29
|
+
any per-Action permission tightening done in the Actions tab is wiped. Deployment-order
|
|
30
|
+
implication: do the Actions-tab tightening pass only AFTER the last redeploy of a block, and
|
|
31
|
+
re-check every tightened block after any future redeploy. Hit across a 15-block production
|
|
32
|
+
deployment; treat it as standing platform behavior, not a one-off.
|
|
33
|
+
|
|
26
34
|
The `enabled` boolean on a mutation hook is a combined signal — it's `true` only when BOTH conditions are met:
|
|
27
35
|
|
|
28
36
|
1. **The Action was successfully derived from the code** (parser side). Causes of failure here:
|
|
@@ -65,6 +73,11 @@ if (createRecord.enabled) {
|
|
|
65
73
|
}
|
|
66
74
|
```
|
|
67
75
|
|
|
76
|
+
**Create payloads are FLAT — no `{ fields }` wrapper** (verified live 2026-08-25). The payload's
|
|
77
|
+
keys are the aliases from the hook's `fields:` q.select, at the top level. This is deliberately
|
|
78
|
+
asymmetric with `useRecordUpdate`, whose payload nests them: `{ recordId, fields: { ... } }`.
|
|
79
|
+
Wrapping a create payload in `fields:` is a wrong shape — don't copy it from an update call.
|
|
80
|
+
|
|
68
81
|
### useRecordUpdate
|
|
69
82
|
|
|
70
83
|
```jsx
|
|
@@ -85,38 +98,31 @@ updateRecord.mutate({
|
|
|
85
98
|
});
|
|
86
99
|
```
|
|
87
100
|
|
|
88
|
-
#### CRITICAL:
|
|
89
|
-
|
|
90
|
-
Softr's Action parser is strict about both the **method name** and the **payload shape**. Get either wrong and the Action is never derived, `enabled` stays `false`, and any UI gated on it silently does nothing — no error, no warning, console just shows `enabled: false, error: null, status: "idle"`.
|
|
101
|
+
#### CRITICAL: The `useRecordUpdate` payload shape (and the retired `.mutate()`-only rule)
|
|
91
102
|
|
|
92
|
-
**
|
|
93
|
-
|
|
94
|
-
```jsx
|
|
95
|
-
// CORRECT — parser sees `.mutate(`, derives the Action
|
|
96
|
-
updateRecord.mutate(
|
|
97
|
-
{ recordId: id, fields: { status: optionId } },
|
|
98
|
-
{
|
|
99
|
-
onSuccess: function() { toast.success("Saved"); },
|
|
100
|
-
onError: function(err) { toast.error(err.message); },
|
|
101
|
-
}
|
|
102
|
-
);
|
|
103
|
-
|
|
104
|
-
// WRONG — parser ignores `.mutateAsync(`, Action never created
|
|
105
|
-
updateRecord.mutateAsync({ recordId: id, fields: { status: optionId } })
|
|
106
|
-
.then(function() { toast.success("Saved"); });
|
|
107
|
-
```
|
|
108
|
-
|
|
109
|
-
**Requirement 2 — Payload must be `{ recordId, fields: {...} }` — not flat.** Field values must be nested inside a `fields: {...}` object. The flat form (`mutate({ recordId, status: "active" })`) can succeed at runtime, but the parser doesn't see field references inside it, so no Action is derived:
|
|
103
|
+
**Payload must be `{ recordId, fields: {...} }` — not flat.** Field values must be nested inside a `fields: {...}` object. The flat form (`mutate({ recordId, status: "active" })`) can succeed at runtime, but Softr's Action parser doesn't see field references inside it, so no Update Action is derived — `enabled` stays `false`, the UI gated on it silently does nothing, and Studio's Actions tab shows "No actions used in this block yet":
|
|
110
104
|
|
|
111
105
|
```jsx
|
|
112
106
|
// CORRECT
|
|
113
|
-
updateRecord.mutate({ recordId: id, fields: { status:
|
|
107
|
+
updateRecord.mutate({ recordId: id, fields: { status: "Active" } }, { onSuccess, onError });
|
|
114
108
|
|
|
115
109
|
// WRONG — Action parser ignores this, hook stays disabled
|
|
116
|
-
updateRecord.mutate({ recordId: id, status:
|
|
110
|
+
updateRecord.mutate({ recordId: id, status: "Active" }, { onSuccess, onError });
|
|
117
111
|
```
|
|
118
112
|
|
|
119
|
-
|
|
113
|
+
Note the asymmetry: **update payloads nest under `fields:`, create payloads are flat** (no wrapper). Verified live 2026-08-25.
|
|
114
|
+
|
|
115
|
+
**`.mutateAsync()` is fully supported (verified live 2026-08-25 — supersedes the old rule).**
|
|
116
|
+
Until mid-2026 this skill documented that the Action parser only recognized the literal
|
|
117
|
+
`.mutate(` token, and that any mutation written as `.mutateAsync(...)` produced no derived
|
|
118
|
+
Action (verified by direct experiment, May 2026, on the then-current platform). The current
|
|
119
|
+
platform derives Actions for `mutateAsync` call sites too — a 15-block production deployment
|
|
120
|
+
built its entire multi-row write layer on `await hook.mutateAsync(...)` with Actions deriving
|
|
121
|
+
correctly on every block. Use `.mutate(payload, { onSuccess, onError })` for fire-and-forget
|
|
122
|
+
single writes; use `await .mutateAsync(payload)` when the code must sequence writes or branch
|
|
123
|
+
on the result (see [Sequential Multi-Row Writes](#sequential-multi-row-writes-mutateasync)).
|
|
124
|
+
If you're maintaining an old app where an Action refuses to derive, the legacy `.mutate(`-only
|
|
125
|
+
parser is worth checking before deeper debugging.
|
|
120
126
|
|
|
121
127
|
### useRecordDelete
|
|
122
128
|
|
|
@@ -153,18 +159,75 @@ var createFields = q.select({ name: "FIELD_1", email: "FIELD_2" });
|
|
|
153
159
|
var updateFields = q.select({ name: "FIELD_1", email: "FIELD_2" });
|
|
154
160
|
```
|
|
155
161
|
|
|
162
|
+
## Sequential Multi-Row Writes (mutateAsync)
|
|
163
|
+
|
|
164
|
+
`await hook.mutateAsync(...)` is the tool for any save that writes several rows in a required
|
|
165
|
+
order — a header record followed by its line items, a source record followed by ledger rows, a
|
|
166
|
+
batch of rows that must stop cleanly on the first failure. Verified live 2026-08-25: this
|
|
167
|
+
pattern carried every multi-row save in a 15-block production deployment.
|
|
168
|
+
|
|
169
|
+
The battle-tested queue shape:
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
// Track queue state in component state so a failure is renderable and resumable.
|
|
173
|
+
// { phase: "idle" | "saving" | "failed" | "done", failedIndex: number | null }
|
|
174
|
+
|
|
175
|
+
async function saveAll() {
|
|
176
|
+
setQueue({ phase: "saving", failedIndex: null });
|
|
177
|
+
|
|
178
|
+
// 1. Header first — its id links every line.
|
|
179
|
+
const header = await createHeader.mutateAsync({ name, date }); // create payloads are FLAT
|
|
180
|
+
if (!header?.id) throw new Error("Header created without an id");
|
|
181
|
+
|
|
182
|
+
// 2. Lines in order. STOP on the first failure; never re-issue completed writes.
|
|
183
|
+
for (let i = 0; i < lines.length; i++) {
|
|
184
|
+
if (lines[i].saved) continue; // resume support: skip completed rows
|
|
185
|
+
try {
|
|
186
|
+
await createLine.mutateAsync({ header: [header.id], product: lines[i].product, qty: lines[i].qty });
|
|
187
|
+
markSaved(i);
|
|
188
|
+
} catch (err) {
|
|
189
|
+
setQueue({ phase: "failed", failedIndex: i }); // render the failed line + a Retry button
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
await refetch(); // refresh affected queries BEFORE toasting
|
|
194
|
+
setQueue({ phase: "done", failedIndex: null });
|
|
195
|
+
toast.success("Saved");
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Rules that make this safe:
|
|
200
|
+
|
|
201
|
+
- **Header first, then lines in order.** Await each write; never fire the loop in parallel and
|
|
202
|
+
never chain with nested `.then()` callbacks.
|
|
203
|
+
- **Stop on failure, keep the queue.** Render the failed row with its error and a Retry button;
|
|
204
|
+
Retry re-runs only the failed write and resumes the remainder. Completed writes are never
|
|
205
|
+
re-issued.
|
|
206
|
+
- **Guard against ambiguous failures.** A failed *response* doesn't prove a failed *write* (the
|
|
207
|
+
server may have committed and the response been lost). Before a Retry re-writes, re-fetch the
|
|
208
|
+
already-written child rows for that header and skip any the server already has — this is what
|
|
209
|
+
makes the queue double-write-proof.
|
|
210
|
+
- **Guard the created id.** If a create resolves without an id, throw — don't write lines linked
|
|
211
|
+
to `undefined`.
|
|
212
|
+
- **Gate the whole flow on the hooks' `enabled` booleans**, same as any mutation UI.
|
|
213
|
+
|
|
156
214
|
## File Uploads
|
|
157
215
|
|
|
158
216
|
```jsx
|
|
159
217
|
import { useUpload } from "@/lib/datasource";
|
|
160
218
|
|
|
161
219
|
var upload = useUpload();
|
|
220
|
+
// Returns { uploadAsync, isUploading } — use isUploading to disable the submit
|
|
221
|
+
// button / show a spinner while the upload is in flight:
|
|
222
|
+
// <Button disabled={upload.isUploading || !file}>...
|
|
162
223
|
|
|
163
224
|
// Single file:
|
|
164
225
|
upload.uploadAsync(file).then(function(results) {
|
|
165
226
|
var result = results[0];
|
|
166
227
|
if (result.status === "completed") {
|
|
167
228
|
// result.url = uploaded file URL, result.file.name = original filename
|
|
229
|
+
} else {
|
|
230
|
+
toast.error((result.error && result.error.message) || "Upload failed");
|
|
168
231
|
}
|
|
169
232
|
});
|
|
170
233
|
|
|
@@ -176,10 +239,19 @@ upload.uploadAsync(file).then(function(results) {
|
|
|
176
239
|
name: "Document",
|
|
177
240
|
attachment: { filename: result.file.name, url: result.url },
|
|
178
241
|
});
|
|
242
|
+
} else {
|
|
243
|
+
toast.error((result.error && result.error.message) || "Upload failed");
|
|
179
244
|
}
|
|
180
245
|
});
|
|
246
|
+
|
|
247
|
+
// Multiple files: uploadAsync accepts an array; filter the completed results.
|
|
248
|
+
upload.uploadAsync(Array.from(e.target.files)).then(function(results) {
|
|
249
|
+
var completed = results.filter(function(r) { return r.status === "completed"; });
|
|
250
|
+
});
|
|
181
251
|
```
|
|
182
252
|
|
|
253
|
+
Always handle the non-`"completed"` branch — a failed result carries `result.error?.message` for the toast.
|
|
254
|
+
|
|
183
255
|
### Async/await style
|
|
184
256
|
|
|
185
257
|
The official Softr Vibe Coding docs use this form; it's more ergonomic when uploading inside a larger async flow:
|
|
@@ -188,81 +260,91 @@ The official Softr Vibe Coding docs use this form; it's more ergonomic when uplo
|
|
|
188
260
|
var [result] = await upload.uploadAsync(file);
|
|
189
261
|
if (result.status === "completed") {
|
|
190
262
|
createRecord.mutate(
|
|
191
|
-
{
|
|
263
|
+
{ attachment: { filename: result.file.name, url: result.url } }, // create payload is FLAT
|
|
192
264
|
{ onSuccess: function() { toast.success("Saved"); } }
|
|
193
265
|
);
|
|
194
266
|
}
|
|
195
267
|
```
|
|
196
268
|
|
|
197
|
-
|
|
269
|
+
Inside a larger async flow, `await createRecord.mutateAsync({ attachment: ... })` works just as
|
|
270
|
+
well — `mutateAsync` is fully supported on the current platform (verified 2026-08-25; see the
|
|
271
|
+
supersession note under `useRecordUpdate` above).
|
|
198
272
|
|
|
199
273
|
## Linked Record Format for Mutations
|
|
200
274
|
|
|
275
|
+
Write linked-record fields as an **array of record-id strings** (verified live 2026-08-25 on
|
|
276
|
+
Softr Database — every cross-table link in a 15-block production deployment used this shape):
|
|
277
|
+
|
|
201
278
|
```jsx
|
|
202
|
-
// CORRECT --
|
|
279
|
+
// CORRECT -- array of record-id strings, even for a single link
|
|
203
280
|
createRecord.mutate({
|
|
204
|
-
parentAccount: [
|
|
205
|
-
teamMembers: [
|
|
281
|
+
parentAccount: ["RECORD_ID_1"],
|
|
282
|
+
teamMembers: ["MEMBER_1", "MEMBER_2"],
|
|
206
283
|
});
|
|
207
284
|
|
|
208
|
-
// WRONG --
|
|
209
|
-
parentAccount: "RECORD_ID_1"
|
|
210
|
-
teamMembers: ["MEMBER_1"] // Won't work
|
|
285
|
+
// WRONG -- bare string, not wrapped in an array
|
|
286
|
+
parentAccount: "RECORD_ID_1"
|
|
211
287
|
```
|
|
212
288
|
|
|
289
|
+
**Legacy / Airtable note.** This skill previously documented arrays of `{ id }` objects
|
|
290
|
+
(`teamMembers: [{ id: "MEMBER_1" }]`), verified May 2026 on Airtable-backed blocks. The
|
|
291
|
+
string-array shape is the verified current form on Softr Database; if a linked-record write
|
|
292
|
+
fails on an Airtable-backed block, try the `[{ id }]` object shape before deeper debugging.
|
|
293
|
+
|
|
213
294
|
## Writing to Field Types
|
|
214
295
|
|
|
215
296
|
Different Softr field types accept different value shapes in mutation payloads. The shape returned when you READ a field is often different from the shape you must SEND when you WRITE.
|
|
216
297
|
|
|
217
298
|
### Dropdown / Single Select (Softr Database)
|
|
218
299
|
|
|
219
|
-
Write the option's
|
|
300
|
+
Write the option's **LABEL string** — it must exactly match a defined choice on the field. No
|
|
301
|
+
option-UUID discovery step is needed:
|
|
220
302
|
|
|
221
303
|
```jsx
|
|
222
|
-
// CORRECT --
|
|
304
|
+
// CORRECT -- the option's display label, exactly as defined
|
|
223
305
|
createRecord.mutate({
|
|
224
|
-
status: "
|
|
306
|
+
status: "Active",
|
|
225
307
|
});
|
|
226
308
|
|
|
227
309
|
// WRONG -- object form (returned on read, but rejected on write)
|
|
228
310
|
createRecord.mutate({
|
|
229
311
|
status: { id: "822b8d69-3af4-47b4-90eb-3a80c5d1b85c", label: "Active" },
|
|
230
312
|
});
|
|
231
|
-
|
|
232
|
-
// WRONG -- display label
|
|
233
|
-
createRecord.mutate({
|
|
234
|
-
status: "Active",
|
|
235
|
-
});
|
|
236
313
|
```
|
|
237
314
|
|
|
238
|
-
|
|
315
|
+
Verified live 2026-08-25 (Softr Database, `useRecordCreate` AND `useRecordUpdate`): every
|
|
316
|
+
controlled-vocabulary SELECT write in a 15-block production deployment wrote label strings
|
|
317
|
+
(`"Tier 1"`, `"Prospect"`, `"Physical count correction"`, ...) with no UUID discovery step.
|
|
239
318
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
-
|
|
319
|
+
The label must match a defined choice character-for-character — a typo fails the write, so
|
|
320
|
+
keep vocabularies as greppable constants, or fetch them live with `useFieldOptions` (see
|
|
321
|
+
[reading.md](reading.md#usefieldoptions----fetch-singlemulti-select-choices)) and write
|
|
322
|
+
`option.label`.
|
|
243
323
|
|
|
244
|
-
|
|
324
|
+
**Legacy note.** Until mid-2026 this skill documented the opposite — write the option UUID,
|
|
325
|
+
labels rejected (verified April 2026 on the then-current platform). If a label write is
|
|
326
|
+
rejected on an old app, the UUID form is the thing to try; on the current platform it is not
|
|
327
|
+
needed.
|
|
245
328
|
|
|
246
329
|
### Linked Record
|
|
247
330
|
|
|
248
|
-
Array of `{ id }` objects. See "Linked Record Format for Mutations" above.
|
|
331
|
+
Array of record-id **strings** (`["RECORD_ID"]`) on Softr Database (verified 2026-08-25); the legacy / Airtable fallback shape is `[{ id }]` objects. See "Linked Record Format for Mutations" above.
|
|
249
332
|
|
|
250
333
|
### Multi-Select
|
|
251
334
|
|
|
252
|
-
|
|
335
|
+
Expected: array of option **label strings** — mirrors Single Select (which writes by label,
|
|
336
|
+
verified 2026-08-25) but wrapped in an array:
|
|
253
337
|
|
|
254
338
|
```jsx
|
|
255
|
-
//
|
|
256
|
-
createRecord.mutate({ tags: ["
|
|
339
|
+
// EXPECTED
|
|
340
|
+
createRecord.mutate({ tags: ["Urgent", "Internal"] });
|
|
257
341
|
|
|
258
342
|
// WRONG -- {id, label} objects (returned on read, rejected on write)
|
|
259
343
|
tags: [{ id: "uuid-1", label: "Urgent" }]
|
|
260
|
-
|
|
261
|
-
// WRONG -- display labels
|
|
262
|
-
tags: ["Urgent", "Internal"]
|
|
263
344
|
```
|
|
264
345
|
|
|
265
|
-
|
|
346
|
+
_Not independently verified — inferred from the verified single-select label behavior; verify by
|
|
347
|
+
experiment before production use. (The pre-2026-08 platform took option-UUID arrays instead.)_
|
|
266
348
|
|
|
267
349
|
### Number
|
|
268
350
|
|
|
@@ -296,7 +378,10 @@ createRecord.mutate({ dueDate: "2025-03-15" });
|
|
|
296
378
|
createRecord.mutate({ lastSeenAt: "2025-03-15T14:00:00Z" });
|
|
297
379
|
```
|
|
298
380
|
|
|
299
|
-
|
|
381
|
+
Verified live 2026-08-25 (Softr Database): DATETIME fields with **date semantics** (a due date,
|
|
382
|
+
a distribution date) take the `"yyyy-MM-dd"` form; **timestamp** fields (`*_at` audit fields)
|
|
383
|
+
take `new Date().toISOString()`. Writing a full timestamp into a date-semantics field invites
|
|
384
|
+
timezone-shift bugs — compare and display such fields on the `yyyy-MM-dd` slice.
|
|
300
385
|
|
|
301
386
|
### Date Range
|
|
302
387
|
|
|
@@ -333,16 +418,37 @@ To upload a file before writing it to a record, see [File Uploads](#file-uploads
|
|
|
333
418
|
|
|
334
419
|
_Shape matches the example shown in [File Uploads](#file-uploads). Not yet independently verified across all data sources._
|
|
335
420
|
|
|
336
|
-
### Text / Email / URL
|
|
421
|
+
### Text / Email / URL
|
|
337
422
|
|
|
338
423
|
Plain string. To clear a value, both `null` and `""` work for Softr Database text fields (verified by direct experiment, May 2026, for `useRecordUpdate`). Behavior on other data sources has not been independently verified.
|
|
339
424
|
|
|
425
|
+
### Phone
|
|
426
|
+
|
|
427
|
+
**Unformatted international format only**: `+` followed by digits — nothing else (e.g. `+12125550100`). Some datasources (Monday.com is the one the official guide names) reject values containing spaces, dashes, or parentheses. Strip all non-digit characters except the leading `+` before submitting (official sanitizer from the developer guide):
|
|
428
|
+
|
|
429
|
+
```tsx
|
|
430
|
+
const sanitizePhone = (raw: string) => raw.replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");
|
|
431
|
+
|
|
432
|
+
mutate({ phone: sanitizePhone(inputValue) });
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
Any form block with a phone input should sanitize before `mutate()` — never send the user's typed formatting through.
|
|
436
|
+
|
|
340
437
|
## Cross-Table Operations
|
|
341
438
|
|
|
342
|
-
|
|
439
|
+
**The default path is multi-datasource (supersedes the REST-API-first guidance that used to live
|
|
440
|
+
here).** A block can connect to several data sources at once — declare them with
|
|
441
|
+
`datasource.define({ alias: "uuid", ... })` and pass `from: ds.alias` on every hook, including
|
|
442
|
+
the mutation hooks. Reading two tables and writing a third from one block needs no helper
|
|
443
|
+
block, no REST API, and no exposed key. Required reading:
|
|
444
|
+
[multi-datasource.md](multi-datasource.md). Verified at scale 2026-08-25: a 15-block production
|
|
445
|
+
deployment routed all of its cross-table writes (header + ledger + audit-log rows) through
|
|
446
|
+
`from:`-scoped mutation hooks.
|
|
447
|
+
|
|
448
|
+
Two remaining alternatives, for the cases multi-datasource doesn't cover:
|
|
343
449
|
|
|
344
|
-
- **For Airtable backends** —
|
|
345
|
-
- **Softr Database REST API via `fetch()`** — the
|
|
450
|
+
- **For Airtable backends** — when the cascade logic is heavy, write to the block's own table and let an Airtable automation script handle the cascade. See [../references/airtable-automations.md](../references/airtable-automations.md). Keeps the block simple and lets cross-table logic live next to the data.
|
|
451
|
+
- **Softr Database REST API via `fetch()`** — a fallback for what the hooks can't express (e.g. writes from outside a Vibe block, or admin tooling that must bypass block bindings). Details below.
|
|
346
452
|
|
|
347
453
|
**Base URL:** `https://tables-api.softr.io/api/v1/databases/{databaseId}/tables/{tableId}/records`
|
|
348
454
|
|
|
@@ -358,4 +464,4 @@ Notes:
|
|
|
358
464
|
- Use `fieldNames=true` on GET for human-readable field names
|
|
359
465
|
- Rate limits: Reads 40 req/s, Writes 30 req/s
|
|
360
466
|
|
|
361
|
-
Verified by direct experiment (May 2026): POST to this endpoint with field IDs as keys writes successfully, returning HTTP 200 and the full record JSON.
|
|
467
|
+
Verified by direct experiment (May 2026): POST to this endpoint with field IDs as keys writes successfully, returning HTTP 200 and the full record JSON. At that time the endpoint took plain string UUIDs for dropdown writes, and the response returned the dropdown value as a `{id, label}` object (matching the read shape). Note: that observation predates the 2026-08-25 finding that the **in-block hooks** write SELECTs by label — the REST endpoint is a separate surface and may still expect UUIDs; re-verify whichever shape you use here.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "softr-vibe-coding",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "2.1.0",
|
|
4
4
|
"description": "Claude Code skill for generating production-ready Softr Vibe Coding blocks (JSX). Installs into ~/.claude/skills/ and auto-updates on each Claude Code session.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"softr-vibe-coding": "./bin/cli.js"
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Companion reference for Softr Vibe Coding blocks. Many Softr blocks talk to an Airtable backend, and some flows can't be done from the block side — most commonly **cross-table writes triggered by a record change** (the block can only write to its own configured data source). For those, the right tool is an Airtable Automation Script, written in JavaScript and triggered by Airtable's automation runner.
|
|
4
4
|
|
|
5
|
-
This guide covers Airtable's two scripting environments + Airtable formulas. **It is NOT about Softr Vibe Coding** — runtime, API surface, and gotchas are entirely different. Don't apply Softr block rules (
|
|
5
|
+
This guide covers Airtable's two scripting environments + Airtable formulas. **It is NOT about Softr Vibe Coding** — runtime, API surface, and gotchas are entirely different. Don't apply Softr block rules (shadow DOM isolation, data-hook constraints, etc.) here.
|
|
6
6
|
|
|
7
7
|
## When to reach for an Airtable script vs. a Softr block
|
|
8
8
|
|
|
@@ -16,7 +16,7 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
16
16
|
| Hardcoding API keys for connected API | Use `useProxyFetch` -- key stays server-side |
|
|
17
17
|
| Using `q.select({})` to dump all fields on Softr Database | Returns record IDs with empty `fields: {}`. Look up field IDs in Studio's Data tab, or use the Softr DB REST API with `fieldNames=true` |
|
|
18
18
|
| Building an invisible helper block + `window` globals just to read a second table | A block can connect to **multiple data sources**. Declare them with `datasource.define({ alias: "uuid" })` and pass `from: ds.alias` on every hook. One block instead of two, no page-order dependency, no mount-timing race. See [datasources/multi-datasource.md](../datasources/multi-datasource.md). Helper blocks remain correct for genuinely cross-*block* jobs (triggering another block, sharing computed state) — just not for plain multi-table reads |
|
|
19
|
-
| Omitting `from:` on a hook when the block has more than one datasource | Throws at runtime. `from:` is optional ONLY when exactly one source is connected — then hooks default to it. Applies to `useRecords`, `useRecord`, `useLinkedRecords`, `useFieldOptions`, `useMetric`, `useChartData`, `useRecordCreate`, `useRecordUpdate`, `useRecordDelete`. NOT to `useUpload` / `useCurrentRecordId`, which are app-level |
|
|
19
|
+
| Omitting `from:` on a hook when the block has more than one datasource | Throws at runtime. `from:` is optional ONLY when exactly one source is connected — then hooks default to it. Applies to `useRecords`, `useRecord`, `useLinkedRecords`, `useFieldOptions`, `useMetric`, `useChartData`, `useRecordCreate`, `useRecordUpdate`, `useRecordDelete`. NOT to `useUpload` / `useCurrentRecordId`, which are app-level. `useProxyFetch` has the same multi-datasource requirement but takes the alias as its **argument** — `useProxyFetch(ds.store)` — not as `from:` |
|
|
20
20
|
| Hoisting datasource ids into constants: `datasource.define({ people: PEOPLE_DS_ID })` | Fails to compile — *"datasource.define() object values must be string literals."* Softr statically analyses the call, same as `q.select()`. Keep the UUIDs **inline**: `datasource.define({ people: "74d2cbfd-…" })`. Fails fast with an explicit message, but hoisting magic strings is a strong reflex — resist it here |
|
|
21
21
|
| Asking Studio's AI chat "what are the datasource IDs?" and pasting the answer | **It fabricates them.** Verified July 2026: asked three times for the same three connected tables, it gave three different UUID sets, once reusing a previously-mentioned table's uuid for a different table — all confidently worded, none hedged. Ask it to **write code** instead (*"write a datasource.define call covering every connected source, plus one useRecords per source, code only"*) — scaffolding is bound to the real connections. Then RUN it: real rows under each heading proves each alias maps where you think. A wrong uuid fails safe (matches nothing → error); a *swapped pair* of valid uuids does not |
|
|
22
22
|
|
|
@@ -25,15 +25,19 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
25
25
|
| Anti-Pattern | Correct Approach |
|
|
26
26
|
|---|---|
|
|
27
27
|
| `.mutate({ id: ... })` | `.mutate({ recordId: ... })` -- `id` causes 404 |
|
|
28
|
-
| `updateRecord.mutate({ recordId, status: "..." })` — flat payload | `updateRecord.mutate({ recordId, fields: { status: "..." } })` — fields **must** be nested. The flat form can run at runtime but Softr's Action parser doesn't see field references inside it, so the derived Update Action never gets created. The hook's `enabled` stays `false`, the Save button never lights up, the Actions tab in Studio shows "No actions used in this block yet" — all with no error, no warning. The symptom is a button that does nothing and a console log showing `enabled: false, error: null, status: "idle"`. Use the nested form for EVERY
|
|
29
|
-
|
|
|
28
|
+
| `updateRecord.mutate({ recordId, status: "..." })` — flat payload | `updateRecord.mutate({ recordId, fields: { status: "..." } })` — fields **must** be nested. The flat form can run at runtime but Softr's Action parser doesn't see field references inside it, so the derived Update Action never gets created. The hook's `enabled` stays `false`, the Save button never lights up, the Actions tab in Studio shows "No actions used in this block yet" — all with no error, no warning. The symptom is a button that does nothing and a console log showing `enabled: false, error: null, status: "idle"`. Use the nested form for EVERY update call, even single-field updates (create payloads are flat — see the dedicated row below). See [datasources/writing.md](../datasources/writing.md#critical-the-userecordupdate-payload-shape-and-the-retired-mutate-only-rule) |
|
|
29
|
+
| Sequencing multi-row saves with nested `.then()/.catch()` chains, or firing the rows in parallel | `await hook.mutateAsync(row)` per row, in order — header first, then lines; stop on the first failure with renderable retry state; never re-issue completed writes. `mutateAsync` is **fully supported** on the current platform (verified live 2026-08-25 — this supersedes the May 2026 finding that the Action parser only recognized the literal `.mutate(` token; that limitation is gone, and is worth checking only when maintaining an old app whose Action refuses to derive). Full queue pattern: [datasources/writing.md](../datasources/writing.md#sequential-multi-row-writes-mutateasync) |
|
|
30
30
|
| Assuming `mutation.enabled === false` always means a code bug | `enabled` is BOTH a parser signal AND a permissions signal. Per the official Softr docs, "`enabled` reflects user permissions." When code looks correct and the Actions tab shows the action listed, the cause is almost always permissions. Test by switching "Preview as" in Studio to an Owner / admin; if it then works, the issue is permissions. Three places to check, in priority order: (1) the block's **Visibility** tab (right panel), (2) **Studio → Users → Data Restrictions → Global data restrictions** — an app-wide layer that easily gets overlooked because it's hidden under Users (not on the block); it overlays every block in the app, and a single restriction on the target table will silently disable every mutation against that table for the affected user group, (3) the data-source PAT scope — if granted read-only, every write fails regardless of UI permissions. See [datasources/writing.md](../datasources/writing.md#how-actions-work-studios-actions-tab) |
|
|
31
31
|
| `deleteRecord.mutate({ id: r.id })` | `deleteRecord.mutate(r.id)` -- just the string |
|
|
32
|
-
| `var { mutateAsync } = useRecordUpdate({...})` | `var updateRecord = useRecordUpdate({...})` -- keep full object
|
|
32
|
+
| `var { mutateAsync } = useRecordUpdate({...})` -- destructuring the mutate function off the hook | `var updateRecord = useRecordUpdate({...})` -- keep the full object so `.enabled`, `.status`, `.reset()` stay reachable (using `.mutateAsync` itself is fine) |
|
|
33
33
|
| Not calling `refetch()` after mutations | Always `refetch()` in `onSuccess` |
|
|
34
34
|
| Including read-only fields (formula / rollup / aiText / lookup / createdTime / lastModifiedTime / autoNumber) in the `fields` q.select passed to `useRecordCreate` or `useRecordUpdate` | Softr's Action parser silently rejects the **entire** create/update Action — not just the bad alias. Same all-or-nothing failure mode as a renamed/missing column: Studio's Actions tab shows "No actions used in this block yet", `createRecord.enabled` / `updateRecord.enabled` stays `false`, `.mutate()` calls dispatch but resolve to "not yet ready", every OTHER writable field in the same q.select is also lost. Reads handle these field types fine — only the write q.select chokes. Fix: split into separate q.selects per the Three Mappings Pattern (`useRecord` / `useRecords` gets the full select with read-only fields; `useRecordCreate` / `useRecordUpdate` gets a writable-only subset). Diagnostic when the symptom shows up: same bisection procedure as the renamed-column case — strip the write q.select to a known-writable minimum, then add fields back in halves until the Action drops out. Verified 2026-05-22: `blocks/wig-details/wig-details-page.jsx` shared one `wigSelect` between `useRecord` and `useRecordUpdate`; the select included `Wig Tag ID` (formula), `Total client price` (rollup), `Total worker pay` (rollup), `Instrucciones` (aiText) — Update Action stayed disabled until those four read-only fields were lifted out into a separate write-only select. See [datasources/airtable.md](../datasources/airtable.md#maintainability-gotcha) and [datasources/writing.md](../datasources/writing.md). |
|
|
35
|
-
| Linked record as
|
|
36
|
-
| Writing dropdown values as `{ id, label }` objects (the read shape) | Write the option
|
|
35
|
+
| Linked record as bare string, or `[{ id }]` objects on Softr Database | Array of record-id **strings**: `familyLink: [familyId]` — verified live 2026-08-25 on Softr DB. (The `[{ id }]` object shape was the May 2026 verified form on Airtable-backed blocks; try it if a string-array write fails there.) See [datasources/writing.md](../datasources/writing.md#linked-record-format-for-mutations) |
|
|
36
|
+
| Writing dropdown values as `{ id, label }` objects (the read shape) or hunting for option UUIDs | Write the option **LABEL string**, exactly matching a defined choice — e.g. `status: "Active"` (verified live 2026-08-25; supersedes the April 2026 UUID rule). Keep vocabularies as greppable constants or fetch live via `useFieldOptions` and write `option.label` |
|
|
37
|
+
| Writing a formatted phone value (`(212) 555-0100`, `212-555-0100`) to a PHONE field | Sanitize to unformatted international format — `+` followed by digits only (`+12125550100`) — before `mutate()`; some datasources (Monday.com, per the official guide) reject formatted values outright. Official sanitizer: `const sanitizePhone = (raw) => raw.replace(/[^\d+]/g, "").replace(/(?!^)\+/g, "");` See [datasources/writing.md](../datasources/writing.md#phone) |
|
|
38
|
+
| Wrapping a `useRecordCreate` payload in `{ fields: {...} }` (copied from an update call) | Create payloads are **FLAT** — `createRecord.mutate({ name: "Jane" })`. Only update payloads nest: `{ recordId, fields: {...} }`. The asymmetry is by design (verified 2026-08-25) |
|
|
39
|
+
| Passing a data hook's options through a variable or wrapper function: `useRecords(buildOpts())` | **Fails to compile** — the options object must be an inline literal at the call site (verified live 2026-08-25; hit in production, fixed by making the wrapper take the hook's *result* instead). Share `q.select` mappings between hooks, never whole options objects |
|
|
40
|
+
| Tightening Actions-tab permissions before the block's final redeploy | Every code recompile **resets the auto-registered Actions to default permissions** (verified live 2026-08-25). Tighten permissions after the LAST redeploy, and re-check after any future one |
|
|
37
41
|
| Treating Studio's Actions tab as a separately-managed configuration to keep in sync with code | Actions auto-derive from your `useRecordCreate`/`useRecordUpdate`/`useRecordDelete` + `q.select` on every save. The Actions tab is a read-only inspector; there is no manual delete control. To change an Action, change the code |
|
|
38
42
|
| One alias in a write-side `q.select` referencing a renamed / non-existent Airtable column | Softr's Action parser silently rejects the **entire** create/update Action — not just the bad alias. Symptoms: Studio's Actions tab shows "No actions used in this block yet", `createRecord.enabled` / `updateRecord.enabled` stays `false`, `.mutate()` calls dispatch but resolve immediately to "not yet ready". Every OTHER field in the same `q.select()` is also lost, even the ones that map cleanly. Diagnostic: bisect the `q.select` — strip down to a known-good minimal set, confirm the Action appears in Studio, then add fields back in halves until it drops out. The culprit is in the last half added. Once narrowed to a single field, grep its name against the freshest Airtable schema export to catch the rename / trailing-space / case-mismatch. Verified 2026-05-21: a `"Photos"` column on Wigs was renamed to `"Before Photos"`, the helper that wrote `photos: "Photos"` had its entire Action disabled even though 11 other fields in the same `q.select` were fine. See [datasources/airtable.md](../datasources/airtable.md#maintainability-gotcha) |
|
|
39
43
|
|
|
@@ -51,7 +55,7 @@ Run through this catalog before delivering any block. Every row is a violation o
|
|
|
51
55
|
| `import React from 'react'` | Named imports only |
|
|
52
56
|
| Named export | `export default function Block()` |
|
|
53
57
|
| Hook declared after conditional `return` | All hooks at top before any conditional `return` -- React error #310 |
|
|
54
|
-
| `fetchNextPage()` in render body |
|
|
58
|
+
| `fetchNextPage()` in render body | Never in the render body (render → data update → re-render → infinite loop). Call from an event handler — the official Load More pattern: `<button onClick={() => fetchNextPage()} disabled={isFetching}>` — or a guarded `useEffect` for auto-load-all |
|
|
55
59
|
| `useRef` for IDs used in `useMemo` | `useState` -- ref mutations don't trigger recomputation |
|
|
56
60
|
| Defining a sub-component INSIDE the `Block()` function body | Define ALL sub-components at MODULE scope (above `export default function Block()`). Sub-components defined inside `Block()` get a brand-new function reference on every render, which makes React unmount/remount their entire DOM subtree every time `Block` re-renders. The user-visible symptom: **inputs lose focus after typing one character** (because each keystroke triggers a `setState` -> re-render -> the `<input>` is destroyed and recreated). Move `function FieldLabel`, `function TextInput`, `function ChipButton`, `function SectionCard`, etc. above `export default function Block()` so React sees stable component identity across renders. Closure-captured `Block`-internal state must be passed as props, not closed over. |
|
|
57
61
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Common Patterns
|
|
2
2
|
|
|
3
|
-
Small reusable patterns that come up across Vibe Coding blocks but don't warrant their own reference file. Each is a copy-pasteable snippet
|
|
3
|
+
Small reusable patterns that come up across Vibe Coding blocks but don't warrant their own reference file. Each is a copy-pasteable snippet. The snippets below use legacy var-style (`var`, `function() {}`), which remains valid — write new blocks in modern TS (see SKILL.md Style Conventions).
|
|
4
4
|
|
|
5
5
|
## Table of Contents
|
|
6
6
|
|
|
@@ -56,7 +56,7 @@ in the published app.
|
|
|
56
56
|
Key rules:
|
|
57
57
|
- The helper block must be on the **same Softr page** as the consumer -- `window` is page-scoped, globals don't cross pages.
|
|
58
58
|
- **One helper block per foreign table.** Multiple helpers with distinct namespaces coexist fine on the same page.
|
|
59
|
-
- **Read-only pattern.** Helpers expose foreign table data for lookups, pickers, and display only. Writes still happen from the main block via its own `useRecordUpdate` / `useRecordCreate`.
|
|
59
|
+
- **Read-only pattern.** Helpers expose foreign table data for lookups, pickers, and display only. Writes still happen from the main block via its own `useRecordUpdate` / `useRecordCreate`. **Modern path for writing to another table:** connect it as a second datasource and write with `useRecordCreate({ from: ds.x })` — see [writing.md Cross-Table Operations](../datasources/writing.md#cross-table-operations); a webhook or the Softr Database REST API remain fallbacks for writes the hooks can't express. Never write through the helper.
|
|
60
60
|
|
|
61
61
|
## Companion Field Helpers
|
|
62
62
|
|
|
@@ -144,7 +144,9 @@ Key points:
|
|
|
144
144
|
import { useRecords, q } from "@/lib/datasource";
|
|
145
145
|
import { useEffect, useMemo, useRef } from "react";
|
|
146
146
|
|
|
147
|
-
|
|
147
|
+
// q.select values: field NAMES for Airtable/Notion/Google Sheets, field IDs for Softr DB/Supabase.
|
|
148
|
+
// (An Airtable "fldXXX" ID here compiles and saves, then silently returns empty data.)
|
|
149
|
+
var select = q.select({ fullName: "Full Name", isActive: "Active" });
|
|
148
150
|
|
|
149
151
|
function toOption(record) {
|
|
150
152
|
return { id: record.id, title: record.fields.fullName || "" };
|
|
@@ -8,7 +8,7 @@ Softr's native **List / Grid** blocks support category chips and *static* filter
|
|
|
8
8
|
|
|
9
9
|
> **Sibling to [native-chrome-styling.md](native-chrome-styling.md).** That doc restyles Softr's *shell* (header/footer/nav) with global CSS. This one *drives and augments native blocks* with custom-code JS. Both reach the **main document**, never a block's shadow DOM — so this is a **Custom Code Static block**, not a Vibe Coding (JSX) block.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
Modern JS is fine in the `<script>` — Custom Code blocks run unbundled in the browser, and the old `var`/`function(){}`/no-`?.` house style is retired everywhere (the Vibe Coding compiler accepts modern syntax too, verified 2026-08-25).
|
|
12
12
|
|
|
13
13
|
---
|
|
14
14
|
|