Webhooks for the AT Protocol airglow.run
atproto atprotocol automation webhook
12
fork

Configure Feed

Select the types of activity you want to include in your feed.

ui: better forEach actions management

Hugo 30832530 a2d70dc6

+187 -17
+3 -3
app/components/FetchCard/index.tsx
··· 1 1 import type { FetchStep, Condition } from "@/db/schema.js"; 2 2 import { FileText, Search } from "../../icons.js"; 3 - import { opLabels } from "@/automations/labels.js"; 3 + import { opLabelsFetch } from "@/automations/labels.js"; 4 4 import { DescriptionList } from "../DescriptionList/index.js"; 5 5 import { InlineCode } from "../CodeBlock/index.js"; 6 6 import * as s from "./styles.css.ts"; ··· 33 33 return <>{kind === "search" ? "no matching record is found" : "the record is not found"}</>; 34 34 } 35 35 } 36 - const op = opLabels[cond.operator] ?? cond.operator; 36 + const op = opLabelsFetch[cond.operator] ?? cond.operator; 37 37 const valueless = cond.operator === "exists" || cond.operator === "not-exists"; 38 38 return ( 39 39 <> ··· 81 81 {f.where.map((w, i) => ( 82 82 <span key={i}> 83 83 {i > 0 && <> and </>} 84 - <InlineCode>{w.field}</InlineCode> {opLabels[w.operator] ?? w.operator}{" "} 84 + <InlineCode>{w.field}</InlineCode> {opLabelsFetch[w.operator] ?? w.operator}{" "} 85 85 <InlineCode>{w.value}</InlineCode> 86 86 </span> 87 87 ))}
+41
app/components/ForEachSummary/index.tsx
··· 1 + import type { ForEachConfig } from "@/db/schema.js"; 2 + import { opLabels } from "@/automations/labels.js"; 3 + import { InlineCode } from "../CodeBlock/index.js"; 4 + 5 + /** dt/dd entries describing an action's per-item fan-out config. Render inside 6 + * a <DescriptionList>. Returns null when the action has no forEach. */ 7 + export function ForEachSummary({ forEach }: { forEach?: ForEachConfig | null }) { 8 + if (!forEach) return null; 9 + const conditions = forEach.conditions ?? []; 10 + return ( 11 + <> 12 + <dt>For each item in</dt> 13 + <dd> 14 + <InlineCode>{forEach.path}</InlineCode> 15 + </dd> 16 + {conditions.length > 0 && ( 17 + <> 18 + <dt>Where</dt> 19 + <dd> 20 + {conditions.map((c, i) => { 21 + const op = opLabels[c.operator] ?? c.operator; 22 + const valueless = c.operator === "exists" || c.operator === "not-exists"; 23 + return ( 24 + <span key={i}> 25 + {i > 0 && <> and </>} 26 + <InlineCode>{c.field}</InlineCode> {op} 27 + {!valueless && c.value && ( 28 + <> 29 + {" "} 30 + <InlineCode>{c.value}</InlineCode> 31 + </> 32 + )} 33 + </span> 34 + ); 35 + })} 36 + </dd> 37 + </> 38 + )} 39 + </> 40 + ); 41 + }
+130 -14
app/islands/AutomationForm.tsx
··· 1 1 import { useState, useCallback, useRef, useMemo, useEffect } from "hono/jsx"; 2 - import type { RecordSchema } from "../../lib/lexicons/schema-types.js"; 2 + import type { RecordSchema, SchemaNode } from "../../lib/lexicons/schema-types.js"; 3 3 import { nsidRequiresWantedDids } from "../../lib/lexicons/match.js"; 4 4 import { 5 5 isRecordProducingAction, ··· 143 143 * type is `array`. Each segment that crosses an array level is rendered as 144 144 * `name[]` so the final path is directly usable as a forEach.path value. */ 145 145 function collectArrayPaths( 146 - schema: import("../../lib/lexicons/schema-types.js").RecordSchema | null | undefined, 146 + schema: RecordSchema | null | undefined, 147 147 prefix = "event.commit.record", 148 148 ): string[] { 149 149 if (!schema) return []; ··· 153 153 } 154 154 155 155 function walkSchemaForArrays( 156 - props: Record<string, import("../../lib/lexicons/schema-types.js").SchemaNode> | undefined, 156 + props: Record<string, SchemaNode> | undefined, 157 157 prefix: string, 158 158 out: string[], 159 159 depth: number, ··· 165 165 } 166 166 } 167 167 168 - function walkArrayLevels( 169 - node: import("../../lib/lexicons/schema-types.js").SchemaNode, 170 - path: string, 171 - out: string[], 172 - depth: number, 173 - ): void { 168 + function walkArrayLevels(node: SchemaNode, path: string, out: string[], depth: number): void { 174 169 if (depth > 6) return; 175 170 if (node.type === "array") { 176 171 const arrayPath = `${path}[]`; ··· 191 186 } 192 187 } 193 188 189 + /** Resolve the items schema at a forEach path, then flatten its leaf fields. 190 + * Returns paths relative to the item (so `{{item.<path>}}` is the placeholder). 191 + * Empty list when the path can't be resolved (unknown root, missing key, or 192 + * the leaf isn't an object/union we can introspect). */ 193 + function resolveItemFields( 194 + path: string, 195 + triggerSchema: RecordSchema | null | undefined, 196 + fetches: FetchDraft[], 197 + fetchSchemas: Record<string, FetchSchemaState>, 198 + ): Field[] { 199 + let current: SchemaNode | undefined; 200 + let rest: string; 201 + const triggerPrefix = "event.commit.record."; 202 + if (path.startsWith(triggerPrefix)) { 203 + if (!triggerSchema) return []; 204 + current = { 205 + type: "object", 206 + required: triggerSchema.required, 207 + properties: triggerSchema.properties, 208 + }; 209 + rest = path.slice(triggerPrefix.length); 210 + } else { 211 + const recordIdx = path.indexOf(".record."); 212 + if (recordIdx <= 0) return []; 213 + const fname = path.slice(0, recordIdx); 214 + const fetch = fetches.find((f) => f.name === fname); 215 + if (!fetch?.collection) return []; 216 + const schema = fetchSchemas[fetch.collection]?.record; 217 + if (!schema) return []; 218 + current = { type: "object", required: schema.required, properties: schema.properties }; 219 + rest = path.slice(recordIdx + ".record.".length); 220 + } 221 + 222 + for (const seg of rest.split(".")) { 223 + if (!current) return []; 224 + const isArray = seg.endsWith("[]"); 225 + const key = isArray ? seg.slice(0, -2) : seg; 226 + if (key) { 227 + if (current.type !== "object") return []; 228 + const next: SchemaNode | undefined = current.properties[key]; 229 + if (!next) return []; 230 + current = next; 231 + } 232 + if (isArray) { 233 + if (current.type !== "array") return []; 234 + current = current.items; 235 + } 236 + } 237 + 238 + return flattenItemFields(current); 239 + } 240 + 241 + function flattenItemFields(node: SchemaNode | undefined, prefix = "", depth = 0): Field[] { 242 + if (!node || depth > 4) return []; 243 + if (node.type === "object") { 244 + const out: Field[] = []; 245 + for (const [name, child] of Object.entries(node.properties ?? {})) { 246 + const sub = prefix ? `${prefix}.${name}` : name; 247 + if (child.type === "object") { 248 + out.push(...flattenItemFields(child, sub, depth + 1)); 249 + } else if (child.type === "union") { 250 + out.push({ 251 + path: `${sub}.$type`, 252 + type: "string", 253 + description: "Variant discriminator", 254 + }); 255 + } else { 256 + out.push({ path: sub, type: child.type, description: child.description }); 257 + } 258 + } 259 + return out; 260 + } 261 + if (node.type === "union") { 262 + const typePath = prefix ? `${prefix}.$type` : "$type"; 263 + const out: Field[] = [{ path: typePath, type: "string", description: "Variant discriminator" }]; 264 + const seen = new Set<string>([typePath]); 265 + for (const variant of node.variants) { 266 + for (const f of flattenItemFields(variant.node, prefix, depth + 1)) { 267 + if (seen.has(f.path)) continue; 268 + seen.add(f.path); 269 + out.push(f); 270 + } 271 + } 272 + return out; 273 + } 274 + return []; 275 + } 276 + 194 277 const BUILTIN_CONDITION_FIELDS: Field[] = [ 195 278 { path: "event.did", type: "string", description: "DID of the repo that emitted the event" }, 196 279 ]; ··· 923 1006 draft, 924 1007 onChange, 925 1008 arrayPathSuggestions, 1009 + itemFieldsByPath, 926 1010 }: { 927 1011 index: number; 928 1012 draft: ForEachDraft | undefined; 929 1013 onChange: (next: ForEachDraft | undefined) => void; 930 1014 arrayPathSuggestions: string[]; 1015 + itemFieldsByPath: Record<string, Field[]>; 931 1016 }) { 932 1017 const enabled = draft !== undefined; 933 1018 const pathId = `action-${index}-foreach-path`; 934 1019 const pathListId = `action-${index}-foreach-path-suggestions`; 1020 + const itemFields = enabled && draft ? (itemFieldsByPath[draft.path] ?? []) : []; 935 1021 936 1022 const updateCondition = (i: number, key: keyof Condition, val: string) => { 937 1023 if (!draft) return; ··· 1061 1147 </button> 1062 1148 </div> 1063 1149 1064 - <p class={s.hint}> 1065 - Inside this action, templates can use <code>{`{{item.*}}`}</code> placeholders (e.g.{" "} 1066 - <code>{`{{item.uri}}`}</code>, <code>{`{{item.did}}`}</code>) to read fields off each 1067 - matching item. 1068 - </p> 1150 + {itemFields.length > 0 ? ( 1151 + <details class={s.collapsibleDetails}> 1152 + <summary class={s.collapsibleSummary}> 1153 + Item placeholders <span class={s.hint}>(available inside this action)</span> 1154 + </summary> 1155 + <div class={s.collapsibleContent}> 1156 + <div class={s.placeholderGroup}> 1157 + <CopyPlaceholder value="item"> 1158 + <span class={s.placeholderDesc}>The whole item</span> 1159 + </CopyPlaceholder> 1160 + {itemFields.map((f) => ( 1161 + <CopyPlaceholder key={f.path} value={`item.${f.path}`}> 1162 + {f.description ? ( 1163 + <span class={s.placeholderDesc}>{f.description}</span> 1164 + ) : null} 1165 + </CopyPlaceholder> 1166 + ))} 1167 + </div> 1168 + </div> 1169 + </details> 1170 + ) : ( 1171 + <p class={s.hint}> 1172 + Inside this action, templates can use <code>{`{{item.*}}`}</code> placeholders (e.g.{" "} 1173 + <code>{`{{item.uri}}`}</code>) to read fields off each matching item. 1174 + </p> 1175 + )} 1069 1176 </div> 1070 1177 )} 1071 1178 </div> ··· 1833 1940 } 1834 1941 return out; 1835 1942 }, [triggerRecordSchema, fetches, fetchSchemas]); 1943 + 1944 + const itemFieldsByPath = useMemo(() => { 1945 + const map: Record<string, Field[]> = {}; 1946 + for (const path of arrayPathSuggestions) { 1947 + map[path] = resolveItemFields(path, triggerRecordSchema, fetches, fetchSchemas); 1948 + } 1949 + return map; 1950 + }, [arrayPathSuggestions, triggerRecordSchema, fetches, fetchSchemas]); 1836 1951 1837 1952 // Catalogue-tile identity: follows split into follow-<target> so per-tile 1838 1953 // "#N" counters don't collapse the three tiles into one group. ··· 2814 2929 draft={action.forEach} 2815 2930 onChange={(next) => updateAction(i, { ...action, forEach: next })} 2816 2931 arrayPathSuggestions={arrayPathSuggestions} 2932 + itemFieldsByPath={itemFieldsByPath} 2817 2933 /> 2818 2934 {action.type === "webhook" ? ( 2819 2935 <WebhookActionEditor
+2
app/routes/dashboard/automations/[rkey].tsx
··· 28 28 import { DescriptionList } from "../../../components/DescriptionList/index.js"; 29 29 import { CodeBlock, InlineCode } from "../../../components/CodeBlock/index.js"; 30 30 import { FetchCard } from "../../../components/FetchCard/index.js"; 31 + import { ForEachSummary } from "../../../components/ForEachSummary/index.js"; 31 32 import { NsidCode } from "../../../components/NsidCode/index.js"; 32 33 import { Stack } from "../../../components/Layout/Stack/index.js"; 33 34 import ThemeToggle from "../../../islands/ThemeToggle.js"; ··· 242 243 )} 243 244 </ActionHeader> 244 245 <DescriptionList> 246 + <ForEachSummary forEach={action.forEach} /> 245 247 {action.$type === "webhook" ? ( 246 248 <> 247 249 <dt>Callback URL</dt>
+2
app/routes/u/[handle]/[rkey].tsx
··· 19 19 import { DescriptionList } from "../../../components/DescriptionList/index.js"; 20 20 import { CodeBlock, InlineCode } from "../../../components/CodeBlock/index.js"; 21 21 import { FetchCard } from "../../../components/FetchCard/index.js"; 22 + import { ForEachSummary } from "../../../components/ForEachSummary/index.js"; 22 23 import { NsidCode } from "../../../components/NsidCode/index.js"; 23 24 import { LexiconFlow } from "../../../components/LexiconFlow/index.js"; 24 25 import { Stack } from "../../../components/Layout/Stack/index.js"; ··· 229 230 )} 230 231 </ActionHeader> 231 232 <DescriptionList> 233 + <ForEachSummary forEach={action.forEach} /> 232 234 {action.$type === "webhook" ? ( 233 235 <> 234 236 <dt>Destination</dt>
+9
lib/automations/labels.ts
··· 3 3 startsWith: "starts with", 4 4 endsWith: "ends with", 5 5 contains: "contains", 6 + exists: "exists", 7 + "not-exists": "does not exist", 8 + }; 9 + 10 + /** Fetch-condition variant: "exists/not-exists" reads more naturally as 11 + * presence checks ("is present"/"is missing") since the field at issue is 12 + * whether a record was found, not whether a string is non-empty. */ 13 + export const opLabelsFetch: Record<string, string> = { 14 + ...opLabels, 6 15 exists: "is present", 7 16 "not-exists": "is missing", 8 17 };