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.

refactor: automation form best practices

Hugo 81e82144 d43de415

+341 -91
+16
app/islands/AutomationForm.css.ts
··· 267 267 minInlineSize: 0, 268 268 }); 269 269 270 + export const groupFieldset = style([ 271 + resetFieldset, 272 + fieldGroup, 273 + { 274 + margin: 0, 275 + }, 276 + ]); 277 + 278 + export const groupLegend = style([ 279 + label, 280 + { 281 + padding: 0, 282 + marginBlockEnd: space[1], 283 + }, 284 + ]); 285 + 270 286 export const textarea = style({ 271 287 width: "100%", 272 288 minBlockSize: "64px",
+324 -90
app/islands/AutomationForm.tsx
··· 161 161 162 162 function WebhookActionEditor({ 163 163 action, 164 + index, 164 165 onChange, 165 166 }: { 166 167 action: WebhookDraft; 168 + index: number; 167 169 onChange: (a: WebhookDraft) => void; 168 170 }) { 169 - const updateHeader = (index: number, key: "key" | "value", val: string) => { 170 - const headers = action.headers.map((h, i) => (i === index ? { ...h, [key]: val } : h)); 171 + const updateHeader = (i: number, key: "key" | "value", val: string) => { 172 + const headers = action.headers.map((h, j) => (j === i ? { ...h, [key]: val } : h)); 171 173 onChange({ ...action, headers }); 172 174 }; 173 175 const addHeader = () => { 174 176 onChange({ ...action, headers: [...action.headers, { key: "", value: "" }] }); 175 177 }; 176 - const removeHeader = (index: number) => { 177 - onChange({ ...action, headers: action.headers.filter((_, i) => i !== index) }); 178 + const removeHeader = (i: number) => { 179 + onChange({ ...action, headers: action.headers.filter((_, j) => j !== i) }); 178 180 }; 181 + 182 + const callbackId = `action-${index}-callback-url`; 183 + const headersGroupId = `action-${index}-headers-group`; 179 184 180 185 return ( 181 186 <> 182 187 <div class={s.fieldGroup}> 183 - <label class={s.label}>Callback URL</label> 188 + <label class={s.label} for={callbackId}> 189 + Callback URL 190 + </label> 184 191 <input 192 + id={callbackId} 185 193 class={s.input} 186 194 type="url" 187 - placeholder="https://example.com/hooks/events" 195 + placeholder="e.g. https://example.com/hooks/events" 188 196 value={action.callbackUrl} 189 197 onInput={(e: Event) => 190 198 onChange({ ...action, callbackUrl: (e.target as HTMLInputElement).value }) 191 199 } 192 200 required 201 + autocomplete="off" 193 202 /> 194 203 </div> 195 - <div class={s.fieldGroup}> 196 - <label class={s.label}>Custom Headers</label> 204 + <div class={s.fieldGroup} role="group" aria-labelledby={headersGroupId}> 205 + <span id={headersGroupId} class={s.label}> 206 + Custom headers 207 + </span> 197 208 <span class={s.hint}> 198 209 Use <code>{"{{secret:name}}"}</code> to reference stored secrets 199 210 </span> ··· 203 214 <input 204 215 class={s.input} 205 216 type="text" 206 - placeholder="Authorization" 217 + placeholder="e.g. Authorization" 207 218 value={header.key} 208 219 onInput={(e: Event) => updateHeader(i, "key", (e.target as HTMLInputElement).value)} 220 + aria-label="Header name" 221 + autocomplete="off" 209 222 /> 210 223 </div> 211 224 <div class={s.conditionValue}> 212 225 <input 213 226 class={s.input} 214 227 type="text" 215 - placeholder="Bearer {{secret:my-token}}" 228 + placeholder="e.g. Bearer {{secret:my-token}}" 216 229 value={header.value} 217 230 onInput={(e: Event) => 218 231 updateHeader(i, "value", (e.target as HTMLInputElement).value) 219 232 } 233 + aria-label="Header value" 234 + autocomplete="off" 220 235 /> 221 236 </div> 222 237 <button type="button" class={s.removeBtn} onClick={() => removeHeader(i)}> ··· 318 333 319 334 function RecordActionEditor({ 320 335 action, 336 + index, 321 337 onChange, 322 338 placeholders, 323 339 }: { 324 340 action: RecordDraft; 341 + index: number; 325 342 onChange: (a: RecordDraft) => void; 326 343 placeholders: string[]; 327 344 }) { ··· 334 351 fetchTargetSchema, 335 352 fetchSuggestions, 336 353 } = useNsidSchema(action.targetCollection); 354 + 355 + const targetId = `action-${index}-target-collection`; 356 + const templateId = `action-${index}-record-template`; 337 357 338 358 return ( 339 359 <> 340 360 <div class={s.fieldGroup}> 341 - <label class={s.label}>Target Collection</label> 361 + <label class={s.label} for={targetId}> 362 + Target Lexicon NSID 363 + </label> 342 364 <input 365 + id={targetId} 343 366 class={s.input} 344 367 type="text" 345 368 list={datalistId} ··· 352 375 fetchSuggestions(val); 353 376 }} 354 377 required 378 + autocomplete="off" 355 379 /> 356 380 <span class={s.hint}>NSID of the collection to create a record in</span> 357 381 <datalist id={datalistId}> ··· 374 398 375 399 {!targetSchema && !targetSchemaLoading && action.targetCollection && ( 376 400 <div class={s.fieldGroup}> 377 - <label class={s.label}>Record Template</label> 401 + <label class={s.label} for={templateId}> 402 + Record template 403 + </label> 378 404 {targetSchemaError && <span class={s.errorText}>{targetSchemaError}</span>} 379 405 <textarea 406 + id={templateId} 380 407 class={s.textarea} 381 408 placeholder={ 382 - '{\n "subject": {\n "uri": "at://{{event.did}}/{{event.commit.collection}}/{{event.commit.rkey}}",\n "cid": "{{event.commit.cid}}"\n },\n "createdAt": "{{now}}"\n}' 409 + '{\n "subject": {\n "uri": "{{event.commit.record.subject.uri}}",\n "cid": "{{event.commit.cid}}"\n },\n "createdAt": "{{now}}"\n}' 383 410 } 384 411 value={action.recordTemplate} 385 412 onInput={(e: Event) => ··· 389 416 }) 390 417 } 391 418 required 419 + autocomplete="off" 392 420 /> 393 421 </div> 394 422 )} ··· 409 437 410 438 function BskyPostActionEditor({ 411 439 action, 440 + index, 412 441 onChange, 413 442 }: { 414 443 action: BskyPostDraft; 444 + index: number; 415 445 onChange: (a: BskyPostDraft) => void; 416 446 }) { 447 + const textId = `action-${index}-bsky-text`; 448 + const langsId = `action-${index}-bsky-langs`; 449 + 417 450 return ( 418 451 <> 419 452 <div class={s.fieldGroup}> 420 - <label class={s.label}>Post text</label> 453 + <label class={s.label} for={textId}> 454 + Post text 455 + </label> 421 456 <textarea 457 + id={textId} 422 458 class={s.textarea} 423 459 placeholder={"Write your post here...\nYou can use {{placeholders}}."} 424 460 value={action.textTemplate} ··· 427 463 } 428 464 rows={4} 429 465 required 466 + autocomplete="off" 430 467 /> 431 468 <span class={s.hint}> 432 469 Mentions (@handle), links, and #hashtags are detected automatically. ··· 434 471 </div> 435 472 436 473 <div class={s.fieldGroup}> 437 - <label class={s.label}> 474 + <label class={s.label} for={langsId}> 438 475 Languages <span class={s.hint}>(optional, max 3)</span> 439 476 </label> 440 477 <input 478 + id={langsId} 441 479 class={s.input} 442 480 type="text" 443 481 placeholder="e.g. en, fr, pt" ··· 445 483 onInput={(e: Event) => 446 484 onChange({ ...action, langsText: (e.target as HTMLInputElement).value }) 447 485 } 486 + autocomplete="off" 448 487 /> 449 488 <span class={s.hint}>Comma-separated language codes (BCP-47)</span> 450 489 </div> 451 490 452 - <div class={s.fieldGroup}> 453 - <span class={s.label}> 491 + <fieldset class={s.groupFieldset}> 492 + <legend class={s.groupLegend}> 454 493 Content warnings <span class={s.hint}>(optional)</span> 455 - </span> 494 + </legend> 456 495 <div class={s.operationCheckboxes}> 457 496 {BSKY_LABELS.map(({ value, label }) => ( 458 497 <label key={value} class={s.checkboxLabel}> ··· 471 510 </label> 472 511 ))} 473 512 </div> 474 - </div> 513 + </fieldset> 475 514 </> 476 515 ); 477 516 } ··· 482 521 483 522 function PatchRecordActionEditor({ 484 523 action, 524 + index, 485 525 onChange, 486 526 placeholders, 487 527 }: { 488 528 action: PatchRecordDraft; 529 + index: number; 489 530 onChange: (a: PatchRecordDraft) => void; 490 531 placeholders: string[]; 491 532 }) { ··· 499 540 fetchSuggestions, 500 541 } = useNsidSchema(action.targetCollection); 501 542 543 + const targetId = `action-${index}-target-collection`; 544 + const baseUriId = `action-${index}-base-record-uri`; 545 + const templateId = `action-${index}-record-template`; 546 + 502 547 return ( 503 548 <> 504 549 <div class={s.fieldGroup}> 505 - <label class={s.label}>Target Collection</label> 550 + <label class={s.label} for={targetId}> 551 + Target Lexicon NSID 552 + </label> 506 553 <input 554 + id={targetId} 507 555 class={s.input} 508 556 type="text" 509 557 list={datalistId} ··· 516 564 fetchSuggestions(val); 517 565 }} 518 566 required 567 + autocomplete="off" 519 568 /> 520 569 <span class={s.hint}>NSID of the collection containing the record to update</span> 521 570 <datalist id={datalistId}> ··· 526 575 </div> 527 576 528 577 <div class={s.fieldGroup}> 529 - <label class={s.label}>Base Record URI</label> 578 + <label class={s.label} for={baseUriId}> 579 + Base Record URI 580 + </label> 530 581 <input 582 + id={baseUriId} 531 583 class={s.input} 532 584 type="text" 533 - placeholder="at://{{event.did}}/{{event.commit.collection}}/{{event.commit.rkey}}" 585 + placeholder="e.g. {{event.commit.record.subject.uri}}" 534 586 value={action.baseRecordUri} 535 587 onInput={(e: Event) => 536 588 onChange({ ...action, baseRecordUri: (e.target as HTMLInputElement).value }) 537 589 } 538 590 required 591 + autocomplete="off" 539 592 /> 540 593 <span class={s.hint}>AT URI of the record to update. Supports {"{{placeholders}}"}.</span> 541 594 </div> ··· 554 607 555 608 {!targetSchema && !targetSchemaLoading && action.targetCollection && ( 556 609 <div class={s.fieldGroup}> 557 - <label class={s.label}>Patch Template</label> 610 + <label class={s.label} for={templateId}> 611 + Patch template 612 + </label> 558 613 {targetSchemaError && <span class={s.errorText}>{targetSchemaError}</span>} 559 614 <textarea 615 + id={templateId} 560 616 class={s.textarea} 561 617 placeholder={'{\n "bskyPostRef": "{{action1.uri}}",\n "updatedAt": "{{now}}"\n}'} 562 618 value={action.recordTemplate} ··· 567 623 }) 568 624 } 569 625 required 626 + autocomplete="off" 570 627 /> 571 628 <span class={s.hint}> 572 629 Only include the fields you want to change. They will be merged on top of the existing ··· 584 641 585 642 function BookmarkActionEditor({ 586 643 action, 644 + index, 587 645 onChange, 588 646 }: { 589 647 action: BookmarkDraft; 648 + index: number; 590 649 onChange: (a: BookmarkDraft) => void; 591 650 }) { 651 + const urlId = `action-${index}-bookmark-url`; 652 + const titleId = `action-${index}-bookmark-title`; 653 + const bodyId = `action-${index}-bookmark-body`; 654 + const tagsId = `action-${index}-bookmark-tags`; 655 + 592 656 return ( 593 657 <> 594 658 <div class={s.fieldGroup}> 595 - <label class={s.label}>Page URL</label> 659 + <label class={s.label} for={urlId}> 660 + Page URL 661 + </label> 596 662 <input 663 + id={urlId} 597 664 class={s.input} 598 665 type="text" 599 - placeholder="https://example.com or {{event.commit.record.subject.uri}}" 666 + placeholder="e.g. https://example.com or {{event.commit.record.subject.uri}}" 600 667 value={action.targetSource} 601 668 onInput={(e: Event) => 602 669 onChange({ ...action, targetSource: (e.target as HTMLInputElement).value }) 603 670 } 604 671 required 672 + autocomplete="off" 605 673 /> 606 674 <span class={s.hint}>URL of the page to bookmark. Supports {"{{placeholders}}"}.</span> 607 675 </div> 608 676 609 677 <div class={s.fieldGroup}> 610 - <label class={s.label}> 678 + <label class={s.label} for={titleId}> 611 679 Title <span class={s.hint}>(optional)</span> 612 680 </label> 613 681 <input 682 + id={titleId} 614 683 class={s.input} 615 684 type="text" 616 685 placeholder="e.g. Post by {{event.did}}" ··· 618 687 onInput={(e: Event) => 619 688 onChange({ ...action, targetTitle: (e.target as HTMLInputElement).value }) 620 689 } 690 + autocomplete="off" 621 691 /> 622 692 <span class={s.hint}>Page title. Supports {"{{placeholders}}"}.</span> 623 693 </div> 624 694 625 695 <div class={s.fieldGroup}> 626 - <label class={s.label}> 696 + <label class={s.label} for={bodyId}> 627 697 Description <span class={s.hint}>(optional)</span> 628 698 </label> 629 699 <textarea 700 + id={bodyId} 630 701 class={s.textarea} 631 702 placeholder="A short note about this bookmark" 632 703 value={action.bodyValue} ··· 634 705 onChange({ ...action, bodyValue: (e.target as HTMLTextAreaElement).value }) 635 706 } 636 707 rows={3} 708 + autocomplete="off" 637 709 /> 638 710 <span class={s.hint}>Bookmark description. Supports {"{{placeholders}}"}.</span> 639 711 </div> 640 712 641 713 <div class={s.fieldGroup}> 642 - <label class={s.label}> 714 + <label class={s.label} for={tagsId}> 643 715 Tags <span class={s.hint}>(optional, max 10)</span> 644 716 </label> 645 717 <input 718 + id={tagsId} 646 719 class={s.input} 647 720 type="text" 648 721 placeholder="e.g. reading, research, bluesky" ··· 650 723 onInput={(e: Event) => 651 724 onChange({ ...action, tagsText: (e.target as HTMLInputElement).value }) 652 725 } 726 + autocomplete="off" 653 727 /> 654 728 <span class={s.hint}>Comma-separated. Each tag supports {"{{placeholders}}"}.</span> 655 729 </div> ··· 663 737 664 738 function FollowActionEditor({ 665 739 action, 740 + index, 666 741 onChange, 667 742 }: { 668 743 action: FollowDraft; 744 + index: number; 669 745 onChange: (a: FollowDraft) => void; 670 746 }) { 671 747 const meta = FOLLOW_TARGETS[action.target]; 748 + const subjectId = `action-${index}-follow-subject`; 672 749 return ( 673 750 <> 674 751 <div class={s.fieldGroup}> 675 - <label class={s.label}>Subject DID</label> 752 + <label class={s.label} for={subjectId}> 753 + Subject DID 754 + </label> 676 755 <input 756 + id={subjectId} 677 757 class={s.input} 678 758 type="text" 679 - placeholder="did:plc:... or {{event.did}}" 759 + placeholder="e.g. did:plc:... or {{event.did}}" 680 760 value={action.subject} 681 761 onInput={(e: Event) => 682 762 onChange({ ...action, subject: (e.target as HTMLInputElement).value }) 683 763 } 684 764 required 765 + autocomplete="off" 685 766 /> 686 767 <span class={s.hint}> 687 768 DID of the account to follow on {meta.appName}. Supports {"{{placeholders}}"} like{" "} ··· 1501 1582 value={name} 1502 1583 onInput={(e: Event) => setName((e.target as HTMLInputElement).value)} 1503 1584 required 1585 + autocomplete="off" 1504 1586 /> 1505 1587 </div> 1506 1588 ··· 1515 1597 value={description} 1516 1598 onInput={(e: Event) => setDescription((e.target as HTMLTextAreaElement).value)} 1517 1599 rows={2} 1600 + autocomplete="off" 1518 1601 /> 1519 1602 </div> 1520 1603 ··· 1542 1625 } 1543 1626 readOnly={isEdit} 1544 1627 required 1628 + autocomplete="off" 1545 1629 /> 1546 1630 {isEdit && <span class={s.hint}>Lexicon cannot be changed after creation</span>} 1547 1631 {fieldsLoading && <span class={s.hint}>Loading fields...</span>} ··· 1555 1639 </div> 1556 1640 1557 1641 {NSID_RE.test(lexicon) && (wantedDidsRequired || wantedDids.length > 0) && ( 1558 - <div class={s.fieldGroup}> 1559 - <span class={s.label}> 1642 + <fieldset class={s.groupFieldset}> 1643 + <legend class={s.groupLegend}> 1560 1644 Watched repos {wantedDidsRequired && <span class={s.hint}>(required)</span>} 1561 - </span> 1645 + </legend> 1562 1646 {wantedDidsRequired ? ( 1563 1647 <> 1564 1648 <span class={s.hint}> 1565 - DIDs to subscribe to at the Jetstream level — events from other repos are not 1649 + DIDs to subscribe to at the Jetstream level. Events from other repos are not 1566 1650 received. Use <code>{"{{self}}"}</code> for your own DID. 1567 1651 </span> 1568 1652 <span class={s.hint}> ··· 1572 1656 <div key={i} class={s.conditionRow}> 1573 1657 <div class={s.conditionValue}> 1574 1658 <input 1659 + id={`wanted-did-${i}`} 1575 1660 class={s.input} 1576 1661 type="text" 1577 - placeholder="did:plc:… or {{self}}" 1662 + placeholder="e.g. did:plc:… or {{self}}" 1578 1663 value={did} 1579 1664 onInput={(e: Event) => 1580 1665 updateWantedDid(i, (e.target as HTMLInputElement).value) 1581 1666 } 1667 + aria-label="Watched DID" 1668 + autocomplete="off" 1582 1669 /> 1583 1670 </div> 1584 1671 <button type="button" class={s.removeBtn} onClick={() => removeWantedDid(i)}> ··· 1613 1700 {wantedDids.map((did, i) => ( 1614 1701 <div key={i} class={s.conditionRow}> 1615 1702 <div class={s.conditionValue}> 1616 - <input class={s.input} type="text" value={did} readOnly /> 1703 + <input 1704 + class={s.input} 1705 + type="text" 1706 + value={did} 1707 + readOnly 1708 + aria-label="Watched DID" 1709 + /> 1617 1710 </div> 1618 1711 </div> 1619 1712 ))} ··· 1624 1717 </div> 1625 1718 </> 1626 1719 )} 1627 - </div> 1720 + </fieldset> 1628 1721 )} 1629 1722 1630 - <div class={s.fieldGroup}> 1631 - <span class={s.label}>Trigger events</span> 1723 + <fieldset class={s.groupFieldset}> 1724 + <legend class={s.groupLegend}>Trigger events</legend> 1632 1725 <div class={s.operationCheckboxes}> 1633 1726 {( 1634 1727 [ ··· 1653 1746 ))} 1654 1747 </div> 1655 1748 <span class={s.hint}>Run this automation when a record is...</span> 1656 - </div> 1749 + </fieldset> 1657 1750 1658 1751 {NSID_RE.test(lexicon) && ( 1659 1752 <details class={s.collapsibleDetails}> ··· 1741 1834 {schemaUnresolved && fields.length === 0 ? ( 1742 1835 <> 1743 1836 <input 1837 + id={`cond-${i}-field`} 1744 1838 class={s.input} 1745 1839 type="text" 1746 1840 list="condition-builtin-fields" ··· 1749 1843 onInput={(e: Event) => 1750 1844 updateCondition(i, "field", (e.target as HTMLInputElement).value) 1751 1845 } 1846 + aria-label="Condition field" 1847 + autocomplete="off" 1752 1848 /> 1753 1849 <span class={s.hint}> 1754 1850 Enter a dot-separated field path from the event record ··· 1757 1853 ) : ( 1758 1854 <> 1759 1855 <select 1856 + id={`cond-${i}-field`} 1760 1857 class={s.select} 1761 1858 value={cond.field} 1762 1859 disabled={fieldsLoading} 1763 1860 onChange={(e: Event) => 1764 1861 updateCondition(i, "field", (e.target as HTMLSelectElement).value) 1765 1862 } 1863 + aria-label="Condition field" 1766 1864 > 1767 1865 <option value=""> 1768 1866 {fieldsLoading ? "Loading fields..." : "Select field..."} 1769 1867 </option> 1868 + {cond.field && !conditionFields.some((f) => f.path === cond.field) && ( 1869 + <option value={cond.field}>{cond.field}</option> 1870 + )} 1770 1871 {conditionFields.map((f) => ( 1771 1872 <option key={f.path} value={f.path}> 1772 1873 {f.path} ··· 1786 1887 conditionFields.find((f) => f.path === cond.field)?.type !== "boolean" && ( 1787 1888 <div class={s.conditionOperator}> 1788 1889 <select 1890 + id={`cond-${i}-operator`} 1789 1891 class={s.select} 1790 1892 value={cond.operator} 1791 1893 onChange={(e: Event) => 1792 1894 updateCondition(i, "operator", (e.target as HTMLSelectElement).value) 1793 1895 } 1896 + aria-label="Condition operator" 1794 1897 > 1795 1898 <option value="eq">equals</option> 1796 1899 <option value="startsWith">starts with</option> ··· 1805 1908 <div class={s.conditionValue}> 1806 1909 {conditionFields.find((f) => f.path === cond.field)?.type === "boolean" ? ( 1807 1910 <select 1911 + id={`cond-${i}-value`} 1808 1912 class={s.select} 1809 1913 value={cond.value} 1810 1914 onChange={(e: Event) => 1811 1915 updateCondition(i, "value", (e.target as HTMLSelectElement).value) 1812 1916 } 1917 + aria-label="Condition value" 1813 1918 > 1814 1919 <option value="">Select...</option> 1815 1920 <option value="true">true</option> ··· 1817 1922 </select> 1818 1923 ) : ( 1819 1924 <input 1925 + id={`cond-${i}-value`} 1820 1926 class={s.input} 1821 1927 type="text" 1822 1928 placeholder="Value" ··· 1824 1930 onInput={(e: Event) => 1825 1931 updateCondition(i, "value", (e.target as HTMLInputElement).value) 1826 1932 } 1933 + aria-label="Condition value" 1934 + autocomplete="off" 1827 1935 /> 1828 1936 )} 1829 1937 </div> ··· 1832 1940 Remove 1833 1941 </button> 1834 1942 </div> 1835 - <input 1836 - class={s.input} 1837 - type="text" 1838 - placeholder="Note (optional)" 1839 - value={cond.comment} 1840 - onInput={(e: Event) => 1841 - updateCondition(i, "comment", (e.target as HTMLInputElement).value) 1842 - } 1843 - /> 1943 + <div class={s.fieldGroup}> 1944 + <label class={s.label} for={`cond-${i}-note`}> 1945 + Note <span class={s.hint}>(optional)</span> 1946 + </label> 1947 + <input 1948 + id={`cond-${i}-note`} 1949 + class={s.input} 1950 + type="text" 1951 + value={cond.comment} 1952 + onInput={(e: Event) => 1953 + updateCondition(i, "comment", (e.target as HTMLInputElement).value) 1954 + } 1955 + autocomplete="off" 1956 + /> 1957 + </div> 1844 1958 </div> 1845 1959 ))} 1846 1960 <button type="button" class={s.addBtn} onClick={addCondition}> ··· 1867 1981 <div class={s.fieldGroup}> 1868 1982 <div class={s.fetchTopRow}> 1869 1983 <div class={s.fieldGroup} style={{ flex: "1 1 160px", minWidth: 0 }}> 1870 - <label class={s.label}>Variable name</label> 1984 + <label class={s.label} for={`fetch-${i}-name`}> 1985 + Variable name 1986 + </label> 1871 1987 <input 1988 + id={`fetch-${i}-name`} 1872 1989 class={s.input} 1873 1990 type="text" 1874 1991 placeholder="e.g. likedPost, publication, alreadyFollow, ..." ··· 1876 1993 onInput={(e: Event) => 1877 1994 updateFetch(i, "name", (e.target as HTMLInputElement).value) 1878 1995 } 1996 + autocomplete="off" 1879 1997 /> 1880 1998 </div> 1881 1999 <div class={s.fieldGroup} style={{ flex: "1 1 220px", minWidth: 0 }}> 1882 - <label class={s.label}>Source type</label> 2000 + <label class={s.label} for={`fetch-${i}-kind`}> 2001 + Source type 2002 + </label> 1883 2003 <select 2004 + id={`fetch-${i}-kind`} 1884 2005 class={s.select} 1885 2006 value={f.kind} 1886 2007 onChange={(e: Event) => ··· 1912 2033 <div class={s.fetchSubsectionTitle}>Record to fetch</div> 1913 2034 <div class={s.twoColRow}> 1914 2035 <div class={s.fieldGroup}> 1915 - <label class={s.label}>AT URI</label> 2036 + <label class={s.label} for={`fetch-${i}-uri`}> 2037 + AT URI 2038 + </label> 1916 2039 <input 2040 + id={`fetch-${i}-uri`} 1917 2041 class={s.input} 1918 2042 type="text" 1919 - placeholder="{{event.commit.record.subject}}" 2043 + placeholder="e.g. {{event.commit.record.subject}}" 1920 2044 value={f.uri} 1921 2045 onInput={(e: Event) => 1922 2046 updateFetch(i, "uri", (e.target as HTMLInputElement).value) 1923 2047 } 2048 + autocomplete="off" 1924 2049 /> 1925 2050 <span class={s.hint}> 1926 2051 Supports <code class={s.inlineCode}>{"{{event.*}}"}</code>,{" "} ··· 1928 2053 </span> 1929 2054 </div> 1930 2055 <div class={s.fieldGroup}> 1931 - <label class={s.label}> 1932 - Collection <span class={s.hint}>(optional)</span> 2056 + <label class={s.label} for={`fetch-${i}-collection`}> 2057 + Lexicon NSID <span class={s.hint}>(optional)</span> 1933 2058 </label> 1934 2059 <input 2060 + id={`fetch-${i}-collection`} 1935 2061 class={s.input} 1936 2062 type="text" 1937 - placeholder="app.bsky.feed.post" 2063 + placeholder="e.g. app.bsky.feed.post" 1938 2064 value={f.collection} 1939 2065 onInput={(e: Event) => 1940 2066 updateFetch(i, "collection", (e.target as HTMLInputElement).value) 1941 2067 } 2068 + autocomplete="off" 1942 2069 /> 1943 2070 <FetchSchemaStatus state={fetchSchemas[f.collection]} /> 1944 2071 <span class={s.hint}>NSID hint for typed placeholders.</span> ··· 1951 2078 <div class={s.fetchSubsectionTitle}>Where to search</div> 1952 2079 <div class={s.twoColRow}> 1953 2080 <div class={s.fieldGroup}> 1954 - <label class={s.label}>Repo</label> 2081 + <label class={s.label} for={`fetch-${i}-repo`}> 2082 + Repo 2083 + </label> 1955 2084 <input 2085 + id={`fetch-${i}-repo`} 1956 2086 class={s.input} 1957 2087 type="text" 1958 - placeholder="did:plc:... or {{self}}" 2088 + placeholder="e.g. did:plc:... or {{self}}" 1959 2089 value={f.repo} 1960 2090 onInput={(e: Event) => 1961 2091 updateFetch(i, "repo", (e.target as HTMLInputElement).value) 1962 2092 } 2093 + autocomplete="off" 1963 2094 /> 1964 2095 <span class={s.hint}> 1965 2096 DID of the repo. Use <code class={s.inlineCode}>{"{{self}}"}</code>{" "} ··· 1967 2098 </span> 1968 2099 </div> 1969 2100 <div class={s.fieldGroup}> 1970 - <label class={s.label}>Collection</label> 2101 + <label class={s.label} for={`fetch-${i}-collection`}> 2102 + Lexicon NSID 2103 + </label> 1971 2104 <input 2105 + id={`fetch-${i}-collection`} 1972 2106 class={s.input} 1973 2107 type="text" 1974 - placeholder="app.bsky.graph.follow" 2108 + placeholder="e.g. app.bsky.graph.follow" 1975 2109 value={f.collection} 1976 2110 onInput={(e: Event) => 1977 2111 updateFetch(i, "collection", (e.target as HTMLInputElement).value) 1978 2112 } 2113 + autocomplete="off" 1979 2114 /> 1980 2115 <FetchSchemaStatus state={fetchSchemas[f.collection]} /> 1981 - <span class={s.hint}>NSID of the collection.</span> 2116 + <span class={s.hint}>NSID of the collection to search.</span> 1982 2117 </div> 1983 2118 </div> 1984 2119 </div> ··· 1987 2122 <div class={s.fetchSubsectionTitle}>Match records where</div> 1988 2123 <div class={s.fetchMatchRow}> 1989 2124 <div class={s.fieldGroup} style={{ flex: "1 1 140px" }}> 1990 - <label class={s.label}>Field</label> 1991 - <input 1992 - class={s.input} 1993 - type="text" 1994 - placeholder="subject" 1995 - value={f.whereField} 1996 - onInput={(e: Event) => 1997 - updateFetch(i, "whereField", (e.target as HTMLInputElement).value) 2125 + <label class={s.label} for={`fetch-${i}-where-field`}> 2126 + Field 2127 + </label> 2128 + {(() => { 2129 + const schemaState = f.collection 2130 + ? fetchSchemas[f.collection] 2131 + : undefined; 2132 + const recordFields = schemaState?.fields ?? []; 2133 + const useFreeForm = 2134 + !f.collection || 2135 + (!!schemaState && 2136 + (schemaState.unresolved || !!schemaState.error) && 2137 + recordFields.length === 0); 2138 + if (useFreeForm) { 2139 + return ( 2140 + <input 2141 + id={`fetch-${i}-where-field`} 2142 + class={s.input} 2143 + type="text" 2144 + placeholder="e.g. subject" 2145 + value={f.whereField} 2146 + onInput={(e: Event) => 2147 + updateFetch( 2148 + i, 2149 + "whereField", 2150 + (e.target as HTMLInputElement).value, 2151 + ) 2152 + } 2153 + autocomplete="off" 2154 + /> 2155 + ); 1998 2156 } 1999 - /> 2157 + const hasCustomValue = 2158 + !!f.whereField && 2159 + !recordFields.some((rf) => rf.path === f.whereField); 2160 + return ( 2161 + <select 2162 + id={`fetch-${i}-where-field`} 2163 + class={s.select} 2164 + value={f.whereField} 2165 + disabled={schemaState?.loading} 2166 + onChange={(e: Event) => 2167 + updateFetch( 2168 + i, 2169 + "whereField", 2170 + (e.target as HTMLSelectElement).value, 2171 + ) 2172 + } 2173 + > 2174 + <option value=""> 2175 + {schemaState?.loading ? "Loading fields..." : "Select field..."} 2176 + </option> 2177 + {hasCustomValue && ( 2178 + <option value={f.whereField}>{f.whereField}</option> 2179 + )} 2180 + {recordFields.map((field) => ( 2181 + <option key={field.path} value={field.path}> 2182 + {field.path} 2183 + </option> 2184 + ))} 2185 + </select> 2186 + ); 2187 + })()} 2000 2188 </div> 2001 2189 <div class={s.fieldGroup} style={{ flex: "0 0 auto" }}> 2002 2190 <span class={`${s.label} ${s.labelSpacer}`} aria-hidden="true"> ··· 2005 2193 <span class={s.fetchMatchOperator}>equals</span> 2006 2194 </div> 2007 2195 <div class={s.fieldGroup} style={{ flex: "2 1 200px" }}> 2008 - <label class={s.label}>Value</label> 2196 + <label class={s.label} for={`fetch-${i}-where-value`}> 2197 + Value 2198 + </label> 2009 2199 <input 2200 + id={`fetch-${i}-where-value`} 2010 2201 class={s.input} 2011 2202 type="text" 2012 - placeholder="{{event.commit.record.subject}}" 2203 + placeholder="e.g. {{event.commit.record.subject}}" 2013 2204 value={f.whereValue} 2014 2205 onInput={(e: Event) => 2015 2206 updateFetch(i, "whereValue", (e.target as HTMLInputElement).value) 2016 2207 } 2208 + autocomplete="off" 2017 2209 /> 2018 2210 </div> 2019 2211 </div> ··· 2089 2281 <div class={s.conditionField}> 2090 2282 {useFreeFormField ? ( 2091 2283 <input 2284 + id={`fetch-${i}-cond-${ci}-field`} 2092 2285 class={s.input} 2093 2286 type="text" 2094 - placeholder="record.subject" 2287 + placeholder="e.g. record.subject" 2095 2288 value={cond.field} 2096 2289 onInput={(e: Event) => 2097 2290 updateFetchCondition( ··· 2101 2294 (e.target as HTMLInputElement).value, 2102 2295 ) 2103 2296 } 2297 + aria-label="Condition field" 2298 + autocomplete="off" 2104 2299 /> 2105 2300 ) : ( 2106 2301 <> 2107 2302 <select 2303 + id={`fetch-${i}-cond-${ci}-field`} 2108 2304 class={s.select} 2109 2305 value={cond.field} 2110 2306 disabled={schemaState?.loading} ··· 2116 2312 (e.target as HTMLSelectElement).value, 2117 2313 ) 2118 2314 } 2315 + aria-label="Condition field" 2119 2316 > 2120 2317 <option value=""> 2121 2318 {schemaState?.loading 2122 2319 ? "Loading fields..." 2123 2320 : "Select field..."} 2124 2321 </option> 2322 + {cond.field && !selectedField && ( 2323 + <option value={cond.field}>{cond.field}</option> 2324 + )} 2125 2325 {condOptions.map((cf) => ( 2126 2326 <option key={cf.path} value={cf.path}> 2127 2327 {cf.path} ··· 2137 2337 {cond.field && !isBoolean && ( 2138 2338 <div class={s.conditionOperator}> 2139 2339 <select 2340 + id={`fetch-${i}-cond-${ci}-operator`} 2140 2341 class={s.select} 2141 2342 value={cond.operator} 2142 2343 onChange={(e: Event) => ··· 2147 2348 (e.target as HTMLSelectElement).value, 2148 2349 ) 2149 2350 } 2351 + aria-label="Condition operator" 2150 2352 > 2151 2353 <option value="eq">equals</option> 2152 2354 <option value="startsWith">starts with</option> ··· 2161 2363 <div class={s.conditionValue}> 2162 2364 {isBoolean ? ( 2163 2365 <select 2366 + id={`fetch-${i}-cond-${ci}-value`} 2164 2367 class={s.select} 2165 2368 value={cond.value} 2166 2369 onChange={(e: Event) => ··· 2171 2374 (e.target as HTMLSelectElement).value, 2172 2375 ) 2173 2376 } 2377 + aria-label="Condition value" 2174 2378 > 2175 2379 <option value="">Select...</option> 2176 2380 <option value="true">true</option> ··· 2178 2382 </select> 2179 2383 ) : ( 2180 2384 <input 2385 + id={`fetch-${i}-cond-${ci}-value`} 2181 2386 class={s.input} 2182 2387 type="text" 2183 2388 placeholder="Value to compare" ··· 2190 2395 (e.target as HTMLInputElement).value, 2191 2396 ) 2192 2397 } 2398 + aria-label="Condition value" 2399 + autocomplete="off" 2193 2400 /> 2194 2401 )} 2195 2402 </div> ··· 2207 2414 2208 2415 {/* Note */} 2209 2416 <div class={s.fieldGroup}> 2210 - <label class={s.label}> 2417 + <label class={s.label} for={`fetch-${i}-note`}> 2211 2418 Note <span class={s.hint}>(optional)</span> 2212 2419 </label> 2213 2420 <input 2421 + id={`fetch-${i}-note`} 2214 2422 class={s.input} 2215 2423 type="text" 2216 2424 placeholder="A reminder for future-you about why this source exists" ··· 2218 2426 onInput={(e: Event) => 2219 2427 updateFetch(i, "comment", (e.target as HTMLInputElement).value) 2220 2428 } 2429 + autocomplete="off" 2221 2430 /> 2222 2431 </div> 2223 2432 ··· 2299 2508 </button> 2300 2509 </div> 2301 2510 {action.type === "webhook" ? ( 2302 - <WebhookActionEditor action={action} onChange={(a) => updateAction(i, a)} /> 2511 + <WebhookActionEditor 2512 + action={action} 2513 + index={i} 2514 + onChange={(a) => updateAction(i, a)} 2515 + /> 2303 2516 ) : action.type === "bsky-post" ? ( 2304 - <BskyPostActionEditor action={action} onChange={(a) => updateAction(i, a)} /> 2517 + <BskyPostActionEditor 2518 + action={action} 2519 + index={i} 2520 + onChange={(a) => updateAction(i, a)} 2521 + /> 2305 2522 ) : action.type === "patch-record" ? ( 2306 2523 <PatchRecordActionEditor 2307 2524 action={action} 2525 + index={i} 2308 2526 onChange={(a) => updateAction(i, a)} 2309 2527 placeholders={allPlaceholders} 2310 2528 /> 2311 2529 ) : action.type === "bookmark" ? ( 2312 - <BookmarkActionEditor action={action} onChange={(a) => updateAction(i, a)} /> 2530 + <BookmarkActionEditor 2531 + action={action} 2532 + index={i} 2533 + onChange={(a) => updateAction(i, a)} 2534 + /> 2313 2535 ) : action.type === "follow" ? ( 2314 - <FollowActionEditor action={action} onChange={(a) => updateAction(i, a)} /> 2536 + <FollowActionEditor 2537 + action={action} 2538 + index={i} 2539 + onChange={(a) => updateAction(i, a)} 2540 + /> 2315 2541 ) : ( 2316 2542 <RecordActionEditor 2317 2543 action={action} 2544 + index={i} 2318 2545 onChange={(a) => updateAction(i, a)} 2319 2546 placeholders={allPlaceholders} 2320 2547 /> 2321 2548 )} 2322 - <input 2323 - class={s.input} 2324 - type="text" 2325 - placeholder="Note (optional)" 2326 - value={action.comment} 2327 - onInput={(e: Event) => 2328 - updateAction(i, { 2329 - ...action, 2330 - comment: (e.target as HTMLInputElement).value, 2331 - }) 2332 - } 2333 - /> 2549 + <div class={s.fieldGroup}> 2550 + <label class={s.label} for={`action-${i}-note`}> 2551 + Note <span class={s.hint}>(optional)</span> 2552 + </label> 2553 + <input 2554 + id={`action-${i}-note`} 2555 + class={s.input} 2556 + type="text" 2557 + value={action.comment} 2558 + onInput={(e: Event) => 2559 + updateAction(i, { 2560 + ...action, 2561 + comment: (e.target as HTMLInputElement).value, 2562 + }) 2563 + } 2564 + autocomplete="off" 2565 + /> 2566 + </div> 2334 2567 {isRecordProducingAction(action.type) && ( 2335 2568 <details class={s.collapsibleDetails}> 2336 2569 <summary class={s.collapsibleSummary}> ··· 2418 2651 type="submit" 2419 2652 class={s.submitBtn} 2420 2653 disabled={!name.trim() || operations.length === 0 || actions.length === 0} 2654 + aria-busy={submitting} 2421 2655 > 2422 2656 {submitting 2423 2657 ? isEdit
+1 -1
app/islands/RecordFormBuilder.tsx
··· 340 340 341 341 const formatHints: Record<string, string> = { 342 342 datetime: "ISO datetime, e.g. {{now}}", 343 - "at-uri": "AT URI, e.g. at://did/collection/rkey", 343 + "at-uri": "AT URI, e.g. {{event.commit.record.subject}}", 344 344 uri: "URI", 345 345 did: "DID identifier", 346 346 handle: "Handle, e.g. user.bsky.social",