studio_tools.js 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. /**
  2. * Copyright (c) UNA, Inc - https://una.io
  3. * MIT License - https://opensource.org/licenses/MIT
  4. *
  5. * @defgroup Analytics Analytics
  6. * @ingroup UnaModules
  7. *
  8. * @{
  9. */
  10. /**
  11. * Studio tools of Analytics, for site administrators: the API playground, new analytics
  12. * pages, and the tracker checks in Setup. Every action is a POST with the form token; the
  13. * server renders any HTML, and report data is shown as text, never as HTML.
  14. */
  15. function BxAnalyticsStudioTools(oOptions) {
  16. this._sActionUrl = oOptions.sActionUrl;
  17. this._aStrings = oOptions.aStrings || {};
  18. // a report's response is shown up to this many characters
  19. this._iMaxOutput = 1024 * 1024;
  20. var $this = this;
  21. if (document.readyState === 'loading')
  22. document.addEventListener('DOMContentLoaded', function () { $this.init(); });
  23. else
  24. this.init();
  25. }
  26. BxAnalyticsStudioTools.prototype.init = function () {
  27. var $this = this;
  28. document.addEventListener('click', function (oEvent) {
  29. var oTarget;
  30. if ((oTarget = oEvent.target.closest('[data-bx-analytics-copy]'))) {
  31. $this.copy(oTarget);
  32. return;
  33. }
  34. if ((oTarget = oEvent.target.closest('[data-bx-analytics-setup]')))
  35. $this.runSetup(oTarget);
  36. });
  37. document.addEventListener('submit', function (oEvent) {
  38. var oForm;
  39. if ((oForm = oEvent.target.closest('[data-bx-analytics-api-form]'))) {
  40. oEvent.preventDefault();
  41. $this.runApi(oForm);
  42. return;
  43. }
  44. if ((oForm = oEvent.target.closest('[data-bx-analytics-dashboard-form]'))) {
  45. oEvent.preventDefault();
  46. $this.createDashboard(oForm);
  47. }
  48. });
  49. var oApi = document.querySelector('[data-bx-analytics-api-form]');
  50. if (oApi) {
  51. var fUpdate = function () { $this.updateApi(oApi); };
  52. oApi.querySelector('[data-bx-analytics-api-report]').addEventListener('change', fUpdate);
  53. oApi.querySelector('[data-bx-analytics-api-params]').addEventListener('input', fUpdate);
  54. fUpdate();
  55. }
  56. };
  57. /**
  58. * Copy the text of the element a button names, and say so for a moment.
  59. */
  60. BxAnalyticsStudioTools.prototype.copy = function (oButton) {
  61. var oSource = document.getElementById(oButton.getAttribute('data-bx-analytics-copy'));
  62. if (!oSource)
  63. return;
  64. var sText = oSource.textContent;
  65. var fDone = function () {
  66. var sLabel = oButton.getAttribute('data-bx-analytics-copied');
  67. if (!sLabel)
  68. return;
  69. if (!oButton._bxAnalyticsLabel)
  70. oButton._bxAnalyticsLabel = oButton.textContent;
  71. oButton.textContent = sLabel;
  72. clearTimeout(oButton._bxAnalyticsTimer);
  73. oButton._bxAnalyticsTimer = setTimeout(function () {
  74. oButton.textContent = oButton._bxAnalyticsLabel;
  75. }, 2000);
  76. };
  77. if (navigator.clipboard && window.isSecureContext) {
  78. navigator.clipboard.writeText(sText).then(fDone, function () {
  79. if (BxAnalyticsStudioTools.copyFallback(sText))
  80. fDone();
  81. });
  82. return;
  83. }
  84. if (BxAnalyticsStudioTools.copyFallback(sText))
  85. fDone();
  86. };
  87. /**
  88. * Copy without the Clipboard API (a page served over plain HTTP).
  89. */
  90. BxAnalyticsStudioTools.copyFallback = function (sText) {
  91. var oArea = document.createElement('textarea');
  92. oArea.value = sText;
  93. oArea.setAttribute('readonly', '');
  94. oArea.style.position = 'fixed';
  95. oArea.style.opacity = '0';
  96. document.body.appendChild(oArea);
  97. oArea.select();
  98. var bCopied = false;
  99. try {
  100. bCopied = document.execCommand('copy');
  101. } catch (e) {
  102. bCopied = false;
  103. }
  104. document.body.removeChild(oArea);
  105. return bCopied;
  106. };
  107. /**
  108. * The parameters typed in the playground: an object, or null when it isn't valid JSON.
  109. */
  110. BxAnalyticsStudioTools.prototype.getApiParams = function (oForm) {
  111. var sValue = oForm.querySelector('[data-bx-analytics-api-params]').value.trim();
  112. if (sValue === '')
  113. return {};
  114. try {
  115. var oParams = JSON.parse(sValue);
  116. return oParams && typeof oParams === 'object' && !Array.isArray(oParams) ? oParams : null;
  117. } catch (e) {
  118. return null;
  119. }
  120. };
  121. BxAnalyticsStudioTools.prototype.getApiCatalog = function (oForm) {
  122. var oTool = oForm.closest('.bx-analytics-api');
  123. var oJson = oTool ? oTool.querySelector('[data-bx-analytics-api-catalog]') : null;
  124. if (!oJson)
  125. return {root: '/', module: 'bx_analytics'};
  126. if (!oJson._bxAnalyticsData) {
  127. try {
  128. oJson._bxAnalyticsData = JSON.parse(oJson.textContent);
  129. } catch (e) {
  130. oJson._bxAnalyticsData = {root: '/', module: 'bx_analytics'};
  131. }
  132. }
  133. return oJson._bxAnalyticsData;
  134. };
  135. /**
  136. * Show the chosen report's parameters and write the call three ways.
  137. */
  138. BxAnalyticsStudioTools.prototype.updateApi = function (oForm) {
  139. var sReport = oForm.querySelector('[data-bx-analytics-api-report]').value;
  140. var oInput = oForm.querySelector('[data-bx-analytics-api-params]');
  141. var oParams = this.getApiParams(oForm);
  142. var oCatalog = this.getApiCatalog(oForm);
  143. oForm.querySelectorAll('[data-bx-analytics-api-doc]').forEach(function (oDoc) {
  144. oDoc.hidden = oDoc.getAttribute('data-bx-analytics-api-doc') !== sReport;
  145. });
  146. if (oParams === null) {
  147. oInput.setAttribute('aria-invalid', 'true');
  148. return;
  149. }
  150. oInput.removeAttribute('aria-invalid');
  151. var sJson = JSON.stringify(oParams);
  152. var aCalls = {
  153. 'bx-analytics-api-srv': "bx_srv('" + oCatalog.module + "', 'get_report', [" + this.toPhp(sReport) + ', ' + this.toPhp(oParams) + ']);',
  154. 'bx-analytics-api-url': oCatalog.root + 'api.php?r=' + oCatalog.module + '/get_report&params=' + encodeURIComponent(JSON.stringify([sReport, oParams])),
  155. 'bx-analytics-api-neo': '/api.php?r=' + oCatalog.module + '/get_report&params[]=' + encodeURIComponent(sReport) + '&params[]=' + encodeURIComponent(sJson)
  156. };
  157. Object.keys(aCalls).forEach(function (sId) {
  158. var oCode = document.querySelector('#' + sId + ' code');
  159. if (oCode)
  160. oCode.textContent = aCalls[sId];
  161. });
  162. };
  163. /**
  164. * A JSON value as a PHP literal: short arrays, single-quoted strings.
  165. */
  166. BxAnalyticsStudioTools.prototype.toPhp = function (mixedValue) {
  167. var $this = this;
  168. if (mixedValue === null || mixedValue === undefined)
  169. return 'null';
  170. if (typeof mixedValue === 'boolean')
  171. return mixedValue ? 'true' : 'false';
  172. if (typeof mixedValue === 'number')
  173. return String(mixedValue);
  174. if (typeof mixedValue === 'string')
  175. return "'" + mixedValue.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + "'";
  176. if (Array.isArray(mixedValue))
  177. return '[' + mixedValue.map(function (m) { return $this.toPhp(m); }).join(', ') + ']';
  178. return '[' + Object.keys(mixedValue).map(function (sKey) {
  179. return $this.toPhp(sKey) + ' => ' + $this.toPhp(mixedValue[sKey]);
  180. }).join(', ') + ']';
  181. };
  182. /**
  183. * Run the report as the signed-in administrator and show api.php's answer as text.
  184. */
  185. BxAnalyticsStudioTools.prototype.runApi = function (oForm) {
  186. var $this = this;
  187. var oError = oForm.querySelector('.bx-analytics-form-error');
  188. var oSubmit = oForm.querySelector('[type="submit"]');
  189. var oTool = oForm.closest('.bx-analytics-api');
  190. var oResult = oTool.querySelector('[data-bx-analytics-api-result]');
  191. var oOutput = oTool.querySelector('[data-bx-analytics-api-output]');
  192. var oMeta = oTool.querySelector('[data-bx-analytics-api-meta]');
  193. oError.hidden = true;
  194. if (this.getApiParams(oForm) === null) {
  195. oError.textContent = this._aStrings.bad_json || '';
  196. oError.hidden = false;
  197. oForm.querySelector('[data-bx-analytics-api-params]').focus();
  198. return;
  199. }
  200. var oData = new FormData(oForm);
  201. oSubmit.disabled = true;
  202. oMeta.textContent = this._aStrings.running || '';
  203. this.post('api', oData)
  204. .then(function (oResponse) {
  205. if (!oResponse.ok)
  206. throw new Error(oResponse.error || $this._aStrings.error || '');
  207. var sJson = JSON.stringify(oResponse.response, null, 2);
  208. var iBytes = typeof TextEncoder !== 'undefined' ? new TextEncoder().encode(JSON.stringify(oResponse.response)).length : sJson.length;
  209. var bTruncated = sJson.length > $this._iMaxOutput;
  210. oOutput.textContent = bTruncated ? sJson.slice(0, $this._iMaxOutput) + '\n…' : sJson;
  211. oMeta.textContent = ($this._aStrings.meta || '')
  212. .replace('{status}', String(oResponse.response.status))
  213. .replace('{ms}', String(oResponse.ms))
  214. .replace('{size}', $this.formatBytes(iBytes)) + (bTruncated ? ' ' + ($this._aStrings.truncated || '') : '');
  215. oResult.hidden = false;
  216. })
  217. .catch(function (oException) {
  218. oMeta.textContent = '';
  219. oError.textContent = oException.message || $this._aStrings.error || '';
  220. oError.hidden = false;
  221. })
  222. .then(function () {
  223. oSubmit.disabled = false;
  224. })
  225. .catch(function () {}); // nothing is left to fail: the catch above reports errors
  226. };
  227. BxAnalyticsStudioTools.prototype.formatBytes = function (iBytes) {
  228. var sLang = document.documentElement.lang || undefined;
  229. try {
  230. if (iBytes >= 1024 * 1024)
  231. return new Intl.NumberFormat(sLang, {maximumFractionDigits: 1}).format(iBytes / 1024 / 1024) + ' MB';
  232. if (iBytes >= 1024)
  233. return new Intl.NumberFormat(sLang, {maximumFractionDigits: 1}).format(iBytes / 1024) + ' KB';
  234. } catch (e) {
  235. // a plain number below
  236. }
  237. return iBytes + ' B';
  238. };
  239. /**
  240. * Create an analytics page and open it in the page builder.
  241. */
  242. BxAnalyticsStudioTools.prototype.createDashboard = function (oForm) {
  243. var $this = this;
  244. var oError = oForm.querySelector('.bx-analytics-form-error');
  245. var oSubmit = oForm.querySelector('[type="submit"]');
  246. var oTitle = oForm.querySelector('[name="title"]');
  247. oError.hidden = true;
  248. if (!oTitle.value.trim()) {
  249. oTitle.setAttribute('aria-invalid', 'true');
  250. oTitle.focus();
  251. return;
  252. }
  253. oTitle.removeAttribute('aria-invalid');
  254. oSubmit.disabled = true;
  255. this.post('dashboard_create', new FormData(oForm))
  256. .then(function (oResponse) {
  257. if (!oResponse.ok || !oResponse.url)
  258. throw new Error(oResponse.error || $this._aStrings.error || '');
  259. window.location.href = oResponse.url;
  260. })
  261. .catch(function (oException) {
  262. oSubmit.disabled = false;
  263. oError.textContent = oException.message || $this._aStrings.error || '';
  264. oError.hidden = false;
  265. });
  266. };
  267. /**
  268. * Setup: check this browser (stores nothing) or send a test event (stores a visit).
  269. */
  270. BxAnalyticsStudioTools.prototype.runSetup = function (oButton) {
  271. var $this = this;
  272. var sAction = oButton.getAttribute('data-bx-analytics-setup');
  273. var oTool = oButton.closest('.bx-analytics-setup');
  274. var oResult = oTool.querySelector('[data-bx-analytics-setup-result]');
  275. var oData = new FormData();
  276. oData.set('csrf_token', oButton.getAttribute('data-bx-analytics-csrf') || '');
  277. oButton.disabled = true;
  278. this.post(sAction, oData)
  279. .then(function (oResponse) {
  280. if (!oResponse.ok)
  281. throw new Error(oResponse.error || $this._aStrings.error || '');
  282. if (sAction === 'check') {
  283. // rendered and escaped by the server
  284. oResult.innerHTML = oResponse.html;
  285. return;
  286. }
  287. oResult.textContent = oResponse.message || '';
  288. var oStatus = oTool.querySelector('[data-bx-analytics-setup-status]');
  289. if (oStatus && oResponse.html) {
  290. oStatus.innerHTML = oResponse.html;
  291. if (typeof bx_time === 'function')
  292. bx_time(undefined, false, oStatus);
  293. }
  294. })
  295. .catch(function (oException) {
  296. oResult.textContent = oException.message || $this._aStrings.error || '';
  297. })
  298. .then(function () {
  299. oButton.disabled = false;
  300. })
  301. .catch(function () {}); // nothing is left to fail: the catch above reports errors
  302. };
  303. BxAnalyticsStudioTools.prototype.post = function (sAction, oData) {
  304. return fetch(this._sActionUrl + sAction + '/', {method: 'POST', body: oData, credentials: 'same-origin'})
  305. .then(function (oResponse) {
  306. return oResponse.json().catch(function () {
  307. return {error: ''};
  308. });
  309. });
  310. };
  311. /** @} */