// Helper functions for interactive tutorials // Locale strings. TODO: maybe move these out to their own file. LOCALES = { en: { // Leave empty, use the keys provided (in English) by default }, ja: { "Address:": "アドレス:", "Secret:": "シード:", "Balance:": "残高:", "Complete all previous steps first": "前の手順をすべて完了して下さい", "Conection to the XRP Ledger required": "XRP Ledgerの接続が必要です", "Error:": "エラー:", "Populated this page's examples with these credentials.": "このページの例にこのアドレスとシードを入力しました。", "There was an error connecting to the Faucet. Please try again.": "テストネットワークFaucetにエラーが発生しました。もう一度試してください。", "Connecting...": "接続しています...", "Connection required": "接続が必要です", Connected: "接続されました", "Faucet returned an error:": "テストネットワークFaucetがこのエラーを返しました:", Validated: "検証済み", "Final Result:": "確定結果:", "(Still pending...)": "(まだ未決…)", "(None)": "(無)", "Prepared transaction:": "準備済みトランザクション:", "Failed to achieve consensus (final)": "検証済みレジャーには含まれません(決定結果)", "Preliminary result:": "予備結果:", Unknown: "不明", "Couldn't get a valid address/secret value. Check that the previous steps were completed successfully.": "有効なアドレスかシードの取得出来ませんでした。前の手順が完了しましたことを確認して下さい。", "Transaction hash:": "トランザクションハッシュ:", }, }; /** * Quick-n-dirty localization function. TODO: migrate to a real localization * library, such as https://github.com/wikimedia/jquery.i18n * @param {String} key The string to translate into this page's locale * @return {String} The translated string, if one is available, or the provided * key value if no translation is available. */ const current_locale = $("html").prop("lang"); function tl(key) { let mesg = LOCALES[current_locale][key]; if (typeof mesg === "undefined") { mesg = key; } return mesg; } /** * Convert a string of text into a "slug" form that is appropriate for use in * URLs, HTML id and class attributes, and similar. This matches the equivalent * function in filter_interactive_steps.py so that each step's ID is derived * from the given step_name in a consistent way. * @param {String} s The text (step_name or similar) to convert into a * @return {String} The "slug" version of the text, lowercase with no whitespace * and with most non-alphanumeric characters removed. */ function slugify(s) { const unacceptable_chars = /[^A-Za-z0-9._ ]+/g; const whitespace_regex = /\s+/g; s = s.replace(unacceptable_chars, ""); s = s.replace(whitespace_regex, "_"); s = s.toLowerCase(); if (!s) { s = "_"; } return s; } /** * Check whether a given step has been marked completed already. * @param {String} step_name The exact name of the step, as defined in the * start_step(step_name) function in the MD file. * @return {Boolean} Whether or not this step has been marked complete. */ function is_complete(step_name) { return is_complete_by_id(slugify(step_name)); } /** * Helper for is_complete. Also called directly in cases where we only have * the step_id and not the step_name (since that's a one-way conversion). * @param {String} step_id The slugified name of the step. * @return {Boolean} Whether or not this step has been marked complete. */ function is_complete_by_id(step_id) { return $(".bc-" + step_id).hasClass("done"); } /** * Mark a step as done in the breadcrumbs, mark the following step as active, * and enable buttons and such in the following step that have the * "previous-steps-required" class. * @param {String} step_name The exact name of the step, as defined in the * start_step(step_name) function in the MD file. */ function complete_step(step_name) { complete_step_by_id(slugify(step_name)); } /** * Helper for complete_step. Also called directly in cases where we only have * the step_id and not the step_name (since that's a one-way conversion). * @param {String} step_id The slugified name of the step. */ function complete_step_by_id(step_id) { $(".bc-" + step_id) .removeClass("active") .addClass("done"); $(".bc-" + step_id) .next() .removeClass("disabled") .addClass("active"); // Enable follow-up steps that require this step to be done first const next_ui = $(`#interactive-${step_id}`) .nextAll(".interactive-block") .eq(0) .find(".previous-steps-required"); next_ui.prop("title", ""); next_ui.prop("disabled", false); next_ui.removeClass("disabled"); } /** * Get the step_id of the interactive block that contains a given element. * @param {jQuery} jEl The jQuery result representing a single HTML element in * an interactive block. * @return {String} The step_id of the block that contains jEl. */ function get_block_id(jEl) { // Traverse up, then slice "interactive-" off the block's HTML ID return jEl.closest(".interactive-block").prop("id").slice(12); } /** * Pretty-print JSON with standard indentation. * @param {String, Object} j Either a JSON/JSON-like object, or a string containing JSON. */ function pretty_print(j) { try { return JSON.stringify(JSON.parse(j), null, 2); } catch (e) { // probably already decoded JSON return JSON.stringify(j, null, 2); } } /** * Disable buttons and such with the "previous-steps-required" class, and give * them an appropriate tooltip message. */ function disable_followup_steps() { $(".previous-steps-required").prop( "title", tl("Complete all previous steps first") ); $(".previous-steps-required").prop("disabled", true).addClass("disabled"); $(".connection-required").prop( "title", tl("Conection to the XRP Ledger required") ); $(".connection-required").prop("disabled", true).addClass("disabled"); } /** * Output an error message in the given area. * @param {jQuery} block The block where this error message should go. This * should be a parent element containing an element with * the "output-area" class. * @param {String} message The HTML contents to put inside the message. */ function show_error(block, message) { block.find(".output-area").html( `
${tl("Error:")} ${message}
` ); } /** * To be used with _snippets/interactive/tutorials/generate-step.md. * Adds an event to the "Generate" button to call the appropriate faucet * (Testnet or Devnet) and write the credentials to elements that later steps * can use in their examples. Also updates code samples in the current page to * use the generated credentials instead of the placeholder EXAMPLE_ADDR and * EXAMPLE_SECRET. */ const EXAMPLE_ADDR = "rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe"; const EXAMPLE_SECRET = "s████████████████████████████"; function setup_generate_step() { $("#generate-creds-button").click(async (event) => { const block = $(event.target).closest(".interactive-block"); block.find(".output-area").html(""); block.find(".loader").show(); // Get faucet URL (Testnet/Devnet/etc.) const faucet_url = $("#generate-creds-button").data("fauceturl"); try { const data = await call_faucet(faucet_url, undefined, event); block.find(".loader").hide(); block.find(".output-area").html(`${tl( "Populated this page's examples with these credentials." )}
` ); complete_step("Generate"); } catch (err) { block.find(".loader").hide(); block.find(".output-area").html( `${tl("Error:")} ${tl("There was an error connecting to the Faucet. Please try again.")}
` ); return; } }); } /** * Get the address from the Generate step (snippet), or display an error in * the relevant interactive block if it fails—usually because the user hasn't * cliked the "Get Credentials" button yet. * @return {String, undefined} The address, if available, or undefined if not */ function get_address(event) { const address = $("#use-address").text(); if (!address) { const block = $(event.target).closest(".interactive-block"); if (!block.length) { return; } show_error( block, tl( "Couldn't get a valid address/secret value. Check that the previous steps were completed successfully." ) ); } return address; } /** * Get the Wallet from the Generate step (snippet), or display an error in * the relevant interactive block if it fails—usually because the user hasn't * cliked the "Get Credentials" button yet. * @return {Wallet, undefined} The Wallet instance, if available, or undefined if not */ function get_wallet(event) { const secret = $("#use-secret").text(); if (!secret) { const block = $(event.target).closest(".interactive-block"); if (!block.length) { return; } show_error( block, tl( "Couldn't get a valid address/secret value. Check that the previous steps were completed successfully." ) ); } if (secret == EXAMPLE_SECRET) { const block = $(event.target).closest(".interactive-block"); if (!block.length) { return; } show_error( block, tl( "Can't use the example secret here. Check that the previous steps were completed successfully." ) ); return; } return xrpl.Wallet.fromSeed(secret); } /** * Helper for calling the Testnet/Devnet faucet. * @param {String} faucet_url The URL of the faucet to call, for example: * https://faucet.altnet.rippletest.net/accounts */ async function call_faucet(faucet_url, destination, event) { // Future feature: support the Faucet's optional xrpAmount param const block = $(event.target).closest(".interactive-block"); const tutorial_info = { path: window.location.pathname, button: event.target.id, step: block.data("stepnumber"), totalsteps: block.data("totalsteps"), }; const memo = { data: JSON.stringify(tutorial_info, null, 0), format: "application/json", // application/json // The MemoType decodes to a URL that explains the format of this memo type: // https://github.com/XRPLF/xrpl-dev-portal/blob/master/tool/INTERACTIVE_TUTORIALS_README.md type: "https://github.com/XRPLF/xrpl-dev-portal/blob/master/tool/INTERACTIVE_TUTORIALS_README.md", }; const body = {}; if (typeof destination != "undefined") { body["destination"] = destination; } body["memos"] = [memo]; const response = await fetch(faucet_url, { method: "POST", headers: { "Content-Type": "application/json; charset=utf-8", }, body: JSON.stringify(body), }); const data = await response.json(); if (!response.ok) { throw `${tl("Faucet returned an error:")} ${data.error}`; } return data; } /** * Tutorials' scripts should push functions to this array to have them run * automatically after connecting to the network. The scopes don't work out * right to use api.on("connect", callback) directly from the tutorials' unique * scripts because the api instance (specific to the network) is instantiated * by the setup_connect_step() in this file, below. */ window.after_connect = window.after_connect || []; /** * To be used with _snippets/interactive-tutorials/connect-step.md * Adds an event to the "Connect" button to connect to the appropriate * WebSocket server (Testnet, Devnet, maybe Mainnet for some cases). * Also adds an event to re-disable following steps if we get disconnected. */ function setup_connect_step() { if (!$("#connect-button").length) { console.debug("Connect step not included. Skipping related setup."); return; } const ws_url = $("#connect-button").data("wsurl"); if (!ws_url) { console.error( "Interactive Tutorial: WS URL not found. Did you set use_network?" ); return; } api = new xrpl.Client(ws_url); api.on("connected", async function () { $("#connection-status").text(tl("Connected")); $("#connect-button").prop("disabled", true); $("#loader-connect").hide(); $(".connection-required").prop("disabled", false); $(".connection-required").prop("title", ""); // Subscribe to ledger close events api.request({ command: "subscribe", streams: ["ledger"] }); complete_step("Connect"); }); api.on("disconnected", (code) => { $("#connection-status").text(tl("Disconnected") + " (" + code + ")"); $("#connect-button").prop("disabled", false); $(".connection-required").prop("disabled", true); $(".connection-required").prop("title", tl("Connection required")); disable_followup_steps(); }); $("#connect-button").click(async (event) => { $("#connection-status").text(tl("Connecting...")); $("#loader-connect").show(); await api.connect(); for (const fn of after_connect) { fn(); } }); } /** * To be used with _snippets/interactive-tutorials/wait-step.md * For each wait step in the page, set up a listener that checks for the * transaction shown in that step's "waiting-for-tx" field, as long as that * step's "tx-validation-status" field doesn't have a final result set. * These listeners do very little (just updating the latest validated ledger * index) until you activate one with activate_wait_step(step_name). * Requires xrpl.js to be loaded and instantiated as "api" first. */ function setup_wait_steps() { const wait_steps = $(".wait-step"); wait_steps.each(async (i, el) => { const wait_step = $(el); const explorer_url = wait_step.data("explorerurl"); const status_box = wait_step.find(".tx-validation-status"); api.on("ledgerClosed", async (ledger) => { // Update the latest validated ledger index in this step's table wait_step.find(".validated-ledger-version").text(ledger.ledger_index); if (!status_box.data("status_pending")) { // Before submission or after a final result. // Either way, nothing more to do here. return; } const transaction = wait_step.find(".waiting-for-tx").text().trim(); const min_ledger = parseInt( wait_step.find(".earliest-ledger-version").text() ); const max_ledger = parseInt(wait_step.find(".lastledgersequence").text()); let tx_response; try { tx_response = await api.request({ command: "tx", transaction, min_ledger, max_ledger, }); if (tx_response.result.validated) { status_box.html( `
${tl("(Still pending...)")}${tl("Prepared transaction:")}
${pretty_print(prepared)}`
);
const { tx_blob, hash } = wallet.sign(prepared);
block
.find(".output-area")
.append(
`${tl("Transaction hash:")} ${hash}
${tl("Preliminary result:")}
${pretty_print(prelim_result)}`
);
block.find(".loader").hide();
submit_step_id = get_block_id(block);
complete_step_by_id(submit_step_id);
if (wait_step_name) {
activate_wait_step(wait_step_name, prelim_result);
}
return prelim_result;
} catch (error) {
block.find(".loader").hide();
show_error(block, error);
}
}
$(document).ready(() => {
disable_followup_steps();
setup_generate_step();
setup_connect_step();
setup_wait_steps();
});