Editable workflows (#792)

Co-authored-by: Muhammed Salih Altun <muhammedsalihaltun@gmail.com>
This commit is contained in:
Kerem Yilmaz
2024-09-09 02:15:50 -07:00
committed by GitHub
parent c85d868c13
commit f940c71e87
45 changed files with 2709 additions and 322 deletions

View File

@@ -19,6 +19,7 @@
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-separator": "^1.0.3",
@@ -1622,6 +1623,419 @@
} }
} }
}, },
"node_modules/@radix-ui/react-popover": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz",
"integrity": "sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-dismissable-layer": "1.1.0",
"@radix-ui/react-focus-guards": "1.1.0",
"@radix-ui/react-focus-scope": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-popper": "1.2.0",
"@radix-ui/react-portal": "1.1.1",
"@radix-ui/react-presence": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-slot": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "2.5.7"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/primitive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.0.tgz",
"integrity": "sha512-4Z8dn6Upk0qk4P74xBhZ6Hd/w0mPEzOOLxy4xiPXOXqjF7jZS0VAKk7/x/H6FyY2zCkYJqePf1G5KmkmNJ4RBA=="
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-arrow": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.0.tgz",
"integrity": "sha512-FmlW1rCg7hBpEBwFbjHwCW6AmWLQM6g/v0Sn8XbP9NvmSZ2San1FpQeyPtufzOMSIx7Y4dzjlHoifhp+7NkZhw==",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.0.tgz",
"integrity": "sha512-b4inOtiaOnYf9KWyO3jAeeCG6FeyfY6ldiEPanbUjWd+xIk5wZeHa8yVwmrJ2vderhu/BQvzCrJI0lHd+wIiqw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-context": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.0.tgz",
"integrity": "sha512-OKrckBy+sMEgYM/sMmqmErVn0kZqrHPJze+Ql3DzYsDDp0hl0L62nx/2122/Bvps1qz645jlcu2tD9lrRSdf8A==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.0.tgz",
"integrity": "sha512-/UovfmmXGptwGcBQawLzvn2jOfM0t4z3/uKffoBlj724+n3FvBbZ7M0aaBOmkp6pqFYpO4yx8tSVJjx3Fl2jig==",
"dependencies": {
"@radix-ui/primitive": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-guards": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.0.tgz",
"integrity": "sha512-w6XZNUPVv6xCpZUqb/yN9DL6auvpGX3C/ee6Hdi16v2UUy25HV2Q5bcflsiDyT/g5RwbPQ/GIT1vLkeRb+ITBw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-focus-scope": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.0.tgz",
"integrity": "sha512-200UD8zylvEyL8Bx+z76RJnASR2gRMuxlgFCPAe/Q/679a/r0eK3MBVYMb7vZODZcffZBdob1EGnky78xmVvcA==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-id": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.0.tgz",
"integrity": "sha512-EJUrI8yYh7WOjNOqpoJaf1jlFIH2LvtgAl+YcFqNCa+4hj64ZXmPkAKOFs/ukjz3byN6bdb/AVUqHkI8/uWWMA==",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-popper": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.0.tgz",
"integrity": "sha512-ZnRMshKF43aBxVWPWvbj21+7TQCvhuULWJ4gNIKYpRlQt5xGRhLx66tMp8pya2UkGHTSlhpXwmjqltDYHhw7Vg==",
"dependencies": {
"@floating-ui/react-dom": "^2.0.0",
"@radix-ui/react-arrow": "1.1.0",
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-context": "1.1.0",
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0",
"@radix-ui/react-use-rect": "1.1.0",
"@radix-ui/react-use-size": "1.1.0",
"@radix-ui/rect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.1.tgz",
"integrity": "sha512-A3UtLk85UtqhzFqtoC8Q0KvR2GbXF3mtPgACSazajqq6A41mEQgo53iPzY4i6BwDxlIFqWIhiQ2G729n+2aw/g==",
"dependencies": {
"@radix-ui/react-primitive": "2.0.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.0.tgz",
"integrity": "sha512-Gq6wuRN/asf9H/E/VzdKoUtT8GC9PQc9z40/vEr0VCJ4u5XvvhWIrSsCB6vD2/cH7ugTdSfYq9fLJCcM00acrQ==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-primitive": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.0.tgz",
"integrity": "sha512-ZSpFm0/uHa8zTvKBDjLFWLo8dkr4MBsiDLz0g3gMUwqgLHz9rTaRRGYDgvZPtBJgYCBKXkS9fzmoySgr8CO6Cw==",
"dependencies": {
"@radix-ui/react-slot": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz",
"integrity": "sha512-FUCf5XMfmW4dtYl69pdS4DbxKy8nj4M7SafBgPllysxmdachynNflAdp/gCsnYWNDnge6tI9onzMp5ARYc1KNw==",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-callback-ref": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.0.tgz",
"integrity": "sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-controllable-state": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.1.0.tgz",
"integrity": "sha512-MtfMVJiSr2NjzS0Aa90NPTnvTSg6C/JLCV7ma0W6+OMV78vd8OyRpID+Ng9LxzsPbLeuBnWBA1Nq30AtBIDChw==",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-escape-keydown": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.0.tgz",
"integrity": "sha512-L7vwWlR1kTTQ3oh7g1O0CBF3YCyyTj8NmhLR+phShpyA50HCfBFKVJTpshm9PzLiKmehsrQzTYTpX9HvmC9rhw==",
"dependencies": {
"@radix-ui/react-use-callback-ref": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-layout-effect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.0.tgz",
"integrity": "sha512-+FPE0rOdziWSrH9athwI1R0HDVbWlEhd+FR+aSDk4uWGmSJ9Z54sdZVDQPZAinJhJXwfT+qnj969mCsT2gfm5w==",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-rect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.0.tgz",
"integrity": "sha512-0Fmkebhr6PiseyZlYAOtLS+nb7jLmpqTrJyv61Pe68MKYW6OWdRE2kI70TaYY27u7H0lajqM3hSMMLFq18Z7nQ==",
"dependencies": {
"@radix-ui/rect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-use-size": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.0.tgz",
"integrity": "sha512-XW3/vWuIXHa+2Uwcc2ABSfcCledmXhhQPlGbfcRXbiUQI5Icjcg19BGCZVKKInYbvUCut/ufbbLLPFC5cbb1hw==",
"dependencies": {
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/rect": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.0.tgz",
"integrity": "sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg=="
},
"node_modules/@radix-ui/react-popover/node_modules/react-remove-scroll": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.7.tgz",
"integrity": "sha512-FnrTWO4L7/Bhhf3CYBNArEG/yROV0tKmTv7/3h9QCFvH6sndeFf1wPqOcbFVu5VAulS5dV1wGT3GZZ/1GawqiA==",
"dependencies": {
"react-remove-scroll-bar": "^2.3.4",
"react-style-singleton": "^2.2.1",
"tslib": "^2.1.0",
"use-callback-ref": "^1.3.0",
"use-sidecar": "^1.1.2"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popper": { "node_modules/@radix-ui/react-popper": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.3.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.1.3.tgz",

View File

@@ -27,6 +27,7 @@
"@radix-ui/react-dropdown-menu": "^2.0.6", "@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-radio-group": "^1.1.3", "@radix-ui/react-radio-group": "^1.1.3",
"@radix-ui/react-select": "^2.0.0", "@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-separator": "^1.0.3",

View File

@@ -0,0 +1,24 @@
type Props = {
className: string;
};
function GarbageIcon({ className }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={className}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0"
/>
</svg>
);
}
export { GarbageIcon };

View File

@@ -0,0 +1,21 @@
function SaveIcon() {
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M7 3V6.4C7 6.96005 7 7.24008 7.10899 7.45399C7.20487 7.64215 7.35785 7.79513 7.54601 7.89101C7.75992 8 8.03995 8 8.6 8H15.4C15.9601 8 16.2401 8 16.454 7.89101C16.6422 7.79513 16.7951 7.64215 16.891 7.45399C17 7.24008 17 6.96005 17 6.4V4M17 21V14.6C17 14.0399 17 13.7599 16.891 13.546C16.7951 13.3578 16.6422 13.2049 16.454 13.109C16.2401 13 15.9601 13 15.4 13H8.6C8.03995 13 7.75992 13 7.54601 13.109C7.35785 13.2049 7.20487 13.3578 7.10899 13.546C7 13.7599 7 14.0399 7 14.6V21M21 9.32548V16.2C21 17.8802 21 18.7202 20.673 19.362C20.3854 19.9265 19.9265 20.3854 19.362 20.673C18.7202 21 17.8802 21 16.2 21H7.8C6.11984 21 5.27976 21 4.63803 20.673C4.07354 20.3854 3.6146 19.9265 3.32698 19.362C3 18.7202 3 17.8802 3 16.2V7.8C3 6.11984 3 5.27976 3.32698 4.63803C3.6146 4.07354 4.07354 3.6146 4.63803 3.32698C5.27976 3 6.11984 3 7.8 3H14.6745C15.1637 3 15.4083 3 15.6385 3.05526C15.8425 3.10425 16.0376 3.18506 16.2166 3.29472C16.4184 3.4184 16.5914 3.59135 16.9373 3.93726L20.0627 7.06274C20.4086 7.40865 20.5816 7.5816 20.7053 7.78343C20.8149 7.96237 20.8957 8.15746 20.9447 8.36154C21 8.59171 21 8.8363 21 9.32548Z"
stroke="#F8FAFC"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
export { SaveIcon };

View File

@@ -0,0 +1,31 @@
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { cn } from "@/util/utils";
const Popover = PopoverPrimitive.Root;
const PopoverTrigger = PopoverPrimitive.Trigger;
const PopoverAnchor = PopoverPrimitive.Anchor;
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className,
)}
{...props}
/>
</PopoverPrimitive.Portal>
));
PopoverContent.displayName = PopoverPrimitive.Content.displayName;
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };

View File

@@ -31,15 +31,29 @@ import {
CounterClockwiseClockIcon, CounterClockwiseClockIcon,
Pencil2Icon, Pencil2Icon,
PlayIcon, PlayIcon,
PlusIcon,
ReloadIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { WorkflowsBetaAlertCard } from "./WorkflowsBetaAlertCard"; import { WorkflowsBetaAlertCard } from "./WorkflowsBetaAlertCard";
import { WorkflowTitle } from "./WorkflowTitle"; import { WorkflowTitle } from "./WorkflowTitle";
import { WorkflowCreateYAMLRequest } from "./types/workflowYamlTypes";
import { stringify as convertToYAML } from "yaml";
const emptyWorkflowRequest: WorkflowCreateYAMLRequest = {
title: "New Workflow",
description: "",
workflow_definition: {
blocks: [],
parameters: [],
},
};
function Workflows() { function Workflows() {
const credentialGetter = useCredentialGetter(); const credentialGetter = useCredentialGetter();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const workflowsPage = searchParams.get("workflowsPage") const workflowsPage = searchParams.get("workflowsPage")
? Number(searchParams.get("workflowsPage")) ? Number(searchParams.get("workflowsPage"))
@@ -79,6 +93,27 @@ function Workflows() {
}, },
}); });
const createNewWorkflowMutation = useMutation({
mutationFn: async () => {
const client = await getClient(credentialGetter);
const yaml = convertToYAML(emptyWorkflowRequest);
return client.post<
typeof emptyWorkflowRequest,
{ data: WorkflowApiResponse }
>("/workflows", yaml, {
headers: {
"Content-Type": "text/plain",
},
});
},
onSuccess: (response) => {
queryClient.invalidateQueries({
queryKey: ["workflows"],
});
navigate(`/workflows/${response.data.workflow_permanent_id}`);
},
});
if (workflows?.length === 0 && workflowsPage === 1) { if (workflows?.length === 0 && workflowsPage === 1) {
return <WorkflowsBetaAlertCard />; return <WorkflowsBetaAlertCard />;
} }
@@ -115,8 +150,21 @@ function Workflows() {
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<header> <header className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Workflows</h1> <h1 className="text-2xl font-semibold">Workflows</h1>
<Button
disabled={createNewWorkflowMutation.isPending}
onClick={() => {
createNewWorkflowMutation.mutate();
}}
>
{createNewWorkflowMutation.isPending ? (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
) : (
<PlusIcon className="mr-2 h-4 w-4" />
)}
Create Workflow
</Button>
</header> </header>
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>

View File

@@ -1,4 +1,4 @@
import CodeMirror from "@uiw/react-codemirror"; import CodeMirror, { EditorView } from "@uiw/react-codemirror";
import { json } from "@codemirror/lang-json"; import { json } from "@codemirror/lang-json";
import { python } from "@codemirror/lang-python"; import { python } from "@codemirror/lang-python";
import { tokyoNightStorm } from "@uiw/codemirror-theme-tokyo-night-storm"; import { tokyoNightStorm } from "@uiw/codemirror-theme-tokyo-night-storm";
@@ -22,7 +22,10 @@ function CodeEditor({
className, className,
fontSize = 8, fontSize = 8,
}: Props) { }: Props) {
const extensions = language === "json" ? [json()] : [python()]; const extensions =
language === "json"
? [json(), EditorView.lineWrapping]
: [python(), EditorView.lineWrapping];
return ( return (
<CodeMirror <CodeMirror
value={value} value={value}

View File

@@ -13,23 +13,100 @@ import "@xyflow/react/dist/style.css";
import { WorkflowHeader } from "./WorkflowHeader"; import { WorkflowHeader } from "./WorkflowHeader";
import { AppNode, nodeTypes } from "./nodes"; import { AppNode, nodeTypes } from "./nodes";
import "./reactFlowOverrideStyles.css"; import "./reactFlowOverrideStyles.css";
import { layout } from "./workflowEditorUtils"; import { createNode, getWorkflowBlocks, layout } from "./workflowEditorUtils";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel"; import { WorkflowParametersPanel } from "./panels/WorkflowParametersPanel";
import { edgeTypes } from "./edges";
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
import { WorkflowNodeLibraryPanel } from "./panels/WorkflowNodeLibraryPanel";
import {
BitwardenLoginCredentialParameterYAML,
BlockYAML,
WorkflowParameterYAML,
} from "../types/workflowYamlTypes";
import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext";
import { WorkflowParameterValueType } from "../types/workflowTypes";
function convertToParametersYAML(
parameters: ParametersState,
): Array<WorkflowParameterYAML | BitwardenLoginCredentialParameterYAML> {
return parameters.map((parameter) => {
if (parameter.parameterType === "workflow") {
return {
parameter_type: "workflow",
key: parameter.key,
description: parameter.description || null,
workflow_parameter_type: parameter.dataType,
default_value: null,
};
} else {
return {
parameter_type: "bitwarden_login_credential",
key: parameter.key,
description: parameter.description || null,
bitwarden_collection_id: parameter.collectionId,
url_parameter_key: parameter.urlParameterKey,
bitwarden_client_id_aws_secret_key: "SKYVERN_BITWARDEN_CLIENT_ID",
bitwarden_client_secret_aws_secret_key:
"SKYVERN_BITWARDEN_CLIENT_SECRET",
bitwarden_master_password_aws_secret_key:
"SKYVERN_BITWARDEN_MASTER_PASSWORD",
};
}
});
}
export type ParametersState = Array<
| {
key: string;
parameterType: "workflow";
dataType: WorkflowParameterValueType;
description?: string;
}
| {
key: string;
parameterType: "credential";
collectionId: string;
urlParameterKey: string;
description?: string;
}
>;
type Props = { type Props = {
title: string; initialTitle: string;
initialNodes: Array<AppNode>; initialNodes: Array<AppNode>;
initialEdges: Array<Edge>; initialEdges: Array<Edge>;
initialParameters: ParametersState;
handleSave: (
parameters: Array<
WorkflowParameterYAML | BitwardenLoginCredentialParameterYAML
>,
blocks: Array<BlockYAML>,
title: string,
) => void;
}; };
function FlowRenderer({ title, initialEdges, initialNodes }: Props) { export type AddNodeProps = {
const [rightSidePanelOpen, setRightSidePanelOpen] = useState(false); nodeType: Exclude<keyof typeof nodeTypes, "nodeAdder">;
const [rightSidePanelContent, setRightSidePanelContent] = useState< previous: string | null;
"parameters" | "nodeLibrary" | null next: string | null;
>(null); parent?: string;
connectingEdgeType: string;
};
function FlowRenderer({
initialTitle,
initialEdges,
initialNodes,
initialParameters,
handleSave,
}: Props) {
const { workflowPanelState, setWorkflowPanelState, closeWorkflowPanel } =
useWorkflowPanelStore();
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges); const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [parameters, setParameters] = useState(initialParameters);
const [title, setTitle] = useState(initialTitle);
const nodesInitialized = useNodesInitialized(); const nodesInitialized = useNodesInitialized();
function doLayout(nodes: Array<AppNode>, edges: Array<Edge>) { function doLayout(nodes: Array<AppNode>, edges: Array<Edge>) {
@@ -45,62 +122,173 @@ function FlowRenderer({ title, initialEdges, initialNodes }: Props) {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [nodesInitialized]); }, [nodesInitialized]);
function addNode({
nodeType,
previous,
next,
parent,
connectingEdgeType,
}: AddNodeProps) {
const newNodes: Array<AppNode> = [];
const newEdges: Array<Edge> = [];
const index = parent
? nodes.filter((node) => node.parentId === parent).length
: nodes.length;
const id = parent ? `${parent}-${index}` : String(index);
const node = createNode({ id, parentId: parent }, nodeType, String(index));
newNodes.push(node);
if (previous) {
const newEdge = {
id: `edge-${previous}-${id}`,
type: "edgeWithAddButton",
source: previous,
target: id,
style: {
strokeWidth: 2,
},
};
newEdges.push(newEdge);
}
if (next) {
const newEdge = {
id: `edge-${id}-${next}`,
type: connectingEdgeType,
source: id,
target: next,
style: {
strokeWidth: 2,
},
};
newEdges.push(newEdge);
}
if (nodeType === "loop") {
newNodes.push({
id: `${id}-nodeAdder`,
type: "nodeAdder",
parentId: id,
position: { x: 0, y: 0 },
data: {},
draggable: false,
connectable: false,
});
}
const editedEdges = previous
? edges.filter((edge) => edge.source !== previous)
: edges;
const previousNode = nodes.find((node) => node.id === previous);
const previousNodeIndex = previousNode
? nodes.indexOf(previousNode)
: nodes.length - 1;
const newNodesAfter = [
...nodes.slice(0, previousNodeIndex + 1),
...newNodes,
...nodes.slice(previousNodeIndex + 1),
];
doLayout(newNodesAfter, [...editedEdges, ...newEdges]);
}
return ( return (
<ReactFlow <WorkflowParametersStateContext.Provider
nodes={nodes} value={[parameters, setParameters]}
edges={edges}
onNodesChange={(changes) => {
const dimensionChanges = changes.filter(
(change) => change.type === "dimensions",
);
const tempNodes = [...nodes];
dimensionChanges.forEach((change) => {
const node = tempNodes.find((node) => node.id === change.id);
if (node) {
if (node.measured?.width) {
node.measured.width = change.dimensions?.width;
}
if (node.measured?.height) {
node.measured.height = change.dimensions?.height;
}
}
});
if (dimensionChanges.length > 0) {
doLayout(tempNodes, edges);
}
onNodesChange(changes);
}}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
colorMode="dark"
fitView
fitViewOptions={{
maxZoom: 1,
}}
> >
<Background variant={BackgroundVariant.Dots} bgColor="#020617" /> <ReactFlow
<Controls position="bottom-left" /> nodes={nodes}
<Panel position="top-center" className="h-20"> edges={edges}
<WorkflowHeader onNodesChange={(changes) => {
title={title} const dimensionChanges = changes.filter(
parametersPanelOpen={rightSidePanelOpen} (change) => change.type === "dimensions",
onParametersClick={() => { );
setRightSidePanelOpen((open) => !open); const tempNodes = [...nodes];
setRightSidePanelContent("parameters"); dimensionChanges.forEach((change) => {
}} const node = tempNodes.find((node) => node.id === change.id);
/> if (node) {
</Panel> if (node.measured?.width) {
{rightSidePanelOpen && ( node.measured.width = change.dimensions?.width;
<Panel }
position="top-right" if (node.measured?.height) {
className="w-96 rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl" node.measured.height = change.dimensions?.height;
> }
{rightSidePanelContent === "parameters" && ( }
<WorkflowParametersPanel /> });
)} if (dimensionChanges.length > 0) {
doLayout(tempNodes, edges);
}
onNodesChange(changes);
}}
onEdgesChange={onEdgesChange}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
colorMode="dark"
fitView
fitViewOptions={{
maxZoom: 1,
}}
>
<Background variant={BackgroundVariant.Dots} bgColor="#020617" />
<Controls position="bottom-left" />
<Panel position="top-center" className="h-20">
<WorkflowHeader
title={title}
onTitleChange={setTitle}
parametersPanelOpen={
workflowPanelState.active &&
workflowPanelState.content === "parameters"
}
onParametersClick={() => {
if (
workflowPanelState.active &&
workflowPanelState.content === "parameters"
) {
closeWorkflowPanel();
} else {
setWorkflowPanelState({
active: true,
content: "parameters",
});
}
}}
onSave={() => {
const blocksInYAMLConvertibleJSON = getWorkflowBlocks(nodes);
const parametersInYAMLConvertibleJSON =
convertToParametersYAML(parameters);
handleSave(
parametersInYAMLConvertibleJSON,
blocksInYAMLConvertibleJSON,
title,
);
}}
/>
</Panel> </Panel>
)} {workflowPanelState.active && (
</ReactFlow> <Panel position="top-right">
{workflowPanelState.content === "parameters" && (
<WorkflowParametersPanel />
)}
{workflowPanelState.content === "nodeLibrary" && (
<WorkflowNodeLibraryPanel
onNodeClick={(props) => {
addNode(props);
}}
/>
)}
</Panel>
)}
{nodes.length === 0 && (
<Panel position="top-right">
<WorkflowNodeLibraryPanel
onNodeClick={(props) => {
addNode(props);
}}
first
/>
</Panel>
)}
</ReactFlow>
</WorkflowParametersStateContext.Provider>
); );
} }

View File

@@ -1,16 +1,75 @@
import { ReactFlowProvider } from "@xyflow/react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { useWorkflowQuery } from "../hooks/useWorkflowQuery"; import { useWorkflowQuery } from "../hooks/useWorkflowQuery";
import { FlowRenderer } from "./FlowRenderer";
import { getElements } from "./workflowEditorUtils"; import { getElements } from "./workflowEditorUtils";
import { useMutation } from "@tanstack/react-query";
import {
BlockYAML,
ParameterYAML,
WorkflowCreateYAMLRequest,
} from "../types/workflowYamlTypes";
import { getClient } from "@/api/AxiosClient";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { stringify as convertToYAML } from "yaml";
import { ReactFlowProvider } from "@xyflow/react";
import { FlowRenderer } from "./FlowRenderer";
import { toast } from "@/components/ui/use-toast";
import { AxiosError } from "axios";
function WorkflowEditor() { function WorkflowEditor() {
const { workflowPermanentId } = useParams(); const { workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter();
const { data: workflow, isLoading } = useWorkflowQuery({ const { data: workflow, isLoading } = useWorkflowQuery({
workflowPermanentId, workflowPermanentId,
}); });
const saveWorkflowMutation = useMutation({
mutationFn: async (data: {
parameters: Array<ParameterYAML>;
blocks: Array<BlockYAML>;
title: string;
}) => {
if (!workflow || !workflowPermanentId) {
return;
}
const client = await getClient(credentialGetter);
const requestBody: WorkflowCreateYAMLRequest = {
title: data.title,
description: workflow.description,
proxy_location: workflow.proxy_location,
webhook_callback_url: workflow.webhook_callback_url,
totp_verification_url: workflow.totp_verification_url,
workflow_definition: {
parameters: data.parameters,
blocks: data.blocks,
},
is_saved_task: workflow.is_saved_task,
};
const yaml = convertToYAML(requestBody);
return client
.put(`/workflows/${workflowPermanentId}`, yaml, {
headers: {
"Content-Type": "text/plain",
},
})
.then((response) => response.data);
},
onSuccess: () => {
toast({
title: "Changes saved",
description: "Your changes have been saved",
variant: "success",
});
},
onError: (error: AxiosError) => {
toast({
title: "Error",
description: error.message,
variant: "destructive",
});
},
});
// TODO // TODO
if (isLoading) { if (isLoading) {
return ( return (
@@ -30,9 +89,38 @@ function WorkflowEditor() {
<div className="h-screen w-full"> <div className="h-screen w-full">
<ReactFlowProvider> <ReactFlowProvider>
<FlowRenderer <FlowRenderer
title={workflow.title} initialTitle={workflow.title}
initialNodes={elements.nodes} initialNodes={elements.nodes}
initialEdges={elements.edges} initialEdges={elements.edges}
initialParameters={workflow.workflow_definition.parameters
.filter(
(parameter) =>
parameter.parameter_type === "workflow" ||
parameter.parameter_type === "bitwarden_login_credential",
)
.map((parameter) => {
if (parameter.parameter_type === "workflow") {
return {
key: parameter.key,
parameterType: "workflow",
dataType: parameter.workflow_parameter_type,
};
} else {
return {
key: parameter.key,
parameterType: "credential",
collectionId: parameter.bitwarden_collection_id,
urlParameterKey: parameter.url_parameter_key,
};
}
})}
handleSave={(parameters, blocks, title) => {
saveWorkflowMutation.mutate({
parameters,
blocks,
title,
});
}}
/> />
</ReactFlowProvider> </ReactFlowProvider>
</div> </div>

View File

@@ -1,3 +1,4 @@
import { SaveIcon } from "@/components/icons/SaveIcon";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
ChevronDownIcon, ChevronDownIcon,
@@ -6,17 +7,22 @@ import {
PlayIcon, PlayIcon,
} from "@radix-ui/react-icons"; } from "@radix-ui/react-icons";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { EditableNodeTitle } from "./nodes/components/EditableNodeTitle";
type Props = { type Props = {
title: string; title: string;
parametersPanelOpen: boolean; parametersPanelOpen: boolean;
onParametersClick: () => void; onParametersClick: () => void;
onSave: () => void;
onTitleChange: (title: string) => void;
}; };
function WorkflowHeader({ function WorkflowHeader({
title, title,
parametersPanelOpen, parametersPanelOpen,
onParametersClick, onParametersClick,
onSave,
onTitleChange,
}: Props) { }: Props) {
const { workflowPermanentId } = useParams(); const { workflowPermanentId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -24,17 +30,34 @@ function WorkflowHeader({
return ( return (
<div className="flex h-full w-full bg-slate-elevation2"> <div className="flex h-full w-full bg-slate-elevation2">
<div className="flex h-full w-1/3 items-center pl-6"> <div className="flex h-full w-1/3 items-center pl-6">
<div <div className="flex">
className="cursor-pointer rounded-full p-2 hover:bg-slate-elevation5" <div
onClick={() => { className="cursor-pointer rounded-full p-2 hover:bg-slate-elevation5"
navigate("/workflows"); onClick={() => {
}} navigate("/workflows");
> }}
<ExitIcon className="h-6 w-6" /> >
<ExitIcon className="h-6 w-6" />
</div>
<div>
<div
className="cursor-pointer rounded-full p-2 hover:bg-slate-elevation5"
onClick={() => {
onSave();
}}
>
<SaveIcon />
</div>
</div>
</div> </div>
</div> </div>
<div className="flex h-full w-1/3 items-center justify-center"> <div className="flex h-full w-1/3 items-center justify-center p-1">
<span className="max-w-max truncate text-3xl">{title}</span> <EditableNodeTitle
editable={true}
onChange={onTitleChange}
value={title}
className="max-w-96 text-3xl"
/>
</div> </div>
<div className="flex h-full w-1/3 items-center justify-end gap-4 p-4"> <div className="flex h-full w-1/3 items-center justify-end gap-4 p-4">
<Button variant="secondary" size="lg" onClick={onParametersClick}> <Button variant="secondary" size="lg" onClick={onParametersClick}>

View File

@@ -0,0 +1,13 @@
import { createContext } from "react";
import { ParametersState } from "./FlowRenderer";
type WorkflowParametersState = [
ParametersState,
React.Dispatch<React.SetStateAction<ParametersState>>,
];
const WorkflowParametersStateContext = createContext<
WorkflowParametersState | undefined
>(undefined);
export { WorkflowParametersStateContext };

View File

@@ -0,0 +1,2 @@
// nodes have 1000 Z index and we want edges above
export const REACT_FLOW_EDGE_Z_INDEX = 1001;

View File

@@ -0,0 +1,78 @@
import { Button } from "@/components/ui/button";
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
import { PlusIcon } from "@radix-ui/react-icons";
import {
BaseEdge,
EdgeLabelRenderer,
EdgeProps,
getBezierPath,
useNodes,
} from "@xyflow/react";
import { REACT_FLOW_EDGE_Z_INDEX } from "../constants";
function EdgeWithAddButton({
source,
target,
sourceX,
sourceY,
targetX,
targetY,
sourcePosition,
targetPosition,
style = {},
markerEnd,
}: EdgeProps) {
const nodes = useNodes();
const [edgePath, labelX, labelY] = getBezierPath({
sourceX,
sourceY,
sourcePosition,
targetX,
targetY,
targetPosition,
});
const setWorkflowPanelState = useWorkflowPanelStore(
(state) => state.setWorkflowPanelState,
);
const sourceNode = nodes.find((node) => node.id === source);
return (
<>
<BaseEdge path={edgePath} markerEnd={markerEnd} style={style} />
<EdgeLabelRenderer>
<div
style={{
position: "absolute",
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
fontSize: 12,
// everything inside EdgeLabelRenderer has no pointer events by default
// if you have an interactive element, set pointer-events: all
pointerEvents: "all",
zIndex: REACT_FLOW_EDGE_Z_INDEX + 1, // above the edge
}}
className="nodrag nopan"
>
<Button
size="icon"
className="h-4 w-4 rounded-full transition-all hover:scale-150"
onClick={() => {
setWorkflowPanelState({
active: true,
content: "nodeLibrary",
data: {
previous: source,
next: target,
parent: sourceNode?.parentId,
},
});
}}
>
<PlusIcon />
</Button>
</div>
</EdgeLabelRenderer>
</>
);
}
export { EdgeWithAddButton };

View File

@@ -0,0 +1,5 @@
import { EdgeWithAddButton } from "./EdgeWithAddButton";
export const edgeTypes = {
edgeWithAddButton: EdgeWithAddButton,
};

View File

@@ -1,10 +1,13 @@
import { Handle, NodeProps, Position } from "@xyflow/react"; import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import type { CodeBlockNode } from "./types"; import type { CodeBlockNode } from "./types";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { CodeIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"; import { CodeIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
function CodeBlockNode({ id, data }: NodeProps<CodeBlockNode>) {
const { updateNodeData } = useReactFlow();
function CodeBlockNode({ data }: NodeProps<CodeBlockNode>) {
return ( return (
<div> <div>
<Handle <Handle
@@ -26,8 +29,12 @@ function CodeBlockNode({ data }: NodeProps<CodeBlockNode>) {
<CodeIcon className="h-6 w-6" /> <CodeIcon className="h-6 w-6" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="max-w-64 truncate text-base">{data.label}</span> <EditableNodeTitle
<span className="text-xs text-slate-400">Task Block</span> value={data.label}
editable={data.editable}
onChange={(value) => updateNodeData(id, { label: value })}
/>
<span className="text-xs text-slate-400">Code Block</span>
</div> </div>
</div> </div>
<div> <div>
@@ -39,9 +46,11 @@ function CodeBlockNode({ data }: NodeProps<CodeBlockNode>) {
<CodeEditor <CodeEditor
language="python" language="python"
value={data.code} value={data.code}
onChange={() => { onChange={(value) => {
if (!data.editable) return; if (!data.editable) {
// TODO return;
}
updateNodeData(id, { code: value });
}} }}
className="nopan" className="nopan"
/> />

View File

@@ -7,3 +7,9 @@ export type CodeBlockNodeData = {
}; };
export type CodeBlockNode = Node<CodeBlockNodeData, "codeBlock">; export type CodeBlockNode = Node<CodeBlockNodeData, "codeBlock">;
export const codeBlockNodeDefaultData: CodeBlockNodeData = {
editable: true,
label: "",
code: "",
} as const;

View File

@@ -1,10 +1,13 @@
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { DotsHorizontalIcon, DownloadIcon } from "@radix-ui/react-icons"; import { DotsHorizontalIcon, DownloadIcon } from "@radix-ui/react-icons";
import { Handle, NodeProps, Position } from "@xyflow/react"; import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import type { DownloadNode } from "./types"; import type { DownloadNode } from "./types";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
function DownloadNode({ id, data }: NodeProps<DownloadNode>) {
const { updateNodeData } = useReactFlow();
function DownloadNode({ data }: NodeProps<DownloadNode>) {
return ( return (
<div> <div>
<Handle <Handle
@@ -26,7 +29,11 @@ function DownloadNode({ data }: NodeProps<DownloadNode>) {
<DownloadIcon className="h-6 w-6" /> <DownloadIcon className="h-6 w-6" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="max-w-64 truncate text-base">{data.label}</span> <EditableNodeTitle
value={data.label}
editable={data.editable}
onChange={(value) => updateNodeData(id, { label: value })}
/>
<span className="text-xs text-slate-400">Download Block</span> <span className="text-xs text-slate-400">Download Block</span>
</div> </div>
</div> </div>
@@ -39,11 +46,11 @@ function DownloadNode({ data }: NodeProps<DownloadNode>) {
<Label className="text-sm text-slate-400">File URL</Label> <Label className="text-sm text-slate-400">File URL</Label>
<Input <Input
value={data.url} value={data.url}
onChange={() => { onChange={(event) => {
if (!data.editable) { if (!data.editable) {
return; return;
} }
// TODO updateNodeData(id, { url: event.target.value });
}} }}
className="nopan" className="nopan"
/> />

View File

@@ -7,3 +7,9 @@ export type DownloadNodeData = {
}; };
export type DownloadNode = Node<DownloadNodeData, "download">; export type DownloadNode = Node<DownloadNodeData, "download">;
export const downloadNodeDefaultData: DownloadNodeData = {
editable: true,
label: "",
url: "SKYVERN_DOWNLOAD_DIRECTORY",
} as const;

View File

@@ -1,9 +1,11 @@
import { Handle, NodeProps, Position } from "@xyflow/react"; import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import type { FileParserNode } from "./types"; import type { FileParserNode } from "./types";
import { CursorTextIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"; import { CursorTextIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
function FileParserNode({ data }: NodeProps<FileParserNode>) { function FileParserNode({ id, data }: NodeProps<FileParserNode>) {
const { updateNodeData } = useReactFlow();
return ( return (
<div> <div>
<Handle <Handle
@@ -25,7 +27,11 @@ function FileParserNode({ data }: NodeProps<FileParserNode>) {
<CursorTextIcon className="h-6 w-6" /> <CursorTextIcon className="h-6 w-6" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="max-w-64 truncate text-base">{data.label}</span> <EditableNodeTitle
value={data.label}
editable={data.editable}
onChange={(value) => updateNodeData(id, { label: value })}
/>
<span className="text-xs text-slate-400">File Parser Block</span> <span className="text-xs text-slate-400">File Parser Block</span>
</div> </div>
</div> </div>
@@ -38,11 +44,11 @@ function FileParserNode({ data }: NodeProps<FileParserNode>) {
<span className="text-sm text-slate-400">File URL</span> <span className="text-sm text-slate-400">File URL</span>
<Input <Input
value={data.fileUrl} value={data.fileUrl}
onChange={() => { onChange={(event) => {
if (!data.editable) { if (!data.editable) {
return; return;
} }
// TODO updateNodeData(id, { fileUrl: event.target.value });
}} }}
className="nopan" className="nopan"
/> />

View File

@@ -7,3 +7,9 @@ export type FileParserNodeData = {
}; };
export type FileParserNode = Node<FileParserNodeData, "fileParser">; export type FileParserNode = Node<FileParserNodeData, "fileParser">;
export const fileParserNodeDefaultData: FileParserNodeData = {
editable: true,
label: "",
fileUrl: "",
} as const;

View File

@@ -1,11 +1,19 @@
import { DotsHorizontalIcon, UpdateIcon } from "@radix-ui/react-icons"; import { DotsHorizontalIcon, UpdateIcon } from "@radix-ui/react-icons";
import { Handle, NodeProps, Position, useNodes } from "@xyflow/react"; import {
Handle,
NodeProps,
Position,
useNodes,
useReactFlow,
} from "@xyflow/react";
import type { LoopNode } from "./types"; import type { LoopNode } from "./types";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import type { Node } from "@xyflow/react"; import type { Node } from "@xyflow/react";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
function LoopNode({ id, data }: NodeProps<LoopNode>) { function LoopNode({ id, data }: NodeProps<LoopNode>) {
const { updateNodeData } = useReactFlow();
const nodes = useNodes(); const nodes = useNodes();
const children = nodes.filter((node) => node.parentId === id); const children = nodes.filter((node) => node.parentId === id);
const furthestDownChild: Node | null = children.reduce( const furthestDownChild: Node | null = children.reduce(
@@ -54,7 +62,11 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
<UpdateIcon className="h-6 w-6" /> <UpdateIcon className="h-6 w-6" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="text-base">{data.label}</span> <EditableNodeTitle
value={data.label}
editable={data.editable}
onChange={(value) => updateNodeData(id, { label: value })}
/>
<span className="text-xs text-slate-400">Loop Block</span> <span className="text-xs text-slate-400">Loop Block</span>
</div> </div>
</div> </div>
@@ -66,11 +78,11 @@ function LoopNode({ id, data }: NodeProps<LoopNode>) {
<Label className="text-xs text-slate-300">Loop Value</Label> <Label className="text-xs text-slate-300">Loop Value</Label>
<Input <Input
value={data.loopValue} value={data.loopValue}
onChange={() => { onChange={(event) => {
if (!data.editable) { if (!data.editable) {
return; return;
} }
// TODO updateNodeData(id, { loopValue: event.target.value });
}} }}
placeholder="What value are you iterating over?" placeholder="What value are you iterating over?"
className="nopan" className="nopan"

View File

@@ -7,3 +7,9 @@ export type LoopNodeData = {
}; };
export type LoopNode = Node<LoopNodeData, "loop">; export type LoopNode = Node<LoopNodeData, "loop">;
export const loopNodeDefaultData: LoopNodeData = {
editable: true,
label: "",
loopValue: "",
} as const;

View File

@@ -0,0 +1,48 @@
import { Handle, NodeProps, Position, useEdges } from "@xyflow/react";
import type { NodeAdderNode } from "./types";
import { PlusIcon } from "@radix-ui/react-icons";
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
function NodeAdderNode({ id, parentId }: NodeProps<NodeAdderNode>) {
const edges = useEdges();
const setWorkflowPanelState = useWorkflowPanelStore(
(state) => state.setWorkflowPanelState,
);
return (
<div>
<Handle
type="source"
position={Position.Bottom}
id="a"
className="opacity-0"
/>
<Handle
type="target"
position={Position.Top}
id="b"
className="opacity-0"
/>
<div
className="rounded-full bg-slate-50 p-2"
onClick={() => {
const previous = edges.find((edge) => edge.target === id)?.source;
setWorkflowPanelState({
active: true,
content: "nodeLibrary",
data: {
previous: previous ?? null,
next: id,
parent: parentId,
connectingEdgeType: "default",
},
});
}}
>
<PlusIcon className="h-12 w-12 text-slate-950" />
</div>
</div>
);
}
export { NodeAdderNode };

View File

@@ -0,0 +1,5 @@
import type { Node } from "@xyflow/react";
export type NodeAdderNodeData = Record<string, never>;
export type NodeAdderNode = Node<NodeAdderNodeData, "nodeAdder">;

View File

@@ -1,12 +1,14 @@
import { Handle, NodeProps, Position } from "@xyflow/react"; import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import type { SendEmailNode } from "./types"; import type { SendEmailNode } from "./types";
import { DotsHorizontalIcon, EnvelopeClosedIcon } from "@radix-ui/react-icons"; import { DotsHorizontalIcon, EnvelopeClosedIcon } from "@radix-ui/react-icons";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch"; import { EditableNodeTitle } from "../components/EditableNodeTitle";
function SendEmailNode({ id, data }: NodeProps<SendEmailNode>) {
const { updateNodeData } = useReactFlow();
function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
return ( return (
<div> <div>
<Handle <Handle
@@ -28,7 +30,11 @@ function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
<EnvelopeClosedIcon className="h-6 w-6" /> <EnvelopeClosedIcon className="h-6 w-6" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="max-w-64 truncate text-base">{data.label}</span> <EditableNodeTitle
value={data.label}
editable={data.editable}
onChange={(value) => updateNodeData(id, { label: value })}
/>
<span className="text-xs text-slate-400">Send Email Block</span> <span className="text-xs text-slate-400">Send Email Block</span>
</div> </div>
</div> </div>
@@ -37,24 +43,42 @@ function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-slate-300">Recipient</Label> <Label className="text-xs text-slate-300">Sender</Label>
<Input <Input
onChange={() => { onChange={(event) => {
if (!data.editable) return; if (!data.editable) {
// TODO return;
}
updateNodeData(id, { sender: event.target.value });
}} }}
value={data.recipients.join(", ")} value={data.sender}
placeholder="example@gmail.com" placeholder="example@gmail.com"
className="nopan" className="nopan"
/> />
</div> </div>
<div className="space-y-1">
<Label className="text-xs text-slate-300">Recipients</Label>
<Input
onChange={(event) => {
if (!data.editable) {
return;
}
updateNodeData(id, { recipients: event.target.value });
}}
value={data.recipients}
placeholder="example@gmail.com, example2@gmail.com..."
className="nopan"
/>
</div>
<Separator /> <Separator />
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-slate-300">Subject</Label> <Label className="text-xs text-slate-300">Subject</Label>
<Input <Input
onChange={() => { onChange={(event) => {
if (!data.editable) return; if (!data.editable) {
// TODO return;
}
updateNodeData(id, { subject: event.target.value });
}} }}
value={data.subject} value={data.subject}
placeholder="What is the gist?" placeholder="What is the gist?"
@@ -64,9 +88,11 @@ function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-slate-300">Body</Label> <Label className="text-xs text-slate-300">Body</Label>
<Input <Input
onChange={() => { onChange={(event) => {
if (!data.editable) return; if (!data.editable) {
// TODO return;
}
updateNodeData(id, { body: event.target.value });
}} }}
value={data.body} value={data.body}
placeholder="What would you like to say?" placeholder="What would you like to say?"
@@ -77,21 +103,16 @@ function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-slate-300">File Attachments</Label> <Label className="text-xs text-slate-300">File Attachments</Label>
<Input <Input
value={data.fileAttachments?.join(", ") ?? ""} value={data.fileAttachments}
onChange={() => { onChange={(event) => {
if (!data.editable) return; if (!data.editable) {
// TODO return;
}
updateNodeData(id, { fileAttachments: event.target.value });
}} }}
className="nopan" className="nopan"
/> />
</div> </div>
<Separator />
<div className="flex items-center gap-10">
<Label className="text-xs text-slate-300">
Attach all downloaded files
</Label>
<Switch />
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,12 +1,23 @@
import type { Node } from "@xyflow/react"; import type { Node } from "@xyflow/react";
export type SendEmailNodeData = { export type SendEmailNodeData = {
recipients: string[]; recipients: string;
subject: string; subject: string;
body: string; body: string;
fileAttachments: string[] | null; fileAttachments: string;
editable: boolean; editable: boolean;
label: string; label: string;
sender: string;
}; };
export type SendEmailNode = Node<SendEmailNodeData, "sendEmail">; export type SendEmailNode = Node<SendEmailNodeData, "sendEmail">;
export const sendEmailNodeDefaultData: SendEmailNodeData = {
recipients: "",
subject: "",
body: "",
fileAttachments: "",
editable: true,
label: "",
sender: "",
} as const;

View File

@@ -1,23 +1,35 @@
import { Handle, NodeProps, Position } from "@xyflow/react";
import { useState } from "react";
import { DotsHorizontalIcon, ListBulletIcon } from "@radix-ui/react-icons";
import { TaskNodeDisplayModeSwitch } from "./TaskNodeDisplayModeSwitch";
import type { TaskNodeDisplayMode } from "./types";
import type { TaskNode } from "./types";
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
import { Label } from "@/components/ui/label";
import { import {
Accordion, Accordion,
AccordionContent, AccordionContent,
AccordionItem, AccordionItem,
AccordionTrigger, AccordionTrigger,
} from "@/components/ui/accordion"; } from "@/components/ui/accordion";
import { DataSchema } from "../../../components/DataSchema"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { TaskNodeErrorMapping } from "./TaskNodeErrorMapping"; import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import {
DotsHorizontalIcon,
ListBulletIcon,
MixerVerticalIcon,
} from "@radix-ui/react-icons";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import { useState } from "react";
import { TaskNodeDisplayModeSwitch } from "./TaskNodeDisplayModeSwitch";
import { TaskNodeParametersPanel } from "./TaskNodeParametersPanel";
import type { TaskNode, TaskNodeDisplayMode } from "./types";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
function TaskNode({ data }: NodeProps<TaskNode>) { function TaskNode({ id, data }: NodeProps<TaskNode>) {
const { updateNodeData } = useReactFlow();
const [displayMode, setDisplayMode] = useState<TaskNodeDisplayMode>("basic"); const [displayMode, setDisplayMode] = useState<TaskNodeDisplayMode>("basic");
const { editable } = data; const { editable } = data;
@@ -28,9 +40,12 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
<AutoResizingTextarea <AutoResizingTextarea
value={data.url} value={data.url}
className="nopan" className="nopan"
onChange={() => { name="url"
if (!editable) return; onChange={(event) => {
// TODO if (!editable) {
return;
}
updateNodeData(id, { url: event.target.value });
}} }}
placeholder="https://" placeholder="https://"
/> />
@@ -38,9 +53,11 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-slate-300">Goal</Label> <Label className="text-xs text-slate-300">Goal</Label>
<AutoResizingTextarea <AutoResizingTextarea
onChange={() => { onChange={(event) => {
if (!editable) return; if (!editable) {
// TODO return;
}
updateNodeData(id, { navigationGoal: event.target.value });
}} }}
value={data.navigationGoal} value={data.navigationGoal}
placeholder="What are you looking to do?" placeholder="What are you looking to do?"
@@ -63,9 +80,11 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-slate-300">URL</Label> <Label className="text-xs text-slate-300">URL</Label>
<AutoResizingTextarea <AutoResizingTextarea
onChange={() => { onChange={(event) => {
if (!editable) return; if (!editable) {
// TODO return;
}
updateNodeData(id, { url: event.target.value });
}} }}
value={data.url} value={data.url}
placeholder="https://" placeholder="https://"
@@ -75,9 +94,11 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-slate-300">Goal</Label> <Label className="text-xs text-slate-300">Goal</Label>
<AutoResizingTextarea <AutoResizingTextarea
onChange={() => { onChange={(event) => {
if (!editable) return; if (!editable) {
// TODO return;
}
updateNodeData(id, { navigationGoal: event.target.value });
}} }}
value={data.navigationGoal} value={data.navigationGoal}
placeholder="What are you looking to do?" placeholder="What are you looking to do?"
@@ -96,28 +117,56 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
Data Extraction Goal Data Extraction Goal
</Label> </Label>
<AutoResizingTextarea <AutoResizingTextarea
onChange={() => { onChange={(event) => {
if (!editable) return; if (!editable) {
// TODO return;
}
updateNodeData(id, {
dataExtractionGoal: event.target.value,
});
}} }}
value={data.dataExtractionGoal} value={data.dataExtractionGoal}
placeholder="What outputs are you looking to get?" placeholder="What outputs are you looking to get?"
className="nopan" className="nopan"
/> />
</div> </div>
<DataSchema <div className="space-y-2">
value={data.dataSchema} <div className="flex gap-2">
onChange={() => { <Label className="text-xs text-slate-300">Data Schema</Label>
if (!editable) return; <Checkbox
// TODO checked={data.dataSchema !== "null"}
}} onCheckedChange={(checked) => {
/> if (!editable) {
return;
}
updateNodeData(id, {
dataSchema: checked ? "{}" : "null",
});
}}
/>
</div>
{data.dataSchema !== "null" && (
<div>
<CodeEditor
language="json"
value={data.dataSchema}
onChange={(value) => {
if (!editable) {
return;
}
updateNodeData(id, { dataSchema: value });
}}
className="nowheel nopan"
/>
</div>
)}
</div>
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
<AccordionItem value="limits"> <AccordionItem value="limits">
<AccordionTrigger>Limits</AccordionTrigger> <AccordionTrigger>Limits</AccordionTrigger>
<AccordionContent className="pl-[1.5rem] pr-1"> <AccordionContent className="pl-[1.5rem] pr-1 pt-1">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Label className="text-xs font-normal text-slate-300"> <Label className="text-xs font-normal text-slate-300">
@@ -127,10 +176,15 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
type="number" type="number"
placeholder="0" placeholder="0"
className="nopan w-44" className="nopan w-44"
min="0"
value={data.maxRetries ?? 0} value={data.maxRetries ?? 0}
onChange={() => { onChange={(event) => {
if (!editable) return; if (!editable) {
// TODO return;
}
updateNodeData(id, {
maxRetries: Number(event.target.value),
});
}} }}
/> />
</div> </div>
@@ -142,10 +196,15 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
type="number" type="number"
placeholder="0" placeholder="0"
className="nopan w-44" className="nopan w-44"
min="0"
value={data.maxStepsOverride ?? 0} value={data.maxStepsOverride ?? 0}
onChange={() => { onChange={(event) => {
if (!editable) return; if (!editable) {
// TODO return;
}
updateNodeData(id, {
maxStepsOverride: Number(event.target.value),
});
}} }}
/> />
</div> </div>
@@ -156,20 +215,49 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
<div className="w-44"> <div className="w-44">
<Switch <Switch
checked={data.allowDownloads} checked={data.allowDownloads}
onCheckedChange={() => { onCheckedChange={(checked) => {
if (!editable) return; if (!editable) {
// TODO return;
}
updateNodeData(id, { allowDownloads: checked });
}} }}
/> />
</div> </div>
</div> </div>
<TaskNodeErrorMapping <div className="space-y-2">
value={data.errorCodeMapping} <div className="flex gap-2">
onChange={() => { <Label className="text-xs font-normal text-slate-300">
if (!editable) return; Error Messages
// TODO </Label>
}} <Checkbox
/> checked={data.errorCodeMapping !== "null"}
disabled={!editable}
onCheckedChange={(checked) => {
if (!editable) {
return;
}
updateNodeData(id, {
errorCodeMapping: checked ? "{}" : "null",
});
}}
/>
</div>
{data.errorCodeMapping !== "null" && (
<div>
<CodeEditor
language="json"
value={data.errorCodeMapping}
onChange={(value) => {
if (!editable) {
return;
}
updateNodeData(id, { errorCodeMapping: value });
}}
className="nowheel nopan"
/>
</div>
)}
</div>
</div> </div>
</AccordionContent> </AccordionContent>
</AccordionItem> </AccordionItem>
@@ -198,7 +286,11 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
<ListBulletIcon className="h-6 w-6" /> <ListBulletIcon className="h-6 w-6" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="max-w-64 truncate text-base">{data.label}</span> <EditableNodeTitle
value={data.label}
editable={editable}
onChange={(value) => updateNodeData(id, { label: value })}
/>
<span className="text-xs text-slate-400">Task Block</span> <span className="text-xs text-slate-400">Task Block</span>
</div> </div>
</div> </div>
@@ -206,10 +298,28 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
<DotsHorizontalIcon className="h-6 w-6" /> <DotsHorizontalIcon className="h-6 w-6" />
</div> </div>
</div> </div>
<TaskNodeDisplayModeSwitch <div className="flex justify-between">
value={displayMode} <TaskNodeDisplayModeSwitch
onChange={setDisplayMode} value={displayMode}
/> onChange={setDisplayMode}
/>
<Popover>
<PopoverTrigger asChild>
<Button size="icon" variant="outline">
<MixerVerticalIcon />
</Button>
</PopoverTrigger>
<PopoverContent className="w-72">
<TaskNodeParametersPanel
parameters={data.parameterKeys}
onParametersChange={(parameterKeys) => {
updateNodeData(id, { parameterKeys });
}}
/>
</PopoverContent>
</Popover>
</div>
{displayMode === "basic" && basicContent} {displayMode === "basic" && basicContent}
{displayMode === "advanced" && advancedContent} {displayMode === "advanced" && advancedContent}
</div> </div>

View File

@@ -1,61 +0,0 @@
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
type Props = {
value: Record<string, unknown> | null;
onChange: (value: Record<string, unknown> | null) => void;
disabled?: boolean;
};
function TaskNodeErrorMapping({ value, onChange, disabled }: Props) {
if (value === null) {
return (
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Error Messages
</Label>
<Checkbox
checked={false}
disabled={disabled}
onCheckedChange={() => {
onChange({});
}}
/>
</div>
);
}
return (
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Error Messages
</Label>
<Checkbox
checked
disabled={disabled}
onCheckedChange={() => {
onChange(null);
}}
/>
</div>
<div>
<CodeEditor
language="json"
value={JSON.stringify(value, null, 2)}
disabled={disabled}
onChange={() => {
if (disabled) {
return;
}
// TODO
}}
className="nowheel nopan"
/>
</div>
</div>
);
}
export { TaskNodeErrorMapping };

View File

@@ -0,0 +1,50 @@
import { Checkbox } from "@/components/ui/checkbox";
import { useWorkflowParametersState } from "../../useWorkflowParametersState";
type Props = {
parameters: Array<string>;
onParametersChange: (parameters: Array<string>) => void;
};
function TaskNodeParametersPanel({ parameters, onParametersChange }: Props) {
const [workflowParameters] = useWorkflowParametersState();
return (
<div className="space-y-4">
<header className="space-y-1">
<h1>Parameters</h1>
<span className="text-xs text-slate-300">
Check off the parameters you want to use in this task.
</span>
</header>
<div className="space-y-2">
{workflowParameters.map((workflowParameter) => {
return (
<div
key={workflowParameter.key}
className="flex items-center gap-2 rounded-sm bg-slate-elevation1 px-3 py-2"
>
<Checkbox
checked={parameters.includes(workflowParameter.key)}
onCheckedChange={(checked) => {
if (checked) {
onParametersChange([...parameters, workflowParameter.key]);
} else {
onParametersChange(
parameters.filter(
(parameter) => parameter !== workflowParameter.key,
),
);
}
}}
/>
<span className="text-xs">{workflowParameter.key}</span>
</div>
);
})}
</div>
</div>
);
}
export { TaskNodeParametersPanel };

View File

@@ -4,15 +4,30 @@ export type TaskNodeData = {
url: string; url: string;
navigationGoal: string; navigationGoal: string;
dataExtractionGoal: string; dataExtractionGoal: string;
errorCodeMapping: Record<string, string> | null; errorCodeMapping: string;
dataSchema: Record<string, unknown> | null; dataSchema: string;
maxRetries: number | null; maxRetries: number | null;
maxStepsOverride: number | null; maxStepsOverride: number | null;
allowDownloads: boolean; allowDownloads: boolean;
editable: boolean; editable: boolean;
label: string; label: string;
parameterKeys: Array<string>;
}; };
export type TaskNode = Node<TaskNodeData, "task">; export type TaskNode = Node<TaskNodeData, "task">;
export type TaskNodeDisplayMode = "basic" | "advanced"; export type TaskNodeDisplayMode = "basic" | "advanced";
export const taskNodeDefaultData: TaskNodeData = {
url: "",
navigationGoal: "",
dataExtractionGoal: "",
errorCodeMapping: "null",
dataSchema: "null",
maxRetries: null,
maxStepsOverride: null,
allowDownloads: false,
editable: true,
label: "",
parameterKeys: [],
} as const;

View File

@@ -1,12 +1,17 @@
import { CursorTextIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"; import { CursorTextIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
import { Handle, NodeProps, Position } from "@xyflow/react"; import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import type { TextPromptNode } from "./types"; import type { TextPromptNode } from "./types";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { DataSchema } from "@/routes/workflows/components/DataSchema"; import { Checkbox } from "@/components/ui/checkbox";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
function TextPromptNode({ id, data }: NodeProps<TextPromptNode>) {
const { updateNodeData } = useReactFlow();
const { editable } = data;
function TextPromptNode({ data }: NodeProps<TextPromptNode>) {
return ( return (
<div> <div>
<Handle <Handle
@@ -28,7 +33,11 @@ function TextPromptNode({ data }: NodeProps<TextPromptNode>) {
<CursorTextIcon className="h-6 w-6" /> <CursorTextIcon className="h-6 w-6" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="max-w-64 truncate text-base">{data.label}</span> <EditableNodeTitle
value={data.label}
editable={editable}
onChange={(value) => updateNodeData(id, { label: value })}
/>
<span className="text-xs text-slate-400">Text Prompt Block</span> <span className="text-xs text-slate-400">Text Prompt Block</span>
</div> </div>
</div> </div>
@@ -39,9 +48,11 @@ function TextPromptNode({ data }: NodeProps<TextPromptNode>) {
<div className="space-y-1"> <div className="space-y-1">
<Label className="text-xs text-slate-300">Prompt</Label> <Label className="text-xs text-slate-300">Prompt</Label>
<AutoResizingTextarea <AutoResizingTextarea
onChange={() => { onChange={(event) => {
if (!data.editable) return; if (!editable) {
// TODO return;
}
updateNodeData(id, { prompt: event.target.value });
}} }}
value={data.prompt} value={data.prompt}
placeholder="What do you want to generate?" placeholder="What do you want to generate?"
@@ -49,13 +60,37 @@ function TextPromptNode({ data }: NodeProps<TextPromptNode>) {
/> />
</div> </div>
<Separator /> <Separator />
<DataSchema <div className="space-y-2">
value={data.jsonSchema} <div className="flex gap-2">
onChange={() => { <Label className="text-xs text-slate-300">Data Schema</Label>
if (!data.editable) return; <Checkbox
// TODO checked={data.jsonSchema !== "null"}
}} onCheckedChange={(checked) => {
/> if (!editable) {
return;
}
updateNodeData(id, {
jsonSchema: checked ? "{}" : "null",
});
}}
/>
</div>
{data.jsonSchema !== "null" && (
<div>
<CodeEditor
language="json"
value={data.jsonSchema}
onChange={(value) => {
if (!editable) {
return;
}
updateNodeData(id, { jsonSchema: value });
}}
className="nowheel nopan"
/>
</div>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -2,9 +2,16 @@ import type { Node } from "@xyflow/react";
export type TextPromptNodeData = { export type TextPromptNodeData = {
prompt: string; prompt: string;
jsonSchema: Record<string, unknown> | null; jsonSchema: string;
editable: boolean; editable: boolean;
label: string; label: string;
}; };
export type TextPromptNode = Node<TextPromptNodeData, "textPrompt">; export type TextPromptNode = Node<TextPromptNodeData, "textPrompt">;
export const textPromptNodeDefaultData: TextPromptNodeData = {
editable: true,
label: "",
prompt: "",
jsonSchema: "null",
} as const;

View File

@@ -1,10 +1,13 @@
import { Handle, NodeProps, Position } from "@xyflow/react"; import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import type { UploadNode } from "./types"; import type { UploadNode } from "./types";
import { DotsHorizontalIcon, UploadIcon } from "@radix-ui/react-icons"; import { DotsHorizontalIcon, UploadIcon } from "@radix-ui/react-icons";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { EditableNodeTitle } from "../components/EditableNodeTitle";
function UploadNode({ id, data }: NodeProps<UploadNode>) {
const { updateNodeData } = useReactFlow();
function UploadNode({ data }: NodeProps<UploadNode>) {
return ( return (
<div> <div>
<Handle <Handle
@@ -26,7 +29,11 @@ function UploadNode({ data }: NodeProps<UploadNode>) {
<UploadIcon className="h-6 w-6" /> <UploadIcon className="h-6 w-6" />
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<span className="max-w-64 truncate text-base">{data.label}</span> <EditableNodeTitle
value={data.label}
editable={data.editable}
onChange={(value) => updateNodeData(id, { label: value })}
/>
<span className="text-xs text-slate-400">Upload Block</span> <span className="text-xs text-slate-400">Upload Block</span>
</div> </div>
</div> </div>
@@ -39,11 +46,11 @@ function UploadNode({ data }: NodeProps<UploadNode>) {
<Label className="text-sm text-slate-400">File Path</Label> <Label className="text-sm text-slate-400">File Path</Label>
<Input <Input
value={data.path} value={data.path}
onChange={() => { onChange={(event) => {
if (!data.editable) { if (!data.editable) {
return; return;
} }
// TODO updateNodeData(id, { path: event.target.value });
}} }}
className="nopan" className="nopan"
/> />

View File

@@ -7,3 +7,9 @@ export type UploadNodeData = {
}; };
export type UploadNode = Node<UploadNodeData, "upload">; export type UploadNode = Node<UploadNodeData, "upload">;
export const uploadNodeDefaultData: UploadNodeData = {
editable: true,
label: "",
path: "SKYVERN_DOWNLOAD_DIRECTORY",
} as const;

View File

@@ -0,0 +1,62 @@
import { Input } from "@/components/ui/input";
import { cn } from "@/util/utils";
import { useLayoutEffect, useRef } from "react";
type Props = {
value: string;
editable: boolean;
onChange: (value: string) => void;
className?: string;
};
function EditableNodeTitle({ value, editable, onChange, className }: Props) {
const ref = useRef<HTMLInputElement>(null);
useLayoutEffect(() => {
// size the textarea correctly on first render
if (!ref.current) {
return;
}
ref.current.style.width = `${ref.current.scrollWidth + 2}px`;
}, []);
function setSize() {
if (!ref.current) {
return;
}
ref.current.style.width = "auto";
ref.current.style.width = `${ref.current.scrollWidth + 2}px`;
}
return (
<Input
disabled={!editable}
ref={ref}
className={cn("w-fit min-w-fit max-w-64 border-0 px-0", className)}
onBlur={(event) => {
if (!editable) {
event.currentTarget.value = value;
return;
}
onChange(event.target.value);
}}
onKeyDown={(event) => {
if (!editable) {
return;
}
if (event.key === "Enter") {
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.currentTarget.value = value;
event.currentTarget.blur();
}
setSize();
}}
onInput={setSize}
defaultValue={value}
/>
);
}
export { EditableNodeTitle };

View File

@@ -15,6 +15,8 @@ import type { UploadNode } from "./UploadNode/types";
import { UploadNode as UploadNodeComponent } from "./UploadNode/UploadNode"; import { UploadNode as UploadNodeComponent } from "./UploadNode/UploadNode";
import type { DownloadNode } from "./DownloadNode/types"; import type { DownloadNode } from "./DownloadNode/types";
import { DownloadNode as DownloadNodeComponent } from "./DownloadNode/DownloadNode"; import { DownloadNode as DownloadNodeComponent } from "./DownloadNode/DownloadNode";
import type { NodeAdderNode } from "./NodeAdderNode/types";
import { NodeAdderNode as NodeAdderNodeComponent } from "./NodeAdderNode/NodeAdderNode";
export type AppNode = export type AppNode =
| LoopNode | LoopNode
@@ -24,7 +26,8 @@ export type AppNode =
| CodeBlockNode | CodeBlockNode
| FileParserNode | FileParserNode
| UploadNode | UploadNode
| DownloadNode; | DownloadNode
| NodeAdderNode;
export const nodeTypes = { export const nodeTypes = {
loop: memo(LoopNodeComponent), loop: memo(LoopNodeComponent),
@@ -35,4 +38,5 @@ export const nodeTypes = {
fileParser: memo(FileParserNodeComponent), fileParser: memo(FileParserNodeComponent),
upload: memo(UploadNodeComponent), upload: memo(UploadNodeComponent),
download: memo(DownloadNodeComponent), download: memo(DownloadNodeComponent),
nodeAdder: memo(NodeAdderNodeComponent),
}; };

View File

@@ -0,0 +1,149 @@
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
import {
CodeIcon,
Cross2Icon,
CursorTextIcon,
DownloadIcon,
EnvelopeClosedIcon,
FileIcon,
ListBulletIcon,
PlusIcon,
UpdateIcon,
UploadIcon,
} from "@radix-ui/react-icons";
import { nodeTypes } from "../nodes";
import { AddNodeProps } from "../FlowRenderer";
const nodeLibraryItems: Array<{
nodeType: Exclude<keyof typeof nodeTypes, "nodeAdder">;
icon: JSX.Element;
title: string;
description: string;
}> = [
{
nodeType: "task",
icon: <ListBulletIcon className="h-6 w-6" />,
title: "Task Block",
description: "Takes actions or extracts information",
},
{
nodeType: "loop",
icon: <UpdateIcon className="h-6 w-6" />,
title: "For Loop Block",
description: "Repeats nested elements",
},
{
nodeType: "textPrompt",
icon: <CursorTextIcon className="h-6 w-6" />,
title: "Text Prompt Block",
description: "Generates AI response",
},
{
nodeType: "sendEmail",
icon: <EnvelopeClosedIcon className="h-6 w-6" />,
title: "Send Email Block",
description: "Sends an email",
},
{
nodeType: "codeBlock",
icon: <CodeIcon className="h-6 w-6" />,
title: "Code Block",
description: "Executes Python code",
},
{
nodeType: "fileParser",
icon: <FileIcon className="h-6 w-6" />,
title: "File Parser Block",
description: "Downloads and parses a file",
},
{
nodeType: "download",
icon: <DownloadIcon className="h-6 w-6" />,
title: "Download Block",
description: "Downloads a file from S3",
},
{
nodeType: "upload",
icon: <UploadIcon className="h-6 w-6" />,
title: "Upload Block",
description: "Uploads a file to S3",
},
];
type Props = {
onNodeClick: (props: AddNodeProps) => void;
first?: boolean;
};
function WorkflowNodeLibraryPanel({ onNodeClick, first }: Props) {
const workflowPanelData = useWorkflowPanelStore(
(state) => state.workflowPanelState.data,
);
const closeWorkflowPanel = useWorkflowPanelStore(
(state) => state.closeWorkflowPanel,
);
return (
<div className="w-[25rem] rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl">
<div className="space-y-4">
<header className="space-y-2">
<div className="flex justify-between">
<h1 className="text-lg">Node Library</h1>
{!first && (
<Cross2Icon
className="h-6 w-6 cursor-pointer"
onClick={() => {
closeWorkflowPanel();
}}
/>
)}
</div>
<span className="text-sm text-slate-400">
{first
? "Click on the node type to add your first node"
: "Click on the node type you want to add"}
</span>
</header>
<div className="space-y-2">
{nodeLibraryItems.map((item) => {
return (
<div
key={item.nodeType}
className="flex cursor-pointer items-center justify-between rounded-sm bg-slate-elevation4 p-4 hover:bg-slate-elevation5"
onClick={() => {
onNodeClick({
nodeType: item.nodeType,
next: workflowPanelData?.next ?? null,
parent: workflowPanelData?.parent,
previous: workflowPanelData?.previous ?? null,
connectingEdgeType:
workflowPanelData?.connectingEdgeType ??
"edgeWithAddButton",
});
closeWorkflowPanel();
}}
>
<div className="flex gap-2">
<div className="flex h-[2.75rem] w-[2.75rem] items-center justify-center rounded border border-slate-600">
{item.icon}
</div>
<div className="flex flex-col gap-1">
<span className="max-w-64 truncate text-base">
{item.title}
</span>
<span className="text-xs text-slate-400">
{item.description}
</span>
</div>
</div>
<PlusIcon className="h-6 w-6" />
</div>
);
})}
</div>
</div>
</div>
);
}
export { WorkflowNodeLibraryPanel };

View File

@@ -0,0 +1,129 @@
import { Cross2Icon } from "@radix-ui/react-icons";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { WorkflowParameterValueType } from "../../types/workflowTypes";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { ParametersState } from "../FlowRenderer";
type Props = {
type: "workflow" | "credential";
onClose: () => void;
onSave: (value: ParametersState[number]) => void;
};
const workflowParameterTypeOptions = [
{ label: "string", value: WorkflowParameterValueType.String },
{ label: "number", value: WorkflowParameterValueType.Float },
{ label: "boolean", value: WorkflowParameterValueType.Boolean },
{ label: "file", value: WorkflowParameterValueType.FileURL },
{ label: "JSON", value: WorkflowParameterValueType.JSON },
];
function WorkflowParameterAddPanel({ type, onClose, onSave }: Props) {
const [key, setKey] = useState("");
const [urlParameterKey, setUrlParameterKey] = useState("");
const [description, setDescription] = useState("");
const [collectionId, setCollectionId] = useState("");
const [parameterType, setParameterType] =
useState<WorkflowParameterValueType>("string");
return (
<div className="space-y-4">
<header className="flex items-center justify-between">
<span>
Add {type === "workflow" ? "Workflow" : "Credential"} Parameter
</span>
<Cross2Icon className="h-6 w-6 cursor-pointer" onClick={onClose} />
</header>
<div className="space-y-1">
<Label className="text-xs text-slate-300">Key</Label>
<Input value={key} onChange={(e) => setKey(e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs text-slate-300">Description</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{type === "workflow" && (
<div className="space-y-1">
<Label className="text-xs">Value Type</Label>
<Select
value={parameterType}
onValueChange={(value) =>
setParameterType(value as WorkflowParameterValueType)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{workflowParameterTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
)}
{type === "credential" && (
<>
<div className="space-y-1">
<Label className="text-xs text-slate-300">URL Parameter Key</Label>
<Input
value={urlParameterKey}
onChange={(e) => setUrlParameterKey(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-slate-300">Collection ID</Label>
<Input
value={collectionId}
onChange={(e) => setCollectionId(e.target.value)}
/>
</div>
</>
)}
<div className="flex justify-end">
<Button
onClick={() => {
if (type === "workflow") {
onSave({
key,
parameterType: "workflow",
dataType: parameterType,
description,
});
}
if (type === "credential") {
onSave({
key,
parameterType: "credential",
collectionId,
urlParameterKey,
description,
});
}
}}
>
Save
</Button>
</div>
</div>
);
}
export { WorkflowParameterAddPanel };

View File

@@ -0,0 +1,149 @@
import { Cross2Icon } from "@radix-ui/react-icons";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { useState } from "react";
import { WorkflowParameterValueType } from "../../types/workflowTypes";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Button } from "@/components/ui/button";
import { ParametersState } from "../FlowRenderer";
type Props = {
type: "workflow" | "credential";
onClose: () => void;
onSave: (value: ParametersState[number]) => void;
initialValues: ParametersState[number];
};
const workflowParameterTypeOptions = [
{ label: "string", value: WorkflowParameterValueType.String },
{ label: "number", value: WorkflowParameterValueType.Float },
{ label: "boolean", value: WorkflowParameterValueType.Boolean },
{ label: "file", value: WorkflowParameterValueType.FileURL },
{ label: "JSON", value: WorkflowParameterValueType.JSON },
];
function WorkflowParameterEditPanel({
type,
onClose,
onSave,
initialValues,
}: Props) {
const [key, setKey] = useState(initialValues.key);
const [urlParameterKey, setUrlParameterKey] = useState(
initialValues.parameterType === "credential"
? initialValues.urlParameterKey
: "",
);
const [description, setDescription] = useState(
initialValues.description || "",
);
const [collectionId, setCollectionId] = useState(
initialValues.parameterType === "credential"
? initialValues.collectionId
: "",
);
const [parameterType, setParameterType] =
useState<WorkflowParameterValueType>(
initialValues.parameterType === "workflow"
? initialValues.dataType
: "string",
);
return (
<div className="space-y-4">
<header className="flex items-center justify-between">
<span>
Edit {type === "workflow" ? "Workflow" : "Credential"} Parameter
</span>
<Cross2Icon className="h-6 w-6 cursor-pointer" onClick={onClose} />
</header>
<div className="space-y-1">
<Label className="text-xs text-slate-300">Key</Label>
<Input value={key} onChange={(e) => setKey(e.target.value)} />
</div>
<div className="space-y-1">
<Label className="text-xs text-slate-300">Description</Label>
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{type === "workflow" && (
<div className="space-y-1">
<Label className="text-xs">Value Type</Label>
<Select
value={parameterType}
onValueChange={(value) =>
setParameterType(value as WorkflowParameterValueType)
}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select a type" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{workflowParameterTypeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
</div>
)}
{type === "credential" && (
<>
<div className="space-y-1">
<Label className="text-xs text-slate-300">URL Parameter Key</Label>
<Input
value={urlParameterKey}
onChange={(e) => setUrlParameterKey(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-xs text-slate-300">Collection ID</Label>
<Input
value={collectionId}
onChange={(e) => setCollectionId(e.target.value)}
/>
</div>
</>
)}
<div className="flex justify-end">
<Button
onClick={() => {
if (type === "workflow") {
onSave({
key,
parameterType: "workflow",
dataType: parameterType,
description,
});
}
if (type === "credential") {
onSave({
key,
parameterType: "credential",
urlParameterKey,
collectionId,
description,
});
}
}}
>
Save
</Button>
</div>
</div>
);
}
export { WorkflowParameterEditPanel };

View File

@@ -1,45 +1,227 @@
import { useParams } from "react-router-dom"; import { useState } from "react";
import { useWorkflowQuery } from "../../hooks/useWorkflowQuery"; import { useWorkflowParametersState } from "../useWorkflowParametersState";
import { WorkflowParameterAddPanel } from "./WorkflowParameterAddPanel";
import { ParametersState } from "../FlowRenderer";
import { WorkflowParameterEditPanel } from "./WorkflowParameterEditPanel";
import { MixerVerticalIcon, PlusIcon } from "@radix-ui/react-icons";
import { Button } from "@/components/ui/button";
import { GarbageIcon } from "@/components/icons/GarbageIcon";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { DialogClose } from "@radix-ui/react-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
const WORKFLOW_EDIT_PANEL_WIDTH = 20 * 16;
const WORKFLOW_EDIT_PANEL_GAP = 1 * 16;
function WorkflowParametersPanel() { function WorkflowParametersPanel() {
const { workflowPermanentId } = useParams(); const [workflowParameters, setWorkflowParameters] =
useWorkflowParametersState();
const { data: workflow, isLoading } = useWorkflowQuery({ const [operationPanelState, setOperationPanelState] = useState<{
workflowPermanentId, active: boolean;
operation: "add" | "edit";
parameter?: ParametersState[number] | null;
type: "workflow" | "credential";
}>({
active: false,
operation: "add",
parameter: null,
type: "workflow",
}); });
if (isLoading || !workflow) {
return null;
}
const workflowParameters = workflow.workflow_definition.parameters.filter(
(parameter) => parameter.parameter_type === "workflow",
);
return ( return (
<div className="space-y-4"> <div className="relative w-[25rem] rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl">
<header> <div className="space-y-4">
<h1 className="text-lg">Workflow Parameters</h1> <header>
<span className="text-sm text-slate-400"> <h1 className="text-lg">Workflow Parameters</h1>
Create placeholder values that you can link in nodes. You will be <span className="text-sm text-slate-400">
prompted to fill them in before running your workflow. Create placeholder values that you can link in nodes. You will be
</span> prompted to fill them in before running your workflow.
</header> </span>
<section className="space-y-2"> </header>
{workflowParameters.map((parameter) => { <DropdownMenu>
return ( <DropdownMenuTrigger asChild>
<div <Button className="w-full">
key={parameter.key} <PlusIcon className="mr-2 h-6 w-6" />
className="flex items-center gap-4 rounded-md bg-slate-elevation1 px-3 py-2" Add Parameter
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-60">
<DropdownMenuLabel>Add Parameter</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => {
setOperationPanelState({
active: true,
operation: "add",
type: "workflow",
});
}}
> >
<span className="text-sm">{parameter.key}</span> Workflow Parameter
<span className="text-sm text-slate-400"> </DropdownMenuItem>
{parameter.workflow_parameter_type} <DropdownMenuItem
</span> onClick={() => {
setOperationPanelState({
active: true,
operation: "add",
type: "credential",
});
}}
>
Credential Parameter
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<section className="space-y-2">
{workflowParameters.map((parameter) => {
return (
<div
key={parameter.key}
className="flex items-center justify-between rounded-md bg-slate-elevation1 px-3 py-2"
>
<div className="flex items-center gap-4">
<span className="text-sm">{parameter.key}</span>
{parameter.parameterType === "workflow" ? (
<span className="text-sm text-slate-400">
{parameter.dataType}
</span>
) : (
<span className="text-sm text-slate-400">
{parameter.parameterType}
</span>
)}
</div>
<div className="flex items-center gap-2">
<MixerVerticalIcon
className="cursor-pointer"
onClick={() => {
setOperationPanelState({
active: true,
operation: "edit",
parameter: parameter,
type: parameter.parameterType,
});
}}
/>
<Dialog>
<DialogTrigger>
<GarbageIcon className="size-4 cursor-pointer text-destructive-foreground text-red-600" />
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
This parameter will be deleted.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Cancel</Button>
</DialogClose>
<Button
variant="destructive"
onClick={() => {
setWorkflowParameters(
workflowParameters.filter(
(p) => p.key !== parameter.key,
),
);
}}
>
Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);
})}
</section>
</div>
{operationPanelState.active && (
<div
className="absolute"
style={{
top: 0,
left: -1 * (WORKFLOW_EDIT_PANEL_WIDTH + WORKFLOW_EDIT_PANEL_GAP),
}}
>
{operationPanelState.operation === "add" && (
<div className="w-80 rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl">
<WorkflowParameterAddPanel
type={operationPanelState.type}
onSave={(parameter) => {
setWorkflowParameters([...workflowParameters, parameter]);
setOperationPanelState({
active: false,
operation: "add",
type: "workflow",
});
}}
onClose={() => {
setOperationPanelState({
active: false,
operation: "add",
type: "workflow",
});
}}
/>
</div> </div>
); )}
})} {operationPanelState.operation === "edit" &&
</section> operationPanelState.parameter && (
<div className="w-80 rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl">
<WorkflowParameterEditPanel
type={operationPanelState.type}
initialValues={operationPanelState.parameter}
onSave={(editedParameter) => {
setWorkflowParameters(
workflowParameters.map((parameter) => {
if (
parameter.key === operationPanelState.parameter?.key
) {
return editedParameter;
}
return parameter;
}),
);
setOperationPanelState({
active: false,
operation: "edit",
parameter: null,
type: "workflow",
});
}}
onClose={() => {
setOperationPanelState({
active: false,
operation: "edit",
parameter: null,
type: "workflow",
});
}}
/>
</div>
)}
</div>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,14 @@
import { useContext } from "react";
import { WorkflowParametersStateContext } from "./WorkflowParametersStateContext";
function useWorkflowParametersState() {
const value = useContext(WorkflowParametersStateContext);
if (value === undefined) {
throw new Error(
"useWorkflowParametersState must be used within a WorkflowParametersStateProvider",
);
}
return value;
}
export { useWorkflowParametersState };

View File

@@ -2,6 +2,18 @@ import { Edge } from "@xyflow/react";
import { AppNode } from "./nodes"; import { AppNode } from "./nodes";
import Dagre from "@dagrejs/dagre"; import Dagre from "@dagrejs/dagre";
import type { WorkflowBlock } from "../types/workflowTypes"; import type { WorkflowBlock } from "../types/workflowTypes";
import { nodeTypes } from "./nodes";
import { taskNodeDefaultData } from "./nodes/TaskNode/types";
import { LoopNode, loopNodeDefaultData } from "./nodes/LoopNode/types";
import { codeBlockNodeDefaultData } from "./nodes/CodeBlockNode/types";
import { downloadNodeDefaultData } from "./nodes/DownloadNode/types";
import { uploadNodeDefaultData } from "./nodes/UploadNode/types";
import { sendEmailNodeDefaultData } from "./nodes/SendEmailNode/types";
import { textPromptNodeDefaultData } from "./nodes/TextPromptNode/types";
import { fileParserNodeDefaultData } from "./nodes/FileParserNode/types";
import { BlockYAML } from "../types/workflowYamlTypes";
import { NodeAdderNode } from "./nodes/NodeAdderNode/types";
import { REACT_FLOW_EDGE_Z_INDEX } from "./constants";
function layoutUtil( function layoutUtil(
nodes: Array<AppNode>, nodes: Array<AppNode>,
@@ -52,8 +64,12 @@ function layout(
(node) => node.id === edge.source || node.id === edge.target, (node) => node.id === edge.source || node.id === edge.target,
), ),
); );
const maxChildWidth = Math.max(
...childNodes.map((node) => node.measured?.width ?? 0),
);
const loopNodeWidth = 60 * 16; // 60 rem
const layouted = layoutUtil(childNodes, childEdges, { const layouted = layoutUtil(childNodes, childEdges, {
marginx: 240, marginx: (loopNodeWidth - maxChildWidth) / 2,
marginy: 200, marginy: 200,
}); });
loopNodeChildren[index] = layouted.nodes; loopNodeChildren[index] = layouted.nodes;
@@ -75,6 +91,8 @@ function convertToNode(
): AppNode { ): AppNode {
const common = { const common = {
draggable: false, draggable: false,
position: { x: 0, y: 0 },
connectable: false,
}; };
switch (block.block_type) { switch (block.block_type) {
case "task": { case "task": {
@@ -84,17 +102,17 @@ function convertToNode(
type: "task", type: "task",
data: { data: {
label: block.label, label: block.label,
editable: false, editable: true,
url: block.url ?? "", url: block.url ?? "",
navigationGoal: block.navigation_goal ?? "", navigationGoal: block.navigation_goal ?? "",
dataExtractionGoal: block.data_extraction_goal ?? "", dataExtractionGoal: block.data_extraction_goal ?? "",
dataSchema: block.data_schema ?? null, dataSchema: JSON.stringify(block.data_schema, null, 2),
errorCodeMapping: block.error_code_mapping ?? null, errorCodeMapping: JSON.stringify(block.error_code_mapping, null, 2),
allowDownloads: block.complete_on_download ?? false, allowDownloads: block.complete_on_download ?? false,
maxRetries: block.max_retries ?? null, maxRetries: block.max_retries ?? null,
maxStepsOverride: block.max_steps_per_run ?? null, maxStepsOverride: block.max_steps_per_run ?? null,
parameterKeys: block.parameters.map((p) => p.key),
}, },
position: { x: 0, y: 0 },
}; };
} }
case "code": { case "code": {
@@ -104,10 +122,9 @@ function convertToNode(
type: "codeBlock", type: "codeBlock",
data: { data: {
label: block.label, label: block.label,
editable: false, editable: true,
code: block.code, code: block.code,
}, },
position: { x: 0, y: 0 },
}; };
} }
case "send_email": { case "send_email": {
@@ -117,13 +134,13 @@ function convertToNode(
type: "sendEmail", type: "sendEmail",
data: { data: {
label: block.label, label: block.label,
editable: false, editable: true,
body: block.body, body: block.body,
fileAttachments: block.file_attachments, fileAttachments: block.file_attachments.join(", "),
recipients: block.recipients, recipients: block.recipients.join(", "),
subject: block.subject, subject: block.subject,
sender: block.sender,
}, },
position: { x: 0, y: 0 },
}; };
} }
case "text_prompt": { case "text_prompt": {
@@ -133,11 +150,10 @@ function convertToNode(
type: "textPrompt", type: "textPrompt",
data: { data: {
label: block.label, label: block.label,
editable: false, editable: true,
prompt: block.prompt, prompt: block.prompt,
jsonSchema: block.json_schema ?? null, jsonSchema: JSON.stringify(block.json_schema, null, 2),
}, },
position: { x: 0, y: 0 },
}; };
} }
case "for_loop": { case "for_loop": {
@@ -147,10 +163,9 @@ function convertToNode(
type: "loop", type: "loop",
data: { data: {
label: block.label, label: block.label,
editable: false, editable: true,
loopValue: block.loop_over.key, loopValue: block.loop_over.key,
}, },
position: { x: 0, y: 0 },
}; };
} }
case "file_url_parser": { case "file_url_parser": {
@@ -160,10 +175,9 @@ function convertToNode(
type: "fileParser", type: "fileParser",
data: { data: {
label: block.label, label: block.label,
editable: false, editable: true,
fileUrl: block.file_url, fileUrl: block.file_url,
}, },
position: { x: 0, y: 0 },
}; };
} }
@@ -174,10 +188,9 @@ function convertToNode(
type: "download", type: "download",
data: { data: {
label: block.label, label: block.label,
editable: false, editable: true,
url: block.url, url: block.url,
}, },
position: { x: 0, y: 0 },
}; };
} }
@@ -188,10 +201,9 @@ function convertToNode(
type: "upload", type: "upload",
data: { data: {
label: block.label, label: block.label,
editable: false, editable: true,
path: block.path, path: block.path,
}, },
position: { x: 0, y: 0 },
}; };
} }
} }
@@ -210,22 +222,274 @@ function getElements(
nodes.push(convertToNode({ id, parentId }, block)); nodes.push(convertToNode({ id, parentId }, block));
if (block.block_type === "for_loop") { if (block.block_type === "for_loop") {
const subElements = getElements(block.loop_blocks, id); const subElements = getElements(block.loop_blocks, id);
if (subElements.nodes.length === 0) {
nodes.push({
id: `${id}-nodeAdder`,
type: "nodeAdder",
position: { x: 0, y: 0 },
data: {},
draggable: false,
connectable: false,
});
}
nodes.push(...subElements.nodes); nodes.push(...subElements.nodes);
edges.push(...subElements.edges); edges.push(...subElements.edges);
} }
if (index !== blocks.length - 1) { if (index !== blocks.length - 1) {
edges.push({ edges.push({
id: `edge-${id}-${nextId}`, id: `edge-${id}-${nextId}`,
type: "edgeWithAddButton",
source: id, source: id,
target: nextId, target: nextId,
style: { style: {
strokeWidth: 2, strokeWidth: 2,
}, },
zIndex: REACT_FLOW_EDGE_Z_INDEX,
}); });
} }
}); });
if (nodes.length > 0) {
edges.push({
id: "edge-nodeAdder",
type: "default",
source: nodes[nodes.length - 1]!.id,
target: "nodeAdder",
style: {
strokeWidth: 2,
},
});
nodes.push({
id: "nodeAdder",
type: "nodeAdder",
position: { x: 0, y: 0 },
data: {},
draggable: false,
connectable: false,
});
}
return { nodes, edges }; return { nodes, edges };
} }
export { getElements, layout }; function createNode(
identifiers: { id: string; parentId?: string },
nodeType: Exclude<keyof typeof nodeTypes, "nodeAdder">,
labelPostfix: string, // unique label requirement
): AppNode {
const label = "Block " + labelPostfix;
const common = {
draggable: false,
position: { x: 0, y: 0 },
};
switch (nodeType) {
case "task": {
return {
...identifiers,
...common,
type: "task",
data: {
...taskNodeDefaultData,
label,
},
};
}
case "loop": {
return {
...identifiers,
...common,
type: "loop",
data: {
...loopNodeDefaultData,
label,
},
};
}
case "codeBlock": {
return {
...identifiers,
...common,
type: "codeBlock",
data: {
...codeBlockNodeDefaultData,
label,
},
};
}
case "download": {
return {
...identifiers,
...common,
type: "download",
data: {
...downloadNodeDefaultData,
label,
},
};
}
case "upload": {
return {
...identifiers,
...common,
type: "upload",
data: {
...uploadNodeDefaultData,
label,
},
};
}
case "sendEmail": {
return {
...identifiers,
...common,
type: "sendEmail",
data: {
...sendEmailNodeDefaultData,
label,
},
};
}
case "textPrompt": {
return {
...identifiers,
...common,
type: "textPrompt",
data: {
...textPromptNodeDefaultData,
label,
},
};
}
case "fileParser": {
return {
...identifiers,
...common,
type: "fileParser",
data: {
...fileParserNodeDefaultData,
label,
},
};
}
}
}
function JSONParseSafe(json: string): Record<string, unknown> | null {
try {
return JSON.parse(json);
} catch {
return null;
}
}
function getWorkflowBlock(
node: Exclude<AppNode, LoopNode | NodeAdderNode>,
): BlockYAML {
switch (node.type) {
case "task": {
return {
block_type: "task",
label: node.data.label,
url: node.data.url,
navigation_goal: node.data.navigationGoal,
data_extraction_goal: node.data.dataExtractionGoal,
data_schema: JSONParseSafe(node.data.dataSchema),
error_code_mapping: JSONParseSafe(node.data.errorCodeMapping) as Record<
string,
string
> | null,
max_retries: node.data.maxRetries ?? undefined,
max_steps_per_run: node.data.maxStepsOverride,
complete_on_download: node.data.allowDownloads,
parameter_keys: node.data.parameterKeys,
};
}
case "sendEmail": {
return {
block_type: "send_email",
label: node.data.label,
body: node.data.body,
file_attachments: node.data.fileAttachments.split(","),
recipients: node.data.recipients.split(","),
subject: node.data.subject,
sender: node.data.sender,
};
}
case "codeBlock": {
return {
block_type: "code",
label: node.data.label,
code: node.data.code,
};
}
case "download": {
return {
block_type: "download_to_s3",
label: node.data.label,
url: node.data.url,
};
}
case "upload": {
return {
block_type: "upload_to_s3",
label: node.data.label,
path: node.data.path,
};
}
case "fileParser": {
return {
block_type: "file_url_parser",
label: node.data.label,
file_url: node.data.fileUrl,
file_type: "csv",
};
}
case "textPrompt": {
return {
block_type: "text_prompt",
label: node.data.label,
llm_key: "",
prompt: node.data.prompt,
json_schema: JSONParseSafe(node.data.jsonSchema),
};
}
default: {
throw new Error("Invalid node type for getWorkflowBlock");
}
}
}
function getWorkflowBlocksUtil(nodes: Array<AppNode>): Array<BlockYAML> {
return nodes.flatMap((node) => {
if (node.parentId) {
return [];
}
if (node.type === "loop") {
return [
{
block_type: "for_loop",
label: node.data.label,
loop_over_parameter_key: node.data.loopValue,
loop_blocks: nodes
.filter((n) => n.parentId === node.id)
.map((n) => {
return getWorkflowBlock(
n as Exclude<AppNode, LoopNode | NodeAdderNode>,
);
}),
},
];
}
return [
getWorkflowBlock(node as Exclude<AppNode, LoopNode | NodeAdderNode>),
];
});
}
function getWorkflowBlocks(nodes: Array<AppNode>): Array<BlockYAML> {
return getWorkflowBlocksUtil(
nodes.filter((node) => node.type !== "nodeAdder"),
);
}
export { getElements, layout, createNode, getWorkflowBlocks };

View File

@@ -196,7 +196,7 @@ export type WorkflowBlock =
| FileURLParserBlock; | FileURLParserBlock;
export type WorkflowDefinition = { export type WorkflowDefinition = {
parameters: Array<WorkflowParameter>; parameters: Array<Parameter>;
blocks: Array<WorkflowBlock>; blocks: Array<WorkflowBlock>;
}; };
@@ -211,6 +211,7 @@ export type WorkflowApiResponse = {
workflow_definition: WorkflowDefinition; workflow_definition: WorkflowDefinition;
proxy_location: string; proxy_location: string;
webhook_callback_url: string; webhook_callback_url: string;
totp_verification_url: string;
created_at: string; created_at: string;
modified_at: string; modified_at: string;
deleted_at: string | null; deleted_at: string | null;

View File

@@ -0,0 +1,133 @@
export type WorkflowCreateYAMLRequest = {
title: string;
description?: string | null;
proxy_location?: string | null;
webhook_callback_url?: string | null;
totp_verification_url?: string | null;
workflow_definition: WorkflowDefinitionYAML;
is_saved_task?: boolean;
};
export type WorkflowDefinitionYAML = {
parameters: Array<ParameterYAML>;
blocks: Array<BlockYAML>;
};
export type ParameterYAML =
| WorkflowParameterYAML
| BitwardenLoginCredentialParameterYAML;
export type ParameterYAMLBase = {
parameter_type: string;
key: string;
description?: string | null;
};
export type WorkflowParameterYAML = ParameterYAMLBase & {
parameter_type: "workflow";
workflow_parameter_type: string;
default_value: string | null;
};
export type BitwardenLoginCredentialParameterYAML = ParameterYAMLBase & {
parameter_type: "bitwarden_login_credential";
bitwarden_collection_id: string;
url_parameter_key: string;
bitwarden_client_id_aws_secret_key: "SKYVERN_BITWARDEN_CLIENT_ID";
bitwarden_client_secret_aws_secret_key: "SKYVERN_BITWARDEN_CLIENT_SECRET";
bitwarden_master_password_aws_secret_key: "SKYVERN_BITWARDEN_MASTER_PASSWORD";
};
const BlockTypes = {
TASK: "task",
FOR_LOOP: "for_loop",
CODE: "code",
TEXT_PROMPT: "text_prompt",
DOWNLOAD_TO_S3: "download_to_s3",
UPLOAD_TO_S3: "upload_to_s3",
SEND_EMAIL: "send_email",
FILE_URL_PARSER: "file_url_parser",
} as const;
export type BlockType = (typeof BlockTypes)[keyof typeof BlockTypes];
export type BlockYAML =
| TaskBlockYAML
| CodeBlockYAML
| TextPromptBlockYAML
| DownloadToS3BlockYAML
| UploadToS3BlockYAML
| SendEmailBlockYAML
| FileUrlParserBlockYAML
| ForLoopBlockYAML;
export type BlockYAMLBase = {
block_type: BlockType;
label: string;
continue_on_failure?: boolean;
};
export type TaskBlockYAML = BlockYAMLBase & {
block_type: "task";
url: string | null;
title?: string;
navigation_goal: string | null;
data_extraction_goal: string | null;
data_schema: Record<string, unknown> | null;
error_code_mapping: Record<string, string> | null;
max_retries?: number;
max_steps_per_run?: number | null;
parameter_keys?: Array<string> | null;
complete_on_download?: boolean;
};
export type CodeBlockYAML = BlockYAMLBase & {
block_type: "code";
code: string;
parameter_keys?: Array<string> | null;
};
export type TextPromptBlockYAML = BlockYAMLBase & {
block_type: "text_prompt";
llm_key: string;
prompt: string;
json_schema?: Record<string, unknown> | null;
parameter_keys?: Array<string> | null;
};
export type DownloadToS3BlockYAML = BlockYAMLBase & {
block_type: "download_to_s3";
url: string;
};
export type UploadToS3BlockYAML = BlockYAMLBase & {
block_type: "upload_to_s3";
path?: string | null;
};
export type SendEmailBlockYAML = BlockYAMLBase & {
block_type: "send_email";
smtp_host_secret_parameter_key?: string;
smtp_port_secret_parameter_key?: string;
smtp_username_secret_parameter_key?: string;
smtp_password_secret_parameter_key?: string;
sender: string;
recipients: Array<string>;
subject: string;
body: string;
file_attachments?: Array<string> | null;
};
export type FileUrlParserBlockYAML = BlockYAMLBase & {
block_type: "file_url_parser";
file_url: string;
file_type: "csv";
};
export type ForLoopBlockYAML = BlockYAMLBase & {
block_type: "for_loop";
loop_over_parameter_key: string;
loop_blocks: Array<BlockYAML>;
};

View File

@@ -0,0 +1,49 @@
import { create } from "zustand";
type WorkflowPanelState = {
active: boolean;
content: "parameters" | "nodeLibrary";
data?: {
previous?: string | null;
next?: string | null;
parent?: string;
connectingEdgeType?: string;
};
};
type WorkflowPanelStore = {
workflowPanelState: WorkflowPanelState;
closeWorkflowPanel: () => void;
setWorkflowPanelState: (state: WorkflowPanelState) => void;
toggleWorkflowPanel: () => void;
};
const useWorkflowPanelStore = create<WorkflowPanelStore>((set, get) => {
return {
workflowPanelState: {
active: false,
content: "parameters",
},
setWorkflowPanelState: (workflowPanelState: WorkflowPanelState) => {
set({ workflowPanelState });
},
closeWorkflowPanel: () => {
set({
workflowPanelState: {
...get().workflowPanelState,
active: false,
},
});
},
toggleWorkflowPanel: () => {
set({
workflowPanelState: {
...get().workflowPanelState,
active: !get().workflowPanelState.active,
},
});
},
};
});
export { useWorkflowPanelStore };