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-icons": "^1.3.0",
"@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-select": "^2.0.0",
"@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": {
"version": "1.1.3",
"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-icons": "^1.3.0",
"@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-select": "^2.0.0",
"@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,
Pencil2Icon,
PlayIcon,
PlusIcon,
ReloadIcon,
} 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 { WorkflowsBetaAlertCard } from "./WorkflowsBetaAlertCard";
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() {
const credentialGetter = useCredentialGetter();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const workflowsPage = 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) {
return <WorkflowsBetaAlertCard />;
}
@@ -115,8 +150,21 @@ function Workflows() {
return (
<div className="space-y-8">
<header>
<header className="flex items-center justify-between">
<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>
<div className="rounded-md border">
<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 { python } from "@codemirror/lang-python";
import { tokyoNightStorm } from "@uiw/codemirror-theme-tokyo-night-storm";
@@ -22,7 +22,10 @@ function CodeEditor({
className,
fontSize = 8,
}: Props) {
const extensions = language === "json" ? [json()] : [python()];
const extensions =
language === "json"
? [json(), EditorView.lineWrapping]
: [python(), EditorView.lineWrapping];
return (
<CodeMirror
value={value}

View File

@@ -13,23 +13,100 @@ import "@xyflow/react/dist/style.css";
import { WorkflowHeader } from "./WorkflowHeader";
import { AppNode, nodeTypes } from "./nodes";
import "./reactFlowOverrideStyles.css";
import { layout } from "./workflowEditorUtils";
import { createNode, getWorkflowBlocks, layout } from "./workflowEditorUtils";
import { useEffect, useState } from "react";
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 = {
title: string;
initialTitle: string;
initialNodes: Array<AppNode>;
initialEdges: Array<Edge>;
initialParameters: ParametersState;
handleSave: (
parameters: Array<
WorkflowParameterYAML | BitwardenLoginCredentialParameterYAML
>,
blocks: Array<BlockYAML>,
title: string,
) => void;
};
function FlowRenderer({ title, initialEdges, initialNodes }: Props) {
const [rightSidePanelOpen, setRightSidePanelOpen] = useState(false);
const [rightSidePanelContent, setRightSidePanelContent] = useState<
"parameters" | "nodeLibrary" | null
>(null);
export type AddNodeProps = {
nodeType: Exclude<keyof typeof nodeTypes, "nodeAdder">;
previous: string | null;
next: string | 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 [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [parameters, setParameters] = useState(initialParameters);
const [title, setTitle] = useState(initialTitle);
const nodesInitialized = useNodesInitialized();
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
}, [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 (
<ReactFlow
nodes={nodes}
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,
}}
<WorkflowParametersStateContext.Provider
value={[parameters, setParameters]}
>
<Background variant={BackgroundVariant.Dots} bgColor="#020617" />
<Controls position="bottom-left" />
<Panel position="top-center" className="h-20">
<WorkflowHeader
title={title}
parametersPanelOpen={rightSidePanelOpen}
onParametersClick={() => {
setRightSidePanelOpen((open) => !open);
setRightSidePanelContent("parameters");
}}
/>
</Panel>
{rightSidePanelOpen && (
<Panel
position="top-right"
className="w-96 rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl"
>
{rightSidePanelContent === "parameters" && (
<WorkflowParametersPanel />
)}
<ReactFlow
nodes={nodes}
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}
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>
)}
</ReactFlow>
{workflowPanelState.active && (
<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 { useWorkflowQuery } from "../hooks/useWorkflowQuery";
import { FlowRenderer } from "./FlowRenderer";
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() {
const { workflowPermanentId } = useParams();
const credentialGetter = useCredentialGetter();
const { data: workflow, isLoading } = useWorkflowQuery({
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
if (isLoading) {
return (
@@ -30,9 +89,38 @@ function WorkflowEditor() {
<div className="h-screen w-full">
<ReactFlowProvider>
<FlowRenderer
title={workflow.title}
initialTitle={workflow.title}
initialNodes={elements.nodes}
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>
</div>

View File

@@ -1,3 +1,4 @@
import { SaveIcon } from "@/components/icons/SaveIcon";
import { Button } from "@/components/ui/button";
import {
ChevronDownIcon,
@@ -6,17 +7,22 @@ import {
PlayIcon,
} from "@radix-ui/react-icons";
import { useNavigate, useParams } from "react-router-dom";
import { EditableNodeTitle } from "./nodes/components/EditableNodeTitle";
type Props = {
title: string;
parametersPanelOpen: boolean;
onParametersClick: () => void;
onSave: () => void;
onTitleChange: (title: string) => void;
};
function WorkflowHeader({
title,
parametersPanelOpen,
onParametersClick,
onSave,
onTitleChange,
}: Props) {
const { workflowPermanentId } = useParams();
const navigate = useNavigate();
@@ -24,17 +30,34 @@ function WorkflowHeader({
return (
<div className="flex h-full w-full bg-slate-elevation2">
<div className="flex h-full w-1/3 items-center pl-6">
<div
className="cursor-pointer rounded-full p-2 hover:bg-slate-elevation5"
onClick={() => {
navigate("/workflows");
}}
>
<ExitIcon className="h-6 w-6" />
<div className="flex">
<div
className="cursor-pointer rounded-full p-2 hover:bg-slate-elevation5"
onClick={() => {
navigate("/workflows");
}}
>
<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 className="flex h-full w-1/3 items-center justify-center">
<span className="max-w-max truncate text-3xl">{title}</span>
<div className="flex h-full w-1/3 items-center justify-center p-1">
<EditableNodeTitle
editable={true}
onChange={onTitleChange}
value={title}
className="max-w-96 text-3xl"
/>
</div>
<div className="flex h-full w-1/3 items-center justify-end gap-4 p-4">
<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 { Label } from "@/components/ui/label";
import { CodeIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
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 (
<div>
<Handle
@@ -26,8 +29,12 @@ function CodeBlockNode({ data }: NodeProps<CodeBlockNode>) {
<CodeIcon className="h-6 w-6" />
</div>
<div className="flex flex-col gap-1">
<span className="max-w-64 truncate text-base">{data.label}</span>
<span className="text-xs text-slate-400">Task Block</span>
<EditableNodeTitle
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>
@@ -39,9 +46,11 @@ function CodeBlockNode({ data }: NodeProps<CodeBlockNode>) {
<CodeEditor
language="python"
value={data.code}
onChange={() => {
if (!data.editable) return;
// TODO
onChange={(value) => {
if (!data.editable) {
return;
}
updateNodeData(id, { code: value });
}}
className="nopan"
/>

View File

@@ -7,3 +7,9 @@ export type CodeBlockNodeData = {
};
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 { Label } from "@/components/ui/label";
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 { EditableNodeTitle } from "../components/EditableNodeTitle";
function DownloadNode({ id, data }: NodeProps<DownloadNode>) {
const { updateNodeData } = useReactFlow();
function DownloadNode({ data }: NodeProps<DownloadNode>) {
return (
<div>
<Handle
@@ -26,7 +29,11 @@ function DownloadNode({ data }: NodeProps<DownloadNode>) {
<DownloadIcon className="h-6 w-6" />
</div>
<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>
</div>
</div>
@@ -39,11 +46,11 @@ function DownloadNode({ data }: NodeProps<DownloadNode>) {
<Label className="text-sm text-slate-400">File URL</Label>
<Input
value={data.url}
onChange={() => {
onChange={(event) => {
if (!data.editable) {
return;
}
// TODO
updateNodeData(id, { url: event.target.value });
}}
className="nopan"
/>

View File

@@ -7,3 +7,9 @@ export type DownloadNodeData = {
};
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 { CursorTextIcon, DotsHorizontalIcon } from "@radix-ui/react-icons";
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 (
<div>
<Handle
@@ -25,7 +27,11 @@ function FileParserNode({ data }: NodeProps<FileParserNode>) {
<CursorTextIcon className="h-6 w-6" />
</div>
<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>
</div>
</div>
@@ -38,11 +44,11 @@ function FileParserNode({ data }: NodeProps<FileParserNode>) {
<span className="text-sm text-slate-400">File URL</span>
<Input
value={data.fileUrl}
onChange={() => {
onChange={(event) => {
if (!data.editable) {
return;
}
// TODO
updateNodeData(id, { fileUrl: event.target.value });
}}
className="nopan"
/>

View File

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

View File

@@ -7,3 +7,9 @@ export type LoopNodeData = {
};
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 { DotsHorizontalIcon, EnvelopeClosedIcon } from "@radix-ui/react-icons";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
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 (
<div>
<Handle
@@ -28,7 +30,11 @@ function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
<EnvelopeClosedIcon className="h-6 w-6" />
</div>
<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>
</div>
</div>
@@ -37,24 +43,42 @@ function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
</div>
</div>
<div className="space-y-1">
<Label className="text-xs text-slate-300">Recipient</Label>
<Label className="text-xs text-slate-300">Sender</Label>
<Input
onChange={() => {
if (!data.editable) return;
// TODO
onChange={(event) => {
if (!data.editable) {
return;
}
updateNodeData(id, { sender: event.target.value });
}}
value={data.recipients.join(", ")}
value={data.sender}
placeholder="example@gmail.com"
className="nopan"
/>
</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 />
<div className="space-y-1">
<Label className="text-xs text-slate-300">Subject</Label>
<Input
onChange={() => {
if (!data.editable) return;
// TODO
onChange={(event) => {
if (!data.editable) {
return;
}
updateNodeData(id, { subject: event.target.value });
}}
value={data.subject}
placeholder="What is the gist?"
@@ -64,9 +88,11 @@ function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
<div className="space-y-1">
<Label className="text-xs text-slate-300">Body</Label>
<Input
onChange={() => {
if (!data.editable) return;
// TODO
onChange={(event) => {
if (!data.editable) {
return;
}
updateNodeData(id, { body: event.target.value });
}}
value={data.body}
placeholder="What would you like to say?"
@@ -77,21 +103,16 @@ function SendEmailNode({ data }: NodeProps<SendEmailNode>) {
<div className="space-y-1">
<Label className="text-xs text-slate-300">File Attachments</Label>
<Input
value={data.fileAttachments?.join(", ") ?? ""}
onChange={() => {
if (!data.editable) return;
// TODO
value={data.fileAttachments}
onChange={(event) => {
if (!data.editable) {
return;
}
updateNodeData(id, { fileAttachments: event.target.value });
}}
className="nopan"
/>
</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>
);

View File

@@ -1,12 +1,23 @@
import type { Node } from "@xyflow/react";
export type SendEmailNodeData = {
recipients: string[];
recipients: string;
subject: string;
body: string;
fileAttachments: string[] | null;
fileAttachments: string;
editable: boolean;
label: string;
sender: string;
};
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 { Label } from "@/components/ui/label";
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} 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 { Label } from "@/components/ui/label";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
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 { editable } = data;
@@ -28,9 +40,12 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
<AutoResizingTextarea
value={data.url}
className="nopan"
onChange={() => {
if (!editable) return;
// TODO
name="url"
onChange={(event) => {
if (!editable) {
return;
}
updateNodeData(id, { url: event.target.value });
}}
placeholder="https://"
/>
@@ -38,9 +53,11 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
<div className="space-y-1">
<Label className="text-xs text-slate-300">Goal</Label>
<AutoResizingTextarea
onChange={() => {
if (!editable) return;
// TODO
onChange={(event) => {
if (!editable) {
return;
}
updateNodeData(id, { navigationGoal: event.target.value });
}}
value={data.navigationGoal}
placeholder="What are you looking to do?"
@@ -63,9 +80,11 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
<div className="space-y-1">
<Label className="text-xs text-slate-300">URL</Label>
<AutoResizingTextarea
onChange={() => {
if (!editable) return;
// TODO
onChange={(event) => {
if (!editable) {
return;
}
updateNodeData(id, { url: event.target.value });
}}
value={data.url}
placeholder="https://"
@@ -75,9 +94,11 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
<div className="space-y-1">
<Label className="text-xs text-slate-300">Goal</Label>
<AutoResizingTextarea
onChange={() => {
if (!editable) return;
// TODO
onChange={(event) => {
if (!editable) {
return;
}
updateNodeData(id, { navigationGoal: event.target.value });
}}
value={data.navigationGoal}
placeholder="What are you looking to do?"
@@ -96,28 +117,56 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
Data Extraction Goal
</Label>
<AutoResizingTextarea
onChange={() => {
if (!editable) return;
// TODO
onChange={(event) => {
if (!editable) {
return;
}
updateNodeData(id, {
dataExtractionGoal: event.target.value,
});
}}
value={data.dataExtractionGoal}
placeholder="What outputs are you looking to get?"
className="nopan"
/>
</div>
<DataSchema
value={data.dataSchema}
onChange={() => {
if (!editable) return;
// TODO
}}
/>
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">Data Schema</Label>
<Checkbox
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>
</AccordionContent>
</AccordionItem>
<AccordionItem value="limits">
<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="flex items-center justify-between">
<Label className="text-xs font-normal text-slate-300">
@@ -127,10 +176,15 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
type="number"
placeholder="0"
className="nopan w-44"
min="0"
value={data.maxRetries ?? 0}
onChange={() => {
if (!editable) return;
// TODO
onChange={(event) => {
if (!editable) {
return;
}
updateNodeData(id, {
maxRetries: Number(event.target.value),
});
}}
/>
</div>
@@ -142,10 +196,15 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
type="number"
placeholder="0"
className="nopan w-44"
min="0"
value={data.maxStepsOverride ?? 0}
onChange={() => {
if (!editable) return;
// TODO
onChange={(event) => {
if (!editable) {
return;
}
updateNodeData(id, {
maxStepsOverride: Number(event.target.value),
});
}}
/>
</div>
@@ -156,20 +215,49 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
<div className="w-44">
<Switch
checked={data.allowDownloads}
onCheckedChange={() => {
if (!editable) return;
// TODO
onCheckedChange={(checked) => {
if (!editable) {
return;
}
updateNodeData(id, { allowDownloads: checked });
}}
/>
</div>
</div>
<TaskNodeErrorMapping
value={data.errorCodeMapping}
onChange={() => {
if (!editable) return;
// TODO
}}
/>
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs font-normal text-slate-300">
Error Messages
</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>
</AccordionContent>
</AccordionItem>
@@ -198,7 +286,11 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
<ListBulletIcon className="h-6 w-6" />
</div>
<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>
</div>
</div>
@@ -206,10 +298,28 @@ function TaskNode({ data }: NodeProps<TaskNode>) {
<DotsHorizontalIcon className="h-6 w-6" />
</div>
</div>
<TaskNodeDisplayModeSwitch
value={displayMode}
onChange={setDisplayMode}
/>
<div className="flex justify-between">
<TaskNodeDisplayModeSwitch
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 === "advanced" && advancedContent}
</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;
navigationGoal: string;
dataExtractionGoal: string;
errorCodeMapping: Record<string, string> | null;
dataSchema: Record<string, unknown> | null;
errorCodeMapping: string;
dataSchema: string;
maxRetries: number | null;
maxStepsOverride: number | null;
allowDownloads: boolean;
editable: boolean;
label: string;
parameterKeys: Array<string>;
};
export type TaskNode = Node<TaskNodeData, "task">;
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 { Handle, NodeProps, Position } from "@xyflow/react";
import { Handle, NodeProps, Position, useReactFlow } from "@xyflow/react";
import type { TextPromptNode } from "./types";
import { Label } from "@/components/ui/label";
import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea";
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 (
<div>
<Handle
@@ -28,7 +33,11 @@ function TextPromptNode({ data }: NodeProps<TextPromptNode>) {
<CursorTextIcon className="h-6 w-6" />
</div>
<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>
</div>
</div>
@@ -39,9 +48,11 @@ function TextPromptNode({ data }: NodeProps<TextPromptNode>) {
<div className="space-y-1">
<Label className="text-xs text-slate-300">Prompt</Label>
<AutoResizingTextarea
onChange={() => {
if (!data.editable) return;
// TODO
onChange={(event) => {
if (!editable) {
return;
}
updateNodeData(id, { prompt: event.target.value });
}}
value={data.prompt}
placeholder="What do you want to generate?"
@@ -49,13 +60,37 @@ function TextPromptNode({ data }: NodeProps<TextPromptNode>) {
/>
</div>
<Separator />
<DataSchema
value={data.jsonSchema}
onChange={() => {
if (!data.editable) return;
// TODO
}}
/>
<div className="space-y-2">
<div className="flex gap-2">
<Label className="text-xs text-slate-300">Data Schema</Label>
<Checkbox
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>
);

View File

@@ -2,9 +2,16 @@ import type { Node } from "@xyflow/react";
export type TextPromptNodeData = {
prompt: string;
jsonSchema: Record<string, unknown> | null;
jsonSchema: string;
editable: boolean;
label: string;
};
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 { DotsHorizontalIcon, UploadIcon } from "@radix-ui/react-icons";
import { Label } from "@/components/ui/label";
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 (
<div>
<Handle
@@ -26,7 +29,11 @@ function UploadNode({ data }: NodeProps<UploadNode>) {
<UploadIcon className="h-6 w-6" />
</div>
<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>
</div>
</div>
@@ -39,11 +46,11 @@ function UploadNode({ data }: NodeProps<UploadNode>) {
<Label className="text-sm text-slate-400">File Path</Label>
<Input
value={data.path}
onChange={() => {
onChange={(event) => {
if (!data.editable) {
return;
}
// TODO
updateNodeData(id, { path: event.target.value });
}}
className="nopan"
/>

View File

@@ -7,3 +7,9 @@ export type UploadNodeData = {
};
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 type { DownloadNode } from "./DownloadNode/types";
import { DownloadNode as DownloadNodeComponent } from "./DownloadNode/DownloadNode";
import type { NodeAdderNode } from "./NodeAdderNode/types";
import { NodeAdderNode as NodeAdderNodeComponent } from "./NodeAdderNode/NodeAdderNode";
export type AppNode =
| LoopNode
@@ -24,7 +26,8 @@ export type AppNode =
| CodeBlockNode
| FileParserNode
| UploadNode
| DownloadNode;
| DownloadNode
| NodeAdderNode;
export const nodeTypes = {
loop: memo(LoopNodeComponent),
@@ -35,4 +38,5 @@ export const nodeTypes = {
fileParser: memo(FileParserNodeComponent),
upload: memo(UploadNodeComponent),
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 { useWorkflowQuery } from "../../hooks/useWorkflowQuery";
import { useState } from "react";
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() {
const { workflowPermanentId } = useParams();
const { data: workflow, isLoading } = useWorkflowQuery({
workflowPermanentId,
const [workflowParameters, setWorkflowParameters] =
useWorkflowParametersState();
const [operationPanelState, setOperationPanelState] = useState<{
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 (
<div className="space-y-4">
<header>
<h1 className="text-lg">Workflow Parameters</h1>
<span className="text-sm text-slate-400">
Create placeholder values that you can link in nodes. You will be
prompted to fill them in before running your workflow.
</span>
</header>
<section className="space-y-2">
{workflowParameters.map((parameter) => {
return (
<div
key={parameter.key}
className="flex items-center gap-4 rounded-md bg-slate-elevation1 px-3 py-2"
<div className="relative w-[25rem] rounded-xl border border-slate-700 bg-slate-950 p-5 shadow-xl">
<div className="space-y-4">
<header>
<h1 className="text-lg">Workflow Parameters</h1>
<span className="text-sm text-slate-400">
Create placeholder values that you can link in nodes. You will be
prompted to fill them in before running your workflow.
</span>
</header>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button className="w-full">
<PlusIcon className="mr-2 h-6 w-6" />
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>
<span className="text-sm text-slate-400">
{parameter.workflow_parameter_type}
</span>
Workflow Parameter
</DropdownMenuItem>
<DropdownMenuItem
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>
);
})}
</section>
)}
{operationPanelState.operation === "edit" &&
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>
);
}

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 Dagre from "@dagrejs/dagre";
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(
nodes: Array<AppNode>,
@@ -52,8 +64,12 @@ function layout(
(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, {
marginx: 240,
marginx: (loopNodeWidth - maxChildWidth) / 2,
marginy: 200,
});
loopNodeChildren[index] = layouted.nodes;
@@ -75,6 +91,8 @@ function convertToNode(
): AppNode {
const common = {
draggable: false,
position: { x: 0, y: 0 },
connectable: false,
};
switch (block.block_type) {
case "task": {
@@ -84,17 +102,17 @@ function convertToNode(
type: "task",
data: {
label: block.label,
editable: false,
editable: true,
url: block.url ?? "",
navigationGoal: block.navigation_goal ?? "",
dataExtractionGoal: block.data_extraction_goal ?? "",
dataSchema: block.data_schema ?? null,
errorCodeMapping: block.error_code_mapping ?? null,
dataSchema: JSON.stringify(block.data_schema, null, 2),
errorCodeMapping: JSON.stringify(block.error_code_mapping, null, 2),
allowDownloads: block.complete_on_download ?? false,
maxRetries: block.max_retries ?? null,
maxStepsOverride: block.max_steps_per_run ?? null,
parameterKeys: block.parameters.map((p) => p.key),
},
position: { x: 0, y: 0 },
};
}
case "code": {
@@ -104,10 +122,9 @@ function convertToNode(
type: "codeBlock",
data: {
label: block.label,
editable: false,
editable: true,
code: block.code,
},
position: { x: 0, y: 0 },
};
}
case "send_email": {
@@ -117,13 +134,13 @@ function convertToNode(
type: "sendEmail",
data: {
label: block.label,
editable: false,
editable: true,
body: block.body,
fileAttachments: block.file_attachments,
recipients: block.recipients,
fileAttachments: block.file_attachments.join(", "),
recipients: block.recipients.join(", "),
subject: block.subject,
sender: block.sender,
},
position: { x: 0, y: 0 },
};
}
case "text_prompt": {
@@ -133,11 +150,10 @@ function convertToNode(
type: "textPrompt",
data: {
label: block.label,
editable: false,
editable: true,
prompt: block.prompt,
jsonSchema: block.json_schema ?? null,
jsonSchema: JSON.stringify(block.json_schema, null, 2),
},
position: { x: 0, y: 0 },
};
}
case "for_loop": {
@@ -147,10 +163,9 @@ function convertToNode(
type: "loop",
data: {
label: block.label,
editable: false,
editable: true,
loopValue: block.loop_over.key,
},
position: { x: 0, y: 0 },
};
}
case "file_url_parser": {
@@ -160,10 +175,9 @@ function convertToNode(
type: "fileParser",
data: {
label: block.label,
editable: false,
editable: true,
fileUrl: block.file_url,
},
position: { x: 0, y: 0 },
};
}
@@ -174,10 +188,9 @@ function convertToNode(
type: "download",
data: {
label: block.label,
editable: false,
editable: true,
url: block.url,
},
position: { x: 0, y: 0 },
};
}
@@ -188,10 +201,9 @@ function convertToNode(
type: "upload",
data: {
label: block.label,
editable: false,
editable: true,
path: block.path,
},
position: { x: 0, y: 0 },
};
}
}
@@ -210,22 +222,274 @@ function getElements(
nodes.push(convertToNode({ id, parentId }, block));
if (block.block_type === "for_loop") {
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);
edges.push(...subElements.edges);
}
if (index !== blocks.length - 1) {
edges.push({
id: `edge-${id}-${nextId}`,
type: "edgeWithAddButton",
source: id,
target: nextId,
style: {
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 };
}
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;
export type WorkflowDefinition = {
parameters: Array<WorkflowParameter>;
parameters: Array<Parameter>;
blocks: Array<WorkflowBlock>;
};
@@ -211,6 +211,7 @@ export type WorkflowApiResponse = {
workflow_definition: WorkflowDefinition;
proxy_location: string;
webhook_callback_url: string;
totp_verification_url: string;
created_at: string;
modified_at: string;
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 };