StreamBlock.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362
  1. /* eslint-disable no-underscore-dangle */
  2. import { v4 as uuidv4 } from 'uuid';
  3. import { BaseSequenceBlock, BaseSequenceChild, BaseInsertionControl } from './BaseSequenceBlock';
  4. import { escapeHtml as h } from '../../../utils/text';
  5. /* global $ */
  6. export class StreamBlockValidationError {
  7. constructor(nonBlockErrors, blockErrors) {
  8. this.nonBlockErrors = nonBlockErrors;
  9. this.blockErrors = blockErrors;
  10. }
  11. }
  12. class StreamChild extends BaseSequenceChild {
  13. /*
  14. wrapper for a block inside a StreamBlock, handling StreamBlock-specific metadata
  15. such as id
  16. */
  17. getState() {
  18. return {
  19. type: this.type,
  20. value: this.block.getState(),
  21. id: this.id,
  22. };
  23. }
  24. getValue() {
  25. return {
  26. type: this.type,
  27. value: this.block.getValue(),
  28. id: this.id,
  29. };
  30. }
  31. }
  32. class StreamBlockMenu extends BaseInsertionControl {
  33. constructor(placeholder, opts) {
  34. super(placeholder, opts);
  35. this.groupedChildBlockDefs = opts.groupedChildBlockDefs;
  36. const animate = opts.animate;
  37. const dom = $(`
  38. <div>
  39. <button data-streamblock-menu-open type="button" title="${h(opts.strings.ADD)}"
  40. class="c-sf-add-button c-sf-add-button--visible">
  41. <i aria-hidden="true">+</i>
  42. </button>
  43. <div data-streamblock-menu-outer>
  44. <div data-streamblock-menu-inner class="c-sf-add-panel"></div>
  45. </div>
  46. </div>
  47. `);
  48. $(placeholder).replaceWith(dom);
  49. this.element = dom.get(0);
  50. this.addButton = dom.find('[data-streamblock-menu-open]');
  51. this.addButton.click(() => {
  52. this.toggle();
  53. });
  54. this.outerContainer = dom.find('[data-streamblock-menu-outer]');
  55. this.innerContainer = dom.find('[data-streamblock-menu-inner]');
  56. this.hasRenderedMenu = false;
  57. this.isOpen = false;
  58. this.canAddBlock = true;
  59. this.disabledBlockTypes = new Set();
  60. this.close({ animate: false });
  61. if (animate) {
  62. dom.hide().slideDown();
  63. }
  64. }
  65. renderMenu() {
  66. if (this.hasRenderedMenu) return;
  67. this.hasRenderedMenu = true;
  68. this.groupedChildBlockDefs.forEach(([group, blockDefs]) => {
  69. if (group) {
  70. const heading = $('<h4 class="c-sf-add-panel__group-title"></h4>').text(group);
  71. this.innerContainer.append(heading);
  72. }
  73. const grid = $('<div class="c-sf-add-panel__grid"></div>');
  74. this.innerContainer.append(grid);
  75. blockDefs.forEach(blockDef => {
  76. const button = $(`
  77. <button type="button" class="c-sf-button action-add-block-${h(blockDef.name)}">
  78. <svg class="icon icon-${h(blockDef.meta.icon)} c-sf-button__icon" aria-hidden="true">
  79. <use href="#icon-${h(blockDef.meta.icon)}"></use>
  80. </svg>
  81. ${h(blockDef.meta.label)}
  82. </button>
  83. `);
  84. grid.append(button);
  85. button.click(() => {
  86. if (this.onRequestInsert) {
  87. this.onRequestInsert(this.index, { type: blockDef.name });
  88. }
  89. this.close({ animate: true });
  90. });
  91. });
  92. });
  93. // Disable buttons for any disabled block types
  94. this.disabledBlockTypes.forEach(blockType => {
  95. $(`button.action-add-block-${h(blockType)}`, this.innerContainer).attr('disabled', 'true');
  96. });
  97. }
  98. setNewBlockRestrictions(canAddBlock, disabledBlockTypes) {
  99. this.canAddBlock = canAddBlock;
  100. this.disabledBlockTypes = disabledBlockTypes;
  101. // Disable/enable menu open button
  102. if (this.canAddBlock) {
  103. this.addButton.removeAttr('disabled');
  104. } else {
  105. this.addButton.attr('disabled', 'true');
  106. }
  107. // Close menu if its open and we no longer can add blocks
  108. if (!canAddBlock && this.isOpen) {
  109. this.close({ animate: true });
  110. }
  111. // Disable/enable individual block type buttons
  112. $('button', this.innerContainer).removeAttr('disabled');
  113. disabledBlockTypes.forEach(blockType => {
  114. $(`button.action-add-block-${h(blockType)}`, this.innerContainer).attr('disabled', 'true');
  115. });
  116. }
  117. toggle() {
  118. if (this.isOpen) {
  119. this.close({ animate: true });
  120. } else {
  121. this.open({ animate: true });
  122. }
  123. }
  124. open(opts) {
  125. if (!this.canAddBlock) {
  126. return;
  127. }
  128. this.renderMenu();
  129. if (opts && opts.animate) {
  130. this.outerContainer.slideDown();
  131. } else {
  132. this.outerContainer.show();
  133. }
  134. this.addButton.addClass('c-sf-add-button--close');
  135. this.outerContainer.attr('aria-hidden', 'false');
  136. this.isOpen = true;
  137. }
  138. close(opts) {
  139. if (opts && opts.animate) {
  140. this.outerContainer.slideUp();
  141. } else {
  142. this.outerContainer.hide();
  143. }
  144. this.addButton.removeClass('c-sf-add-button--close');
  145. this.outerContainer.attr('aria-hidden', 'true');
  146. this.isOpen = false;
  147. }
  148. }
  149. export class StreamBlock extends BaseSequenceBlock {
  150. constructor(blockDef, placeholder, prefix, initialState, initialError) {
  151. this.blockDef = blockDef;
  152. this.type = blockDef.name;
  153. this.prefix = prefix;
  154. const dom = $(`
  155. <div class="c-sf-container ${h(this.blockDef.meta.classname || '')}">
  156. <input type="hidden" name="${h(prefix)}-count" data-streamfield-stream-count value="0">
  157. <div data-streamfield-stream-container></div>
  158. </div>
  159. `);
  160. $(placeholder).replaceWith(dom);
  161. if (this.blockDef.meta.helpText) {
  162. // help text is left unescaped as per Django conventions
  163. $(`
  164. <span>
  165. <div class="help">
  166. ${this.blockDef.meta.helpIcon}
  167. ${this.blockDef.meta.helpText}
  168. </div>
  169. </span>
  170. `).insertBefore(dom);
  171. }
  172. // StreamChild objects for the current (non-deleted) child blocks
  173. this.children = [];
  174. // Insertion control objects - there are one more of these than there are children.
  175. // The control at index n will insert a block at index n
  176. this.inserters = [];
  177. // Incrementing counter used to generate block prefixes, also reflected in the
  178. // 'count' hidden input. This count includes deleted items
  179. this.blockCounter = 0;
  180. this.countInput = dom.find('[data-streamfield-stream-count]');
  181. // Parent element of insert control and block elements (potentially including deleted items,
  182. // which are left behind as hidden elements with a '-deleted' input so that the
  183. // server-side form handler knows to skip it)
  184. this.sequenceContainer = dom.find('[data-streamfield-stream-container]');
  185. this.setState(initialState || []);
  186. if (this.blockDef.meta.collapsed) {
  187. this.children.forEach(block => {
  188. block.collapse();
  189. });
  190. }
  191. this.container = dom;
  192. if (initialError) {
  193. this.setError(initialError);
  194. }
  195. }
  196. /*
  197. * Called whenever a block is added or removed
  198. *
  199. * Updates the state of add / duplicate block buttons to prevent too many blocks being inserted.
  200. */
  201. blockCountChanged() {
  202. super.blockCountChanged();
  203. this.canAddBlock = true;
  204. if (typeof this.blockDef.meta.maxNum === 'number' && this.children.length >= this.blockDef.meta.maxNum) {
  205. this.canAddBlock = false;
  206. }
  207. // If we can add blocks, check if there are any block types that have count limits
  208. this.disabledBlockTypes = new Set();
  209. if (this.canAddBlock) {
  210. for (const blockType in this.blockDef.meta.blockCounts) {
  211. if (this.blockDef.meta.blockCounts.hasOwnProperty(blockType)) {
  212. const counts = this.blockDef.meta.blockCounts[blockType];
  213. if (typeof counts.max_num === 'number') {
  214. const currentBlockCount = this.children.filter(child => child.type === blockType).length;
  215. if (currentBlockCount >= counts.max_num) {
  216. this.disabledBlockTypes.add(blockType);
  217. }
  218. }
  219. }
  220. }
  221. }
  222. for (let i = 0; i < this.children.length; i++) {
  223. const canDuplicate = this.canAddBlock && !this.disabledBlockTypes.has(this.children[i].type);
  224. if (canDuplicate) {
  225. this.children[i].enableDuplication();
  226. } else {
  227. this.children[i].disableDuplication();
  228. }
  229. }
  230. for (let i = 0; i < this.inserters.length; i++) {
  231. this.inserters[i].setNewBlockRestrictions(this.canAddBlock, this.disabledBlockTypes);
  232. }
  233. }
  234. _createChild(blockDef, placeholder, prefix, index, id, initialState, sequence, opts) {
  235. return new StreamChild(blockDef, placeholder, prefix, index, id, initialState, sequence, opts);
  236. }
  237. _createInsertionControl(placeholder, opts) {
  238. // eslint-disable-next-line no-param-reassign
  239. opts.groupedChildBlockDefs = this.blockDef.groupedChildBlockDefs;
  240. return new StreamBlockMenu(placeholder, opts);
  241. }
  242. insert({ type, value, id }, index, opts) {
  243. const childBlockDef = this.blockDef.childBlockDefsByName[type];
  244. return this._insert(childBlockDef, value, id, index, opts);
  245. }
  246. _getChildDataForInsertion({ type }) {
  247. /* Called when an 'insert new block' action is triggered: given a dict of data from the insertion control,
  248. return the block definition and initial state to be used for the new block.
  249. For a StreamBlock, the dict of data consists of 'type' (the chosen block type name, as a string).
  250. */
  251. const blockDef = this.blockDef.childBlockDefsByName[type];
  252. const initialState = this.blockDef.initialChildStates[type];
  253. return [blockDef, initialState, uuidv4()];
  254. }
  255. duplicateBlock(index, opts) {
  256. const child = this.children[index];
  257. const childState = child.getState();
  258. const animate = opts && opts.animate;
  259. childState.id = null;
  260. this.insert(childState, index + 1, { animate, collapsed: child.collapsed });
  261. // focus the newly added field if we can do so without obtrusive UI behaviour
  262. this.children[index + 1].focus({ soft: true });
  263. }
  264. setState(values) {
  265. super.setState(values);
  266. if (values.length === 0) {
  267. /* for an empty list, begin with the menu open */
  268. this.inserters[0].open({ animate: false });
  269. }
  270. }
  271. setError(errorList) {
  272. if (errorList.length !== 1) {
  273. return;
  274. }
  275. const error = errorList[0];
  276. // Non block errors
  277. const container = this.container[0];
  278. container.querySelectorAll(':scope > .help-block.help-critical').forEach(element => element.remove());
  279. if (error.nonBlockErrors.length > 0) {
  280. // Add a help block for each error raised
  281. error.nonBlockErrors.forEach(nonBlockError => {
  282. const errorElement = document.createElement('p');
  283. errorElement.classList.add('help-block');
  284. errorElement.classList.add('help-critical');
  285. errorElement.innerHTML = h(nonBlockError.messages[0]);
  286. container.insertBefore(errorElement, container.childNodes[0]);
  287. });
  288. }
  289. // Block errors
  290. for (const blockIndex in error.blockErrors) {
  291. if (error.blockErrors.hasOwnProperty(blockIndex)) {
  292. this.children[blockIndex].setError(error.blockErrors[blockIndex]);
  293. }
  294. }
  295. }
  296. }
  297. export class StreamBlockDefinition {
  298. constructor(name, groupedChildBlockDefs, initialChildStates, meta) {
  299. this.name = name;
  300. this.groupedChildBlockDefs = groupedChildBlockDefs;
  301. this.initialChildStates = initialChildStates;
  302. this.childBlockDefsByName = {};
  303. // eslint-disable-next-line @typescript-eslint/no-unused-vars
  304. this.groupedChildBlockDefs.forEach(([group, blockDefs]) => {
  305. blockDefs.forEach(blockDef => {
  306. this.childBlockDefsByName[blockDef.name] = blockDef;
  307. });
  308. });
  309. this.meta = meta;
  310. }
  311. render(placeholder, prefix, initialState, initialError) {
  312. return new StreamBlock(this, placeholder, prefix, initialState, initialError);
  313. }
  314. }