Compare commits

...

245 Commits

Author SHA1 Message Date
muzam1l
b2b7059774 Remove account import limit. 2022-07-21 15:31:06 +05:30
muzamil
3897f2d823 Merge pull request #246 from XRPLF/feat/tabs-comp
Tabs and Context menu components.
2022-07-20 17:46:04 +05:30
muzam1l
bf792f1495 Fix dependecy issues. 2022-07-20 14:55:59 +05:30
muzam1l
df3210a663 Add context menu component 2022-07-20 14:12:48 +05:30
muzam1l
bad7730c32 Fix extension requirement error. 2022-07-20 14:05:02 +05:30
muzam1l
adb6a78549 update active compiled file based on active editor file. 2022-07-20 14:05:02 +05:30
muzam1l
8cc27f20c3 Migrate File navigation to tabs component. 2022-07-20 14:05:02 +05:30
muzamil
8e49a3f5f1 Merge pull request #253 from XRPLF/fix/json-tx
Fix json mode.
2022-07-20 13:59:42 +05:30
muzam1l
3179757469 Fix TxFields type. 2022-07-19 17:46:13 +05:30
muzam1l
554cfb3db9 Add fee field to all transactions. 2022-07-19 17:45:41 +05:30
muzam1l
637a066f69 Fix tx reset state button 2022-07-19 17:36:09 +05:30
muzam1l
c9a852e9be Add destination field to NFTokenCreateOffer 2022-07-19 16:05:10 +05:30
muzam1l
307a5407eb Fix handling of Destination field in transactions. 2022-07-19 16:01:21 +05:30
muzam1l
faa28845c8 Ensure editor value updation on state change 2022-07-18 21:26:05 +05:30
muzam1l
168d11d48e Remove editorSavedValue from tx state 2022-07-18 19:10:33 +05:30
muzam1l
60f2bb558c Cancel button on json discard dialog. 2022-07-18 17:10:13 +05:30
muzamil
fdf33b9f45 Merge pull request #251 from XRPLF/fix/binary-codec
Fix binary codec (#250)
2022-07-15 16:17:43 +05:30
muzam1l
d05180d148 Fix array fields. 2022-07-15 14:49:54 +05:30
muzamil
bfaa6be17d Fix binary codec (#250)
* TokenID -> NFTokenID

* update xrpl-accountlib

* fixup patch

* add missing transaction types

* fix: repositories

* fix: update @types/react, move middleware

* revert yarn.lock

* revert

* revert: yarn.lock

* fix: transactions

* update NFTokenCancelOffer

* add missing fee

* add: nft transaction type + recalculate `start`

Co-authored-by: dangell7 <denisangell7@gmail.com>
2022-07-15 13:34:19 +05:30
muzamil
9e368dec84 Merge pull request #248 from XRPLF/fix/trust-set
Fix TrustSet transaction example.
2022-07-15 13:13:21 +05:30
muzam1l
25eec6980f Fix TrustSet transaction example. 2022-07-14 20:05:32 +05:30
muzamil
8e2f20c5ac Merge pull request #239 from XRPLF/feat/monaco-comp
Monaco component.
2022-07-14 13:51:24 +05:30
muzam1l
a3d094e873 Fix some more spelling errors. 2022-07-13 20:11:21 +05:30
muzam1l
ef70bfb13a Fix spelling error 2022-07-13 19:49:54 +05:30
muzam1l
c26c7c13d1 Improve compile error handling. 2022-07-13 16:50:47 +05:30
muzam1l
fc461ddd0d Content changed warning on deploy page. 2022-07-13 16:17:31 +05:30
muzam1l
c7001f6089 'Exit editor mode' button in wat editor. 2022-07-13 15:40:08 +05:30
muzam1l
243cbfec08 Upgrade hooks editor to comp. 2022-07-13 15:09:45 +05:30
muzam1l
1295e7fa41 Deploy editor to comp 2022-07-13 15:09:45 +05:30
muzam1l
793623d216 Migrate transaction json editor to comp. 2022-07-13 15:09:45 +05:30
muzam1l
0cde0eb240 Monaco component 2022-07-13 15:09:45 +05:30
muzamil
e2acb48e03 Merge pull request #236 from XRPLF/feat/jsdoc-to-ui
User declaration of input fields via JSDOC in script files.
2022-07-12 16:53:27 +05:30
muzam1l
a4373bb970 fix spelling error 2022-07-12 12:40:35 +05:30
muzam1l
cfb791092a Update template files of some examples to forked versions. 2022-07-11 14:44:35 +05:30
muzamil
3fcbac5ed9 Merge pull request #231 from XRPLF/feat/create-account-name
Allow passing desired name while creating account.
2022-07-11 14:29:38 +05:30
muzam1l
c40b272ce8 Fix disabled prop behaviour 2022-07-07 23:34:14 +05:30
muzam1l
860ff66a8a fix error in error handler 2022-07-07 23:25:53 +05:30
muzam1l
f4f700bea1 Handle required prop on fields. 2022-07-06 19:30:25 +05:30
muzam1l
789bc00ac3 Enhance account secret label! 2022-07-06 19:03:29 +05:30
muzam1l
6a0aabdeda Handle jsdoc errors 2022-07-05 19:38:26 +05:30
muzam1l
175b6266e8 Add script error handling 2022-07-05 19:09:08 +05:30
muzam1l
621482e2ee enhance empty log display 2022-07-05 18:52:45 +05:30
muzam1l
e55f48bc83 Use Single LogBox comp for scripts too 2022-07-05 18:45:52 +05:30
muzam1l
3e9e26a46a Data variables in process.env instead of process 2022-07-05 16:58:07 +05:30
muzam1l
f0e730bb9b Remove secret tag on type Account 2022-07-05 16:35:23 +05:30
muzam1l
6ce4828fc6 Remove console logs 2022-07-05 16:25:15 +05:30
muzam1l
bb0a246ae5 User declaration of input fields via JSDOC in script files. 2022-07-05 16:02:15 +05:30
Valtteri Karesto
3af2bad536 Make ping interval longer (#232)
* Make ping interval 45s

Co-authored-by: Vaclav Barta <vbarta@mangrove.cz>
2022-07-04 12:22:06 +02:00
muzam1l
4f1b877db0 Added optional tag to create account label. 2022-07-01 19:41:52 +05:30
muzamil
0289d64f5e Merge pull request #233 from XRPLF/fix/tab-names
Fix tab names
2022-07-01 19:29:33 +05:30
muzamil
868a0bcf78 Merge pull request #234 from XRPLF/feat/account-in-deploy-dialog
Add account selectable in deploy dialog.
2022-07-01 18:51:04 +05:30
muzam1l
aab2476a05 Add account selectable in deploy dialog. 2022-07-01 18:06:01 +05:30
muzam1l
cb25986d72 update transaction tab labels 2022-07-01 17:30:57 +05:30
muzam1l
309ad57173 Skip auto appneding test file extension. 2022-07-01 17:26:10 +05:30
muzam1l
53afb1d3d1 Fix html erros. 2022-07-01 17:08:34 +05:30
muzam1l
31ff7c0e28 Name field in import account dialog. 2022-07-01 16:43:15 +05:30
muzam1l
dfa35df465 reset input value on submit 2022-07-01 16:27:13 +05:30
muzam1l
f163b052e1 Allow passing desired name while creating account. 2022-07-01 14:33:28 +05:30
Valtteri Karesto
25c5b9c015 Merge pull request #229 from XRPLF/feat/links-to-explorer
Link from hashes/addresses to Hook Explorer
2022-06-30 15:40:57 +03:00
Valtteri Karesto
407e3946ce Added underline on hover to links 2022-06-30 15:30:43 +03:00
Valtteri Karesto
dc5b0d71eb Simplified hook state, since endpoint now works with hookhashes 2022-06-30 08:54:43 +03:00
Valtteri Karesto
3fd6c3f50e Remove debug code 2022-06-29 18:03:26 +03:00
Valtteri Karesto
ec8bfc5eee Add links to account modal 2022-06-29 15:26:33 +03:00
Valtteri Karesto
b4a0bcb90d Merge pull request #227 from XRPLF/feat/remember-deploy-values
Remember deploy values / Add feedback button
2022-06-29 14:08:47 +03:00
Valtteri Karesto
2c729e2aa4 Update button text 2022-06-29 14:04:06 +03:00
Valtteri Karesto
1cb2542170 Merge branch 'main' of github.com:eqlabs/xrpl-hooks-ide into feat/remember-deploy-values 2022-06-29 13:47:41 +03:00
Wietse Wind
00b309df34 Merge pull request #228 from XRPLF/feature/add-plausible-analytics
Add plausible analytics to builder
2022-06-29 12:10:58 +02:00
Joni Juup
a6fc730de6 add plausible analytics to builder 2022-06-29 12:58:17 +03:00
Valtteri Karesto
2245c5a221 Test gh integration 2022-06-29 12:18:38 +03:00
Valtteri Karesto
60c33661ad Add proper defaultvalue 2022-06-29 12:14:52 +03:00
Valtteri Karesto
ea21c85038 Add noopener and noreferrer to link 2022-06-29 11:37:22 +03:00
Valtteri Karesto
5478f43609 persist deploy values in memory 2022-06-29 11:32:15 +03:00
Valtteri Karesto
a9b64abb85 Add feedback button, show modal only on homepage 2022-06-29 11:31:46 +03:00
Valtteri Karesto
c6ced424d8 Merge pull request #226 from XRPLF/feat/long-navigation-support
Fix #215 scrollbar issues
2022-06-28 14:59:35 +03:00
Valtteri Karesto
3a1159cffc Make thumbs more visible 2022-06-28 14:49:34 +03:00
Valtteri Karesto
3136de1bd1 Slight style adjustments 2022-06-28 14:38:59 +03:00
Valtteri Karesto
67ffd3f1b4 Fix #215 scrollbar issues 2022-06-28 14:01:08 +03:00
Valtteri Karesto
8508cb69c4 Merge pull request #224 from XRPLF/feat/debug-stream-fixes
Feat/debug stream fixes
2022-06-28 11:31:03 +03:00
Valtteri Karesto
89217d2633 Remove console.log 2022-06-28 11:03:56 +03:00
Valtteri Karesto
ba1b64391c ping socket connection 2022-06-28 09:36:45 +03:00
Valtteri Karesto
098d919a77 Bring back dispose 2022-06-27 15:03:49 +03:00
Valtteri Karesto
b2af37ab4b Use reconnecting-websocket and refactor debug stream 2022-06-27 15:03:42 +03:00
Valtteri Karesto
dcb7e94e86 New gists provided by XRPL (#223)
* Prepare logic for new gists

* Remove unused imports

* updated gist IDs for xrplfgists

* Add macro.h to apiheaderfiles

* Update headers

Co-authored-by: Vaclav Barta <vbarta@mangrove.cz>
2022-06-27 08:25:25 +02:00
Valtteri Karesto
67848b3d8d Merge pull request #222 from XRPLF/fix/suggest-button-disabled
Fix/suggest button disabled
2022-06-23 11:12:04 +03:00
Valtteri Karesto
31a86263a1 Disable suggest if no account selected 2022-06-22 14:42:50 +03:00
Valtteri Karesto
4d0025afc1 Fix splitscreen error 2022-06-22 14:42:39 +03:00
Valtteri Karesto
f85bd2398d Merge pull request #220 from XRPLF/fix/decimals-again
fixes #201
2022-06-22 12:28:12 +03:00
Valtteri Karesto
a2a6596cc5 Prevent pasting decimals 2022-06-22 12:16:36 +03:00
Valtteri Karesto
37208ce97e fixes #201 2022-06-22 12:03:43 +03:00
Valtteri Karesto
bf4042926d Merge pull request #218 from XRPLF/feat/ui-fixes
Fixes #213 and fixes #200
2022-06-22 11:41:31 +03:00
Valtteri Karesto
3ccc1c16ac Fixed deploy 2022-06-22 11:32:07 +03:00
Valtteri Karesto
135f0c91a1 Fixes #213 and fixes #200 2022-06-22 11:06:15 +03:00
Valtteri Karesto
8f5786e242 Merge pull request #216 from XRPLF/feat/change-default-optimization
fixes #214 change default optimization
2022-06-22 09:56:15 +03:00
Valtteri Karesto
810eb4ca27 Add new default to compileCode as well 2022-06-22 09:52:47 +03:00
Valtteri Karesto
e6574f9f12 fixes #214 change default optimization 2022-06-22 09:47:30 +03:00
Valtteri Karesto
1a6726fabf Merge pull request #212 from XRPLF/feat/improve-supp-js
Improve supplementary JS feature
2022-06-21 11:28:42 +03:00
Valtteri Karesto
89f8671217 Clear log should now work 2022-06-21 10:53:52 +03:00
Valtteri Karesto
fb5259221b Changed color of starting running 2022-06-21 09:43:29 +03:00
Valtteri Karesto
fd17f59616 Show LogBoxForScrips if js file active 2022-06-20 23:54:56 +03:00
Valtteri Karesto
91bbc7ea61 Catch template errors, add better labels, styling 2022-06-20 23:54:33 +03:00
Valtteri Karesto
783d832c6d Remove export for unused component 2022-06-20 23:53:32 +03:00
Valtteri Karesto
698ca376e7 Add showbuttons prop to LogBoxForScripts 2022-06-20 23:53:15 +03:00
Valtteri Karesto
bfd9e21ab8 Remove unused component 2022-06-20 23:52:45 +03:00
Valtteri Karesto
e46411f245 Rename template helpers 2022-06-20 14:53:30 +03:00
Valtteri Karesto
08447c6b29 Add support for select parameters 2022-06-20 14:16:16 +03:00
Valtteri Karesto
9216cc6bf7 When downloading zip, include wasm if it exists 2022-06-20 11:01:13 +03:00
Valtteri Karesto
5108b08e39 Do not show scripts panel if no supplementary scripts 2022-06-20 10:40:47 +03:00
Valtteri Karesto
6c46a4f809 Merge pull request #211 from XRPLF/feat/user-provided-scripts
Feat/user provided scripts
2022-06-20 10:16:06 +03:00
Valtteri Karesto
0ea88f0d32 Remove unused imports 2022-06-17 21:48:32 +03:00
Valtteri Karesto
4c2e1f36f3 Add empty line 2022-06-17 21:42:50 +03:00
Valtteri Karesto
fa5315fc0e Fix errors on development 2022-06-17 17:32:21 +03:00
Valtteri Karesto
eda8b1550c If not string, stringify it 2022-06-17 13:42:19 +03:00
Valtteri Karesto
742b11374f Just empty body 2022-06-16 10:12:40 +03:00
Valtteri Karesto
d16e83dcfa No need for specific log() or error() functions anymore 2022-06-16 00:50:16 +03:00
Valtteri Karesto
155aa57784 Fix warning about useLayoutEffect 2022-06-16 00:49:43 +03:00
Valtteri Karesto
b88b6da7d9 Add one more example env value 2022-06-16 00:49:17 +03:00
Valtteri Karesto
fa13f7e282 Add logic for scripts 2022-06-15 15:15:08 +03:00
Valtteri Karesto
f1a43ef758 Add dependencies and fix handlebars errors 2022-06-15 15:14:51 +03:00
Vaclav Barta
4217813fd7 take default header names from /api/header-files JSON (#210)
This is a client-side upgrade for the server-side XRPLF/xrpl-hooks-compiler#17 - not hard-coding expected header names but taking them from the compilation backend.
2022-06-13 08:22:55 +02:00
muzamil
c588f7b1f3 Merge pull request #209 from XRPLF/feat/json-in-ui
Display tx json in UI inside textarea.
2022-06-10 16:50:28 +05:30
muzam1l
985e8ee820 Merge branch 'main' into feat/json-in-ui 2022-06-10 16:50:08 +05:30
muzamil
8832e76a0a Merge pull request #208 from XRPLF/feat/default-tx
Set payment as default transaction
2022-06-10 16:47:01 +05:30
muzam1l
9777f1dbd1 Increase vert. padding of textarea 2022-06-08 15:03:51 +05:30
muzam1l
213d468aab Merge branch 'main' into feat/json-in-ui 2022-06-07 19:06:57 +05:30
muzam1l
46becb0e7b Display tx json in UI inside textarea. 2022-06-07 19:06:13 +05:30
Valtteri Karesto
fad6bd100a Merge pull request #205 from XRPLF/feat/prevent-decimals
Prevent inputing decimals
2022-06-07 14:55:05 +03:00
muzam1l
5a11f83fea Set payment as default transaction 2022-06-07 00:52:59 +05:30
Valtteri Karesto
525338abf7 Merge branch 'main' of github.com:eqlabs/xrpl-hooks-ide into feat/prevent-decimals 2022-06-03 17:21:00 +03:00
Valtteri Karesto
ea977816a4 Merge pull request #203 from XRPLF/feat/tab-ordering
Sort the files after fetching
2022-06-03 17:20:27 +03:00
Valtteri Karesto
0ee599a2b6 Update sort 2022-06-03 16:50:31 +03:00
Valtteri Karesto
02c59f8d79 Update sorting function 2022-06-03 16:34:46 +03:00
Valtteri Karesto
3d5b77e60a Fix issue if no filenames 2022-06-03 16:34:36 +03:00
Valtteri Karesto
92a167d47a Prevent also pasting 2022-06-03 15:33:46 +03:00
Valtteri Karesto
d41e263942 Merge pull request #204 from XRPLF/feat/remove-cleaner-ui
Remove cleaner options from UI
2022-06-03 15:08:50 +03:00
Valtteri Karesto
bd1226fe90 Remove log 2022-06-02 16:35:18 +03:00
Valtteri Karesto
57403e42dd Adjustments to sorting 2022-06-02 16:34:53 +03:00
Valtteri Karesto
2b42a96c4a Update ordering 2022-06-02 16:15:39 +03:00
Valtteri Karesto
80d6bb691d Prevent inputing decimals 2022-06-02 15:22:28 +03:00
Valtteri Karesto
c7e4cd7c92 Remove unused imports 2022-06-02 14:11:18 +03:00
Valtteri Karesto
4a22861860 Sort the files after fetching 2022-06-02 14:04:52 +03:00
muzamil
b09d029931 Merge pull request #196 from XRPLF/feat/fee-hint
Fee hints in transactions.
2022-06-01 16:21:24 +05:30
muzam1l
b2dc49754f Error toast in suggest button! 2022-06-01 15:51:08 +05:30
muzam1l
6f636645f7 Fix json saving mismatch 2022-05-31 00:53:58 +05:30
muzam1l
377c963c7a FIx json mode schema 2022-05-30 23:32:53 +05:30
muzam1l
ae038f17ff Suggest fee button in transaction ui 2022-05-30 23:01:46 +05:30
Vaclav Barta
0d8f2c31e7 Feature/hook documentation (#197)
* updated hooks-entry-points-check

* added hooks-trivial-cbak

* updated hooks-hash-buf-len: nonce => etxn_nonce + ledger_nonce
2022-05-30 12:49:19 +02:00
muzam1l
da9986eb66 Remove unnecessary console logs 2022-05-27 22:46:15 +05:30
muzam1l
a21350770e Fee hints in transactions. 2022-05-27 16:44:01 +05:30
Valtteri Karesto
49dfd43220 Merge pull request #193 from XRPLF/feat/fee-estimates
Feat/fee estimates
2022-05-27 12:23:50 +03:00
Valtteri Karesto
4472957f5c Updated based on feedback 2022-05-27 10:43:16 +03:00
muzamil
ca46707bb5 Merge pull request #195 from XRPLF/feat/tx_hash_link
Show tx hash instead of server ledger index in deploy log.
2022-05-26 15:28:31 +05:30
muzam1l
704ebe4b92 Show tx hash instead of server ledger index in deploy log. 2022-05-26 14:55:24 +05:30
Valtteri Karesto
9a6ef2c393 Minor fixes based on feedback 2022-05-25 13:39:52 +03:00
Valtteri Karesto
56203ce9c6 Remove package-lock 2022-05-25 13:10:05 +03:00
Valtteri Karesto
933bdb5968 Add fee estimate button to fee and update the deploying 2022-05-25 13:05:04 +03:00
Valtteri Karesto
864711697b Update accounts 2022-05-25 13:03:49 +03:00
Valtteri Karesto
e5eaf09721 Just return the fee values, no mutating 2022-05-25 13:03:38 +03:00
Valtteri Karesto
d0dde56c67 Merge pull request #192 from XRPLF/fix/fix-sequence-number
Fix/fix sequence number
2022-05-24 18:20:31 +03:00
Valtteri Karesto
45c6927e72 Take next sequence number from response 2022-05-24 18:16:56 +03:00
Valtteri Karesto
6014b6e79f Update sequence after successful request 2022-05-24 18:14:01 +03:00
Valtteri Karesto
04a99227df Merge pull request #191 from XRPLF/fix/v2-updates
Fix/v2 updates
2022-05-24 15:44:00 +03:00
Valtteri Karesto
0965a1e898 Merge pull request #190 from XRPLF/feat/update-descriptions
Update meta tags
2022-05-24 15:43:47 +03:00
Valtteri Karesto
32445dbebf Mutate tx with fee estimation 2022-05-24 15:27:20 +03:00
Valtteri Karesto
1a1d4901aa Add estimate fee function 2022-05-24 15:26:38 +03:00
Valtteri Karesto
8b646c56dc Fix recursion 2022-05-24 15:26:03 +03:00
Valtteri Karesto
ac38bbc72c Turn the cleaner on by default 2022-05-24 15:25:33 +03:00
Valtteri Karesto
bf1182351a Added link to project 2022-05-23 13:28:41 +03:00
Valtteri Karesto
55e48a943b Update readme 2022-05-23 13:26:46 +03:00
Valtteri Karesto
faf417be69 Update meta tags 2022-05-23 08:55:03 +03:00
Valtteri Karesto
c2eb57211f hotfix/Remove debug code 2022-05-12 14:31:00 +03:00
Valtteri Karesto
0e97df3c8e Merge pull request #188 from eqlabs/fix/old-account-fix
Fixes to users who has old accounts
2022-05-12 08:35:02 +03:00
Valtteri Karesto
5dd0dfdc18 Fixes to users who has old accounts 2022-05-11 16:07:49 +03:00
Valtteri Karesto
ef48bac8f6 Merge pull request #187 from eqlabs/feat/fix-modal
Updated main modal
2022-05-11 14:31:53 +03:00
Valtteri Karesto
3a3d984098 Updated some copy 2022-05-11 14:22:48 +03:00
Valtteri Karesto
2300c201f8 Lift up modal a bit 2022-05-11 13:37:05 +03:00
Valtteri Karesto
329dc4a355 Remove unused import 2022-05-11 13:31:27 +03:00
Valtteri Karesto
cd6a5b23d4 Icons are now components so we can control the color 2022-05-11 13:30:02 +03:00
Valtteri Karesto
4dd7cbe2ca Updated main modal 2022-05-11 13:07:08 +03:00
Valtteri Karesto
260de7c838 Merge pull request #186 from eqlabs/fix/change-label
Added experimental to label
2022-05-11 10:02:03 +03:00
Valtteri Karesto
e0ed31f220 Added experimental to label 2022-05-11 08:50:00 +03:00
muzamil
eba183497f Merge pull request #176 from eqlabs/feat/json-transactions
Json transactions
2022-05-10 18:15:00 +05:30
Valtteri Karesto
4378afa9a1 Merge pull request #185 from eqlabs/feat/add-cleaner-ui
Feat/add cleaner UI
2022-05-10 14:43:04 +03:00
Valtteri Karesto
491e10920b Updated label 2022-05-10 13:25:41 +03:00
Valtteri Karesto
65bb209713 Fixed wrong key 2022-05-10 11:53:31 +03:00
Valtteri Karesto
c07e70acc9 Add popover descriptions 2022-05-10 11:52:03 +03:00
Valtteri Karesto
8fd7f8ecad Change state key 2022-05-10 11:51:49 +03:00
Valtteri Karesto
2bb3c646db Add compile logic to ui 2022-05-09 14:18:32 +03:00
Valtteri Karesto
87f10a11b0 Add switch component and bg color to popover 2022-05-09 14:18:23 +03:00
Valtteri Karesto
949fb45ae2 Add colors 2022-05-09 14:18:02 +03:00
Valtteri Karesto
ea52f014dd Add radix switch 2022-05-09 14:17:55 +03:00
Valtteri Karesto
77eab8d88d Merge pull request #182 from eqlabs/fix/save-before-sync
"Save" files before syncing to github
2022-05-09 12:56:37 +03:00
Vaclav Barta
4ca8f5f236 Merge pull request #184 from eqlabs/feature/hook-doc-upd
documentation for new checks
2022-05-09 08:36:07 +02:00
muzam1l
53f2a71b08 minor changes 2022-05-05 21:02:33 +05:30
muzam1l
866f6257f1 json schema 2022-05-05 20:00:10 +05:30
Vaclav Barta
814b074cc0 added hooks-control-string-arg, hooks-release-define and hooks-skip-hash-buf-len docs 2022-05-04 14:22:25 +02:00
muzam1l
386619619b support object Amount 2022-05-04 16:57:32 +05:30
muzam1l
d8bf10d0b8 fix handling Destination field 2022-05-04 16:12:50 +05:30
muzam1l
d18c893025 Replace native alerts 2022-05-04 14:38:59 +05:30
Valtteri Karesto
822f7a30f5 "Save" files before syncing to github 2022-05-03 16:33:17 +03:00
Valtteri Karesto
1d66137c23 Merge pull request #181 from eqlabs/feat/add-optimization-settings
Feat/add optimization settings
2022-05-03 15:30:51 +03:00
Valtteri Karesto
4c42e75686 Remove example 2022-05-03 14:46:42 +03:00
Valtteri Karesto
501b7fefec Removed unnecessary setting 2022-05-03 14:17:36 +03:00
Valtteri Karesto
aa7e1517a2 Removed unused import 2022-05-03 14:07:43 +03:00
Valtteri Karesto
e33093f160 Remove console.log 2022-05-03 14:04:18 +03:00
Valtteri Karesto
923b689c98 Add compile options to ui 2022-05-03 14:03:14 +03:00
Valtteri Karesto
246e7f137f Add compile options to compile function 2022-05-03 14:03:02 +03:00
Valtteri Karesto
5defd12a11 Add popover component 2022-05-03 14:02:43 +03:00
Valtteri Karesto
abb7c2bb28 Add compileoptions to global state 2022-05-03 14:02:26 +03:00
Valtteri Karesto
12013907f8 Added radix popover 2022-05-03 14:02:16 +03:00
Valtteri Karesto
ec75fff74b Merge pull request #179 from eqlabs/fix/issue-175
Initial fix for issue #175
2022-05-03 11:20:19 +03:00
muzam1l
5e997044ed alert dialog component 2022-05-02 21:05:18 +05:30
muzam1l
e88720327e Allow using only imported accounts 2022-04-28 18:51:50 +05:30
muzam1l
bf568c3f46 Update button text 2022-04-28 18:33:20 +05:30
Valtteri Karesto
7c1068449f Initial fix for issue #175 2022-04-28 11:46:24 +03:00
Valtteri Karesto
b66d2a09a0 Merge pull request #178 from eqlabs/fix/issue-177
Fixes issue #177
2022-04-28 10:53:26 +03:00
muzam1l
1d3bd128f8 Implement auto save 2022-04-27 23:18:00 +05:30
muzam1l
ab1f45febd Editable json and unified state. 2022-04-27 18:34:28 +05:30
Valtteri Karesto
54265e024c Merge branch 'main' of github.com:eqlabs/xrpl-hooks-ide into fix/issue-177 2022-04-27 15:38:53 +03:00
Valtteri Karesto
20cb66ba18 Fixes issue #177 2022-04-27 15:33:42 +03:00
muzam1l
56a9806b70 add path to tx editor and refactor its value providing 2022-04-25 15:47:58 +05:30
muzam1l
b3f2d0fb6d Readonly tx json view 2022-04-22 18:46:35 +05:30
Vaclav Barta
b7d62dda83 Merge pull request #174 from eqlabs/feat/req-bin-hook-params
requiring user-quoted Hook Parameter values
2022-04-22 13:25:48 +02:00
Vaclav Barta
c690334f92 requiring user-quoted Hook Parameter values 2022-04-21 09:02:59 +02:00
muzamil
587f09ec00 Merge pull request #168 from eqlabs/feat/improved-logs
Background logs.
2022-04-20 16:45:14 +05:30
muzam1l
9296ea1acc remove a console.log 2022-04-20 16:44:49 +05:30
muzam1l
582fb17c94 Link ledger index to explorer 2022-04-20 13:45:17 +05:30
muzam1l
aff0142870 Debug history log only after clear timestamp 2022-04-19 20:51:11 +05:30
muzamil
df51d87cb2 Merge branch 'main' into feat/improved-logs 2022-04-19 20:43:05 +05:30
muzam1l
6a46f5f173 Merge branch 'main' into feat/improved-logs 2022-04-19 18:52:13 +05:30
muzamil
9e25cefef9 Merge pull request #162 from eqlabs/feat/transaction-persistence
Persisted transactions and debug stream state.
2022-04-19 18:42:46 +05:30
muzam1l
95966fa514 Merge branch 'main' into feat/transaction-persistence 2022-04-19 18:38:56 +05:30
Valtteri Karesto
f49d69e75d Merge pull request #172 from eqlabs/fix/regression-#171
Fix/regression #171
2022-04-14 15:41:59 +03:00
muzam1l
dfe5589074 fix 2022-04-14 17:05:36 +05:30
muzam1l
cdc50da840 don't show legacy logs in debug stream 2022-04-14 16:31:19 +05:30
muzam1l
4893b41936 revert to browser time parsing only 2022-04-14 15:02:00 +05:30
muzam1l
bf21fe36c3 timestamp and log fixes 2022-04-13 21:44:24 +05:30
muzam1l
a33a3eb6e2 Background logs 2022-04-13 16:59:02 +05:30
muzam1l
46e6927c68 Fix debug stream default account being enforcive 2022-04-08 16:33:46 +05:30
muzam1l
de95a82c5a Fix tx fields not updating 2022-04-08 16:25:03 +05:30
muzam1l
3b66d64c14 Fix debug stream default account when changing tx tabs 2022-04-08 16:03:37 +05:30
muzam1l
6e90a4c3d7 some refactoring 2022-04-08 15:17:54 +05:30
muzam1l
2287e7babb Added Label comp and minor style fixes 2022-04-08 14:41:08 +05:30
muzam1l
c219f7ea00 Persisted transaction tabs state 2022-04-07 16:10:16 +05:30
muzam1l
04e2274dbf some refactoring 2022-04-06 14:42:39 +05:30
muzam1l
842b8a5226 Set debug stream default account from transaction account 2022-04-04 21:29:08 +05:30
76 changed files with 5881 additions and 2927 deletions

View File

@@ -1,4 +1,5 @@
NEXTAUTH_URL=https://example.com
NEXTAUTH_SECRET="1234"
GITHUB_SECRET=""
GITHUB_ID=""
NEXT_PUBLIC_COMPILE_API_ENDPOINT="http://localhost:9000/api/build"

1
.gitignore vendored
View File

@@ -32,3 +32,4 @@ yarn-error.log*
# vercel
.vercel
.vscode

View File

@@ -1,6 +1,8 @@
# XRPL Hooks IDE
# XRPL Hooks Builder
This is the repository for XRPL Hooks IDE. This project is built with Next.JS
https://hooks-builder.xrpl.org/
This is the repository for XRPL Hooks Builder. This project is built with Next.JS
## General
@@ -106,3 +108,5 @@ To learn more about Next.js, take a look at the following resources:
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome!

View File

@@ -18,7 +18,7 @@ import {
DialogTrigger,
} from "./Dialog";
import { css } from "../stitches.config";
import { Input } from "./Input";
import { Input, Label } from "./Input";
import truncate from "../utils/truncate";
const labelStyle = css({
@@ -31,6 +31,7 @@ import transactionsData from "../content/transactions.json";
import { SetHookDialog } from "./SetHookDialog";
import { addFunds } from "../state/actions/addFaucetAccount";
import { deleteHook } from "../state/actions/deployHook";
import { capitalize } from "../utils/helpers";
export const AccountDialog = ({
activeAccountAddress,
@@ -42,12 +43,12 @@ export const AccountDialog = ({
const snap = useSnapshot(state);
const [showSecret, setShowSecret] = useState(false);
const activeAccount = snap.accounts.find(
(account) => account.address === activeAccountAddress
account => account.address === activeAccountAddress
);
return (
<Dialog
open={Boolean(activeAccountAddress)}
onOpenChange={(open) => {
onOpenChange={open => {
setShowSecret(false);
!open && setActiveAccountAddress(null);
}}
@@ -99,7 +100,7 @@ export const AccountDialog = ({
tabIndex={-1}
onClick={() => {
const index = state.accounts.findIndex(
(acc) => acc.address === activeAccount?.address
acc => acc.address === activeAccount?.address
);
state.accounts.splice(index, 1);
}}
@@ -116,9 +117,16 @@ export const AccountDialog = ({
<Text
css={{
fontFamily: "$monospace",
a: { "&:hover": { textDecoration: "underline" } },
}}
>
{activeAccount?.address}
<a
href={`https://${process.env.NEXT_PUBLIC_EXPLORER_URL}/${activeAccount?.address}`}
target="_blank"
rel="noopener noreferrer"
>
{activeAccount?.address}
</a>
</Text>
</Flex>
<Flex css={{ marginLeft: "auto", color: "$mauve12" }}>
@@ -158,7 +166,7 @@ export const AccountDialog = ({
}}
ghost
size="xs"
onClick={() => setShowSecret((curr) => !curr)}
onClick={() => setShowSecret(curr => !curr)}
>
{showSecret ? "Hide" : "Show"}
</Button>
@@ -215,7 +223,11 @@ export const AccountDialog = ({
</Button>
</Text>
</Flex>
<Flex css={{ marginLeft: "auto" }}>
<Flex
css={{
marginLeft: "auto",
}}
>
<a
href={`https://${process.env.NEXT_PUBLIC_EXPLORER_URL}/${activeAccount?.address}`}
target="_blank"
@@ -237,10 +249,22 @@ export const AccountDialog = ({
<Text
css={{
fontFamily: "$monospace",
a: { "&:hover": { textDecoration: "underline" } },
}}
>
{activeAccount && activeAccount.hooks.length > 0
? activeAccount.hooks.map((i) => truncate(i, 12)).join(",")
? activeAccount.hooks.map(i => {
return (
<a
key={i}
href={`https://${process.env.NEXT_PUBLIC_EXPLORER_URL}/${i}`}
target="_blank"
rel="noopener noreferrer"
>
{truncate(i, 12)}
</a>
);
})
: ""}
</Text>
</Flex>
@@ -278,7 +302,7 @@ interface AccountProps {
showHookStats?: boolean;
}
const Accounts: FC<AccountProps> = (props) => {
const Accounts: FC<AccountProps> = props => {
const snap = useSnapshot(state);
const [activeAccountAddress, setActiveAccountAddress] = useState<
string | null
@@ -286,7 +310,7 @@ const Accounts: FC<AccountProps> = (props) => {
useEffect(() => {
const fetchAccInfo = async () => {
if (snap.clientStatus === "online") {
const requests = snap.accounts.map((acc) =>
const requests = snap.accounts.map(acc =>
snap.client?.send({
id: `hooks-builder-req-info-${acc.address}`,
command: "account_info",
@@ -299,14 +323,26 @@ const Accounts: FC<AccountProps> = (props) => {
const balance = res?.account_data?.Balance as string;
const sequence = res?.account_data?.Sequence as number;
const accountToUpdate = state.accounts.find(
(acc) => acc.address === address
acc => acc.address === address
);
if (accountToUpdate) {
accountToUpdate.xrp = balance;
accountToUpdate.sequence = sequence;
accountToUpdate.error = null;
} else {
const oldAccount = state.accounts.find(
acc => acc.address === res?.account
);
if (oldAccount) {
oldAccount.xrp = "0";
oldAccount.error = {
code: res?.error,
message: res?.error_message,
};
}
}
});
const objectRequests = snap.accounts.map((acc) => {
const objectRequests = snap.accounts.map(acc => {
return snap.client?.send({
id: `hooks-builder-req-objects-${acc.address}`,
command: "account_objects",
@@ -317,7 +353,7 @@ const Accounts: FC<AccountProps> = (props) => {
objectResponses.forEach((res: any) => {
const address = res?.account as string;
const accountToUpdate = state.accounts.find(
(acc) => acc.address === address
acc => acc.address === address
);
if (accountToUpdate) {
accountToUpdate.hooks =
@@ -343,7 +379,7 @@ const Accounts: FC<AccountProps> = (props) => {
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [snap.accounts, snap.clientStatus]);
}, [snap.accounts.length, snap.clientStatus]);
return (
<Box
as="div"
@@ -381,9 +417,7 @@ const Accounts: FC<AccountProps> = (props) => {
<Wallet size="15px" /> <Text css={{ lineHeight: 1 }}>Accounts</Text>
</Heading>
<Flex css={{ ml: "auto", gap: "$3", marginRight: "$3" }}>
<Button ghost size="sm" onClick={() => addFaucetAccount(true)}>
Create
</Button>
<ImportAccountDialog type="create" />
<ImportAccountDialog />
</Flex>
</Flex>
@@ -400,7 +434,7 @@ const Accounts: FC<AccountProps> = (props) => {
overflowY: "auto",
}}
>
{snap.accounts.map((account) => (
{snap.accounts.map(account => (
<Flex
column
key={account.address + account.name}
@@ -431,28 +465,33 @@ const Accounts: FC<AccountProps> = (props) => {
wordBreak: "break-word",
}}
>
{account.address} (
{Dinero({
amount: Number(account?.xrp || "0"),
precision: 6,
})
.toUnit()
.toLocaleString(undefined, {
style: "currency",
currency: "XRP",
currencyDisplay: "name",
})}
)
{account.address}{" "}
{!account?.error ? (
`(${Dinero({
amount: Number(account?.xrp || "0"),
precision: 6,
})
.toUnit()
.toLocaleString(undefined, {
style: "currency",
currency: "XRP",
currencyDisplay: "name",
})})`
) : (
<Box css={{ color: "$red11" }}>
(Account not found, request funds to activate account)
</Box>
)}
</Text>
</Box>
{!props.hideDeployBtn && (
<div
className="hook-deploy-button"
onClick={(e) => {
onClick={e => {
e.stopPropagation();
}}
>
<SetHookDialog account={account} />
<SetHookDialog accountAddress={account.address} />
</div>
)}
</Flex>
@@ -474,31 +513,71 @@ const Accounts: FC<AccountProps> = (props) => {
);
};
export const transactionsOptions = transactionsData.map((tx) => ({
export const transactionsOptions = transactionsData.map(tx => ({
value: tx.TransactionType,
label: tx.TransactionType,
}));
const ImportAccountDialog = () => {
const [value, setValue] = useState("");
const ImportAccountDialog = ({
type = "import",
}: {
type?: "import" | "create";
}) => {
const [secret, setSecret] = useState("");
const [name, setName] = useState("");
const btnText = type === "import" ? "Import" : "Create";
const title = type === "import" ? "Import Account" : "Create Account";
const handleSubmit = async () => {
if (type === "create") {
const value = capitalize(name);
await addFaucetAccount(value, true);
setName("");
setSecret("");
return;
}
importAccount(secret, name);
setName("");
setSecret("");
};
return (
<Dialog>
<DialogTrigger asChild>
<Button ghost size="sm">
Import
{btnText}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Import account</DialogTitle>
<DialogDescription>
<label>Add account secret</label>
<Input
name="secret"
type="password"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
</DialogDescription>
<DialogContent aria-describedby={undefined}>
<DialogTitle css={{ mb: "$4" }}>{title}</DialogTitle>
<Flex column>
<Box css={{ mb: "$2" }}>
<Label>
Account name <Text muted>(optional)</Text>
</Label>
<Input
name="name"
type="text"
autoComplete="off"
autoCapitalize="on"
value={name}
onChange={e => setName(e.target.value)}
/>
</Box>
{type === "import" && (
<Box>
<Label>Account secret</Label>
<Input
required
name="secret"
type="password"
autoComplete="new-password"
value={secret}
onChange={e => setSecret(e.target.value)}
/>
</Box>
)}
</Flex>
<Flex
css={{
@@ -511,14 +590,8 @@ const ImportAccountDialog = () => {
<Button outline>Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button
variant="primary"
onClick={() => {
importAccount(value);
setValue("");
}}
>
Import account
<Button type="submit" variant="primary" onClick={handleSubmit}>
{title}
</Button>
</DialogClose>
</Flex>

View File

@@ -0,0 +1,75 @@
import { FC, ReactNode } from "react";
import { proxy, useSnapshot } from "valtio";
import Button from "../Button";
import Flex from "../Flex";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogTitle,
} from "./primitive";
export interface AlertState {
isOpen: boolean;
title?: string;
body?: ReactNode;
cancelText?: string;
confirmText?: string;
confirmPrefix?: ReactNode;
onConfirm?: () => any;
onCancel?: () => any;
}
export const alertState = proxy<AlertState>({
isOpen: false,
});
const Alert: FC = () => {
const {
title = "Are you sure?",
isOpen,
body,
cancelText,
confirmText = "Ok",
confirmPrefix,
onCancel,
onConfirm,
} = useSnapshot(alertState);
return (
<AlertDialog
open={isOpen}
onOpenChange={value => (alertState.isOpen = value)}
>
<AlertDialogContent>
<AlertDialogTitle>{title}</AlertDialogTitle>
<AlertDialogDescription>{body}</AlertDialogDescription>
<Flex css={{ justifyContent: "flex-end", gap: "$3" }}>
{(cancelText || onCancel) && (
<AlertDialogCancel asChild>
<Button css={{ minWidth: "$16" }} outline onClick={onCancel}>
{cancelText || "Cancel"}
</Button>
</AlertDialogCancel>
)}
<AlertDialogAction asChild>
<Button
css={{ minWidth: "$16" }}
variant="primary"
onClick={async () => {
await onConfirm?.();
alertState.isOpen = false;
}}
>
{confirmPrefix}
{confirmText}
</Button>
</AlertDialogAction>
</Flex>
</AlertDialogContent>
</AlertDialog>
);
};
export default Alert;

View File

@@ -1,7 +1,7 @@
import React from "react";
import { blackA } from "@radix-ui/colors";
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog";
import { styled, keyframes } from "../stitches.config";
import { styled, keyframes } from "../../stitches.config";
const overlayShow = keyframes({
"0%": { opacity: 0 },
@@ -75,7 +75,7 @@ const StyledDescription = styled(AlertDialogPrimitive.Description, {
marginBottom: 20,
color: "$mauve11",
lineHeight: 1.5,
fontSize: "$sm",
fontSize: "$md",
});
// Exports

View File

@@ -0,0 +1,152 @@
import { CaretRight, Check, Circle } from "phosphor-react";
import { FC, Fragment, ReactNode } from "react";
import { Flex, Text } from "../";
import {
ContextMenuCheckboxItem,
ContextMenuContent,
ContextMenuItem,
ContextMenuItemIndicator,
ContextMenuLabel,
ContextMenuRadioGroup,
ContextMenuRadioItem,
ContextMenuRoot,
ContextMenuSeparator,
ContextMenuTrigger,
ContextMenuTriggerItem,
} from "./primitive";
[
{
label: "Show bookmarks",
type: "checkbox",
checked: true,
indicator: "*",
onCheckedChange: () => {},
},
{
type: "radio",
label: "People",
value: "pedro",
onValueChange: () => {},
options: [
{
value: "pedro",
label: "Pedro Duarte",
},
{
value: "colm",
label: "Colm Tuite",
},
],
},
];
export type TextOption = {
type: "text";
label: ReactNode;
onClick?: () => any;
children?: Option[];
};
export type SeparatorOption = { type: "separator" };
export type CheckboxOption = {
type: "checkbox";
label: ReactNode;
checked?: boolean;
onCheckedChange?: (isChecked: boolean) => any;
};
export type RadioOption<T extends string = string> = {
type: "radio";
label: ReactNode;
onValueChange?: (value: string) => any;
value: T;
options?: { value: T; label?: ReactNode }[];
};
type WithCommons = { key: string; disabled?: boolean };
type Option = (TextOption | SeparatorOption | CheckboxOption | RadioOption) &
WithCommons;
interface IContextMenu {
options?: Option[];
isNested?: boolean;
}
export const ContextMenu: FC<IContextMenu> = ({
children,
options,
isNested,
}) => {
return (
<ContextMenuRoot>
{isNested ? (
<ContextMenuTriggerItem>{children}</ContextMenuTriggerItem>
) : (
<ContextMenuTrigger>{children}</ContextMenuTrigger>
)}
{options && (
<ContextMenuContent sideOffset={isNested ? 2 : 5}>
{options.map(({ key, ...option }) => {
if (option.type === "text") {
const { children, label } = option;
if (children)
return (
<ContextMenu isNested key={key} options={children}>
<Flex fluid row justify="space-between" align="center">
<Text>{label}</Text>
<CaretRight />
</Flex>
</ContextMenu>
);
return <ContextMenuItem key={key}>{label}</ContextMenuItem>;
}
if (option.type === "checkbox") {
const { label, checked, onCheckedChange } = option;
return (
<ContextMenuCheckboxItem
key={key}
checked={checked}
onCheckedChange={onCheckedChange}
>
<Flex row align="center">
<ContextMenuItemIndicator>
<Check />
</ContextMenuItemIndicator>
<Text css={{ ml: checked ? "$4" : undefined }}>
{label}
</Text>
</Flex>
</ContextMenuCheckboxItem>
);
}
if (option.type === "radio") {
const { label, options, onValueChange, value } = option;
return (
<Fragment key={key}>
<ContextMenuLabel>{label}</ContextMenuLabel>
<ContextMenuRadioGroup
value={value}
onValueChange={onValueChange}
>
{options?.map(({ value: v, label }) => {
return (
<ContextMenuRadioItem key={v} value={v}>
<ContextMenuItemIndicator>
<Circle weight="fill" />
</ContextMenuItemIndicator>
<Text css={{ ml: "$4" }}>{label}</Text>
</ContextMenuRadioItem>
);
})}
</ContextMenuRadioGroup>
</Fragment>
);
}
return <ContextMenuSeparator key={key} />;
})}
</ContextMenuContent>
)}
</ContextMenuRoot>
);
};
export default ContextMenu;

View File

@@ -0,0 +1,107 @@
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu";
import { styled } from "../../stitches.config";
import {
slideDownAndFade,
slideLeftAndFade,
slideRightAndFade,
slideUpAndFade,
} from "../../styles/keyframes";
const StyledContent = styled(ContextMenuPrimitive.Content, {
minWidth: 140,
backgroundColor: "$backgroundOverlay",
borderRadius: 6,
overflow: "hidden",
padding: "5px",
boxShadow:
"0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)",
"@media (prefers-reduced-motion: no-preference)": {
animationDuration: "400ms",
animationTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)",
willChange: "transform, opacity",
'&[data-state="open"]': {
'&[data-side="top"]': { animationName: slideDownAndFade },
'&[data-side="right"]': { animationName: slideLeftAndFade },
'&[data-side="bottom"]': { animationName: slideUpAndFade },
'&[data-side="left"]': { animationName: slideRightAndFade },
},
},
".dark &": {
boxShadow:
"0px 10px 38px -10px rgba(22, 23, 24, 0.85), 0px 10px 20px -15px rgba(22, 23, 24, 0.6)",
},
});
const itemStyles = {
all: "unset",
fontSize: 13,
lineHeight: 1,
color: "$text",
borderRadius: 3,
display: "flex",
alignItems: "center",
height: 28,
padding: "0 7px",
position: "relative",
paddingLeft: 10,
userSelect: "none",
"&[data-disabled]": {
color: "$textMuted",
pointerEvents: "none",
},
"&:focus": {
backgroundColor: "$purple9",
color: "$white",
},
};
const StyledItem = styled(ContextMenuPrimitive.Item, { ...itemStyles });
const StyledCheckboxItem = styled(ContextMenuPrimitive.CheckboxItem, {
...itemStyles,
});
const StyledRadioItem = styled(ContextMenuPrimitive.RadioItem, {
...itemStyles,
});
const StyledTriggerItem = styled(ContextMenuPrimitive.TriggerItem, {
'&[data-state="open"]': {
backgroundColor: "$purple9",
color: "$purple9",
},
...itemStyles,
});
const StyledLabel = styled(ContextMenuPrimitive.Label, {
paddingLeft: 10,
fontSize: 12,
lineHeight: "25px",
color: "$text",
});
const StyledSeparator = styled(ContextMenuPrimitive.Separator, {
height: 1,
backgroundColor: "$backgroundAlt",
margin: 5,
});
const StyledItemIndicator = styled(ContextMenuPrimitive.ItemIndicator, {
position: "absolute",
left: 0,
width: 25,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
});
export const ContextMenuRoot = ContextMenuPrimitive.Root;
export const ContextMenuTrigger = ContextMenuPrimitive.Trigger;
export const ContextMenuContent = StyledContent;
export const ContextMenuItem = StyledItem;
export const ContextMenuCheckboxItem = StyledCheckboxItem;
export const ContextMenuRadioGroup = ContextMenuPrimitive.RadioGroup;
export const ContextMenuRadioItem = StyledRadioItem;
export const ContextMenuItemIndicator = StyledItemIndicator;
export const ContextMenuTriggerItem = StyledTriggerItem;
export const ContextMenuLabel = StyledLabel;
export const ContextMenuSeparator = StyledSeparator;

View File

@@ -1,7 +1,9 @@
import { useCallback, useEffect } from "react";
import { useEffect } from "react";
import ReconnectingWebSocket, { CloseEvent } from "reconnecting-websocket";
import { proxy, ref, useSnapshot } from "valtio";
import { subscribeKey } from "valtio/utils";
import { Select } from ".";
import state, { ILog } from "../state";
import state, { ILog, transactionsState } from "../state";
import { extractJSON } from "../utils/json";
import LogBox from "./LogBox";
@@ -10,14 +12,96 @@ interface ISelect<T = string> {
value: T;
}
const streamState = proxy({
export interface IStreamState {
selectedAccount: ISelect | null;
status: "idle" | "opened" | "closed";
statusChangeTimestamp?: number;
logs: ILog[];
socket?: ReconnectingWebSocket;
}
export const streamState = proxy<IStreamState>({
selectedAccount: null as ISelect | null,
status: "idle",
logs: [] as ILog[],
socket: undefined as WebSocket | undefined,
});
const onOpen = (account: ISelect | null) => {
if (!account) {
return;
}
// streamState.logs = [];
streamState.status = "opened";
streamState.statusChangeTimestamp = Date.now();
pushLog(`Debug stream opened for account ${account?.value}`, {
type: "success",
});
};
const onError = () => {
pushLog("Something went wrong! Check your connection and try again.", {
type: "error",
});
};
const onClose = (e: CloseEvent) => {
// 999 = closed websocket connection by switching account
if (e.code !== 4999) {
pushLog(`Connection was closed. [code: ${e.code}]`, {
type: "error",
});
}
streamState.status = "closed";
streamState.statusChangeTimestamp = Date.now();
};
const onMessage = (event: any) => {
// Ping returns just account address, if we get that
// response we don't need to log anything
if (event.data !== streamState.selectedAccount?.value) {
pushLog(event.data);
}
};
let interval: NodeJS.Timer | null = null;
const addListeners = (account: ISelect | null) => {
if (account?.value && streamState.socket?.url.endsWith(account?.value)) {
return;
}
streamState.logs = [];
if (account?.value) {
if (interval) {
clearInterval(interval);
}
if (streamState.socket) {
streamState.socket?.removeEventListener("open", () => onOpen(account));
streamState.socket?.removeEventListener("close", onClose);
streamState.socket?.removeEventListener("error", onError);
streamState.socket?.removeEventListener("message", onMessage);
}
streamState.socket = ref(
new ReconnectingWebSocket(
`wss://${process.env.NEXT_PUBLIC_DEBUG_STREAM_URL}/${account?.value}`
)
);
if (streamState.socket) {
interval = setInterval(() => {
streamState.socket?.send("");
}, 45000);
}
streamState.socket.addEventListener("open", () => onOpen(account));
streamState.socket.addEventListener("close", onClose);
streamState.socket.addEventListener("error", onError);
streamState.socket.addEventListener("message", onMessage);
}
};
subscribeKey(streamState, "selectedAccount", addListeners);
const DebugStream = () => {
const { selectedAccount, logs, socket } = useSnapshot(streamState);
const { selectedAccount, logs } = useSnapshot(streamState);
const { activeHeader: activeTxTab } = useSnapshot(transactionsState);
const { accounts } = useSnapshot(state);
const accountOptions = accounts.map((acc) => ({
@@ -33,115 +117,80 @@ const DebugStream = () => {
options={accountOptions}
hideSelectedOptions
value={selectedAccount}
onChange={(acc) => (streamState.selectedAccount = acc as any)}
onChange={(acc) => {
streamState.socket?.close(
4999,
"Old connection closed because user switched account"
);
streamState.selectedAccount = acc as any;
}}
css={{ width: "100%" }}
/>
</>
);
const prepareLog = useCallback((str: any): ILog => {
if (typeof str !== "string") throw Error("Unrecognized debug log stream!");
const match = str.match(/([\s\S]+(?:UTC|ISO|GMT[+|-]\d+))\ ?([\s\S]*)/m);
const [_, tm, msg] = match || [];
const extracted = extractJSON(msg);
const timestamp = isNaN(Date.parse(tm || ""))
? tm
: new Date(tm).toLocaleTimeString();
const message = !extracted
? msg
: msg.slice(0, extracted.start) + msg.slice(extracted.end + 1);
const jsonData = extracted
? JSON.stringify(extracted.result, null, 2)
: undefined;
return {
type: "log",
message,
timestamp,
jsonData,
defaultCollapsed: true,
};
}, []);
useEffect(() => {
const account = selectedAccount?.value;
if (account && (!socket || !socket.url.endsWith(account))) {
socket?.close();
streamState.socket = ref(
new WebSocket(
`wss://${process.env.NEXT_PUBLIC_DEBUG_STREAM_URL}/${account}`
)
);
} else if (!account && socket) {
socket.close();
streamState.socket = undefined;
}
}, [selectedAccount?.value, socket]);
const account = transactionsState.transactions.find(
(tx) => tx.header === activeTxTab
)?.state.selectedAccount;
useEffect(() => {
const account = selectedAccount?.value;
const socket = streamState.socket;
if (!socket) return;
if (account && account.value !== streamState.selectedAccount?.value)
streamState.selectedAccount = account;
}, [activeTxTab]);
const onOpen = () => {
streamState.logs = [];
streamState.logs.push({
type: "success",
message: `Debug stream opened for account ${account}`,
});
};
const onError = () => {
streamState.logs.push({
type: "error",
message: "Something went wrong! Check your connection and try again.",
});
};
const onClose = (e: CloseEvent) => {
streamState.logs.push({
type: "error",
message: `Connection was closed. [code: ${e.code}]`,
});
streamState.selectedAccount = null;
};
const onMessage = (event: any) => {
if (!event.data) return;
const log = prepareLog(event.data);
// Filter out account_info and account_objects requests
try {
const parsed = JSON.parse(log.jsonData);
if (parsed?.id?._Request?.includes("hooks-builder-req")) {
return;
}
} catch (err) {
// Lets just skip if we cannot parse the message
}
return streamState.logs.push(log);
};
const clearLog = () => {
streamState.logs = [];
streamState.statusChangeTimestamp = Date.now();
};
socket.addEventListener("open", onOpen);
socket.addEventListener("close", onClose);
socket.addEventListener("error", onError);
socket.addEventListener("message", onMessage);
return () => {
socket.removeEventListener("open", onOpen);
socket.removeEventListener("close", onClose);
socket.removeEventListener("message", onMessage);
socket.removeEventListener("error", onError);
};
}, [prepareLog, selectedAccount?.value, socket]);
return (
<LogBox
enhanced
renderNav={renderNav}
title="Debug stream"
logs={logs}
clearLog={() => (streamState.logs = [])}
clearLog={clearLog}
/>
);
};
export default DebugStream;
export const pushLog = (
str: any,
opts: Partial<Pick<ILog, "type">> = {}
): ILog | undefined => {
if (!str) return;
if (typeof str !== "string") throw Error("Unrecognized debug log stream!");
const match = str.match(/([\s\S]+(?:UTC|ISO|GMT[+|-]\d+))?\ ?([\s\S]*)/m);
const [_, tm, msg] = match || [];
const timestamp = Date.parse(tm || "") || undefined;
const timestring = !timestamp ? tm : new Date(timestamp).toLocaleTimeString();
const extracted = extractJSON(msg);
const message = !extracted
? msg
: msg.slice(0, extracted.start) + msg.slice(extracted.end + 1);
const jsonData = extracted
? JSON.stringify(extracted.result, null, 2)
: undefined;
if (extracted?.result?.id?._Request?.includes("hooks-builder-req")) {
return;
}
const { type = "log" } = opts;
const log: ILog = {
type,
message,
timestring,
jsonData,
defaultCollapsed: true,
};
if (log) streamState.logs.push(log);
return log;
};

View File

@@ -1,7 +1,6 @@
import React, { useRef, useState } from "react";
import { useSnapshot, ref } from "valtio";
import Editor, { loader } from "@monaco-editor/react";
import type monaco from "monaco-editor";
import React, { useState } from "react";
import { useSnapshot } from "valtio";
import { useTheme } from "next-themes";
import { useRouter } from "next/router";
import NextLink from "next/link";
@@ -10,31 +9,36 @@ import filesize from "filesize";
import Box from "./Box";
import Container from "./Container";
import dark from "../theme/editor/amy.json";
import light from "../theme/editor/xcode_default.json";
import state from "../state";
import wat from "../utils/wat-highlight";
import EditorNavigation from "./EditorNavigation";
import { Button, Text, Link, Flex } from ".";
loader.config({
paths: {
vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.30.1/min/vs",
},
});
import { Button, Text, Link, Flex, Tabs, Tab } from ".";
import Monaco from "./Monaco";
const FILESIZE_BREAKPOINTS: [number, number] = [2 * 1024, 5 * 1024];
const DeployEditor = () => {
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>();
const snap = useSnapshot(state);
const router = useRouter();
const { theme } = useTheme();
const [showContent, setShowContent] = useState(false);
const activeFile = snap.files[snap.active];
const compiledFiles = snap.files.filter(file => file.compiledContent);
const activeFile = compiledFiles[snap.activeWat];
const renderNav = () => (
<Tabs
activeIndex={snap.activeWat}
onChangeActive={idx => (state.activeWat = idx)}
>
{compiledFiles.map((file, index) => {
return <Tab key={file.name} header={`${file.name}.wat`} />;
})}
</Tabs>
);
const compiledSize = activeFile?.compiledContent?.byteLength || 0;
const color =
compiledSize > FILESIZE_BREAKPOINTS[1]
@@ -43,6 +47,10 @@ const DeployEditor = () => {
? "$warning"
: "$success";
const isContentChanged =
activeFile && activeFile.compiledValueSnapshot !== activeFile.content;
// const hasDeployErrors = activeFile && activeFile.containsErrors;
const CompiledStatView = activeFile && (
<Flex
column
@@ -60,15 +68,30 @@ const DeployEditor = () => {
{activeFile?.lastCompiled && (
<ReactTimeAgo date={activeFile.lastCompiled} locale="en-US" />
)}
{activeFile.compiledContent?.byteLength && (
<Text css={{ ml: "$2", color }}>
({filesize(activeFile.compiledContent.byteLength)})
</Text>
)}
</Flex>
{activeFile.compiledContent?.byteLength &&
activeFile.compiledContent?.byteLength >= 64000 && (
<Flex css={{ flexDirection: "column", py: "$3", pb: "$1" }}>
<Text css={{ ml: "$2", color: "$error" }}>
File size is larger than 64kB, cannot set hook!
</Text>
</Flex>
)}
<Button variant="link" onClick={() => setShowContent(true)}>
View as WAT-file
</Button>
{isContentChanged && (
<Text warning>
File contents were changed after last compile, compile again to
incorporate your latest changes in the build.
</Text>
)}
</Flex>
);
const NoContentView = !snap.loading && router.isReady && (
@@ -87,8 +110,9 @@ const DeployEditor = () => {
</NextLink>
</Text>
);
const isContent =
snap.files?.filter((file) => file.compiledWatContent).length > 0 &&
snap.files?.filter(file => file.compiledWatContent).length > 0 &&
router.isReady;
return (
<Box
@@ -101,7 +125,7 @@ const DeployEditor = () => {
width: "100%",
}}
>
<EditorNavigation showWat />
<EditorNavigation renderNav={renderNav} />
<Container
css={{
display: "flex",
@@ -115,32 +139,38 @@ const DeployEditor = () => {
) : !showContent ? (
CompiledStatView
) : (
<Editor
<Monaco
className="hooks-editor"
defaultLanguage={"wat"}
language={"wat"}
path={`file://tmp/c/${snap.files?.[snap.active]?.name}.wat`}
value={snap.files?.[snap.active]?.compiledWatContent || ""}
beforeMount={(monaco) => {
path={`file://tmp/c/${activeFile?.name}.wat`}
value={activeFile?.compiledWatContent || ""}
beforeMount={monaco => {
monaco.languages.register({ id: "wat" });
monaco.languages.setLanguageConfiguration("wat", wat.config);
monaco.languages.setMonarchTokensProvider("wat", wat.tokens);
if (!state.editorCtx) {
state.editorCtx = ref(monaco.editor);
// @ts-expect-error
monaco.editor.defineTheme("dark", dark);
// @ts-expect-error
monaco.editor.defineTheme("light", light);
}
}}
onMount={(editor, monaco) => {
editorRef.current = editor;
onMount={editor => {
editor.updateOptions({
glyphMargin: true,
readOnly: true,
});
}}
theme={theme === "dark" ? "dark" : "light"}
overlay={
<Flex
css={{
m: "$1",
ml: "auto",
fontSize: "$sm",
color: "$textMuted",
}}
>
<Link onClick={() => setShowContent(false)}>
Exit editor mode
</Link>
</Flex>
}
/>
)}
</Container>

View File

@@ -1,103 +0,0 @@
import React, { useRef, useLayoutEffect } from "react";
import { useSnapshot } from "valtio";
import { Play, Prohibit } from "phosphor-react";
import useStayScrolled from "react-stay-scrolled";
import Container from "./Container";
import Box from "./Box";
import LogText from "./LogText";
import { compileCode } from "../state/actions";
import state from "../state";
import Button from "./Button";
import Heading from "./Heading";
const Footer = () => {
const snap = useSnapshot(state);
const logRef = useRef<HTMLPreElement>(null);
const { stayScrolled /*, scrollBottom*/ } = useStayScrolled(logRef);
useLayoutEffect(() => {
stayScrolled();
}, [snap.logs, stayScrolled]);
return (
<Box
as="footer"
css={{
display: "flex",
borderTop: "1px solid $mauve6",
background: "$mauve1",
position: "relative",
}}
>
<Container css={{ py: "$3", flexShrink: 1 }}>
<Heading
as="h3"
css={{ fontWeight: 300, m: 0, fontSize: "11px", color: "$mauve9" }}
>
DEVELOPMENT LOG
</Heading>
<Button
ghost
size="xs"
css={{
position: "absolute",
right: "$3",
top: "$2",
color: "$mauve10",
}}
onClick={() => {
state.logs = [];
}}
>
<Prohibit size="14px" />
</Button>
<Box
as="pre"
ref={logRef}
css={{
display: "flex",
flexDirection: "column",
width: "100%",
height: "160px",
fontSize: "13px",
fontWeight: "$body",
fontFamily: "$monospace",
overflowY: "auto",
wordWrap: "break-word",
py: 3,
}}
>
{snap.logs?.map((log, index) => (
<Box as="span" key={log.type + index}>
<LogText capitalize variant={log.type}>
{log.type}:{" "}
</LogText>
<LogText>{log.message}</LogText>
</Box>
))}
</Box>
<Button
variant="primary"
uppercase
disabled={!snap.files.length}
isLoading={snap.compiling}
onClick={() => compileCode(snap.active)}
css={{
position: "absolute",
bottom: "$4",
left: "$4",
alignItems: "center",
display: "flex",
cursor: "pointer",
}}
>
<Play weight="bold" size="16px" />
Compile to Wasm
</Button>
</Container>
</Box>
);
};
export default Footer;

View File

@@ -40,6 +40,7 @@ const StyledContent = styled(DialogPrimitive.Content, {
color: "$mauve12",
borderRadius: "$md",
position: "relative",
mb: "15%",
boxShadow:
"0px 10px 38px -5px rgba(22, 23, 24, 0.25), 0px 10px 20px -5px rgba(22, 23, 24, 0.2)",
width: "90vw",

View File

@@ -1,27 +1,7 @@
import { keyframes } from "@stitches/react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { styled } from "../stitches.config";
const slideUpAndFade = keyframes({
"0%": { opacity: 0, transform: "translateY(2px)" },
"100%": { opacity: 1, transform: "translateY(0)" },
});
const slideRightAndFade = keyframes({
"0%": { opacity: 0, transform: "translateX(-2px)" },
"100%": { opacity: 1, transform: "translateX(0)" },
});
const slideDownAndFade = keyframes({
"0%": { opacity: 0, transform: "translateY(-2px)" },
"100%": { opacity: 1, transform: "translateY(0)" },
});
const slideLeftAndFade = keyframes({
"0%": { opacity: 0, transform: "translateX(2px)" },
"100%": { opacity: 1, transform: "translateX(0)" },
});
import { slideDownAndFade, slideLeftAndFade, slideRightAndFade, slideUpAndFade } from '../styles/keyframes';
const StyledContent = styled(DropdownMenuPrimitive.Content, {
minWidth: 220,

View File

@@ -1,6 +1,10 @@
import React, { useState, useEffect, useCallback } from "react";
import React, {
useState,
useEffect,
useRef,
ReactNode,
} from "react";
import {
Plus,
Share,
DownloadSimple,
Gear,
@@ -28,7 +32,6 @@ import { useSnapshot } from "valtio";
import toast from "react-hot-toast";
import {
createNewFile,
syncToGist,
updateEditorSettings,
downloadAsZip,
@@ -47,217 +50,90 @@ import {
} from "./Dialog";
import Flex from "./Flex";
import Stack from "./Stack";
import Input from "./Input";
import Text from "./Text";
import { Input, Label } from "./Input";
import Tooltip from "./Tooltip";
import {
AlertDialog,
AlertDialogContent,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogCancel,
AlertDialogAction,
} from "./AlertDialog";
import { styled } from "../stitches.config";
import { showAlert } from "../state/actions/showAlert";
const ErrorText = styled(Text, {
color: "$error",
mt: "$1",
display: "block",
});
const EditorNavigation = ({ showWat }: { showWat?: boolean }) => {
const EditorNavigation = ({ renderNav }: { renderNav?: () => ReactNode }) => {
const snap = useSnapshot(state);
const [createNewAlertOpen, setCreateNewAlertOpen] = useState(false);
const [editorSettingsOpen, setEditorSettingsOpen] = useState(false);
const [isNewfileDialogOpen, setIsNewfileDialogOpen] = useState(false);
const [newfileError, setNewfileError] = useState<string | null>(null);
const [filename, setFilename] = useState("");
const { data: session, status } = useSession();
const [popup, setPopUp] = useState(false);
const [editorSettings, setEditorSettings] = useState(snap.editorSettings);
useEffect(() => {
if (session && session.user && popup) {
setPopUp(false);
}
}, [session, popup]);
// when filename changes, reset error
useEffect(() => {
setNewfileError(null);
}, [filename, setNewfileError]);
const validateFilename = useCallback(
(filename: string): { error: string | null } => {
// check if filename already exists
if (!filename) {
return { error: "You need to add filename" };
}
if (snap.files.find((file) => file.name === filename)) {
return { error: "Filename already exists." };
}
const showNewGistAlert = () => {
showAlert("Are you sure?", {
body: (
<>
This action will create new <strong>public</strong> Github Gist from
your current saved files. You can delete gist anytime from your GitHub
Gists page.
</>
),
cancelText: "Cancel",
confirmText: "Create new Gist",
confirmPrefix: <FilePlus size="15px" />,
onConfirm: () => syncToGist(session, true),
});
};
if (!filename.includes(".") || filename[filename.length - 1] === ".") {
return { error: "Filename should include file extension" };
}
// check for illegal characters
const ALPHA_NUMERICAL_REGEX = /^[A-Za-z0-9_-]+[.][A-Za-z0-9]{1,4}$/g;
if (!filename.match(ALPHA_NUMERICAL_REGEX)) {
return {
error: `Filename can contain only characters from a-z, A-Z, 0-9, "_" and "-" and it needs to have file extension (e.g. ".c")`,
};
}
return { error: null };
},
[snap.files]
);
const handleConfirm = useCallback(() => {
// add default extension in case omitted
const chk = validateFilename(filename);
if (chk && chk.error) {
setNewfileError(`Error: ${chk.error}`);
return;
}
setIsNewfileDialogOpen(false);
createNewFile(filename);
setFilename("");
}, [filename, setIsNewfileDialogOpen, setFilename, validateFilename]);
const files = snap.files;
const scrollRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
return (
<Flex css={{ flexShrink: 0, gap: "$0" }}>
<Flex
id="kissa"
ref={scrollRef}
css={{
overflowX: "scroll",
overflowY: "hidden",
py: "$3",
pb: "$0",
flex: 1,
"&::-webkit-scrollbar": {
height: 0,
background: "transparent",
height: "0.3em",
background: "rgba(0,0,0,.0)",
},
"&::-webkit-scrollbar-gutter": "stable",
"&::-webkit-scrollbar-thumb": {
backgroundColor: "rgba(0,0,0,.2)",
outline: "0px",
borderRadius: "9999px",
},
scrollbarColor: "rgba(0,0,0,.2) rgba(0,0,0,0)",
scrollbarGutter: "stable",
scrollbarWidth: "thin",
".dark &": {
"&::-webkit-scrollbar": {
background: "rgba(0,0,0,.0)",
},
"&::-webkit-scrollbar-gutter": "stable",
"&::-webkit-scrollbar-thumb": {
backgroundColor: "rgba(255,255,255,.2)",
outline: "0px",
borderRadius: "9999px",
},
scrollbarColor: "rgba(255,255,255,.2) rgba(0,0,0,0)",
scrollbarGutter: "stable",
scrollbarWidth: "thin",
},
}}
onWheelCapture={e => {
if (scrollRef.current) {
scrollRef.current.scrollLeft += e.deltaY;
}
}}
>
<Container css={{ flex: 1 }}>
<Stack
css={{
gap: "$3",
flex: 1,
flexWrap: "nowrap",
marginBottom: "-1px",
}}
>
{files &&
files.length > 0 &&
files.map((file, index) => {
if (!file.compiledContent && showWat) {
return null;
}
return (
<Button
size="sm"
outline={
showWat ? snap.activeWat !== index : snap.active !== index
}
onClick={() => (state.active = index)}
key={file.name + index}
css={{
"&:hover": {
span: {
visibility: "visible",
},
},
}}
>
{file.name}
{showWat && ".wat"}
{!showWat && (
<Box
as="span"
css={{
display: "flex",
p: "2px",
borderRadius: "$full",
mr: "-4px",
"&:hover": {
// boxSizing: "0px 0px 1px",
backgroundColor: "$mauve2",
color: "$mauve12",
},
}}
onClick={(ev: React.MouseEvent<HTMLElement>) => {
ev.stopPropagation();
// Remove file from state
state.files.splice(index, 1);
// Change active file state
// If deleted file is behind active tab
// we keep the current state otherwise
// select previous file on the list
state.active =
index > snap.active ? snap.active : snap.active - 1;
}}
>
<X size="9px" weight="bold" />
</Box>
)}
</Button>
);
})}
{!showWat && (
<Dialog
open={isNewfileDialogOpen}
onOpenChange={setIsNewfileDialogOpen}
>
<DialogTrigger asChild>
<Button
ghost
size="sm"
css={{ alignItems: "center", px: "$2", mr: "$3" }}
>
<Plus size="16px" />{" "}
{snap.files.length === 0 && "Add new file"}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Create new file</DialogTitle>
<DialogDescription>
<label>Filename</label>
<Input
value={filename}
onChange={(e) => setFilename(e.target.value)}
onKeyPress={(e) => {
if (e.key === "Enter") {
handleConfirm();
}
}}
/>
<ErrorText>{newfileError}</ErrorText>
</DialogDescription>
<Flex
css={{
marginTop: 25,
justifyContent: "flex-end",
gap: "$3",
}}
>
<DialogClose asChild>
<Button outline>Cancel</Button>
</DialogClose>
<Button variant="primary" onClick={handleConfirm}>
Create file
</Button>
</Flex>
<DialogClose asChild>
<Box css={{ position: "absolute", top: "$3", right: "$3" }}>
<X size="20px" />
</Box>
</DialogClose>
</DialogContent>
</Dialog>
)}
</Stack>
<Container css={{ flex: 1 }} ref={containerRef}>
{renderNav?.()}
</Container>
</Flex>
<Flex
@@ -416,7 +292,7 @@ const EditorNavigation = ({ showWat }: { showWat?: boolean }) => {
if (snap.gistOwner === session?.user.username) {
syncToGist(session);
} else {
setCreateNewAlertOpen(true);
showNewGistAlert();
}
}}
>
@@ -466,7 +342,7 @@ const EditorNavigation = ({ showWat }: { showWat?: boolean }) => {
<DropdownMenuItem
disabled={status !== "authenticated"}
onClick={() => {
setCreateNewAlertOpen(true);
showNewGistAlert();
}}
>
<FilePlus size="16px" /> Create as a new Gist
@@ -486,34 +362,6 @@ const EditorNavigation = ({ showWat }: { showWat?: boolean }) => {
) : null}
</Container>
</Flex>
<AlertDialog
open={createNewAlertOpen}
onOpenChange={(value) => setCreateNewAlertOpen(value)}
>
<AlertDialogContent>
<AlertDialogTitle>Are you sure?</AlertDialogTitle>
<AlertDialogDescription>
This action will create new <strong>public</strong> Github Gist from
your current saved files. You can delete gist anytime from your
GitHub Gists page.
</AlertDialogDescription>
<Flex css={{ justifyContent: "flex-end", gap: "$3" }}>
<AlertDialogCancel asChild>
<Button outline>Cancel</Button>
</AlertDialogCancel>
<AlertDialogAction asChild>
<Button
variant="primary"
onClick={() => {
syncToGist(session, true);
}}
>
<FilePlus size="15px" /> Create new Gist
</Button>
</AlertDialogAction>
</Flex>
</AlertDialogContent>
</AlertDialog>
<Dialog open={editorSettingsOpen} onOpenChange={setEditorSettingsOpen}>
<DialogTrigger asChild>
@@ -524,13 +372,13 @@ const EditorNavigation = ({ showWat }: { showWat?: boolean }) => {
<DialogContent>
<DialogTitle>Editor settings</DialogTitle>
<DialogDescription>
<label>Tab size</label>
<Label>Tab size</Label>
<Input
type="number"
min="1"
value={editorSettings.tabSize}
onChange={(e) =>
setEditorSettings((curr) => ({
onChange={e =>
setEditorSettings(curr => ({
...curr,
tabSize: Number(e.target.value),
}))

View File

@@ -1,17 +1,13 @@
import React, { useEffect, useRef } from "react";
import { useSnapshot, ref } from "valtio";
import Editor, { loader } from "@monaco-editor/react";
import type monaco from "monaco-editor";
import { ArrowBendLeftUp } from "phosphor-react";
import { useTheme } from "next-themes";
import { useRouter } from "next/router";
import uniqBy from "lodash.uniqby";
import Box from "./Box";
import Container from "./Container";
import dark from "../theme/editor/amy.json";
import light from "../theme/editor/xcode_default.json";
import { saveFile } from "../state/actions";
import { createNewFile, saveFile } from "../state/actions";
import { apiHeaderFiles } from "../state/constants";
import state from "../state";
@@ -23,16 +19,13 @@ import { listen } from "@codingame/monaco-jsonrpc";
import ReconnectingWebSocket from "reconnecting-websocket";
import docs from "../xrpl-hooks-docs/docs";
loader.config({
paths: {
vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.30.1/min/vs",
},
});
import Monaco from "./Monaco";
import { saveAllFiles } from "../state/actions/saveFile";
import { Tab, Tabs } from "./Tabs";
const validateWritability = (editor: monaco.editor.IStandaloneCodeEditor) => {
const currPath = editor.getModel()?.uri.path;
if (apiHeaderFiles.find((h) => currPath?.endsWith(h))) {
if (apiHeaderFiles.find(h => currPath?.endsWith(h))) {
editor.updateOptions({ readOnly: true });
} else {
editor.updateOptions({ readOnly: false });
@@ -45,18 +38,15 @@ const setMarkers = (monacoE: typeof monaco) => {
// Get all the markers that are active at the moment,
// Also if same error is there twice, we can show the content
// only once (that's why we're using uniqBy)
const markers = uniqBy(
monacoE.editor
.getModelMarkers({})
// Filter out the markers that are hooks specific
.filter(
(marker) =>
typeof marker?.code === "string" &&
// Take only markers that starts with "hooks-"
marker?.code?.includes("hooks-")
),
"code"
);
const markers = monacoE.editor
.getModelMarkers({})
// Filter out the markers that are hooks specific
.filter(
marker =>
typeof marker?.code === "string" &&
// Take only markers that starts with "hooks-"
marker?.code?.includes("hooks-")
);
// Get the active model (aka active file you're editing)
// const model = monacoE.editor?.getModel(
@@ -66,16 +56,16 @@ const setMarkers = (monacoE: typeof monaco) => {
// Add decoration (aka extra hoverMessages) to markers in the
// exact same range (location) where the markers are
const models = monacoE.editor.getModels();
models.forEach((model) => {
models.forEach(model => {
decorations[model.id] = model?.deltaDecorations(
decorations?.[model.id] || [],
markers
.filter((marker) =>
.filter(marker =>
marker?.resource.path
.split("/")
.includes(`${state.files?.[state.active]?.name}`)
)
.map((marker) => ({
.map(marker => ({
range: new monacoE.Range(
marker.startLineNumber,
marker.startColumn,
@@ -123,6 +113,33 @@ const HooksEditor = () => {
setMarkers(monacoRef.current);
}
}, [snap.active]);
useEffect(() => {
return () => {
saveAllFiles();
};
}, []);
const file = snap.files[snap.active];
const renderNav = () => (
<Tabs
label="File"
activeIndex={snap.active}
onChangeActive={idx => (state.active = idx)}
extensionRequired
onCreateNewTab={createNewFile}
onCloseTab={idx => state.files.splice(idx, 1)}
headerExtraValidation={{
regex: /^[A-Za-z0-9_-]+[.][A-Za-z0-9]{1,4}$/g,
error:
'Filename can contain only characters from a-z, A-Z, 0-9, "_" and "-"',
}}
>
{snap.files.map((file, index) => {
return <Tab key={file.name} header={file.name} />;
})}
</Tabs>
);
return (
<Box
css={{
@@ -135,18 +152,18 @@ const HooksEditor = () => {
width: "100%",
}}
>
<EditorNavigation />
<EditorNavigation renderNav={renderNav} />
{snap.files.length > 0 && router.isReady ? (
<Editor
className="hooks-editor"
<Monaco
keepCurrentModel
defaultLanguage={snap.files?.[snap.active]?.language}
language={snap.files?.[snap.active]?.language}
path={`file:///work/c/${snap.files?.[snap.active]?.name}`}
defaultValue={snap.files?.[snap.active]?.content}
beforeMount={(monaco) => {
defaultLanguage={file?.language}
language={file?.language}
path={`file:///work/c/${file?.name}`}
defaultValue={file?.content}
// onChange={val => (state.files[snap.active].content = val)} // Auto save?
beforeMount={monaco => {
if (!snap.editorCtx) {
snap.files.forEach((file) =>
snap.files.forEach(file =>
monaco.editor.createModel(
file.content,
file.language,
@@ -171,13 +188,13 @@ const HooksEditor = () => {
// listen when the web socket is opened
listen({
webSocket: webSocket as WebSocket,
onConnection: (connection) => {
onConnection: connection => {
// create and start the language client
const languageClient = createLanguageClient(connection);
const disposable = languageClient.start();
connection.onClose(() => {
try {
// disposable.stop();
disposable.dispose();
} catch (err) {
console.log("err", err);
@@ -187,7 +204,6 @@ const HooksEditor = () => {
});
}
// // hook editor to global state
// editor.updateOptions({
// minimap: {
// enabled: false,
@@ -196,10 +212,6 @@ const HooksEditor = () => {
// });
if (!state.editorCtx) {
state.editorCtx = ref(monaco.editor);
// @ts-expect-error
monaco.editor.defineTheme("dark", dark);
// @ts-expect-error
monaco.editor.defineTheme("light", light);
}
}}
onMount={(editor, monaco) => {
@@ -227,13 +239,13 @@ const HooksEditor = () => {
});
// Hacky way to hide Peek menu
editor.onContextMenu((e) => {
editor.onContextMenu(e => {
const host =
document.querySelector<HTMLElement>(".shadow-root-host");
const contextMenuItems =
host?.shadowRoot?.querySelectorAll("li.action-item");
contextMenuItems?.forEach((k) => {
contextMenuItems?.forEach(k => {
// If menu item contains "Peek" lets hide it
if (k.querySelector(".action-label")?.textContent === "Peek") {
// @ts-expect-error

View File

@@ -1,5 +1,6 @@
import React from "react";
import { styled } from "../stitches.config";
import * as LabelPrim from '@radix-ui/react-label';
export const Input = styled("input", {
// Reset
@@ -158,3 +159,11 @@ const ReffedInput = React.forwardRef<
>((props, ref) => <Input {...props} ref={ref} />);
export default ReffedInput;
const LabelRoot = (props: LabelPrim.LabelProps) => <LabelPrim.Root {...props} />
export const Label = styled(LabelRoot, {
display: 'inline-block',
mb: '$1'
})

View File

@@ -6,7 +6,7 @@ import {
useState,
useCallback,
} from "react";
import { Notepad, Prohibit } from "phosphor-react";
import { IconProps, Notepad, Prohibit } from "phosphor-react";
import useStayScrolled from "react-stay-scrolled";
import NextLink from "next/link";
@@ -24,6 +24,7 @@ interface ILogBox {
logs: ILog[];
renderNav?: () => ReactNode;
enhanced?: boolean;
Icon?: FC<IconProps>;
}
const LogBox: FC<ILogBox> = ({
@@ -33,6 +34,7 @@ const LogBox: FC<ILogBox> = ({
children,
renderNav,
enhanced,
Icon = Notepad,
}) => {
const logRef = useRef<HTMLPreElement>(null);
const { stayScrolled /*, scrollBottom*/ } = useStayScrolled(logRef);
@@ -82,14 +84,14 @@ const LogBox: FC<ILogBox> = ({
gap: "$3",
}}
>
<Notepad size="15px" /> <Text css={{ lineHeight: 1 }}>{title}</Text>
<Icon size="15px" /> <Text css={{ lineHeight: 1 }}>{title}</Text>
</Heading>
<Flex
row
align="center"
css={{
width: "50%", // TODO make it max without breaking layout!
}}
// css={{
// maxWidth: "100%", // TODO make it max without breaking layout!
// }}
>
{renderNav?.()}
</Flex>
@@ -147,7 +149,7 @@ const LogBox: FC<ILogBox> = ({
export const Log: FC<ILog> = ({
type,
timestamp: timestamp,
timestring,
message: _message,
link,
linkText,
@@ -162,11 +164,11 @@ export const Log: FC<ILog> = ({
(str?: string): ReactNode => {
if (!str || !accounts.length) return null;
const pattern = `(${accounts.map((acc) => acc.address).join("|")})`;
const pattern = `(${accounts.map(acc => acc.address).join("|")})`;
const res = regexifyString({
pattern: new RegExp(pattern, "gim"),
decorator: (match, idx) => {
const name = accounts.find((acc) => acc.address === match)?.name;
const name = accounts.find(acc => acc.address === match)?.name;
return (
<Link
key={match + idx}
@@ -186,8 +188,17 @@ export const Log: FC<ILog> = ({
},
[accounts]
);
_message = _message.trim().replace(/\n /gi, "\n");
const message = enrichAccounts(_message);
let message: ReactNode;
if (typeof _message === "string") {
_message = _message.trim().replace(/\n /gi, "\n");
if (_message) message = enrichAccounts(_message);
else message = <Text muted>{'""'}</Text>
} else {
message = _message;
}
const jsonData = enrichAccounts(_jsonData);
return (
@@ -197,9 +208,9 @@ export const Log: FC<ILog> = ({
activeAccountAddress={dialogAccount}
/>
<LogText variant={type}>
{timestamp && (
{timestring && (
<Text muted monospace>
{timestamp}{" "}
{timestring}{" "}
</Text>
)}
<Pre>{message} </Pre>

75
components/Monaco.tsx Normal file
View File

@@ -0,0 +1,75 @@
import Editor, { loader, EditorProps, Monaco } from "@monaco-editor/react";
import { CSS } from "@stitches/react";
import type monaco from "monaco-editor";
import { useTheme } from "next-themes";
import { FC, MutableRefObject, ReactNode } from "react";
import { Flex } from ".";
import dark from "../theme/editor/amy.json";
import light from "../theme/editor/xcode_default.json";
export type MonacoProps = EditorProps & {
id?: string;
rootProps?: { css: CSS } & Record<string, any>;
overlay?: ReactNode;
editorRef?: MutableRefObject<monaco.editor.IStandaloneCodeEditor>;
monacoRef?: MutableRefObject<typeof monaco>;
};
loader.config({
paths: {
vs: "https://cdn.jsdelivr.net/npm/monaco-editor@0.30.1/min/vs",
},
});
const Monaco: FC<MonacoProps> = ({
id,
path = `file:///${id}`,
className = id,
language = "json",
overlay,
editorRef,
monacoRef,
beforeMount,
rootProps,
...rest
}) => {
const { theme } = useTheme();
const setTheme = (monaco: Monaco) => {
monaco.editor.defineTheme("dark", dark as any);
monaco.editor.defineTheme("light", light as any);
};
return (
<Flex
fluid
column
{...rootProps}
css={{
position: "relative",
height: "100%",
...rootProps?.css,
}}
>
<Editor
className={className}
language={language}
path={path}
beforeMount={monaco => {
beforeMount?.(monaco);
setTheme(monaco);
}}
theme={theme === "dark" ? "dark" : "light"}
{...rest}
/>
{overlay && (
<Flex
css={{ position: "absolute", bottom: 0, right: 0, width: "100%" }}
>
{overlay}
</Flex>
)}
</Flex>
);
};
export default Monaco;

View File

@@ -28,6 +28,22 @@ import {
} from "./Dialog";
import PanelBox from "./PanelBox";
import { templateFileIds } from "../state/constants";
import { styled } from "../stitches.config";
const ImageWrapper = styled(Flex, {
position: "relative",
mt: "$2",
mb: "$10",
svg: {
// fill: "red",
".angle": {
fill: "$text",
},
":not(.angle)": {
stroke: "$text",
},
},
});
const Navigation = () => {
const router = useRouter();
@@ -91,7 +107,7 @@ const Navigation = () => {
<Text
css={{ fontSize: "$xs", color: "$mauve10", lineHeight: 1 }}
>
{snap.files.length > 0 ? "Gist: " : "Playground"}
{snap.files.length > 0 ? "Gist: " : "Builder"}
{snap.files.length > 0 && (
<Link
href={`https://gist.github.com/${snap.gistOwner || ""}/${
@@ -128,19 +144,20 @@ const Navigation = () => {
</DialogTrigger>
<DialogContent
css={{
display: "flex",
maxWidth: "1080px",
width: "80vw",
height: "80%",
maxHeight: "80%",
backgroundColor: "$mauve1 !important",
overflowY: "auto",
background: "black",
p: 0,
}}
>
<Flex
css={{
flexDirection: "column",
flex: 1,
height: "auto",
height: "100%",
"@md": {
flexDirection: "row",
height: "100%",
@@ -151,15 +168,15 @@ const Navigation = () => {
css={{
borderBottom: "1px solid $colors$mauve5",
width: "100%",
minWidth: "240px",
flexDirection: "column",
p: "$7",
height: "100%",
backgroundColor: "$mauve2",
"@md": {
width: "30%",
maxWidth: "300px",
borderBottom: "0px",
borderRight: "1px solid $colors$mauve6",
borderRight: "1px solid $colors$mauve5",
},
}}
>
@@ -196,9 +213,9 @@ const Navigation = () => {
display: "inline-flex",
alignItems: "center",
gap: "$3",
color: "$purple10",
color: "$purple11",
"&:hover": {
color: "$purple11",
color: "$purple12",
},
"&:focus": {
outline: 0,
@@ -217,9 +234,9 @@ const Navigation = () => {
display: "inline-flex",
alignItems: "center",
gap: "$3",
color: "$purple10",
color: "$purple11",
"&:hover": {
color: "$purple11",
color: "$purple12",
},
"&:focus": {
outline: 0,
@@ -237,9 +254,9 @@ const Navigation = () => {
display: "inline-flex",
alignItems: "center",
gap: "$3",
color: "$purple10",
color: "$purple11",
"&:hover": {
color: "$purple11",
color: "$purple12",
},
"&:focus": {
outline: 0,
@@ -255,67 +272,42 @@ const Navigation = () => {
</Flex>
</DialogDescription>
</Flex>
<div>
<Flex
css={{
display: "grid",
gridTemplateColumns: "1fr",
<Flex
css={{
display: "grid",
gridTemplateColumns: "1fr",
gridTemplateRows: "max-content",
flex: 1,
p: "$7",
pb: "$16",
gap: "$3",
alignItems: "normal",
flexWrap: "wrap",
backgroundColor: "$mauve1",
"@md": {
gridTemplateColumns: "1fr 1fr",
gridTemplateRows: "max-content",
flex: 1,
p: "$7",
gap: "$3",
alignItems: "normal",
flexWrap: "wrap",
backgroundColor: "$mauve1",
"@md": {
gridTemplateColumns: "1fr 1fr 1fr",
gridTemplateRows: "max-content",
},
}}
>
},
"@lg": {
gridTemplateColumns: "1fr 1fr 1fr",
gridTemplateRows: "max-content",
},
}}
>
{Object.values(templateFileIds).map((template) => (
<PanelBox
key={template.id}
as="a"
href={`/develop/${templateFileIds.starter}`}
href={`/develop/${template.id}`}
>
<Heading>Starter</Heading>
<Text>
Just a basic starter with essential imports
</Text>
<ImageWrapper>{template.icon()}</ImageWrapper>
<Heading>{template.name}</Heading>
<Text>{template.description}</Text>
</PanelBox>
<PanelBox
as="a"
href={`/develop/${templateFileIds.firewall}`}
>
<Heading>Firewall</Heading>
<Text>
This Hook essentially checks a blacklist of accounts
</Text>
</PanelBox>
<PanelBox
as="a"
href={`/develop/${templateFileIds.notary}`}
>
<Heading>Notary</Heading>
<Text>
Collecting signatures for multi-sign transactions
</Text>
</PanelBox>
<PanelBox
as="a"
href={`/develop/${templateFileIds.carbon}`}
>
<Heading>Carbon</Heading>
<Text>Send a percentage of sum to an address</Text>
</PanelBox>
<PanelBox
as="a"
href={`/develop/${templateFileIds.peggy}`}
>
<Heading>Peggy</Heading>
<Text>An oracle based stable coin hook</Text>
</PanelBox>
</Flex>
</div>
))}
</Flex>
</Flex>
<DialogClose asChild>
<Box
@@ -348,6 +340,8 @@ const Navigation = () => {
height: 0,
background: "transparent",
},
scrollbarColor: "transparent",
scrollbarWidth: "none",
}}
>
<Stack

109
components/Popover.tsx Normal file
View File

@@ -0,0 +1,109 @@
import React, { ReactNode } from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { styled, keyframes } from "../stitches.config";
const slideUpAndFade = keyframes({
"0%": { opacity: 0, transform: "translateY(2px)" },
"100%": { opacity: 1, transform: "translateY(0)" },
});
const slideRightAndFade = keyframes({
"0%": { opacity: 0, transform: "translateX(-2px)" },
"100%": { opacity: 1, transform: "translateX(0)" },
});
const slideDownAndFade = keyframes({
"0%": { opacity: 0, transform: "translateY(-2px)" },
"100%": { opacity: 1, transform: "translateY(0)" },
});
const slideLeftAndFade = keyframes({
"0%": { opacity: 0, transform: "translateX(2px)" },
"100%": { opacity: 1, transform: "translateX(0)" },
});
const StyledContent = styled(PopoverPrimitive.Content, {
borderRadius: 4,
padding: "$3 $3",
fontSize: 12,
lineHeight: 1,
color: "$text",
backgroundColor: "$background",
boxShadow:
"0px 10px 38px -10px rgba(22, 23, 24, 0.35), 0px 10px 20px -15px rgba(22, 23, 24, 0.2)",
"@media (prefers-reduced-motion: no-preference)": {
animationDuration: "400ms",
animationTimingFunction: "cubic-bezier(0.16, 1, 0.3, 1)",
willChange: "transform, opacity",
'&[data-state="open"]': {
'&[data-side="top"]': { animationName: slideDownAndFade },
'&[data-side="right"]': { animationName: slideLeftAndFade },
'&[data-side="bottom"]': { animationName: slideUpAndFade },
'&[data-side="left"]': { animationName: slideRightAndFade },
},
},
".dark &": {
backgroundColor: "$mauve5",
boxShadow:
"0px 5px 38px -2px rgba(22, 23, 24, 1), 0px 10px 20px 0px rgba(22, 23, 24, 1)",
},
});
const StyledArrow = styled(PopoverPrimitive.Arrow, {
fill: "$colors$mauve2",
".dark &": {
fill: "$mauve5",
},
});
const StyledClose = styled(PopoverPrimitive.Close, {
all: "unset",
fontFamily: "inherit",
borderRadius: "100%",
height: 25,
width: 25,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
color: "$text",
position: "absolute",
top: 5,
right: 5,
});
// Exports
export const PopoverRoot = PopoverPrimitive.Root;
export const PopoverTrigger = PopoverPrimitive.Trigger;
export const PopoverContent = StyledContent;
export const PopoverArrow = StyledArrow;
export const PopoverClose = StyledClose;
interface IPopover {
content: string | ReactNode;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
const Popover: React.FC<
IPopover & React.ComponentProps<typeof PopoverContent>
> = ({
children,
content,
open,
defaultOpen = false,
onOpenChange,
...rest
}) => (
<PopoverRoot
open={open}
defaultOpen={defaultOpen}
onOpenChange={onOpenChange}
>
<PopoverTrigger asChild>{children}</PopoverTrigger>
<PopoverContent sideOffset={5} {...rest}>
{content} <PopoverArrow offset={5} className="arrow" />
</PopoverContent>
</PopoverRoot>
);
export default Popover;

View File

@@ -0,0 +1,338 @@
import { Play, X } from "phosphor-react";
import {
HTMLInputTypeAttribute,
useCallback,
useEffect,
useState,
} from "react";
import state, { IAccount, IFile, ILog } from "../../state";
import Button from "../Button";
import Box from "../Box";
import Input, { Label } from "../Input";
import Stack from "../Stack";
import {
Dialog,
DialogTrigger,
DialogContent,
DialogTitle,
DialogDescription,
DialogClose,
} from "../Dialog";
import Flex from "../Flex";
import { useSnapshot } from "valtio";
import Select from "../Select";
import Text from "../Text";
import { saveFile } from "../../state/actions/saveFile";
import { getErrors, getTags } from "../../utils/comment-parser";
import toast from "react-hot-toast";
const generateHtmlTemplate = (code: string, data?: Record<string, any>) => {
let processString: string | undefined;
const process = { env: { NODE_ENV: "production" } } as any;
if (data) {
Object.keys(data).forEach(key => {
process.env[key] = data[key];
});
}
processString = JSON.stringify(process);
return `
<html>
<head>
<script>
var log = console.log;
var errorLog = console.error;
var infoLog = console.info;
var warnLog = console.warn
console.log = function(){
var args = Array.from(arguments);
parent.window.postMessage({ type: 'log', args: args || [] }, '*');
log.apply(console, args);
}
console.error = function(){
var args = Array.from(arguments);
parent.window.postMessage({ type: 'error', args: args || [] }, '*');
errorLog.apply(console, args);
}
console.info = function(){
var args = Array.from(arguments);
parent.window.postMessage({ type: 'info', args: args || [] }, '*');
infoLog.apply(console, args);
}
console.warn = function(){
var args = Array.from(arguments);
parent.window.postMessage({ type: 'warning', args: args || [] }, '*');
warnLog.apply(console, args);
}
var process = '${processString || "{}"}';
process = JSON.parse(process);
window.process = process
function windowErrorHandler(event) {
event.preventDefault() // to prevent automatically logging to console
console.error(event.error?.toString())
}
window.addEventListener('error', windowErrorHandler);
</script>
<script type="module">
${code}
</script>
</head>
<body>
</body>
</html>
`;
};
type Fields = Record<
string,
{
name: string;
value: string;
type?: "Account" | `Account.${keyof IAccount}` | HTMLInputTypeAttribute;
description?: string;
required?: boolean;
}
>;
const RunScript: React.FC<{ file: IFile }> = ({ file: { content, name } }) => {
const snap = useSnapshot(state);
const [templateError, setTemplateError] = useState("");
const [fields, setFields] = useState<Fields>({});
const [iFrameCode, setIframeCode] = useState("");
const [isDialogOpen, setIsDialogOpen] = useState(false);
const getFields = useCallback(() => {
const inputTags = ["input", "param", "arg", "argument"];
const tags = getTags(content)
.filter(tag => inputTags.includes(tag.tag))
.filter(tag => !!tag.name);
let _fields = tags.map(tag => ({
name: tag.name,
value: tag.default || "",
type: tag.type,
description: tag.description,
required: !tag.optional,
}));
const fields: Fields = _fields.reduce((acc, field) => {
acc[field.name] = field;
return acc;
}, {} as Fields);
const error = getErrors(content);
if (error) setTemplateError(error.message);
else setTemplateError("");
return fields;
}, [content]);
const runScript = useCallback(() => {
try {
let data: any = {};
Object.keys(fields).forEach(key => {
data[key] = fields[key].value;
});
const template = generateHtmlTemplate(content, data);
setIframeCode(template);
state.scriptLogs = [
...snap.scriptLogs,
{ type: "success", message: "Started running..." },
];
} catch (err) {
state.scriptLogs = [
...snap.scriptLogs,
// @ts-expect-error
{ type: "error", message: err?.message || "Could not parse template" },
];
}
}, [content, fields, snap.scriptLogs]);
useEffect(() => {
const handleEvent = (e: any) => {
if (e.data.type === "log" || e.data.type === "error") {
const data: ILog[] = e.data.args.map((msg: any) => ({
type: e.data.type,
message: typeof msg === "string" ? msg : JSON.stringify(msg, null, 2),
}));
state.scriptLogs = [...snap.scriptLogs, ...data];
}
};
window.addEventListener("message", handleEvent);
return () => window.removeEventListener("message", handleEvent);
}, [snap.scriptLogs]);
useEffect(() => {
const defaultFields = getFields() || {};
setFields(defaultFields);
}, [content, setFields, getFields]);
const accOptions = snap.accounts?.map(acc => ({
...acc,
label: acc.name,
value: acc.address,
}));
const isDisabled = Object.values(fields).some(
field => field.required && !field.value
);
const handleRun = useCallback(() => {
if (isDisabled)
return toast.error("Please fill in all the required fields.");
state.scriptLogs = [];
runScript();
setIsDialogOpen(false);
}, [isDisabled, runScript]);
return (
<>
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogTrigger asChild>
<Button
variant="primary"
onClick={() => {
saveFile(false);
setIframeCode("");
}}
>
<Play weight="bold" size="16px" /> {name}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Run {name} script</DialogTitle>
<DialogDescription>
<Box>
You are about to run scripts provided by the developer of the
hook, make sure you trust the author before you continue.
</Box>
{templateError && (
<Box
as="span"
css={{
display: "block",
color: "$error",
mt: "$3",
whiteSpace: "pre",
}}
>
{templateError}
</Box>
)}
{Object.keys(fields).length > 0 && (
<Box css={{ mt: "$4", mb: 0 }}>
Fill in the following parameters to run the script.
</Box>
)}
</DialogDescription>
<Stack css={{ width: "100%" }}>
{Object.keys(fields).map(key => {
const { name, value, type, description, required } = fields[key];
const isAccount = type?.startsWith("Account");
const isAccountSecret = type === "Account.secret";
const accountField =
(isAccount && type?.split(".")[1]) || "address";
return (
<Box key={name} css={{ width: "100%" }}>
<Label
css={{ display: "flex", justifyContent: "space-between" }}
>
<span>
{description || name} {required && <Text error>*</Text>}
</span>
{isAccountSecret && (
<Text error small css={{ alignSelf: "end" }}>
can access account secret key
</Text>
)}
</Label>
{isAccount ? (
<Select
css={{ mt: "$1" }}
options={accOptions}
onChange={(val: any) => {
setFields({
...fields,
[key]: {
...fields[key],
value: val[accountField],
},
});
}}
value={accOptions.find(
(acc: any) => acc[accountField] === value
)}
/>
) : (
<Input
type={type || "text"}
value={value}
css={{ mt: "$1" }}
onChange={e => {
setFields({
...fields,
[key]: { ...fields[key], value: e.target.value },
});
}}
/>
)}
</Box>
);
})}
<Flex
css={{ justifyContent: "flex-end", width: "100%", gap: "$3" }}
>
<DialogClose asChild>
<Button outline>Cancel</Button>
</DialogClose>
<Button
variant="primary"
isDisabled={isDisabled}
onClick={handleRun}
>
Run script
</Button>
</Flex>
</Stack>
<DialogClose asChild>
<Box
css={{
position: "absolute",
top: "$1",
right: "$1",
cursor: "pointer",
background: "$mauve1",
display: "flex",
borderRadius: "$full",
p: "$1",
}}
>
<X size="20px" />
</Box>
</DialogClose>
</DialogContent>
</Dialog>
{iFrameCode && (
<iframe
style={{ display: "none" }}
srcDoc={iFrameCode}
sandbox="allow-scripts"
/>
)}
</>
);
};
export default RunScript;

View File

@@ -52,6 +52,7 @@ const Select = forwardRef<any, Props>((props, ref) => {
control: (provided, state) => {
return {
...provided,
minHeight: 0,
border: "0px",
backgroundColor: colors.mauve4,
boxShadow: `0 0 0 1px ${
@@ -118,32 +119,6 @@ const Select = forwardRef<any, Props>((props, ref) => {
};
},
}}
// theme={(theme) => ({
// ...theme,
// spacing: {
// ...theme.spacing,
// controlHeight: 30,
// },
// colors: {
// primary: colors.selected,
// primary25: colors.active,
// primary50: colors.primary,
// primary75: colors.primary,
// danger: colors.primary,
// dangerLight: colors.primary,
// neutral0: colors.background,
// neutral5: colors.primary,
// neutral10: colors.primary,
// neutral20: colors.outline,
// neutral30: colors.primary,
// neutral40: colors.primary,
// neutral50: colors.placeholder,
// neutral60: colors.primary,
// neutral70: colors.primary,
// neutral80: colors.searchText,
// neutral90: colors.primary,
// },
// })}
{...props}
/>
);

View File

@@ -11,7 +11,7 @@ import {
DialogClose,
DialogTrigger,
} from "./Dialog";
import { Input } from "./Input";
import { Input, Label } from "./Input";
import {
Controller,
SubmitHandler,
@@ -21,13 +21,13 @@ import {
import { TTS, tts } from "../utils/hookOnCalculator";
import { deployHook } from "../state/actions";
import type { IAccount } from "../state";
import { useSnapshot } from "valtio";
import state from "../state";
import state, { IFile, SelectOption } from "../state";
import toast from "react-hot-toast";
import { sha256 } from "../state/actions/deployHook";
import { prepareDeployHookTx, sha256 } from "../state/actions/deployHook";
import estimateFee from "../utils/estimateFee";
const transactionOptions = Object.keys(tts).map((key) => ({
const transactionOptions = Object.keys(tts).map(key => ({
label: key,
value: key as keyof TTS,
}));
@@ -37,6 +37,7 @@ export type SetHookData = {
value: keyof TTS;
label: string;
}[];
Fee: string;
HookNamespace: string;
HookParameters: {
HookParameter: {
@@ -52,167 +53,317 @@ export type SetHookData = {
// }[];
};
export const SetHookDialog: React.FC<{ account: IAccount }> = ({ account }) => {
const snap = useSnapshot(state);
const [isSetHookDialogOpen, setIsSetHookDialogOpen] = useState(false);
const {
register,
handleSubmit,
control,
watch,
formState: { errors },
} = useForm<SetHookData>({
defaultValues: {
HookNamespace: snap.files?.[snap.active]?.name?.split(".")?.[0] || "",
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "HookParameters", // unique name for your Field Array
});
// const {
// fields: grantFields,
// append: grantAppend,
// remove: grantRemove,
// } = useFieldArray({
// control,
// name: "HookGrants", // unique name for your Field Array
// });
const [hashedNamespace, setHashedNamespace] = useState("");
const namespace = watch(
"HookNamespace",
snap.files?.[snap.active]?.name?.split(".")?.[0] || ""
);
const calculateHashedValue = useCallback(async () => {
const hashedVal = await sha256(namespace);
setHashedNamespace(hashedVal.toUpperCase());
}, [namespace]);
useEffect(() => {
calculateHashedValue();
}, [namespace, calculateHashedValue]);
export const SetHookDialog: React.FC<{ accountAddress: string }> = React.memo(
({ accountAddress }) => {
const snap = useSnapshot(state);
const compiledFiles = snap.files.filter(file => file.compiledContent);
const activeFile = compiledFiles[snap.activeWat] as IFile | undefined;
if (!account) {
return null;
}
const [isSetHookDialogOpen, setIsSetHookDialogOpen] = useState(false);
const onSubmit: SubmitHandler<SetHookData> = async (data) => {
const currAccount = state.accounts.find(
(acc) => acc.address === account.address
const accountOptions: SelectOption[] = snap.accounts.map(acc => ({
label: acc.name,
value: acc.address,
}));
const [selectedAccount, setSelectedAccount] = useState(
accountOptions.find(acc => acc.value === accountAddress)
);
const account = snap.accounts.find(
acc => acc.address === selectedAccount?.value
);
if (currAccount) currAccount.isLoading = true;
const res = await deployHook(account, data);
if (currAccount) currAccount.isLoading = false;
if (res && res.engine_result === "tesSUCCESS") {
toast.success("Transaction succeeded!");
return setIsSetHookDialogOpen(false);
}
toast.error(`Transaction failed! (${res?.engine_result_message})`);
};
const getHookNamespace = useCallback(
() =>
activeFile && snap.deployValues[activeFile.name]
? snap.deployValues[activeFile.name].HookNamespace
: activeFile?.name.split(".")[0] || "",
[activeFile, snap.deployValues]
);
return (
<Dialog open={isSetHookDialogOpen} onOpenChange={setIsSetHookDialogOpen}>
<DialogTrigger asChild>
<Button
ghost
size="xs"
uppercase
variant={"secondary"}
disabled={
account.isLoading ||
!snap.files.filter((file) => file.compiledWatContent).length
const {
register,
handleSubmit,
control,
watch,
setValue,
getValues,
formState: { errors },
} = useForm<SetHookData>({
defaultValues: (activeFile && snap.deployValues[activeFile.name]) || {
HookNamespace: activeFile?.name.split(".")[0] || "",
Invoke: transactionOptions.filter(to => to.label === "ttPAYMENT"),
},
});
const { fields, append, remove } = useFieldArray({
control,
name: "HookParameters", // unique name for your Field Array
});
const [formInitialized, setFormInitialized] = useState(false);
const [estimateLoading, setEstimateLoading] = useState(false);
const watchedFee = watch("Fee");
// Update value if activeFile changes
useEffect(() => {
if (!activeFile) return;
const defaultValue = getHookNamespace();
setValue("HookNamespace", defaultValue);
setFormInitialized(true);
}, [setValue, activeFile, snap.deployValues, getHookNamespace]);
useEffect(() => {
if (
watchedFee &&
(watchedFee.includes(".") || watchedFee.includes(","))
) {
setValue("Fee", watchedFee.replaceAll(".", "").replaceAll(",", ""));
}
}, [watchedFee, setValue]);
// const {
// fields: grantFields,
// append: grantAppend,
// remove: grantRemove,
// } = useFieldArray({
// control,
// name: "HookGrants", // unique name for your Field Array
// });
const [hashedNamespace, setHashedNamespace] = useState("");
const namespace = watch("HookNamespace", getHookNamespace());
const calculateHashedValue = useCallback(async () => {
const hashedVal = await sha256(namespace);
setHashedNamespace(hashedVal.toUpperCase());
}, [namespace]);
useEffect(() => {
calculateHashedValue();
}, [namespace, calculateHashedValue]);
// Calculate initial fee estimate when modal opens
useEffect(() => {
if (formInitialized && account) {
(async () => {
const formValues = getValues();
const tx = await prepareDeployHookTx(account, formValues);
if (!tx) {
return;
}
>
Set Hook
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogTitle>Deploy configuration</DialogTitle>
<DialogDescription as="div">
<Stack css={{ width: "100%", flex: 1 }}>
<Box css={{ width: "100%" }}>
<label>Invoke on transactions</label>
<Controller
name="Invoke"
control={control}
defaultValue={transactionOptions.filter(
(to) => to.label === "ttPAYMENT"
)}
render={({ field }) => (
<Select
{...field}
closeMenuOnSelect={false}
isMulti
menuPosition="fixed"
options={transactionOptions}
/>
)}
/>
</Box>
<Box css={{ width: "100%" }}>
<label>Hook Namespace Seed</label>
<Input
{...register("HookNamespace", { required: true })}
autoComplete={"off"}
defaultValue={
snap.files?.[snap.active]?.name?.split(".")?.[0] || ""
}
/>
{errors.HookNamespace?.type === "required" && (
<Box css={{ display: "inline", color: "$red11" }}>
Namespace is required
</Box>
)}
<Box css={{ mt: "$3" }}>
<label>Hook Namespace (sha256)</label>
<Input readOnly value={hashedNamespace} />
const res = await estimateFee(tx, account);
if (res && res.base_fee) {
setValue("Fee", Math.round(Number(res.base_fee || "")).toString());
}
})();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formInitialized]);
const tooLargeFile = () => {
return Boolean(
activeFile?.compiledContent?.byteLength &&
activeFile?.compiledContent?.byteLength >= 64000
);
};
const onSubmit: SubmitHandler<SetHookData> = async data => {
const currAccount = state.accounts.find(
acc => acc.address === account?.address
);
if (!account) return;
if (currAccount) currAccount.isLoading = true;
const res = await deployHook(account, data);
if (currAccount) currAccount.isLoading = false;
if (res && res.engine_result === "tesSUCCESS") {
toast.success("Transaction succeeded!");
return setIsSetHookDialogOpen(false);
}
toast.error(`Transaction failed! (${res?.engine_result_message})`);
};
return (
<Dialog open={isSetHookDialogOpen} onOpenChange={setIsSetHookDialogOpen}>
<DialogTrigger asChild>
<Button
ghost
size="xs"
uppercase
variant={"secondary"}
disabled={
!account || account.isLoading || !activeFile || tooLargeFile()
}
>
Set Hook
</Button>
</DialogTrigger>
<DialogContent>
<form onSubmit={handleSubmit(onSubmit)}>
<DialogTitle>Deploy configuration</DialogTitle>
<DialogDescription as="div">
<Stack css={{ width: "100%", flex: 1 }}>
<Box css={{ width: "100%" }}>
<Label>Account</Label>
<Select
instanceId="deploy-account"
placeholder="Select account"
hideSelectedOptions
options={accountOptions}
value={selectedAccount}
onChange={(acc: any) => setSelectedAccount(acc)}
/>
</Box>
</Box>
<Box css={{ width: "100%" }}>
<label style={{ marginBottom: "10px", display: "block" }}>
Hook parameters
</label>
<Stack>
{fields.map((field, index) => (
<Stack key={field.id}>
<Input
// important to include key with field's id
placeholder="Parameter name"
{...register(
`HookParameters.${index}.HookParameter.HookParameterName`
)}
<Box css={{ width: "100%" }}>
<Label>Invoke on transactions</Label>
<Controller
name="Invoke"
control={control}
render={({ field }) => (
<Select
{...field}
closeMenuOnSelect={false}
isMulti
menuPosition="fixed"
options={transactionOptions}
/>
<Input
placeholder="Parameter value"
{...register(
`HookParameters.${index}.HookParameter.HookParameterValue`
)}
/>
<Button onClick={() => remove(index)} variant="destroy">
<Trash weight="regular" size="16px" />
</Button>
</Stack>
))}
<Button
outline
fullWidth
type="button"
onClick={() =>
append({
HookParameter: {
HookParameterName: "",
HookParameterValue: "",
)}
/>
</Box>
<Box css={{ width: "100%" }}>
<Label>Hook Namespace Seed</Label>
<Input
{...register("HookNamespace", { required: true })}
autoComplete={"off"}
/>
{errors.HookNamespace?.type === "required" && (
<Box css={{ display: "inline", color: "$red11" }}>
Namespace is required
</Box>
)}
<Box css={{ mt: "$3" }}>
<Label>Hook Namespace (sha256)</Label>
<Input readOnly value={hashedNamespace} />
</Box>
</Box>
<Box css={{ width: "100%" }}>
<Label style={{ marginBottom: "10px", display: "block" }}>
Hook parameters
</Label>
<Stack>
{fields.map((field, index) => (
<Stack key={field.id}>
<Input
// important to include key with field's id
placeholder="Parameter name"
{...register(
`HookParameters.${index}.HookParameter.HookParameterName`
)}
/>
<Input
placeholder="Value (hex-quoted)"
{...register(
`HookParameters.${index}.HookParameter.HookParameterValue`
)}
/>
<Button onClick={() => remove(index)} variant="destroy">
<Trash weight="regular" size="16px" />
</Button>
</Stack>
))}
<Button
outline
fullWidth
type="button"
onClick={() =>
append({
HookParameter: {
HookParameterName: "",
HookParameterValue: "",
},
})
}
>
<Plus size="16px" />
Add Hook Parameter
</Button>
</Stack>
</Box>
<Box css={{ width: "100%", position: "relative" }}>
<Label>Fee</Label>
<Box css={{ display: "flex", alignItems: "center" }}>
<Input
type="number"
{...register("Fee", { required: true })}
autoComplete={"off"}
onKeyPress={e => {
if (e.key === "." || e.key === ",") {
e.preventDefault();
}
}}
step="1"
defaultValue={10000}
css={{
"-moz-appearance": "textfield",
"&::-webkit-outer-spin-button": {
"-webkit-appearance": "none",
margin: 0,
},
})
}
>
<Plus size="16px" />
Add Hook Parameter
</Button>
</Stack>
</Box>
{/* <Box css={{ width: "100%" }}>
"&::-webkit-inner-spin-button ": {
"-webkit-appearance": "none",
margin: 0,
},
}}
/>
<Button
size="xs"
variant="primary"
outline
isLoading={estimateLoading}
css={{
position: "absolute",
right: "$2",
fontSize: "$xs",
cursor: "pointer",
alignContent: "center",
display: "flex",
}}
onClick={async e => {
e.preventDefault();
if (!account) return;
setEstimateLoading(true);
const formValues = getValues();
try {
const tx = await prepareDeployHookTx(
account,
formValues
);
if (tx) {
const res = await estimateFee(tx, account);
if (res && res.base_fee) {
setValue(
"Fee",
Math.round(
Number(res.base_fee || "")
).toString()
);
}
}
} catch (err) {}
setEstimateLoading(false);
}}
>
Suggest
</Button>
</Box>
{errors.Fee?.type === "required" && (
<Box css={{ display: "inline", color: "$red11" }}>
Fee is required
</Box>
)}
</Box>
{/* <Box css={{ width: "100%" }}>
<label style={{ marginBottom: "10px", display: "block" }}>
Hook Grants
</label>
@@ -260,38 +411,41 @@ export const SetHookDialog: React.FC<{ account: IAccount }> = ({ account }) => {
</Button>
</Stack>
</Box> */}
</Stack>
</DialogDescription>
</Stack>
</DialogDescription>
<Flex
css={{
marginTop: 25,
justifyContent: "flex-end",
gap: "$3",
}}
>
<DialogClose asChild>
<Button outline>Cancel</Button>
</DialogClose>
{/* <DialogClose asChild> */}
<Button
variant="primary"
type="submit"
isLoading={account.isLoading}
<Flex
css={{
marginTop: 25,
justifyContent: "flex-end",
gap: "$3",
}}
>
Set Hook
</Button>
{/* </DialogClose> */}
</Flex>
<DialogClose asChild>
<Box css={{ position: "absolute", top: "$3", right: "$3" }}>
<X size="20px" />
</Box>
</DialogClose>
</form>
</DialogContent>
</Dialog>
);
};
<DialogClose asChild>
<Button outline>Cancel</Button>
</DialogClose>
{/* <DialogClose asChild> */}
<Button
variant="primary"
type="submit"
isLoading={account?.isLoading}
>
Set Hook
</Button>
{/* </DialogClose> */}
</Flex>
<DialogClose asChild>
<Box css={{ position: "absolute", top: "$3", right: "$3" }}>
<X size="20px" />
</Box>
</DialogClose>
</form>
</DialogContent>
</Dialog>
);
}
);
SetHookDialog.displayName = "SetHookDialog";
export default SetHookDialog;

32
components/Switch.tsx Normal file
View File

@@ -0,0 +1,32 @@
import { styled } from "../stitches.config";
import * as SwitchPrimitive from "@radix-ui/react-switch";
const StyledSwitch = styled(SwitchPrimitive.Root, {
all: "unset",
width: 42,
height: 25,
backgroundColor: "$mauve9",
borderRadius: "9999px",
position: "relative",
boxShadow: `0 2px 10px $colors$mauve2`,
WebkitTapHighlightColor: "rgba(0, 0, 0, 0)",
"&:focus": { boxShadow: `0 0 0 2px $colors$mauveA2` },
'&[data-state="checked"]': { backgroundColor: "$green11" },
});
const StyledThumb = styled(SwitchPrimitive.Thumb, {
display: "block",
width: 21,
height: 21,
backgroundColor: "white",
borderRadius: "9999px",
boxShadow: `0 2px 2px $colors$mauveA6`,
transition: "transform 100ms",
transform: "translateX(2px)",
willChange: "transform",
'&[data-state="checked"]': { transform: "translateX(19px)" },
});
// Exports
export const Switch = StyledSwitch;
export const SwitchThumb = StyledThumb;

View File

@@ -6,7 +6,7 @@ import React, {
useCallback,
} from "react";
import type { ReactNode, ReactElement } from "react";
import { Box, Button, Flex, Input, Stack, Text } from ".";
import { Box, Button, Flex, Input, Label, Stack, Text } from ".";
import {
Dialog,
DialogTrigger,
@@ -17,6 +17,8 @@ import {
} from "./Dialog";
import { Plus, X } from "phosphor-react";
import { styled } from "../stitches.config";
import { capitalize } from "../utils/helpers";
import ContextMenu from "./ContextMenu";
const ErrorText = styled(Text, {
color: "$error",
@@ -25,26 +27,34 @@ const ErrorText = styled(Text, {
});
interface TabProps {
header?: string;
children: ReactNode;
header: string;
children?: ReactNode;
}
// TODO customise strings shown
// TODO customize messages shown
interface Props {
label?: string;
activeIndex?: number;
activeHeader?: string;
headless?: boolean;
children: ReactElement<TabProps>[];
keepAllAlive?: boolean;
defaultExtension?: string;
forceDefaultExtension?: boolean;
extensionRequired?: boolean;
allowedExtensions?: string[];
headerExtraValidation?: {
regex: string | RegExp;
error: string;
};
onCreateNewTab?: (name: string) => any;
onCloseTab?: (index: number, header?: string) => any;
onChangeActive?: (index: number, header?: string) => any;
}
export const Tab = (props: TabProps) => null;
export const Tabs = ({
label = "Tab",
children,
activeIndex,
activeHeader,
@@ -52,11 +62,14 @@ export const Tabs = ({
keepAllAlive = false,
onCreateNewTab,
onCloseTab,
onChangeActive,
headerExtraValidation,
extensionRequired,
defaultExtension = "",
forceDefaultExtension,
allowedExtensions,
}: Props) => {
const [active, setActive] = useState(activeIndex || 0);
const tabs: TabProps[] = children.map((elem) => elem.props);
const tabs: TabProps[] = children.map(elem => elem.props);
const [isNewtabDialogOpen, setIsNewtabDialogOpen] = useState(false);
const [tabname, setTabname] = useState("");
@@ -68,8 +81,9 @@ export const Tabs = ({
useEffect(() => {
if (activeHeader) {
const idx = tabs.findIndex((tab) => tab.header === activeHeader);
setActive(idx);
const idx = tabs.findIndex(tab => tab.header === activeHeader);
if (idx !== -1) setActive(idx);
else setActive(0);
}
}, [activeHeader, tabs]);
@@ -80,34 +94,73 @@ export const Tabs = ({
const validateTabname = useCallback(
(tabname: string): { error: string | null } => {
if (tabs.find((tab) => tab.header === tabname)) {
return { error: "Name already exists." };
if (!tabname) {
return { error: `Please enter ${label.toLocaleLowerCase()} name.` };
}
if (tabs.find(tab => tab.header === tabname)) {
return { error: `${capitalize(label)} name already exists.` };
}
const ext =
(tabname.includes(".") && tabname.split(".").pop()) ||
defaultExtension ||
"";
if (extensionRequired && !ext) {
return { error: "File extension is required!" };
}
if (allowedExtensions && !allowedExtensions.includes(ext)) {
return { error: "This file extension is not allowed!" };
}
if (
headerExtraValidation &&
!tabname.match(headerExtraValidation.regex)
) {
return { error: headerExtraValidation.error };
}
return { error: null };
},
[tabs]
[
allowedExtensions,
defaultExtension,
extensionRequired,
headerExtraValidation,
label,
tabs,
]
);
const handleActiveChange = useCallback(
(idx: number, header?: string) => {
setActive(idx);
onChangeActive?.(idx, header);
},
[onChangeActive]
);
const handleCreateTab = useCallback(() => {
// add default extension in case omitted
let _tabname = tabname.includes(".") ? tabname : tabname + defaultExtension;
if (forceDefaultExtension && !_tabname.endsWith(defaultExtension)) {
_tabname = _tabname + defaultExtension;
}
const chk = validateTabname(_tabname);
const chk = validateTabname(tabname);
if (chk.error) {
setNewtabError(`Error: ${chk.error}`);
return;
}
let _tabname = tabname;
if (defaultExtension && !_tabname.endsWith(defaultExtension)) {
_tabname = `${_tabname}.${defaultExtension}`;
}
setIsNewtabDialogOpen(false);
setTabname("");
// switch to new tab?
setActive(tabs.length);
onCreateNewTab?.(_tabname);
}, [tabname, defaultExtension, validateTabname, onCreateNewTab, tabs.length]);
handleActiveChange(tabs.length, _tabname);
}, [
tabname,
defaultExtension,
validateTabname,
onCreateNewTab,
handleActiveChange,
tabs.length,
]);
const handleCloseTab = useCallback(
(idx: number) => {
@@ -116,8 +169,10 @@ export const Tabs = ({
}
onCloseTab?.(idx, tabs[idx].header);
handleActiveChange(idx, tabs[idx].header);
},
[active, onCloseTab, tabs]
[active, handleActiveChange, onCloseTab, tabs]
);
return (
@@ -128,52 +183,53 @@ export const Tabs = ({
gap: "$3",
flex: 1,
flexWrap: "nowrap",
marginBottom: "-1px",
marginBottom: "$2",
width: "100%",
overflow: "auto",
}}
>
{tabs.map((tab, idx) => (
<Button
key={tab.header}
role="tab"
tabIndex={idx}
onClick={() => setActive(idx)}
onKeyPress={() => setActive(idx)}
outline={active !== idx}
size="sm"
css={{
"&:hover": {
span: {
visibility: "visible",
},
},
}}
>
{tab.header || idx}
{onCloseTab && (
<Box
as="span"
css={{
display: "flex",
p: "2px",
borderRadius: "$full",
mr: "-4px",
"&:hover": {
// boxSizing: "0px 0px 1px",
backgroundColor: "$mauve2",
color: "$mauve12",
<ContextMenu key={tab.header}>
<Button
role="tab"
tabIndex={idx}
onClick={() => handleActiveChange(idx, tab.header)}
onKeyPress={() => handleActiveChange(idx, tab.header)}
outline={active !== idx}
size="sm"
css={{
"&:hover": {
span: {
visibility: "visible",
},
}}
onClick={(ev: React.MouseEvent<HTMLElement>) => {
ev.stopPropagation();
handleCloseTab(idx);
}}
>
<X size="9px" weight="bold" />
</Box>
)}
</Button>
},
}}
>
{tab.header || idx}
{onCloseTab && (
<Box
as="span"
css={{
display: "flex",
p: "2px",
borderRadius: "$full",
mr: "-4px",
"&:hover": {
// boxSizing: "0px 0px 1px",
backgroundColor: "$mauve2",
color: "$mauve12",
},
}}
onClick={(ev: React.MouseEvent<HTMLElement>) => {
ev.stopPropagation();
handleCloseTab(idx);
}}
>
<X size="9px" weight="bold" />
</Box>
)}
</Button>
</ContextMenu>
))}
{onCreateNewTab && (
<Dialog
@@ -186,17 +242,20 @@ export const Tabs = ({
size="sm"
css={{ alignItems: "center", px: "$2", mr: "$3" }}
>
<Plus size="16px" /> {tabs.length === 0 && "Add new tab"}
<Plus size="16px" />{" "}
{tabs.length === 0 && `Add new ${label.toLocaleLowerCase()}`}
</Button>
</DialogTrigger>
<DialogContent>
<DialogTitle>Create new tab</DialogTitle>
<DialogTitle>
Create new {label.toLocaleLowerCase()}
</DialogTitle>
<DialogDescription>
<label>Tabname</label>
<Label>{label} name</Label>
<Input
value={tabname}
onChange={(e) => setTabname(e.target.value)}
onKeyPress={(e) => {
onChange={e => setTabname(e.target.value)}
onKeyPress={e => {
if (e.key === "Enter") {
handleCreateTab();
}
@@ -229,29 +288,32 @@ export const Tabs = ({
)}
</Stack>
)}
{keepAllAlive ? (
tabs.map((tab, idx) => {
// TODO Maybe rule out fragments as children
if (!isValidElement(tab.children)) {
if (active !== idx) return null;
return tab.children;
}
let key = tab.children.key || tab.header || idx;
let { children } = tab;
let { style, ...props } = children.props;
return (
<children.type
key={key}
{...props}
style={{ ...style, display: active !== idx ? "none" : undefined }}
/>
);
})
) : (
<Fragment key={tabs[active].header || active}>
{tabs[active].children}
</Fragment>
)}
{keepAllAlive
? tabs.map((tab, idx) => {
// TODO Maybe rule out fragments as children
if (!isValidElement(tab.children)) {
if (active !== idx) return null;
return tab.children;
}
let key = tab.children.key || tab.header || idx;
let { children } = tab;
let { style, ...props } = children.props;
return (
<children.type
key={key}
{...props}
style={{
...style,
display: active !== idx ? "none" : undefined,
}}
/>
);
})
: tabs[active] && (
<Fragment key={tabs[active].header || active}>
{tabs[active].children}
</Fragment>
)}
</>
);
};

View File

@@ -7,20 +7,35 @@ const Text = styled("span", {
variants: {
small: {
true: {
fontSize: '$xs'
}
fontSize: "$xs",
},
},
muted: {
true: {
color: '$mauve9'
}
color: "$mauve9",
},
},
error: {
true: {
color: "$error",
},
},
warning: {
true: {
color: "$warning",
},
},
monospace: {
true: {
fontFamily: '$monospace'
}
}
}
fontFamily: "$monospace",
},
},
block: {
true: {
display: "block",
},
},
},
});
export default Text;

115
components/Textarea.tsx Normal file
View File

@@ -0,0 +1,115 @@
import { styled } from "../stitches.config";
export const Textarea = styled("textarea", {
// Reset
appearance: "none",
borderWidth: "0",
boxSizing: "border-box",
fontFamily: "inherit",
outline: "none",
width: "100%",
flex: "1",
backgroundColor: "$mauve4",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
borderRadius: "$sm",
p: "$2",
fontSize: "$md",
lineHeight: 1,
color: "$mauve12",
boxShadow: `0 0 0 1px $colors$mauve8`,
WebkitTapHighlightColor: "rgba(0,0,0,0)",
"&::before": {
boxSizing: "border-box",
},
"&::after": {
boxSizing: "border-box",
},
fontVariantNumeric: "tabular-nums",
"&:-webkit-autofill": {
boxShadow: "inset 0 0 0 1px $colors$blue6, inset 0 0 0 100px $colors$blue3",
},
"&:-webkit-autofill::first-line": {
fontFamily: "$untitled",
color: "$mauve12",
},
"&:focus": {
boxShadow: `0 0 0 1px $colors$mauve10`,
"&:-webkit-autofill": {
boxShadow: `0 0 0 1px $colors$mauve10`,
},
},
"&::placeholder": {
color: "$mauve9",
},
"&:disabled": {
pointerEvents: "none",
backgroundColor: "$mauve2",
color: "$mauve8",
cursor: "not-allowed",
"&::placeholder": {
color: "$mauve7",
},
},
variants: {
variant: {
ghost: {
boxShadow: "none",
backgroundColor: "transparent",
"@hover": {
"&:hover": {
boxShadow: "inset 0 0 0 1px $colors$mauve7",
},
},
"&:focus": {
backgroundColor: "$loContrast",
boxShadow: `0 0 0 1px $colors$mauve10`,
},
"&:disabled": {
backgroundColor: "transparent",
},
"&:read-only": {
backgroundColor: "transparent",
},
},
deep: {
backgroundColor: "$deep",
boxShadow: "none",
},
},
state: {
invalid: {
boxShadow: "inset 0 0 0 1px $colors$crimson7",
"&:focus": {
boxShadow:
"inset 0px 0px 0px 1px $colors$crimson8, 0px 0px 0px 1px $colors$crimson8",
},
},
valid: {
boxShadow: "inset 0 0 0 1px $colors$grass7",
"&:focus": {
boxShadow:
"inset 0px 0px 0px 1px $colors$grass8, 0px 0px 0px 1px $colors$grass8",
},
},
},
cursor: {
default: {
cursor: "default",
"&:focus": {
cursor: "text",
},
},
text: {
cursor: "text",
},
},
},
});
export default Textarea;

View File

@@ -45,11 +45,11 @@ const StyledContent = styled(TooltipPrimitive.Content, {
},
".dark &": {
boxShadow:
"0px 0px 10px 2px rgba(255,255,255,.15), hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px",
"0px 0px 10px 2px rgba(0,0,0,.45), hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px",
},
".light &": {
boxShadow:
"0px 0px 10px 2px rgba(0,0,0,.15), hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px",
"0px 0px 10px 2px rgba(0,0,0,.25), hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px",
},
});
@@ -64,12 +64,15 @@ interface ITooltip {
onOpenChange?: (open: boolean) => void;
}
const Tooltip: React.FC<ITooltip> = ({
const Tooltip: React.FC<
React.ComponentProps<typeof StyledContent> & ITooltip
> = ({
children,
content,
open,
defaultOpen = false,
onOpenChange,
...rest
}) => {
return (
<TooltipPrimitive.Root
@@ -78,8 +81,8 @@ const Tooltip: React.FC<ITooltip> = ({
onOpenChange={onOpenChange}
>
<TooltipPrimitive.Trigger asChild>{children}</TooltipPrimitive.Trigger>
<StyledContent side="bottom" align="center">
{content}
<StyledContent side="bottom" align="center" {...rest}>
<div dangerouslySetInnerHTML={{ __html: content }} />
<StyledArrow offset={5} width={11} height={5} />
</StyledContent>
</TooltipPrimitive.Root>

View File

@@ -0,0 +1,252 @@
import { Play } from "phosphor-react";
import { FC, useCallback, useEffect } from "react";
import { useSnapshot } from "valtio";
import state from "../../state";
import {
defaultTransactionType,
getTxFields,
modifyTransaction,
prepareState,
prepareTransaction,
SelectOption,
TransactionState,
} from "../../state/transactions";
import { sendTransaction } from "../../state/actions";
import Box from "../Box";
import Button from "../Button";
import Flex from "../Flex";
import { TxJson } from "./json";
import { TxUI } from "./ui";
import { default as _estimateFee } from "../../utils/estimateFee";
import toast from "react-hot-toast";
export interface TransactionProps {
header: string;
state: TransactionState;
}
const Transaction: FC<TransactionProps> = ({
header,
state: txState,
...props
}) => {
const { accounts, editorSettings } = useSnapshot(state);
const {
selectedAccount,
selectedTransaction,
txIsDisabled,
txIsLoading,
viewType,
editorValue,
} = txState;
const setState = useCallback(
(pTx?: Partial<TransactionState>) => {
return modifyTransaction(header, pTx);
},
[header]
);
const prepareOptions = useCallback(
(state: Partial<TransactionState> = txState) => {
const {
selectedTransaction,
selectedDestAccount,
selectedAccount,
txFields,
} = state;
const TransactionType = selectedTransaction?.value || null;
const Destination = selectedDestAccount?.value || txFields?.Destination;
const Account = selectedAccount?.value || null;
return prepareTransaction({
...txFields,
TransactionType,
Destination,
Account,
});
},
[txState]
);
useEffect(() => {
const transactionType = selectedTransaction?.value;
const account = selectedAccount?.value;
if (!account || !transactionType || txIsLoading) {
setState({ txIsDisabled: true });
} else {
setState({ txIsDisabled: false });
}
}, [
selectedAccount?.value,
selectedTransaction?.value,
setState,
txIsLoading,
]);
const submitTest = useCallback(async () => {
let st: TransactionState | undefined;
const tt = txState.selectedTransaction?.value;
if (viewType === "json") {
// save the editor state first
const pst = prepareState(editorValue || "", tt);
if (!pst) return;
st = setState(pst);
}
const account = accounts.find(
acc => acc.address === selectedAccount?.value
);
if (txIsDisabled) return;
setState({ txIsLoading: true });
const logPrefix = header ? `${header.split(".")[0]}: ` : undefined;
try {
if (!account) {
throw Error("Account must be selected from imported accounts!");
}
const options = prepareOptions(st);
const fields = getTxFields(options.TransactionType);
if (fields.Destination && !options.Destination) {
throw Error("Destination account is required!");
}
await sendTransaction(account, options, { logPrefix });
} catch (error) {
console.error(error);
if (error instanceof Error) {
state.transactionLogs.push({
type: "error",
message: `${logPrefix}${error.message}`,
});
}
}
setState({ txIsLoading: false });
}, [
viewType,
accounts,
txIsDisabled,
setState,
header,
editorValue,
txState,
selectedAccount?.value,
prepareOptions,
]);
const getJsonString = useCallback(
(state?: Partial<TransactionState>) =>
JSON.stringify(
prepareOptions?.(state) || {},
null,
editorSettings.tabSize
),
[editorSettings.tabSize, prepareOptions]
);
const resetState = useCallback(
(transactionType: SelectOption | undefined = defaultTransactionType) => {
const fields = getTxFields(transactionType?.value);
const nwState: Partial<TransactionState> = {
viewType,
selectedTransaction: transactionType,
};
if (fields.Destination !== undefined) {
nwState.selectedDestAccount = null;
fields.Destination = "";
} else {
fields.Destination = undefined;
}
nwState.txFields = fields;
const state = modifyTransaction(header, nwState, { replaceState: true });
const editorValue = getJsonString(state);
return setState({ editorValue });
},
[getJsonString, header, setState, viewType]
);
const estimateFee = useCallback(
async (st?: TransactionState, opts?: { silent?: boolean }) => {
const state = st || txState;
const ptx = prepareOptions(state);
const account = accounts.find(
acc => acc.address === state.selectedAccount?.value
);
if (!account) {
if (!opts?.silent) {
toast.error("Please select account from the list.");
}
return;
}
ptx.Account = account.address;
ptx.Sequence = account.sequence;
const res = await _estimateFee(ptx, account, opts);
const fee = res?.base_fee;
setState({ estimatedFee: fee });
return fee;
},
[accounts, prepareOptions, setState, txState]
);
return (
<Box css={{ position: "relative", height: "calc(100% - 28px)" }} {...props}>
{viewType === "json" ? (
<TxJson
getJsonString={getJsonString}
header={header}
state={txState}
setState={setState}
estimateFee={estimateFee}
/>
) : (
<TxUI state={txState} setState={setState} estimateFee={estimateFee} />
)}
<Flex
row
css={{
justifyContent: "space-between",
position: "absolute",
left: 0,
bottom: 0,
width: "100%",
mb: "$1",
}}
>
<Button
onClick={() => {
if (viewType === "ui") {
setState({ viewType: "json" });
} else setState({ viewType: "ui" });
}}
outline
>
{viewType === "ui" ? "EDIT AS JSON" : "EXIT JSON MODE"}
</Button>
<Flex row>
<Button onClick={() => resetState()} outline css={{ mr: "$3" }}>
RESET
</Button>
<Button
variant="primary"
onClick={submitTest}
isLoading={txIsLoading}
disabled={txIsDisabled}
>
<Play weight="bold" size="16px" />
RUN TEST
</Button>
</Flex>
</Flex>
</Box>
);
};
export default Transaction;

View File

@@ -0,0 +1,232 @@
import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { useSnapshot } from "valtio";
import state, {
prepareState,
transactionsData,
TransactionState,
} from "../../state";
import Text from "../Text";
import { Flex, Link } from "..";
import { showAlert } from "../../state/actions/showAlert";
import { parseJSON } from "../../utils/json";
import { extractSchemaProps } from "../../utils/schema";
import amountSchema from "../../content/amount-schema.json";
import Monaco from "../Monaco";
import type monaco from "monaco-editor";
interface JsonProps {
getJsonString?: (state?: Partial<TransactionState>) => string;
header?: string;
setState: (pTx?: Partial<TransactionState> | undefined) => void;
state: TransactionState;
estimateFee?: () => Promise<string | undefined>;
}
export const TxJson: FC<JsonProps> = ({
getJsonString,
state: txState,
header,
setState,
}) => {
const { editorSettings, accounts } = useSnapshot(state);
const { editorValue, estimatedFee } = txState;
const [currTxType, setCurrTxType] = useState<string | undefined>(
txState.selectedTransaction?.value
);
useEffect(() => {
setState({
editorValue: getJsonString?.(),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const parsed = parseJSON(editorValue);
if (!parsed) return;
const tt = parsed.TransactionType;
const tx = transactionsData.find(t => t.TransactionType === tt);
if (tx) setCurrTxType(tx.TransactionType);
else {
setCurrTxType(undefined);
}
}, [editorValue]);
const saveState = (value: string, transactionType?: string) => {
const tx = prepareState(value, transactionType);
if (tx) {
setState(tx);
setState({
editorValue: getJsonString?.(tx),
});
}
};
const discardChanges = () => {
showAlert("Confirm", {
body: "Are you sure to discard these changes?",
confirmText: "Yes",
onCancel: () => {},
onConfirm: () => setState({ editorValue: getJsonString?.() }),
});
};
const onExit = (value: string) => {
const options = parseJSON(value);
if (options) {
saveState(value, currTxType);
return;
}
showAlert("Error!", {
body: `Malformed Transaction in ${header}, would you like to discard these changes?`,
confirmText: "Discard",
onConfirm: () => setState({ editorValue: getJsonString?.() }),
onCancel: () => setState({ viewType: "json" }),
});
};
const getSchemas = useCallback(async (): Promise<any[]> => {
const txObj = transactionsData.find(
td => td.TransactionType === currTxType
);
let genericSchemaProps: any;
if (txObj) {
genericSchemaProps = extractSchemaProps(txObj);
} else {
genericSchemaProps = transactionsData.reduce(
(cumm, td) => ({
...cumm,
...extractSchemaProps(td),
}),
{}
);
}
return [
{
uri: "file:///main-schema.json", // id of the first schema
fileMatch: ["**.json"], // associate with our model
schema: {
title: header,
type: "object",
required: ["TransactionType", "Account"],
properties: {
...genericSchemaProps,
TransactionType: {
title: "Transaction Type",
enum: transactionsData.map(td => td.TransactionType),
},
Account: {
$ref: "file:///account-schema.json",
},
Destination: {
anyOf: [
{
$ref: "file:///account-schema.json",
},
{
type: "string",
title: "Destination Account",
},
],
},
Amount: {
$ref: "file:///amount-schema.json",
},
Fee: {
$ref: "file:///fee-schema.json",
},
},
},
},
{
uri: "file:///account-schema.json",
schema: {
type: "string",
title: "Account type",
enum: accounts.map(acc => acc.address),
},
},
{
uri: "file:///fee-schema.json",
schema: {
type: "string",
title: "Fee type",
const: estimatedFee,
description: estimatedFee
? "Above mentioned value is recommended base fee"
: undefined,
},
},
{
...amountSchema,
},
];
}, [accounts, currTxType, estimatedFee, header]);
const [monacoInst, setMonacoInst] = useState<typeof monaco>();
useEffect(() => {
if (!monacoInst) return;
getSchemas().then(schemas => {
monacoInst.languages.json.jsonDefaults.setDiagnosticsOptions({
validate: true,
schemas,
});
});
}, [getSchemas, monacoInst]);
const hasUnsaved = useMemo(
() => editorValue !== getJsonString?.(),
[editorValue, getJsonString]
);
return (
<Monaco
rootProps={{
css: { height: "calc(100% - 45px)" },
}}
language={"json"}
id={header}
height="100%"
value={editorValue}
onChange={val => setState({ editorValue: val })}
onMount={(editor, monaco) => {
editor.updateOptions({
minimap: { enabled: false },
glyphMargin: true,
tabSize: editorSettings.tabSize,
dragAndDrop: true,
fontSize: 14,
});
setMonacoInst(monaco);
// register onExit cb
const model = editor.getModel();
model?.onWillDispose(() => onExit(model.getValue()));
}}
overlay={
hasUnsaved ? (
<Flex
row
align="center"
css={{ fontSize: "$xs", color: "$textMuted", ml: "auto" }}
>
<Text muted small>
This file has unsaved changes.
</Text>
<Link
css={{ ml: "$1" }}
onClick={() => saveState(editorValue || "", currTxType)}
>
save
</Link>
<Link css={{ ml: "$1" }} onClick={discardChanges}>
discard
</Link>
</Flex>
) : undefined
}
/>
);
};

View File

@@ -0,0 +1,331 @@
import { FC, useCallback, useEffect, useMemo, useState } from "react";
import Container from "../Container";
import Flex from "../Flex";
import Input from "../Input";
import Select from "../Select";
import Text from "../Text";
import {
SelectOption,
TransactionState,
transactionsOptions,
TxFields,
getTxFields,
defaultTransactionType,
} from "../../state/transactions";
import { useSnapshot } from "valtio";
import state from "../../state";
import { streamState } from "../DebugStream";
import { Button } from "..";
import Textarea from "../Textarea";
interface UIProps {
setState: (
pTx?: Partial<TransactionState> | undefined
) => TransactionState | undefined;
state: TransactionState;
estimateFee?: (...arg: any) => Promise<string | undefined>;
}
export const TxUI: FC<UIProps> = ({
state: txState,
setState,
estimateFee,
}) => {
const { accounts } = useSnapshot(state);
const {
selectedAccount,
selectedDestAccount,
selectedTransaction,
txFields,
} = txState;
const accountOptions: SelectOption[] = accounts.map(acc => ({
label: acc.name,
value: acc.address,
}));
const destAccountOptions: SelectOption[] = accounts
.map(acc => ({
label: acc.name,
value: acc.address,
}))
.filter(acc => acc.value !== selectedAccount?.value);
const [feeLoading, setFeeLoading] = useState(false);
const resetFields = useCallback(
(tt: string) => {
const fields = getTxFields(tt);
if (fields.Destination !== undefined) {
setState({ selectedDestAccount: null });
fields.Destination = "";
} else {
fields.Destination = undefined;
}
return setState({ txFields: fields });
},
[setState]
);
const handleSetAccount = (acc: SelectOption) => {
setState({ selectedAccount: acc });
streamState.selectedAccount = acc;
};
const handleSetField = useCallback(
(field: keyof TxFields, value: string, opFields?: TxFields) => {
const fields = opFields || txFields;
const obj = fields[field];
setState({
txFields: {
...fields,
[field]: typeof obj === "object" ? { ...obj, $value: value } : value,
},
});
},
[setState, txFields]
);
const handleEstimateFee = useCallback(
async (state?: TransactionState, silent?: boolean) => {
setFeeLoading(true);
const fee = await estimateFee?.(state, { silent });
if (fee) handleSetField("Fee", fee, state?.txFields);
setFeeLoading(false);
},
[estimateFee, handleSetField]
);
const handleChangeTxType = useCallback(
(tt: SelectOption) => {
setState({ selectedTransaction: tt });
const newState = resetFields(tt.value);
handleEstimateFee(newState, true);
},
[handleEstimateFee, resetFields, setState]
);
const switchToJson = () => setState({ viewType: "json" });
// default tx
useEffect(() => {
if (selectedTransaction?.value) return;
if (defaultTransactionType) {
handleChangeTxType(defaultTransactionType);
}
}, [handleChangeTxType, selectedTransaction?.value]);
const fields = useMemo(
() => getTxFields(selectedTransaction?.value),
[selectedTransaction?.value]
);
const specialFields = ["TransactionType", "Account"];
if (fields.Destination !== undefined) {
specialFields.push("Destination");
}
const otherFields = Object.keys(txFields).filter(
k => !specialFields.includes(k)
) as [keyof TxFields];
return (
<Container
css={{
p: "$3 01",
fontSize: "$sm",
height: "calc(100% - 45px)",
}}
>
<Flex column fluid css={{ height: "100%", overflowY: "auto", pr: "$1" }}>
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
mt: "1px",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
Transaction type:{" "}
</Text>
<Select
instanceId="transactionsType"
placeholder="Select transaction type"
options={transactionsOptions}
hideSelectedOptions
css={{ width: "70%" }}
value={selectedTransaction}
onChange={(tt: any) => handleChangeTxType(tt)}
/>
</Flex>
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
Account:{" "}
</Text>
<Select
instanceId="from-account"
placeholder="Select your account"
css={{ width: "70%" }}
options={accountOptions}
value={selectedAccount}
onChange={(acc: any) => handleSetAccount(acc)} // TODO make react-select have correct types for acc
/>
</Flex>
{fields.Destination !== undefined && (
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
Destination account:{" "}
</Text>
<Select
instanceId="to-account"
placeholder="Select the destination account"
css={{ width: "70%" }}
options={destAccountOptions}
value={selectedDestAccount}
isClearable
onChange={(acc: any) => setState({ selectedDestAccount: acc })}
/>
</Flex>
)}
{otherFields.map(field => {
let _value = txFields[field];
let value: string | undefined;
if (typeof _value === "object") {
if (_value.$type === "json" && typeof _value.$value === "object") {
value = JSON.stringify(_value.$value, null, 2);
} else {
value = _value.$value.toString();
}
} else {
value = _value?.toString();
}
const isXrp = typeof _value === "object" && _value.$type === "xrp";
const isJson = typeof _value === "object" && _value.$type === "json";
const isFee = field === "Fee";
let rows = isJson
? (value?.match(/\n/gm)?.length || 0) + 1
: undefined;
if (rows && rows > 5) rows = 5;
return (
<Flex column key={field} css={{ mb: "$2", pr: "1px" }}>
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
position: "relative",
}}
>
<Text muted css={{ mr: "$3" }}>
{field + (isXrp ? " (XRP)" : "")}:{" "}
</Text>
{isJson ? (
<Textarea
rows={rows}
value={value}
spellCheck={false}
onChange={switchToJson}
css={{
width: "70%",
flex: "inherit",
resize: "vertical",
}}
/>
) : (
<Input
type={isFee ? "number" : "text"}
value={value}
onChange={e => {
if (isFee) {
const val = e.target.value
.replaceAll(".", "")
.replaceAll(",", "");
handleSetField(field, val);
} else {
handleSetField(field, e.target.value);
}
}}
onKeyPress={
isFee
? e => {
if (e.key === "." || e.key === ",") {
e.preventDefault();
}
}
: undefined
}
css={{
width: "70%",
flex: "inherit",
"-moz-appearance": "textfield",
"&::-webkit-outer-spin-button": {
"-webkit-appearance": "none",
margin: 0,
},
"&::-webkit-inner-spin-button ": {
"-webkit-appearance": "none",
margin: 0,
},
}}
/>
)}
{isFee && (
<Button
size="xs"
variant="primary"
outline
disabled={txState.txIsDisabled}
isDisabled={txState.txIsDisabled}
isLoading={feeLoading}
css={{
position: "absolute",
right: "$2",
fontSize: "$xs",
cursor: "pointer",
alignContent: "center",
display: "flex",
}}
onClick={() => handleEstimateFee()}
>
Suggest
</Button>
)}
</Flex>
</Flex>
);
})}
</Flex>
</Container>
);
};

View File

@@ -0,0 +1,40 @@
const Carbon = () => (
<svg
width="66"
height="32"
viewBox="0 0 66 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M33 2L23 15H28L21 24H45L38 15H43L33 2Z"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M33 24V30"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
className="angle"
fillRule="evenodd"
clipRule="evenodd"
d="M-1.14441e-05 4L8.94099 15.0625L4.00543e-05 26.125H2.27587L10.5015 15.9475H16.5938V14.1775H10.5015L2.27582 4H-1.14441e-05Z"
fill="#EDEDEF"
/>
<path
className="angle"
fillRule="evenodd"
clipRule="evenodd"
d="M66 4L57.059 15.0625L66 26.125H63.7241L55.4985 15.9475H49.4062V14.1775H55.4985L63.7242 4H66Z"
fill="#EDEDEF"
/>
</svg>
);
export default Carbon;

View File

@@ -0,0 +1,75 @@
const Firewall = () => (
<svg
width="66"
height="32"
viewBox="0 0 66 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M33 13V7"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M27 19V13"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M39 19V13"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M33 25V19"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M21 13H45"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M21 19H45"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M45 7H21V25H45V7Z"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
className="angle"
fillRule="evenodd"
clipRule="evenodd"
d="M-1.14441e-05 4.875L8.94099 15.9375L4.00543e-05 27H2.27587L10.5015 16.8225H16.5938V15.0525H10.5015L2.27582 4.875H-1.14441e-05Z"
fill="#EDEDEF"
/>
<path
className="angle"
fillRule="evenodd"
clipRule="evenodd"
d="M66 4.875L57.059 15.9375L66 27H63.7241L55.4985 16.8225H49.4062V15.0525H55.4985L63.7242 4.875H66Z"
fill="#EDEDEF"
/>
</svg>
);
export default Firewall;

View File

@@ -0,0 +1,40 @@
const Notary = () => (
<svg
width="66"
height="32"
viewBox="0 0 66 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M37.5 10.5L26.5 21.5L21 16.0002"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M49 10.5L38 21.5L35.0784 18.5785"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
className="angle"
fillRule="evenodd"
clipRule="evenodd"
d="M-1.14441e-05 5L8.94099 16.0625L4.00543e-05 27.125H2.27587L10.5015 16.9475H16.5938V15.1775H10.5015L2.27582 5H-1.14441e-05Z"
fill="#EDEDEF"
/>
<path
className="angle"
fillRule="evenodd"
clipRule="evenodd"
d="M66 5L57.059 16.0625L66 27.125H63.7241L55.4985 16.9475H49.4062V15.1775H55.4985L63.7242 5H66Z"
fill="#EDEDEF"
/>
</svg>
);
export default Notary;

View File

@@ -0,0 +1,61 @@
const Peggy = () => (
<svg
width="66"
height="32"
viewBox="0 0 66 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M33 19C40.1797 19 46 16.3137 46 13C46 9.68629 40.1797 7 33 7C25.8203 7 20 9.68629 20 13C20 16.3137 25.8203 19 33 19Z"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M33 19V25"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M20 13V19C20 22 25 25 33 25C41 25 46 22 46 19V13"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M41 17.7633V23.7634"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M25 17.7633V23.7634"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
className="angle"
fillRule="evenodd"
clipRule="evenodd"
d="M-1.14441e-05 4L8.94099 15.0625L4.00543e-05 26.125H2.27587L10.5015 15.9475H16.5938V14.1775H10.5015L2.27582 4H-1.14441e-05Z"
fill="#EDEDEF"
/>
<path
className="angle"
fillRule="evenodd"
clipRule="evenodd"
d="M66 4L57.059 15.0625L66 26.125H63.7241L55.4985 15.9475H49.4062V14.1775H55.4985L63.7242 4H66Z"
fill="#EDEDEF"
/>
</svg>
);
export default Peggy;

View File

@@ -0,0 +1,40 @@
const Starter = () => (
<svg
width="66"
height="32"
viewBox="0 0 66 32"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M42 28H24C23.7347 28 23.4804 27.8946 23.2929 27.7071C23.1053 27.5196 23 27.2652 23 27V5C23 4.73479 23.1053 4.48044 23.2929 4.2929C23.4804 4.10537 23.7347 4.00001 24 4H36.0003L43 11V27C43 27.2652 42.8947 27.5196 42.7071 27.7071C42.5196 27.8946 42.2653 28 42 28V28Z"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M36 4V11H43.001"
stroke="#EDEDEF"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
className="angle"
fillRule="evenodd"
clipRule="evenodd"
d="M-1.14441e-05 4.875L8.94099 15.9375L4.00543e-05 27H2.27587L10.5015 16.8225H16.5938V15.0525H10.5015L2.27582 4.875H-1.14441e-05Z"
fill="#EDEDEF"
/>
<path
className="angle"
fillRule="evenodd"
clipRule="evenodd"
d="M66 4.875L57.059 15.9375L66 27H63.7241L55.4985 16.8225H49.4062V15.0525H55.4985L63.7242 4.875H66Z"
fill="#EDEDEF"
/>
</svg>
);
export default Starter;

View File

@@ -4,14 +4,13 @@ export { default as Container } from "./Container";
export { default as Heading } from "./Heading";
export { default as Stack } from "./Stack";
export { default as Text } from "./Text";
export { default as Input } from "./Input";
export { default as Input, Label } from "./Input";
export { default as Select } from "./Select";
export * from "./Tabs";
export * from "./AlertDialog";
export * from "./AlertDialog/primitive";
export { default as Box } from "./Box";
export { default as Button } from "./Button";
export { default as Pre } from "./Pre";
export { default as ButtonGroup } from "./ButtonGroup";
export { default as DeployFooter } from "./DeployFooter";
export * from "./Dialog";
export * from "./DropdownMenu";

View File

@@ -0,0 +1,50 @@
{
"uri": "file:///amount-schema.json",
"title": "Amount",
"description": "Specify xrp in drops and tokens as objects.",
"schema": {
"anyOf": [
{
"type": [
"number",
"string"
],
"exclusiveMinimum": 0,
"maximum": "100000000000000000"
},
{
"type": "object",
"properties": {
"currency": {
"description": "Arbitrary currency code for the token. Cannot be XRP."
},
"value": {
"type": [
"string",
"number"
],
"description": "Quoted decimal representation of the amount of the token."
},
"issuer": {
"type": "string",
"description": "Generally, the account that issues this token. In special cases, this can refer to the account that holds the token instead."
}
}
}
],
"defaultSnippets": [
{
"label": "Xrp",
"body": "1000000"
},
{
"label": "Token",
"body": {
"currency": "${1:USD}",
"value": "${2:100}",
"issuer": "${3:rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpns}"
}
}
]
}
}

View File

@@ -27,8 +27,8 @@
"Account": "rfkE1aSy9G8Upk4JssnwBxhEv5p4mn2KTy",
"TransactionType": "CheckCash",
"Amount": {
"value": "100",
"type": "currency"
"$value": "100",
"$type": "xrp"
},
"CheckID": "838766BA2B995C00744175F69A1B11E32C3DBC40E64801A4056FCBD657F57334",
"Fee": "12"
@@ -55,21 +55,23 @@
"Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
"TransactionType": "EscrowCancel",
"Owner": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
"OfferSequence": 7
"OfferSequence": 7,
"Fee": "10"
},
{
"Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
"TransactionType": "EscrowCreate",
"Amount": {
"value": "100",
"type": "currency"
"$value": "100",
"$type": "xrp"
},
"Destination": "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW",
"CancelAfter": 533257958,
"FinishAfter": 533171558,
"Condition": "A0258020E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855810100",
"DestinationTag": 23480,
"SourceTag": 11747
"SourceTag": 11747,
"Fee": "10"
},
{
"Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
@@ -77,32 +79,50 @@
"Owner": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
"OfferSequence": 7,
"Condition": "A0258020E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855810100",
"Fulfillment": "A0028000"
"Fulfillment": "A0028000",
"Fee": "10"
},
{
"TransactionType": "NFTokenMint",
"Account": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B",
"Fee": "10",
"NFTokenTaxon": 0,
"URI": "697066733A2F2F516D614374444B5A4656767666756676626479346573745A626851483744586831364354707631686F776D424779"
},
{
"TransactionType": "NFTokenBurn",
"Account": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B",
"Fee": "10",
"TokenID": "000B013A95F14B0044F78A264E41713C64B5F89242540EE208C3098E00000D65"
"NFTokenID": "000B013A95F14B0044F78A264E41713C64B5F89242540EE208C3098E00000D65"
},
{
"TransactionType": "NFTokenAcceptOffer",
"Fee": "10"
"Account": "rvYAfWj5gh67oV6fW32ZzP3Aw4Eubs59B",
"Fee": "10",
"NFTokenSellOffer": "A2FA1A9911FE2AEF83DAB05F437768E26A301EF899BD31EB85E704B3D528FF18",
"NFTokenBuyOffer": "4AAAEEA76E3C8148473CB3840CE637676E561FB02BD4CA22CA59729EA815B862",
"NFTokenBrokerFee": "10"
},
{
"TransactionType": "NFTokenCancelOffer",
"Account": "ra5nK24KXen9AHvsdFTKHSANinZseWnPcX",
"TokenIDs": "000100001E962F495F07A990F4ED55ACCFEEF365DBAA76B6A048C0A200000007"
"Fee": "10",
"NFTokenOffers": {
"$type": "json",
"$value": ["4AAAEEA76E3C8148473CB3840CE637676E561FB02BD4CA22CA59729EA815B862"]
}
},
{
"TransactionType": "NFTokenCreateOffer",
"Account": "rs8jBmmfpwgmrSPgwMsh7CvKRmRt1JTVSX",
"TokenID": "000100001E962F495F07A990F4ED55ACCFEEF365DBAA76B6A048C0A200000007",
"NFTokenID": "000100001E962F495F07A990F4ED55ACCFEEF365DBAA76B6A048C0A200000007",
"Amount": {
"value": "100",
"type": "currency"
"$value": "100",
"$type": "xrp"
},
"Flags": 1
"Flags": 1,
"Destination": "",
"Fee": "10"
},
{
"TransactionType": "OfferCancel",
@@ -122,8 +142,8 @@
"Sequence": 8,
"TakerGets": "6000000",
"Amount": {
"value": "100",
"type": "currency"
"$value": "100",
"$type": "xrp"
}
},
{
@@ -131,8 +151,8 @@
"Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
"Destination": "ra5nK24KXen9AHvsdFTKHSANinZseWnPcX",
"Amount": {
"value": "100",
"type": "currency"
"$value": "100",
"$type": "xrp"
},
"Fee": "12",
"Flags": 2147483648,
@@ -142,25 +162,27 @@
"Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
"TransactionType": "PaymentChannelCreate",
"Amount": {
"value": "100",
"type": "currency"
"$value": "100",
"$type": "xrp"
},
"Destination": "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW",
"SettleDelay": 86400,
"PublicKey": "32D2471DB72B27E3310F355BB33E339BF26F8392D5A93D3BC0FC3B566612DA0F0A",
"CancelAfter": 533171558,
"DestinationTag": 23480,
"SourceTag": 11747
"SourceTag": 11747,
"Fee": "10"
},
{
"Account": "rf1BiGeXwwQoi8Z2ueFYTEXSwuJYfV2Jpn",
"TransactionType": "PaymentChannelFund",
"Channel": "C1AE6DDDEEC05CF2978C0BAD6FE302948E9533691DC749DCDD3B9E5992CA6198",
"Amount": {
"value": "200",
"type": "currency"
"$value": "200",
"$type": "xrp"
},
"Expiration": 543171558
"Expiration": 543171558,
"Fee": "10"
},
{
"Flags": 0,
@@ -176,8 +198,8 @@
"Fee": "12",
"SignerQuorum": 3,
"SignerEntries": {
"type": "json",
"value": [
"$type": "json",
"$value": [
{
"SignerEntry": {
"Account": "rsA2LpzuawewSBQXkiju3YQTMzW13pAAdW",
@@ -212,9 +234,13 @@
"Fee": "12",
"Flags": 262144,
"LastLedgerSequence": 8007750,
"Amount": {
"value": "100",
"type": "currency"
"LimitAmount": {
"$type": "json",
"$value": {
"currency": "USD",
"issuer": "rsP3mgGb2tcYUrxiLFiHJiQXhsziegtwBc",
"value": "100"
}
},
"Sequence": 12
}

View File

@@ -12,16 +12,21 @@
"dependencies": {
"@codingame/monaco-jsonrpc": "^0.3.1",
"@codingame/monaco-languageclient": "^0.17.0",
"@monaco-editor/react": "^4.4.1",
"@monaco-editor/react": "^4.4.5",
"@octokit/core": "^3.5.1",
"@radix-ui/colors": "^0.1.7",
"@radix-ui/react-alert-dialog": "^0.1.1",
"@radix-ui/react-context-menu": "^0.1.6",
"@radix-ui/react-dialog": "^0.1.1",
"@radix-ui/react-dropdown-menu": "^0.1.1",
"@radix-ui/react-dropdown-menu": "^0.1.6",
"@radix-ui/react-id": "^0.1.1",
"@radix-ui/react-label": "^0.1.5",
"@radix-ui/react-popover": "^0.1.6",
"@radix-ui/react-switch": "^0.1.5",
"@radix-ui/react-tooltip": "^0.1.7",
"@stitches/react": "^1.2.6-0",
"@stitches/react": "^1.2.8",
"base64-js": "^1.5.1",
"comment-parser": "^1.3.1",
"dinero.js": "^1.9.1",
"file-saver": "^2.0.5",
"filesize": "^8.0.7",
@@ -31,7 +36,8 @@
"lodash.xor": "^4.5.0",
"monaco-editor": "^0.33.0",
"next": "^12.0.4",
"next-auth": "^4.0.0-beta.5",
"next-auth": "^4.10.1",
"next-plausible": "^3.2.0",
"next-themes": "^0.1.1",
"normalize-url": "^7.0.2",
"octokit": "^1.7.0",
@@ -56,7 +62,7 @@
"vscode-languageserver": "^7.0.0",
"vscode-uri": "^3.0.2",
"wabt": "1.0.16",
"xrpl-accountlib": "^1.3.2",
"xrpl-accountlib": "^1.5.2",
"xrpl-client": "^1.9.4"
},
"devDependencies": {
@@ -70,5 +76,8 @@
"eslint-config-next": "11.1.2",
"raw-loader": "^4.0.2",
"typescript": "4.4.4"
},
"resolutions": {
"ripple-binary-codec": "=1.4.2"
}
}
}

View File

@@ -7,6 +7,7 @@ import { ThemeProvider } from "next-themes";
import { Toaster } from "react-hot-toast";
import { useRouter } from "next/router";
import { IdProvider } from "@radix-ui/react-id";
import PlausibleProvider from "next-plausible";
import { darkTheme, css } from "../stitches.config";
import Navigation from "../components/Navigation";
@@ -16,6 +17,9 @@ import state from "../state";
import TimeAgo from "javascript-time-ago";
import en from "javascript-time-ago/locale/en.json";
import { useSnapshot } from "valtio";
import Alert from "../components/AlertDialog";
import { Button, Flex } from "../components";
import { ChatCircleText } from "phosphor-react";
TimeAgo.setDefaultLocale(en.locale);
TimeAgo.addLocale(en);
@@ -36,7 +40,7 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
if (
!gistId &&
router.isReady &&
!router.pathname.includes("/sign-in") &&
router.pathname.includes("/develop") &&
!snap.files.length &&
!snap.mainModalShowed
) {
@@ -60,22 +64,22 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
<meta name="format-detection" content="telephone=no" />
<meta property="og:url" content={`${origin}${router.asPath}`} />
<title>XRPL Hooks Editor</title>
<meta property="og:title" content="XRPL Hooks Editor" />
<meta name="twitter:title" content="XRPL Hooks Editor" />
<title>XRPL Hooks Builder</title>
<meta property="og:title" content="XRPL Hooks Builder" />
<meta name="twitter:title" content="XRPL Hooks Builder" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:site" content="@xrpllabs" />
<meta name="twitter:site" content="@XRPLF" />
<meta
name="description"
content="Playground for buildings Hooks, that add smart contract functionality to the XRP Ledger."
content="Hooks Builder, add smart contract functionality to the XRP Ledger."
/>
<meta
property="og:description"
content="Playground for buildings Hooks, that add smart contract functionality to the XRP Ledger."
content="Hooks Builder, add smart contract functionality to the XRP Ledger."
/>
<meta
name="twitter:description"
content="Playground for buildings Hooks, that add smart contract functionality to the XRP Ledger.."
content="Hooks Builder, add smart contract functionality to the XRP Ledger."
/>
<meta property="og:image" content={`${origin}${shareImg}`} />
<meta property="og:image:width" content="1200" />
@@ -100,7 +104,7 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
/>
<link rel="manifest" href="/site.webmanifest" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#161618" />
<meta name="application-name" content="XRPL Hooks Editor" />
<meta name="application-name" content="XRPL Hooks Builder" />
<meta name="msapplication-TileColor" content="#c10ad0" />
<meta
name="theme-color"
@@ -113,6 +117,7 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
media="(prefers-color-scheme: light)"
/>
</Head>
<IdProvider>
<SessionProvider session={session}>
<ThemeProvider
@@ -124,22 +129,40 @@ function MyApp({ Component, pageProps: { session, ...pageProps } }: AppProps) {
dark: darkTheme.className,
}}
>
<Navigation />
<Component {...pageProps} />
<Toaster
toastOptions={{
className: css({
backgroundColor: "$mauve1",
color: "$mauve10",
fontSize: "$sm",
zIndex: 9999,
".dark &": {
backgroundColor: "$mauve4",
color: "$mauve12",
},
})(),
}}
/>
<PlausibleProvider
domain="hooks-builder.xrpl.org"
trackOutboundLinks
>
<Navigation />
<Component {...pageProps} />
<Toaster
toastOptions={{
className: css({
backgroundColor: "$mauve1",
color: "$mauve10",
fontSize: "$sm",
zIndex: 9999,
".dark &": {
backgroundColor: "$mauve4",
color: "$mauve12",
},
})(),
}}
/>
<Alert />
<Flex
as="a"
href="https://github.com/XRPLF/Hooks/discussions"
target="_blank"
rel="noopener noreferrer"
css={{ position: "fixed", right: "$4", bottom: "$4" }}
>
<Button size="sm" variant="primary" outline>
<ChatCircleText size={14} style={{ marginRight: "0px" }} />
Bugs & Discussions
</Button>
</Flex>
</PlausibleProvider>
</ThemeProvider>
</SessionProvider>
</IdProvider>

18
pages/api/proxy.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { NextApiRequest, NextApiResponse } from 'next'
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
try {
const { url, opts } = req.body
const r = await fetch(url, opts);
if (!r.ok) throw (r.statusText)
const data = await r.json()
return res.json(data)
} catch (error) {
console.warn(error)
return res.status(500).json({ message: "Something went wrong!" })
}
}

View File

@@ -1,15 +1,19 @@
import { Label } from "@radix-ui/react-label";
import type { NextPage } from "next";
import dynamic from "next/dynamic";
import { Play } from "phosphor-react";
import { FileJs, Gear, Play } from "phosphor-react";
import Hotkeys from "react-hot-keys";
import Split from "react-split";
import { useSnapshot } from "valtio";
import { ButtonGroup, Flex } from "../../components";
import Box from "../../components/Box";
import Button from "../../components/Button";
import Popover from "../../components/Popover";
import RunScript from "../../components/RunScript";
import state from "../../state";
import { compileCode } from "../../state/actions";
import { getSplit, saveSplit } from "../../state/actions/persistSplits";
import { styled } from "../../stitches.config";
const HooksEditor = dynamic(() => import("../../components/HooksEditor"), {
ssr: false,
@@ -19,6 +23,128 @@ const LogBox = dynamic(() => import("../../components/LogBox"), {
ssr: false,
});
const OptimizationText = () => (
<span>
Specify which optimization level to use for compiling. For example -O0 means
no optimization: this level compiles the fastest and generates the most
debuggable code. -O2 means moderate level of optimization which enables most
optimizations. Read more about the options from{" "}
<a
className="link"
rel="noopener noreferrer"
target="_blank"
href="https://clang.llvm.org/docs/CommandGuide/clang.html#cmdoption-o0"
>
clang documentation
</a>
.
</span>
);
const StyledOptimizationText = styled(OptimizationText, {
color: "$mauve12 !important",
fontSize: "200px",
"span a.link": {
color: "red",
},
});
const CompilerSettings = () => {
const snap = useSnapshot(state);
return (
<Flex css={{ minWidth: 200, flexDirection: "column", gap: "$5" }}>
<Box>
<Label
style={{
flexDirection: "row",
display: "flex",
}}
>
Optimization level{" "}
<Popover
css={{
maxWidth: "240px",
lineHeight: "1.3",
a: {
color: "$purple11",
},
".dark &": {
backgroundColor: "$black !important",
".arrow": {
fill: "$colors$black",
},
},
}}
content={<StyledOptimizationText />}
>
<Flex
css={{
position: "relative",
top: "-1px",
ml: "$1",
backgroundColor: "$mauve8",
borderRadius: "$full",
cursor: "pointer",
width: "16px",
height: "16px",
alignItems: "center",
justifyContent: "center",
}}
>
?
</Flex>
</Popover>
</Label>
<ButtonGroup css={{ mt: "$2", fontFamily: "$monospace" }}>
<Button
css={{ fontFamily: "$monospace" }}
outline={snap.compileOptions.optimizationLevel !== "-O0"}
onClick={() => (state.compileOptions.optimizationLevel = "-O0")}
>
-O0
</Button>
<Button
css={{ fontFamily: "$monospace" }}
outline={snap.compileOptions.optimizationLevel !== "-O1"}
onClick={() => (state.compileOptions.optimizationLevel = "-O1")}
>
-O1
</Button>
<Button
css={{ fontFamily: "$monospace" }}
outline={snap.compileOptions.optimizationLevel !== "-O2"}
onClick={() => (state.compileOptions.optimizationLevel = "-O2")}
>
-O2
</Button>
<Button
css={{ fontFamily: "$monospace" }}
outline={snap.compileOptions.optimizationLevel !== "-O3"}
onClick={() => (state.compileOptions.optimizationLevel = "-O3")}
>
-O3
</Button>
<Button
css={{ fontFamily: "$monospace" }}
outline={snap.compileOptions.optimizationLevel !== "-O4"}
onClick={() => (state.compileOptions.optimizationLevel = "-O4")}
>
-O4
</Button>
<Button
css={{ fontFamily: "$monospace" }}
outline={snap.compileOptions.optimizationLevel !== "-Os"}
onClick={() => (state.compileOptions.optimizationLevel = "-Os")}
>
-Os
</Button>
</ButtonGroup>
</Box>
</Flex>
);
};
const Home: NextPage = () => {
const snap = useSnapshot(state);
@@ -34,7 +160,7 @@ const Home: NextPage = () => {
>
<main style={{ display: "flex", flex: 1, position: "relative" }}>
<HooksEditor />
{snap.files[snap.active]?.name?.split(".")?.[1].toLowerCase() ===
{snap.files[snap.active]?.name?.split(".")?.[1]?.toLowerCase() ===
"c" && (
<Hotkeys
keyName="command+b,ctrl+b"
@@ -42,12 +168,7 @@ const Home: NextPage = () => {
!snap.compiling && snap.files.length && compileCode(snap.active)
}
>
<Button
variant="primary"
uppercase
disabled={!snap.files.length}
isLoading={snap.compiling}
onClick={() => compileCode(snap.active)}
<Flex
css={{
position: "absolute",
bottom: "$4",
@@ -55,27 +176,82 @@ const Home: NextPage = () => {
alignItems: "center",
display: "flex",
cursor: "pointer",
gap: "$2",
}}
>
<Play weight="bold" size="16px" />
Compile to Wasm
</Button>
<Button
variant="primary"
uppercase
disabled={!snap.files.length}
isLoading={snap.compiling}
onClick={() => compileCode(snap.active)}
>
<Play weight="bold" size="16px" />
Compile to Wasm
</Button>
<Popover content={<CompilerSettings />}>
<Button variant="primary" css={{ px: "10px" }}>
<Gear size="16px" />
</Button>
</Popover>
</Flex>
</Hotkeys>
)}
{snap.files[snap.active]?.name?.split(".")?.[1]?.toLowerCase() ===
"js" && (
<Hotkeys
keyName="command+b,ctrl+b"
onKeyDown={() =>
!snap.compiling && snap.files.length && compileCode(snap.active)
}
>
<Flex
css={{
position: "absolute",
bottom: "$4",
left: "$4",
alignItems: "center",
display: "flex",
cursor: "pointer",
gap: "$2",
}}
>
<RunScript file={snap.files[snap.active]} />
</Flex>
</Hotkeys>
)}
</main>
<Box
css={{
display: "flex",
background: "$mauve1",
position: "relative",
}}
>
<LogBox
title="Development Log"
clearLog={() => (state.logs = [])}
logs={snap.logs}
/>
</Box>
<Flex css={{ width: "100%" }}>
<Flex
css={{
flex: 1,
background: "$mauve1",
position: "relative",
borderRight: "1px solid $mauve8",
}}
>
<LogBox
title="Development Log"
clearLog={() => (state.logs = [])}
logs={snap.logs}
/>
</Flex>
{snap.files[snap.active]?.name?.split(".")?.[1]?.toLowerCase() ===
"js" && (
<Flex
css={{
flex: 1,
}}
>
<LogBox
Icon={FileJs}
title="Script Log"
logs={snap.scriptLogs}
clearLog={() => (state.scriptLogs = [])}
/>
</Flex>
)}
</Flex>
</Split>
);
};

View File

@@ -1,23 +1,14 @@
import dynamic from "next/dynamic";
import { Play } from "phosphor-react";
import { FC, useCallback, useEffect, useState } from "react";
import Split from "react-split";
import { useSnapshot } from "valtio";
import {
Box,
Button,
Container,
Flex,
Input,
Select,
Tab,
Tabs,
Text,
} from "../../components";
import transactionsData from "../../content/transactions.json";
import { Box, Container, Flex, Tab, Tabs } from "../../components";
import Transaction from "../../components/Transaction";
import state from "../../state";
import { sendTransaction } from "../../state/actions";
import { getSplit, saveSplit } from "../../state/actions/persistSplits";
import { transactionsState, modifyTransaction } from "../../state";
import { useEffect, useState } from "react";
import { FileJs } from "phosphor-react";
import RunScript from '../../components/RunScript';
const DebugStream = dynamic(() => import("../../components/DebugStream"), {
ssr: false,
@@ -30,353 +21,47 @@ const Accounts = dynamic(() => import("../../components/Accounts"), {
ssr: false,
});
// type SelectOption<T> = { value: T, label: string };
type TxFields = Omit<
typeof transactionsData[0],
"Account" | "Sequence" | "TransactionType"
>;
type OtherFields = (keyof Omit<TxFields, "Destination">)[];
interface Props {
header?: string;
}
const Transaction: FC<Props> = ({ header, ...props }) => {
const snap = useSnapshot(state);
const transactionsOptions = transactionsData.map((tx) => ({
value: tx.TransactionType,
label: tx.TransactionType,
}));
const [selectedTransaction, setSelectedTransaction] = useState<
typeof transactionsOptions[0] | null
>(null);
const accountOptions = snap.accounts.map((acc) => ({
label: acc.name,
value: acc.address,
}));
const [selectedAccount, setSelectedAccount] = useState<
typeof accountOptions[0] | null
>(null);
const destAccountOptions = snap.accounts
.map((acc) => ({
label: acc.name,
value: acc.address,
}))
.filter((acc) => acc.value !== selectedAccount?.value);
const [selectedDestAccount, setSelectedDestAccount] = useState<
typeof destAccountOptions[0] | null
>(null);
const [txIsLoading, setTxIsLoading] = useState(false);
const [txIsDisabled, setTxIsDisabled] = useState(false);
const [txFields, setTxFields] = useState<TxFields>({});
useEffect(() => {
const transactionType = selectedTransaction?.value;
const account = snap.accounts.find(
(acc) => acc.address === selectedAccount?.value
);
if (!account || !transactionType || txIsLoading) {
setTxIsDisabled(true);
} else {
setTxIsDisabled(false);
}
}, [txIsLoading, selectedTransaction, selectedAccount, snap.accounts]);
useEffect(() => {
let _txFields: TxFields | undefined = transactionsData.find(
(tx) => tx.TransactionType === selectedTransaction?.value
);
if (!_txFields) return setTxFields({});
_txFields = { ..._txFields } as TxFields;
setSelectedDestAccount(null);
// @ts-ignore
delete _txFields.TransactionType;
// @ts-ignore
delete _txFields.Account;
// @ts-ignore
delete _txFields.Sequence;
setTxFields(_txFields);
}, [selectedTransaction, setSelectedDestAccount]);
const submitTest = useCallback(async () => {
const account = snap.accounts.find(
(acc) => acc.address === selectedAccount?.value
);
const TransactionType = selectedTransaction?.value;
if (!account || !TransactionType || txIsDisabled) return;
setTxIsLoading(true);
// setTxIsError(null)
try {
let options = { ...txFields };
options.Destination = selectedDestAccount?.value;
(Object.keys(options) as (keyof TxFields)[]).forEach((field) => {
let _value = options[field];
// convert currency
if (typeof _value === "object" && _value.type === "currency") {
if (+_value.value) {
options[field] = (+_value.value * 1000000 + "") as any;
} else {
options[field] = undefined; // 👇 💀
}
}
// handle type: `json`
if (typeof _value === "object" && _value.type === "json") {
if (typeof _value.value === "object") {
options[field] = _value.value as any;
} else {
try {
options[field] = JSON.parse(_value.value);
} catch (error) {
const message = `Input error for json field '${field}': ${
error instanceof Error ? error.message : ""
}`;
throw Error(message);
}
}
}
// delete unneccesary fields
if (!options[field]) {
delete options[field];
}
});
const logPrefix = header ? `${header.split(".")[0]}: ` : undefined;
await sendTransaction(
account,
{
TransactionType,
...options,
},
{ logPrefix }
);
} catch (error) {
console.error(error);
if (error instanceof Error) {
state.transactionLogs.push({ type: "error", message: error.message });
}
}
setTxIsLoading(false);
}, [
header,
selectedAccount?.value,
selectedDestAccount?.value,
selectedTransaction?.value,
snap.accounts,
txFields,
txIsDisabled,
]);
const resetState = useCallback(() => {
setSelectedAccount(null);
setSelectedDestAccount(null);
setSelectedTransaction(null);
setTxFields({});
setTxIsDisabled(false);
setTxIsLoading(false);
}, []);
const usualFields = ["TransactionType", "Amount", "Account", "Destination"];
const otherFields = Object.keys(txFields).filter(
(k) => !usualFields.includes(k)
) as OtherFields;
return (
<Box css={{ position: "relative", height: "calc(100% - 28px)" }} {...props}>
<Container
css={{
p: "$3 01",
fontSize: "$sm",
height: "calc(100% - 45px)",
}}
>
<Flex column fluid css={{ height: "100%", overflowY: "auto" }}>
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
mt: "1px",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
Transaction type:{" "}
</Text>
<Select
instanceId="transactionsType"
placeholder="Select transaction type"
options={transactionsOptions}
hideSelectedOptions
css={{ width: "70%" }}
value={selectedTransaction}
onChange={(tt) => setSelectedTransaction(tt as any)}
/>
</Flex>
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
Account:{" "}
</Text>
<Select
instanceId="from-account"
placeholder="Select your account"
css={{ width: "70%" }}
options={accountOptions}
value={selectedAccount}
onChange={(acc) => setSelectedAccount(acc as any)}
/>
</Flex>
{txFields.Amount !== undefined && (
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
Amount (XRP):{" "}
</Text>
<Input
value={txFields.Amount.value}
onChange={(e) =>
setTxFields({
...txFields,
Amount: { type: "currency", value: e.target.value },
})
}
css={{ width: "70%", flex: "inherit", height: "$9" }}
/>
</Flex>
)}
{txFields.Destination !== undefined && (
<Flex
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
Destination account:{" "}
</Text>
<Select
instanceId="to-account"
placeholder="Select the destination account"
css={{ width: "70%" }}
options={destAccountOptions}
value={selectedDestAccount}
isClearable
onChange={(acc) => setSelectedDestAccount(acc as any)}
/>
</Flex>
)}
{otherFields.map((field) => {
let _value = txFields[field];
let value = typeof _value === "object" ? _value.value : _value;
value =
typeof value === "object"
? JSON.stringify(value)
: value?.toLocaleString();
let isCurrency =
typeof _value === "object" && _value.type === "currency";
return (
<Flex
key={field}
row
fluid
css={{
justifyContent: "flex-end",
alignItems: "center",
mb: "$3",
pr: "1px",
}}
>
<Text muted css={{ mr: "$3" }}>
{field + (isCurrency ? " (XRP)" : "")}:{" "}
</Text>
<Input
value={value}
onChange={(e) =>
setTxFields({
...txFields,
[field]:
typeof _value === "object"
? { ..._value, value: e.target.value }
: e.target.value,
})
}
css={{ width: "70%", flex: "inherit", height: "$9" }}
/>
</Flex>
);
})}
</Flex>
</Container>
<Flex
row
css={{
justifyContent: "space-between",
position: "absolute",
left: 0,
bottom: 0,
width: "100%",
}}
>
<Button outline>VIEW AS JSON</Button>
<Flex row>
<Button onClick={resetState} outline css={{ mr: "$3" }}>
RESET
</Button>
<Button
variant="primary"
onClick={submitTest}
isLoading={txIsLoading}
disabled={txIsDisabled}
>
<Play weight="bold" size="16px" />
RUN TEST
</Button>
</Flex>
</Flex>
</Box>
);
};
const Test = () => {
// This and useEffect is here to prevent useLayoutEffect warnings from react-split
const [showComponent, setShowComponent] = useState(false);
const { transactionLogs } = useSnapshot(state);
const [tabHeaders, setTabHeaders] = useState<string[]>(["test1.json"]);
const { transactions, activeHeader } = useSnapshot(transactionsState);
const snap = useSnapshot(state);
useEffect(() => {
setShowComponent(true);
}, []);
if (!showComponent) {
return null;
}
const hasScripts = Boolean(
snap.files.filter(f => f.name.toLowerCase()?.endsWith(".js")).length
);
const renderNav = () => (
<Flex css={{ gap: "$3" }}>
{snap.files
.filter(f => f.name.endsWith(".js"))
.map(file => (
<RunScript file={file} key={file.name} />
))}
</Flex>
);
return (
<Container css={{ px: 0 }}>
<Split
direction="vertical"
sizes={getSplit("testVertical") || [50, 50]}
sizes={
hasScripts && getSplit("testVertical")?.length === 2
? [50, 20, 30]
: hasScripts
? [50, 20, 50]
: [50, 50]
}
gutterSize={4}
gutterAlign="center"
style={{ height: "calc(100vh - 60px)" }}
onDragEnd={(e) => saveSplit("testVertical", e)}
onDragEnd={e => saveSplit("testVertical", e)}
>
<Flex
row
@@ -398,23 +83,27 @@ const Test = () => {
width: "100%",
height: "100%",
}}
onDragEnd={(e) => saveSplit("testHorizontal", e)}
onDragEnd={e => saveSplit("testHorizontal", e)}
>
<Box css={{ width: "55%", px: "$2" }}>
<Tabs
label="Transaction"
activeHeader={activeHeader}
// TODO make header a required field
onChangeActive={(idx, header) => {
if (header) transactionsState.activeHeader = header;
}}
keepAllAlive
forceDefaultExtension
defaultExtension=".json"
onCreateNewTab={(name) =>
setTabHeaders(tabHeaders.concat(name))
}
onCloseTab={(index) =>
setTabHeaders(tabHeaders.filter((_, idx) => idx !== index))
defaultExtension="json"
allowedExtensions={["json"]}
onCreateNewTab={header => modifyTransaction(header, {})}
onCloseTab={(idx, header) =>
header && modifyTransaction(header, undefined)
}
>
{tabHeaders.map((header) => (
{transactions.map(({ header, state }) => (
<Tab key={header} header={header}>
<Transaction header={header} />
<Transaction state={state} header={header} />
</Tab>
))}
</Tabs>
@@ -424,8 +113,25 @@ const Test = () => {
</Box>
</Split>
</Flex>
<Flex row fluid>
{hasScripts ? (
<Flex
as="div"
css={{
borderTop: "1px solid $mauve6",
background: "$mauve1",
flexDirection: "column",
}}
>
<LogBox
Icon={FileJs}
title="Helper scripts"
logs={snap.scriptLogs}
clearLog={() => (state.scriptLogs = [])}
renderNav={renderNav}
/>
</Flex>
) : null}
<Flex>
<Split
direction="horizontal"
sizes={[50, 50]}

View File

@@ -27,41 +27,35 @@ export const names = [
* new account with 10 000 XRP. Hooks Testnet /newcreds endpoint
* is protected with CORS so that's why we did our own endpoint
*/
export const addFaucetAccount = async (showToast: boolean = false) => {
// Lets limit the number of faucet accounts to 5 for now
if (state.accounts.length > 5) {
return toast.error("You can only have maximum 6 accounts");
}
if (typeof window !== 'undefined') {
export const addFaucetAccount = async (name?: string, showToast: boolean = false) => {
if (typeof window === undefined) return
const toastId = showToast ? toast.loading("Creating account") : "";
const res = await fetch(`${window.location.origin}/api/faucet`, {
method: "POST",
});
const json: FaucetAccountRes | { error: string } = await res.json();
if ("error" in json) {
if (showToast) {
return toast.error(json.error, { id: toastId });
} else {
return;
}
const toastId = showToast ? toast.loading("Creating account") : "";
const res = await fetch(`${window.location.origin}/api/faucet`, {
method: "POST",
});
const json: FaucetAccountRes | { error: string } = await res.json();
if ("error" in json) {
if (showToast) {
return toast.error(json.error, { id: toastId });
} else {
if (showToast) {
toast.success("New account created", { id: toastId });
}
const currNames = state.accounts.map(acc => acc.name);
state.accounts.push({
name: names.filter(name => !currNames.includes(name))[0],
xrp: (json.xrp || 0 * 1000000).toString(),
address: json.address,
secret: json.secret,
sequence: 1,
hooks: [],
isLoading: false,
version: '2'
});
return;
}
} else {
if (showToast) {
toast.success("New account created", { id: toastId });
}
const currNames = state.accounts.map(acc => acc.name);
state.accounts.push({
name: name || names.filter(name => !currNames.includes(name))[0],
xrp: (json.xrp || 0 * 1000000).toString(),
address: json.address,
secret: json.secret,
sequence: 1,
hooks: [],
isLoading: false,
version: '2'
});
}
};

View File

@@ -14,19 +14,21 @@ import { ref } from "valtio";
*/
export const compileCode = async (activeId: number) => {
// Save the file to global state
saveFile(false);
saveFile(false, activeId);
if (!process.env.NEXT_PUBLIC_COMPILE_API_ENDPOINT) {
throw Error("Missing env!");
}
// Bail out if we're already compiling
if (state.compiling) {
// if compiling is ongoing return
// if compiling is ongoing return // TODO Inform user about it.
return;
}
// Set loading state to true
state.compiling = true;
state.logs = []
const file = state.files[activeId]
try {
file.containsErrors = false
const res = await fetch(process.env.NEXT_PUBLIC_COMPILE_API_ENDPOINT, {
method: "POST",
headers: {
@@ -35,11 +37,13 @@ export const compileCode = async (activeId: number) => {
body: JSON.stringify({
output: "wasm",
compress: true,
strip: state.compileOptions.strip,
files: [
{
type: "c",
name: state.files[activeId].name,
src: state.files[activeId].content,
options: state.compileOptions.optimizationLevel || '-O2',
name: file.name,
src: file.content,
},
],
}),
@@ -47,15 +51,15 @@ export const compileCode = async (activeId: number) => {
const json = await res.json();
state.compiling = false;
if (!json.success) {
state.logs.push({ type: "error", message: json.message });
const errors = [json.message]
if (json.tasks && json.tasks.length > 0) {
json.tasks.forEach((task: any) => {
if (!task.success) {
state.logs.push({ type: "error", message: task?.console });
errors.push(task?.console)
}
});
}
return toast.error(`Couldn't compile!`, { position: "bottom-center" });
throw errors
}
state.logs.push({
type: "success",
@@ -65,8 +69,9 @@ export const compileCode = async (activeId: number) => {
});
// Decode base64 encoded wasm that is coming back from the endpoint
const bufferData = await decodeBinary(json.output);
state.files[state.active].compiledContent = ref(bufferData);
state.files[state.active].lastCompiled = new Date();
file.compiledContent = ref(bufferData);
file.lastCompiled = new Date();
file.compiledValueSnapshot = file.content
// Import wabt from and create human readable version of wasm file and
// put it into state
import("wabt").then((wabt) => {
@@ -82,10 +87,23 @@ export const compileCode = async (activeId: number) => {
});
} catch (err) {
console.log(err);
state.logs.push({
type: "error",
message: "Error occured while compiling!",
});
if (err instanceof Array && typeof err[0] === 'string') {
err.forEach(message => {
state.logs.push({
type: "error",
message,
});
})
}
else {
state.logs.push({
type: "error",
message: "Something went wrong, check your connection try again later!",
});
}
state.compiling = false;
toast.error(`Error occurred while compiling!`, { position: "bottom-center" });
file.containsErrors = true
}
};

View File

@@ -4,19 +4,22 @@ import toast from "react-hot-toast";
import state, { IAccount } from "../index";
import calculateHookOn, { TTS } from "../../utils/hookOnCalculator";
import { SetHookData } from "../../components/SetHookDialog";
import { Link } from "../../components";
import { ref } from "valtio";
import estimateFee from "../../utils/estimateFee";
export const sha256 = async (string: string) => {
const utf8 = new TextEncoder().encode(string);
const hashBuffer = await crypto.subtle.digest('SHA-256', utf8);
const hashBuffer = await crypto.subtle.digest("SHA-256", utf8);
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray
.map((bytes) => bytes.toString(16).padStart(2, '0'))
.join('');
.map((bytes) => bytes.toString(16).padStart(2, "0"))
.join("");
return hashHex;
}
};
function toHex(str: string) {
var result = '';
var result = "";
for (var i = 0; i < str.length; i++) {
result += str.charCodeAt(i).toString(16);
}
@@ -47,29 +50,36 @@ function arrayBufferToHex(arrayBuffer?: ArrayBuffer | null) {
return result;
}
/* deployHook function turns the wasm binary into
* hex string, signs the transaction and deploys it to
* Hooks testnet.
*/
export const deployHook = async (account: IAccount & { name?: string }, data: SetHookData) => {
if (
!state.files ||
state.files.length === 0 ||
!state.files?.[state.active]?.compiledContent
) {
export const prepareDeployHookTx = async (
account: IAccount & { name?: string },
data: SetHookData
) => {
const activeFile = state.files[state.active]?.compiledContent
? state.files[state.active]
: state.files.filter((file) => file.compiledContent)[0];
if (!state.files || state.files.length === 0) {
return;
}
if (!state.files?.[state.active]?.compiledContent) {
if (!activeFile?.compiledContent) {
return;
}
if (!state.client) {
return;
}
const HookNamespace = (await sha256(data.HookNamespace)).toUpperCase();
const hookOnValues: (keyof TTS)[] = data.Invoke.map(tt => tt.value);
const hookOnValues: (keyof TTS)[] = data.Invoke.map((tt) => tt.value);
const { HookParameters } = data;
const filteredHookParameters = HookParameters.filter(hp => hp.HookParameter.HookParameterName && hp.HookParameter.HookParameterValue)?.map(aa => ({ HookParameter: { HookParameterName: toHex(aa.HookParameter.HookParameterName || ''), HookParameterValue: toHex(aa.HookParameter.HookParameterValue || '') } }));
const filteredHookParameters = HookParameters.filter(
(hp) =>
hp.HookParameter.HookParameterName && hp.HookParameter.HookParameterValue
)?.map((aa) => ({
HookParameter: {
HookParameterName: toHex(aa.HookParameter.HookParameterName || ""),
HookParameterValue: aa.HookParameter.HookParameterValue || "",
},
}));
// const filteredHookGrants = HookGrants.filter(hg => hg.HookGrant.Authorize || hg.HookGrant.HookHash).map(hg => {
// return {
// HookGrant: {
@@ -79,31 +89,56 @@ export const deployHook = async (account: IAccount & { name?: string }, data: Se
// }
// }
// });
if (typeof window !== "undefined") {
const tx = {
Account: account.address,
TransactionType: "SetHook",
Sequence: account.sequence,
Fee: "100000",
Fee: data.Fee,
Hooks: [
{
Hook: {
CreateCode: arrayBufferToHex(
state.files?.[state.active]?.compiledContent
activeFile?.compiledContent
).toUpperCase(),
HookOn: calculateHookOn(hookOnValues),
HookNamespace,
HookApiVersion: 0,
Flags: 1,
// ...(filteredHookGrants.length > 0 && { HookGrants: filteredHookGrants }),
...(filteredHookParameters.length > 0 && { HookParameters: filteredHookParameters }),
}
}
]
...(filteredHookParameters.length > 0 && {
HookParameters: filteredHookParameters,
}),
},
},
],
};
return tx;
}
};
/* deployHook function turns the wasm binary into
* hex string, signs the transaction and deploys it to
* Hooks testnet.
*/
export const deployHook = async (
account: IAccount & { name?: string },
data: SetHookData
) => {
if (typeof window !== "undefined") {
const activeFile = state.files[state.active]?.compiledContent
? state.files[state.active]
: state.files.filter((file) => file.compiledContent)[0];
state.deployValues[activeFile.name] = data;
const tx = await prepareDeployHookTx(account, data);
if (!tx) {
return;
}
if (!state.client) {
return;
}
const keypair = derive.familySeed(account.secret);
const { signedTransaction } = sign(tx, keypair);
const currentAccount = state.accounts.find(
(acc) => acc.address === account.address
@@ -112,8 +147,9 @@ export const deployHook = async (account: IAccount & { name?: string }, data: Se
currentAccount.isLoading = true;
}
let submitRes;
try {
submitRes = await state.client.send({
submitRes = await state.client?.send({
command: "submit",
tx_blob: signedTransaction,
});
@@ -125,19 +161,35 @@ export const deployHook = async (account: IAccount & { name?: string }, data: Se
});
state.deployLogs.push({
type: "success",
message: `[${submitRes.engine_result}] ${submitRes.engine_result_message} Validated ledger index: ${submitRes.validated_ledger_index}`,
message: ref(
<>
[{submitRes.engine_result}] {submitRes.engine_result_message}{" "}
Transaction hash:{" "}
<Link
as="a"
href={`https://${process.env.NEXT_PUBLIC_EXPLORER_URL}/${submitRes.tx_json?.hash}`}
target="_blank"
rel="noopener noreferrer"
>
{submitRes.tx_json?.hash}
</Link>
</>
),
// message: `[${submitRes.engine_result}] ${submitRes.engine_result_message} Validated ledger index: ${submitRes.validated_ledger_index}`,
});
} else {
state.deployLogs.push({
type: "error",
message: `[${submitRes.engine_result || submitRes.error}] ${submitRes.engine_result_message || submitRes.error_exception}`,
message: `[${submitRes.engine_result || submitRes.error}] ${
submitRes.engine_result_message || submitRes.error_exception
}`,
});
}
} catch (err) {
console.log(err);
state.deployLogs.push({
type: "error",
message: "Error occured while deploying",
message: "Error occurred while deploying",
});
}
if (currentAccount) {
@@ -155,7 +207,7 @@ export const deleteHook = async (account: IAccount & { name?: string }) => {
(acc) => acc.address === account.address
);
if (currentAccount?.isLoading || !currentAccount?.hooks.length) {
return
return;
}
if (typeof window !== "undefined") {
const tx = {
@@ -168,12 +220,20 @@ export const deleteHook = async (account: IAccount & { name?: string }) => {
Hook: {
CreateCode: "",
Flags: 1,
}
}
]
},
},
],
};
const keypair = derive.familySeed(account.secret);
try {
// Update tx Fee value with network estimation
const res = await estimateFee(tx, account);
tx["Fee"] = res?.base_fee ? res?.base_fee : "1000";
} catch (err) {
// use default value what you defined earlier
console.log(err);
}
const { signedTransaction } = sign(tx, keypair);
if (currentAccount) {
@@ -188,7 +248,7 @@ export const deleteHook = async (account: IAccount & { name?: string }) => {
});
if (submitRes.engine_result === "tesSUCCESS") {
toast.success('Hook deleted successfully ✅', { id: toastId })
toast.success("Hook deleted successfully ✅", { id: toastId });
state.deployLogs.push({
type: "success",
message: "Hook deleted successfully ✅",
@@ -199,18 +259,23 @@ export const deleteHook = async (account: IAccount & { name?: string }) => {
});
currentAccount.hooks = [];
} else {
toast.error(`${submitRes.engine_result_message || submitRes.error_exception}`, { id: toastId })
toast.error(
`${submitRes.engine_result_message || submitRes.error_exception}`,
{ id: toastId }
);
state.deployLogs.push({
type: "error",
message: `[${submitRes.engine_result || submitRes.error}] ${submitRes.engine_result_message || submitRes.error_exception}`,
message: `[${submitRes.engine_result || submitRes.error}] ${
submitRes.engine_result_message || submitRes.error_exception
}`,
});
}
} catch (err) {
console.log(err);
toast.error('Error occured while deleting hoook', { id: toastId })
toast.error("Error occurred while deleting hook", { id: toastId });
state.deployLogs.push({
type: "error",
message: "Error occured while deleting hook",
message: "Error occurred while deleting hook",
});
}
if (currentAccount) {
@@ -218,4 +283,4 @@ export const deleteHook = async (account: IAccount & { name?: string }) => {
}
return submitRes;
}
};
};

View File

@@ -8,11 +8,12 @@ export const downloadAsZip = async () => {
state.zipLoading = true
// TODO do something about file/gist loading state
const files = state.files.map(({ name, content }) => ({ name, content }));
const zipped = await createZip(files);
const wasmFiles = state.files.filter(i => i.compiledContent).map(({ name, compiledContent }) => ({ name: `${name}.wasm`, content: compiledContent }));
const zipped = await createZip([...files, ...wasmFiles]);
const zipFileName = guessZipFileName(files);
zipped.saveFile(zipFileName);
} catch (error) {
toast.error('Error occured while creating zip file, try again later')
toast.error('Error occurred while creating zip file, try again later')
} finally {
state.zipLoading = false
}

View File

@@ -19,20 +19,22 @@ export const fetchFiles = (gistId: string) => {
octokit
.request("GET /gists/{gist_id}", { gist_id: gistId })
.then(async res => {
if (!Object.values(templateFileIds).includes(gistId)) {
if (!Object.values(templateFileIds).map(v => v.id).includes(gistId)) {
return res
}
// in case of templates, fetch header file(s) and append to res
let resHeaderJson;
try {
const resHeader = await fetch(`${process.env.NEXT_PUBLIC_COMPILE_API_BASE_URL}/api/header-files`);
if (resHeader.ok) {
resHeaderJson = await resHeader.json();
const resHeaderJson = await resHeader.json()
const headerFiles: Record<string, { filename: string; content: string; language: string }> = {};
Object.entries(resHeaderJson).forEach(([key, value]) => {
const fname = `${key}.h`;
headerFiles[fname] = { filename: fname, content: value as string, language: 'C' }
})
const files = {
...res.data.files,
'hookapi.h': res.data.files?.['hookapi.h'] || { filename: 'hookapi.h', content: resHeaderJson.hookapi, language: 'C' },
'hookmacro.h': res.data.files?.['hookmacro.h'] || { filename: 'hookmacro.h', content: resHeaderJson.hookmacro, language: 'C' },
'sfcodes.h': res.data.files?.['sfcodes.h'] || { filename: 'sfcodes.h', content: resHeaderJson.sfcodes, language: 'C' },
...headerFiles
};
res.data.files = files;
}
@@ -58,6 +60,29 @@ export const fetchFiles = (gistId: string) => {
language: res.data.files?.[filename]?.language?.toLowerCase() || "",
content: res.data.files?.[filename]?.content || "",
}));
// Sort files so that the source files are first
// In case of other files leave the order as it its
files.sort((a, b) => {
const aBasename = a.name.split('.')?.[0];
const aCext = a.name?.toLowerCase().endsWith('.c');
const bBasename = b.name.split('.')?.[0];
const bCext = b.name?.toLowerCase().endsWith('.c');
// If a has c extension and b doesn't move a up
if (aCext && !bCext) {
return -1;
}
if (!aCext && bCext) {
return 1
}
// Otherwise fallback to default sorting based on basename
if (aBasename > bBasename) {
return 1;
}
if (bBasename > aBasename) {
return -1;
}
return 0;
})
state.loading = false;
if (files.length > 0) {
state.logs.push({
@@ -89,4 +114,4 @@ export const fetchFiles = (gistId: string) => {
return;
}
state.loading = false;
};
};

View File

@@ -5,7 +5,7 @@ import state from '../index';
import { names } from './addFaucetAccount';
// Adds test account to global state with secret key
export const importAccount = (secret: string) => {
export const importAccount = (secret: string, name?: string) => {
if (!secret) {
return toast.error("You need to add secret!");
}
@@ -19,7 +19,7 @@ export const importAccount = (secret: string) => {
if (err?.message) {
toast.error(err.message)
} else {
toast.error('Error occured while importing account')
toast.error('Error occurred while importing account')
}
return;
}
@@ -27,7 +27,7 @@ export const importAccount = (secret: string) => {
return toast.error(`Couldn't create account!`);
}
state.accounts.push({
name: names[state.accounts.length],
name: name || names[state.accounts.length],
address: account.address || "",
secret: account.secret.familySeed || "",
xrp: "0",

View File

@@ -2,16 +2,27 @@ import toast from "react-hot-toast";
import state from '../index';
// Saves the current editor content to global state
export const saveFile = (showToast: boolean = true) => {
export const saveFile = (showToast: boolean = true, activeId?: number) => {
const editorModels = state.editorCtx?.getModels();
const sought = '/' + state.files[state.active].name;
const currentModel = editorModels?.find((editorModel) => {
return editorModel.uri.path.endsWith(sought);
});
const file = state.files[activeId || state.active]
if (state.files.length > 0) {
state.files[state.active].content = currentModel?.getValue() || "";
file.content = currentModel?.getValue() || "";
}
if (showToast) {
toast.success("Saved successfully", { position: "bottom-center" });
}
};
export const saveAllFiles = () => {
const editorModels = state.editorCtx?.getModels();
state.files.forEach(file => {
const currentModel = editorModels?.find(model => model.uri.path.endsWith('/' + file.name))
if (currentModel) {
file.content = currentModel?.getValue() || '';
}
})
}

View File

@@ -20,14 +20,10 @@ export const sendTransaction = async (account: IAccount, txOptions: TransactionO
const { Fee = "1000", ...opts } = txOptions
const tx: TransactionOptions = {
Account: account.address,
Sequence: account.sequence, // TODO auto-fillable
Fee, // TODO auto-fillable
Sequence: account.sequence,
Fee, // TODO auto-fillable default
...opts
};
const currAcc = state.accounts.find(acc => acc.address === account.address);
if (currAcc) {
currAcc.sequence = account.sequence + 1;
}
const { logPrefix = '' } = options || {}
try {
const signedAccount = derive.familySeed(account.secret);
@@ -47,6 +43,10 @@ export const sendTransaction = async (account: IAccount, txOptions: TransactionO
message: `${logPrefix}[${response.error || response.engine_result}] ${response.error_exception || response.engine_result_message}`,
});
}
const currAcc = state.accounts.find(acc => acc.address === account.address);
if (currAcc && response.account_sequence_next) {
currAcc.sequence = response.account_sequence_next;
}
} catch (err) {
console.error(err);
state.transactionLogs.push({

View File

@@ -0,0 +1,23 @@
import { ref } from 'valtio';
import { AlertState, alertState } from "../../components/AlertDialog";
export const showAlert = (title: string, opts: Omit<Partial<AlertState>, 'title' | 'isOpen'> = {}) => {
const { body: _body, confirmPrefix: _confirmPrefix, ...rest } = opts
const body = (_body && typeof _body === 'object') ? ref(_body) : _body
const confirmPrefix = (_confirmPrefix && typeof _confirmPrefix === 'object') ? ref(_confirmPrefix) : _confirmPrefix
const nwState: AlertState = {
isOpen: true,
title,
body,
confirmPrefix,
cancelText: undefined,
confirmText: undefined,
onCancel: undefined,
onConfirm: undefined,
...rest,
}
Object.entries(nwState).forEach(([key, value]) => {
(alertState as any)[key] = value
})
}

View File

@@ -4,6 +4,7 @@ import { Octokit } from "@octokit/core";
import Router from "next/router";
import state from '../index';
import { saveAllFiles } from "./saveFile";
const octokit = new Octokit();
@@ -12,6 +13,7 @@ export const syncToGist = async (
session?: Session | null,
createNewGist?: boolean
) => {
saveAllFiles();
let files: Record<string, { filename: string; content: string }> = {};
state.gistLoading = true;

View File

@@ -1,20 +1,41 @@
// export const templateFileIds = {
// 'starter': '1d14e51e2e02dc0a508cb0733767a914', // TODO currently same as accept
// 'firewall': 'bcd6d0c0fcbe52545ddb802481ff9d26',
// 'notary': 'a789c75f591eeab7932fd702ed8cf9ea',
// 'carbon': '43925143fa19735d8c6505c34d3a6a47',
// 'peggy': 'ceaf352e2a65741341033ab7ef05c448',
// 'headers': '9b448e8a55fab11ef5d1274cb59f9cf3'
// }
import Carbon from "../../components/icons/Carbon";
import Firewall from "../../components/icons/Firewall";
import Notary from "../../components/icons/Notary";
import Peggy from "../../components/icons/Peggy";
import Starter from "../../components/icons/Starter";
export const templateFileIds = {
'starter': '1f7d2963d9e342ea092286115274f3e3',
'firewall': '70edec690f0de4dd315fad1f4f996d8c',
'notary': '3d5677768fe8a54c4f6317e185d9ba66',
'carbon': 'a9fbcaf1b816b198c7fc0f62962bebf2',
'doubler': '56b86174aeb70b2b48eee962bad3e355',
'peggy': 'd21298a37e1550b781682014762a567b',
'headers': '55f639bce59a49c58c45e663776b5138'
'starter': {
id: '9106f1fe60482d90475bfe8f1315affe',
name: 'Starter',
description: 'Just a basic starter with essential imports, just accepts any transaction coming through',
icon: Starter
},
'firewall': {
id: '1cc30f39c8a0b9c55b88c312669ca45e', // Forked
name: 'Firewall',
description: 'This Hook essentially checks a blacklist of accounts',
icon: Firewall
},
'notary': {
id: '87b6f5a8c2f5038fb0f20b8b510efa10', // Forked
name: 'Notary',
description: 'Collecting signatures for multi-sign transactions',
icon: Notary
},
'carbon': {
id: '5941c19dce3e147948f564e224553c02',
name: 'Carbon',
description: 'Send a percentage of sum to an address',
icon: Carbon
},
'peggy': {
id: '049784a83fa068faf7912f663f7b6471', // Forked
name: 'Peggy',
description: 'An oracle based stable coin hook',
icon: Peggy
},
}
export const apiHeaderFiles = ['hookapi.h', 'sfcodes.h', 'hookmacro.h']
export const apiHeaderFiles = ['hookapi.h', 'sfcodes.h', 'macro.h', 'extern.h', 'error.h'];

View File

@@ -1,6 +1,6 @@
import type monaco from "monaco-editor";
import { proxy, ref, subscribe } from "valtio";
import { devtools } from 'valtio/utils';
import { devtools, subscribeKey } from 'valtio/utils';
import { XrplClient } from "xrpl-client";
import { SplitSize } from "./actions/persistSplits";
@@ -13,9 +13,11 @@ export interface IFile {
name: string;
language: string;
content: string;
compiledValueSnapshot?: string
compiledContent?: ArrayBuffer | null;
compiledWatContent?: string | null;
lastCompiled?: Date
containsErrors?: boolean
}
export interface FaucetAccountRes {
@@ -35,18 +37,25 @@ export interface IAccount {
hooks: string[];
isLoading: boolean;
version?: string;
error?: {
message: string;
code: string;
} | null;
}
export interface ILog {
type: "error" | "warning" | "log" | "success";
message: string;
message: string | JSX.Element;
key?: string;
jsonData?: any,
timestamp?: string;
timestring?: string;
link?: string;
linkText?: string;
defaultCollapsed?: boolean
}
export type DeployValue = Record<IFile['name'], any>;
export interface IState {
files: IFile[];
gistId?: string | null;
@@ -61,6 +70,7 @@ export interface IState {
logs: ILog[];
deployLogs: ILog[];
transactionLogs: ILog[];
scriptLogs: ILog[];
editorCtx?: typeof monaco.editor;
editorSettings: {
tabSize: number;
@@ -73,6 +83,11 @@ export interface IState {
mainModalOpen: boolean;
mainModalShowed: boolean;
accounts: IAccount[];
compileOptions: {
optimizationLevel: '-O0' | '-O1' | '-O2' | '-O3' | '-O4' | '-Os';
strip: boolean
},
deployValues: DeployValue
}
// let localStorageState: null | string = null;
@@ -87,6 +102,7 @@ let initialState: IState = {
logs: [],
deployLogs: [],
transactionLogs: [],
scriptLogs: [],
editorCtx: undefined,
gistId: undefined,
gistOwner: undefined,
@@ -102,6 +118,11 @@ let initialState: IState = {
mainModalOpen: false,
mainModalShowed: false,
accounts: [],
compileOptions: {
optimizationLevel: '-O2',
strip: true
},
deployValues: {}
};
let localStorageAccounts: string | null = null;
@@ -147,15 +168,24 @@ if (process.env.NODE_ENV !== "production") {
}
if (typeof window !== "undefined") {
subscribe(state, () => {
const { accounts, active } = state;
subscribe(state.accounts, () => {
const { accounts } = state;
const accountsNoLoading = accounts.map(acc => ({ ...acc, isLoading: false }))
localStorage.setItem("hooksIdeAccounts", JSON.stringify(accountsNoLoading));
if (!state.files[active]?.compiledWatContent) {
state.activeWat = 0;
} else {
state.activeWat = active;
}
});
const updateActiveWat = () => {
const filename = state.files[state.active]?.name
const compiledFiles = state.files.filter(
file => file.compiledContent)
const idx = compiledFiles.findIndex(file => file.name === filename)
if (idx !== -1) state.activeWat = idx
}
subscribeKey(state, 'active', updateActiveWat)
subscribeKey(state, 'files', updateActiveWat)
}
export default state
export * from './transactions'

252
state/transactions.ts Normal file
View File

@@ -0,0 +1,252 @@
import { proxy } from 'valtio';
import { deepEqual } from '../utils/object';
import transactionsData from "../content/transactions.json";
import state from '.';
import { showAlert } from "../state/actions/showAlert";
import { parseJSON } from '../utils/json';
export type SelectOption = {
value: string;
label: string;
};
export interface TransactionState {
selectedTransaction: SelectOption | null;
selectedAccount: SelectOption | null;
selectedDestAccount: SelectOption | null;
txIsLoading: boolean;
txIsDisabled: boolean;
txFields: TxFields;
viewType: 'json' | 'ui',
editorValue?: string,
estimatedFee?: string
}
export type TxFields = Omit<
Partial<typeof transactionsData[0]>,
"Account" | "Sequence" | "TransactionType"
>;
export const defaultTransaction: TransactionState = {
selectedTransaction: null,
selectedAccount: null,
selectedDestAccount: null,
txIsLoading: false,
txIsDisabled: false,
txFields: {},
viewType: 'ui'
};
export const transactionsState = proxy({
transactions: [
{
header: "test1.json",
state: { ...defaultTransaction },
},
],
activeHeader: "test1.json"
});
/**
* Simple transaction state changer
* @param header Unique key and tab name for the transaction tab
* @param partialTx partial transaction state, `undefined` deletes the transaction
*
*/
export const modifyTransaction = (
header: string,
partialTx?: Partial<TransactionState>,
opts: { replaceState?: boolean } = {}
) => {
const tx = transactionsState.transactions.find(tx => tx.header === header);
if (partialTx === undefined) {
transactionsState.transactions = transactionsState.transactions.filter(
tx => tx.header !== header
);
return;
}
if (!tx) {
const state = {
...defaultTransaction,
...partialTx,
}
transactionsState.transactions.push({
header,
state,
});
return state;
}
if (opts.replaceState) {
const repTx: TransactionState = {
...defaultTransaction,
...partialTx,
}
tx.state = repTx
return repTx
}
Object.keys(partialTx).forEach(k => {
// Typescript mess here, but is definitely safe!
const s = tx.state as any;
const p = partialTx as any; // ? Make copy
if (!deepEqual(s[k], p[k])) s[k] = p[k];
});
return tx.state
};
// state to tx options
export const prepareTransaction = (data: any) => {
let options = { ...data };
(Object.keys(options)).forEach(field => {
let _value = options[field];
// convert xrp
if (_value && typeof _value === "object" && _value.$type === "xrp") {
if (+_value.$value) {
options[field] = (+_value.$value * 1000000 + "") as any;
} else {
options[field] = undefined; // 👇 💀
}
}
// handle type: `json`
if (_value && typeof _value === "object" && _value.$type === "json") {
if (typeof _value.$value === "object") {
options[field] = _value.$value;
} else {
try {
options[field] = JSON.parse(_value.$value);
} catch (error) {
const message = `Input error for json field '${field}': ${error instanceof Error ? error.message : ""
}`;
console.error(message)
options[field] = _value.$value
}
}
}
// delete unnecessary fields
if (!options[field]) {
delete options[field];
}
});
return options
}
// editor value to state
export const prepareState = (value: string, transactionType?: string) => {
const options = parseJSON(value);
if (!options) {
showAlert("Error!", {
body: "Cannot save editor with malformed transaction."
})
return
};
const { Account, TransactionType, Destination, ...rest } = options;
let tx: Partial<TransactionState> = {};
const schema = getTxFields(transactionType)
if (Account) {
const acc = state.accounts.find(acc => acc.address === Account);
if (acc) {
tx.selectedAccount = {
label: acc.name,
value: acc.address,
};
} else {
tx.selectedAccount = {
label: Account,
value: Account,
};
}
} else {
tx.selectedAccount = null;
}
if (TransactionType) {
tx.selectedTransaction = {
label: TransactionType,
value: TransactionType,
};
} else {
tx.selectedTransaction = null;
}
if (schema.Destination !== undefined) {
const dest = state.accounts.find(acc => acc.address === Destination);
if (dest) {
tx.selectedDestAccount = {
label: dest.name,
value: dest.address,
};
}
else if (Destination) {
tx.selectedDestAccount = {
label: Destination,
value: Destination,
};
}
else {
tx.selectedDestAccount = null
}
}
else if (Destination) {
rest.Destination = Destination
}
Object.keys(rest).forEach(field => {
const value = rest[field];
const schemaVal = schema[field as keyof TxFields]
const isXrp = typeof value !== 'object' && schemaVal && typeof schemaVal === 'object' && schemaVal.$type === 'xrp'
if (isXrp) {
rest[field] = {
$type: "xrp",
$value: +value / 1000000, // ! maybe use bigint?
};
} else if (typeof value === "object") {
rest[field] = {
$type: "json",
$value: value,
};
}
});
tx.txFields = rest;
return tx
}
export const getTxFields = (tt?: string) => {
const txFields: TxFields | undefined = transactionsData.find(
tx => tx.TransactionType === tt
);
if (!txFields) return {}
let _txFields = Object.keys(txFields)
.filter(
key => !["TransactionType", "Account", "Sequence"].includes(key)
)
.reduce<TxFields>(
(tf, key) => (
(tf[key as keyof TxFields] = (txFields as any)[key]), tf
),
{}
);
return _txFields
}
export { transactionsData }
export const transactionsOptions = transactionsData.map(tx => ({
value: tx.TransactionType,
label: tx.TransactionType,
}));
export const defaultTransactionType = transactionsOptions.find(tt => tt.value === 'Payment')

View File

@@ -9,16 +9,20 @@ import {
grass,
slate,
mauve,
mauveA,
amber,
purple,
green,
grayDark,
blueDark,
crimsonDark,
grassDark,
slateDark,
mauveDark,
mauveDarkA,
amberDark,
purpleDark,
greenDark,
red,
redDark,
} from "@radix-ui/colors";
@@ -41,12 +45,15 @@ export const {
...grass,
...slate,
...mauve,
...mauveA,
...amber,
...purple,
...green,
...red,
accent: "#9D2DFF",
background: "$gray1",
backgroundAlt: "$gray4",
backgroundOverlay: "$mauve2",
text: "$gray12",
textMuted: "$gray10",
primary: "$plum",
@@ -353,10 +360,13 @@ export const darkTheme = createTheme("dark", {
...grassDark,
...slateDark,
...mauveDark,
...mauveDarkA,
...amberDark,
...purpleDark,
...greenDark,
...redDark,
deep: "rgb(10, 10, 10)",
backgroundOverlay: "$mauve5"
// backgroundA: transparentize(0.1, grayDark.gray1),
},
});

21
styles/keyframes.ts Normal file
View File

@@ -0,0 +1,21 @@
import { keyframes } from '../stitches.config';
export const slideUpAndFade = keyframes({
"0%": { opacity: 0, transform: "translateY(2px)" },
"100%": { opacity: 1, transform: "translateY(0)" },
});
export const slideRightAndFade = keyframes({
"0%": { opacity: 0, transform: "translateX(-2px)" },
"100%": { opacity: 1, transform: "translateX(0)" },
});
export const slideDownAndFade = keyframes({
"0%": { opacity: 0, transform: "translateY(-2px)" },
"100%": { opacity: 1, transform: "translateY(0)" },
});
export const slideLeftAndFade = keyframes({
"0%": { opacity: 0, transform: "translateX(2px)" },
"100%": { opacity: 1, transform: "translateX(0)" },
});

24
utils/comment-parser.ts Normal file
View File

@@ -0,0 +1,24 @@
import { Spec, parse, Problem } from "comment-parser"
export const getTags = (source?: string): Spec[] => {
if (!source) return []
const blocks = parse(source)
const tags = blocks.reduce(
(acc, block) => acc.concat(block.tags),
[] as Spec[]
);
return tags
}
export const getErrors = (source?: string): Error | undefined => {
if (!source) return undefined
const blocks = parse(source)
const probs = blocks.reduce(
(acc, block) => acc.concat(block.problems),
[] as Problem[]
);
if (!probs.length) return undefined
const errors = probs.map(prob => `[${prob.code}] on line ${prob.line}: ${prob.message}`)
const error = new Error(`The following error(s) occurred while parsing JSDOC: \n${errors.join('\n')}`)
return error
}

30
utils/estimateFee.ts Normal file
View File

@@ -0,0 +1,30 @@
import toast from 'react-hot-toast';
import { derive, sign } from "xrpl-accountlib"
import state, { IAccount } from "../state"
const estimateFee = async (tx: Record<string, unknown>, account: IAccount, opts: { silent?: boolean } = {}): Promise<null | { base_fee: string, median_fee: string; minimum_fee: string; open_ledger_fee: string; }> => {
try {
const copyTx = JSON.parse(JSON.stringify(tx))
delete copyTx['SigningPubKey']
if (!copyTx.Fee) {
copyTx.Fee = '1000'
}
const keypair = derive.familySeed(account.secret)
const { signedTransaction } = sign(copyTx, keypair);
const res = await state.client?.send({ command: 'fee', tx_blob: signedTransaction })
if (res && res.drops) {
return res.drops;
}
return null
} catch (err) {
if (!opts.silent) {
console.error(err)
toast.error("Cannot estimate fee.") // ? Some better msg
}
return null
}
}
export default estimateFee

View File

@@ -6,4 +6,10 @@ export const guessZipFileName = (files: File[]) => {
let parts = (files.filter(f => f.name.endsWith('.c'))[0]?.name || 'hook').split('.')
parts = parts.length > 1 ? parts.slice(0, -1) : parts
return parts.join('')
}
export const capitalize = (value?: string) => {
if (!value) return '';
return value[0].toLocaleUpperCase() + value.slice(1);
}

View File

@@ -18,13 +18,18 @@ export const tts = {
ttDEPOSIT_PREAUTH: 19,
ttTRUST_SET: 20,
ttACCOUNT_DELETE: 21,
ttHOOK_SET: 22
ttHOOK_SET: 22,
ttNFTOKEN_MINT: 25,
ttNFTOKEN_BURN: 26,
ttNFTOKEN_CREATE_OFFER: 27,
ttNFTOKEN_CANCEL_OFFER: 28,
ttNFTOKEN_ACCEPT_OFFER: 29
};
export type TTS = typeof tts;
const calculateHookOn = (arr: (keyof TTS)[]) => {
let start = '0x00000000003ff5bf';
let start = '0x000000003e3ff5bf';
arr.forEach(n => {
let v = BigInt(start);
v ^= (BigInt(1) << BigInt(tts[n as keyof TTS]));

View File

@@ -18,4 +18,14 @@ export const extractJSON = (str?: string) => {
} while (firstClose > firstOpen);
firstOpen = str.indexOf('{', firstOpen + 1);
} while (firstOpen != -1);
}
export const parseJSON = (str?: string | null): any | undefined => {
if (!str) return undefined
try {
const parsed = JSON.parse(str);
return typeof parsed === "object" ? parsed : undefined;
} catch (error) {
return undefined;
}
}

View File

@@ -1,6 +1,5 @@
import { MessageConnection } from "@codingame/monaco-jsonrpc";
import { MonacoLanguageClient, ErrorAction, CloseAction, createConnection } from "@codingame/monaco-languageclient";
import Router from "next/router";
import normalizeUrl from "normalize-url";
import ReconnectingWebSocket from "reconnecting-websocket";
@@ -14,11 +13,7 @@ export function createLanguageClient(connection: MessageConnection): MonacoLangu
errorHandler: {
error: () => ErrorAction.Continue,
closed: () => {
if (Router.pathname.includes('/develop')) {
return CloseAction.Restart
} else {
return CloseAction.DoNotRestart
}
return CloseAction.DoNotRestart
}
},

24
utils/object.ts Normal file
View File

@@ -0,0 +1,24 @@
export const deepEqual = (object1: any, object2: any) => {
if (!isObject(object1) || !isObject(object2)) return object1 === object2
const keys1 = Object.keys(object1);
const keys2 = Object.keys(object2);
if (keys1.length !== keys2.length) {
return false;
}
for (const key of keys1) {
const val1 = object1[key];
const val2 = object2[key];
const areObjects = isObject(val1) && isObject(val2);
if (
areObjects && !deepEqual(val1, val2) ||
!areObjects && val1 !== val2
) {
return false;
}
}
return true;
}
export const isObject = (object: any) => {
return object != null && typeof object === 'object';
}

39
utils/schema.ts Normal file
View File

@@ -0,0 +1,39 @@
export const extractSchemaProps = <O extends object>(obj: O) =>
Object.entries(obj).reduce((prev, [key, val]) => {
const typeOf = <T>(arg: T) =>
arg instanceof Array
? "array"
: arg === null
? "undefined"
: typeof arg;
const value = (typeOf(val) === "object" && '$type' in val && '$value' in val) ? val?.$value : val;
const type = typeOf(value);
let schema: any = {
title: key,
type,
default: value,
}
if (typeOf(value) === 'array') {
const item = value[0] // TODO merge other item schema's into one
if (typeOf(item) !== 'object') {
schema.items = {
type: 'object',
properties: extractSchemaProps(item),
default: item
}
}
// TODO support primitive-value arrays
}
if (typeOf(value) === "object") {
schema.properties = extractSchemaProps(value)
}
return {
...prev,
[key]: schema,
};
}, {} as any);

View File

@@ -3,6 +3,7 @@ import hooksAccountConvBufLen from "./md/hooks-account-conv-buf-len.md";
import hooksAccountConvPure from "./md/hooks-account-conv-pure.md";
import hooksArrayBufLen from "./md/hooks-array-buf-len.md";
import hooksBurdenPrereq from "./md/hooks-burden-prereq.md";
import hooksControlStringArg from "./md/hooks-control-string-arg.md";
import hooksDetailBufLen from "./md/hooks-detail-buf-len.md";
import hooksDetailPrereq from "./md/hooks-detail-prereq.md";
import hooksEmitBufLen from "./md/hooks-emit-buf-len.md";
@@ -29,15 +30,18 @@ import hooksParamBufLen from "./md/hooks-param-buf-len.md";
import hooksParamSetBufLen from "./md/hooks-param-set-buf-len.md";
import hooksRaddrConvBufLen from "./md/hooks-raddr-conv-buf-len.md";
import hooksRaddrConvPure from "./md/hooks-raddr-conv-pure.md";
import hooksReleaseDefine from "./md/hooks-release-define.md";
import hooksReserveLimit from "./md/hooks-reserve-limit.md";
import hooksSlotHashBufLen from "./md/hooks-slot-hash-buf-len.md";
import hooksSlotKeyletBufLen from "./md/hooks-slot-keylet-buf-len.md";
import hooksSlotLimit from "./md/hooks-slot-limit.md";
import hooksSlotSubLimit from "./md/hooks-slot-sub-limit.md";
import hooksSlotTypeLimit from "./md/hooks-slot-type-limit.md";
import hooksSkipHashBufLen from "./md/hooks-skip-hash-buf-len.md";
import hooksStateBufLen from "./md/hooks-state-buf-len.md";
import hooksTransactionHashBufLen from "./md/hooks-transaction-hash-buf-len.md";
import hooksTransactionSlotLimit from "./md/hooks-transaction-slot-limit.md";
import hooksTrivialCbak from "./md/hooks-trivial-cbak.md";
import hooksValidateBufLen from "./md/hooks-validate-buf-len.md";
import hooksVerifyBufLen from "./md/hooks-verify-buf-len.md";
@@ -49,6 +53,7 @@ const docs: { [key: string]: string; } = {
"hooks-account-conv-pure": hooksAccountConvPure,
"hooks-array-buf-len": hooksArrayBufLen,
"hooks-burden-prereq": hooksBurdenPrereq,
"hooks-control-string-arg": hooksControlStringArg,
"hooks-detail-buf-len": hooksDetailBufLen,
"hooks-detail-prereq": hooksDetailPrereq,
"hooks-emit-buf-len": hooksEmitBufLen,
@@ -75,15 +80,18 @@ const docs: { [key: string]: string; } = {
"hooks-param-set-buf-len": hooksParamSetBufLen,
"hooks-raddr-conv-buf-len": hooksRaddrConvBufLen,
"hooks-raddr-conv-pure": hooksRaddrConvPure,
"hooks-release-define": hooksReleaseDefine,
"hooks-reserve-limit": hooksReserveLimit,
"hooks-slot-hash-buf-len": hooksSlotHashBufLen,
"hooks-slot-keylet-buf-len": hooksSlotKeyletBufLen,
"hooks-slot-limit": hooksSlotLimit,
"hooks-slot-sub-limit": hooksSlotSubLimit,
"hooks-slot-type-limit": hooksSlotTypeLimit,
"hooks-skip-hash-buf-len": hooksSkipHashBufLen,
"hooks-state-buf-len": hooksStateBufLen,
"hooks-transaction-hash-buf-len": hooksTransactionHashBufLen,
"hooks-transaction-slot-limit": hooksTransactionSlotLimit,
"hooks-trivial-cbak": hooksTrivialCbak,
"hooks-validate-buf-len": hooksValidateBufLen,
"hooks-verify-buf-len": hooksVerifyBufLen,
};

View File

@@ -0,0 +1,5 @@
# hooks-control-string-arg
Functions [accept](https://xrpl-hooks.readme.io/v2.0/reference/accept) and [rollback](https://xrpl-hooks.readme.io/v2.0/reference/rollback) take an optional string buffer stored outside the hook as its result message. This is useful for debugging but takes up space.
For a release version, this check warns about constant strings passed to `accept` and `rollback`.

View File

@@ -1,7 +1,7 @@
# hooks-entry-points
A Hook always implements and exports exactly two functions: [cbak](https://xrpl-hooks.readme.io/v2.0/reference/cbak) and [hook](https://xrpl-hooks.readme.io/v2.0/reference/hook).
A Hook always implements and exports a [hook](https://xrpl-hooks.readme.io/v2.0/reference/hook) function.
This check shows error on translation units that do not have them.
This check shows error on translation units that do not have it.
[Read more](https://xrpl-hooks.readme.io/v2.0/docs/compiling-hooks)

View File

@@ -1,5 +1,5 @@
# hooks-hash-buf-len
Functions [util_sha512h](https://xrpl-hooks.readme.io/v2.0/reference/util_sha512h), [hook_hash](https://xrpl-hooks.readme.io/v2.0/reference/hook_hash), [ledger_last_hash](https://xrpl-hooks.readme.io/v2.0/reference/ledger_last_hash) and [nonce](https://xrpl-hooks.readme.io/v2.0/reference/nonce) have fixed-size hash output.
Functions [util_sha512h](https://xrpl-hooks.readme.io/v2.0/reference/util_sha512h), [hook_hash](https://xrpl-hooks.readme.io/v2.0/reference/hook_hash), [ledger_last_hash](https://xrpl-hooks.readme.io/v2.0/reference/ledger_last_hash), [etxn_nonce](https://xrpl-hooks.readme.io/v2.0/reference/etxn_nonce) and [ledger_nonce](https://xrpl-hooks.readme.io/v2.0/reference/ledger_nonce) have fixed-size hash output.
This check warns about too-small size of their output buffer (if it's specified by a constant - variable parameter is ignored).

View File

@@ -0,0 +1,5 @@
# hooks-release-define
Hook users can define a `NDEBUG` macro to disable tracing calls at compile time - but for the definition to be effective, it must be defined before including hook-specific headers.
This check warns when `NDEBUG` is defined too late.

View File

@@ -0,0 +1,5 @@
# hooks-skip-hash-buf-len
Function [hook_skip](https://xrpl-hooks.readme.io/v2.0/reference/hook_skip) has fixed-size canonical hash input.
This check warns about invalid size of its input buffer (if it's specified by a constant - variable parameter is ignored).

View File

@@ -0,0 +1,7 @@
# hooks-trivial-cbak
A Hook may implement and export a [cbak](https://xrpl-hooks.readme.io/v2.0/reference/cbak) function.
But the function is optional, and defining it so that it doesn't do anything besides returning a constant value is unnecessary (except for some debugging scenarios) and just increases the hook size. This check warns about such implementations.
[Read more](https://xrpl-hooks.readme.io/v2.0/docs/compiling-hooks)

1318
yarn.lock

File diff suppressed because it is too large Load Diff