softr-vibe-coding 1.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.
@@ -0,0 +1,350 @@
1
+ # Airtable Automation Scripts & Formulas
2
+
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
+
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 (no `?.`, shadow DOM, etc.) here.
6
+
7
+ ## When to reach for an Airtable script vs. a Softr block
8
+
9
+ | Want to … | Use |
10
+ |---|---|
11
+ | Build UI inside a Softr page | Softr Vibe Coding block |
12
+ | React to a record-level event in Airtable (e.g. status change) and cascade to other tables | **Airtable Automation Script** |
13
+ | Run ad-hoc transformations / bulk fixes | Airtable Scripting Extension |
14
+ | Compute a derived value displayed in an Airtable cell | Airtable formula field |
15
+
16
+ If a Softr block needs to write across multiple tables in response to a user click, the **cleanest pattern** is: have the Softr block write to its own table, and let an Airtable automation handle the cascade. See [datasources/writing.md → Cross-Table Operations](../datasources/writing.md#cross-table-operations) for the alternative (Softr Database REST API), but the Airtable-side automation is usually simpler.
17
+
18
+ ## Two scripting environments — pick the right one
19
+
20
+ **Default to Automation Script unless the user explicitly says Scripting Extension.** The Automations panel is most Airtable users' default entry point for scripting — Scripting Extension requires installing an extension first and isn't part of the base UI. Most "write me an Airtable script" requests are about Automation Scripts.
21
+
22
+ ### 1. Automation Script (the "Run a script" action inside an automation) — DEFAULT
23
+
24
+ Background-only. No UI. Receives input from the automation trigger; can output values for downstream automation steps. Reachable in Airtable's left sidebar → **Automations** → add a step → **Run a script**.
25
+
26
+ - Read trigger inputs via `input.config()` — call it WITHOUT arguments. Input variables are configured in the automation's left panel, not in code.
27
+ - Read workspace-level secrets via `input.secret('SECRET_NAME')`. **Critical:** the secret has to be granted to each step that uses it. Each automation **step** that calls `input.secret()` must ALSO declare a matching input variable in its left panel that references the workspace secret — having the secret defined at the workspace level is NOT enough. Calling `input.secret('FOO')` without granting that step access returns falsy, NOT throws. So `if (!token) throw` is the standard guard. When chaining multiple "Run a script" steps in one automation, configure the secret access on EVERY step that needs it — they don't inherit from each other.
28
+ - Output to downstream steps via `output.set(key, value)`.
29
+ - `console.log()` appears in the automation's run log (NOT the browser console). This is your ONLY progress / debug surface.
30
+ - **NO `output.markdown`, NO `output.text`, NO `output.table`, NO `output.inspect`** — those throw `TypeError: output.<x> is not a function` here. They're Scripting-Extension-only.
31
+ - **NO `input.buttonsAsync` / `input.textAsync`** — interactive UI is not available in the background runner.
32
+ - Execution cap: 120 seconds (recently raised from 30s).
33
+ - Can be triggered manually via the "Test" button on the automation, useful for one-shot scripts you don't want to set up a real trigger for.
34
+
35
+ ```js
36
+ // Inputs are wired in the automation UI's left panel:
37
+ // recordId = (record from trigger).id
38
+ let config = input.config();
39
+ let recordId = config.recordId;
40
+
41
+ async function main() {
42
+ try {
43
+ let table = base.getTable('Your Table');
44
+ let record = await table.selectRecordAsync(recordId);
45
+ // ... do work ...
46
+ output.set('processed', true);
47
+ } catch (error) {
48
+ console.log('ERROR: ' + error.message);
49
+ output.set('error', error.message);
50
+ throw error; // re-throw so Airtable marks the run as failed
51
+ }
52
+ }
53
+
54
+ await main();
55
+ ```
56
+
57
+ ### 2. Scripting Extension (run-in-the-UI scripts) — only when explicitly asked
58
+
59
+ Foreground, interactive. Used for one-off transforms, bulk fixes, building admin dashboards. Has a richer API. Requires installing the "Scripting" extension in the base — most bases don't have it by default.
60
+
61
+ - Configure interactive inputs via `input.config({ title, items: [...] })` — note: takes an OBJECT here, unlike Automation Scripts.
62
+ - Show prompts with `await input.buttonsAsync(prompt, options)`, `input.textAsync(...)`, etc.
63
+ - Render output with `output.markdown(...)`, `output.table(...)`.
64
+ - `console.log()` shows in the browser devtools console.
65
+
66
+ ```js
67
+ let settings = input.config({
68
+ title: 'Bulk update tool',
69
+ items: [
70
+ input.config.table('mainTable', { label: 'Primary Table' }),
71
+ input.config.field('targetField', { parentTable: 'mainTable' }),
72
+ ],
73
+ });
74
+
75
+ async function main() {
76
+ let confirm = await input.buttonsAsync('Confirm?', [
77
+ { label: 'Run', variant: 'danger' },
78
+ { label: 'Cancel' },
79
+ ]);
80
+ if (confirm === 'Cancel') return;
81
+ // ... do work ...
82
+ output.markdown('# Done');
83
+ }
84
+
85
+ await main();
86
+ ```
87
+
88
+ ### Pick by question
89
+
90
+ - Default: **Automation Script** (the user is in the Automations panel).
91
+ - **Scripting Extension** only when the user says "Scripting Extension" or describes installing/using the Scripting extension specifically.
92
+
93
+ The two are NOT interchangeable. Mixing patterns breaks things:
94
+
95
+ - `input.config({...})` with arguments in an Automation Script → TypeError
96
+ - `output.markdown(...)` in an Automation Script → TypeError (verified — common mistake when porting a Scripting Extension script)
97
+ - `output.table(...)` / `output.text(...)` / `output.inspect(...)` in an Automation Script → TypeError
98
+ - `await input.buttonsAsync(...)` in an Automation Script → TypeError
99
+
100
+ ## Default to table IDs and field IDs, NOT names
101
+
102
+ For any non-throwaway Airtable script (automations, recurring scripts, anything that lives past one run), reference tables and fields by their **stable IDs** — not their user-facing names. Names are renamed and pluralized constantly during a base's lifecycle; IDs never change once assigned.
103
+
104
+ ```js
105
+ // CORRECT — IDs with names in comments. Renames don't break anything.
106
+ const T_CLIENTS = 'tbllPOXe15SMC81QS'; // Clients
107
+ const F_NAME = 'fldNFd931LGhkIWpE'; // Clients.Name (singleLineText)
108
+ const F_SQUARE_ID = 'fldMDtw2hQCQcDbuX'; // Clients.Square Customer ID
109
+
110
+ const clientsTable = base.getTable(T_CLIENTS);
111
+ const record = await clientsTable.selectRecordAsync(recId, {
112
+ fields: [F_NAME, F_SQUARE_ID],
113
+ });
114
+ const name = record.getCellValueAsString(F_NAME);
115
+ await clientsTable.updateRecordAsync(recId, {
116
+ [F_SQUARE_ID]: 'cust_abc',
117
+ });
118
+
119
+ // WRONG — names break silently when someone renames a field. Two failure
120
+ // modes seen today: (1) Airtable pluralizes reciprocal linked-record fields
121
+ // without warning ("Client" → "Clients"), (2) string-replace refactors of
122
+ // constant names can clobber neighbouring identifiers ("APPT_CLIENT" is a
123
+ // substring of the new "APPT_CLIENTS").
124
+ const F_NAME = 'Name';
125
+ const F_SQUARE_ID = 'Square Customer ID';
126
+ ```
127
+
128
+ **Where this works:** every API that accepts a field reference also accepts the field ID — `getCellValue()`, `getCellValueAsString()`, `selectRecordsAsync({ fields: [...] })`, `selectRecordAsync(id, { fields: [...] })`, the `fields` keys in `createRecordsAsync` / `updateRecordsAsync`, `table.getField(...)`. Same for `base.getTable(...)`.
129
+
130
+ **The one exception — Airtable formulas.** Formula fields reference other fields with `{Field Name}` curly-brace syntax; there is no `{fld...}` ID form. Inside a formula, you're stuck with names.
131
+
132
+ **Practical setup:** declare a constants block at the top of every script with all the IDs you'll use, each with the user-facing name as a trailing comment. When the base schema is exported, IDs are right there in the JSON next to the names — easy to copy in.
133
+
134
+ ```js
135
+ // Tables
136
+ const T_CLIENTS = 'tbllPOXe15SMC81QS'; // Clients
137
+ const T_APPOINTMENTS = 'tbleTXCyKb3v7IOPY'; // Appointments
138
+
139
+ // Clients fields
140
+ const F_CL_NAME = 'fldNFd931LGhkIWpE'; // Name
141
+ const F_CL_EMAIL = 'fld1BHkfMAd6McaHx'; // Email
142
+ const F_CL_SQUARE_ID = 'fldMDtw2hQCQcDbuX'; // Square Customer ID
143
+
144
+ // Appointments fields
145
+ const F_AP_CUSTOMER = 'fldNw09E911YV2S8X'; // Customer (linked → Customers)
146
+ const F_AP_CLIENTS = 'fldcCUrXf8AUtZRgr'; // Clients (linked → Clients)
147
+ ```
148
+
149
+ Reads like noise the first time, but renames stop being a class of bugs.
150
+
151
+ ## Common patterns (both environments)
152
+
153
+ ### Batch updates — Airtable caps at 50 records per call
154
+
155
+ ```js
156
+ // CORRECT — batched
157
+ let remaining = updates.slice();
158
+ while (remaining.length > 0) {
159
+ await table.updateRecordsAsync(remaining.slice(0, 50));
160
+ remaining = remaining.slice(50);
161
+ }
162
+
163
+ // WRONG — single-record updates in a loop are dramatically slower and chew quota
164
+ for (let r of records) {
165
+ await table.updateRecordAsync(r.id, { ... }); // don't
166
+ }
167
+ ```
168
+
169
+ The same 50-record cap applies to `createRecordsAsync`. `selectRecordsAsync` has no batch cap but loads the whole table — keep that in mind for large tables.
170
+
171
+ ### `updateRecordsAsync` rejects duplicate record IDs in a single batch
172
+
173
+ If the same record ID appears twice in the array passed to `updateRecordsAsync`, Airtable rejects the ENTIRE call with:
174
+
175
+ ```
176
+ Error: Record "recXXXXXXXXXXXXX" was specified twice in this request.
177
+ ```
178
+
179
+ No records get updated — it's all-or-nothing per batch. The duplicate doesn't have to be in the same chunk of 50; any duplicate across the full payload triggers it.
180
+
181
+ This is the typical failure mode of a **many-to-one migration** — e.g. consolidating two source tables when multiple source rows map to the same destination row (Customer A and Customer B both match Client X by email, so each one pushes an update for Client X). The fix is to dedupe by destination ID using a Map instead of an Array:
182
+
183
+ ```js
184
+ // WRONG — duplicate Client IDs across the Array trigger the rejection
185
+ const updates = [];
186
+ for (const src of sources) {
187
+ const dest = findDestinationFor(src);
188
+ updates.push({ id: dest.id, fields: { /* merge from src */ } });
189
+ }
190
+ await table.updateRecordsAsync(updates); // throws when two `src` map to same `dest`
191
+
192
+ // CORRECT — Map keyed by destination ID, last-write-wins (or merge in-place)
193
+ const updatesById = new Map();
194
+ for (const src of sources) {
195
+ const dest = findDestinationFor(src);
196
+ let entry = updatesById.get(dest.id);
197
+ if (!entry) {
198
+ entry = { id: dest.id, fields: {} };
199
+ updatesById.set(dest.id, entry);
200
+ }
201
+ Object.assign(entry.fields, mergeFrom(src, entry.fields));
202
+ }
203
+ const updates = Array.from(updatesById.values());
204
+ // ... batch-write as usual
205
+ ```
206
+
207
+ For "fill if empty"-style merges, read the empty check off the **original destination record** (loaded once at the start), not off the in-progress merge entry — otherwise the first source row's value blocks subsequent ones from filling other empty fields.
208
+
209
+ ### Linked records — array of `{ id }` objects
210
+
211
+ ```js
212
+ await table.updateRecordAsync(recordId, {
213
+ 'Linked Field': [{ id: 'recXYZ' }],
214
+ });
215
+ ```
216
+
217
+ Plain strings, arrays of strings, and objects with `recordId` keys all fail. The shape is `[{ id: ... }]`.
218
+
219
+ ### Single-select / multi-select — `{ id }` or `{ name }`
220
+
221
+ ```js
222
+ // Either works for writes — prefer `{ id }` for rename safety:
223
+ 'Status': { id: 'selXXXXX' } // ← survives option rename
224
+ 'Status': { name: 'Active' } // ← breaks if "Active" is renamed
225
+ ```
226
+
227
+ For multi-selects, wrap in an array: `'Tags': [{ id: 'sel1' }, { id: 'sel2' }]`.
228
+
229
+ ### Attachments — `{ url, filename }`
230
+
231
+ ```js
232
+ 'Photos': [
233
+ { url: 'https://example.com/photo.jpg', filename: 'photo.jpg' },
234
+ ]
235
+ ```
236
+
237
+ ### Reading fields
238
+
239
+ - `record.getCellValue('Field Name')` — returns the raw value (linked records as `[{id, name}]`, selects as `{id, name, color}`, attachments as `[{id, url, filename, ...}]`).
240
+ - `record.getCellValueAsString('Field Name')` — returns the user-facing display string. Use for text comparisons (esp. when computed fields could surprise you).
241
+
242
+ ### Field type validation before writing
243
+
244
+ ```js
245
+ let field = table.getField('Target');
246
+ if (field.type !== 'multipleRecordLinks') {
247
+ throw new Error('Target must be a linked-record field');
248
+ }
249
+ ```
250
+
251
+ ### Error handling
252
+
253
+ ```js
254
+ try {
255
+ // ... operation ...
256
+ } catch (e) {
257
+ console.log('ERROR: ' + e.message);
258
+ // In Automation Scripts, also surface to downstream steps:
259
+ output.set('error', e.message);
260
+ throw e; // re-throw so Airtable flags the run as failed
261
+ }
262
+ ```
263
+
264
+ ## Cheat sheet
265
+
266
+ All examples use field-ID style (the recommended default). Field IDs come from the schema export (`base-schema-*.json`) or from Studio's Data tab → "Customize field type" panel.
267
+
268
+ | Operation | Pattern |
269
+ |---|---|
270
+ | Get table by ID | `base.getTable('tblXXX')` |
271
+ | Get field on a table | `table.getField('fldXXX')` |
272
+ | Batch update | `table.updateRecordsAsync(updates.slice(0, 50))` |
273
+ | Batch create | `table.createRecordsAsync(records.slice(0, 50))` |
274
+ | Single fetch | `await table.selectRecordAsync(recordId, { fields: ['fldNAME', 'fldEMAIL'] })` |
275
+ | All records | `await table.selectRecordsAsync({ fields: ['fldNAME'] })` — loads whole table |
276
+ | Read field | `record.getCellValue('fldXXX')` or `getCellValueAsString('fldXXX')` |
277
+ | Lookup by id (in-memory) | `query.getRecord(recordId)` after a `selectRecordsAsync` |
278
+ | Write linked | `{ 'fldLINK': [{ id: 'recXXX' }] }` |
279
+ | Write select | `{ 'fldSEL': { id: 'selXXX' } }` |
280
+ | Write attachment | `{ 'fldATT': [{ url: '...', filename: '...' }] }` |
281
+ | Today's date (ISO) | `new Date().toISOString()` |
282
+ | Field type guard | `table.getField('fldXXX').type === 'singleSelect'` |
283
+
284
+ ## DOs and DON'Ts
285
+
286
+ **DO:**
287
+ - **Reference tables and fields by ID, not by name.** See "Default to table IDs and field IDs" above. Pulls renames out of the failure-mode set entirely.
288
+ - Use `getCellValueAsString()` for text comparisons across field types.
289
+ - Cache `base.getTable(...)` results in variables — don't re-call inside loops.
290
+ - Batch updates/creates in 50s.
291
+ - Re-throw caught errors in Automation Scripts so Airtable flags failed runs.
292
+ - Use option *ids* (not names) for select-field writes — survives renames (same principle as field IDs above).
293
+ - Verify field types with `table.getField(...).type` before writing exotic shapes.
294
+
295
+ **`output` API by environment** — this is the most common mistake when porting scripts:
296
+
297
+ | Method | Automation Script | Scripting Extension |
298
+ |---|---|---|
299
+ | `output.set(key, value)` | ✅ — pass values to downstream automation steps | ❌ — not available |
300
+ | `output.markdown(string)` | ❌ — `TypeError` | ✅ — rendered markdown |
301
+ | `output.text(string)` | ❌ — `TypeError` | ✅ — plain text |
302
+ | `output.table(array | object)` | ❌ — `TypeError` | ✅ — tabular view |
303
+ | `output.inspect(value)` | ❌ — `TypeError` | ✅ — inspectable view |
304
+ | `output.clear()` | ❌ — **doesn't exist anywhere** | ❌ — **doesn't exist anywhere** |
305
+ | `console.log(...)` | ✅ — appears in the automation's run log | ✅ — appears in browser devtools |
306
+
307
+ For Automation Scripts, your ONLY user-visible surfaces are `console.log()` (run log) and `output.set()` (step output panel). If you need to report a list of results, log them line-by-line with `console.log` or `output.set("resultsJson", JSON.stringify(results))` so the JSON shows up in the step output inspector.
308
+
309
+ `output.clear()` is sometimes listed in older third-party reference docs as a valid Scripting Extension method — it isn't. Calling it throws `TypeError: output.clear is not a function` in both environments.
310
+
311
+ **DON'T:**
312
+ - Don't hardcode table/field strings deep inside business logic — pull them to the top as constants so renames are one-place fixes.
313
+ - Don't await inside synchronous loops the wrong way: `for (let r of records) await table.updateRecordAsync(...)` is correct syntactically but creates a 1-by-1 round-trip storm. Use batched `updateRecordsAsync` instead.
314
+ - Don't try to write to computed fields (formula, rollup, lookup, createdTime, lastModifiedTime). Airtable will reject the write.
315
+ - Don't use `input.config({...})` with arguments in an Automation Script.
316
+ - Don't call `output.markdown` / `output.table` / `input.buttonsAsync` in an Automation Script.
317
+ - Don't push duplicate record IDs into a single `updateRecordsAsync` call. Airtable rejects the WHOLE batch with `Error: Record "X" was specified twice in this request.` Dedupe by destination ID with a Map before flushing — see the many-to-one migration pattern in "Batch updates" above.
318
+
319
+ ## Airtable Formulas
320
+
321
+ For formula fields inside Airtable itself (NOT scripts). Quick rules:
322
+
323
+ - **NEVER add comments inside Airtable formulas.** Airtable's formula engine doesn't have a comment syntax — anything that looks like a comment will fail to compile.
324
+ - Reference fields by name with curly braces: `{Field Name}` (single field) or `{Other Table}` is not valid — formulas are scoped to the current table only.
325
+ - Functions are case-insensitive (`IF` = `if`) but field names ARE case-sensitive.
326
+ - Use `&` for concatenation, NOT `+` (the latter is numeric).
327
+ - For conditional logic, prefer `IF()` nested or `SWITCH()` for many branches.
328
+
329
+ Common patterns:
330
+
331
+ ```
332
+ IF({Status} = "Paid", "✓", "")
333
+
334
+ CONCATENATE({First Name}, " ", {Last Name})
335
+
336
+ DATETIME_FORMAT({Created}, 'YYYY-MM-DD')
337
+
338
+ IF(AND({Amount} > 100, {Paid} = TRUE()), "VIP", "Standard")
339
+
340
+ SWITCH({Tier},
341
+ "gold", "Premium",
342
+ "silver", "Standard",
343
+ "Free"
344
+ )
345
+ ```
346
+
347
+ If a complex formula isn't compiling, suspect:
348
+ - An invisible character (smart quote, em-dash) that the formula parser can't read — retype the offending chunk.
349
+ - A field name that contains parentheses or punctuation — wrap in `{Field (Name)}` carefully or rename the field.
350
+ - An accidental comment-looking sequence.
@@ -0,0 +1,86 @@
1
+ # Anti-Patterns Checklist
2
+
3
+ Run through this catalog before delivering any block. Every row is a violation observed in production that caused either runtime errors, silent data loss, or hours of debugging.
4
+
5
+ ## Data Access
6
+
7
+ | Anti-Pattern | Correct Approach |
8
+ |---|---|
9
+ | `useRecords()` bare hook | `useRecords({ select, count: 25 })` |
10
+ | `records.map(...)` on raw hook | `data.pages.flatMap(function(p) { return p.items; })` |
11
+ | `record.fields["Field Name"]` | `record.fields.alias` via `q.select()` mapping |
12
+ | `useLinkedRecords({ fieldId })` | `useLinkedRecords({ select, field: "alias" })` |
13
+ | `opt.label` on linked records | `opt.title` -- shape is `{ id, title }` |
14
+ | `useRecords` with REST API source | Use `useProxyFetch` + `useQuery` |
15
+ | `q.select()` for REST API fields | Access raw API response directly |
16
+ | Hardcoding API keys for connected API | Use `useProxyFetch` -- key stays server-side |
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
+
19
+ ## Mutations
20
+
21
+ | Anti-Pattern | Correct Approach |
22
+ |---|---|
23
+ | `.mutate({ id: ... })` | `.mutate({ recordId: ... })` -- `id` causes 404 |
24
+ | `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 mutate call, even single-field updates. See [datasources/writing.md](../datasources/writing.md#critical-two-parser-requirements-for-userecordupdate) |
25
+ | `updateRecord.mutateAsync(payload).then(...).catch(...)` | `updateRecord.mutate(payload, { onSuccess, onError })` — Softr's Action parser scans for the **literal `.mutate(` token** to detect mutation call sites. `.mutateAsync()` runs fine at runtime (it's just a Promise wrapper) but the parser ignores it — no Action gets derived, `enabled` stays `false`, the Actions tab shows "No actions used in this block yet". This is the same silent-failure mode as the flat-payload anti-pattern, and the two often appear together because devs reach for `mutateAsync` to chain `.then()/.catch()`. The fix is to use `.mutate(payload, { onSuccess, onError })` — per-call handlers go in the second argument (react-query convention). Verified by direct experiment, May 2026. See [datasources/writing.md](../datasources/writing.md#critical-two-parser-requirements-for-userecordupdate) |
26
+ | 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) |
27
+ | `deleteRecord.mutate({ id: r.id })` | `deleteRecord.mutate(r.id)` -- just the string |
28
+ | `var { mutateAsync } = useRecordUpdate({...})` | `var updateRecord = useRecordUpdate({...})` -- keep full object for `.enabled`, `.status`, `.reset()` |
29
+ | Not calling `refetch()` after mutations | Always `refetch()` in `onSuccess` |
30
+ | Including formulas in create/update | Only writable fields |
31
+ | Linked record as plain string | Must be `[{ id: "..." }]` array |
32
+ | Writing dropdown values as `{ id, label }` objects (the read shape) | Write the option UUID as a plain string -- e.g. `status: "822b8d69-..."`, not `status: { id: "...", label: "..." }` |
33
+ | 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 |
34
+
35
+ ## Field Values
36
+
37
+ | Anti-Pattern | Correct Approach |
38
+ |---|---|
39
+ | `field.toLowerCase()` on selects | `getFieldValue(field).toLowerCase()` |
40
+ | `item.fields.formula === true` | Formula booleans: `=== "1"` |
41
+
42
+ ## Hooks & React
43
+
44
+ | Anti-Pattern | Correct Approach |
45
+ |---|---|
46
+ | `import React from 'react'` | Named imports only |
47
+ | Named export | `export default function Block()` |
48
+ | Hook declared after conditional `return` | All hooks at top before any conditional `return` -- React error #310 |
49
+ | `fetchNextPage()` in render body | Inside `useEffect` only -- in render = infinite loop |
50
+ | `useRef` for IDs used in `useMemo` | `useState` -- ref mutations don't trigger recomputation |
51
+ | 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. |
52
+
53
+ ## Layout & Styling
54
+
55
+ | Anti-Pattern | Correct Approach |
56
+ |---|---|
57
+ | Hardcoded domain in navigation | Relative paths: `/task-details?recordId=...` |
58
+ | Placing a `<NavigationAction navigation={{ action: "OPEN_CHAT" }}>` Ask-AI button on a block that has no data source connected | Connect the block to the data source the AI should read from in Studio's Source tab. Softr's AI pulls context from the **block that triggered the chat**, not from the page — a button-only helper block with no data source causes `chat/prepare` → HTTP 500 ("Failed to prepare AI assistant") even though the chat UI opens fine. The block doesn't need to read or write records itself; the connection is purely for AI context. Verified by direct experiment, May 2026 |
59
+ | Emojis in UI | lucide-react icons only |
60
+ | `[&_svg]:opacity-0` on SelectTrigger | `<style>` + `data-fix-chevron` attribute (Softr bundler limitation) |
61
+ | Relying on `custom-code-header.html` (Softr → Settings → Custom Code → Code inside header) to apply brand fonts/colors INSIDE a Vibe Coding block | Vibe Coding blocks render inside a shadow DOM. CSS custom properties (`--brand-*`) pierce that boundary, but `html, body { font-family: ... !important }` rules **do not** — `<html>` and `<body>` don't exist inside the shadow root. Apply brand fonts/colors at the block's **own outermost wrapper** via inline style: `style={{ fontFamily: "'Manrope', system-ui, sans-serif", color: BRAND_INK }}` on the outer `<div>` so every descendant inherits brand defaults. Override per-element with explicit inline `fontFamily` (e.g., `"'Fraunces', Georgia, serif"` on h1/h2). Google `<link>` tags in the page head DO load `@font-face` globally — the fonts are available inside shadow DOM, they just need to be applied. |
62
+ | Painting `backgroundColor: BRAND_CANVAS` on a Vibe Coding block's outer wrapper when `custom-code-header.html` already sets `body { background-color: var(--brand-canvas) !important }` | Don't double-paint. If the body bg is already the brand canvas, the block leaves its own backgroundColor unset and the page bg shows through. Painting the same color twice produces a visible seam — Softr's content wrapper sits between `<body>` and the Vibe Coding block, and the two backgrounds composite slightly differently due to sub-pixel rendering, transparency stacking, or wrapper paddings. Set fontFamily and color on the block's wrapper (those don't inherit cleanly through shadow DOM), but **leave backgroundColor unset** — let the page bg flow through. The exception: if the block needs a brand-tinted *section* (e.g., a card-style admin shell that's different from the page bg), paint that bg explicitly on its specific container, not on the outer wrapper. |
63
+ | `document.getElementById(...)` / `document.querySelector(...)` to find an element inside the block — for example, a hidden `<input type="file">` triggered by a visible "Upload" button via `getElementById('myInput').click()` | Vibe Coding blocks render inside a shadow DOM. The global `document` traversal stops at the shadow boundary, so id/selector lookups for elements inside the block return `null`. The user-visible symptom is a control that does nothing — no error, no file picker, no focus, no scroll — because the chained `.click()` / `.focus()` / `.scrollIntoView()` was called on `null`. Use a **React `useRef`** instead: `var inputRef = useRef(null)`, then `<input ref={inputRef} />` and `<button onClick={function() { if (inputRef.current) inputRef.current.click(); }}>`. Refs hold direct node references and don't depend on DOM traversal, so they work regardless of which DOM tree the node lives in. This applies to every "trigger a hidden element" pattern: hidden file inputs, programmatic focus, scroll-into-view, `.click()` on a non-visible button. |
64
+
65
+ ## Permissions
66
+
67
+ | Anti-Pattern | Correct Approach |
68
+ |---|---|
69
+ | `currentUser.role` for tiers | `window.__softr_current_user.userGroups` |
70
+
71
+ ## Editable Settings
72
+
73
+ | Anti-Pattern | Correct Approach |
74
+ |---|---|
75
+ | `useNavigationSetting` with `openIn: "SAME_TAB"` / `"NEW_TAB"` / any other string | `openIn` must be exactly `"SELF"`, `"TAB"`, or `"MODAL"` -- Softr's setting validator rejects unknown values at save time with: *"The 'initialValue.openIn' property in the 'navigation' setting must be \"SELF\", \"TAB\", or \"MODAL\" if provided"* |
76
+
77
+ ## Helper Blocks
78
+
79
+ | Anti-Pattern | Correct Approach |
80
+ |---|---|
81
+ | Helper block returning `null` during dev | Return a minimal visible badge until feature stable |
82
+ | Single CustomEvent on full load | Dispatch `_progress` per page AND `_ready` on completion |
83
+ | Helper publishes only raw records | Also publish computed `filterOptions` as separate globals |
84
+ | Refactoring helper shape without updating consumers | Version namespace OR update all consumers in same commit |
85
+ | Helper B placed above A when B depends on A | A must be above B -- Softr renders top-to-bottom |
86
+ | Using `useLinkedRecords` for rich foreign data | It only returns `{id, title}` -- use a helper block instead |
@@ -0,0 +1,102 @@
1
+ # Common Patterns
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 using the skill's preferred style (`var`, `function() {}`).
4
+
5
+ ## Table of Contents
6
+
7
+ - [Cross-Page State with localStorage + URL Parameters](#cross-page-state-with-localstorage--url-parameters)
8
+ - [Clipboard Copy Button](#clipboard-copy-button)
9
+
10
+ ## Cross-Page State with localStorage + URL Parameters
11
+
12
+ When a block needs to remember user state across pages (currently selected record, last filter, last viewed dashboard), `localStorage` works inside Vibe Coding blocks just like in any browser context. Pair with a URL parameter so deep links also work:
13
+
14
+ ```jsx
15
+ import { useState, useEffect } from "react";
16
+
17
+ export default function Block() {
18
+ var fromUrl = new URLSearchParams(window.location.search).get("eventId");
19
+ var saved = localStorage.getItem("softr_myapp_selected_event_id");
20
+ var initialId = fromUrl || saved || null;
21
+
22
+ var [selectedId, setSelectedId] = useState(initialId);
23
+
24
+ useEffect(function() {
25
+ if (selectedId) {
26
+ localStorage.setItem("softr_myapp_selected_event_id", selectedId);
27
+ }
28
+ }, [selectedId]);
29
+
30
+ /* ... rest of block ... */
31
+ }
32
+ ```
33
+
34
+ Why both:
35
+
36
+ - **`localStorage`** survives page navigation and refresh. Depending on the browser, it may also survive logout.
37
+ - **URL parameter** makes the state shareable -- a user can paste a link and the destination page lands on the same record.
38
+ - **URL wins** over localStorage so explicit links override stored state.
39
+
40
+ ### Namespacing
41
+
42
+ Always namespace your keys: `softr_<app>_<resource>_<key>`. Without a namespace, two Vibe Coding blocks on the same domain can stomp on each other's state:
43
+
44
+ ```jsx
45
+ /* Good */
46
+ localStorage.setItem("softr_acme_pursuits_filter", JSON.stringify(filter));
47
+
48
+ /* Bad -- collides with anything else using "filter" */
49
+ localStorage.setItem("filter", JSON.stringify(filter));
50
+ ```
51
+
52
+ ### Clearing on logout
53
+
54
+ If state should NOT survive logout (e.g. it references record IDs the next user shouldn't see), clear it explicitly when the user signs out, or scope keys to the current user's email:
55
+
56
+ ```jsx
57
+ var currentUser = useCurrentUser();
58
+ var key = "softr_myapp_selected_event_" + ((currentUser && currentUser.email) || "anon");
59
+ ```
60
+
61
+ ## Clipboard Copy Button
62
+
63
+ Standard browser `navigator.clipboard.writeText` works inside Vibe Coding blocks. Softr-published apps run on HTTPS, which is the only requirement for the Clipboard API, so no fallback is needed.
64
+
65
+ ```jsx
66
+ import { useState } from "react";
67
+ import { Check, Copy } from "lucide-react";
68
+ import { Button } from "@/components/ui/button";
69
+ import { toast } from "sonner";
70
+
71
+ function CopyButton(props) {
72
+ var [copied, setCopied] = useState(false);
73
+
74
+ function handleClick() {
75
+ navigator.clipboard.writeText(props.value).then(function() {
76
+ setCopied(true);
77
+ toast.success("Copied " + (props.label || "value"));
78
+ setTimeout(function() { setCopied(false); }, 1500);
79
+ });
80
+ }
81
+
82
+ return (
83
+ <Button
84
+ variant="ghost"
85
+ size="sm"
86
+ onClick={handleClick}
87
+ aria-label={"Copy " + (props.label || "value")}
88
+ >
89
+ {copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
90
+ </Button>
91
+ );
92
+ }
93
+ ```
94
+
95
+ Usage:
96
+
97
+ ```jsx
98
+ <CopyButton value={record.fields.invoiceUrl} label="invoice URL" />
99
+ <CopyButton value={getFieldValue(record.fields.email)} label="email" />
100
+ ```
101
+
102
+ The `aria-label` is required because the button has no visible text, only an icon. Without it the button is not screen-reader accessible.