Source: review.js

  1. /* global $, config, libreviews */
  2. /* eslint prefer-reflect: "off" */
  3. /**
  4. * Immediately invoked function expression that initializes various event
  5. * handlers that are part of the review form, including:
  6. * - management of client-side drafts
  7. * - "star rating" selector
  8. * - mode switcher for source selection and integration with lookup adapters
  9. *
  10. * @namespace Review
  11. */
  12. (function() {
  13. 'use strict';
  14. require('es6-promise').polyfill();
  15. const NativeLookupAdapter = require('./adapters/native-lookup-adapter');
  16. const OpenStreetMapLookupAdapter = require('./adapters/openstreetmap-lookup-adapter');
  17. const WikidataAutocompleteAdapter = require('./adapters/wikidata-autocomplete-adapter');
  18. const OpenLibraryAutocompleteAdapter = require('./adapters/openlibrary-autocomplete-adapter');
  19. // All adapters will be tried against a provided review subject URL. If they
  20. // support it, they will perform a parallel, asynchronous lookup. The array order
  21. // matters in that the first element in the array (not the first to return
  22. // a result) will be used for the review subject metadata. The native (lib.reviews)
  23. // lookup therefore always takes precedence.
  24. const adapters = [
  25. new NativeLookupAdapter(),
  26. new OpenStreetMapLookupAdapter(),
  27. new WikidataAutocompleteAdapter(updateURLAndReviewSubject, '#review-search-database'),
  28. new OpenLibraryAutocompleteAdapter(updateURLAndReviewSubject, '#review-search-database')
  29. ];
  30. /**
  31. * URL of the most recently used lookup from an adapter.
  32. *
  33. * @type {String}
  34. * @memberof Review
  35. */
  36. let lastLookup;
  37. /**
  38. * Most recently selected adapter.
  39. *
  40. * @type {Object}
  41. * @memberof Review
  42. */
  43. let lastAdapter;
  44. // Our form's behavior depends significantly on whether we're creating
  45. // a new review, or editing an old one.
  46. let editing = config.editing;
  47. let textFields = editing ? '#review-title,#review-text' :
  48. '#review-url,#review-title,#review-text';
  49. /**
  50. * Holds instance of external library for client-side storage of new reviews
  51. * in progress.
  52. *
  53. * @memberof Review
  54. */
  55. let sisyphus;
  56. // Add inputs used only with JS here so they don't appear/conflict when JS is disabled
  57. $('#star-rating-control').append(`<input id="review-rating" name="review-rating" type="hidden" data-required>`);
  58. /**
  59. * Rating value of previous POST request, if any.
  60. *
  61. * @memberof Review
  62. */
  63. let postRating = $('#star-rating-control').attr('data-post') || '';
  64. // Register event handlers
  65. $('[id^=star-button-]')
  66. .mouseout(clearStars)
  67. .mouseover(indicateStar)
  68. .click(selectStar)
  69. .keyup(maybeSelectStar);
  70. // Little "X" icon that makes previously looked up information from Wikidata
  71. // and other sources go away and clears out the URL field
  72. $('#remove-resolved-info')
  73. .click(removeResolvedInfo)
  74. .keyup(maybeRemoveResolvedInfo);
  75. // Highlight rating from POST request
  76. if (postRating)
  77. selectStar.apply($(`#star-button-${postRating}`)[0]);
  78. // We only have to worry about review subject lookup for new reviews,
  79. // not when editing existing reviews
  80. if (!editing) {
  81. initializeURLValidation();
  82. $(textFields).change(hideAbandonDraft);
  83. $('#review-url').keyup(function(event) {
  84. handleURLLookup.call(this, event, true); // Suppresses modal while typing
  85. });
  86. $('#review-url').keyup(handleURLFixes);
  87. $('#review-url').change(handleURLLookup);
  88. $('#review-url').change(handleURLValidation);
  89. $('#dismiss-draft-notice').click(hideDraftNotice);
  90. $('#abandon-draft').click(emptyAllFormFields);
  91. $('#add-http').click(addHTTP);
  92. $('#add-https').click(addHTTPS);
  93. // When writing a review of an existing thing, the URL field is not
  94. // available, so do not attempt to store data to it.
  95. let maybeExcludeURL = '';
  96. if (!$('#review-url').length)
  97. maybeExcludeURL = ',#review-url';
  98. // Persist form in local storage
  99. sisyphus = $('#review-form').sisyphus({
  100. onBeforeRestore: restoreDynamicFields,
  101. onRestore: processLoadedData,
  102. excludeFields: $('[data-ignore-autosave]' + maybeExcludeURL)
  103. });
  104. // Wire up mode switcher for source of review subject: URL or database
  105. $('#review-via-url').conditionalSwitcherClick(activateReviewViaURL);
  106. $('#review-via-database').conditionalSwitcherClick(activateReviewViaDatabase);
  107. // Wire up source selector dropdown for "review via database" workflow
  108. $('#source-selector-dropdown').change(selectSource);
  109. // Bubbling interferes with autocomplete's window-level click listener
  110. $('#source-selector-dropdown').click(function(event) {
  111. event.stopPropagation();
  112. });
  113. // Initialize search autocomplete for currently selected source
  114. selectSource.apply($('#source-selector-dropdown')[0]);
  115. }
  116. // In case we're in preview mode and have a URL, make sure we fire the URL
  117. // validation/lookup handlers
  118. if ($('#preview-contents').length && $('#review-url').val()) {
  119. handleURLLookup.apply($('#review-url')[0]);
  120. handleURLValidation.apply($('#review-url')[0]);
  121. }
  122. /**
  123. * Hide information indicating that an unsaved draft is available.
  124. *
  125. * @memberof Review
  126. */
  127. function hideDraftNotice() {
  128. if ($('#draft-notice').is(':visible'))
  129. $('#draft-notice').fadeOut(200);
  130. }
  131. /**
  132. * Remove a previously resolved review subject from a review.
  133. *
  134. * @memberof Review
  135. */
  136. function removeResolvedInfo() {
  137. clearResolvedInfo();
  138. $('#review-url').val('');
  139. // Trigger change-related event handlers
  140. handleURLLookup.apply($('#review-url')[0]);
  141. handleURLValidation.apply($('#review-url')[0]);
  142. // Update saved data w/ empty URL
  143. sisyphus.saveAllData();
  144. // Focus back on URL field.
  145. // We don't clear out any search fields in case the user quickly wants
  146. // to get back to the text they previously entered.
  147. $('#review-via-url').click();
  148. }
  149. /**
  150. * Keyboard handler that triggers {@link Review.removeResolvedInfo}
  151. * on `[Enter]` and `[Space]`.
  152. *
  153. * @memberof Review
  154. */
  155. function maybeRemoveResolvedInfo() {
  156. if (event.keyCode == 13 || event.keyCode == 32)
  157. removeResolvedInfo();
  158. }
  159. /**
  160. * Hide the option for abandoning a draft once the user has started editing
  161. * it.
  162. *
  163. * @memberof Review
  164. */
  165. function hideAbandonDraft() {
  166. if ($('#abandon-draft').is(':visible'))
  167. $('#abandon-draft').fadeOut(200);
  168. }
  169. /**
  170. * Add a HTTP or HTTPS prefix to the review URL.
  171. * @param {String} protocol - "http" or "https"
  172. *
  173. * @memberof Review
  174. */
  175. function addProtocol(protocol) {
  176. $('#review-url').val(protocol + '://' + $('#review-url').val());
  177. $('#review-url').trigger('change');
  178. }
  179. /**
  180. * Add HTTP prefix to the review URL field and focus on it.
  181. * @param {Event} event the click event on the "Add HTTP" link
  182. *
  183. * @memberof Review
  184. */
  185. function addHTTP(event) {
  186. addProtocol('http');
  187. $('#review-label').focus();
  188. event.preventDefault();
  189. }
  190. /**
  191. * Add HTTPS prefix to the review URL field and focus on it.
  192. * @param {Event} event the click event on the "Add HTTPS" link
  193. *
  194. * @memberof Review
  195. */
  196. function addHTTPS(event) {
  197. addProtocol('https');
  198. $('#review-label').focus();
  199. event.preventDefault();
  200. }
  201. /**
  202. * Ask all available adapters for information about the currently provided
  203. * URL, via asynchronous lookup. Note this is separate from the search
  204. * option -- we look up information, e.g., from Wikidata even if the user just
  205. * puts in a Wikidata URL.
  206. *
  207. * @param {Event} [_event]
  208. * the event that triggered the lookup; not used
  209. * @param {Boolean} [suppressModal=false]
  210. * Suppress the modal shown if the user has previously reviewed this subject.
  211. * Overkill while typing, so suppressed there.
  212. *
  213. * @memberof Review
  214. */
  215. function handleURLLookup(_event, suppressModal) {
  216. let inputEle = this;
  217. let inputURL = inputEle.value;
  218. let promises = [];
  219. // Avoid duplicate lookups on keyup
  220. if (inputURL === lastLookup)
  221. return;
  222. // Track lookups, regardless of success or failure
  223. lastLookup = inputURL;
  224. if (!inputURL) {
  225. clearResolvedInfo();
  226. return;
  227. }
  228. // We look up this URL using all adapters that support it. The native
  229. // adapter performs its own URL schema validation, and other adapters
  230. // are more restrictive.
  231. adapters.forEach(adapter => {
  232. if (adapter.ask && adapter.lookup && adapter.ask(inputURL))
  233. promises.push(adapter.lookup(inputURL));
  234. });
  235. // We use a mapped array so we can catch failing promises and pass along
  236. // the error as payload, instead of aborting the whole process if even
  237. // one of the queries fails.
  238. Promise
  239. .all(promises.map(promise => promise.catch(error => ({ error }))))
  240. .then(results => {
  241. // If the URL field has been cleared since the user started the query,
  242. // don't bother with the result of the lookup
  243. if (!inputEle.value)
  244. return;
  245. // Use first valid result in order of the array. Since the native lookup
  246. // is the first array element, it will take precedence over any adapters.
  247. for (let result of results) {
  248. if (result.data && result.data.label) {
  249. // User has previously reviewed this subject
  250. if (result.data.thing && typeof result.data.thing.reviews == 'object' &&
  251. result.data.thing.reviews.length) {
  252. if (!suppressModal) {
  253. showModalForEditingExistingReview(result.data);
  254. return;
  255. } else {
  256. // Don't show modal - perhaps user is still typing.
  257. // May have to show modal later, so don't cache lookup
  258. lastLookup = undefined;
  259. }
  260. }
  261. updateReviewSubject({
  262. url: inputURL,
  263. label: result.data.label,
  264. description: result.data.description, // may be undefined
  265. subtitle: result.data.subtitle,
  266. thing: result.data.thing // may be undefined
  267. });
  268. return;
  269. }
  270. clearResolvedInfo();
  271. }
  272. });
  273. }
  274. /**
  275. * Show modal that asks the user to edit an existing review of the given
  276. * subject, or to pick another review subject.
  277. *
  278. * @param {Object} data
  279. * data used for the redirect
  280. * @param {Object[]} data.reviews
  281. * array of reviews by the current user for this thing. Under normal
  282. * circumstances there should only be 1, but this is not a hard constraint
  283. * at the database level.
  284. */
  285. function showModalForEditingExistingReview(data) {
  286. const $modal = $(`<div class="hidden-regular" id="review-modal"></div>`)
  287. .append('<p>' + window.libreviews.msg('previously reviewed') + '</p>')
  288. .append('<p><b>' + data.label + '</b></p>')
  289. .append('<p>' + window.libreviews.msg('abandon form changes') + '</p>')
  290. .append('<button class="pure-button pure-button-primary button-rounded" id="edit-existing-review">' +
  291. window.libreviews.msg('edit review') + '</button>')
  292. .append('&nbsp;<a href="#" id="pick-different-subject">' + window.libreviews.msg('pick a different subject') + '</a>');
  293. $modal.insertAfter('#review-subject');
  294. $modal.modal({
  295. escapeClose: false,
  296. clickClose: false,
  297. showClose: false
  298. });
  299. $('#edit-existing-review').click(() => {
  300. // Clear draft
  301. sisyphus.manuallyReleaseData();
  302. window.location.assign(`/review/${data.thing.reviews[0].id}/edit`);
  303. });
  304. $('#pick-different-subject').click(event => {
  305. $.modal.close();
  306. $modal.remove();
  307. $('#review-url').val('');
  308. $('#review-url').trigger('change');
  309. event.preventDefault();
  310. });
  311. $modal.lockTab();
  312. }
  313. /**
  314. * Clean out any old URL metadata and show the label field again
  315. *
  316. * @memberof Review
  317. */
  318. function clearResolvedInfo() {
  319. $('.resolved-info').empty();
  320. $('#review-subject').hide();
  321. $('.review-label-group').show();
  322. window.libreviews.repaintFocusedHelp();
  323. }
  324. /**
  325. * Show warning and helper links as appropriate for a given subject URL.
  326. *
  327. * @memberof Review
  328. */
  329. function handleURLValidation() {
  330. let inputURL = this.value;
  331. if (inputURL && !libreviews.validateURL(inputURL)) {
  332. $('#review-url-error').show();
  333. if (!libreviews.urlHasSupportedProtocol(inputURL)) {
  334. $('.helper-links').show();
  335. $('#add-https').focus();
  336. } else {
  337. $('.helper-links').hide();
  338. }
  339. } else {
  340. $('#review-url-error').hide();
  341. $('.helper-links').hide();
  342. }
  343. }
  344. /**
  345. * Update UI based on URL corrections of validation problems. This is
  346. * registered on keyup for the URL input field, so corrections are instantly
  347. * detected.
  348. *
  349. * @memberof Review
  350. */
  351. function handleURLFixes() {
  352. let inputURL = this.value;
  353. if ($('#review-url-error').is(':visible')) {
  354. if (libreviews.validateURL(inputURL)) {
  355. $('#review-url-error').hide();
  356. $('.helper-links').hide();
  357. }
  358. }
  359. }
  360. /**
  361. * Callback called from adapters for adding informationed obtained via an
  362. * adapter to the form. Except for the URL, the information is not actually
  363. * associated with the review, since the corresponding server-side adapter
  364. * will perform its own deep lookup.
  365. *
  366. * @param {Object} data - Data obtained by the adapter
  367. * @memberof Review
  368. */
  369. function updateURLAndReviewSubject(data) {
  370. if (!data.url)
  371. throw new Error('To update a URL, we must get one.');
  372. $('#review-url').val(data.url);
  373. // Re-validate URL. There shouldn't be any problems with the new URL, so
  374. // this will mainly clear out old validation errors.
  375. handleURLValidation.apply($('#review-url')[0]);
  376. // If user has previously reviewed this, they need to choose to pick a
  377. // different subject or to edit their existing review
  378. if (data.thing && data.thing.reviews) {
  379. // Previous adapter may have loaded info
  380. clearResolvedInfo();
  381. showModalForEditingExistingReview(data);
  382. return;
  383. }
  384. updateReviewSubject(data);
  385. // Make sure we save draft in case user aborts here
  386. sisyphus.saveAllData();
  387. }
  388. /**
  389. * Handle the non-URL information obtained via the
  390. * {@link Review.updateURLAndReviewSubject} callback, or via the
  391. * {@link Review.handleURLLookup} handler on the URL input field.
  392. *
  393. * @param {Object} data - Data obtained by the adapter
  394. * @memberof Review
  395. */
  396. function updateReviewSubject(data) {
  397. const { url, label, description, subtitle, thing } = data;
  398. if (!label)
  399. throw new Error('Review subject must have a label.');
  400. let wasFocused = $('#resolved-url a').is(':focus');
  401. $('.resolved-info').empty();
  402. $('#resolved-url').append(`<a href="${url}" target="_blank">${label}</a>`);
  403. if (description)
  404. $('#resolved-description').html(description);
  405. if (subtitle)
  406. $('#resolved-subtitle').html(`<i>${subtitle}</i>`);
  407. if (thing) {
  408. $('#resolved-thing').append(`<a href="/${thing.urlID}" target="_blank">${libreviews.msg('more info')}</a>`);
  409. }
  410. $('#review-subject').show();
  411. if (wasFocused)
  412. $('#resolved-url a').focus();
  413. $('.review-label-group').hide();
  414. // We don't want to submit previously entered label data
  415. $('#review-label').val('');
  416. // If now hidden field is focused, focus on title field instead (next in form)
  417. if ($('#review-label').is(':focus') || document.activeElement === document.body)
  418. $('#review-title').focus();
  419. }
  420. /**
  421. * Clear out old draft
  422. *
  423. * @param {Event} event - click event from the "Clear draft" button
  424. * @memberof Review
  425. */
  426. function emptyAllFormFields(event) {
  427. clearStars();
  428. $('#review-url,#review-title,#review-text,#review-rating').val('');
  429. $('#review-url').trigger('change');
  430. for (let rte in window.libreviews.activeRTEs)
  431. window.libreviews.activeRTEs[rte].reRender();
  432. sisyphus.manuallyReleaseData();
  433. hideDraftNotice();
  434. event.preventDefault();
  435. }
  436. /**
  437. * Restore fields that were dynamically added by JavaScript and not part of
  438. * the original form.
  439. *
  440. * @memberof Review
  441. */
  442. function restoreDynamicFields() {
  443. const uploadRegex = /^\[id=review-form\].*\[name=(uploaded-file-.*?)\]$/;
  444. for (let key in localStorage) {
  445. if (uploadRegex.test(key)) {
  446. const fieldName = key.match(uploadRegex)[1];
  447. $('#review-form').append(`<input type="hidden" name="${fieldName}">`);
  448. }
  449. }
  450. }
  451. /**
  452. * Load draft data into the review form.
  453. *
  454. * @memberof Review
  455. */
  456. function processLoadedData() {
  457. let rating = Number($('#review-rating').val());
  458. // Trim just in case whitespace got persisted
  459. $('input[data-auto-trim],textarea[data-auto-trim]').each(window.libreviews.trimInput);
  460. // Only show notice if we've actually recovered some data
  461. if (rating || $('#review-url').val() || $('#review-title').val() || $('#review-text').val()) {
  462. if (rating)
  463. selectStar.apply($(`#star-button-${rating}`)[0]);
  464. $('#draft-notice').show();
  465. // Repaint help in case it got pushed down
  466. window.libreviews.repaintFocusedHelp();
  467. }
  468. // Show URL issues if appropriate
  469. if ($('#review-url').length) {
  470. handleURLLookup.apply($('#review-url')[0]);
  471. handleURLValidation.apply($('#review-url')[0]);
  472. }
  473. }
  474. /**
  475. * Replace star images with their placeholder versions
  476. *
  477. * @param {Number} start - rating from which to start clearing
  478. * @memberof Review
  479. */
  480. function clearStars(start) {
  481. if (!start || typeof start !== "number")
  482. start = 1;
  483. for (let i = start; i < 6; i++)
  484. replaceStar(i, `/static/img/star-placeholder.svg`, 'star-holder');
  485. }
  486. /**
  487. * replaceStar - Helper function to replace individual star image
  488. *
  489. * @param {Number} id - number of the star to replace
  490. * @param {String} src - value for image source attribute
  491. * @param {String} className - CSS class to assign to element
  492. * @memberof Review
  493. */
  494. function replaceStar(id, src, className) {
  495. $(`#star-button-${id}`)
  496. .attr('src', src)
  497. .removeClass()
  498. .addClass(className);
  499. }
  500. /**
  501. * Handler to restore star rating to a previously selected setting.
  502. *
  503. * @memberof Review
  504. */
  505. function restoreSelected() {
  506. let selectedStar = $('#star-rating-control').attr('data-selected');
  507. if (selectedStar)
  508. selectStar.apply($(`#star-button-${selectedStar}`)[0]);
  509. }
  510. /**
  511. * Handler to "preview" a star rating, used on mouseover.
  512. *
  513. * @returns {Number} - the number value of the selected star
  514. * @memberof Review
  515. */
  516. function indicateStar() {
  517. // We want to set all stars to the color of the selected star
  518. let selectedStar = Number(this.id.match(/\d/)[0]);
  519. for (let i = 1; i <= selectedStar; i++)
  520. replaceStar(i, `/static/img/star-${selectedStar}-full.svg`, 'star-full');
  521. if (selectedStar < 5)
  522. clearStars(selectedStar + 1);
  523. return selectedStar;
  524. }
  525. /**
  526. * Key handler for selecting stars with `[Enter]` or `[Space]`.
  527. *
  528. * @param {Event} event - the key event
  529. */
  530. function maybeSelectStar(event) {
  531. if (event.keyCode == 13 || event.keyCode == 32)
  532. selectStar.apply(this);
  533. }
  534. /**
  535. * Actually apply a star rating.
  536. *
  537. * @memberof Review
  538. */
  539. function selectStar() {
  540. let selectedStar = indicateStar.apply(this);
  541. $('#star-rating-control').attr('data-selected', selectedStar);
  542. $('#star-rating-control img[id^=star-button-]')
  543. .off('mouseout')
  544. .mouseout(restoreSelected);
  545. $('#review-rating').val(selectedStar);
  546. $('#review-rating').trigger('change');
  547. }
  548. /**
  549. * Add template for URL validation errors, including "Add HTTP" and "Add HTTPS"
  550. * helper links.
  551. *
  552. * @memberof Review
  553. */
  554. function initializeURLValidation() {
  555. $('#url-validation').append(
  556. `<div id="review-url-error" class="validation-error">${libreviews.msg('not a url')}</div>` +
  557. `<div class="helper-links"><a href="#" id="add-https">${libreviews.msg('add https')}</a> &ndash; <a href="#" id="add-http">${libreviews.msg('add http')}</a></div>`
  558. );
  559. }
  560. /**
  561. * Click handler for activating the form for reviewing a subject by specifying
  562. * a URL
  563. *
  564. * @memberof Review
  565. */
  566. function activateReviewViaURL() {
  567. $('#review-via-database-inputs').addClass('hidden');
  568. $('#review-via-url-inputs').removeClass('hidden');
  569. $('.review-label-group').removeClass('hidden-regular');
  570. $('#source-selector').toggleClass('hidden', true);
  571. if (!$('#review-url').val())
  572. $('#review-url').focus();
  573. }
  574. /**
  575. * Click handler for activating the form for reviewing a subject by selecting
  576. * it from an external database.
  577. *
  578. * @param {Event} event - the click event
  579. * @memberof Review
  580. */
  581. function activateReviewViaDatabase(event) {
  582. // Does not conflict with other hide/show actions on this group
  583. $('.review-label-group').addClass('hidden-regular');
  584. $('#review-via-url-inputs').addClass('hidden');
  585. $('#review-via-database-inputs').removeClass('hidden');
  586. // Focusing pops the selection back up, so this check is extra important here
  587. $('#source-selector').toggleClass('hidden', false);
  588. if (!$('#review-search-database').val()) {
  589. $('#review-search-database').focus();
  590. // Ensure focus event to mount autocomplete widget is triggered
  591. const focusEvent = new FocusEvent('focus', {
  592. view: window,
  593. bubbles: false,
  594. cancelable: true
  595. });
  596. $('#review-search-database')[0].dispatchEvent(focusEvent);
  597. }
  598. // Suppress event bubbling up to window, which the AC widget listens to, and
  599. // which would unmount the autocomplete function
  600. event.stopPropagation();
  601. }
  602. /**
  603. * Handler for selecting a source from the "database sources" dropdown,
  604. * which requires lookup using an adapter plugin.
  605. *
  606. * @memberof Review
  607. */
  608. function selectSource() {
  609. let sourceID = $(this).val();
  610. let adapter;
  611. // Locate adapter responsible for source declared in dropdown
  612. for (let a of adapters) {
  613. if (a.getSourceID() == sourceID) {
  614. adapter = a;
  615. break;
  616. }
  617. }
  618. if (lastAdapter && lastAdapter.removeAutocomplete)
  619. lastAdapter.removeAutocomplete();
  620. if (adapter && adapter.setupAutocomplete) {
  621. adapter.setupAutocomplete();
  622. if (lastAdapter) {
  623. // Re-run search, unless this is the first one. Sets focus on input.
  624. if (adapter.runAutocomplete)
  625. adapter.runAutocomplete();
  626. // Change help text
  627. $('#review-search-database-help .help-heading')
  628. .html(libreviews.msg(`review via ${adapter.getSourceID()} help label`));
  629. $('#review-search-database-help .help-paragraph')
  630. .html(libreviews.msg(`review via ${adapter.getSourceID()} help text`));
  631. // Change input placeholder
  632. $('#review-search-database').attr('placeholder',
  633. libreviews.msg(`start typing to search ${adapter.getSourceID()}`));
  634. window.libreviews.repaintFocusedHelp();
  635. }
  636. }
  637. // Track usage of the adapter so we can run functions on change
  638. lastAdapter = adapter;
  639. }
  640. }());