├── .changeset ├── README.md ├── config.json ├── dull-chairs-attend.md ├── healthy-shoes-hear.md ├── odd-goats-dance.md └── orange-coins-speak.md ├── .github ├── assets │ ├── workflow-demo.gif │ └── workflow-kit.jpg └── workflows │ ├── pr.yml │ └── release.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── examples └── nextjs-blog-cms │ ├── .env.example │ ├── .eslintrc.json │ ├── .gitignore │ ├── LICENSE.md │ ├── README.md │ ├── app │ ├── actions.ts │ ├── api │ │ ├── blog-posts │ │ │ └── route.ts │ │ ├── inngest │ │ │ └── route.ts │ │ └── workflows │ │ │ └── route.ts │ ├── automation │ │ ├── [id] │ │ │ └── page.tsx │ │ └── page.tsx │ ├── blog-post │ │ └── [id] │ │ │ └── page.tsx │ ├── favicon.ico │ ├── fonts │ │ ├── GeistMonoVF.woff │ │ └── GeistVF.woff │ ├── globals.css │ ├── layout.tsx │ └── page.tsx │ ├── components.json │ ├── components │ ├── automation-editor.tsx │ ├── automation-list.tsx │ ├── blog-post-actions.tsx │ ├── blog-post-list.tsx │ ├── menu.tsx │ └── ui │ │ ├── badge.tsx │ │ ├── button.tsx │ │ ├── card.tsx │ │ ├── checkbox.tsx │ │ ├── label.tsx │ │ ├── select.tsx │ │ ├── switch.tsx │ │ └── tabs.tsx │ ├── lib │ ├── inngest │ │ ├── client.ts │ │ ├── workflow.ts │ │ ├── workflowActionHandlers.ts │ │ └── workflowActions.ts │ ├── loaders │ │ ├── blog-post.ts │ │ └── workflow.ts │ ├── mdxComponents.tsx │ ├── supabase │ │ ├── client.ts │ │ ├── database.types.ts │ │ ├── server.ts │ │ └── types.ts │ └── utils.ts │ ├── next.config.mjs │ ├── package.json │ ├── pnpm-lock.yaml │ ├── postcss.config.mjs │ ├── supabase │ ├── .gitignore │ ├── config.toml │ ├── schema.sql │ └── seed.sql │ ├── tailwind.config.ts │ ├── tsconfig.json │ └── workflow-editor.png ├── package.json ├── packages └── workflow │ ├── .gitignore │ ├── .storybook │ ├── main.js │ └── preview.js │ ├── CHANGELOG.md │ ├── LICENSE.md │ ├── README.md │ ├── jest.config.js │ ├── package.json │ ├── src │ ├── builtin.test.ts │ ├── builtin.ts │ ├── engine.execution.test.ts │ ├── engine.test.ts │ ├── engine.ts │ ├── graph.test.ts │ ├── graph.ts │ ├── index.ts │ ├── interpolation.ts │ ├── stories │ │ ├── UI.jsx │ │ ├── UI.stories.jsx │ │ └── ui.storybook.css │ ├── types.ts │ └── ui │ │ ├── Editor.tsx │ │ ├── Provider.tsx │ │ ├── Sidebar.tsx │ │ ├── index.ts │ │ ├── layout.tsx │ │ ├── nodes.tsx │ │ ├── nodes │ │ ├── Action.tsx │ │ ├── Handles.tsx │ │ └── Trigger.tsx │ │ ├── sidebar │ │ ├── ActionForm.tsx │ │ ├── ActionList.tsx │ │ ├── Footer.tsx │ │ ├── Header.tsx │ │ ├── Sidebar.tsx │ │ └── WorfklowForm.tsx │ │ └── ui.css │ └── tsconfig.json ├── pnpm-lock.yaml └── pnpm-workspace.yaml /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/@changesets/config@3.0.3/schema.json", 3 | "changelog": "@changesets/cli/changelog", 4 | "commit": false, 5 | "fixed": [], 6 | "linked": [], 7 | "access": "public", 8 | "baseBranch": "main", 9 | "updateInternalDependencies": "patch", 10 | "ignore": [], 11 | "snapshot": { 12 | "useCalculatedVersion": true, 13 | "prereleaseTemplate": "{tag}-{datetime}-{commit}" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.changeset/dull-chairs-attend.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@inngest/workflow-kit": minor 3 | --- 4 | 5 | Execute Provider.onChange callback when action input values change 6 | -------------------------------------------------------------------------------- /.changeset/healthy-shoes-hear.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@inngest/workflow-kit": patch 3 | --- 4 | 5 | Minor improvements to conditional support 6 | -------------------------------------------------------------------------------- /.changeset/odd-goats-dance.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@inngest/workflow-kit": minor 3 | --- 4 | 5 | Switch to a maintained version of jsonpath 6 | -------------------------------------------------------------------------------- /.changeset/orange-coins-speak.md: -------------------------------------------------------------------------------- 1 | --- 2 | "@inngest/workflow-kit": minor 3 | --- 4 | 5 | Show edge names in editor 6 | -------------------------------------------------------------------------------- /.github/assets/workflow-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inngest/workflow-kit/26ad93b85d396f64f4929ced0b64ff6a5fd76761/.github/assets/workflow-demo.gif -------------------------------------------------------------------------------- /.github/assets/workflow-kit.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inngest/workflow-kit/26ad93b85d396f64f4929ced0b64ff6a5fd76761/.github/assets/workflow-kit.jpg -------------------------------------------------------------------------------- /.github/workflows/pr.yml: -------------------------------------------------------------------------------- 1 | name: "canary" 2 | on: 3 | pull_request: 4 | branches: 5 | - main 6 | paths: 7 | - ".changeset/**/*.md" 8 | 9 | env: 10 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 11 | 12 | jobs: 13 | release: 14 | runs-on: ubuntu-latest 15 | permissions: 16 | contents: write 17 | id-token: write 18 | steps: 19 | - uses: actions/checkout@v4 20 | with: 21 | persist-credentials: false 22 | # Used to fetch all history so that changesets doesn't attempt to 23 | # publish duplicate tags. 24 | fetch-depth: 0 25 | # Replaces `concurrency` - never cancels any jobs 26 | - uses: softprops/turnstyle@v1 27 | with: 28 | poll-interval-seconds: 30 29 | env: 30 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 31 | - name: Install pnpm 32 | uses: pnpm/action-setup@v4 33 | - run: pnpm install 34 | - uses: "the-guild-org/changesets-snapshot-action@v0.0.2" 35 | with: 36 | tag: "alpha" 37 | prepareScript: "pnpm run build" 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }} 40 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | NODE_ENV: test # disable npm access checks; they don't work in CI 42 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 10 | 11 | jobs: 12 | release: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | id-token: write 17 | steps: 18 | - uses: actions/checkout@v4 19 | with: 20 | persist-credentials: false 21 | # Used to fetch all history so that changesets doesn't attempt to 22 | # publish duplicate tags. 23 | fetch-depth: 0 24 | # Replaces `concurrency` - never cancels any jobs 25 | - uses: softprops/turnstyle@v1 26 | with: 27 | poll-interval-seconds: 30 28 | env: 29 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 30 | - name: Install pnpm 31 | uses: pnpm/action-setup@v4 32 | - run: pnpm install 33 | - run: pnpm run build 34 | - run: pnpm run test 35 | - uses: changesets/action@v1 36 | with: 37 | title: "Release @latest" 38 | publish: pnpm run release 39 | env: 40 | GITHUB_TOKEN: ${{ secrets.CHANGESET_GITHUB_TOKEN }} 41 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | NODE_ENV: test # disable npm access checks; they don't work in CI 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Node packages 2 | node_modules 3 | 4 | # Nix 5 | /.direnv/ 6 | 7 | .env.local -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Releasing 4 | 5 | To release to production, we use 6 | [Changesets](https://github.com/changesets/changesets). This means that 7 | releasing and changelog generation is all managed through PRs, where a bot will 8 | guide you through the process of adding release notes to PRs. 9 | 10 | When you create a PR, the changeset-bot will add a comment prompting you 11 | to add changesets if this PR requires it. 12 | 13 | ![image](https://github.com/user-attachments/assets/91a980f0-d4af-4bad-bb29-732e2be5ff87) 14 | 15 | Clicking the bottom link will take you to the creation of a new changeset on GitHub. It'll look something like this: 16 | 17 | ``` 18 | --- 19 | "@inngest/workflow-kit": patch 20 | --- 21 | 22 | An important change 23 | ``` 24 | 25 | Here we're stating that this PR will change the patch version with the release note `An important change`. 26 | 27 | Each changeset you create will constitute a single user-facing note in 28 | the changelog and GitHub Release notes, so think about how best to introduce a 29 | user to a new feature, fix, or migration when adding a changeset to a PR. 30 | 31 | > [!NOTE] 32 | > Not all changes need a changeset! Since changesets are focused on releases and 33 | > changelogs, changes to a package that don't require these won't need a 34 | > changeset. For example, changing GitHub configuration files or non-user-facing 35 | > markdown. 36 | 37 | As PRs are merged into main, a new PR (usually called **Release @latest**) is 38 | created that rolls up all release notes since the last release, allowing you 39 | bundle changes together. Once you're happy with the release, merge this new PR 40 | and the bot will release the package to npm for you and tag the release commit 41 | (which changes version numbers and modifies the changelog) with a tag like 42 | `@inngest/workflow-kit@1.2.3`. 43 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | ./packages/workflow/LICENSE.md -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ./packages/workflow/README.md -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/.env.example: -------------------------------------------------------------------------------- 1 | NEXT_PUBLIC_SUPABASE_URL= 2 | NEXT_PUBLIC_SUPABASE_ANON_KEY= 3 | INNGEST_EVENT_KEY= 4 | INNGEST_SIGNING_KEY= 5 | OPENAI_API_KEY= 6 | OPENAI_MODEL=gpt-3.5-turbo -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "next/typescript"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/README.md: -------------------------------------------------------------------------------- 1 | ## Blog AI workflows | Next.js template 2 | 3 |

4 | 5 | ![Workflow Kit by Inngest](./workflow-editor.png) 6 | 7 |

8 | 9 |

10 | Documentation 11 |  ·  12 | Blog 13 |  ·  14 | Community 15 |

