gitea源码

common-button.ts 9.0KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import {POST} from '../modules/fetch.ts';
  2. import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
  3. import {fomanticQuery} from '../modules/fomantic/base.ts';
  4. import {camelize} from 'vue';
  5. export function initGlobalButtonClickOnEnter(): void {
  6. addDelegatedEventListener(document, 'keypress', 'div.ui.button, span.ui.button', (el, e: KeyboardEvent) => {
  7. if (e.code === 'Space' || e.code === 'Enter') {
  8. e.preventDefault();
  9. el.click();
  10. }
  11. });
  12. }
  13. export function initGlobalDeleteButton(): void {
  14. // ".delete-button" shows a confirmation modal defined by `data-modal-id` attribute.
  15. // Some model/form elements will be filled by `data-id` / `data-name` / `data-data-xxx` attributes.
  16. // If there is a form defined by `data-form`, then the form will be submitted as-is (without any modification).
  17. // If there is no form, then the data will be posted to `data-url`.
  18. // TODO: do not use this method in new code. `show-modal` / `link-action(data-modal-confirm)` does far better than this.
  19. // FIXME: all legacy `delete-button` should be refactored to use `show-modal` or `link-action`
  20. for (const btn of document.querySelectorAll<HTMLElement>('.delete-button')) {
  21. btn.addEventListener('click', (e) => {
  22. e.preventDefault();
  23. // eslint-disable-next-line github/no-dataset -- code depends on the camel-casing
  24. const dataObj = btn.dataset;
  25. const modalId = btn.getAttribute('data-modal-id');
  26. const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`);
  27. // set the modal "display name" by `data-name`
  28. const modalNameEl = modal.querySelector('.name');
  29. if (modalNameEl) modalNameEl.textContent = btn.getAttribute('data-name');
  30. // fill the modal elements with data-xxx attributes: `data-data-organization-name="..."` => `<span class="dataOrganizationName">...</span>`
  31. for (const [key, value] of Object.entries(dataObj)) {
  32. if (key.startsWith('data')) {
  33. const textEl = modal.querySelector(`.${key}`);
  34. if (textEl) textEl.textContent = value;
  35. }
  36. }
  37. fomanticQuery(modal).modal({
  38. closable: false,
  39. onApprove: () => {
  40. // if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
  41. if (btn.getAttribute('data-type') === 'form') {
  42. const formSelector = btn.getAttribute('data-form');
  43. const form = document.querySelector<HTMLFormElement>(formSelector);
  44. if (!form) throw new Error(`no form named ${formSelector} found`);
  45. modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal
  46. form.classList.add('is-loading');
  47. form.submit();
  48. return false; // prevent modal from closing automatically
  49. }
  50. // prepare an AJAX form by data attributes
  51. const postData = new FormData();
  52. for (const [key, value] of Object.entries(dataObj)) {
  53. if (key.startsWith('data')) { // for data-data-xxx (HTML) -> dataXxx (form)
  54. postData.append(key.slice(4), value);
  55. }
  56. if (key === 'id') { // for data-id="..."
  57. postData.append('id', value);
  58. }
  59. }
  60. (async () => {
  61. const response = await POST(btn.getAttribute('data-url'), {data: postData});
  62. if (response.ok) {
  63. const data = await response.json();
  64. window.location.href = data.redirect;
  65. }
  66. })();
  67. modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal
  68. return false; // prevent modal from closing automatically
  69. },
  70. }).modal('show');
  71. });
  72. }
  73. }
  74. function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
  75. // a '.show-panel' element can show a panel, by `data-panel="selector"`
  76. // if it has "toggle" class, it toggles the panel
  77. e.preventDefault();
  78. const sel = el.getAttribute('data-panel');
  79. const elems = el.classList.contains('toggle') ? toggleElem(sel) : showElem(sel);
  80. for (const elem of elems) {
  81. if (isElemVisible(elem as HTMLElement)) {
  82. elem.querySelector<HTMLElement>('[autofocus]')?.focus();
  83. }
  84. }
  85. }
  86. function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
  87. // a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"`
  88. e.preventDefault();
  89. let sel = el.getAttribute('data-panel');
  90. if (sel) {
  91. hideElem(sel);
  92. return;
  93. }
  94. sel = el.getAttribute('data-panel-closest');
  95. if (sel) {
  96. hideElem((el.parentNode as HTMLElement).closest(sel));
  97. return;
  98. }
  99. throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code
  100. }
  101. export type ElementWithAssignableProperties = {
  102. getAttribute: (name: string) => string | null;
  103. setAttribute: (name: string, value: string) => void;
  104. } & Record<string, any>;
  105. export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) {
  106. const camelizedName = camelize(kebabName);
  107. const old = el[camelizedName];
  108. if (typeof old === 'boolean') {
  109. el[camelizedName] = val === 'true';
  110. } else if (typeof old === 'number') {
  111. el[camelizedName] = parseFloat(val);
  112. } else if (typeof old === 'string') {
  113. el[camelizedName] = val;
  114. } else if (old?.nodeName) {
  115. // "form" has an edge case: its "<input name=action>" element overwrites the "action" property, we can only set attribute
  116. el.setAttribute(kebabName, val);
  117. } else {
  118. // in the future, we could introduce a better typing system like `data-modal-form.action:string="..."`
  119. throw new Error(`cannot assign element property "${camelizedName}" by value "${val}"`);
  120. }
  121. }
  122. function onShowModalClick(el: HTMLElement, e: MouseEvent) {
  123. // A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute.
  124. // Each "data-modal-{target}" attribute will be filled to target element's value or text-content.
  125. // * First, try to query '#target'
  126. // * Then, try to query '[name=target]'
  127. // * Then, try to query '.target'
  128. // * Then, try to query 'target' as HTML tag
  129. // If there is a ".{prop-name}" part like "data-modal-form.action", the "form" element's "action" property will be set, the "prop-name" will be camel-cased to "propName".
  130. e.preventDefault();
  131. const modalSelector = el.getAttribute('data-modal');
  132. const elModal = document.querySelector(modalSelector);
  133. if (!elModal) throw new Error('no modal for this action');
  134. const modalAttrPrefix = 'data-modal-';
  135. for (const attrib of el.attributes) {
  136. if (!attrib.name.startsWith(modalAttrPrefix)) {
  137. continue;
  138. }
  139. const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
  140. const [attrTargetName, attrTargetProp] = attrTargetCombo.split('.');
  141. // try to find target by: "#target" -> "[name=target]" -> ".target" -> "<target> tag", and then try the modal itself
  142. const attrTarget = elModal.querySelector(`#${attrTargetName}`) ||
  143. elModal.querySelector(`[name=${attrTargetName}]`) ||
  144. elModal.querySelector(`.${attrTargetName}`) ||
  145. elModal.querySelector(`${attrTargetName}`) ||
  146. (elModal.matches(`${attrTargetName}`) || elModal.matches(`#${attrTargetName}`) || elModal.matches(`.${attrTargetName}`) ? elModal : null);
  147. if (!attrTarget) {
  148. if (!window.config.runModeIsProd) throw new Error(`attr target "${attrTargetCombo}" not found for modal`);
  149. continue;
  150. }
  151. if (attrTargetProp) {
  152. assignElementProperty(attrTarget, attrTargetProp, attrib.value);
  153. } else if (attrTarget.matches('input, textarea')) {
  154. (attrTarget as HTMLInputElement | HTMLTextAreaElement).value = attrib.value; // FIXME: add more supports like checkbox
  155. } else {
  156. attrTarget.textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p
  157. }
  158. }
  159. fomanticQuery(elModal).modal('show');
  160. }
  161. export function initGlobalButtons(): void {
  162. // There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form.
  163. // However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission.
  164. // There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content")
  165. addDelegatedEventListener(document, 'click', 'form button.ui.cancel.button', (_ /* el */, e) => e.preventDefault());
  166. // Ideally these "button" events should be handled by registerGlobalEventFunc
  167. // Refactoring would involve too many changes, so at the moment, just use the global event listener.
  168. addDelegatedEventListener(document, 'click', '.show-panel, .hide-panel, .show-modal', (el, e: MouseEvent) => {
  169. if (el.classList.contains('show-panel')) {
  170. onShowPanelClick(el, e);
  171. } else if (el.classList.contains('hide-panel')) {
  172. onHidePanelClick(el, e);
  173. } else if (el.classList.contains('show-modal')) {
  174. onShowModalClick(el, e);
  175. }
  176. });
  177. }