fpa.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666
  1. /**
  2. * @file
  3. * JS functionality that creates dynamic CSS which hides permission rows and role columns.
  4. */
  5. // Wrapper normalizes 'jQuery' to '$'.
  6. ;(function fpa_scope($, Drupal, window, document) {
  7. "use strict";
  8. var Fpa = function (context, settings) {
  9. this.init(context, settings);
  10. return this;
  11. };
  12. Fpa.prototype.selector = {
  13. form: '#user-admin-permissions',
  14. table : '#permissions'
  15. };
  16. Fpa.prototype.init = function (context, settings) {
  17. this.drupal_html_class_cache = {};
  18. this.dom = {};
  19. this.attr = settings.attr;
  20. this.filter_timeout= null;
  21. this.filter_timeout_time = 0;
  22. this.module_match = '*=';
  23. this.filter_selector_cache = {
  24. '*=': {},
  25. '~=': {}
  26. };
  27. this.selector.table_base_selector = '.fpa-table-wrapper tr[' + this.attr.module + ']';
  28. this.selector.list_counter_selector = '.fpa-perm-counter';
  29. this.selector.list_base_selector = '.fpa-left-section li[' + this.attr.module + ']';
  30. if (this.select(context)) {
  31. this.prepare();
  32. this.authenticated_role_behavior();
  33. }
  34. };
  35. Fpa.prototype.styles = {
  36. module_active_style: '{margin-right:-1px; background-color: white; border-right: solid 1px transparent;}'
  37. };
  38. /**
  39. * Select all elements that are used by FPA ahead of time and cache on 'Fpa' instance.
  40. */
  41. Fpa.prototype.select = function (context) {
  42. this.dom.context = $(context);
  43. this.dom.form = this.dom.context.find(this.selector.form);
  44. // Prevent anything else from running if the form is not found.
  45. if (this.dom.form.length === 0) {
  46. return false;
  47. }
  48. this.dom.container = this.dom.form.find('.fpa-container');
  49. // Raw element since $().html(); does not work for <style /> elements.
  50. this.dom.perm_style = this.dom.container.find('.fpa-perm-styles style').get(0);
  51. this.dom.role_style = this.dom.container.find('.fpa-role-styles style').get(0);
  52. this.dom.section_left = this.dom.container.find('.fpa-left-section');
  53. this.dom.section_right = this.dom.container.find('.fpa-right-section');
  54. this.dom.table_wrapper = this.dom.section_right.find('.fpa-table-wrapper');
  55. this.dom.table = this.dom.table_wrapper.find(this.selector.table);
  56. this.dom.module_list = this.dom.section_left.find('ul');
  57. this.dom.filter_form = this.dom.container.find('.fpa-filter-form');
  58. this.dom.filter = this.dom.filter_form.find('input[type="text"]');
  59. this.dom.role_select = this.dom.filter_form.find('select');
  60. this.dom.checked_status = this.dom.filter_form.find('input[type="checkbox"]');
  61. return true;
  62. };
  63. /**
  64. * Prepares a string for use as a CSS identifier (element, class, or ID name).
  65. *
  66. * @see https://api.drupal.org/api/drupal/includes!common.inc/function/drupal_clean_css_identifier/7
  67. */
  68. Fpa.prototype.drupal_clean_css_identifier = function (str) {
  69. return str
  70. // replace ' ', '_', '/', '[' with '-'
  71. .replace(/[ _\/\[]/g, '-')
  72. // replace ']' with ''
  73. .replace(/\]/g, '')
  74. // Valid characters in a CSS identifier are:
  75. // - the hyphen (U+002D)
  76. // - a-z (U+0030 - U+0039)
  77. // - A-Z (U+0041 - U+005A)
  78. // - the underscore (U+005F)
  79. // - 0-9 (U+0061 - U+007A)
  80. // - ISO 10646 characters U+00A1 and higher
  81. // We strip out any character not in the above list.
  82. .replace(/[^\u002D\u0030-\u0039\u0041-\u005A\u005F\u0061-\u007A\u00A1-\uFFFF]/, '');
  83. };
  84. /**
  85. * Prepares a string for use as a valid class name.
  86. *
  87. * @see https://api.drupal.org/api/drupal/includes!common.inc/function/drupal_html_class/7
  88. */
  89. Fpa.prototype.drupal_html_class = function (str) {
  90. if (this.drupal_html_class_cache[str] === undefined) {
  91. this.drupal_html_class_cache[str] = this.drupal_clean_css_identifier(str.toLowerCase());
  92. }
  93. return this.drupal_html_class_cache[str];
  94. };
  95. /**
  96. * Handles applying styles to <style /> tags.
  97. */
  98. Fpa.prototype.set_style = (function () {
  99. // Feature detection. Mainly for IE8.
  100. if ($('<style type="text/css" />').get(0).styleSheet) {
  101. return function (element, styles) {
  102. element.styleSheet.cssText = styles;
  103. };
  104. }
  105. // Default that works in modern browsers.
  106. return function (element, styles) {
  107. element.innerHTML = styles;
  108. };
  109. })();
  110. /**
  111. * Callback for click events on module list.
  112. */
  113. Fpa.prototype.filter_module = function (e) {
  114. e.preventDefault();
  115. e.stopPropagation();
  116. var $this = $(e.currentTarget);
  117. this.dom.filter.val([
  118. // remove current module filter string.
  119. this.dom.filter.val().replace(/(@.*)/, ''),
  120. // remove trailing @ as that means no module; clean 'All' filter value
  121. $this.attr(this.attr.module) !== '' ? '@' + $this.find('a[href]').text() : ''
  122. ].join(''));
  123. /**
  124. * ~= matches exactly one whitespace separated word in attribute.
  125. *
  126. * @see http://www.w3.org/TR/CSS2/selector.html#matching-attrs
  127. */
  128. this.module_match = '~=';
  129. this.filter();
  130. };
  131. Fpa.prototype.build_filter_selectors = function (filter_string) {
  132. // Extracts 'permissions@module', trimming leading and trailing whitespace.
  133. var matches = filter_string.match(/^\s*([^@]*)@?(.*?)\s*$/i);
  134. matches.shift(); // Remove whole match item.
  135. var safe_matches = $.map(matches, $.proxy(this.drupal_html_class, this));
  136. this.filter_selector_cache[this.module_match][filter_string] = [
  137. safe_matches[0].length > 0 ? '[' + this.attr.permission + '*="' + safe_matches[0] + '"]' : '',
  138. safe_matches[1].length > 0 ? '[' + this.attr.module + this.module_match + '"' + safe_matches[1] + '"]' : ''
  139. ];
  140. return this.filter_selector_cache[this.module_match][filter_string];
  141. };
  142. Fpa.prototype.get_filter_selectors = function (filter_string) {
  143. filter_string = filter_string || this.dom.filter.val();
  144. return this.filter_selector_cache[this.module_match][filter_string] || this.build_filter_selectors(filter_string);
  145. };
  146. Fpa.prototype.permission_grid_styles = function (filters) {
  147. filters = filters || this.get_filter_selectors();
  148. var checked_filters = this.build_checked_selectors();
  149. var styles = [
  150. this.selector.table_base_selector,
  151. '{display: none;}'
  152. ];
  153. for (var i = 0; i < checked_filters.length; i++) {
  154. styles = styles.concat([
  155. this.selector.table_base_selector,
  156. checked_filters[i],
  157. filters[0],
  158. filters[1],
  159. '{display: table-row;}'
  160. ]);
  161. }
  162. return styles.join('');
  163. };
  164. Fpa.prototype.counter_styles = function (filters) {
  165. filters = filters || this.get_filter_selectors();
  166. return [
  167. this.selector.list_counter_selector,
  168. '{display: none;}',
  169. this.selector.list_counter_selector,
  170. filters[0],
  171. '{display: inline;}'
  172. ].join('');
  173. };
  174. Fpa.prototype.module_list_styles = function (filters) {
  175. filters = filters || this.get_filter_selectors();
  176. return [
  177. this.selector.list_base_selector,
  178. (filters[1].length > 0 ? filters[1] : '[' + this.attr.module + '=""]'),
  179. this.styles.module_active_style
  180. ].join('');
  181. };
  182. Fpa.prototype.filter = function () {
  183. var perm = this.dom.filter.val();
  184. $.cookie('fpa_filter', perm, {path: '/'});
  185. $.cookie('fpa_module_match', this.module_match, {path: '/'});
  186. this.save_filters();
  187. var filter_selector = this.get_filter_selectors(perm);
  188. this.set_style(this.dom.perm_style, [
  189. this.permission_grid_styles(filter_selector),
  190. this.counter_styles(filter_selector),
  191. this.module_list_styles(filter_selector)
  192. ].join(''));
  193. };
  194. Fpa.prototype.build_role_selectors = function (roles) {
  195. roles = roles || this.dom.role_select.val();
  196. var selectors = ['*'];
  197. if ($.inArray('*', roles) === -1) {
  198. selectors = $.map(roles, $.proxy(function (value, index) {
  199. return '[' + this.attr.role + '="' + value + '"]';
  200. }, this));
  201. }
  202. return selectors;
  203. };
  204. Fpa.prototype.build_checked_selectors = function (roles) {
  205. roles = roles || this.dom.role_select.val();
  206. var checked_boxes = $.map(this.dom.checked_status, function (element, index) {
  207. return element.checked ? $(element).val() : null;
  208. });
  209. var selectors = [''];
  210. if ($.inArray('*', roles) !== -1) {
  211. roles = $.map(this.dom.role_select.find('option').not('[value="*"]'), $.proxy(function (element, index) {
  212. return $(element).attr('value');
  213. }, this));
  214. }
  215. if (checked_boxes.length != this.dom.checked_status.length) {
  216. selectors = $.map(roles, $.proxy(function (value, index) {
  217. return $.map(checked_boxes, $.proxy(function (checked_attr, index) {
  218. return '[' + checked_attr + '~="' + value + '"]';
  219. }, this));
  220. }, this));
  221. }
  222. return selectors;
  223. };
  224. // Even handler for role selection.
  225. Fpa.prototype.filter_roles = function () {
  226. this.save_filters();
  227. var values = this.dom.role_select.val() || [];
  228. var role_style_code = [];
  229. $.cookie('fpa_roles', JSON.stringify(values), {path: '/'});
  230. // Only filter if "All Roles" is not selected.
  231. if ($.inArray('*', values) === -1) {
  232. role_style_code.push('.fpa-table-wrapper [' + this.attr.role + '] {display: none;}');
  233. if (values.length > 0) {
  234. var role_selectors = this.build_role_selectors(values);
  235. role_style_code = role_style_code.concat($.map(role_selectors, $.proxy(function (value, index) {
  236. return '.fpa-table-wrapper ' + value + ' {display: table-cell;}';
  237. }, this)));
  238. // Ensure right border on last visible role.
  239. role_style_code.push('.fpa-table-wrapper ' + role_selectors.pop() + ' {border-right: 1px solid #bebfb9;}');
  240. }
  241. else {
  242. role_style_code.push('td[class="permission"] {border-right: 1px solid #bebfb9;}');
  243. }
  244. }
  245. this.set_style(this.dom.role_style, role_style_code.join(''));
  246. this.filter();
  247. };
  248. /**
  249. * Prevent the current filter from being cleared on form reset.
  250. */
  251. Fpa.prototype.save_filters = function () {
  252. /**
  253. * element.defaultValue is what 'text' elements reset to.
  254. *
  255. * @link http://www.w3.org/TR/REC-DOM-Level-1/level-one-html.html#ID-26091157
  256. */
  257. this.dom.filter.get(0).defaultValue = this.dom.filter.val();
  258. /**
  259. * element.defaultSelected is what 'option' elements reset to.
  260. *
  261. * @see http://www.w3.org/TR/REC-DOM-Level-1/level-one-html.html#ID-37770574
  262. */
  263. this.dom.role_select.find('option').each(function (index, element) {
  264. element.defaultSelected = element.selected;
  265. });
  266. /**
  267. * element.defaultChecked is what 'checkbox' elements reset to.
  268. *
  269. * @see http://www.w3.org/TR/REC-DOM-Level-1/level-one-html.html#ID-20509171
  270. */
  271. this.dom.checked_status.each(function (index, element) {
  272. element.defaultChecked = element.checked;
  273. });
  274. };
  275. Fpa.prototype.prepare = function () {
  276. this.filter_timeout_time = Math.min(this.dom.table.find('tr').length, 200);
  277. this.dom.form
  278. .delegate('.fpa-toggle-container a', 'click', $.proxy(function fpa_toggle(e) {
  279. e.preventDefault();
  280. var toggle_class = $(e.currentTarget).attr('fpa-toggle-class');
  281. this.dom.container.toggleClass(toggle_class).hasClass(toggle_class);
  282. }, this))
  283. ;
  284. this.dom.section_left
  285. .delegate('li', 'click', $.proxy(this.filter_module, this))
  286. ;
  287. this.dom.filter
  288. // Prevent Enter/Return from submitting form.
  289. .keypress(function fpa_prevent_form_submission(e) {
  290. if (e.which === 13) {
  291. e.preventDefault();
  292. e.stopPropagation();
  293. }
  294. })
  295. // Prevent non-character keys from triggering filter.
  296. .keyup($.proxy(function fpa_filter_keyup(e) {
  297. // Prevent ['Enter', 'Shift', 'Ctrl', 'Alt'] from triggering filter.
  298. if ($.inArray(e.which, [13, 16, 17, 18]) === -1) {
  299. window.clearTimeout(this.filter_timeout);
  300. this.filter_timeout = window.setTimeout($.proxy(function () {
  301. this.dom.table_wrapper
  302. .detach()
  303. .each($.proxy(function (index, element) {
  304. this.module_match = '*=';
  305. this.filter();
  306. }, this))
  307. .appendTo(this.dom.section_right)
  308. ;
  309. }, this), this.filter_timeout_time);
  310. }
  311. }, this))
  312. ;
  313. // Handle links to sections on permission admin page.
  314. this.dom.form
  315. .delegate('a[href*="admin/people/permissions#"]', 'click', $.proxy(function fpa_inter_page_links_click(e) {
  316. e.preventDefault();
  317. e.stopPropagation();
  318. this.dom.module_list
  319. .find('li[' + this.attr.module + '~="' + this.drupal_html_class(e.currentTarget.hash.substring(8)) + '"]')
  320. .click()
  321. ;
  322. $('body').scrollTop(this.dom.container.position().top);
  323. }, this))
  324. ;
  325. // Handler for links that use #hash and can't be capture server side.
  326. if(window.location.hash.indexOf('module-') === 1) {
  327. this.dom.module_list
  328. .find('li[' + this.attr.module + '~="' + this.drupal_html_class(window.location.hash.substring(8)) + '"]')
  329. .click()
  330. ;
  331. }
  332. /**
  333. * Reset authenticated role behavior when form resets.
  334. *
  335. * @todo should this be synchronous? Would have to trigger reset on elements while detached.
  336. */
  337. this.dom.form.bind('reset', $.proxy(function fpa_form_reset(e) {
  338. // Wait till after the form elements have been reset.
  339. window.setTimeout($.proxy(function fpa_fix_authenticated_behavior() {
  340. this.dom.table_wrapper
  341. .detach() // Don't make numerous changes while elements are in the rendered DOM.
  342. .each($.proxy(function (index, element) {
  343. $(element)
  344. .find('input[type="checkbox"].rid-2')
  345. .each(this.dummy_checkbox_behavior)
  346. ;
  347. }, this))
  348. .appendTo(this.dom.section_right)
  349. ;
  350. }, this), 0);
  351. }, this));
  352. // Role checkboxes toggle all visible permissions for this column.
  353. this.dom.section_right
  354. .delegate('th[' + this.attr.role + '] input[type="checkbox"].fpa-checkboxes-toggle', 'change', $.proxy(function fpa_role_permissions_toggle(e) {
  355. var $this = $(e.currentTarget);
  356. // Get visible rows selectors.
  357. var filters = this.get_filter_selectors(this.dom.filter.val());
  358. this.dom.table_wrapper
  359. .detach()
  360. .each($.proxy(function (index, element) {
  361. var rid = $this.closest('[' + this.attr.role + ']').attr(this.attr.role);
  362. $(element)
  363. .find([
  364. 'tr' + filters.join(''),
  365. 'td.checkbox[' + this.attr.role + '="' + rid + '"]',
  366. 'input[type="checkbox"][name]'
  367. ].join(' ')) // Array is easier to read, separated for descendant selectors.
  368. .attr('checked', $this.attr('checked'))
  369. .filter('.rid-2') // Following only applies to "Authenticated User" role.
  370. .each(this.dummy_checkbox_behavior)
  371. ;
  372. }, this))
  373. .appendTo(this.dom.section_right)
  374. ;
  375. }, this))
  376. ;
  377. // Permission checkboxes toggle all visible permissions for this row.
  378. this.dom.section_right
  379. .delegate('td.permission input[type="checkbox"].fpa-checkboxes-toggle', 'change', $.proxy(function fpa_role_permissions_toggle(e) {
  380. // Get visible rows selectors.
  381. var $row = $(e.currentTarget).closest('tr');
  382. $row.prev('tr').after(
  383. $row
  384. .detach()
  385. .each($.proxy(function (index, element) {
  386. $(element)
  387. .find('td.checkbox')
  388. .filter(this.build_role_selectors().join(','))
  389. .find('input[type="checkbox"][name]')
  390. .attr('checked', e.currentTarget.checked)
  391. .filter('.rid-2') // Following only applies to "Authenticated User" role.
  392. .each(this.dummy_checkbox_behavior)
  393. ;
  394. }, this))
  395. );
  396. }, this))
  397. ;
  398. // Clear contents of search field and reset visible permissions.
  399. this.dom.section_right
  400. .delegate('.fpa-clear-search', 'click', $.proxy(function (e) {
  401. this.dom.filter
  402. .val('')
  403. ;
  404. this.filter();
  405. }, this))
  406. ;
  407. // Change visible roles.
  408. this.dom.role_select
  409. .bind('change blur', $.proxy(this.filter_roles, this))
  410. ;
  411. this.dom.checked_status
  412. .bind('change', $.proxy(function (e) {
  413. this.save_filters();
  414. this.filter();
  415. }, this))
  416. ;
  417. /**
  418. * System name is not normally selectable because its a pseudo-element.
  419. *
  420. * This detects clicks directly on the TR, which happens when a click is on
  421. * the pseudo-element, and displays a prompt() with the system name as the
  422. * pre-populated value.
  423. */
  424. this.dom.table
  425. .delegate('tr[' + this.attr.system_name + ']', 'click', $.proxy(function (e) {
  426. var $target = $(e.target);
  427. if ($target.is('tr[' + this.attr.system_name + ']')) {
  428. window.prompt('You can grab the system name here', $target.attr(this.attr.system_name));
  429. }
  430. }, this))
  431. ;
  432. // Focus on element takes long time, bump after normal execution.
  433. window.setTimeout($.proxy(function fpa_filter_focus() {
  434. this.dom.filter.focus();
  435. }, this), 0);
  436. };
  437. /**
  438. * Event handler/iterator.
  439. *
  440. * Should not be $.proxy()'d.
  441. */
  442. Fpa.prototype.dummy_checkbox_behavior = function () {
  443. // 'this' refers to the element, not the 'Fpa' instance.
  444. $(this).closest('tr').toggleClass('fpa-authenticated-role-behavior', this.checked);
  445. };
  446. Fpa.prototype.authenticated_role_behavior = function () {
  447. this.dom.table_wrapper
  448. .delegate('input[type=checkbox].rid-2', 'mousedown', function (e) {
  449. $(e.currentTarget).unbind('click.permissions');
  450. })
  451. .delegate('input[type=checkbox].rid-2', 'change.fpa_authenticated_role', this.dummy_checkbox_behavior)
  452. ;
  453. };
  454. Drupal.behaviors.fpa = {
  455. attach: function (context, settings) {
  456. // Add touch-screen styling for checkboxes to make easier to use.
  457. if (document.documentElement.ontouchstart !== undefined) {
  458. $(document.body).addClass('fpa-mobile');
  459. }
  460. // Fix table sticky table headers width due to changes in visible roles.
  461. $(window)
  462. .bind('scroll', function fpa_fix_tableheader(e) {
  463. $(e.currentTarget).triggerHandler('resize.drupal-tableheader');
  464. })
  465. ;
  466. new Fpa(context, settings.fpa);
  467. }
  468. };
  469. // Override Drupal core's Authenticated role checkbox behavior.
  470. Drupal.behaviors.permissions.attach = $.noop;
  471. // Drupal.behaviors.formUpdated.attach = $.noop;
  472. })(jQuery, Drupal, window, document);