16 |
17 | 18 | This demo is Next.js blog back-office featuring some AI workflows helping with grammar fixes, generating Table of Contents or Tweets, built with [Inngest](https://www.inngest.com/?ref=github-workflow-kit-example-nextjs-blog-cms-readme), [Supabase](https://supabase.com) and [OpenAI](https://github.com/openai/openai-node). 19 | 20 | Get started by cloning this repo and following the below setup instructions or directly deploy this template on Vercel. 21 | 22 | - [Getting Started - Local setup](#getting-started-local-setup) 23 | - [0. Prerequisites](#0-prerequisites) 24 | - [1. Setup](#1-setup) 25 | - [2. Database setup](#2-database-setup) 26 | - [3. Starting the application](#3-starting-the-application) 27 | - [Getting Started - Vercel/Deployment setup](#getting-started-verceldeployment-setup) 28 | - [0. Prerequisites](#0-prerequisites-1) 29 | - [1. Deploy on Vercel](#1-deploy-on-vercel) 30 | - [2. Inngest Integration setup](#2-inngest-integration-setup) 31 | - [3. Database setup](#3-database-setup) 32 | - [Demo tour](#demo-tour) 33 | 34 | ## Getting Started - Local setup 35 | 36 | ### 0. Prerequisites 37 | 38 | To run this demo locally, you'll need the following: 39 | 40 | - a [Supabase account](https://supabase.com) 41 | - an [OpenAI account](https://platform.openai.com/) 42 | 43 | ### 1. Setup 44 | 45 | 1. First, clone the repository and navigate the `examples/` folder: 46 | 47 | ``` 48 | git clone 49 | 50 | cd examples/nextjs-blog-cms 51 | ``` 52 | 53 | 2. Then, install the dependencies: 54 | 55 | ```bash 56 | npm i 57 | # or 58 | yarn 59 | # or 60 | pnpm --ignore-workspace i 61 | ``` 62 | 63 | 3. Finally, copy your local `.env.example` as `.env.local` and fill your `OPENAI_API_KEY`. 64 | 65 | ### 2. Database setup 66 | 67 | This project needs a database to store the blog posts and workflows. 68 | 69 | Follow the below steps to get a database up and running with Supabase: 70 | 71 | 1. Go to [your Supabase Dashboard](https://supabase.com/dashboard/projects) and create a new project 72 | 1. While your database is being created, update your `.env.local` and fill the `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` 73 | 1. Open the SQL Editor from the left side navigation, and copy the content of the `examples/nextjs-blog-cms/supabase/schema.sql` file 74 | 1. Still in the SQL Editor, create a new snippet and do the same with the `examples/nextjs-blog-cms/supabase/seed.sql` file 75 | 1. Navigate to the Table Editor, you should see two tables: `blog_posts` and `workflows` 76 | 77 | You are all set, your database is ready to be used! 78 | 79 | ### 3. Starting the application 80 | 81 | First, start the Next.js application: 82 | 83 | ```bash 84 | npm run dev 85 | # or 86 | yarn dev 87 | # or 88 | pnpm dev 89 | # or 90 | bun dev 91 | ``` 92 | 93 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the application. See our [Demo Tour section](#demo-tour) to trigger your first workflow. 94 | 95 | Finally, start the Inngest Dev Server by running the following command: 96 | 97 | ``` 98 | npx inngest-cli@latest dev 99 | ``` 100 | 101 | Open [http://localhost:8288](http://localhost:8288) with your browser to explore the [Inngest Dev Server](https://www.inngest.com/docs/dev-server?ref=github-workflow-kit-example-nextjs-blog-cms-readme). 102 | 103 | ## Getting Started - Vercel/Deployment setup 104 | 105 | ### 0. Prerequisites 106 | 107 | To run this demo locally, you'll need the following: 108 | 109 | - an [Inngest account](https://www.inngest.com/?ref=github-workflow-kit-example-nextjs-blog-cms-readme) 110 | - a [Supabase account](https://supabase.com) 111 | - an [OpenAI account](https://platform.openai.com/) 112 | 113 | ### 1. Deploy on Vercel 114 | 115 | Use the below button to deploy this template to Vercel: 116 | 117 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?install-command=pnpm%20--ignore-workspace%20i&repository-url=https%3A%2F%2Fgithub.com%2Finngest%2Fworkflow-kit%2Ftree%2Fmain%2Fexamples%2Fnextjs-blog-cms%2F&project-name=nextjs-blog-cms-ai-workflow-with-inngest&repository-name=workflow-kit&demo-title=nextjs-blog-cms-ai-workflow-with-inngest&demo-description=Next.js%20blog%20back-office%20featuring%20some%20AI%20workflows%20helping%20with%20grammar%20fixes%2C%20generating%20Table%20of%20Contents%20or%20Tweets&demo-image=https%3A%2F%2Fraw.githubusercontent.com%2Finngest%2Fworkflow-kit%2Frefs%2Fheads%2Fmain%2Fworkflow-kit.jpg) 118 | 119 | **Once deployed, make sure to configure your `OPENAI_API_KEY` environment variable.** 120 | 121 | ### 2. Inngest Integration setup 122 | 123 | Navigate to the [Inngest Vercel Integration page](https://vercel.com/integrations/inngest) and follow the instructions to link your Vercel application with Inngest. 124 | 125 | ### 3. Database setup 126 | 127 | This project needs a database to store the blog posts and workflows. 128 | 129 | Follow the below steps to get a database up and running with Supabase: 130 | 131 | 1. Go to [your Supabase Dashboard](https://supabase.com/dashboard/projects) and create a new project 132 | 1. While your database is being created, update your Vercel project environment variables: `NEXT_PUBLIC_SUPABASE_URL` and `NEXT_PUBLIC_SUPABASE_ANON_KEY` 133 | 1. Open the SQL Editor from the left side navigation, and copy the content of the `examples/nextjs-blog-cms/supabase/schema.sql` file 134 | 1. Still in the SQL Editor, create a new snippet and do the same with the `examples/nextjs-blog-cms/supabase/seed.sql` file 135 | 1. Navigate to the Table Editor, you should see two tables: `blog_posts` and `workflows` 136 | 137 | You are all set, your database is ready to be used! 138 | 139 | ## Demo tour 140 | 141 | This template is shipped with a Database seed containing three blog posts (two drafts) and two workflows. 142 | 143 | Here are some suggestions of steps to follow as a tour of this demo: 144 | 145 | ### 1. Configuring an automation 146 | 147 | 1. Navigate to the automation tab 148 | 1. Click "Configure" on the "When a blog post is moved to review" automation 149 | 1. Hover the "blog-post.updated" node to click on the "+" icon 150 | 1. Select "Add a Table of Contents" on the right side panel 151 | 1. Add another step from "Add a Table of Contents" by using the "+" icon and select "Perform a grammar review" 152 | 1. Finally, add a "Apply changes after approval" as a final step 153 | 1. Click on "Save changes" at the top right 154 | 155 | ### 2. Move a blog post to review 156 | 157 | We now have an active automation that will trigger when a blog post moves to review. 158 | 159 | Let's trigger our automation: 160 | 161 | 1. Navigate to the blog posts page 162 | 1. Click on "Send to review" on the draft blog post 163 | 1. Navigate to your [Inngest Dev Server](http://localhost:8288) or [Inngest Platform](https://app.inngest.com/?ref=github-workflow-kit-example-nextjs-blog-cms-readme) and go over the "Runs" tab 164 | 1. You can now see your automation running live, step by step, to finally pause 165 | 1. Return to the demo app, the blog post should have the "Needs approval" status. Click on "Review" and compare the Original blog post with the AI revision using the tabs. 166 | 1. At the bottom, click on the "Approve suggestions & Publish" button 167 | 1. Back on the Runs page of the [Inngest Dev Server](http://localhost:8288) or [Inngest Platform](https://app.inngest.com/?ref=github-workflow-kit-example-nextjs-blog-cms-readme), you see the workflow in a completed state. 168 | 1. Going back to the demo application, on the "blog posts" tab, the blog post should be flagged as "Published" 169 | 170 | ## License 171 | 172 | [Apache 2.0](./LICENSE.md) 173 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/app/actions.ts: -------------------------------------------------------------------------------- 1 | "use server"; 2 | import { inngest } from "@/lib/inngest/client"; 3 | import { Json } from "@/lib/supabase/database.types"; 4 | import { createClient } from "@/lib/supabase/server"; 5 | import { type Workflow } from "@/lib/supabase/types"; 6 | 7 | export const sendBlogPostToReview = async (id: string) => { 8 | const supabase = createClient(); 9 | await supabase 10 | .from("blog_posts") 11 | .update({ 12 | status: "under review", 13 | markdown_ai_revision: null, 14 | }) 15 | .eq("id", id); 16 | 17 | await inngest.send({ 18 | name: "blog-post.updated", 19 | data: { 20 | id, 21 | }, 22 | }); 23 | }; 24 | 25 | export const approveBlogPostAiSuggestions = async (id: string) => { 26 | await inngest.send({ 27 | name: "blog-post.approve-ai-suggestions", 28 | data: { 29 | id, 30 | }, 31 | }); 32 | }; 33 | 34 | export const publishBlogPost = async (id: string) => { 35 | const supabase = createClient(); 36 | await supabase 37 | .from("blog_posts") 38 | .update({ 39 | status: "published", 40 | markdown_ai_revision: null, 41 | }) 42 | .eq("id", id); 43 | 44 | await inngest.send({ 45 | name: "blog-post.published", 46 | data: { 47 | id, 48 | }, 49 | }); 50 | }; 51 | export const updateWorkflow = async (workflow: Workflow) => { 52 | const supabase = createClient(); 53 | await supabase 54 | .from("workflows") 55 | .update({ 56 | workflow: workflow.workflow as unknown as Json, 57 | }) 58 | .eq("id", workflow.id); 59 | }; 60 | 61 | export const toggleWorkflow = async (workflowId: number, enabled: boolean) => { 62 | const supabase = createClient(); 63 | await supabase 64 | .from("workflows") 65 | .update({ 66 | enabled, 67 | }) 68 | .eq("id", workflowId) 69 | .select("*"); 70 | }; 71 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/app/api/blog-posts/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/lib/supabase/server"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export async function GET() { 5 | const supabase = createClient(); 6 | const { data: blogPosts } = await supabase 7 | .from("blog_posts") 8 | .select( 9 | "id, title, subtitle, markdown_ai_revision, created_at, status, markdown, ai_publishing_recommendations" 10 | ) 11 | .order("created_at", { ascending: false }); 12 | 13 | return NextResponse.json({ blogPosts }); 14 | } 15 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/app/api/inngest/route.ts: -------------------------------------------------------------------------------- 1 | import { inngest } from "@/lib/inngest/client"; 2 | import workflow from "@/lib/inngest/workflow"; 3 | import { serve } from "inngest/next"; 4 | 5 | export const runtime = "edge"; 6 | 7 | export const { GET, POST, PUT } = serve({ 8 | client: inngest, 9 | functions: [workflow], 10 | }); 11 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/app/api/workflows/route.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "@/lib/supabase/server"; 2 | import { NextResponse } from "next/server"; 3 | 4 | export async function GET() { 5 | const supabase = createClient(); 6 | const { data: workflows } = await supabase 7 | .from("workflows") 8 | .select("*") 9 | .order("id"); 10 | 11 | return NextResponse.json({ workflows }); 12 | } 13 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/app/automation/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { AutomationEditor } from "@/components/automation-editor"; 2 | import { createClient } from "@/lib/supabase/server"; 3 | import { notFound } from "next/navigation"; 4 | 5 | export const runtime = "edge"; 6 | 7 | export default async function Automation({ 8 | params, 9 | }: { 10 | params: { id: string }; 11 | }) { 12 | const supabase = createClient(); 13 | const { data: workflow } = await supabase 14 | .from("workflows") 15 | .select("*") 16 | .eq("id", params.id!) 17 | .single(); 18 | if (workflow) { 19 | return ; 20 | } else { 21 | notFound(); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/app/automation/page.tsx: -------------------------------------------------------------------------------- 1 | import { AutomationList } from "@/components/automation-list"; 2 | 3 | export default async function Dashboard() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/app/blog-post/[id]/page.tsx: -------------------------------------------------------------------------------- 1 | import { notFound } from "next/navigation"; 2 | import { evaluate } from "@mdx-js/mdx"; 3 | import * as runtime from "react/jsx-runtime"; 4 | 5 | import { 6 | Card, 7 | CardContent, 8 | CardFooter, 9 | CardHeader, 10 | } from "@/components/ui/card"; 11 | import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; 12 | import { BlogPostActions } from "@/components/blog-post-actions"; 13 | 14 | import { loadBlogPost } from "@/lib/loaders/blog-post"; 15 | import { mdxComponents } from "@/lib/mdxComponents"; 16 | 17 | export const revalidate = 0; 18 | 19 | export default async function BlogPost({ params }: { params: { id: string } }) { 20 | const blogPost = await loadBlogPost(params.id); 21 | 22 | if (blogPost) { 23 | const { default: MDXBlogPostContent } = await evaluate( 24 | blogPost.markdown || "", 25 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 26 | runtime as any 27 | ); 28 | 29 | let MDXBlogPostAIContent: typeof MDXBlogPostContent | undefined; 30 | 31 | if ( 32 | blogPost.markdown_ai_revision || 33 | blogPost.ai_publishing_recommendations 34 | ) { 35 | MDXBlogPostAIContent = ( 36 | await evaluate( 37 | blogPost.markdown_ai_revision || 38 | blogPost.ai_publishing_recommendations!, 39 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 40 | runtime as any 41 | ) 42 | ).default; 43 | } 44 | 45 | return ( 46 |
47 |
48 |

Blog Post

49 |
50 | 51 | 52 | 53 | 54 | Original 55 | {MDXBlogPostAIContent && ( 56 | 57 | {blogPost.markdown_ai_revision 58 | ? "AI version" 59 | : "AI Publishing recommendations"} 60 | 61 | )} 62 | 63 | 64 | 65 | 66 | 67 | 68 | {MDXBlogPostAIContent && ( 69 | 70 | 71 | 72 | )} 73 | 74 | 75 | {blogPost.status === "needs approval" && ( 76 | 77 | )} 78 | 79 | 80 | 81 |
82 | ); 83 | } else { 84 | return notFound(); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inngest/workflow-kit/26ad93b85d396f64f4929ced0b64ff6a5fd76761/examples/nextjs-blog-cms/app/favicon.ico -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/app/fonts/GeistMonoVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inngest/workflow-kit/26ad93b85d396f64f4929ced0b64ff6a5fd76761/examples/nextjs-blog-cms/app/fonts/GeistMonoVF.woff -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/app/fonts/GeistVF.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inngest/workflow-kit/26ad93b85d396f64f4929ced0b64ff6a5fd76761/examples/nextjs-blog-cms/app/fonts/GeistVF.woff -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #ffffff; 7 | --foreground: #171717; 8 | } 9 | 10 | @media (prefers-color-scheme: dark) { 11 | :root { 12 | --background: #0a0a0a; 13 | --foreground: #ededed; 14 | } 15 | } 16 | 17 | body { 18 | color: var(--foreground); 19 | background: var(--background); 20 | font-family: Arial, Helvetica, sans-serif; 21 | } 22 | 23 | @layer utilities { 24 | .text-balance { 25 | text-wrap: balance; 26 | } 27 | } 28 | 29 | @layer base { 30 | :root { 31 | --background: 0 0% 100%; 32 | --foreground: 0 0% 3.9%; 33 | --card: 0 0% 100%; 34 | --card-foreground: 0 0% 3.9%; 35 | --popover: 0 0% 100%; 36 | --popover-foreground: 0 0% 3.9%; 37 | --primary: 0 0% 9%; 38 | --primary-foreground: 0 0% 98%; 39 | --secondary: 0 0% 96.1%; 40 | --secondary-foreground: 0 0% 9%; 41 | --muted: 0 0% 96.1%; 42 | --muted-foreground: 0 0% 45.1%; 43 | --accent: 0 0% 96.1%; 44 | --accent-foreground: 0 0% 9%; 45 | --destructive: 0 84.2% 60.2%; 46 | --destructive-foreground: 0 0% 98%; 47 | --border: 0 0% 89.8%; 48 | --input: 0 0% 89.8%; 49 | --ring: 0 0% 3.9%; 50 | --chart-1: 12 76% 61%; 51 | --chart-2: 173 58% 39%; 52 | --chart-3: 197 37% 24%; 53 | --chart-4: 43 74% 66%; 54 | --chart-5: 27 87% 67%; 55 | --radius: 0.5rem; 56 | } 57 | .dark { 58 | --background: 0 0% 3.9%; 59 | --foreground: 0 0% 98%; 60 | --card: 0 0% 3.9%; 61 | --card-foreground: 0 0% 98%; 62 | --popover: 0 0% 3.9%; 63 | --popover-foreground: 0 0% 98%; 64 | --primary: 0 0% 98%; 65 | --primary-foreground: 0 0% 9%; 66 | --secondary: 0 0% 14.9%; 67 | --secondary-foreground: 0 0% 98%; 68 | --muted: 0 0% 14.9%; 69 | --muted-foreground: 0 0% 63.9%; 70 | --accent: 0 0% 14.9%; 71 | --accent-foreground: 0 0% 98%; 72 | --destructive: 0 62.8% 30.6%; 73 | --destructive-foreground: 0 0% 98%; 74 | --border: 0 0% 14.9%; 75 | --input: 0 0% 14.9%; 76 | --ring: 0 0% 83.1%; 77 | --chart-1: 220 70% 50%; 78 | --chart-2: 160 60% 45%; 79 | --chart-3: 30 80% 55%; 80 | --chart-4: 280 65% 60%; 81 | --chart-5: 340 75% 55%; 82 | } 83 | } 84 | 85 | @layer base { 86 | * { 87 | @apply border-border; 88 | } 89 | body { 90 | @apply bg-background text-foreground; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/app/layout.tsx: -------------------------------------------------------------------------------- 1 | import type { Metadata } from 'next'; 2 | import localFont from 'next/font/local'; 3 | import './globals.css'; 4 | import { Menu } from '@/components/menu'; 5 | 6 | const geistSans = localFont({ 7 | src: './fonts/GeistVF.woff', 8 | variable: '--font-geist-sans', 9 | weight: '100 900', 10 | }); 11 | const geistMono = localFont({ 12 | src: './fonts/GeistMonoVF.woff', 13 | variable: '--font-geist-mono', 14 | weight: '100 900', 15 | }); 16 | 17 | export const metadata: Metadata = { 18 | title: 'Inngest blog CMS', 19 | description: 'Leverage AI Automation to optimize your blog posts', 20 | }; 21 | 22 | export default function RootLayout({ 23 | children, 24 | }: Readonly<{ 25 | children: React.ReactNode; 26 | }>) { 27 | return ( 28 | 29 | 32 |
33 | {/* Sidebar */} 34 |
35 |
36 |

Dashboard

37 | 38 |
39 |
40 | 41 | {/* Main Content */} 42 |
{children}
43 |
44 | 45 | 46 | ); 47 | } 48 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/app/page.tsx: -------------------------------------------------------------------------------- 1 | import { BlogPostList } from "@/components/blog-post-list"; 2 | 3 | export default async function Dashboard() { 4 | return ; 5 | } 6 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "neutral", 10 | "cssVariables": true, 11 | "prefix": "" 12 | }, 13 | "aliases": { 14 | "components": "@/components", 15 | "utils": "@/lib/utils", 16 | "ui": "@/components/ui", 17 | "lib": "@/lib", 18 | "hooks": "@/hooks" 19 | } 20 | } -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/components/automation-editor.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Editor, Provider, Sidebar } from "@inngest/workflow-kit/ui"; 3 | import { SaveIcon } from "lucide-react"; 4 | import { useRouter } from "next/navigation"; 5 | import { useCallback, useState } from "react"; 6 | 7 | import { type Workflow } from "@/lib/supabase/types"; 8 | 9 | import { Button } from "@/components/ui/button"; 10 | import { 11 | Card, 12 | CardContent, 13 | CardDescription, 14 | CardFooter, 15 | CardHeader, 16 | CardTitle, 17 | } from "@/components/ui/card"; 18 | 19 | import { updateWorkflow } from "@/app/actions"; 20 | import { actions } from "@/lib/inngest/workflowActions"; 21 | 22 | import "@inngest/workflow-kit/ui/ui.css"; 23 | import "@xyflow/react/dist/style.css"; 24 | 25 | export const AutomationEditor = ({ workflow }: { workflow: Workflow }) => { 26 | const router = useRouter(); 27 | const [workflowDraft, updateWorkflowDraft] = 28 | useState(workflow); 29 | 30 | const onSaveWorkflow = useCallback(async () => { 31 | await updateWorkflow(workflowDraft); 32 | router.push("/automation"); 33 | }, [router, workflowDraft]); 34 | 35 | return ( 36 |
37 |
38 |

Automation Editor

39 |
40 | 41 | 42 |
43 |
44 | {workflow.name} 45 | {workflow.description} 46 |
47 | 50 |
51 |
52 | 53 |
54 | { 64 | updateWorkflowDraft({ 65 | ...workflowDraft, 66 | workflow: updated, 67 | }); 68 | }} 69 | > 70 | 71 | 72 | 73 | 74 |
75 | 76 |
77 |
78 |
79 | ); 80 | }; 81 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/components/automation-list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | /* eslint-disable react-hooks/exhaustive-deps */ 3 | /* eslint-disable @typescript-eslint/no-explicit-any */ 4 | import useSWR from "swr"; 5 | import Link from "next/link"; 6 | import { EditIcon } from "lucide-react"; 7 | 8 | import { type Workflow } from "@/lib/supabase/types"; 9 | 10 | import { Button } from "@/components/ui/button"; 11 | import { 12 | Card, 13 | CardContent, 14 | CardDescription, 15 | CardFooter, 16 | CardHeader, 17 | CardTitle, 18 | } from "@/components/ui/card"; 19 | import { Label } from "@/components/ui/label"; 20 | import { Switch } from "@/components/ui/switch"; 21 | 22 | import { toggleWorkflow } from "@/app/actions"; 23 | import { fetcher } from "@/lib/utils"; 24 | 25 | export const AutomationList = () => { 26 | const { data } = useSWR<{ workflows: Workflow[] }>( 27 | "/api/workflows", 28 | fetcher, 29 | { refreshInterval: 500 } 30 | ); 31 | 32 | return ( 33 |
34 |
35 |

Automations

36 |
37 |
38 | {(data?.workflows || []).map((workflow) => { 39 | const actions: any[] = (workflow.workflow as any)?.actions || []; 40 | return ( 41 | 42 | 43 | {workflow.name} 44 | {workflow.description} 45 | 46 | 47 |
48 | {actions.length 49 | ? actions.map(({ name, kind }) => name || kind).join(", ") 50 | : "No actions"} 51 |
52 |
53 | 54 |
55 | 59 | toggleWorkflow(workflow.id, !workflow.enabled) 60 | } 61 | /> 62 | 63 |
64 | 69 |
70 |
71 | ); 72 | })} 73 |
74 |
75 | ); 76 | }; 77 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/components/blog-post-actions.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { useCallback } from "react"; 3 | import { useRouter } from "next/navigation"; 4 | import { SaveIcon } from "lucide-react"; 5 | 6 | import { Button } from "@/components/ui/button"; 7 | import { approveBlogPostAiSuggestions, publishBlogPost } from "@/app/actions"; 8 | 9 | export const BlogPostActions = ({ id }: { id: string }) => { 10 | const router = useRouter(); 11 | 12 | const approve = useCallback(async () => { 13 | await approveBlogPostAiSuggestions(id); 14 | router.push("/"); 15 | }, [id, router]); 16 | 17 | const discardAndPublish = useCallback(async () => { 18 | await publishBlogPost(id); 19 | router.push("/"); 20 | }, [id, router]); 21 | 22 | return ( 23 | <> 24 | 27 | 30 | 31 | ); 32 | }; 33 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/components/blog-post-list.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | /* eslint-disable react-hooks/exhaustive-deps */ 3 | 4 | import Link from "next/link"; 5 | import useSWR from "swr"; 6 | import { CalendarIcon, Edit, Eye, RocketIcon } from "lucide-react"; 7 | 8 | import { type BlogPost } from "@/lib/supabase/types"; 9 | 10 | import { 11 | Card, 12 | CardContent, 13 | CardDescription, 14 | CardFooter, 15 | CardHeader, 16 | CardTitle, 17 | } from "@/components/ui/card"; 18 | import { Button } from "@/components/ui/button"; 19 | import { Badge } from "@/components/ui/badge"; 20 | 21 | import { capitalize, fetcher } from "@/lib/utils"; 22 | import { sendBlogPostToReview } from "@/app/actions"; 23 | 24 | export const BlogPostList = () => { 25 | const { data } = useSWR<{ blogPosts: BlogPost[] }>( 26 | "/api/blog-posts", 27 | fetcher, 28 | { refreshInterval: 1000 } 29 | ); 30 | 31 | return ( 32 |
33 |
34 |

Blog Posts

35 |
36 |
37 | {(data?.blogPosts || []).map((blogPost) => ( 38 | 39 | 40 |
41 | {blogPost.title} 42 | {blogPost.subtitle} 43 |
44 | {blogPost.status === "needs approval" && ( 45 |
46 | 47 | An AI revision need approval 48 | 49 |
50 | )} 51 | {blogPost.ai_publishing_recommendations && ( 52 |
53 | 54 | Publishing recommendations are available 55 | 56 |
57 | )} 58 |
59 | 60 |
61 | 62 | {new Date(blogPost.created_at!).toLocaleDateString()} 63 |
64 |
65 | 66 |
73 | {capitalize(blogPost.status!)} 74 |
75 | {blogPost.markdown_ai_revision ? ( 76 | 81 | ) : blogPost.status === "draft" ? ( 82 | 89 | ) : ( 90 | 95 | )} 96 |
97 |
98 | ))} 99 |
100 |
101 | ); 102 | }; 103 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/components/menu.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import { Button } from "@/components/ui/button"; 3 | import { FileTextIcon, ZapIcon } from "lucide-react"; 4 | import { usePathname, useRouter } from "next/navigation"; 5 | 6 | export const Menu = () => { 7 | const pathname = usePathname(); 8 | const router = useRouter(); 9 | return ( 10 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/components/ui/badge.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { cva, type VariantProps } from "class-variance-authority" 3 | 4 | import { cn } from "@/lib/utils" 5 | 6 | const badgeVariants = cva( 7 | "inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", 8 | { 9 | variants: { 10 | variant: { 11 | default: 12 | "border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", 13 | secondary: 14 | "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", 15 | destructive: 16 | "border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80", 17 | outline: "text-foreground", 18 | }, 19 | }, 20 | defaultVariants: { 21 | variant: "default", 22 | }, 23 | } 24 | ) 25 | 26 | export interface BadgeProps 27 | extends React.HTMLAttributes, 28 | VariantProps {} 29 | 30 | function Badge({ className, variant, ...props }: BadgeProps) { 31 | return ( 32 |
33 | ) 34 | } 35 | 36 | export { Badge, badgeVariants } 37 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/components/ui/button.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | import { Slot } from "@radix-ui/react-slot" 3 | import { cva, type VariantProps } from "class-variance-authority" 4 | 5 | import { cn } from "@/lib/utils" 6 | 7 | const buttonVariants = cva( 8 | "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50", 9 | { 10 | variants: { 11 | variant: { 12 | default: 13 | "bg-primary text-primary-foreground shadow hover:bg-primary/90", 14 | destructive: 15 | "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90", 16 | outline: 17 | "border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground", 18 | secondary: 19 | "bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80", 20 | ghost: "hover:bg-accent hover:text-accent-foreground", 21 | link: "text-primary underline-offset-4 hover:underline", 22 | }, 23 | size: { 24 | default: "h-9 px-4 py-2", 25 | sm: "h-8 rounded-md px-3 text-xs", 26 | lg: "h-10 rounded-md px-8", 27 | icon: "h-9 w-9", 28 | }, 29 | }, 30 | defaultVariants: { 31 | variant: "default", 32 | size: "default", 33 | }, 34 | } 35 | ) 36 | 37 | export interface ButtonProps 38 | extends React.ButtonHTMLAttributes, 39 | VariantProps { 40 | asChild?: boolean 41 | } 42 | 43 | const Button = React.forwardRef( 44 | ({ className, variant, size, asChild = false, ...props }, ref) => { 45 | const Comp = asChild ? Slot : "button" 46 | return ( 47 | 52 | ) 53 | } 54 | ) 55 | Button.displayName = "Button" 56 | 57 | export { Button, buttonVariants } 58 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/components/ui/card.tsx: -------------------------------------------------------------------------------- 1 | import * as React from "react" 2 | 3 | import { cn } from "@/lib/utils" 4 | 5 | const Card = React.forwardRef< 6 | HTMLDivElement, 7 | React.HTMLAttributes 8 | >(({ className, ...props }, ref) => ( 9 |
17 | )) 18 | Card.displayName = "Card" 19 | 20 | const CardHeader = React.forwardRef< 21 | HTMLDivElement, 22 | React.HTMLAttributes 23 | >(({ className, ...props }, ref) => ( 24 |
29 | )) 30 | CardHeader.displayName = "CardHeader" 31 | 32 | const CardTitle = React.forwardRef< 33 | HTMLParagraphElement, 34 | React.HTMLAttributes 35 | >(({ className, ...props }, ref) => ( 36 |

41 | )) 42 | CardTitle.displayName = "CardTitle" 43 | 44 | const CardDescription = React.forwardRef< 45 | HTMLParagraphElement, 46 | React.HTMLAttributes 47 | >(({ className, ...props }, ref) => ( 48 |

53 | )) 54 | CardDescription.displayName = "CardDescription" 55 | 56 | const CardContent = React.forwardRef< 57 | HTMLDivElement, 58 | React.HTMLAttributes 59 | >(({ className, ...props }, ref) => ( 60 |

61 | )) 62 | CardContent.displayName = "CardContent" 63 | 64 | const CardFooter = React.forwardRef< 65 | HTMLDivElement, 66 | React.HTMLAttributes 67 | >(({ className, ...props }, ref) => ( 68 |
73 | )) 74 | CardFooter.displayName = "CardFooter" 75 | 76 | export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } 77 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/components/ui/checkbox.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as CheckboxPrimitive from "@radix-ui/react-checkbox" 5 | import { CheckIcon } from "@radix-ui/react-icons" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const Checkbox = React.forwardRef< 10 | React.ElementRef, 11 | React.ComponentPropsWithoutRef 12 | >(({ className, ...props }, ref) => ( 13 | 21 | 24 | 25 | 26 | 27 | )) 28 | Checkbox.displayName = CheckboxPrimitive.Root.displayName 29 | 30 | export { Checkbox } 31 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/components/ui/label.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as LabelPrimitive from "@radix-ui/react-label" 5 | import { cva, type VariantProps } from "class-variance-authority" 6 | 7 | import { cn } from "@/lib/utils" 8 | 9 | const labelVariants = cva( 10 | "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" 11 | ) 12 | 13 | const Label = React.forwardRef< 14 | React.ElementRef, 15 | React.ComponentPropsWithoutRef & 16 | VariantProps 17 | >(({ className, ...props }, ref) => ( 18 | 23 | )) 24 | Label.displayName = LabelPrimitive.Root.displayName 25 | 26 | export { Label } 27 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/components/ui/select.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import { 5 | CaretSortIcon, 6 | CheckIcon, 7 | ChevronDownIcon, 8 | ChevronUpIcon, 9 | } from "@radix-ui/react-icons" 10 | import * as SelectPrimitive from "@radix-ui/react-select" 11 | 12 | import { cn } from "@/lib/utils" 13 | 14 | const Select = SelectPrimitive.Root 15 | 16 | const SelectGroup = SelectPrimitive.Group 17 | 18 | const SelectValue = SelectPrimitive.Value 19 | 20 | const SelectTrigger = React.forwardRef< 21 | React.ElementRef, 22 | React.ComponentPropsWithoutRef 23 | >(({ className, children, ...props }, ref) => ( 24 | span]:line-clamp-1", 28 | className 29 | )} 30 | {...props} 31 | > 32 | {children} 33 | 34 | 35 | 36 | 37 | )) 38 | SelectTrigger.displayName = SelectPrimitive.Trigger.displayName 39 | 40 | const SelectScrollUpButton = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | 53 | 54 | )) 55 | SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName 56 | 57 | const SelectScrollDownButton = React.forwardRef< 58 | React.ElementRef, 59 | React.ComponentPropsWithoutRef 60 | >(({ className, ...props }, ref) => ( 61 | 69 | 70 | 71 | )) 72 | SelectScrollDownButton.displayName = 73 | SelectPrimitive.ScrollDownButton.displayName 74 | 75 | const SelectContent = React.forwardRef< 76 | React.ElementRef, 77 | React.ComponentPropsWithoutRef 78 | >(({ className, children, position = "popper", ...props }, ref) => ( 79 | 80 | 91 | 92 | 99 | {children} 100 | 101 | 102 | 103 | 104 | )) 105 | SelectContent.displayName = SelectPrimitive.Content.displayName 106 | 107 | const SelectLabel = React.forwardRef< 108 | React.ElementRef, 109 | React.ComponentPropsWithoutRef 110 | >(({ className, ...props }, ref) => ( 111 | 116 | )) 117 | SelectLabel.displayName = SelectPrimitive.Label.displayName 118 | 119 | const SelectItem = React.forwardRef< 120 | React.ElementRef, 121 | React.ComponentPropsWithoutRef 122 | >(({ className, children, ...props }, ref) => ( 123 | 131 | 132 | 133 | 134 | 135 | 136 | {children} 137 | 138 | )) 139 | SelectItem.displayName = SelectPrimitive.Item.displayName 140 | 141 | const SelectSeparator = React.forwardRef< 142 | React.ElementRef, 143 | React.ComponentPropsWithoutRef 144 | >(({ className, ...props }, ref) => ( 145 | 150 | )) 151 | SelectSeparator.displayName = SelectPrimitive.Separator.displayName 152 | 153 | export { 154 | Select, 155 | SelectGroup, 156 | SelectValue, 157 | SelectTrigger, 158 | SelectContent, 159 | SelectLabel, 160 | SelectItem, 161 | SelectSeparator, 162 | SelectScrollUpButton, 163 | SelectScrollDownButton, 164 | } 165 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/components/ui/switch.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | 3 | import * as React from 'react'; 4 | import * as SwitchPrimitives from '@radix-ui/react-switch'; 5 | 6 | import { cn } from '@/lib/utils'; 7 | 8 | const Switch = React.forwardRef< 9 | React.ElementRef, 10 | React.ComponentPropsWithoutRef 11 | >(({ className, ...props }, ref) => ( 12 | 20 | 26 | 27 | )); 28 | Switch.displayName = SwitchPrimitives.Root.displayName; 29 | 30 | export { Switch }; 31 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/components/ui/tabs.tsx: -------------------------------------------------------------------------------- 1 | "use client" 2 | 3 | import * as React from "react" 4 | import * as TabsPrimitive from "@radix-ui/react-tabs" 5 | 6 | import { cn } from "@/lib/utils" 7 | 8 | const Tabs = TabsPrimitive.Root 9 | 10 | const TabsList = React.forwardRef< 11 | React.ElementRef, 12 | React.ComponentPropsWithoutRef 13 | >(({ className, ...props }, ref) => ( 14 | 22 | )) 23 | TabsList.displayName = TabsPrimitive.List.displayName 24 | 25 | const TabsTrigger = React.forwardRef< 26 | React.ElementRef, 27 | React.ComponentPropsWithoutRef 28 | >(({ className, ...props }, ref) => ( 29 | 37 | )) 38 | TabsTrigger.displayName = TabsPrimitive.Trigger.displayName 39 | 40 | const TabsContent = React.forwardRef< 41 | React.ElementRef, 42 | React.ComponentPropsWithoutRef 43 | >(({ className, ...props }, ref) => ( 44 | 52 | )) 53 | TabsContent.displayName = TabsPrimitive.Content.displayName 54 | 55 | export { Tabs, TabsList, TabsTrigger, TabsContent } 56 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/lib/inngest/client.ts: -------------------------------------------------------------------------------- 1 | import { Inngest } from "inngest"; 2 | 3 | export const inngest = new Inngest({ 4 | id: "workflow-kit-next-demo", 5 | }); 6 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/lib/inngest/workflow.ts: -------------------------------------------------------------------------------- 1 | import { Engine } from "@inngest/workflow-kit"; 2 | 3 | import { loadWorkflow } from "../loaders/workflow"; 4 | import { inngest } from "./client"; 5 | import { actionsWithHandlers } from "./workflowActionHandlers"; 6 | 7 | const workflowEngine = new Engine({ 8 | actions: actionsWithHandlers, 9 | loader: loadWorkflow, 10 | }); 11 | 12 | export default inngest.createFunction( 13 | { id: "blog-post-workflow" }, 14 | // Triggers 15 | // - When a blog post is set to "review" 16 | // - When a blog post is published 17 | [{ event: "blog-post.updated" }, { event: "blog-post.published" }], 18 | async ({ event, step }) => { 19 | // When `run` is called, the loader function is called with access to the event 20 | await workflowEngine.run({ event, step }); 21 | } 22 | ); 23 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/lib/inngest/workflowActionHandlers.ts: -------------------------------------------------------------------------------- 1 | import { type EngineAction, type WorkflowAction } from "@inngest/workflow-kit"; 2 | import OpenAI from "openai"; 3 | 4 | import { type BlogPost } from "../supabase/types"; 5 | 6 | import { loadBlogPost } from "../loaders/blog-post"; 7 | import { createClient } from "../supabase/server"; 8 | import { actions } from "./workflowActions"; 9 | import { inngest } from "./client"; 10 | 11 | // helper to ensure that each step of the workflow use 12 | // the original content or current AI revision 13 | function getAIworkingCopy(workflowAction: WorkflowAction, blogPost: BlogPost) { 14 | return workflowAction.id === "1" // the first action of the workflow gets assigned id: "1" 15 | ? blogPost.markdown // if we are the first action, we use the initial content 16 | : blogPost.markdown_ai_revision || blogPost.markdown; // otherwise we use the previous current ai revision 17 | } 18 | 19 | // helper to ensure that each step of the workflow use 20 | // the original content or current AI revision 21 | function addAiPublishingSuggestion( 22 | workflowAction: WorkflowAction, 23 | blogPost: BlogPost, 24 | additionalSuggestion: string 25 | ) { 26 | return workflowAction.id === "1" // the first action of the workflow gets assigned id: "1" 27 | ? additionalSuggestion // if we are the first action, we reset the suggestions 28 | : blogPost.ai_publishing_recommendations + `
` + additionalSuggestion; // otherwise add one 29 | } 30 | 31 | export const actionsWithHandlers: EngineAction[] = [ 32 | { 33 | // Add a Table of Contents 34 | ...actions[0], 35 | handler: async ({ event, step, workflowAction }) => { 36 | const supabase = createClient(); 37 | 38 | const blogPost = await step.run("load-blog-post", async () => 39 | loadBlogPost(event.data.id) 40 | ); 41 | 42 | const aiRevision = await step.run("add-toc-to-article", async () => { 43 | const openai = new OpenAI({ 44 | apiKey: process.env["OPENAI_API_KEY"], // This is the default and can be omitted 45 | }); 46 | 47 | const prompt = ` 48 | Please update the below markdown article by adding a Table of Content under the h1 title. Return only the complete updated article in markdown without the wrapping "\`\`\`". 49 | 50 | Here is the text wrapped with "\`\`\`": 51 | \`\`\` 52 | ${getAIworkingCopy(workflowAction, blogPost)} 53 | \`\`\` 54 | `; 55 | 56 | const response = await openai.chat.completions.create({ 57 | model: process.env["OPENAI_MODEL"] || "gpt-3.5-turbo", 58 | messages: [ 59 | { 60 | role: "system", 61 | content: "You are an AI that make text editing changes.", 62 | }, 63 | { 64 | role: "user", 65 | content: prompt, 66 | }, 67 | ], 68 | }); 69 | 70 | return response.choices[0]?.message?.content || ""; 71 | }); 72 | 73 | await step.run("save-ai-revision", async () => { 74 | await supabase 75 | .from("blog_posts") 76 | .update({ 77 | markdown_ai_revision: aiRevision, 78 | status: "under review", 79 | }) 80 | .eq("id", event.data.id) 81 | .select("*"); 82 | }); 83 | }, 84 | }, 85 | { 86 | // Perform a grammar review 87 | ...actions[1], 88 | handler: async ({ event, step, workflowAction }) => { 89 | const supabase = createClient(); 90 | 91 | const blogPost = await step.run("load-blog-post", async () => 92 | loadBlogPost(event.data.id) 93 | ); 94 | 95 | const aiRevision = await step.run("get-ai-grammar-fixes", async () => { 96 | const openai = new OpenAI({ 97 | apiKey: process.env["OPENAI_API_KEY"], // This is the default and can be omitted 98 | }); 99 | 100 | const prompt = ` 101 | You are my "Hemmingway editor" AI. Please update the below article with some grammar fixes. Return only the complete updated article in markdown without the wrapping "\`\`\`". 102 | 103 | Here is the text wrapped with "\`\`\`": 104 | \`\`\` 105 | ${getAIworkingCopy(workflowAction, blogPost)} 106 | \`\`\` 107 | `; 108 | 109 | const response = await openai.chat.completions.create({ 110 | model: process.env["OPENAI_MODEL"] || "gpt-3.5-turbo", 111 | messages: [ 112 | { 113 | role: "system", 114 | content: "You are an AI that make text editing changes.", 115 | }, 116 | { 117 | role: "user", 118 | content: prompt, 119 | }, 120 | ], 121 | }); 122 | 123 | return response.choices[0]?.message?.content || ""; 124 | }); 125 | 126 | await step.run("save-ai-revision", async () => { 127 | await supabase 128 | .from("blog_posts") 129 | .update({ 130 | markdown_ai_revision: aiRevision, 131 | status: "under review", 132 | }) 133 | .eq("id", event.data.id) 134 | .select("*"); 135 | }); 136 | }, 137 | }, 138 | { 139 | // Apply changes after approval 140 | ...actions[2], 141 | handler: async ({ event, step }) => { 142 | const supabase = createClient(); 143 | 144 | const blogPost = await step.run("load-blog-post", async () => 145 | loadBlogPost(event.data.id) 146 | ); 147 | 148 | await step.run("update-blog-post-status", async () => { 149 | await supabase 150 | .from("blog_posts") 151 | .update({ 152 | status: "needs approval", 153 | }) 154 | .eq("id", event.data.id) 155 | .select("*"); 156 | }); 157 | 158 | // wait for the user to approve or discard the AI suggestions 159 | const approval = await step.waitForEvent( 160 | "wait-for-ai-suggestion-approval", 161 | { 162 | event: "blog-post.approve-ai-suggestions", 163 | timeout: "1d", 164 | match: "data.id", 165 | } 166 | ); 167 | 168 | // without action from the user within 1 day, the AI suggestions are discarded 169 | if (!approval) { 170 | await step.run("discard-ai-revision", async () => { 171 | await supabase 172 | .from("blog_posts") 173 | .update({ 174 | markdown_ai_revision: null, 175 | status: "draft", 176 | }) 177 | .eq("id", event.data.id) 178 | .select("*"); 179 | }); 180 | } else { 181 | await step.run("apply-ai-revision", async () => { 182 | await supabase 183 | .from("blog_posts") 184 | .update({ 185 | markdown: blogPost.markdown_ai_revision, 186 | markdown_ai_revision: null, 187 | status: "published", 188 | }) 189 | .eq("id", blogPost.id) 190 | .select("*"); 191 | }); 192 | } 193 | }, 194 | }, 195 | { 196 | // Apply changes 197 | ...actions[3], 198 | handler: async ({ event, step }) => { 199 | const supabase = createClient(); 200 | 201 | const blogPost = await step.run("load-blog-post", async () => 202 | loadBlogPost(event.data.id) 203 | ); 204 | 205 | await step.run("apply-ai-revision", async () => { 206 | await supabase 207 | .from("blog_posts") 208 | .update({ 209 | markdown: blogPost.markdown_ai_revision, 210 | markdown_ai_revision: null, 211 | status: "published", 212 | }) 213 | .eq("id", blogPost.id) 214 | .select("*"); 215 | }); 216 | }, 217 | }, 218 | { 219 | // Generate LinkedIn posts 220 | ...actions[4], 221 | handler: async ({ event, step, workflowAction }) => { 222 | const supabase = createClient(); 223 | 224 | const blogPost = await step.run("load-blog-post", async () => 225 | loadBlogPost(event.data.id) 226 | ); 227 | 228 | const aiRecommendations = await step.run( 229 | "generate-linked-posts", 230 | async () => { 231 | const openai = new OpenAI({ 232 | apiKey: process.env["OPENAI_API_KEY"], // This is the default and can be omitted 233 | }); 234 | 235 | const prompt = ` 236 | Generate a LinkedIn post that will drive traffic to the below blog post. 237 | Keep the a profesionnal tone, do not use emojis. 238 | 239 | Here is the blog post text wrapped with "\`\`\`": 240 | \`\`\` 241 | ${getAIworkingCopy(workflowAction, blogPost)} 242 | \`\`\` 243 | `; 244 | 245 | const response = await openai.chat.completions.create({ 246 | model: process.env["OPENAI_MODEL"] || "gpt-3.5-turbo", 247 | messages: [ 248 | { 249 | role: "system", 250 | content: "You are an Developer Marketing expert.", 251 | }, 252 | { 253 | role: "user", 254 | content: prompt, 255 | }, 256 | ], 257 | }); 258 | 259 | return response.choices[0]?.message?.content || ""; 260 | } 261 | ); 262 | 263 | await step.run("save-ai-recommendations", async () => { 264 | await supabase 265 | .from("blog_posts") 266 | .update({ 267 | ai_publishing_recommendations: addAiPublishingSuggestion( 268 | workflowAction, 269 | blogPost, 270 | `\n## LinkedIn posts: \n
${aiRecommendations}
` 271 | ), 272 | }) 273 | .eq("id", event.data.id) 274 | .select("*"); 275 | }); 276 | }, 277 | }, 278 | { 279 | // Generate Twitter posts 280 | ...actions[5], 281 | handler: async ({ event, step, workflowAction }) => { 282 | const supabase = createClient(); 283 | const numberOfTweets = 2; 284 | 285 | const blogPost = await step.run("load-blog-post", async () => 286 | loadBlogPost(event.data.id) 287 | ); 288 | 289 | const aiRecommendations = await step.run("generate-tweets", async () => { 290 | const openai = new OpenAI({ 291 | apiKey: process.env["OPENAI_API_KEY"], // This is the default and can be omitted 292 | }); 293 | 294 | const prompt = ` 295 | Generate ${numberOfTweets} tweets to announce the blog post. 296 | Keep the tone friendly, feel free to use emojis, and, if relevant, use bullet points teasing the main takeaways of the blog post. 297 | Prefix each tweet with "----- Tweet number {tweet number} -----
" 298 | 299 | Here is the blog post text wrapped with "\`\`\`": 300 | \`\`\` 301 | ${blogPost.markdown} 302 | \`\`\` 303 | `; 304 | 305 | const response = await openai.chat.completions.create({ 306 | model: process.env["OPENAI_MODEL"] || "gpt-3.5-turbo", 307 | messages: [ 308 | { 309 | role: "system", 310 | content: "You are an Developer Marketing expert.", 311 | }, 312 | { 313 | role: "user", 314 | content: prompt, 315 | }, 316 | ], 317 | }); 318 | 319 | return response.choices[0]?.message?.content || ""; 320 | }); 321 | 322 | await step.run("save-ai-recommendations", async () => { 323 | await supabase 324 | .from("blog_posts") 325 | .update({ 326 | ai_publishing_recommendations: addAiPublishingSuggestion( 327 | workflowAction, 328 | blogPost, 329 | `\n## Twitter posts: \n
${aiRecommendations}
` 330 | ), 331 | }) 332 | .eq("id", event.data.id) 333 | .select("*"); 334 | }); 335 | }, 336 | }, 337 | ]; 338 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/lib/inngest/workflowActions.ts: -------------------------------------------------------------------------------- 1 | import { type PublicEngineAction } from "@inngest/workflow-kit"; 2 | 3 | // Actions 4 | // - Review actions 5 | // - Add ToC to the article 6 | // - Add grammar suggestions 7 | // - [Apply changes] 8 | // - [Apply changes after approval] 9 | // - Post-publish actions 10 | // - Get Tweet verbatim 11 | // - Get LinkedIn verbatim 12 | export const actions: PublicEngineAction[] = [ 13 | { 14 | kind: "add_ToC", 15 | name: "Add a Table of Contents", 16 | description: "Add an AI-generated ToC", 17 | }, 18 | { 19 | kind: "grammar_review", 20 | name: "Perform a grammar review", 21 | description: "Use OpenAI for grammar fixes", 22 | }, 23 | { 24 | kind: "wait_for_approval", 25 | name: "Apply changes after approval", 26 | description: "Request approval for changes", 27 | }, 28 | { 29 | kind: "apply_changes", 30 | name: "Apply changes", 31 | description: "Save the AI revisions", 32 | }, 33 | { 34 | kind: "generate_linkedin_posts", 35 | name: "Generate LinkedIn posts", 36 | description: "Generate LinkedIn posts", 37 | }, 38 | { 39 | kind: "generate_tweet_posts", 40 | name: "Generate Twitter posts", 41 | description: "Generate Twitter posts", 42 | }, 43 | ]; 44 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/lib/loaders/blog-post.ts: -------------------------------------------------------------------------------- 1 | import { createClient } from "../supabase/server"; 2 | import { BlogPost } from "../supabase/types"; 3 | 4 | export async function loadBlogPost(id: string): Promise { 5 | const supabase = createClient(); 6 | const { data: blogPosts } = await supabase 7 | .from("blog_posts") 8 | .select( 9 | "id, title, subtitle, markdown_ai_revision, created_at, status, markdown, ai_publishing_recommendations" 10 | ) 11 | .eq("id", id) 12 | .limit(1); 13 | 14 | const blogPost = blogPosts && blogPosts[0]; 15 | 16 | if (!blogPost) { 17 | throw new Error(`Blog post #${id} not found`); 18 | } 19 | 20 | return blogPost; 21 | } 22 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/lib/loaders/workflow.ts: -------------------------------------------------------------------------------- 1 | import { Workflow } from "@inngest/workflow-kit"; 2 | 3 | import { createClient } from "../supabase/server"; 4 | 5 | export async function loadWorkflow(event: { name: string }) { 6 | const supabase = createClient(); 7 | const { data } = await supabase 8 | .from("workflows") 9 | .select("*", {}) 10 | .eq("trigger", event.name) 11 | .eq("enabled", true) 12 | .single(); 13 | return (data && data.workflow) as unknown as Workflow; 14 | } 15 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/lib/mdxComponents.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | export const mdxComponents: any = { 3 | h1: ({ children, ...props }: any) => ( 4 |

