page-editor.js 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405
  1. import $ from 'jquery';
  2. import { cleanForSlug } from '../../utils/cleanForSlug';
  3. import initCollapsibleBreadcrumbs from '../../includes/breadcrumbs';
  4. function InlinePanel(opts) {
  5. // lgtm[js/unused-local-variable]
  6. const self = {};
  7. // eslint-disable-next-line func-names
  8. self.setHasContent = function () {
  9. if ($('> li', self.formsUl).not('.deleted').length) {
  10. self.formsUl.parent().removeClass('empty');
  11. } else {
  12. self.formsUl.parent().addClass('empty');
  13. }
  14. };
  15. // eslint-disable-next-line func-names
  16. self.initChildControls = function (prefix) {
  17. const childId = 'inline_child_' + prefix;
  18. const deleteInputId = 'id_' + prefix + '-DELETE';
  19. // mark container as having children to identify fields in use from those not
  20. self.setHasContent();
  21. $('#' + deleteInputId + '-button').on('click', () => {
  22. /* set 'deleted' form field to true */
  23. $('#' + deleteInputId).val('1');
  24. $('#' + childId)
  25. .addClass('deleted')
  26. .slideUp(() => {
  27. self.updateMoveButtonDisabledStates();
  28. self.updateAddButtonState();
  29. self.setHasContent();
  30. });
  31. });
  32. if (opts.canOrder) {
  33. $('#' + prefix + '-move-up').on('click', () => {
  34. const currentChild = $('#' + childId);
  35. const currentChildOrderElem = currentChild.children(
  36. 'input[name$="-ORDER"]',
  37. );
  38. const currentChildOrder = currentChildOrderElem.val();
  39. /* find the previous visible 'inline_child' li before this one */
  40. const prevChild = currentChild.prevAll(':not(.deleted)').first();
  41. if (!prevChild.length) return;
  42. const prevChildOrderElem = prevChild.children('input[name$="-ORDER"]');
  43. const prevChildOrder = prevChildOrderElem.val();
  44. // async swap animation must run before the insertBefore line below, but doesn't need to finish first
  45. self.animateSwap(currentChild, prevChild);
  46. currentChild.insertBefore(prevChild);
  47. currentChildOrderElem.val(prevChildOrder);
  48. prevChildOrderElem.val(currentChildOrder);
  49. self.updateMoveButtonDisabledStates();
  50. });
  51. $('#' + prefix + '-move-down').on('click', () => {
  52. const currentChild = $('#' + childId);
  53. const currentChildOrderElem = currentChild.children(
  54. 'input[name$="-ORDER"]',
  55. );
  56. const currentChildOrder = currentChildOrderElem.val();
  57. /* find the next visible 'inline_child' li after this one */
  58. const nextChild = currentChild.nextAll(':not(.deleted)').first();
  59. if (!nextChild.length) return;
  60. const nextChildOrderElem = nextChild.children('input[name$="-ORDER"]');
  61. const nextChildOrder = nextChildOrderElem.val();
  62. // async swap animation must run before the insertAfter line below, but doesn't need to finish first
  63. self.animateSwap(currentChild, nextChild);
  64. currentChild.insertAfter(nextChild);
  65. currentChildOrderElem.val(nextChildOrder);
  66. nextChildOrderElem.val(currentChildOrder);
  67. self.updateMoveButtonDisabledStates();
  68. });
  69. }
  70. /* Hide container on page load if it is marked as deleted. Remove the error
  71. message so that it doesn't count towards the number of errors on the tab at the
  72. top of the page. */
  73. if ($('#' + deleteInputId).val() === '1') {
  74. $('#' + childId)
  75. .addClass('deleted')
  76. .hide(0, () => {
  77. self.updateMoveButtonDisabledStates();
  78. self.updateAddButtonState();
  79. self.setHasContent();
  80. });
  81. $('#' + childId)
  82. .find('.error-message')
  83. .remove();
  84. }
  85. };
  86. self.formsUl = $('#' + opts.formsetPrefix + '-FORMS');
  87. // eslint-disable-next-line func-names
  88. self.updateMoveButtonDisabledStates = function () {
  89. if (opts.canOrder) {
  90. const forms = self.formsUl.children('li:not(.deleted)');
  91. // eslint-disable-next-line func-names
  92. forms.each(function (i) {
  93. $('ul.controls .inline-child-move-up', this)
  94. .toggleClass('disabled', i === 0)
  95. .toggleClass('enabled', i !== 0);
  96. $('ul.controls .inline-child-move-down', this)
  97. .toggleClass('disabled', i === forms.length - 1)
  98. .toggleClass('enabled', i !== forms.length - 1);
  99. });
  100. }
  101. };
  102. // eslint-disable-next-line func-names
  103. self.updateAddButtonState = function () {
  104. if (opts.maxForms) {
  105. const forms = $('> [data-inline-panel-child]', self.formsUl).not(
  106. '.deleted',
  107. );
  108. const addButton = $('#' + opts.formsetPrefix + '-ADD');
  109. if (forms.length >= opts.maxForms) {
  110. addButton.addClass('disabled');
  111. } else {
  112. addButton.removeClass('disabled');
  113. }
  114. }
  115. };
  116. // eslint-disable-next-line func-names
  117. self.animateSwap = function (item1, item2) {
  118. const parent = self.formsUl;
  119. const children = parent.children('li:not(.deleted)');
  120. // Apply moving class to container (ul.multiple) so it can assist absolute positioning of it's children
  121. // Also set it's relatively calculated height to be an absolute one,
  122. // to prevent the containercollapsing while its children go absolute
  123. parent.addClass('moving').css('height', parent.height());
  124. children
  125. .each(function moveChildTop() {
  126. $(this).css('top', $(this).position().top);
  127. })
  128. .addClass('moving');
  129. // animate swapping around
  130. item1.animate(
  131. {
  132. top: item2.position().top,
  133. },
  134. 200,
  135. () => {
  136. parent.removeClass('moving').removeAttr('style');
  137. children.removeClass('moving').removeAttr('style');
  138. },
  139. );
  140. item2.animate(
  141. {
  142. top: item1.position().top,
  143. },
  144. 200,
  145. () => {
  146. parent.removeClass('moving').removeAttr('style');
  147. children.removeClass('moving').removeAttr('style');
  148. },
  149. );
  150. };
  151. // eslint-disable-next-line no-undef
  152. buildExpandingFormset(opts.formsetPrefix, {
  153. onAdd(formCount) {
  154. const newChildPrefix = opts.emptyChildFormPrefix.replace(
  155. /__prefix__/g,
  156. formCount,
  157. );
  158. self.initChildControls(newChildPrefix);
  159. if (opts.canOrder) {
  160. /* NB form hidden inputs use 0-based index and only increment formCount *after* this function is run.
  161. Therefore formcount and order are currently equal and order must be incremented
  162. to ensure it's *greater* than previous item */
  163. $('#id_' + newChildPrefix + '-ORDER').val(formCount + 1);
  164. }
  165. self.updateMoveButtonDisabledStates();
  166. self.updateAddButtonState();
  167. if (opts.onAdd) opts.onAdd();
  168. },
  169. });
  170. return self;
  171. }
  172. window.InlinePanel = InlinePanel;
  173. window.cleanForSlug = cleanForSlug;
  174. function initSlugAutoPopulate() {
  175. let slugFollowsTitle = false;
  176. // eslint-disable-next-line func-names
  177. $('#id_title').on('focus', function () {
  178. /* slug should only follow the title field if its value matched the title's value at the time of focus */
  179. const currentSlug = $('#id_slug').val();
  180. const slugifiedTitle = cleanForSlug(this.value, true);
  181. slugFollowsTitle = currentSlug === slugifiedTitle;
  182. });
  183. // eslint-disable-next-line func-names
  184. $('#id_title').on('keyup keydown keypress blur', function () {
  185. if (slugFollowsTitle) {
  186. const slugifiedTitle = cleanForSlug(this.value, true);
  187. $('#id_slug').val(slugifiedTitle);
  188. }
  189. });
  190. }
  191. window.initSlugAutoPopulate = initSlugAutoPopulate;
  192. function initSlugCleaning() {
  193. // eslint-disable-next-line func-names
  194. $('#id_slug').on('blur', function () {
  195. // if a user has just set the slug themselves, don't remove stop words etc, just illegal characters
  196. $(this).val(cleanForSlug($(this).val(), false));
  197. });
  198. }
  199. window.initSlugCleaning = initSlugCleaning;
  200. function initErrorDetection() {
  201. const errorSections = {};
  202. // first count up all the errors
  203. // eslint-disable-next-line func-names
  204. $('.error-message,.help-critical').each(function () {
  205. const parentSection = $(this).closest('section');
  206. if (!errorSections[parentSection.attr('id')]) {
  207. errorSections[parentSection.attr('id')] = 0;
  208. }
  209. errorSections[parentSection.attr('id')] =
  210. errorSections[parentSection.attr('id')] + 1;
  211. });
  212. // now identify them on each tab
  213. // eslint-disable-next-line guard-for-in
  214. for (const index in errorSections) {
  215. $('[data-tabs] a[href="#' + index + '"]')
  216. .find('.w-tabs__errors')
  217. .addClass('w-tabs__errors--active')
  218. .find('.w-tabs__errors-count')
  219. .text(errorSections[index]);
  220. }
  221. }
  222. window.initErrorDetection = initErrorDetection;
  223. function initKeyboardShortcuts() {
  224. // eslint-disable-next-line no-undef
  225. Mousetrap.bind(['mod+p'], () => {
  226. $('.action-preview').trigger('click');
  227. return false;
  228. });
  229. // eslint-disable-next-line no-undef
  230. Mousetrap.bind(['mod+s'], () => {
  231. $('.action-save').trigger('click');
  232. return false;
  233. });
  234. }
  235. window.initKeyboardShortcuts = initKeyboardShortcuts;
  236. $(() => {
  237. /* Only non-live pages should auto-populate the slug from the title */
  238. if (!$('body').hasClass('page-is-live')) {
  239. initSlugAutoPopulate();
  240. }
  241. initSlugCleaning();
  242. initErrorDetection();
  243. initKeyboardShortcuts();
  244. initCollapsibleBreadcrumbs();
  245. //
  246. // Preview
  247. //
  248. // In order to make the preview truly reliable, the preview page needs
  249. // to be perfectly independent from the edit page,
  250. // from the browser perspective. To pass data from the edit page
  251. // to the preview page, we send the form after each change
  252. // and save it inside the user session.
  253. const $previewButton = $('.action-preview');
  254. const $form = $('#page-edit-form');
  255. const previewUrl = $previewButton.data('action');
  256. let autoUpdatePreviewDataTimeout = -1;
  257. function setPreviewData() {
  258. return $.ajax({
  259. url: previewUrl,
  260. method: 'POST',
  261. data: new FormData($form[0]),
  262. processData: false,
  263. contentType: false,
  264. });
  265. }
  266. $previewButton.one('click', () => {
  267. if ($previewButton.data('auto-update')) {
  268. // Form data is changed when field values are changed
  269. // (change event), when HTML elements are added, modified, moved,
  270. // and deleted (DOMSubtreeModified event), and we need to delay
  271. // setPreviewData when typing to avoid useless extra AJAX requests
  272. // (so we postpone setPreviewData when keyup occurs).
  273. // TODO: Replace DOMSubtreeModified with a MutationObserver.
  274. $form
  275. .on('change keyup DOMSubtreeModified', () => {
  276. clearTimeout(autoUpdatePreviewDataTimeout);
  277. autoUpdatePreviewDataTimeout = setTimeout(setPreviewData, 1000);
  278. })
  279. .trigger('change');
  280. }
  281. });
  282. // eslint-disable-next-line func-names
  283. $previewButton.on('click', function (e) {
  284. e.preventDefault();
  285. const $this = $(this);
  286. const $icon = $this.filter('.icon');
  287. const thisPreviewUrl = $this.data('action');
  288. $icon.addClass('icon-spinner').removeClass('icon-view');
  289. const previewWindow = window.open('', thisPreviewUrl);
  290. previewWindow.focus();
  291. setPreviewData()
  292. .done((data) => {
  293. if (data.is_valid) {
  294. previewWindow.document.location = thisPreviewUrl;
  295. } else {
  296. window.focus();
  297. previewWindow.close();
  298. // TODO: Stop sending the form, as it removes file data.
  299. $form.trigger('submit');
  300. }
  301. })
  302. .fail(() => {
  303. // eslint-disable-next-line no-alert
  304. alert('Error while sending preview data.');
  305. window.focus();
  306. previewWindow.close();
  307. })
  308. .always(() => {
  309. $icon.addClass('icon-view').removeClass('icon-spinner');
  310. });
  311. });
  312. });
  313. let updateFooterTextTimeout = -1;
  314. window.updateFooterSaveWarning = (formDirty, commentsDirty) => {
  315. const warningContainer = $('[data-unsaved-warning]');
  316. const warnings = warningContainer.find('[data-unsaved-type]');
  317. const anyDirty = formDirty || commentsDirty;
  318. const typeVisibility = {
  319. all: formDirty && commentsDirty,
  320. any: anyDirty,
  321. comments: commentsDirty && !formDirty,
  322. edits: formDirty && !commentsDirty,
  323. };
  324. let hiding = false;
  325. if (anyDirty) {
  326. warningContainer.removeClass('footer__container--hidden');
  327. } else {
  328. if (!warningContainer.hasClass('footer__container--hidden')) {
  329. hiding = true;
  330. }
  331. warningContainer.addClass('footer__container--hidden');
  332. }
  333. clearTimeout(updateFooterTextTimeout);
  334. const updateWarnings = () => {
  335. for (const warning of warnings) {
  336. const visible = typeVisibility[warning.dataset.unsavedType];
  337. warning.hidden = !visible;
  338. }
  339. };
  340. if (hiding) {
  341. // If hiding, we want to keep the text as-is before it disappears
  342. updateFooterTextTimeout = setTimeout(updateWarnings, 1050);
  343. } else {
  344. updateWarnings();
  345. }
  346. };