2021-03-11 05:43:45 +01:00
|
|
|
import $ from "jquery";
|
|
|
|
|
2021-02-28 00:39:51 +01:00
|
|
|
import * as channel from "../channel";
|
2020-10-23 02:43:28 +02:00
|
|
|
// Main JavaScript file for the integrations development panel at
|
2019-05-20 21:52:56 +02:00
|
|
|
// /devtools/integrations.
|
2019-04-14 15:28:19 +02:00
|
|
|
|
2021-05-10 07:02:14 +02:00
|
|
|
// Data segment: We lazy load the requested fixtures from the backend
|
2019-05-20 21:52:56 +02:00
|
|
|
// as and when required and then cache them here.
|
2019-04-14 15:28:19 +02:00
|
|
|
|
2020-02-12 06:51:18 +01:00
|
|
|
const loaded_fixtures = new Map();
|
2019-11-02 00:06:25 +01:00
|
|
|
const url_base = "/api/v1/external/";
|
2019-04-14 15:28:19 +02:00
|
|
|
|
2019-05-20 21:52:56 +02:00
|
|
|
// A map defining how to clear the various UI elements.
|
2019-11-02 00:06:25 +01:00
|
|
|
const clear_handlers = {
|
2019-04-14 15:28:19 +02:00
|
|
|
stream_name: "#stream_name",
|
|
|
|
topic_name: "#topic_name",
|
|
|
|
URL: "#URL",
|
2019-05-22 15:07:52 +02:00
|
|
|
results_notice: "#results_notice",
|
2020-07-20 22:18:43 +02:00
|
|
|
bot_name() {
|
2020-07-15 00:34:28 +02:00
|
|
|
$("#bot_name").children()[0].selected = true;
|
|
|
|
},
|
2020-07-20 22:18:43 +02:00
|
|
|
integration_name() {
|
2020-07-15 00:34:28 +02:00
|
|
|
$("#integration_name").children()[0].selected = true;
|
|
|
|
},
|
2020-07-20 22:18:43 +02:00
|
|
|
fixture_name() {
|
2020-07-15 00:34:28 +02:00
|
|
|
$("#fixture_name").empty();
|
|
|
|
},
|
2020-07-20 22:18:43 +02:00
|
|
|
fixture_body() {
|
2020-07-15 00:34:28 +02:00
|
|
|
$("#fixture_body")[0].value = "";
|
|
|
|
},
|
2020-07-20 22:18:43 +02:00
|
|
|
custom_http_headers() {
|
2020-07-15 00:34:28 +02:00
|
|
|
$("#custom_http_headers")[0].value = "{}";
|
|
|
|
},
|
2020-07-20 22:18:43 +02:00
|
|
|
results() {
|
2020-07-15 00:34:28 +02:00
|
|
|
$("#idp-results")[0].value = "";
|
|
|
|
},
|
2019-04-14 15:28:19 +02:00
|
|
|
};
|
|
|
|
|
|
|
|
function clear_elements(elements) {
|
2019-05-20 21:52:56 +02:00
|
|
|
// Supports strings (a selector to clear) or calling a function
|
|
|
|
// (for more complex logic).
|
2021-01-22 22:29:08 +01:00
|
|
|
for (const element_name of elements) {
|
2019-11-02 00:06:25 +01:00
|
|
|
const handler = clear_handlers[element_name];
|
2019-04-14 15:28:19 +02:00
|
|
|
if (typeof handler === "string") {
|
2019-11-02 00:06:25 +01:00
|
|
|
const element_object = $(handler)[0];
|
2019-04-14 15:28:19 +02:00
|
|
|
element_object.value = "";
|
|
|
|
element_object.innerHTML = "";
|
|
|
|
} else {
|
|
|
|
handler();
|
|
|
|
}
|
2021-01-22 22:29:08 +01:00
|
|
|
}
|
2019-04-14 15:28:19 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2019-05-20 21:52:56 +02:00
|
|
|
// Success/failure colors used for displaying results to the user.
|
2019-11-02 00:06:25 +01:00
|
|
|
const results_notice_level_to_color_map = {
|
2019-04-14 15:28:19 +02:00
|
|
|
warning: "#be1931",
|
|
|
|
success: "#085d44",
|
|
|
|
};
|
|
|
|
|
2019-05-22 15:07:52 +02:00
|
|
|
function set_results_notice(msg, level) {
|
2019-11-02 00:06:25 +01:00
|
|
|
const results_notice_field = $("#results_notice")[0];
|
2019-05-22 15:07:52 +02:00
|
|
|
results_notice_field.innerHTML = msg;
|
|
|
|
results_notice_field.style.color = results_notice_level_to_color_map[level];
|
2019-04-14 15:28:19 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
function get_api_key_from_selected_bot() {
|
|
|
|
return $("#bot_name").children("option:selected").val();
|
|
|
|
}
|
|
|
|
|
|
|
|
function get_selected_integration_name() {
|
|
|
|
return $("#integration_name").children("option:selected").val();
|
|
|
|
}
|
|
|
|
|
2019-05-16 20:29:18 +02:00
|
|
|
function get_fixture_format(fixture_name) {
|
2022-01-24 09:05:06 +01:00
|
|
|
return fixture_name.split(".").at(-1);
|
2019-05-16 20:29:18 +02:00
|
|
|
}
|
|
|
|
|
2019-05-16 20:29:18 +02:00
|
|
|
function get_custom_http_headers() {
|
2019-11-02 00:06:25 +01:00
|
|
|
let custom_headers = $("#custom_http_headers").val();
|
2019-05-16 20:29:18 +02:00
|
|
|
if (custom_headers !== "") {
|
|
|
|
// JSON.parse("") would trigger an error, as empty strings do not qualify as JSON.
|
|
|
|
try {
|
|
|
|
// Let JavaScript validate the JSON for us.
|
|
|
|
custom_headers = JSON.stringify(JSON.parse(custom_headers));
|
2020-10-07 10:18:48 +02:00
|
|
|
} catch {
|
2019-05-22 15:07:52 +02:00
|
|
|
set_results_notice("Custom HTTP headers are not in a valid JSON format.", "warning");
|
2020-09-24 07:50:36 +02:00
|
|
|
return undefined;
|
2019-05-16 20:29:18 +02:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return custom_headers;
|
|
|
|
}
|
|
|
|
|
2019-05-18 20:59:37 +02:00
|
|
|
function set_results(response) {
|
2019-05-20 21:52:56 +02:00
|
|
|
/* The backend returns the JSON responses for each of the
|
|
|
|
send_message actions included in our request (which is just 1 for
|
|
|
|
send, but usually is several for send all). We display these
|
|
|
|
responses to the user in the "results" panel.
|
2019-05-18 20:59:37 +02:00
|
|
|
|
|
|
|
The following is a bit messy, but it's a devtool, so ultimately OK */
|
2019-11-02 00:06:25 +01:00
|
|
|
const responses = response.responses;
|
2019-05-18 20:59:37 +02:00
|
|
|
|
2019-11-02 00:06:25 +01:00
|
|
|
let data = "Results:\n\n";
|
2021-01-22 22:29:08 +01:00
|
|
|
for (const response of responses) {
|
2019-05-18 20:59:37 +02:00
|
|
|
if (response.fixture_name !== undefined) {
|
|
|
|
data += "Fixture: " + response.fixture_name;
|
2021-05-10 07:02:14 +02:00
|
|
|
data += "\nStatus code: " + response.status_code;
|
2019-05-18 20:59:37 +02:00
|
|
|
} else {
|
2021-05-10 07:02:14 +02:00
|
|
|
data += "Status code: " + response.status_code;
|
2019-05-18 20:59:37 +02:00
|
|
|
}
|
|
|
|
data += "\nResponse: " + response.message + "\n\n";
|
2021-01-22 22:29:08 +01:00
|
|
|
}
|
2019-05-18 20:59:37 +02:00
|
|
|
$("#idp-results")[0].value = data;
|
|
|
|
}
|
|
|
|
|
2019-04-14 15:28:19 +02:00
|
|
|
function load_fixture_body(fixture_name) {
|
2019-05-20 21:52:56 +02:00
|
|
|
/* Given a fixture name, use the loaded_fixtures dictionary to set
|
|
|
|
* the fixture body field. */
|
2019-11-02 00:06:25 +01:00
|
|
|
const integration_name = get_selected_integration_name();
|
2020-02-12 06:51:18 +01:00
|
|
|
const fixture = loaded_fixtures.get(integration_name)[fixture_name];
|
2019-11-02 00:06:25 +01:00
|
|
|
let fixture_body = fixture.body;
|
|
|
|
const headers = fixture.headers;
|
2019-04-14 15:28:19 +02:00
|
|
|
if (fixture_body === undefined) {
|
2019-05-22 15:07:52 +02:00
|
|
|
set_results_notice("Fixture does not have a body.", "warning");
|
2019-04-14 15:28:19 +02:00
|
|
|
return;
|
|
|
|
}
|
2019-05-16 20:29:18 +02:00
|
|
|
if (get_fixture_format(fixture_name) === "json") {
|
2019-05-20 21:52:56 +02:00
|
|
|
// The 4 argument is pretty printer indentation.
|
|
|
|
fixture_body = JSON.stringify(fixture_body, null, 4);
|
2019-05-16 20:29:18 +02:00
|
|
|
}
|
2019-04-14 15:28:19 +02:00
|
|
|
$("#fixture_body")[0].value = fixture_body;
|
2019-06-07 20:06:06 +02:00
|
|
|
$("#custom_http_headers")[0].value = JSON.stringify(headers, null, 4);
|
2019-04-14 15:28:19 +02:00
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
function load_fixture_options(integration_name) {
|
2019-05-20 21:52:56 +02:00
|
|
|
/* Using the integration name and loaded_fixtures object to set
|
|
|
|
the fixture options for the fixture_names dropdown and also set
|
|
|
|
the fixture body to the first fixture by default. */
|
2019-11-02 00:06:25 +01:00
|
|
|
const fixtures_options_dropdown = $("#fixture_name")[0];
|
2020-02-12 06:51:18 +01:00
|
|
|
const fixtures_names = Object.keys(loaded_fixtures.get(integration_name)).sort();
|
2019-04-14 15:28:19 +02:00
|
|
|
|
2021-01-22 22:29:08 +01:00
|
|
|
for (const fixture_name of fixtures_names) {
|
2019-11-02 00:06:25 +01:00
|
|
|
const new_dropdown_option = document.createElement("option");
|
2019-04-14 15:28:19 +02:00
|
|
|
new_dropdown_option.value = fixture_name;
|
|
|
|
new_dropdown_option.innerHTML = fixture_name;
|
|
|
|
fixtures_options_dropdown.add(new_dropdown_option);
|
2021-01-22 22:29:08 +01:00
|
|
|
}
|
2019-04-14 15:28:19 +02:00
|
|
|
load_fixture_body(fixtures_names[0]);
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
function update_url() {
|
2019-05-20 21:52:56 +02:00
|
|
|
/* Construct the URL that the webhook should be targeting, using
|
|
|
|
the bot's API key and the integration name. The stream and topic
|
|
|
|
are both optional, and for the sake of completeness, it should be
|
2020-03-28 01:25:56 +01:00
|
|
|
noted that the topic is irrelevant without specifying the
|
2019-05-20 21:52:56 +02:00
|
|
|
stream. */
|
2019-11-02 00:06:25 +01:00
|
|
|
const url_field = $("#URL")[0];
|
2019-04-14 15:28:19 +02:00
|
|
|
|
2019-11-02 00:06:25 +01:00
|
|
|
const integration_name = get_selected_integration_name();
|
|
|
|
const api_key = get_api_key_from_selected_bot();
|
2019-04-14 15:28:19 +02:00
|
|
|
|
|
|
|
if (integration_name === "" || api_key === "") {
|
|
|
|
clear_elements(["URL"]);
|
|
|
|
} else {
|
2019-11-02 00:06:25 +01:00
|
|
|
let url = url_base + integration_name + "?api_key=" + api_key;
|
|
|
|
const stream_name = $("#stream_name").val();
|
2019-04-14 15:28:19 +02:00
|
|
|
if (stream_name !== "") {
|
|
|
|
url += "&stream=" + stream_name;
|
2019-11-02 00:06:25 +01:00
|
|
|
const topic_name = $("#topic_name").val();
|
2019-04-14 15:28:19 +02:00
|
|
|
if (topic_name !== "") {
|
|
|
|
url += "&topic=" + topic_name;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
url_field.value = url;
|
|
|
|
url_field.innerHTML = url;
|
|
|
|
}
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2021-05-10 07:02:14 +02:00
|
|
|
// API callers: These methods handle communicating with the Python backend API.
|
2019-04-14 15:28:19 +02:00
|
|
|
function handle_unsuccessful_response(response) {
|
|
|
|
try {
|
2019-11-02 00:06:25 +01:00
|
|
|
const status_code = response.statusCode().status;
|
2019-04-14 15:28:19 +02:00
|
|
|
response = JSON.parse(response.responseText);
|
2020-10-07 13:17:55 +02:00
|
|
|
set_results_notice(`Result: (${status_code}) ${response.msg}`, "warning");
|
2020-10-07 10:18:48 +02:00
|
|
|
} catch {
|
2019-05-20 21:52:56 +02:00
|
|
|
// If the response is not a JSON response, then it is probably
|
|
|
|
// Django returning an HTML response containing a stack trace
|
|
|
|
// with useful debugging information regarding the backend
|
|
|
|
// code.
|
2019-04-14 15:28:19 +02:00
|
|
|
document.write(response.responseText);
|
|
|
|
}
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
function get_fixtures(integration_name) {
|
2019-05-20 21:52:56 +02:00
|
|
|
/* Request fixtures from the backend for any integrations that we
|
|
|
|
don't already have fixtures cached in loaded_fixtures). */
|
2019-04-14 15:28:19 +02:00
|
|
|
if (integration_name === "") {
|
2020-07-15 00:34:28 +02:00
|
|
|
clear_elements([
|
|
|
|
"custom_http_headers",
|
|
|
|
"fixture_body",
|
|
|
|
"fixture_name",
|
|
|
|
"URL",
|
|
|
|
"results_notice",
|
|
|
|
]);
|
2019-04-14 15:28:19 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2020-02-12 06:51:18 +01:00
|
|
|
if (loaded_fixtures.has(integration_name)) {
|
2019-04-14 15:28:19 +02:00
|
|
|
load_fixture_options(integration_name);
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2019-05-20 21:52:56 +02:00
|
|
|
// We don't have the fixtures for this integration; fetch them
|
2020-10-23 02:43:28 +02:00
|
|
|
// from the backend. Relative URL pattern:
|
2020-09-12 04:18:53 +02:00
|
|
|
// /devtools/integrations/<integration_name>/fixtures
|
2019-04-14 15:28:19 +02:00
|
|
|
channel.get({
|
|
|
|
url: "/devtools/integrations/" + integration_name + "/fixtures",
|
2020-07-20 22:18:43 +02:00
|
|
|
success(response) {
|
2020-02-12 06:51:18 +01:00
|
|
|
loaded_fixtures.set(integration_name, response.fixtures);
|
2019-04-14 15:28:19 +02:00
|
|
|
load_fixture_options(integration_name);
|
|
|
|
return;
|
|
|
|
},
|
|
|
|
error: handle_unsuccessful_response,
|
|
|
|
});
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
function send_webhook_fixture_message() {
|
2019-05-20 21:52:56 +02:00
|
|
|
/* Make sure that the user is sending valid JSON in the fixture
|
|
|
|
body and that the URL is not empty. Then simply send the fixture
|
|
|
|
body to the target URL. */
|
|
|
|
|
|
|
|
// Note: If the user just logged in to a different Zulip account
|
|
|
|
// using another tab while the integrations dev panel is open,
|
|
|
|
// then the csrf token that we have stored in the hidden input
|
|
|
|
// element would have been expired, leading to an error message
|
|
|
|
// when the user tries to send the fixture body.
|
2019-11-02 00:06:25 +01:00
|
|
|
const csrftoken = $("#csrftoken").val();
|
2019-04-14 15:28:19 +02:00
|
|
|
|
2019-11-02 00:06:25 +01:00
|
|
|
const url = $("#URL").val();
|
2019-04-14 15:28:19 +02:00
|
|
|
if (url === "") {
|
2019-05-22 15:07:52 +02:00
|
|
|
set_results_notice("URL can't be empty.", "warning");
|
2019-04-14 15:28:19 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2019-11-02 00:06:25 +01:00
|
|
|
let body = $("#fixture_body").val();
|
|
|
|
const fixture_name = $("#fixture_name").val();
|
|
|
|
let is_json = false;
|
2019-05-16 20:29:18 +02:00
|
|
|
if (fixture_name && get_fixture_format(fixture_name) === "json") {
|
|
|
|
try {
|
|
|
|
// Let JavaScript validate the JSON for us.
|
|
|
|
body = JSON.stringify(JSON.parse(body));
|
|
|
|
is_json = true;
|
2020-10-07 10:18:48 +02:00
|
|
|
} catch {
|
2019-05-22 15:07:52 +02:00
|
|
|
set_results_notice("Invalid JSON in fixture body.", "warning");
|
2019-05-16 20:29:18 +02:00
|
|
|
return;
|
|
|
|
}
|
2019-04-14 15:28:19 +02:00
|
|
|
}
|
|
|
|
|
2019-11-02 00:06:25 +01:00
|
|
|
const custom_headers = get_custom_http_headers();
|
2019-05-16 09:23:15 +02:00
|
|
|
|
2019-04-14 15:28:19 +02:00
|
|
|
channel.post({
|
|
|
|
url: "/devtools/integrations/check_send_webhook_fixture_message",
|
2020-07-20 22:18:43 +02:00
|
|
|
data: {url, body, custom_headers, is_json},
|
|
|
|
beforeSend(xhr) {
|
2020-07-15 00:34:28 +02:00
|
|
|
xhr.setRequestHeader("X-CSRFToken", csrftoken);
|
|
|
|
},
|
2020-07-20 22:18:43 +02:00
|
|
|
success(response) {
|
2019-05-20 21:52:56 +02:00
|
|
|
// If the previous fixture body was sent successfully,
|
|
|
|
// then we should change the success message up a bit to
|
|
|
|
// let the user easily know that this fixture body was
|
|
|
|
// also sent successfully.
|
2019-05-18 20:59:37 +02:00
|
|
|
set_results(response);
|
2019-05-22 15:07:52 +02:00
|
|
|
if ($("#results_notice")[0].innerHTML === "Success!") {
|
|
|
|
set_results_notice("Success!!!", "success");
|
2019-04-14 15:28:19 +02:00
|
|
|
} else {
|
2019-05-22 15:07:52 +02:00
|
|
|
set_results_notice("Success!", "success");
|
2019-04-14 15:28:19 +02:00
|
|
|
}
|
|
|
|
return;
|
|
|
|
},
|
|
|
|
error: handle_unsuccessful_response,
|
|
|
|
});
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2019-05-16 20:29:18 +02:00
|
|
|
function send_all_fixture_messages() {
|
|
|
|
/* Send all fixture messages for a given integration. */
|
2019-11-02 00:06:25 +01:00
|
|
|
const url = $("#URL").val();
|
|
|
|
const integration = get_selected_integration_name();
|
2019-05-16 20:29:18 +02:00
|
|
|
if (integration === "") {
|
2019-05-22 15:07:52 +02:00
|
|
|
set_results_notice("You have to select an integration first.");
|
2019-05-16 20:29:18 +02:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2019-11-02 00:06:25 +01:00
|
|
|
const csrftoken = $("#csrftoken").val();
|
2019-05-16 20:29:18 +02:00
|
|
|
channel.post({
|
|
|
|
url: "/devtools/integrations/send_all_webhook_fixture_messages",
|
2020-07-20 22:18:43 +02:00
|
|
|
data: {url, integration_name: integration},
|
|
|
|
beforeSend(xhr) {
|
2020-07-15 00:34:28 +02:00
|
|
|
xhr.setRequestHeader("X-CSRFToken", csrftoken);
|
|
|
|
},
|
2020-07-20 22:18:43 +02:00
|
|
|
success(response) {
|
2020-07-15 00:34:28 +02:00
|
|
|
set_results(response);
|
|
|
|
},
|
2019-05-16 20:29:18 +02:00
|
|
|
error: handle_unsuccessful_response,
|
|
|
|
});
|
|
|
|
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
2019-04-14 15:28:19 +02:00
|
|
|
// Initialization
|
2020-07-02 01:45:54 +02:00
|
|
|
$(() => {
|
2020-07-15 00:34:28 +02:00
|
|
|
clear_elements([
|
|
|
|
"stream_name",
|
|
|
|
"topic_name",
|
|
|
|
"URL",
|
|
|
|
"bot_name",
|
|
|
|
"integration_name",
|
|
|
|
"fixture_name",
|
|
|
|
"custom_http_headers",
|
|
|
|
"fixture_body",
|
|
|
|
"results_notice",
|
|
|
|
"results",
|
|
|
|
]);
|
2019-04-14 15:28:19 +02:00
|
|
|
|
2019-05-16 15:40:53 +02:00
|
|
|
$("#stream_name")[0].value = "Denmark";
|
2021-05-10 07:02:14 +02:00
|
|
|
$("#topic_name")[0].value = "Integrations testing";
|
2019-05-16 15:40:53 +02:00
|
|
|
|
2019-11-02 00:06:25 +01:00
|
|
|
const potential_default_bot = $("#bot_name")[0][1];
|
2019-04-14 15:28:19 +02:00
|
|
|
if (potential_default_bot !== undefined) {
|
|
|
|
potential_default_bot.selected = true;
|
|
|
|
}
|
|
|
|
|
2020-07-20 21:26:58 +02:00
|
|
|
$("#integration_name").on("change", function () {
|
2019-05-22 15:07:52 +02:00
|
|
|
clear_elements(["custom_http_headers", "fixture_body", "fixture_name", "results_notice"]);
|
2019-11-02 00:06:25 +01:00
|
|
|
const integration_name = $(this).children("option:selected").val();
|
2019-04-14 15:28:19 +02:00
|
|
|
get_fixtures(integration_name);
|
|
|
|
update_url();
|
|
|
|
return;
|
|
|
|
});
|
|
|
|
|
2020-07-20 21:26:58 +02:00
|
|
|
$("#fixture_name").on("change", function () {
|
2019-05-22 15:07:52 +02:00
|
|
|
clear_elements(["fixture_body", "results_notice"]);
|
2019-11-02 00:06:25 +01:00
|
|
|
const fixture_name = $(this).children("option:selected").val();
|
2019-04-14 15:28:19 +02:00
|
|
|
load_fixture_body(fixture_name);
|
|
|
|
return;
|
|
|
|
});
|
|
|
|
|
2020-07-20 21:26:58 +02:00
|
|
|
$("#send_fixture_button").on("click", () => {
|
2019-04-14 15:28:19 +02:00
|
|
|
send_webhook_fixture_message();
|
|
|
|
return;
|
|
|
|
});
|
|
|
|
|
2020-07-20 21:26:58 +02:00
|
|
|
$("#send_all_fixtures_button").on("click", () => {
|
2019-05-22 15:07:52 +02:00
|
|
|
clear_elements(["results_notice"]);
|
2019-05-16 20:29:18 +02:00
|
|
|
send_all_fixture_messages();
|
|
|
|
return;
|
|
|
|
});
|
|
|
|
|
2020-07-20 21:26:58 +02:00
|
|
|
$("#bot_name").on("change", update_url);
|
2019-04-14 15:28:19 +02:00
|
|
|
|
2020-07-20 21:26:58 +02:00
|
|
|
$("#stream_name").on("change", update_url);
|
2019-04-14 15:28:19 +02:00
|
|
|
|
2020-07-20 21:26:58 +02:00
|
|
|
$("#topic_name").on("change", update_url);
|
2019-04-14 15:28:19 +02:00
|
|
|
});
|