8 | {children} 9 |

10 | ), 11 | h2: ({ children, ...props }: any) => ( 12 |

16 | {children} 17 |

18 | ), 19 | p: ({ children, ...props }: any) => ( 20 |

21 | {children} 22 |

23 | ), 24 | ul: ({ children, ...props }: any) => ( 25 |
    26 | {children} 27 |
28 | ), 29 | li: ({ children, ...props }: any) => ( 30 |
  • 31 | {children} 32 |
  • 33 | ), 34 | a: ({ children, ...props }: any) => ( 35 | 39 | {children} 40 | 41 | ), 42 | }; 43 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/lib/supabase/client.ts: -------------------------------------------------------------------------------- 1 | import { createBrowserClient } from "@supabase/ssr"; 2 | import { Database } from "./types"; 3 | 4 | export function createClient() { 5 | return createBrowserClient( 6 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 7 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/lib/supabase/database.types.ts: -------------------------------------------------------------------------------- 1 | export type Json = 2 | | string 3 | | number 4 | | boolean 5 | | null 6 | | { [key: string]: Json | undefined } 7 | | Json[]; 8 | 9 | export type Database = { 10 | public: { 11 | Tables: { 12 | blog_posts: { 13 | Row: { 14 | ai_publishing_recommendations: string | null; 15 | created_at: string | null; 16 | id: number; 17 | markdown: string | null; 18 | markdown_ai_revision: string | null; 19 | status: string | null; 20 | subtitle: string | null; 21 | title: string | null; 22 | }; 23 | Insert: { 24 | ai_publishing_recommendations?: string | null; 25 | created_at?: string | null; 26 | id?: number; 27 | markdown?: string | null; 28 | markdown_ai_revision?: string | null; 29 | status?: string | null; 30 | subtitle?: string | null; 31 | title?: string | null; 32 | }; 33 | Update: { 34 | ai_publishing_recommendations?: string | null; 35 | created_at?: string | null; 36 | id?: number; 37 | markdown?: string | null; 38 | markdown_ai_revision?: string | null; 39 | status?: string | null; 40 | subtitle?: string | null; 41 | title?: string | null; 42 | }; 43 | Relationships: []; 44 | }; 45 | workflows: { 46 | Row: { 47 | created_at: string; 48 | description: string | null; 49 | enabled: boolean | null; 50 | id: number; 51 | name: string | null; 52 | trigger: string | null; 53 | workflow: Json | null; 54 | }; 55 | Insert: { 56 | created_at?: string; 57 | description?: string | null; 58 | enabled?: boolean | null; 59 | id?: number; 60 | name?: string | null; 61 | trigger?: string | null; 62 | workflow?: Json | null; 63 | }; 64 | Update: { 65 | created_at?: string; 66 | description?: string | null; 67 | enabled?: boolean | null; 68 | id?: number; 69 | name?: string | null; 70 | trigger?: string | null; 71 | workflow?: Json | null; 72 | }; 73 | Relationships: []; 74 | }; 75 | }; 76 | Views: { 77 | [_ in never]: never; 78 | }; 79 | Functions: { 80 | [_ in never]: never; 81 | }; 82 | Enums: { 83 | [_ in never]: never; 84 | }; 85 | CompositeTypes: { 86 | [_ in never]: never; 87 | }; 88 | }; 89 | }; 90 | 91 | type PublicSchema = Database[Extract]; 92 | 93 | export type Tables< 94 | PublicTableNameOrOptions extends 95 | | keyof (PublicSchema["Tables"] & PublicSchema["Views"]) 96 | | { schema: keyof Database }, 97 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 98 | ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] & 99 | Database[PublicTableNameOrOptions["schema"]]["Views"]) 100 | : never = never 101 | > = PublicTableNameOrOptions extends { schema: keyof Database } 102 | ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] & 103 | Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends { 104 | Row: infer R; 105 | } 106 | ? R 107 | : never 108 | : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & 109 | PublicSchema["Views"]) 110 | ? (PublicSchema["Tables"] & 111 | PublicSchema["Views"])[PublicTableNameOrOptions] extends { 112 | Row: infer R; 113 | } 114 | ? R 115 | : never 116 | : never; 117 | 118 | export type TablesInsert< 119 | PublicTableNameOrOptions extends 120 | | keyof PublicSchema["Tables"] 121 | | { schema: keyof Database }, 122 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 123 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] 124 | : never = never 125 | > = PublicTableNameOrOptions extends { schema: keyof Database } 126 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { 127 | Insert: infer I; 128 | } 129 | ? I 130 | : never 131 | : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] 132 | ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { 133 | Insert: infer I; 134 | } 135 | ? I 136 | : never 137 | : never; 138 | 139 | export type TablesUpdate< 140 | PublicTableNameOrOptions extends 141 | | keyof PublicSchema["Tables"] 142 | | { schema: keyof Database }, 143 | TableName extends PublicTableNameOrOptions extends { schema: keyof Database } 144 | ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] 145 | : never = never 146 | > = PublicTableNameOrOptions extends { schema: keyof Database } 147 | ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { 148 | Update: infer U; 149 | } 150 | ? U 151 | : never 152 | : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] 153 | ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { 154 | Update: infer U; 155 | } 156 | ? U 157 | : never 158 | : never; 159 | 160 | export type Enums< 161 | PublicEnumNameOrOptions extends 162 | | keyof PublicSchema["Enums"] 163 | | { schema: keyof Database }, 164 | EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } 165 | ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"] 166 | : never = never 167 | > = PublicEnumNameOrOptions extends { schema: keyof Database } 168 | ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName] 169 | : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] 170 | ? PublicSchema["Enums"][PublicEnumNameOrOptions] 171 | : never; 172 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/lib/supabase/server.ts: -------------------------------------------------------------------------------- 1 | import { createServerClient } from "@supabase/ssr"; 2 | import { cookies } from "next/headers"; 3 | import { Database } from "./types"; 4 | 5 | export function createClient() { 6 | const cookieStore = cookies(); 7 | 8 | return createServerClient( 9 | process.env.NEXT_PUBLIC_SUPABASE_URL!, 10 | process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, 11 | { 12 | cookies: { 13 | getAll() { 14 | return cookieStore.getAll(); 15 | }, 16 | setAll(cookiesToSet) { 17 | try { 18 | cookiesToSet.forEach(({ name, value, options }) => 19 | cookieStore.set(name, value, options) 20 | ); 21 | } catch { 22 | // The `setAll` method was called from a Server Component. 23 | // This can be ignored if you have middleware refreshing 24 | // user sessions. 25 | } 26 | }, 27 | }, 28 | } 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/lib/supabase/types.ts: -------------------------------------------------------------------------------- 1 | import { type Workflow as InngestWorkflow } from "@inngest/workflow-kit"; 2 | import { Database as SourceDatabase } from "./database.types"; 3 | 4 | // typing `workflows.workflow` Json field 5 | export type Database = { 6 | public: { 7 | Tables: Omit & { 8 | workflows: Omit< 9 | SourceDatabase["public"]["Tables"]["workflows"], 10 | "Row" 11 | > & { 12 | Row: Omit< 13 | SourceDatabase["public"]["Tables"]["workflows"]["Row"], 14 | "workflow" 15 | > & { 16 | workflow: InngestWorkflow; 17 | }; 18 | }; 19 | }; 20 | }; 21 | }; 22 | 23 | export type Workflow = Database["public"]["Tables"]["workflows"]["Row"]; 24 | export type BlogPost = Database["public"]["Tables"]["blog_posts"]["Row"]; 25 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { clsx, type ClassValue } from "clsx"; 2 | import { twMerge } from "tailwind-merge"; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | 8 | export function capitalize(str: string) { 9 | return str.charAt(0).toUpperCase() + str.slice(1); 10 | } 11 | 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | export const fetcher = (...args: [any]) => 14 | fetch(...args).then((res) => res.json()); 15 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "next-js-demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "@inngest/workflow-kit": "0.1.3-alpha-20240930224141-9a7240323ec48bf95e5725173ab63ae424d76e6b", 13 | "@mdx-js/mdx": "^3.0.1", 14 | "@radix-ui/react-checkbox": "^1.1.1", 15 | "@radix-ui/react-icons": "^1.3.0", 16 | "@radix-ui/react-label": "^2.1.0", 17 | "@radix-ui/react-select": "^2.1.1", 18 | "@radix-ui/react-slot": "^1.1.0", 19 | "@radix-ui/react-switch": "^1.1.0", 20 | "@radix-ui/react-tabs": "^1.1.0", 21 | "@supabase/ssr": "^0.5.1", 22 | "@supabase/supabase-js": "^2.45.4", 23 | "class-variance-authority": "^0.7.0", 24 | "clsx": "^2.1.1", 25 | "inngest": "^3.19.14", 26 | "lucide-react": "^0.441.0", 27 | "next": "14.2.11", 28 | "next-mdx-remote": "^5.0.0", 29 | "openai": "^4.61.0", 30 | "react": "^18", 31 | "react-dom": "^18", 32 | "remark": "^15.0.1", 33 | "remark-html": "^16.0.1", 34 | "swr": "^2.2.5", 35 | "tailwind-merge": "^2.5.2", 36 | "tailwindcss-animate": "^1.0.7" 37 | }, 38 | "devDependencies": { 39 | "@types/node": "^20", 40 | "@types/react": "^18", 41 | "@types/react-dom": "^18", 42 | "eslint": "^8", 43 | "eslint-config-next": "14.2.11", 44 | "postcss": "^8", 45 | "tailwindcss": "^3.4.1", 46 | "typescript": "^5" 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/supabase/.gitignore: -------------------------------------------------------------------------------- 1 | # Supabase 2 | .branches 3 | .temp 4 | .env 5 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/supabase/config.toml: -------------------------------------------------------------------------------- 1 | # A string used to distinguish different Supabase projects on the same host. Defaults to the 2 | # working directory name when running `supabase init`. 3 | project_id = "next-js-demo" 4 | 5 | [api] 6 | enabled = true 7 | # Port to use for the API URL. 8 | port = 54321 9 | # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API 10 | # endpoints. `public` is always included. 11 | schemas = ["public", "graphql_public"] 12 | # Extra schemas to add to the search_path of every request. `public` is always included. 13 | extra_search_path = ["public", "extensions"] 14 | # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size 15 | # for accidental or malicious requests. 16 | max_rows = 1000 17 | 18 | [api.tls] 19 | enabled = false 20 | 21 | [db] 22 | # Port to use for the local database URL. 23 | port = 54322 24 | # Port used by db diff command to initialize the shadow database. 25 | shadow_port = 54320 26 | # The database major version to use. This has to be the same as your remote database's. Run `SHOW 27 | # server_version;` on the remote database to check. 28 | major_version = 15 29 | 30 | [db.pooler] 31 | enabled = false 32 | # Port to use for the local connection pooler. 33 | port = 54329 34 | # Specifies when a server connection can be reused by other clients. 35 | # Configure one of the supported pooler modes: `transaction`, `session`. 36 | pool_mode = "transaction" 37 | # How many server connections to allow per user/database pair. 38 | default_pool_size = 20 39 | # Maximum number of client connections allowed. 40 | max_client_conn = 100 41 | 42 | [realtime] 43 | enabled = true 44 | # Bind realtime via either IPv4 or IPv6. (default: IPv4) 45 | # ip_version = "IPv6" 46 | # The maximum length in bytes of HTTP request headers. (default: 4096) 47 | # max_header_length = 4096 48 | 49 | [studio] 50 | enabled = true 51 | # Port to use for Supabase Studio. 52 | port = 54323 53 | # External URL of the API server that frontend connects to. 54 | api_url = "http://127.0.0.1" 55 | # OpenAI API Key to use for Supabase AI in the Supabase Studio. 56 | openai_api_key = "env(OPENAI_API_KEY)" 57 | 58 | # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they 59 | # are monitored, and you can view the emails that would have been sent from the web interface. 60 | [inbucket] 61 | enabled = true 62 | # Port to use for the email testing server web interface. 63 | port = 54324 64 | # Uncomment to expose additional ports for testing user applications that send emails. 65 | # smtp_port = 54325 66 | # pop3_port = 54326 67 | 68 | [storage] 69 | enabled = true 70 | # The maximum file size allowed (e.g. "5MB", "500KB"). 71 | file_size_limit = "50MiB" 72 | 73 | [storage.image_transformation] 74 | enabled = true 75 | 76 | # Uncomment to configure local storage buckets 77 | # [storage.buckets.images] 78 | # public = false 79 | # file_size_limit = "50MiB" 80 | # allowed_mime_types = ["image/png", "image/jpeg"] 81 | # objects_path = "./images" 82 | 83 | [auth] 84 | enabled = true 85 | # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used 86 | # in emails. 87 | site_url = "http://127.0.0.1:3000" 88 | # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. 89 | additional_redirect_urls = ["https://127.0.0.1:3000"] 90 | # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). 91 | jwt_expiry = 3600 92 | # If disabled, the refresh token will never expire. 93 | enable_refresh_token_rotation = true 94 | # Allows refresh tokens to be reused after expiry, up to the specified interval in seconds. 95 | # Requires enable_refresh_token_rotation = true. 96 | refresh_token_reuse_interval = 10 97 | # Allow/disallow new user signups to your project. 98 | enable_signup = true 99 | # Allow/disallow anonymous sign-ins to your project. 100 | enable_anonymous_sign_ins = false 101 | # Allow/disallow testing manual linking of accounts 102 | enable_manual_linking = false 103 | 104 | [auth.email] 105 | # Allow/disallow new user signups via email to your project. 106 | enable_signup = true 107 | # If enabled, a user will be required to confirm any email change on both the old, and new email 108 | # addresses. If disabled, only the new email is required to confirm. 109 | double_confirm_changes = true 110 | # If enabled, users need to confirm their email address before signing in. 111 | enable_confirmations = false 112 | # If enabled, users will need to reauthenticate or have logged in recently to change their password. 113 | secure_password_change = false 114 | # Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email. 115 | max_frequency = "1s" 116 | 117 | # Use a production-ready SMTP server 118 | # [auth.email.smtp] 119 | # host = "smtp.sendgrid.net" 120 | # port = 587 121 | # user = "apikey" 122 | # pass = "env(SENDGRID_API_KEY)" 123 | # admin_email = "admin@email.com" 124 | # sender_name = "Admin" 125 | 126 | # Uncomment to customize email template 127 | # [auth.email.template.invite] 128 | # subject = "You have been invited" 129 | # content_path = "./supabase/templates/invite.html" 130 | 131 | [auth.sms] 132 | # Allow/disallow new user signups via SMS to your project. 133 | enable_signup = true 134 | # If enabled, users need to confirm their phone number before signing in. 135 | enable_confirmations = false 136 | # Template for sending OTP to users 137 | template = "Your code is {{ .Code }} ." 138 | # Controls the minimum amount of time that must pass before sending another sms otp. 139 | max_frequency = "5s" 140 | 141 | # Use pre-defined map of phone number to OTP for testing. 142 | # [auth.sms.test_otp] 143 | # 4152127777 = "123456" 144 | 145 | # Configure logged in session timeouts. 146 | # [auth.sessions] 147 | # Force log out after the specified duration. 148 | # timebox = "24h" 149 | # Force log out if the user has been inactive longer than the specified duration. 150 | # inactivity_timeout = "8h" 151 | 152 | # This hook runs before a token is issued and allows you to add additional claims based on the authentication method used. 153 | # [auth.hook.custom_access_token] 154 | # enabled = true 155 | # uri = "pg-functions:////" 156 | 157 | # Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`. 158 | [auth.sms.twilio] 159 | enabled = false 160 | account_sid = "" 161 | message_service_sid = "" 162 | # DO NOT commit your Twilio auth token to git. Use environment variable substitution instead: 163 | auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" 164 | 165 | [auth.mfa] 166 | # Control how many MFA factors can be enrolled at once per user. 167 | max_enrolled_factors = 10 168 | 169 | # Control use of MFA via App Authenticator (TOTP) 170 | [auth.mfa.totp] 171 | enroll_enabled = true 172 | verify_enabled = true 173 | 174 | # Configure Multi-factor-authentication via Phone Messaging 175 | # [auth.mfa.phone] 176 | # enroll_enabled = true 177 | # verify_enabled = true 178 | # otp_length = 6 179 | # template = "Your code is {{ .Code }} ." 180 | # max_frequency = "10s" 181 | 182 | # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, 183 | # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`, 184 | # `twitter`, `slack`, `spotify`, `workos`, `zoom`. 185 | [auth.external.apple] 186 | enabled = false 187 | client_id = "" 188 | # DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: 189 | secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" 190 | # Overrides the default auth redirectUrl. 191 | redirect_uri = "" 192 | # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, 193 | # or any other third-party OIDC providers. 194 | url = "" 195 | # If enabled, the nonce check will be skipped. Required for local sign in with Google auth. 196 | skip_nonce_check = false 197 | 198 | # Use Firebase Auth as a third-party provider alongside Supabase Auth. 199 | [auth.third_party.firebase] 200 | enabled = false 201 | # project_id = "my-firebase-project" 202 | 203 | # Use Auth0 as a third-party provider alongside Supabase Auth. 204 | [auth.third_party.auth0] 205 | enabled = false 206 | # tenant = "my-auth0-tenant" 207 | # tenant_region = "us" 208 | 209 | # Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth. 210 | [auth.third_party.aws_cognito] 211 | enabled = false 212 | # user_pool_id = "my-user-pool-id" 213 | # user_pool_region = "us-east-1" 214 | 215 | [edge_runtime] 216 | enabled = true 217 | # Configure one of the supported request policies: `oneshot`, `per_worker`. 218 | # Use `oneshot` for hot reload, or `per_worker` for load testing. 219 | policy = "oneshot" 220 | inspector_port = 8083 221 | 222 | [analytics] 223 | enabled = true 224 | port = 54327 225 | # Configure one of the supported backends: `postgres`, `bigquery`. 226 | backend = "postgres" 227 | 228 | # Experimental features may be deprecated any time 229 | [experimental] 230 | # Configures Postgres storage engine to use OrioleDB (S3) 231 | orioledb_version = "" 232 | # Configures S3 bucket URL, eg. .s3-.amazonaws.com 233 | s3_host = "env(S3_HOST)" 234 | # Configures S3 bucket region, eg. us-east-1 235 | s3_region = "env(S3_REGION)" 236 | # Configures AWS_ACCESS_KEY_ID for S3 bucket 237 | s3_access_key = "env(S3_ACCESS_KEY)" 238 | # Configures AWS_SECRET_ACCESS_KEY for S3 bucket 239 | s3_secret_key = "env(S3_SECRET_KEY)" 240 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/supabase/schema.sql: -------------------------------------------------------------------------------- 1 | 2 | SET statement_timeout = 0; 3 | SET lock_timeout = 0; 4 | SET idle_in_transaction_session_timeout = 0; 5 | SET client_encoding = 'UTF8'; 6 | SET standard_conforming_strings = on; 7 | SELECT pg_catalog.set_config('search_path', '', false); 8 | SET check_function_bodies = false; 9 | SET xmloption = content; 10 | SET client_min_messages = warning; 11 | SET row_security = off; 12 | 13 | CREATE EXTENSION IF NOT EXISTS "pgsodium" WITH SCHEMA "pgsodium"; 14 | 15 | COMMENT ON SCHEMA "public" IS 'standard public schema'; 16 | 17 | CREATE EXTENSION IF NOT EXISTS "pg_graphql" WITH SCHEMA "graphql"; 18 | 19 | CREATE EXTENSION IF NOT EXISTS "pg_stat_statements" WITH SCHEMA "extensions"; 20 | 21 | CREATE EXTENSION IF NOT EXISTS "pgcrypto" WITH SCHEMA "extensions"; 22 | 23 | CREATE EXTENSION IF NOT EXISTS "pgjwt" WITH SCHEMA "extensions"; 24 | 25 | CREATE EXTENSION IF NOT EXISTS "supabase_vault" WITH SCHEMA "vault"; 26 | 27 | CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA "extensions"; 28 | 29 | SET default_tablespace = ''; 30 | 31 | SET default_table_access_method = "heap"; 32 | 33 | CREATE TABLE IF NOT EXISTS "public"."blog_posts" ( 34 | "id" bigint NOT NULL, 35 | "title" "text", 36 | "subtitle" "text", 37 | "status" "text", 38 | "markdown" "text", 39 | "markdown_ai_revision" "text", 40 | "created_at" timestamp with time zone DEFAULT "now"(), 41 | "ai_publishing_recommendations" "text" 42 | ); 43 | 44 | ALTER TABLE "public"."blog_posts" OWNER TO "postgres"; 45 | 46 | ALTER TABLE "public"."blog_posts" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( 47 | SEQUENCE NAME "public"."blog_posts_id_seq" 48 | START WITH 1 49 | INCREMENT BY 1 50 | NO MINVALUE 51 | NO MAXVALUE 52 | CACHE 1 53 | ); 54 | 55 | CREATE TABLE IF NOT EXISTS "public"."workflows" ( 56 | "id" bigint NOT NULL, 57 | "created_at" timestamp with time zone DEFAULT "now"() NOT NULL, 58 | "workflow" "jsonb", 59 | "enabled" boolean DEFAULT false, 60 | "trigger" "text", 61 | "description" "text", 62 | "name" "text" 63 | ); 64 | 65 | ALTER TABLE "public"."workflows" OWNER TO "postgres"; 66 | 67 | ALTER TABLE "public"."workflows" ALTER COLUMN "id" ADD GENERATED BY DEFAULT AS IDENTITY ( 68 | SEQUENCE NAME "public"."workflows_id_seq" 69 | START WITH 1 70 | INCREMENT BY 1 71 | NO MINVALUE 72 | NO MAXVALUE 73 | CACHE 1 74 | ); 75 | 76 | ALTER TABLE ONLY "public"."blog_posts" 77 | ADD CONSTRAINT "blog_posts_pkey" PRIMARY KEY ("id"); 78 | 79 | ALTER TABLE ONLY "public"."workflows" 80 | ADD CONSTRAINT "workflows_pkey" PRIMARY KEY ("id"); 81 | 82 | ALTER PUBLICATION "supabase_realtime" OWNER TO "postgres"; 83 | 84 | GRANT USAGE ON SCHEMA "public" TO "postgres"; 85 | GRANT USAGE ON SCHEMA "public" TO "anon"; 86 | GRANT USAGE ON SCHEMA "public" TO "authenticated"; 87 | GRANT USAGE ON SCHEMA "public" TO "service_role"; 88 | 89 | GRANT ALL ON TABLE "public"."blog_posts" TO "anon"; 90 | GRANT ALL ON TABLE "public"."blog_posts" TO "authenticated"; 91 | GRANT ALL ON TABLE "public"."blog_posts" TO "service_role"; 92 | 93 | GRANT ALL ON SEQUENCE "public"."blog_posts_id_seq" TO "anon"; 94 | GRANT ALL ON SEQUENCE "public"."blog_posts_id_seq" TO "authenticated"; 95 | GRANT ALL ON SEQUENCE "public"."blog_posts_id_seq" TO "service_role"; 96 | 97 | GRANT ALL ON TABLE "public"."workflows" TO "anon"; 98 | GRANT ALL ON TABLE "public"."workflows" TO "authenticated"; 99 | GRANT ALL ON TABLE "public"."workflows" TO "service_role"; 100 | 101 | GRANT ALL ON SEQUENCE "public"."workflows_id_seq" TO "anon"; 102 | GRANT ALL ON SEQUENCE "public"."workflows_id_seq" TO "authenticated"; 103 | GRANT ALL ON SEQUENCE "public"."workflows_id_seq" TO "service_role"; 104 | 105 | ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "postgres"; 106 | ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "anon"; 107 | ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "authenticated"; 108 | ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON SEQUENCES TO "service_role"; 109 | 110 | ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "postgres"; 111 | ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "anon"; 112 | ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "authenticated"; 113 | ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON FUNCTIONS TO "service_role"; 114 | 115 | ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "postgres"; 116 | ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "anon"; 117 | ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "authenticated"; 118 | ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TABLES TO "service_role"; 119 | 120 | RESET ALL; 121 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "tailwindcss"; 2 | 3 | const config: Config = { 4 | darkMode: ["class"], 5 | content: [ 6 | "./pages/**/*.{js,ts,jsx,tsx,mdx}", 7 | "./components/**/*.{js,ts,jsx,tsx,mdx}", 8 | "./app/**/*.{js,ts,jsx,tsx,mdx}", 9 | ], 10 | theme: { 11 | extend: { 12 | colors: { 13 | background: 'hsl(var(--background))', 14 | foreground: 'hsl(var(--foreground))', 15 | card: { 16 | DEFAULT: 'hsl(var(--card))', 17 | foreground: 'hsl(var(--card-foreground))' 18 | }, 19 | popover: { 20 | DEFAULT: 'hsl(var(--popover))', 21 | foreground: 'hsl(var(--popover-foreground))' 22 | }, 23 | primary: { 24 | DEFAULT: 'hsl(var(--primary))', 25 | foreground: 'hsl(var(--primary-foreground))' 26 | }, 27 | secondary: { 28 | DEFAULT: 'hsl(var(--secondary))', 29 | foreground: 'hsl(var(--secondary-foreground))' 30 | }, 31 | muted: { 32 | DEFAULT: 'hsl(var(--muted))', 33 | foreground: 'hsl(var(--muted-foreground))' 34 | }, 35 | accent: { 36 | DEFAULT: 'hsl(var(--accent))', 37 | foreground: 'hsl(var(--accent-foreground))' 38 | }, 39 | destructive: { 40 | DEFAULT: 'hsl(var(--destructive))', 41 | foreground: 'hsl(var(--destructive-foreground))' 42 | }, 43 | border: 'hsl(var(--border))', 44 | input: 'hsl(var(--input))', 45 | ring: 'hsl(var(--ring))', 46 | chart: { 47 | '1': 'hsl(var(--chart-1))', 48 | '2': 'hsl(var(--chart-2))', 49 | '3': 'hsl(var(--chart-3))', 50 | '4': 'hsl(var(--chart-4))', 51 | '5': 'hsl(var(--chart-5))' 52 | } 53 | }, 54 | borderRadius: { 55 | lg: 'var(--radius)', 56 | md: 'calc(var(--radius) - 2px)', 57 | sm: 'calc(var(--radius) - 4px)' 58 | } 59 | } 60 | }, 61 | plugins: [require("tailwindcss-animate")], 62 | }; 63 | export default config; 64 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["dom", "dom.iterable", "esnext"], 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "noEmit": true, 9 | "incremental": true, 10 | "esModuleInterop": true, 11 | "module": "ESNext", 12 | "moduleResolution": "Bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "baseUrl": ".", 17 | "paths": { 18 | "@/*": ["./*"] 19 | }, 20 | "plugins": [ 21 | { 22 | "name": "next" 23 | } 24 | ], 25 | "strictNullChecks": true 26 | }, 27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], 28 | "exclude": ["node_modules"] 29 | } 30 | -------------------------------------------------------------------------------- /examples/nextjs-blog-cms/workflow-editor.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/inngest/workflow-kit/26ad93b85d396f64f4929ced0b64ff6a5fd76761/examples/nextjs-blog-cms/workflow-editor.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "changeset": "changeset", 4 | "test": "pnpm run --if-present --recursive test", 5 | "build": "pnpm run --if-present --recursive build", 6 | "release": "pnpm run build && changeset publish" 7 | }, 8 | "dependencies": { 9 | "@changesets/cli": "^2.27.8", 10 | "@sinclair/typebox": "^0.33.7" 11 | }, 12 | "packageManager": "pnpm@9.4.0" 13 | } 14 | -------------------------------------------------------------------------------- /packages/workflow/.gitignore: -------------------------------------------------------------------------------- 1 | # Built files 2 | dist 3 | 4 | # Generated version file; always created pre-publish 5 | src/version.ts 6 | 7 | # Release-only files; tree must be clean on release, so these can't be modified 8 | .npmrc 9 | 10 | # Local dev files 11 | coverage/ 12 | 13 | tsdoc-metadata.json 14 | 15 | *storybook.log -------------------------------------------------------------------------------- /packages/workflow/.storybook/main.js: -------------------------------------------------------------------------------- 1 | /** @type { import('@storybook/react-vite').StorybookConfig } */ 2 | const config = { 3 | stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"], 4 | addons: [ 5 | "@storybook/addon-onboarding", 6 | "@storybook/addon-links", 7 | "@storybook/addon-essentials", 8 | "@chromatic-com/storybook", 9 | "@storybook/addon-interactions", 10 | ], 11 | framework: { 12 | name: "@storybook/react-vite", 13 | options: {}, 14 | }, 15 | }; 16 | export default config; 17 | -------------------------------------------------------------------------------- /packages/workflow/.storybook/preview.js: -------------------------------------------------------------------------------- 1 | /** @type { import('@storybook/react').Preview } */ 2 | const preview = { 3 | parameters: { 4 | controls: { 5 | matchers: { 6 | color: /(background|color)$/i, 7 | date: /Date$/i, 8 | }, 9 | }, 10 | }, 11 | }; 12 | 13 | export default preview; 14 | -------------------------------------------------------------------------------- /packages/workflow/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # @inngest/workflow-kit 2 | 3 | ## 0.1.3 4 | 5 | ### Patch Changes 6 | 7 | - a40a1b6: feat(core): typing fixes 8 | - 04c146f: fix: do not update state in render 9 | -------------------------------------------------------------------------------- /packages/workflow/README.md: -------------------------------------------------------------------------------- 1 |

    2 | 3 | ![Workflow Kit by Inngest](https://github.com/inngest/workflow-kit/blob/main/.github/assets/workflow-kit.jpg?raw=true) 4 | 5 |

    6 | 7 |

    8 | Documentation 9 |  ·  10 | Blog 11 |  ·  12 | Community 13 |

    14 | 15 | # Workflow kit 16 | 17 | **Workflow Kit** enables you to build user-defined workflows with Inngest by providing a set of workflow actions to the **[Workflow Engine](https://www.inngest.com/docs/reference/workflow-kit/engine?ref=github-workflow-kit-readme)** while using the **[pre-built React components](https://www.inngest.com/docs/reference/workflow-kit/components-api?ref=github-workflow-kit-readme)** to build your Workflow Editor UI. 18 | 19 | ![Workflow kit UI demo](https://github.com/inngest/workflow-kit/blob/main/.github/assets/workflow-demo.gif?raw=true) 20 | 21 | ## Installation 22 | 23 | Workflow kit requires the [Inngest TypeScript SDK](https://github.com/inngest/inngest-js) as a dependency. You can install both via `npm` or similar: 24 | 25 | ```shell {{ title: "npm" }} 26 | npm install @inngest/workflow-kit inngest 27 | ``` 28 | 29 | ## Documentation 30 | 31 | The full Workflow kit documentation is available [here](https://www.inngest.com/docs/reference/workflow-kit). You can also jump to specific guides and references: 32 | 33 | - [Creating workflow actions](https://www.inngest.com/docs/reference/workflow-kit/actions?ref=github-workflow-kit-readme) 34 | - [Using the workflow engine](https://www.inngest.com/docs/reference/workflow-kit/engine?ref=github-workflow-kit-readme) 35 | - [Workflow instance format](https://www.inngest.com/docs/reference/workflow-kit/workflow-instance?ref=github-workflow-kit-readme) 36 | - [Components API (React)](https://www.inngest.com/docs/reference/workflow-kit/components-api?ref=github-workflow-kit-readme) 37 | 38 | ## Examples 39 | 40 | See Workflow kit in action in fully functioning example projects: 41 | 42 | - [Next.js Blog CMS](/examples/nextjs-blog-cms#readme) - A ready-to-deploy Next.js demo using the Workflow Kit, Supabase, and OpenAI to power some AI content workflows. 43 | 44 | ## License 45 | 46 | [Apache 2.0](/packages/workflow/LICENSE.md) 47 | -------------------------------------------------------------------------------- /packages/workflow/jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'node', 5 | }; -------------------------------------------------------------------------------- /packages/workflow/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@inngest/workflow-kit", 3 | "version": "0.1.3", 4 | "description": "Durable visual workflows in your app, instantly", 5 | "main": "dist/index.js", 6 | "scripts": { 7 | "changeset": "changeset", 8 | "build": "rm -rf dist && tsc && cp src/ui/ui.css dist/ui/ui.css", 9 | "test": "jest --logHeapUsage --maxWorkers=8 --coverage --ci --silent=false", 10 | "storybook": "storybook dev -p 6006", 11 | "build-storybook": "storybook build" 12 | }, 13 | "homepage": "https://github.com/inngest/workflow-kit/tree/main/packages/workflow#readme", 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/inngest/workflow-kit.git", 17 | "directory": "packages/workflow" 18 | }, 19 | "keywords": [ 20 | "inngest", 21 | "workflow" 22 | ], 23 | "author": "Inngest Inc", 24 | "license": "Apache-2.0", 25 | "typings": "index.d.ts", 26 | "publishConfig": { 27 | "access": "public" 28 | }, 29 | "files": [ 30 | "dist" 31 | ], 32 | "exports": { 33 | ".": { 34 | "require": { 35 | "types": "./dist/index.d.ts", 36 | "default": "./dist/index.js" 37 | }, 38 | "import": { 39 | "types": "./dist/index.d.ts", 40 | "default": "./dist/index.js" 41 | }, 42 | "default": { 43 | "types": "./dist/index.d.ts", 44 | "default": "./dist/index.js" 45 | } 46 | }, 47 | "./ui": { 48 | "require": { 49 | "types": "./dist/ui/index.d.ts", 50 | "default": "./dist/ui/index.js" 51 | }, 52 | "import": { 53 | "types": "./dist/ui/index.d.ts", 54 | "default": "./dist/ui/index.js" 55 | }, 56 | "default": { 57 | "types": "./dist/ui/index.d.ts", 58 | "default": "./dist/ui/index.js" 59 | } 60 | }, 61 | "./ui/ui.css": "./dist/ui/ui.css", 62 | "./package.json": "./package.json" 63 | }, 64 | "dependencies": { 65 | "@dagrejs/dagre": "^1.1.4", 66 | "@radix-ui/react-popover": "^1.1.1", 67 | "@sinclair/typebox": "^0.33.7", 68 | "@xyflow/react": "^12.1.1", 69 | "graphology": "^0.25.4", 70 | "graphology-dag": "^0.4.1", 71 | "json-logic-js": "^2.0.5", 72 | "@astronautlabs/jsonpath": "^1.1.1", 73 | "reactflow": "^11.11.3" 74 | }, 75 | "devDependencies": { 76 | "@chromatic-com/storybook": "^1.7.0", 77 | "@storybook/addon-essentials": "^8.2.9", 78 | "@storybook/addon-interactions": "^8.2.9", 79 | "@storybook/addon-links": "^8.2.9", 80 | "@storybook/addon-onboarding": "^8.2.9", 81 | "@storybook/blocks": "^8.2.9", 82 | "@storybook/react": "^8.2.9", 83 | "@storybook/react-vite": "^8.2.9", 84 | "@storybook/test": "^8.2.9", 85 | "@types/jest": "^29.5.12", 86 | "@types/json-logic-js": "^2.0.7", 87 | "@types/jsonpath": "^0.2.4", 88 | "@types/node": "^22.5.5", 89 | "@types/react": "^18.3.5", 90 | "inngest": "^3.19.14", 91 | "jest": "^29.7.0", 92 | "preset": "link:@storybook/react-vite/preset", 93 | "prop-types": "^15.8.1", 94 | "storybook": "^8.2.9", 95 | "ts-jest": "^29.1.5", 96 | "typescript": "^5.7.2" 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /packages/workflow/src/builtin.test.ts: -------------------------------------------------------------------------------- 1 | import { builtinActions } from "./builtin"; 2 | import { resolveInputs } from "./interpolation"; 3 | import { 4 | ActionHandler, 5 | ActionHandlerArgs, 6 | Workflow, 7 | WorkflowAction, 8 | } from "./types"; 9 | 10 | describe("builtin:if", () => { 11 | // This tests the handler logic of the builtin:if action. 12 | const action = builtinActions["builtin:if"]; 13 | if (!action) { 14 | throw new Error("builtin:if action not found"); 15 | } 16 | 17 | it("evaluates simple conditions without refs", async () => { 18 | const workflowAction: WorkflowAction = { 19 | id: "1", 20 | kind: "builtin:if", 21 | inputs: { 22 | condition: { "==": [1, 1] }, 23 | }, 24 | }; 25 | 26 | let result = await action.handler(handlerInput(workflowAction)); 27 | expect(result).toEqual({ result: true }); 28 | 29 | workflowAction.inputs = { condition: { "==": [2, 1] } }; 30 | result = await action.handler(handlerInput(workflowAction)); 31 | expect(result).toEqual({ result: false }); 32 | }); 33 | 34 | it("evaluates complex conditions with refs", async () => { 35 | const state = new Map(Object.entries({ action_a: 1.123 })); 36 | const event = { data: { name: "jimothy" } }; 37 | 38 | const workflowAction: WorkflowAction = { 39 | id: "1", 40 | kind: "builtin:if", 41 | inputs: resolveInputs( 42 | { 43 | condition: { 44 | and: [ 45 | { "==": ["!ref($.state.action_a)", 1.123] }, 46 | { "==": ["!ref($.event.data.name)", "jimothy"] }, 47 | ], 48 | }, 49 | }, 50 | { state: Object.fromEntries(state), event } 51 | ), 52 | }; 53 | 54 | let result = await action.handler({ 55 | workflowAction, 56 | event, 57 | state, 58 | step: {} as any, 59 | workflow: { 60 | actions: [workflowAction], 61 | edges: [], 62 | }, 63 | }); 64 | 65 | expect(result).toEqual({ result: true }); 66 | }); 67 | 68 | const handlerInput = (workflowAction: WorkflowAction): ActionHandlerArgs => { 69 | return { 70 | workflowAction, 71 | event: { 72 | data: { 73 | age: 82.1, 74 | likes: ["a"], 75 | }, 76 | }, 77 | step: {}, 78 | workflow: { 79 | actions: [workflowAction], 80 | edges: [], 81 | }, 82 | state: new Map(), 83 | }; 84 | }; 85 | }); 86 | -------------------------------------------------------------------------------- /packages/workflow/src/builtin.ts: -------------------------------------------------------------------------------- 1 | // TODO: Define builtin nodes, like if statements. 2 | 3 | import { Type } from "@sinclair/typebox"; 4 | import { ActionHandlerArgs, EngineAction } from "./types"; 5 | import { apply } from "json-logic-js"; 6 | 7 | export const builtinActions: Record = { 8 | "builtin:if": { 9 | kind: "builtin:if", 10 | name: "If", 11 | description: "If / else branch", 12 | handler: async ({ workflowAction }: ActionHandlerArgs): Promise<{ result: boolean }> => { 13 | if (!!!workflowAction.inputs?.condition) { 14 | // Always true. 15 | return { result: true }; 16 | } 17 | const result = apply(workflowAction.inputs.condition); 18 | return { result }; 19 | }, 20 | inputs: { 21 | condition: { 22 | type: Type.Object({}, { 23 | title: "Condition", 24 | description: "Condition to evaluate", 25 | examples: [ 26 | // NOTE: Vars aren't necessary, as actions are already interpolated. 27 | { "==": ["!ref($.event.data.likes)", "a"] }, 28 | { 29 | "and": [ 30 | { "==": ["!ref($.event.data.likes)", 1.123] }, // NOTE: 1.123 is a float, not a string. Interpolation handles this. 31 | { "==": ["!ref($.event.data.likes)", "b"] }, 32 | ] 33 | }, 34 | ], 35 | }), 36 | }, 37 | }, 38 | outputs: { 39 | result: { 40 | type: Type.Boolean({ 41 | title: "Result", 42 | description: "Result of the condition", 43 | }), 44 | }, 45 | }, 46 | 47 | edges: { 48 | allowAdd: false, 49 | edges: [ 50 | { name: "True", conditional: { type: "if", ref: "!ref($.output.result)" } }, 51 | { name: "False", conditional: { type: "else", ref: "!ref($.output.result)" } }, 52 | ] 53 | }, 54 | } 55 | } -------------------------------------------------------------------------------- /packages/workflow/src/engine.execution.test.ts: -------------------------------------------------------------------------------- 1 | import { Engine } from "./engine"; 2 | import { Workflow } from "./types"; 3 | import { SourceNodeID } from "./graph"; 4 | import { Type } from '@sinclair/typebox' 5 | import { builtinActions } from "./builtin"; 6 | 7 | test("execution", async () => { 8 | 9 | const engine = new Engine({ 10 | actions: [ 11 | ...Object.values(builtinActions), 12 | { 13 | kind: "multiply", 14 | name: "Multiply some numbers", 15 | handler: async (args) => { 16 | return (args.workflowAction?.inputs?.a || 0) * (args.workflowAction?.inputs?.b || 0) 17 | }, 18 | inputs: { 19 | a: { 20 | type: Type.Number({ 21 | description: "Numerator", 22 | }), 23 | }, 24 | b: { 25 | type: Type.Number({ 26 | description: "Denominator", 27 | }), 28 | }, 29 | }, 30 | outputs: Type.Number(), 31 | } 32 | ] 33 | }); 34 | 35 | const workflow: Workflow = { 36 | actions: [ 37 | { 38 | id: "stepA", 39 | kind: "multiply", 40 | name: "Multiply some numbers", 41 | inputs: { 42 | a: 7, 43 | b: 6, 44 | }, 45 | }, 46 | 47 | // Only continue if the result is 7*6 48 | { 49 | id: "if-a", 50 | kind: "builtin:if", 51 | name: "If A is 42", 52 | inputs: { 53 | condition: { 54 | "==": [ 55 | "!ref($.state.stepA)", 56 | 7*6, 57 | ], 58 | }, 59 | }, 60 | }, 61 | 62 | { 63 | id: "stepB-true", 64 | kind: "multiply", 65 | name: "multiply result of A", 66 | inputs: { 67 | a: "!ref($.state.stepA)", 68 | b: "!ref($.event.data.age)", 69 | }, 70 | }, 71 | { 72 | id: "stepB-false", 73 | kind: "multiply", 74 | name: "Should never run.", 75 | inputs: { 76 | a: "!ref($.state.stepA)", 77 | b: "!ref($.event.data.age)", 78 | }, 79 | }, 80 | 81 | // Finally, run a conditional to match on numeric values. 82 | { 83 | id: "stepC-true", 84 | kind: "multiply", 85 | name: "Multiply 2 and 9", 86 | inputs: { 87 | a: 2, 88 | b: 9, 89 | }, 90 | }, 91 | { 92 | id: "stepC-false", 93 | kind: "multiply", 94 | name: "Never runs, as equality is false", 95 | inputs: { 96 | a: 2, 97 | b: 9, 98 | }, 99 | }, 100 | ], 101 | edges: [ 102 | { from: SourceNodeID, to: "stepA" }, 103 | 104 | { from: "stepA", to: "if-a" }, 105 | 106 | // Check "true" branches of the builtin:if action 107 | { from: "if-a", to: "stepB-true", conditional: { type: "if", ref: "!ref($.output.result)" } }, 108 | // if-a should evaluate to true so this never runs. 109 | { from: "if-a", to: "stepB-false", conditional: { type: "else", ref: "!ref($.output.result)" } }, 110 | 111 | // Check that "match" works with non-string values. 112 | { from: "stepB-true", to: "stepC-true", conditional: { type: "match", ref: "!ref($.output)", value: 42*99 } }, 113 | // This should never run, as the value is not equal (type equality). 114 | { from: "stepB-true", to: "stepC-false", conditional: { type: "match", ref: "!ref($.output)", value: (42*99).toString() } }, 115 | ], 116 | }; 117 | 118 | const es = await engine.run({ 119 | workflow, 120 | event: { 121 | name: "auth/user.created", 122 | data: { 123 | name: "test user", 124 | age: 99, 125 | } 126 | }, 127 | step: {}, 128 | }); 129 | 130 | expect(es.state.get("stepA")).toBe(42); 131 | expect(es.state.get("stepB-true")).toBe(42*99); 132 | expect(es.state.get("stepC-true")).toBe(2*9); 133 | 134 | // Shouldn't run, as the if-a step is true 135 | expect(es.state.get("stepB-false")).toBe(undefined); 136 | expect(es.state.get("stepC-false")).toBe(undefined); 137 | }) -------------------------------------------------------------------------------- /packages/workflow/src/engine.test.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionState, type ExecutionOpts, Engine } from "./engine"; 2 | import { Workflow } from "./types"; 3 | 4 | describe("Engine.graph", () => { 5 | 6 | const engine = new Engine({ 7 | actions: [ 8 | { 9 | kind: "send-email", 10 | name: "Send Email", 11 | handler: async () => {}, // noop 12 | } 13 | ] 14 | }); 15 | 16 | it("validates unknown action kinds", () => { 17 | const wf: Workflow = { 18 | actions: [ 19 | { id: "a", kind: "not-found-in-engine" } 20 | ], 21 | edges: [ 22 | { from: "$source", to: "a" }, 23 | ], 24 | } 25 | 26 | expect(() => engine.graph(wf)).toThrow( 27 | "Workflow instance references unknown action kind" 28 | ); 29 | }); 30 | 31 | it ("validates that there are source edges", () => { 32 | const wf: Workflow = { 33 | actions: [ 34 | { id: "a", kind: "send-email" }, 35 | { id: "b", kind: "send-email" }, 36 | ], 37 | edges: [ 38 | { from: "a", to: "b" }, 39 | ], 40 | } 41 | 42 | expect(() => engine.graph(wf)).toThrow( 43 | "Workflow has no starting actions" 44 | ); 45 | }) 46 | 47 | it ("validates that there are no disconnected actions", () => { 48 | const wf: Workflow = { 49 | actions: [ 50 | { id: "a", kind: "send-email" }, 51 | { id: "b", kind: "send-email" }, 52 | ], 53 | edges: [ 54 | { from: "$source", to: "a" }, 55 | ], 56 | } 57 | 58 | expect(() => engine.graph(wf)).toThrow( 59 | "An action is disconnected and will never run: b" 60 | ); 61 | }) 62 | 63 | 64 | it ("validates that there are no cycles or self-references", () => { 65 | const wf: Workflow = { 66 | actions: [ 67 | { id: "a", kind: "send-email" }, 68 | { id: "b", kind: "send-email" }, 69 | ], 70 | edges: [ 71 | { from: "a", to: "a" }, 72 | ], 73 | } 74 | 75 | expect(() => engine.graph(wf)).toThrow( 76 | "Workflow instance must be a DAG; the given workflow has at least one cycle." 77 | ); 78 | }) 79 | 80 | it("validates parent refs", () => {}); 81 | it("validates action input types", () => {}); 82 | }); 83 | 84 | describe("ExecutionState.interpolate", () => { 85 | it("correctly references current state and events", () => { 86 | let state = new ExecutionState( 87 | ({ 88 | event: { 89 | data: { 90 | userId: 1.1, 91 | likes: ["a"], 92 | } 93 | }, 94 | } as any) as ExecutionOpts, 95 | { 96 | action_a: "test", 97 | action_b: { ok: true }, 98 | action_c: 42, 99 | }, 100 | ); 101 | 102 | expect(state.interpolate("!ref($.event.data.userId)")).toEqual(1.1); 103 | expect(state.interpolate("!ref($.event.data.likes)")).toEqual(["a"]); 104 | expect(state.interpolate("!ref($.state.action_a)")).toEqual("test"); 105 | expect(state.interpolate("!ref($.state.action_b)")).toEqual({ ok: true }); 106 | 107 | // Contains refs in the middle of the string. 108 | expect( 109 | state.interpolate(`{"==": [{"var": "!ref($.state.action_a)"}, "a"}`) 110 | ).toEqual(`{"==": [{"var": "test"}, "a"}`); 111 | 112 | expect(state.interpolate("!ref($.state.not_found)")).toEqual(undefined); 113 | 114 | expect(state.interpolate("lol")).toEqual("lol"); 115 | expect(state.interpolate(123)).toEqual(123); 116 | expect(state.interpolate([123])).toEqual([123]); 117 | 118 | // Object walking. 119 | expect(state.interpolate( 120 | {"==": ["!ref($.state.action_b)", "a"]}, 121 | )).toEqual({"==": [{ ok: true }, "a"]}); 122 | expect(state.interpolate( 123 | { 124 | "and": [ 125 | { "==": ["!ref($.event.data.userId)", "a"] }, 126 | { "==": ["!ref($.state.action_b)", "test"] }, 127 | ] 128 | }, 129 | )).toEqual({ 130 | "and": [ 131 | { "==": [1.1, "a"] }, 132 | { "==": [{ ok: true }, "test"] }, 133 | ] 134 | }); 135 | }) 136 | }); 137 | 138 | describe("ExecutionState.run", () => { 139 | it("executes actions", () => { 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /packages/workflow/src/engine.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type RunArgs, 3 | type EngineOptions, 4 | type EngineAction, 5 | type WorkflowAction, 6 | type Loader, 7 | type Workflow, 8 | DAG, 9 | TriggerEvent, 10 | } from "./types"; 11 | import { bfs, newDAG, SourceNodeID } from './graph'; 12 | import { Value, AssertError } from '@sinclair/typebox/value' 13 | import { interpolate, refs, resolveInputs } from "./interpolation"; 14 | 15 | export class Engine { 16 | #options: EngineOptions; 17 | 18 | #actionKinds: Set; 19 | #actionMap: Record 20 | 21 | constructor(options: EngineOptions) { 22 | this.#options = options; 23 | this.#actionKinds = new Set(); 24 | this.#actionMap = {}; 25 | this.actions = this.#options.actions || []; 26 | } 27 | 28 | /** 29 | * Returns all actions added to the engine 30 | * 31 | */ 32 | get actions(): Record { 33 | return this.#actionMap; 34 | } 35 | 36 | /** 37 | * Replaces all actions in the current engine 38 | * 39 | */ 40 | set actions(actions: Array) { 41 | this.#options.actions = actions; 42 | this.#actionKinds = new Set(); 43 | for (let action of this.#options.actions) { 44 | if (this.#actionKinds.has(action.kind)) { 45 | throw new Error(`Duplicate action kind: ${action.kind}`); 46 | } 47 | this.#actionKinds.add(action.kind); 48 | this.#actionMap[action.kind] = action; 49 | } 50 | } 51 | 52 | /** 53 | * Returns all actions added to the engine 54 | * 55 | */ 56 | get loader(): Loader | undefined { 57 | return this.#options.loader; 58 | } 59 | 60 | /** 61 | * Replaces all actions in the current engine 62 | * 63 | */ 64 | set loader(loader: Loader) { 65 | this.#options.loader = loader; 66 | } 67 | 68 | 69 | /** 70 | * Graph returns a graph for the given workflow instance, and also ensures that the given 71 | * Workflow is valid and has no errors. 72 | * 73 | * It checks for cycles, disconnected vertices and edges, references are valid, and that 74 | * actions exist within the workflow instance. 75 | * 76 | * If the JSON is invalid, this throws an error. 77 | * 78 | */ 79 | graph(flow: Workflow): DAG { 80 | for (let action of flow.actions) { 81 | 82 | // Validate that the action kind exists within the engine. 83 | if (!this.#actionKinds.has(action.kind)) { 84 | throw new Error("Workflow instance references unknown action kind: " + action.kind); 85 | } 86 | 87 | // Validate that the workflow action's input types match the expected 88 | // types defined on the engine's action 89 | for (const [name, input] of Object.entries((this.#actionMap[action.kind]?.inputs || {}))) { 90 | const wval = (action.inputs || {})[name]; 91 | 92 | // If this is a ref, we can't yet validate as we don't have state. 93 | if (refs(wval).length === 0) { 94 | try { 95 | Value.Assert(input.type, wval) 96 | } catch(e) { 97 | throw new Error(`Action '${action.id}' has an invalid input for '${name}': ${(e as AssertError).message}`); 98 | } 99 | 100 | continue 101 | } 102 | 103 | // TODO: Ensure that refs are valid. 104 | 105 | // TODO: Attempt to grab the output type of the action this is referencing, if this 106 | // is an action, and validate that (recursively) 107 | } 108 | } 109 | 110 | const graph = newDAG(flow); 111 | return graph 112 | } 113 | 114 | /** 115 | * run executes a new Workflow of a workflow durably, using the step tooling provided. 116 | * 117 | */ 118 | run = async ({ event, step, workflow }: RunArgs): Promise => { 119 | const { loader } = this.#options; 120 | if (!workflow && !loader) { 121 | throw new Error("Cannot run workflows without a workflow instance specified."); 122 | } 123 | if (!workflow && loader) { 124 | // Always load the workflow within a step so that it's declarative. 125 | workflow = await step.run( 126 | "Load workflow configuration", 127 | async () => { 128 | try { 129 | return await loader(event); 130 | } catch(e) { 131 | // TODO: Is this an WorkflowNotFound error? 132 | } 133 | }, 134 | ); 135 | } 136 | if (!workflow) { 137 | throw new Error("No workflow instance specified."); 138 | } 139 | 140 | let graph = this.graph(workflow); 141 | 142 | // Workflows use `step.run` to manage implicit step state, orchestration, and 143 | // execution, storing the ouput of each action within a custom state object for 144 | // mapping inputs/outputs between steps by references within action metadata. 145 | // 146 | // Unlike regular Inngest step functions, workflow instances have no programming flow 147 | // and so we must maintain some state mapping ourselves. 148 | let state = new ExecutionState({ 149 | engine: this, 150 | graph, 151 | workflow, 152 | event, 153 | step, 154 | }); 155 | 156 | await state.execute(); 157 | 158 | return state; 159 | } 160 | } 161 | 162 | export interface ExecutionOpts { 163 | engine: Engine; 164 | graph: DAG; 165 | workflow: Workflow; 166 | event: TriggerEvent; 167 | step: any; 168 | } 169 | 170 | /** 171 | * ExecutionState iterates through a given Workflow and graph, ensuring that we call 172 | * each action within the graph in order, durably. 173 | * 174 | * Because each action in a workflow can reference previous action's outputs and event data, it 175 | * also resolves references and manages action data. 176 | * 177 | * Note that this relies on Inngest's step functionality for durability and function state 178 | * management. 179 | * 180 | */ 181 | export class ExecutionState { 182 | #opts: ExecutionOpts 183 | 184 | #state: Map; 185 | 186 | constructor(opts: ExecutionOpts, state?: Record) { 187 | this.#opts = opts; 188 | this.#state = new Map(Object.entries(state || {})); 189 | } 190 | 191 | get state(): Map { 192 | return this.#state; 193 | } 194 | 195 | execute = async () => { 196 | const { event, step, graph, workflow, engine } = this.#opts; 197 | 198 | await bfs(graph, async (action, edge) => { 199 | if (edge.conditional) { 200 | const { type, ref, value } = edge.conditional || {}; 201 | 202 | // We allow "!ref($.output)" to refer to the previous action's output. 203 | // Here we must grab the previous step's state for interpolation as the result. 204 | const previousActionOutput = this.#state.get(edge.from); 205 | const input = this.interpolate(ref, previousActionOutput); 206 | 207 | switch (type) { 208 | case "if": 209 | if (!input) { 210 | // This doesn't match, so we skip this edge. 211 | return; 212 | } 213 | break; 214 | case "else": 215 | if (!!input) { 216 | // This doesn't match, so we skip this edge. 217 | return; 218 | } 219 | break 220 | case "match": 221 | // Because object equality is what it is, we JSON stringify both 222 | // values here. 223 | if (JSON.stringify(input) !== JSON.stringify(value)) { 224 | // This doesn't match, so we skip this edge. 225 | return; 226 | } 227 | } 228 | } 229 | 230 | // Find the base action from the workflow class. This includes the handler 231 | // to invoke. 232 | const base = engine.actions[action.kind]; 233 | if (!base) { 234 | throw new Error(`Unable to find workflow action for kind: ${action.kind}`); 235 | } 236 | 237 | // Invoke the action directly. 238 | // 239 | // Note: The handler should use Inngest's step API within handlers, ensuring 240 | // that nodes in the workflow execute once, durably. 241 | const workflowAction = { ...action, inputs: this.resolveInputs(action) }; 242 | 243 | const result = await base.handler({ 244 | event, 245 | step, 246 | workflow, 247 | workflowAction, 248 | state: this.#state, 249 | }); 250 | 251 | // And set our state. This may be a previously memoized output. 252 | this.#state.set(action.id, result); 253 | }); 254 | } 255 | 256 | /** 257 | * resolveInputs itarates through the action configuration, updating any referenced 258 | * variables within the config. 259 | * 260 | */ 261 | resolveInputs = (action: WorkflowAction): Record => { 262 | // For each action, check to see if it references any prior input. 263 | return resolveInputs(action.inputs ?? {}, { 264 | state: Object.fromEntries(this.#state), 265 | event: this.#opts.event 266 | }); 267 | } 268 | 269 | interpolate = (value: any, output?: any): any => { 270 | return interpolate(value, { 271 | state: Object.fromEntries(this.#state), 272 | event: this.#opts.event, 273 | // output is an optional output from the previous step, used to 274 | // interpolate conditional edges. 275 | output, 276 | }); 277 | } 278 | 279 | } -------------------------------------------------------------------------------- /packages/workflow/src/graph.test.ts: -------------------------------------------------------------------------------- 1 | import { bfs, newDAG } from "./graph"; 2 | import { type Workflow } from "./types"; 3 | 4 | describe("newDAG validation", () => { 5 | 6 | it("validates disconnected actions", () => { 7 | const t = () => newDAG({ 8 | actions: [{ id: "a", kind: "test" }, { id: "b", kind: "test" }], 9 | edges: [{ from: "$source", to: "a" }], 10 | }) 11 | expect(t).toThrow(/An action is disconnected and will never run: b/) 12 | }); 13 | 14 | it("correctly detects cycles", () => { 15 | const t = () => newDAG({ 16 | actions: [{ id: "a", kind: "test" }, { id: "b", kind: "test" }], 17 | edges: [{ from: "$source", to: "a" }, { from: "a", to: "b" }, { from: "b", to: "a" }], 18 | }) 19 | expect(t).toThrow(/workflow has at least one cycle/) 20 | }); 21 | 22 | it("validates duplicate action ids", () => { 23 | const t = () => newDAG({ 24 | actions: [{ id: "a", kind: "test" }, { id: "a", kind: "test" }], 25 | edges: [{ from: "$source", to: "a" }], 26 | }) 27 | expect(t).toThrow("Workflow has two actions with the same ID: a") 28 | }); 29 | 30 | it("validates invalid edges with invalid to", () => { 31 | const t = () => newDAG({ 32 | actions: [{ id: "a", kind: "test" }], 33 | edges: [{ from: "$source", to: "a" }, { from: "a", to: "wat_is_this" }], 34 | }) 35 | expect(t).toThrow("Workflow references an unknown action: wat_is_this") 36 | }); 37 | 38 | it("validates invalid edges with invalid from", () => { 39 | const t = () => newDAG({ 40 | actions: [{ id: "a", kind: "test" }], 41 | edges: [{ from: "$source", to: "a" }, { from: "wat_is_this", to: "a" }], 42 | }) 43 | expect(t).toThrow("Workflow references an unknown action: wat_is_this") 44 | }); 45 | }); 46 | 47 | test("bfs with a single node", async () => { 48 | const node = { id: "a", "kind": "test" }; 49 | const edge = { from: "$source", to: "a" }; 50 | 51 | const dag = newDAG({ actions: [node], edges: [edge], }); 52 | 53 | let hits = 0; 54 | await bfs(dag, async (n, e) => { 55 | hits++; 56 | expect(e).toEqual(edge); 57 | expect(n).toEqual(node); 58 | }); 59 | expect(hits).toEqual(1); 60 | 61 | }); 62 | 63 | 64 | test("bfs with a tree single node", async () => { 65 | const a1 = { id: "a1", "kind": "test" }; 66 | const a2 = { id: "a2", "kind": "test" }; 67 | const a1b1 = { id: "a1b1", "kind": "test" }; 68 | const a1b2 = { id: "a1b2", "kind": "test" }; 69 | const a1b2c1 = { id: "a1b2c1", "kind": "test" }; 70 | 71 | const dag = newDAG({ 72 | actions: [a1, a2, a1b1, a1b2, a1b2c1], 73 | edges: [ 74 | // NOTE: The A2 edge comes first, so we expect to hit this first. 75 | { from: "$source", to: "a2" }, 76 | { from: "$source", to: "a1" }, 77 | { from: "a1", to: "a1b1" }, 78 | { from: "a1", to: "a1b2" }, 79 | { from: "a1b2", to: "a1b2c1" }, 80 | ], 81 | }); 82 | 83 | let hits = 0; 84 | await bfs(dag, async (n, _e) => { 85 | // Assert the order is deterministic, based off of edge ordering. 86 | switch (hits) { 87 | case 0: 88 | expect(n).toEqual(a2); 89 | break; 90 | case 1: 91 | expect(n).toEqual(a1); 92 | break; 93 | case 2: 94 | expect(n).toBe(a1b1); 95 | break; 96 | case 3: 97 | expect(n).toBe(a1b2); 98 | break; 99 | case 4: 100 | expect(n).toBe(a1b2c1); 101 | break; 102 | } 103 | hits++; 104 | }); 105 | 106 | expect(hits).toEqual(5); 107 | }); 108 | -------------------------------------------------------------------------------- /packages/workflow/src/graph.ts: -------------------------------------------------------------------------------- 1 | import { DirectedGraph } from "graphology"; 2 | import hasCycle from 'graphology-dag/has-cycle'; 3 | import { WorkflowAction, type Workflow, type WorkflowEdge, type Node, type Edge, type DAG } from "./types"; 4 | 5 | export const SourceNodeID = "$source"; 6 | 7 | export const newDAG = (flow: Workflow): DAG => { 8 | const g = new DirectedGraph(); 9 | 10 | // Always add the triggering event as a source node. 11 | g.mergeNode(SourceNodeID, { id: SourceNodeID, kind: SourceNodeID }); 12 | 13 | for (let action of flow.actions) { 14 | if (g.hasNode(action.id)) { 15 | throw new Error(`Workflow has two actions with the same ID: ${action.id}`); 16 | } 17 | g.addNode(action.id, { id: action.id, kind: "$action", action }); 18 | } 19 | for (let edge of flow.edges) { 20 | if (!g.hasNode(edge.from)) { 21 | throw new Error(`Workflow references an unknown action: ${edge.from}`); 22 | } 23 | if (!g.hasNode(edge.to)) { 24 | throw new Error(`Workflow references an unknown action: ${edge.to}`); 25 | } 26 | g.addEdge(edge.from, edge.to, { edge }); 27 | } 28 | 29 | if (hasCycle(g)) { 30 | throw new Error("Workflow instance must be a DAG; the given workflow has at least one cycle."); 31 | } 32 | 33 | if (g.outDegree(SourceNodeID) === 0) { 34 | throw new Error("Workflow has no starting actions"); 35 | } 36 | 37 | g.forEachNode((id, attrs) => { 38 | if (id !== SourceNodeID && g.inEdges(id).length === 0) { 39 | throw new Error(`An action is disconnected and will never run: ${attrs?.action?.id || id}`); 40 | } 41 | }); 42 | 43 | return g; 44 | } 45 | 46 | export const bfs = async (graph: DAG, cb: (node: WorkflowAction, edge: WorkflowEdge) => Promise): Promise => { 47 | if (graph.order <= 1) { 48 | // Only the event/source exists; do nothing. 49 | return; 50 | }; 51 | 52 | const queue: Array = [SourceNodeID]; 53 | const seen = new Set(); 54 | 55 | while (queue.length > 0) { 56 | let next = queue.shift(); 57 | const nodes: Array = []; 58 | 59 | // Iterate through all children given the parent node in the queue, then push these 60 | // into a list for processing. 61 | graph.forEachOutNeighbor(next, (id, node) => { 62 | if (seen.has(id)) { 63 | return; 64 | } 65 | // We want to iterate into each action afterwards, outside of this function 66 | // for async support. 67 | nodes.push(node); 68 | // And we want to do a BFS search down the tree itself. 69 | queue.push(id); 70 | }); 71 | 72 | // For each child node found, start processing the actions. `cb` should include Inngest's 73 | // step.run tooling for deterministic durability, here. 74 | for (let node of nodes) { 75 | if (!node.action) { 76 | // We don't need to process anything here. 77 | continue; 78 | } 79 | 80 | const iter = graph.edgeEntries(next, node.id); 81 | const edge = iter.next(); 82 | if (!edge || edge?.done == true) { 83 | // Should never happen due to DAG validation. 84 | throw new Error(`Error finding edge during DAG iteration: ${next} -> ${node.id}`); 85 | } 86 | 87 | await cb(node.action, edge.value.attributes.edge as WorkflowEdge); 88 | } 89 | } 90 | 91 | } 92 | -------------------------------------------------------------------------------- /packages/workflow/src/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | type RunArgs, 3 | type EngineOptions, 4 | type EngineAction, 5 | type PublicEngineAction, 6 | type WorkflowAction, 7 | type WorkflowEdge, 8 | type Loader, 9 | type Workflow, 10 | } from "./types"; 11 | 12 | export { Engine } from "./engine"; 13 | -------------------------------------------------------------------------------- /packages/workflow/src/interpolation.ts: -------------------------------------------------------------------------------- 1 | import { JSONPath } from "@astronautlabs/jsonpath"; 2 | import { TriggerEvent } from "./types"; 3 | 4 | /** 5 | * resolveInputs takes an object of action inputs, and fully interpolates all 6 | * refs within the inputs using the given state and event. 7 | * 8 | * @param inputs an object of actio inputs 9 | * @param vars the variables used for interpolation, eg. { state: {...}, event: {...} }. 10 | * @returns an object with the resolved inputs fully interpolated 11 | */ 12 | export const resolveInputs = ( 13 | inputs: Record, 14 | vars: Record, 15 | ): Record => { 16 | const outputs: Record = {}; 17 | for (let key in (inputs ?? {})) { 18 | outputs[key] = interpolate(inputs[key], vars); 19 | } 20 | return outputs 21 | } 22 | 23 | 24 | /** 25 | * refs returns a list of all refs found within the input, as an object 26 | * containing the path and the original ref. 27 | * 28 | * @param input any action input, eg. a string, object, or number, 29 | * @returns a list of all refs found. 30 | */ 31 | export function refs(input: any): Array<{ path: string, ref: string }> { 32 | if (typeof(input) !== "string") { 33 | return [] 34 | } 35 | // Ensure that the ref matches a proper regex. 36 | let result = [] 37 | for (const match of input.matchAll(/\!ref\((\$.[\w\.]+)\)/g)) { 38 | if (match[1]) { 39 | // Push the JSON path and the original ref, to make substring 40 | // replacement easier. 41 | result.push({ path: match[1], ref: match[0] }) 42 | } 43 | } 44 | return result 45 | } 46 | 47 | /** 48 | * interpolate takes a single input and interpolates all refs within the input using 49 | * the given state and event. 50 | * 51 | * This handles non-ref values, single refs which return non-string values, string 52 | * interpolation, and object interpolation with a full depth-first traversal to interpoalte 53 | * all values within an object. 54 | * 55 | * @param value any action input, eg. a string, object, or number, 56 | * @param vars the variables used for interpolation, eg. { state: {...}, event: {...} }. 57 | * @returns the input with all refs interpolated 58 | */ 59 | export function interpolate(value: any, vars: Record) { 60 | let result = value; 61 | 62 | if (isRef(result)) { 63 | // Handle pure references immediately. Remove "!ref(" and ")" 64 | result = result.replace("!ref(", "") 65 | result = result.substring(0, result.length-1) 66 | return interpolatedRefValue(result, vars); 67 | } 68 | 69 | // If this is an object, walk the object and interpolate any refs within. 70 | if (typeof(result) === "object" && result !== null) { 71 | return interpolateObject(result, vars); 72 | } 73 | 74 | 75 | // This is a string which contains refs as values within content, 76 | // eg. "Hello !ref($.event.data.name)". 77 | const foundRefs = refs(result) 78 | while (foundRefs.length > 0) { 79 | // Replace the ref with the interpolated value. 80 | let { path: jsonPath, ref: substr } = foundRefs.shift() ?? { path: "", ref: "" }; 81 | result = result.replace(substr, interpolatedRefValue(jsonPath, vars)) 82 | } 83 | 84 | return result 85 | } 86 | 87 | function interpolateObject(value: Object, vars: Record) { 88 | let result = value; 89 | 90 | const stack: Array<{ obj: Record, key: string | null }> = [{ obj: result, key: null }]; 91 | 92 | // This is a reimplementaiton of interpolace() to prevent recursion and stack overflows. A 93 | // basic implementation is simply: 94 | // if (typeof(result) === "object" && result !== null) { 95 | // for (let key in result) { 96 | // result[key] = interpolate(result[key], state, event); 97 | // } 98 | // return result 99 | // } 100 | while (stack.length > 0) { 101 | const { obj, key } = stack.pop()!; 102 | 103 | if (key === null) { 104 | // Process all keys of the current object 105 | for (let k in obj) { 106 | stack.push({ obj, key: k }); 107 | } 108 | continue 109 | } 110 | 111 | // Process the value of the current key. 112 | let value = obj[key]; 113 | 114 | if (isRef(value)) { 115 | // Handle pure references 116 | value = value.replace("!ref(", "").slice(0, -1); 117 | obj[key] = interpolatedRefValue(value, vars); 118 | } else if (typeof value === "string") { 119 | // Handle string with embedded refs 120 | let foundRefs = refs(value); 121 | while (foundRefs.length > 0) { 122 | let { path, ref } = foundRefs.shift()!; 123 | value = value.replace(ref, interpolatedRefValue(path, vars)); 124 | } 125 | obj[key] = value; 126 | } else if (typeof value === "object" && value !== null) { 127 | // Push object for further processing 128 | stack.push({ obj: value, key: null }); 129 | } 130 | } 131 | 132 | return result; 133 | } 134 | 135 | 136 | function interpolatedRefValue(path: string, vars: Record) { 137 | const value = JSONPath.query(vars, path) 138 | if (!Array.isArray(value)) { 139 | return value 140 | } 141 | if (value.length === 0) { 142 | // Not found in state, so this is undefined. 143 | return undefined; 144 | } 145 | // JSON-path always returns an array containing the embedded results. The first 146 | // el is the result. 147 | if (value.length === 1) { 148 | return value[0] 149 | } 150 | return value; 151 | } 152 | 153 | 154 | // isRef returns true if the input is a reference, like !ref($state.foo). We handle 155 | // pure refs differently than strings which contain refs as part of the value. 156 | function isRef(input: string) { 157 | if (typeof(input) !== "string" || input.indexOf("!ref($.") !== 0) { 158 | return false 159 | } 160 | return input.substring(input.length-1) === ")" 161 | } 162 | -------------------------------------------------------------------------------- /packages/workflow/src/stories/UI.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { Type } from '@sinclair/typebox' 3 | 4 | import { Editor, Provider, Sidebar } from '../ui/'; 5 | import { builtinActions } from '../builtin'; 6 | import './ui.storybook.css'; 7 | import '@xyflow/react/dist/style.css'; 8 | import '../ui/ui.css'; 9 | 10 | // availableActions is of typePublicEngineAction[] 11 | const availableActions = [ 12 | ...Object.values(builtinActions), 13 | { 14 | name: "Send an email", 15 | description: "Sends an email via Resend", 16 | kind: "send-email", 17 | icon: "📧", 18 | inputs: { 19 | to: { 20 | type: Type.String({ 21 | title: "To", 22 | description: "The email address to send the email to", 23 | }), 24 | }, 25 | subject: { 26 | type: Type.String({ 27 | title: "Subject", 28 | description: "The subject of the email", 29 | }), 30 | }, 31 | body: { 32 | type: Type.String({ 33 | title: "Email content", 34 | description: "The content of the email", 35 | }), 36 | fieldType: "textarea", 37 | }, 38 | }, 39 | }, 40 | { 41 | name: "Send a text message", 42 | description: "Sends a text message via Twilio", 43 | kind: "send-text-message", 44 | icon: "📧", 45 | inputs: { 46 | to: { 47 | type: Type.String({ 48 | title: "Phone number", 49 | description: "The phone number to send the text message to", 50 | }), 51 | }, 52 | body: { 53 | type: Type.String({ 54 | title: "Text message", 55 | description: "The text message itself", 56 | }), 57 | }, 58 | }, 59 | }, 60 | ] 61 | 62 | export const UI = ({ workflow, trigger, direction }) => { 63 | const [wf, setWorkflow] = useState(workflow); 64 | 65 | return ( 66 |
    67 | { 72 | console.log("updated", updated); 73 | setWorkflow(updated) 74 | }} 75 | > 76 | 77 | 78 | 79 | 80 | 81 |
    82 | ); 83 | } 84 | 85 | UI.propTypes = { 86 | }; 87 | 88 | UI.defaultProps = { 89 | }; 90 | 91 | -------------------------------------------------------------------------------- /packages/workflow/src/stories/UI.stories.jsx: -------------------------------------------------------------------------------- 1 | import { UI } from './UI'; 2 | 3 | export default { 4 | title: 'Workflow Editor', 5 | component: UI, 6 | // This component will have an automatically generated Autodocs entry: https://storybook.js.org/docs/writing-docs/autodocs 7 | tags: ['autodocs'], 8 | parameters: { 9 | // More on how to position stories at: https://storybook.js.org/docs/configure/story-layout 10 | layout: 'fullscreen', 11 | }, 12 | args: { 13 | workflow: undefined, 14 | trigger: undefined, 15 | direction: "right" | "down", 16 | }, 17 | }; 18 | 19 | export const BlankDown = { 20 | name: "Blank, ↓ graph", 21 | args: { 22 | workflow: undefined, 23 | trigger: undefined, 24 | direction: "down", 25 | } 26 | }; 27 | 28 | export const TriggerOnly = { 29 | name: "Trigger only, ↓ graph", 30 | args: { 31 | workflow: undefined, 32 | trigger: { 33 | event: { 34 | name: "shopify/order.created", 35 | data: { 36 | order: 123, 37 | }, 38 | } 39 | }, 40 | } 41 | }; 42 | 43 | export const SingleAction = { 44 | name: "Single action, ↓ graph", 45 | args: { 46 | direction: "down", 47 | trigger: { 48 | event: { 49 | name: "shopify/order.created", 50 | data: { 51 | order: 123, 52 | }, 53 | } 54 | }, 55 | workflow: { 56 | "actions": [ 57 | { 58 | "id": "1", 59 | "kind": "send-email", 60 | "name": "Send Email", 61 | "description": "Send an email to the user", 62 | "inputs": { 63 | "to": "!ref($.event.data.email)", 64 | "subject": "Welcome to the platform", 65 | "body": "Welcome to the platform" 66 | } 67 | } 68 | ], 69 | "edges": [ 70 | { 71 | "from": "$source", 72 | "to": "1" 73 | } 74 | ] 75 | }, 76 | } 77 | }; -------------------------------------------------------------------------------- /packages/workflow/src/stories/ui.storybook.css: -------------------------------------------------------------------------------- 1 | .sb-wrap { 2 | height: 100vh; 3 | width: 100%; 4 | } -------------------------------------------------------------------------------- /packages/workflow/src/types.ts: -------------------------------------------------------------------------------- 1 | import { DirectedGraph } from "graphology"; 2 | import { TSchema } from "@sinclair/typebox"; 3 | import { type GetStepTools, type Inngest } from "inngest"; 4 | 5 | export interface EngineOptions { 6 | actions?: Array; 7 | 8 | /** 9 | * disableBuiltinActions disables the builtin actions from being used. 10 | * 11 | * For selectively adding built-in actions, set this to true and expose 12 | * the actions you want via the `availableActions` prop (in your Engine) 13 | */ 14 | disableBuiltinActions?: boolean; 15 | 16 | loader?: Loader; 17 | } 18 | 19 | // TODO: Define event type more clearly. 20 | export type TriggerEvent = Record; 21 | 22 | /** 23 | * PublicEngineAction is the type representing an action in the *frontend UI*. This is 24 | * a subset of the entire EngineAction type. 25 | * 26 | * Actions for workflows are defined in the backend, directly on the Engine. The Engine 27 | * provides an API which lists public information around the available actions - this type. 28 | */ 29 | export interface PublicEngineAction { 30 | /** 31 | * Kind is an enum representing the action's ID. This is not named as "id" 32 | * so that we can keep consistency with the WorkflowAction type. 33 | */ 34 | kind: string; 35 | 36 | /** 37 | * Name is the human-readable name of the action. 38 | */ 39 | name: string; 40 | 41 | /** 42 | * Description is a short description of the action. 43 | */ 44 | description?: string; 45 | 46 | /** 47 | * Icon is the name of the icon to use for the action. This may be an HTTP 48 | * URL, or an SVG directly. 49 | */ 50 | icon?: string; 51 | 52 | /** 53 | * Inputs define input variables which can be configured by the workflow UI. 54 | */ 55 | inputs?: Record; 56 | 57 | /** 58 | * Outputs define the responses from the action, including the type, name, and 59 | * an optional description 60 | */ 61 | outputs?: TSchema | Record; 62 | 63 | edges?: { 64 | /** 65 | * allowAdd controls whether the user can add new edges to the graph 66 | * via the "add" handle. 67 | * 68 | * If undefined this defaults to true (as most nodes should allow adding 69 | * subsequent actions). 70 | */ 71 | allowAdd?: boolean; 72 | 73 | /** 74 | * Edges allows the definition of predefined edges from this action, 75 | * eg. "True" and "False" edges for an if statement, or "Not received" 76 | * edges if an action contains `step.waitForEvent`. 77 | */ 78 | edges?: Array; 79 | }; 80 | } 81 | 82 | export type PublicEngineEdge = Omit; 83 | 84 | /** 85 | * EngineAction represents a reusable action, or step, within a workflow. It defines the 86 | * kind, the handler to run, the types for the action, and optionally custom UI for managing 87 | * the action's configuration within the workflow editor. 88 | * 89 | * Note that this is the type representing an action in the *backend engine*. 90 | * 91 | */ 92 | export interface EngineAction 93 | extends PublicEngineAction { 94 | /** 95 | * The handler is the function which runs the action. This may comprise of 96 | * many individual inngest steps. 97 | */ 98 | handler: ActionHandler>; 99 | } 100 | 101 | /** 102 | * ActionHandler runs logic for a given EngineAction 103 | */ 104 | export type ActionHandler = (args: ActionHandlerArgs) => Promise; 105 | 106 | export interface ActionInput { 107 | /** 108 | * Type is the TypeBox type for the input. This is used for type checking, validation, 109 | * and form creation. 110 | * 111 | * Note that this can include any of the JSON-schema refinements within the TypeBox type. 112 | * 113 | * @example 114 | * ``` 115 | * type: Type.String({ 116 | * title: "Email address", 117 | * description: "The email address to send the email to", 118 | * format: "email", 119 | * }) 120 | * ``` 121 | */ 122 | type: TSchema; 123 | 124 | /** 125 | * fieldType allows customization of the text input component, for string types. 126 | */ 127 | fieldType?: "textarea" | "text"; 128 | } 129 | 130 | export interface ActionOutput { 131 | type: TSchema; 132 | description?: string; 133 | } 134 | 135 | /** 136 | * Workflow represents a defined workflow configuration, with a chain or DAG of actions 137 | * configured for execution. 138 | * 139 | */ 140 | export interface Workflow { 141 | name?: string; 142 | description?: string; 143 | metadata?: Record; 144 | 145 | actions: Array; 146 | edges: Array; 147 | } 148 | 149 | /** 150 | * WorkflowAction is the representation of an action within a workflow instance. 151 | */ 152 | export interface WorkflowAction { 153 | /** 154 | * The ID of the action within the workflow instance. This is used as a reference and must 155 | * be unique within the Instance itself. 156 | * 157 | */ 158 | id: string; 159 | 160 | /** 161 | * The action kind, used to look up the EngineAction definition. 162 | * 163 | */ 164 | kind: string; 165 | 166 | name?: string; 167 | description?: string; 168 | 169 | /** 170 | * Inputs is a list of configured inputs for the EngineAction. 171 | * 172 | * The record key is the key of the EngineAction inoput name, and 173 | * the value is the variable's value. 174 | * 175 | * This will be type checked to match the EngineAction type before 176 | * save and before execution. 177 | * 178 | * Ref inputs for interpolation are "!ref($.)", 179 | * eg. "!ref($.event.data.email)" 180 | */ 181 | inputs?: Record; 182 | } 183 | 184 | export interface WorkflowEdge { 185 | from: string; 186 | to: string; 187 | 188 | /** 189 | * The name of the edge to show in the UI 190 | */ 191 | name?: string; 192 | 193 | /** 194 | * Conditional is a ref (eg. "!ref($.action.ifcheck.result)") which must be met 195 | * for the edge to be followed. 196 | * 197 | * The `conditional` field is automatically set when using the built-in if 198 | * action. 199 | * 200 | */ 201 | conditional?: { 202 | /** 203 | * type indicates whether this is the truthy if, the else block, or a 204 | * "select" case block which must match a given value. 205 | * 206 | * for "if", the value will be inteprolated via "!!" to a boolean. 207 | * for "else", the value is will be evaluated via "!" to a boolean. 208 | * for "match", the value is will be evaluated via "===" to a boolean. 209 | * 210 | * It is expected that "if" blocks are used with json-logic, 211 | * which create these conditional edges by default - hence the basic 212 | * boolean logic. 213 | * 214 | * This may change in the future; we may add json-logic directly here. 215 | */ 216 | type: "if" | "else" | "match"; 217 | /** 218 | * The ref to evaluate. This can use the shorthand: `!ref($.output)` to 219 | * refer to the previous action's output. 220 | */ 221 | ref: string; // Ref input, eg. "!ref($.output.email_id)" 222 | /** 223 | * Value to match against, if type is "match" 224 | */ 225 | value?: any; 226 | }; 227 | } 228 | 229 | /** 230 | * Loader represents a function which takes an Inngest event, then returns 231 | * a workflow Instance. 232 | * 233 | * For example, you may write a function which looks up a user's workflow, 234 | * stored as JSON, in a DB, unmarshalled into an Instance. 235 | * 236 | * If an Instance is not found, this should throw an error. 237 | */ 238 | export type Loader = (event: any) => Promise; 239 | 240 | export type DAG = DirectedGraph; 241 | 242 | export interface Node { 243 | kind: "$action" | "$source"; 244 | id: string; 245 | action?: WorkflowAction; 246 | } 247 | 248 | export interface Edge { 249 | edge: WorkflowEdge; 250 | } 251 | 252 | export interface ActionHandlerArgs { 253 | /** 254 | * Event is the event which triggered the workflow. 255 | */ 256 | event: TriggerEvent; 257 | 258 | /** 259 | * Step are the step functions from Inngest's SDK, allowing each 260 | * action to be executed as a durable step function. This exposes 261 | * all step APIs: `step.run`, `step.waitForEvent`, `step.sleep`, etc. 262 | * 263 | */ 264 | step: S; 265 | 266 | /** 267 | * Workflow is the workflow definition. 268 | */ 269 | workflow: Workflow; 270 | 271 | /** 272 | * WorkflowAction is the action being executed, with fully interpolate 273 | * inputs. 274 | */ 275 | workflowAction: WorkflowAction; 276 | 277 | /** 278 | * State represnets the current state of the workflow, with previous 279 | * action's outputs recorded as key-value pairs. 280 | */ 281 | state: Map; 282 | } 283 | 284 | export type RunArgs = { 285 | event: any; 286 | step: any; 287 | workflow?: Workflow; 288 | }; 289 | -------------------------------------------------------------------------------- /packages/workflow/src/ui/Editor.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useRef, useState } from 'react'; 2 | import { 3 | ReactFlow, 4 | useNodesState, 5 | useEdgesState, 6 | Node, 7 | Edge, 8 | ReactFlowProvider, 9 | useReactFlow, 10 | useNodesInitialized, 11 | Rect, 12 | NodeChange, 13 | } from '@xyflow/react'; 14 | import { Workflow, WorkflowAction } from "../types"; 15 | import { getLayoutedElements, parseWorkflow, useLayout } from './layout'; 16 | import { TriggerNode, ActionNode, BlankNode } from './nodes'; 17 | import { useProvider, useSidebarPosition, useTrigger, useWorkflow } from './Provider'; 18 | 19 | export type EditorProps = { 20 | direction?: Direction; 21 | children?: React.ReactNode; 22 | } 23 | 24 | export type Direction = "right" | "down"; 25 | 26 | export const Editor = (props: EditorProps) => { 27 | // Force the correct enum if the user passes in a string via non-TS usage. 28 | const direction = props.direction === "right" ? "right" : "down"; 29 | const sidebarPosition = useSidebarPosition(); 30 | 31 | let className = sidebarPosition === "left" ? "wf-editor-left-sidebar" : ""; 32 | 33 | return ( 34 | 35 |
    36 | 37 | {props.children} 38 |
    39 |
    40 | ); 41 | } 42 | 43 | const EditorUI = ({ direction = "down" }: EditorProps) => { 44 | const { workflow, trigger, setSelectedNode, blankNode, setBlankNode } = 45 | useProvider(); 46 | const nodesInitialized = useNodesInitialized(); 47 | 48 | // Retain the initial node measurement for computing layout when Workflow is refreshed. 49 | const [defaultNodeMeasure, setDefaultNodeMeasure] = useState<{ width: number, height: number } | undefined>(undefined); 50 | 51 | // Store a reference to the parent div to compute layout 52 | const ref = useRef(null); 53 | 54 | const { nodes: initialNodes, edges: initialEdges } = useMemo(() => 55 | parseWorkflow({ workflow, trigger }), 56 | []); 57 | 58 | const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); 59 | const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); 60 | 61 | // Lay out the nodes in the graph. 62 | const layoutRect = useLayout({ 63 | nodes: nodes, 64 | edges: edges, 65 | width: ref.current?.offsetWidth ?? 0, 66 | height: ref.current?.offsetHeight ?? 0, 67 | direction, 68 | setNodes, 69 | setEdges, 70 | nodesInitialized, 71 | defaultNodeMeasure, 72 | }); 73 | 74 | useHandleBlankNode(nodes, edges, setNodes, setEdges, direction, defaultNodeMeasure); 75 | useCenterGraph(layoutRect, ref); 76 | 77 | // When the workflow changes, we need to re-layout the graph. 78 | useEffect(() => { 79 | const { nodes, edges } = parseWorkflow({ workflow, trigger }); 80 | setNodes(nodes); 81 | setEdges(edges); 82 | }, [JSON.stringify(workflow?.edges || [])]); 83 | 84 | const nodeTypes = useMemo(() => ({ 85 | trigger: (node: any) => { // TODO: Define args type. 86 | const { trigger } = node.data; 87 | return 88 | }, 89 | action: (node: any) => { // TODO: Define args type. 90 | const { action} = node.data; 91 | return 92 | }, 93 | blank: () => { 94 | return 95 | } 96 | }), [direction]); 97 | 98 | return ( 99 |
    100 | { 107 | // If the event target is not a node, set the selected node to undefined. 108 | let target = event.target as HTMLElement, 109 | isNode = false, 110 | isBlank = false; 111 | 112 | const results = searchParents(target, ["wf-node", "wf-blank-node"], ref.current); 113 | 114 | if (!results["wf-blank-node"] && !!blankNode) { 115 | // Remove the blank node, as we've clicked elsewhere. 116 | setBlankNode(undefined); 117 | } 118 | if (!results["wf-node"]) { 119 | // Unselect any selected node, as we're not clicking on a node. 120 | setSelectedNode(undefined); 121 | } 122 | }} 123 | onNodeClick={(event, node) => { 124 | // Ensure we're not clicking the "add" handle. When we click 125 | // the add handle, we automatically select the blank node. Selecting 126 | // the node here would override that selection. 127 | const results = searchParents(event.target as HTMLElement, ["wf-add-handle"], ref.current); 128 | if (results["wf-add-handle"]) { 129 | return; 130 | } 131 | 132 | setSelectedNode(node); 133 | event.preventDefault(); 134 | }} 135 | onNodesChange={(args: NodeChange[]) => { 136 | // Required to store .measured in nodes for computing layout. 137 | onNodesChange(args); 138 | 139 | if (!defaultNodeMeasure && args.length > 0 && args[0]?.type === 'dimensions') { 140 | const item = args[0] as any; // TODO: Fix types 141 | setDefaultNodeMeasure(item?.dimensions as { width: number, height: number }); 142 | } 143 | }} 144 | key={direction} 145 | proOptions={{ hideAttribution: true }} 146 | /> 147 |
    148 | ); 149 | }; 150 | 151 | export type TriggerProps = { 152 | trigger?: any; // TODO: Define trigger type. 153 | } 154 | 155 | export type WorkflowProps = { 156 | workflow?: Workflow; 157 | } 158 | 159 | const useCenterGraph = (layoutRect: Rect, ref: React.RefObject) => { 160 | const flow = useReactFlow(); 161 | const nodesInitialized = useNodesInitialized(); 162 | 163 | const [centered, setCentered] = useState(false); 164 | 165 | useEffect(() => { 166 | if (!nodesInitialized) { 167 | return 168 | } 169 | if (centered) { 170 | return; 171 | } 172 | 173 | // Only do this once per render. 174 | setCentered(true); 175 | 176 | // If the workflow is too big for the current viewport, zoom out. 177 | // Otherwise, don't zoom in and center the current graph. 178 | if ( 179 | (layoutRect?.width > (ref.current?.offsetWidth ?? 0)) 180 | || (layoutRect?.height > (ref.current?.offsetHeight ?? 0)) 181 | ) { 182 | flow.fitView(); 183 | return; 184 | } 185 | 186 | const w = ref.current?.offsetWidth ?? 0; 187 | const h = ref.current?.offsetHeight ?? 0; 188 | if (w === 0 || h === 0) { 189 | return; 190 | } 191 | 192 | const fitRect = { 193 | x: -1 * (w - layoutRect.width) / 2, // center the node rect in the viewport 194 | y: -1 * (h - layoutRect.height) / 2, 195 | width: w, // use viewport width 196 | height: h, // use viewport height 197 | } 198 | 199 | flow.fitBounds(fitRect); 200 | }, [nodesInitialized]); 201 | } 202 | 203 | // useHandleBlankNode is a hook that handles the logic for adding and removing 204 | // blank nodes in a graph. 205 | // 206 | // Blank nodes are added when clicking the "AddHandle". This mutates the Provider 207 | // state, which we then listen to here in order to manipulate react flow. 208 | const useHandleBlankNode = ( 209 | nodes: Node[], 210 | edges: Edge[], 211 | setNodes: (nodes: Node[]) => void, 212 | setEdges: (edges: Edge[]) => void, 213 | direction: Direction, 214 | defaultNodeMeasure: { width: number, height: number } | undefined, 215 | ) => { 216 | const { blankNode } = useProvider(); 217 | 218 | useEffect(() => { 219 | // We must manually update the react-flow nodes and edges as they're controlled 220 | // via internal state. 221 | if (blankNode) { 222 | // Add the blank node and its edge. 223 | 224 | // Ensure that the blank node's measured entry is filled. This fixes a layout bug shift. 225 | // Measured is undefined when a node is being added, and is filled after react-flow renders 226 | // the node for the first time. 227 | if (!blankNode.measured) { 228 | blankNode.measured = nodes[0]?.measured || defaultNodeMeasure; 229 | } 230 | 231 | const newNodes = [...nodes, blankNode]; 232 | const newEdges = [...edges, { 233 | id: `blank-node-edge`, 234 | source: blankNode.data.parent.id, 235 | target: '$blank', 236 | type: 'smoothstep', 237 | }]; 238 | 239 | // For each node, ensure there's a measured entry. 240 | 241 | // Re-layout the graph prior to re-rendering. 242 | const result = getLayoutedElements(newNodes, newEdges, direction); 243 | 244 | setNodes(result.nodes); 245 | setEdges(result.edges); 246 | } else { 247 | // Remove the blank node and its edge. 248 | setNodes(nodes.filter((node) => node.id !== '$blank')); 249 | setEdges(edges.filter((edge) => edge.target !== '$blank')); 250 | } 251 | }, [blankNode]); 252 | } 253 | 254 | 255 | // searchParents is a utility to search parent elements for given clasnames. It returns 256 | // a record of whether each class was found. 257 | const searchParents = (target: HTMLElement, search: string[], until?: HTMLElement | null): Record => { 258 | const result: Record = {}; 259 | 260 | while (target !== until) { 261 | for (const key of search) { 262 | if (target.classList.contains(key)) { 263 | result[key] = true; 264 | } 265 | } 266 | target = target.parentElement as HTMLElement; 267 | if (!target) { 268 | break; 269 | } 270 | } 271 | return result; 272 | } -------------------------------------------------------------------------------- /packages/workflow/src/ui/Provider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from 'react'; 2 | import { Node } from '@xyflow/react'; 3 | import { PublicEngineAction, PublicEngineEdge, Workflow } from "../types"; 4 | import { BlankNodeType } from './nodes'; 5 | import { parseWorkflow } from './layout'; 6 | 7 | export type ProviderProps = { 8 | // The workflow to be modified by the user. 9 | workflow: Workflow; 10 | 11 | // The trigger which will run the workflow. 12 | trigger: any; 13 | 14 | // The available actions which can be used within the workflow. 15 | availableActions: PublicEngineAction[]; 16 | 17 | // onChange is called when the workflow is changed via any interactivity 18 | // with the workflow editor. This may be called many times on any update. 19 | // 20 | // Note that this does not imply that you must save the workflow. You may 21 | // store the workflow in an internal state and only save when the user 22 | // hits a save button. 23 | onChange: (w: Workflow) => any; 24 | }; 25 | 26 | export type ProviderContextType = ProviderProps & { 27 | // Used so that the `position` prop in the `Sidebar` component can set 28 | // state and be accessed by other components. We need this to set the 29 | // correct classnames on the editor and parent container. 30 | sidebarPosition: "right" | "left"; 31 | setSidebarPosition: (p: "right" | "left") => void; 32 | 33 | selectedNode: Node | undefined; 34 | setSelectedNode: (n: Node | undefined) => void; 35 | 36 | // blankNodeParent represents the parent of a blank node. The blank node 37 | // is used as a placeholder while users select the action to add to the workflow. 38 | // 39 | // This is set when the "Add" handle is clicked. It's unset when any 40 | // other node or the background is clicked. 41 | blankNode?: BlankNodeType | undefined; 42 | setBlankNode: (n: BlankNodeType | undefined) => void; 43 | 44 | // appendAction appends an action to the workflow under the given parent action ID. 45 | // The parentID may be "$source" to represent the trigger. 46 | // 47 | // The "edge" may be provided from the PublicEngineAction, eg. to specify a true or false edge. 48 | appendAction: (action: PublicEngineAction, parentID: string, edge?: PublicEngineEdge) => void; 49 | 50 | // deleteAction deletes an action from the workflow. 51 | deleteAction: (actionID: string) => void; 52 | 53 | // TODO: Drag n drop 54 | } 55 | 56 | export const ProviderContext = React.createContext(undefined); 57 | 58 | 59 | export const useOnChange = (): (w: Workflow) => void => { 60 | const ctx = useContext(ProviderContext); 61 | return ctx?.onChange ?? (() => { }); 62 | } 63 | 64 | /** 65 | * Hook for accessing the workflow we're modifying 66 | * 67 | */ 68 | export const useWorkflow = (): Workflow | undefined => { 69 | const ctx = useContext(ProviderContext); 70 | return ctx?.workflow; 71 | } 72 | 73 | /** 74 | * Hook for accessing the trigger which runs the workflow. 75 | * 76 | */ 77 | export const useTrigger = (): any => { 78 | const ctx = useContext(ProviderContext); 79 | return ctx?.trigger; 80 | } 81 | 82 | /** 83 | * Hook for accessing the available actions we can use within 84 | * the workflow. 85 | * 86 | */ 87 | export const useAvailableActions = (): PublicEngineAction[] => { 88 | const ctx = useContext(ProviderContext); 89 | return ctx?.availableActions ?? []; 90 | } 91 | 92 | 93 | /** 94 | * Hook for accessing the position of the sidebar. Only for internal 95 | * use. 96 | * 97 | * @returns the position of the sidebar. 98 | */ 99 | export const useSidebarPosition = (): "right" | "left" => { 100 | const ctx = useContext(ProviderContext); 101 | return ctx?.sidebarPosition ?? "right"; 102 | } 103 | 104 | export const useProvider = (): ProviderContextType => { 105 | const ctx = useContext(ProviderContext); 106 | if (!ctx) { 107 | throw new Error("useProvider must be used within a Provider"); 108 | } 109 | return ctx; 110 | }; 111 | 112 | export const Provider = (props: ProviderProps & { children: React.ReactNode }) => { 113 | const { children, workflow, trigger, onChange, availableActions } = props; 114 | const [sidebarPosition, setSidebarPosition] = useState<"right" | "left">("right"); 115 | const [selectedNode, setSelectedNode] = useState(undefined); 116 | const [blankNode, setBlankNode] = useState(undefined); 117 | 118 | const appendAction = (action: PublicEngineAction, parentID: string, edge?: PublicEngineEdge) => { 119 | const id = ((workflow?.actions?.length ?? 0) + 1).toString(); 120 | 121 | const workflowCopy = { 122 | ...workflow, 123 | actions: (workflow?.actions ?? []).slice(), 124 | edges: (workflow?.edges ?? []).slice(), 125 | }; 126 | 127 | workflowCopy.actions.push({ 128 | id, 129 | kind: action.kind, 130 | name: action.name, 131 | }); 132 | workflowCopy.edges.push({ 133 | ...(edge || {}), 134 | from: parentID, 135 | to: id, 136 | }); 137 | 138 | onChange(workflowCopy); 139 | setBlankNode(undefined); 140 | 141 | // Parse the workflow and find the new node for selection. 142 | const parsed = parseWorkflow({ workflow: workflowCopy, trigger }); 143 | const newNode = parsed.nodes.find((n) => n.id === id); 144 | if (newNode) { 145 | setSelectedNode(newNode); 146 | } 147 | } 148 | 149 | const deleteAction = (actionID: string) => { 150 | if (!workflow) return; 151 | 152 | const workflowCopy = { 153 | ...workflow, 154 | actions: workflow.actions.filter(action => action.id !== actionID), 155 | edges: workflow.edges.slice(), 156 | }; 157 | 158 | // Find the parent edge and remove it 159 | const parentEdgeIndex = workflowCopy.edges.findIndex(edge => edge.to === actionID); 160 | const parentEdge = workflowCopy.edges[parentEdgeIndex]; 161 | 162 | if (!parentEdge) { 163 | return; 164 | } 165 | 166 | workflowCopy.edges.splice(parentEdgeIndex, 1); 167 | 168 | // Find child edges and update them 169 | const childEdges = workflowCopy.edges.filter(edge => edge.from === actionID); 170 | childEdges.forEach(childEdge => { 171 | const updatedChildEdge = { 172 | ...childEdge, 173 | from: parentEdge.from, 174 | }; 175 | const index = workflowCopy.edges.findIndex(edge => edge.to === childEdge.to && edge.from === actionID); 176 | workflowCopy.edges[index] = updatedChildEdge; 177 | }); 178 | 179 | onChange(workflowCopy); 180 | }; 181 | 182 | // TODO: Add customizable React components here to the Provider 183 | 184 | return ( 185 | 200 | {children} 201 | 202 | ); 203 | } 204 | -------------------------------------------------------------------------------- /packages/workflow/src/ui/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | export * from "./sidebar/ActionForm"; 2 | export * from "./sidebar/ActionList"; 3 | export * from "./sidebar/Footer"; 4 | export * from "./sidebar/WorfklowForm"; 5 | export * from "./sidebar/Sidebar"; 6 | -------------------------------------------------------------------------------- /packages/workflow/src/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { 2 | Editor, 3 | type Direction, 4 | type EditorProps, 5 | } from "./Editor"; 6 | export { 7 | Sidebar, 8 | type SidebarProps, 9 | } from "./Sidebar"; 10 | export { 11 | Provider, 12 | ProviderContext, 13 | useWorkflow, 14 | useTrigger, 15 | useAvailableActions, 16 | useOnChange, 17 | type ProviderProps, 18 | } from "./Provider"; 19 | export { 20 | TriggerNode, 21 | ActionNode, 22 | } from "./nodes"; 23 | 24 | // TODO: Trigger type -------------------------------------------------------------------------------- /packages/workflow/src/ui/layout.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo, useEffect } from 'react'; 2 | import Dagre from '@dagrejs/dagre'; 3 | import { Node, Edge, Rect } from '@xyflow/react'; 4 | import { TriggerProps, WorkflowProps, type Direction } from './Editor'; 5 | import { KeyOfFromMappedResult } from '@sinclair/typebox'; 6 | import { useProvider } from './Provider'; 7 | 8 | type LayoutArgs = { 9 | nodes: Node[]; 10 | edges: Edge[]; 11 | width: number; 12 | height: number; 13 | direction: Direction; 14 | 15 | setNodes: (nodes: Node[]) => void; 16 | setEdges: (edges: Edge[]) => void; 17 | nodesInitialized: boolean; 18 | 19 | // The default node measure exists after the first render. Nodes reset 20 | // when the parent Workflow prop changes, and we use this as a memoized fallback 21 | // to retain layout. 22 | defaultNodeMeasure: { width: number, height: number } | undefined; 23 | } 24 | 25 | 26 | export const useLayout = (args: LayoutArgs): Rect => { 27 | const { workflow, trigger } = useProvider(); 28 | 29 | const { width, height, direction, setNodes, setEdges, nodesInitialized } = args; 30 | let { nodes, edges } = args; 31 | 32 | // Force a redraw any time the actions or edges change. 33 | const parsed = parseWorkflow({ workflow, trigger }); 34 | if (parsed.nodes.length > nodes.length) { 35 | nodes = parsed.nodes; 36 | edges = parsed.edges; 37 | } 38 | 39 | return useMemo(() => { 40 | if (!nodesInitialized) { 41 | return { x: 0, y: 0, width: 0, height: 0 }; 42 | } 43 | 44 | // Ensure theres a measured property on every node. 45 | const nodesWithMeasures = nodes.map((node) => { 46 | if (!node.measured) { 47 | return { ...node, measured: args.defaultNodeMeasure }; 48 | } 49 | return node; 50 | }); 51 | 52 | const { nodes: newNodes, edges: newEdges, rect } = getLayoutedElements(nodesWithMeasures, edges, direction) 53 | 54 | setNodes(newNodes); 55 | setEdges(newEdges); 56 | 57 | return rect; 58 | }, [JSON.stringify(nodes), JSON.stringify(edges), width, height, direction, nodesInitialized]); 59 | } 60 | 61 | export const getLayoutedElements = (nodes: Node[], edges: Edge[], direction: Direction): { nodes: Node[], edges: Edge[], rect: Rect } => { 62 | const g = new Dagre.graphlib.Graph({ directed: true }).setDefaultEdgeLabel(() => ({})); 63 | 64 | let nodePadding = 60 // TODO: Make this configurable. 65 | if (direction === "right") { 66 | nodePadding += 50; 67 | } 68 | 69 | g.setGraph({ 70 | nodesep: 100, // TODO: Make this configurable. xpad 71 | ranksep: nodePadding, // TODO: Make this configurable. ypad 72 | rankdir: direction === "down" ? "TB" : "LR" 73 | }); 74 | 75 | // Add all nodes to the graph to compute the ideal layout 76 | nodes.forEach((node) => 77 | g.setNode(node.id, { 78 | width: node.measured?.width ?? 0, 79 | height: node.measured?.height ?? 0, 80 | node, 81 | }), 82 | ); 83 | 84 | edges.forEach((edge) => g.setEdge(edge.source, edge.target)); 85 | 86 | Dagre.layout(g); 87 | 88 | // Calculate the bounding rects of the graph. 89 | const layout = g.nodes().map((nodeID) => { 90 | const dagreNode = g.node(nodeID); 91 | const node = (dagreNode as any).node as Node; 92 | 93 | const x = dagreNode.x - (node.measured?.width ?? 0) / 2; 94 | const y = dagreNode.y - (node.measured?.height ?? 0) / 2; 95 | 96 | return { ...node, position: { x, y } }; 97 | }); 98 | 99 | const minX = Math.min(...layout.map((node) => node.position.x)); 100 | const minY = Math.min(...layout.map((node) => node.position.y)); 101 | const maxX = Math.max(...layout.map((node) => node.position.x + (node.measured?.width ?? 0))); 102 | const maxY = Math.max(...layout.map((node) => node.position.y + (node.measured?.height ?? 0))); 103 | 104 | return { 105 | nodes: layout, 106 | edges, 107 | rect: { 108 | x: minX, 109 | y: minY, 110 | width: maxX - minX, 111 | height: maxY - minY, 112 | } 113 | }; 114 | 115 | }; 116 | 117 | type parseWorkflowProps = WorkflowProps & TriggerProps & { blankNodeParent?: Node } 118 | 119 | export const parseWorkflow = ({ workflow, trigger, blankNodeParent }: parseWorkflowProps): { nodes: Node[], edges: Edge[] } => { 120 | const nodes: Node[] = []; 121 | const edges: Edge[] = []; 122 | 123 | // Add trigger node 124 | nodes.push({ 125 | id: '$source', 126 | type: 'trigger', 127 | position: { x: 0, y: 0 }, 128 | data: { trigger } 129 | }); 130 | 131 | // Always handle the blank node case. 132 | if (blankNodeParent) { 133 | nodes.push({ 134 | id: '$blank', 135 | type: 'blank', 136 | position: { x: 0, y: 0 }, 137 | data: { parent: blankNodeParent } 138 | }); 139 | edges.push({ 140 | id: `blank-node-edge`, 141 | source: blankNodeParent.id, 142 | target: '$blank', 143 | type: 'smoothstep', 144 | }); 145 | } 146 | 147 | if (!workflow) { 148 | return { nodes, edges }; 149 | } 150 | 151 | (workflow.actions || []).forEach((action, index) => { 152 | nodes.push({ 153 | id: action.id, 154 | type: 'action', 155 | position: { x: 0, y: 0 }, 156 | data: { action } 157 | }); 158 | }); 159 | 160 | (workflow.edges || []).forEach((edge) => { 161 | edges.push({ 162 | id: `${edge.from}-${edge.to}`, 163 | source: edge.from, 164 | target: edge.to, 165 | label: edge.name, 166 | type: 'smoothstep', 167 | }); 168 | }); 169 | 170 | // TODO: Always add an end node to every sink. 171 | return { nodes, edges }; 172 | }; 173 | -------------------------------------------------------------------------------- /packages/workflow/src/ui/nodes.tsx: -------------------------------------------------------------------------------- 1 | export { 2 | ActionNode, 3 | BlankNode, 4 | type ActionNodeProps, 5 | } from "./nodes/Action"; 6 | 7 | export { 8 | TriggerNode, 9 | type TriggerNodeProps, 10 | } from "./nodes/Trigger"; 11 | 12 | export { 13 | NewBlankNode, 14 | type BlankNodeType, 15 | } from "./nodes/Handles"; -------------------------------------------------------------------------------- /packages/workflow/src/ui/nodes/Action.tsx: -------------------------------------------------------------------------------- 1 | import { Handle, Node } from "@xyflow/react"; 2 | import { WorkflowAction } from "../../"; 3 | import { Direction } from "../Editor"; 4 | import { useProvider } from "../Provider"; 5 | import { AddHandle, sourceHandleProps, targetHandleProps } from "./Handles"; 6 | 7 | export type ActionNodeProps = { 8 | action: WorkflowAction, 9 | node: Node, 10 | direction: Direction 11 | } 12 | 13 | /** 14 | * ActionNode represents a single action in the workflow. 15 | * 16 | * @param action - The action within the workflow that this node represents. 17 | * @param direction - The direction of the workflow, used to determine how handles are placed. 18 | */ 19 | export const ActionNode = ({ action, node, direction }: ActionNodeProps) => { 20 | const { selectedNode, availableActions } = useProvider(); 21 | const engineAction = availableActions.find(a => a.kind === action.kind); 22 | 23 | const isSelected = selectedNode?.type === "action" && selectedNode.id === node.id; 24 | 25 | return ( 26 |
    29 | 30 |
    31 |
    32 | {engineAction?.icon || } 33 |
    34 |

    {action.name || engineAction?.name || action.kind}

    35 |
    36 | 37 |

    { action.description || engineAction?.description || "Performs an action" }

    38 | 39 | {/* TODO: Add handle with menu options */} 40 | 41 |
    42 | ); 43 | } 44 | 45 | /** 46 | * BlankNode is a placeholder node, used as a placeholder for users to select 47 | * an action after hitting the "Add Action" handle. 48 | * 49 | * BlankNodes are temporary; the state is stored in the provider context. As 50 | * soon as a click happens outside of blank node the blank node is deleted. 51 | */ 52 | export const BlankNode = ({ direction }: { direction: Direction }) => { 53 | return ( 54 |
    55 | 56 |
    57 | ); 58 | } 59 | 60 | const DefaultIcon = () => ( 61 | 62 | 63 | 64 | ) -------------------------------------------------------------------------------- /packages/workflow/src/ui/nodes/Handles.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Handle, HandleProps, Position } from "@xyflow/react"; 3 | import * as Popover from '@radix-ui/react-popover'; 4 | import { Node } from "@xyflow/react"; 5 | 6 | import { useProvider } from "../Provider"; 7 | import { Direction } from "../Editor"; 8 | import { PublicEngineEdge, WorkflowAction } from "../../types"; 9 | 10 | export type BlankNodeType = Node<{ 11 | parent: Node, 12 | 13 | /** 14 | * Edge stores the edge information if this was a predefined edge from eg. an 15 | * if block. 16 | */ 17 | edge?: PublicEngineEdge 18 | }>; 19 | 20 | export const NewBlankNode = (parent: Node, edge?: PublicEngineEdge): BlankNodeType => ({ 21 | id: '$blank', 22 | type: 'blank', 23 | position: { x: 0, y: 0 }, 24 | data: { 25 | parent: parent, 26 | edge: edge 27 | } 28 | }) 29 | 30 | export const AddHandle = (props: HandleProps & { node: Node, action?: WorkflowAction }) => { 31 | const { node, action, ...rest } = props; 32 | const { setBlankNode, setSelectedNode, availableActions } = useProvider(); 33 | const [menuOpen, setMenuOpen] = useState(false); 34 | 35 | // We want to find out whether the engine action's definition has any built-in edges, 36 | // or if we disable the 'Add new node' handle. 37 | const engineAction = availableActions.find((ea) => ea.kind === action?.kind); 38 | const edges = engineAction?.edges?.edges || []; 39 | 40 | if (engineAction?.edges?.allowAdd === false && edges.length === 0) { 41 | return null; 42 | } 43 | 44 | // This is the default handler for adding a new blank node. 45 | const addNode = (edge?: PublicEngineEdge): BlankNodeType => { 46 | const blankNode = NewBlankNode(node, edge); 47 | setBlankNode(blankNode); 48 | setSelectedNode(blankNode); 49 | return blankNode; 50 | } 51 | 52 | const renderHandle = (onClick?: () => void) => ( 53 | 54 |
    55 | 56 | 57 | 58 |
    59 |
    60 | ); 61 | 62 | if (!edges.length) { 63 | return renderHandle(() => addNode()); 64 | } 65 | 66 | return ( 67 | 68 | 69 | { 70 | // This handler has no onClick, as we instead handle everything within the popover 71 | renderHandle() 72 | } 73 | 74 | 75 | 76 |
    77 | {edges.map((edge) => ( 78 |
    { 82 | const blankNode = addNode(edge); 83 | setMenuOpen(false); 84 | // This is a hack to auto-select the blank node. We need to fix this shit. 85 | window?.setTimeout(() => { setSelectedNode(blankNode); }, 0); 86 | }} 87 | > 88 |

    {edge.name}

    89 |
    90 | ))} 91 |
    92 |
    93 |
    94 |
    95 | ); 96 | } 97 | 98 | export const targetHandleProps = (direction: Direction): HandleProps => { 99 | return { 100 | type: "target", 101 | position: direction === "down" ? Position.Top : Position.Left, 102 | className: "wf-target-handle", 103 | } 104 | } 105 | 106 | export const sourceHandleProps = (direction: Direction): HandleProps => { 107 | return { 108 | type: "source", 109 | position: direction === "down" ? Position.Bottom : Position.Right, 110 | } 111 | } -------------------------------------------------------------------------------- /packages/workflow/src/ui/nodes/Trigger.tsx: -------------------------------------------------------------------------------- 1 | import { TriggerProps, Direction } from "../Editor"; 2 | import { useProvider } from "../Provider"; 3 | import { Node } from "@xyflow/react"; 4 | import { AddHandle, sourceHandleProps } from "./Handles"; 5 | 6 | export type TriggerNodeProps = TriggerProps & { 7 | direction: Direction; 8 | node: Node; 9 | }; 10 | 11 | /** 12 | * TriggerNode represents the trigger of the workflow. 13 | * 14 | * @param trigger - The trigger within the workflow. 15 | * @param direction - The direction of the workflow, used to determine how handles are placed. 16 | */ 17 | export const TriggerNode = ({ trigger, node, direction }: TriggerNodeProps) => { 18 | const { selectedNode, workflow } = useProvider(); 19 | 20 | const isSelected = selectedNode?.type === "trigger"; 21 | 22 | return ( 23 |
    26 |
    27 |
    28 | 29 |
    30 |

    { trigger === undefined ? "Select a trigger" : trigger?.event?.name }

    31 |
    32 | 33 |

    { workflow?.description || "Starts the workflow" }

    34 | 35 | { trigger && } 36 |
    37 | ); 38 | } 39 | 40 | const Icon = () => ( 41 | 42 | 46 | 47 | ) -------------------------------------------------------------------------------- /packages/workflow/src/ui/sidebar/ActionForm.tsx: -------------------------------------------------------------------------------- 1 | import { ActionInput, PublicEngineAction, WorkflowAction } from "../../types"; 2 | import { useProvider } from '../Provider'; 3 | 4 | type SidebarActionFormProps = { 5 | workflowAction: WorkflowAction, 6 | engineAction: PublicEngineAction | undefined, 7 | } 8 | 9 | export const SidebarActionForm = ({ workflowAction, engineAction }: SidebarActionFormProps) => { 10 | if (engineAction === undefined) { 11 | return ( 12 |
    13 | {/* TODO: Add a nicer looking error state */} 14 |
    15 | {`Action ${workflowAction.kind} not found in provider.`} 16 |
    17 |
    18 | ) 19 | } 20 | 21 | return ( 22 | <> 23 |
    24 |

    {engineAction.name}

    25 |

    {engineAction.description}

    26 |
    27 |
    28 | Configure 29 | {InputFormUI(engineAction.inputs || {})} 30 |
    31 | 32 | ); 33 | } 34 | 35 | export const InputFormUI = (inputs: Record) => { 36 | // TODO: Handle different input types 37 | // TODO: Allow variables to be inserted into the input, based off of the event 38 | // or previous actions. 39 | return ( 40 | <> 41 | {Object.entries(inputs).map(([id, input]) => ( 42 | 47 | ))} 48 | 49 | ) 50 | } 51 | 52 | const FormUIInputRenderer = ({ id, input }: { id: string, input: ActionInput }) => { 53 | const { selectedNode, onChange, workflow } = useProvider(); 54 | 55 | const action = selectedNode!.data.action; 56 | action.inputs = action.inputs || {}; 57 | 58 | const updateWorkflowAction = () => { 59 | const workflowCopy = { 60 | ...workflow 61 | }; 62 | 63 | workflowCopy.actions = workflow.actions.map( 64 | (a) => a.id !== action.id 65 | ? a 66 | : { 67 | ...a, 68 | inputs: action.inputs, 69 | } 70 | ); 71 | 72 | onChange(workflowCopy); 73 | } 74 | 75 | if (input.fieldType === "textarea") { 76 | return ( 77 |