Gitlab plus

Gitlab utils

As of 2025-02-21. See the latest version.

  1. // ==UserScript==
  2. // @name Gitlab plus
  3. // @namespace https://lukaszmical.pl/
  4. // @version 2025-02-21
  5. // @description Gitlab utils
  6. // @author Łukasz Micał
  7. // @match https://gitlab.com/*
  8. // @require https://cdn.jsdelivr.net/combine/npm/preact@10.25.4/dist/preact.min.umd.min.js,npm/preact@10.25.4/hooks/dist/hooks.umd.min.js,npm/preact@10.25.4/jsx-runtime/dist/jsxRuntime.umd.min.js
  9. // @icon https://www.google.com/s2/favicons?sz=64&domain=gitlab.com
  10. // ==/UserScript==
  11.  
  12. // Vite helpers
  13. const __defProp = Object.defineProperty;
  14. const __defNormalProp = (obj, key, value) =>
  15. key in obj
  16. ? __defProp(obj, key, {
  17. enumerable: true,
  18. configurable: true,
  19. writable: true,
  20. value,
  21. })
  22. : (obj[key] = value);
  23. const __publicField = (obj, key, value) =>
  24. __defNormalProp(obj, typeof key !== 'symbol' ? key + '' : key, value);
  25.  
  26. // App code
  27. const { jsx, jsxs, Fragment } = this.jsxRuntime;
  28. const { render } = this.preact;
  29. const { useMemo, useState, useEffect, useRef, useCallback, useLayoutEffect } =
  30. this.preactHooks;
  31.  
  32. // libs/share/src/ui/GlobalStyle.ts
  33. class GlobalStyle {
  34. static addStyle(key, styles) {
  35. const style =
  36. document.getElementById(key) ||
  37. (function () {
  38. const style22 = document.createElement('style');
  39. style22.id = key;
  40. document.head.appendChild(style22);
  41. return style22;
  42. })();
  43. style.textContent = styles;
  44. }
  45. }
  46.  
  47. const style1 =
  48. '.glp-modal {\n position: fixed;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n z-index: 99999;\n display: none;\n background: rgba(0, 0, 0, 0.6);\n justify-content: center;\n align-items: center;\n}\n\n.glp-modal.glp-modal-visible {\n display: flex;\n}\n\n.glp-modal .glp-modal-content {\n width: 700px;\n max-width: 95vw;\n}\n\n.gl-new-dropdown-item.glp-active .gl-new-dropdown-item-content {\n box-shadow: inset 0 0 0 2px var(--gl-focus-ring-outer-color), inset 0 0 0 3px var(--gl-focus-ring-inner-color), inset 0 0 0 1px var(--gl-focus-ring-inner-color);\n background-color: var(--gl-dropdown-option-background-color-unselected-hover);\n outline: none;\n}\n\n';
  49. const style2 =
  50. '.glp-image-preview-modal {\n position: fixed;\n display: flex;\n justify-content: center;\n align-items: center;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.6);\n visibility: hidden;\n opacity: 0;\n pointer-events: none;\n z-index: 99999;\n}\n\n.glp-image-preview-modal.glp-modal-visible {\n visibility: visible;\n opacity: 1;\n pointer-events: auto;\n}\n\n.glp-image-preview-modal .glp-modal-img {\n max-width: 95%;\n max-height: 95%;\n}\n\n.glp-image-preview-modal .glp-modal-close {\n position: absolute;\n z-index: 2;\n top: 20px;\n right: 20px;\n color: black;\n width: 40px;\n height: 40px;\n display: flex;\n justify-content: center;\n align-items: center;\n background: white;\n border-radius: 20px;\n cursor: pointer;\n}\n\n';
  51. const style3 =
  52. '.glp-preview-modal {\n position: fixed;\n display: flex;\n background-color: var(--gl-background-color-default, var(--gl-color-neutral-0, #fff));\n border: 1px solid var(--gl-border-color-default);\n border-radius: .25rem;\n width: 350px;\n min-height: 200px;\n z-index: 99999;\n visibility: hidden;\n top: 0;\n left: 0;\n opacity: 0;\n transition: all .2s ease-out;\n transition-property: visibility, opacity, transform;\n}\n\n.glp-preview-modal.glp-modal-visible {\n visibility: visible;\n opacity: 1;\n}\n \n.glp-preview-modal .glp-block {\n padding: .4rem .5em;\n border-bottom-style: solid;\n border-bottom-color: var(--gl-border-color-subtle, var(--gl-color-neutral-50, #ececef));\n border-bottom-width: 1px;\n width: 100%;\n}\n\n\n.glp-preview-modal * {\n max-width: 100%;\n}\n';
  53.  
  54. // apps/gitlab-plus/src/styles/index.ts
  55. GlobalStyle.addStyle('glp-style', [style1, style2, style3].join('\n'));
  56.  
  57. // libs/share/src/store/Store.ts
  58. class Store {
  59. constructor(key) {
  60. this.key = key;
  61. }
  62.  
  63. decode(val) {
  64. return JSON.parse(val);
  65. }
  66.  
  67. encode(val) {
  68. return JSON.stringify(val);
  69. }
  70.  
  71. get(defaultValue = void 0) {
  72. try {
  73. const data = localStorage.getItem(this.key);
  74. if (data) {
  75. return this.decode(data);
  76. }
  77. return defaultValue;
  78. } catch (e) {
  79. return defaultValue;
  80. }
  81. }
  82.  
  83. remove() {
  84. localStorage.removeItem(this.key);
  85. }
  86.  
  87. set(value) {
  88. try {
  89. localStorage.setItem(this.key, this.encode(value));
  90. } catch (e) {}
  91. }
  92. }
  93.  
  94. // apps/gitlab-plus/src/services/ServiceName.ts
  95. var ServiceName = ((ServiceName2) => {
  96. ServiceName2['ClearCacheService'] = 'ClearCacheService';
  97. ServiceName2['CreateChildIssue'] = 'CreateChildIssue';
  98. ServiceName2['CreateRelatedIssue'] = 'CreateRelatedIssue';
  99. ServiceName2['EpicPreview'] = 'EpicPreview';
  100. ServiceName2['ImagePreview'] = 'ImagePreview';
  101. ServiceName2['IssuePreview'] = 'IssuePreview';
  102. ServiceName2['MrPreview'] = 'MrPreview';
  103. ServiceName2['RelatedIssueAutocomplete'] = 'RelatedIssueAutocomplete';
  104. ServiceName2['RelatedIssuesLabelStatus'] = 'RelatedIssuesLabelStatus';
  105. ServiceName2['SortIssue'] = 'SortIssue';
  106. ServiceName2['UserSettings'] = 'UserSettings';
  107. return ServiceName2;
  108. })(ServiceName || {});
  109. const servicesConfig = {
  110. ['ClearCacheService']: { label: 'Clear cache', required: true },
  111. ['CreateChildIssue']: {
  112. label: 'Create child issue form on epic page',
  113. },
  114. ['CreateRelatedIssue']: {
  115. label: 'Create related issue form on issue page',
  116. },
  117. ['EpicPreview']: { label: 'Epic preview modal' },
  118. ['ImagePreview']: { label: 'Image preview modal' },
  119. ['IssuePreview']: { label: 'Issue preview modal' },
  120. ['MrPreview']: { label: 'Merge request preview modal' },
  121. ['RelatedIssueAutocomplete']: {
  122. label: 'Related issue autocomplete in related issues input',
  123. },
  124. ['RelatedIssuesLabelStatus']: {
  125. label: 'Label status in related issues list items',
  126. },
  127. ['SortIssue']: {
  128. experimental: true,
  129. label: 'Sort issues in board',
  130. },
  131. ['UserSettings']: { label: 'User settings', required: true },
  132. };
  133.  
  134. // apps/gitlab-plus/src/components/user-settings/UserSettingsStore.ts
  135. class UserSettingsStore {
  136. constructor() {
  137. __publicField(this, 'settings', {});
  138. __publicField(this, 'store', new Store('gitlab-plus-settings'));
  139. this.load();
  140. }
  141.  
  142. isActive(name2) {
  143. if (!(name2 in servicesConfig)) {
  144. return false;
  145. }
  146. if (servicesConfig[name2].required) {
  147. return true;
  148. }
  149. if (servicesConfig[name2].experimental) {
  150. return this.getItem(name2, false);
  151. }
  152. return this.getItem(name2, true);
  153. }
  154.  
  155. setIsActive(name2, value) {
  156. this.setItem(name2, value);
  157. }
  158.  
  159. getItem(key, defaultValue) {
  160. if (this.settings[key] === void 0) {
  161. return defaultValue;
  162. }
  163. return this.settings[key];
  164. }
  165.  
  166. load() {
  167. this.settings = this.store.get() || {};
  168. }
  169.  
  170. persist() {
  171. this.store.set(this.settings);
  172. }
  173.  
  174. setItem(key, value) {
  175. this.settings[key] = value;
  176. this.persist();
  177. }
  178. }
  179.  
  180. const userSettingsStore = new UserSettingsStore();
  181.  
  182. // libs/share/src/store/Cache.ts
  183. class Cache {
  184. constructor(prefix) {
  185. this.prefix = prefix;
  186. }
  187.  
  188. clearInvalid() {
  189. for (const key in localStorage) {
  190. if (key.startsWith(this.prefix) && !this.isValid(this.getItem(key))) {
  191. localStorage.removeItem(key);
  192. }
  193. }
  194. }
  195.  
  196. expirationDate(minutes) {
  197. if (typeof minutes === 'string') {
  198. return minutes;
  199. }
  200. const time = new Date();
  201. time.setMinutes(time.getMinutes() + minutes);
  202. return time;
  203. }
  204.  
  205. get(key) {
  206. try {
  207. const data = this.getItem(this.key(key));
  208. if (this.isValid(data)) {
  209. return data.value;
  210. }
  211. } catch (e) {
  212. return void 0;
  213. }
  214. return void 0;
  215. }
  216.  
  217. key(key) {
  218. return `${this.prefix}${key}`;
  219. }
  220.  
  221. set(key, value, minutes) {
  222. localStorage.setItem(
  223. this.key(key),
  224. JSON.stringify({
  225. expirationDate: this.expirationDate(minutes),
  226. value,
  227. })
  228. );
  229. }
  230.  
  231. getItem(key) {
  232. try {
  233. return JSON.parse(localStorage.getItem(key) || '');
  234. } catch (e) {
  235. return void 0;
  236. }
  237. }
  238.  
  239. isValid(item) {
  240. if (item) {
  241. return (
  242. item.expirationDate === 'lifetime' ||
  243. new Date(item.expirationDate) > new Date()
  244. );
  245. }
  246. return false;
  247. }
  248. }
  249.  
  250. // apps/gitlab-plus/src/services/BaseService.ts
  251. class BaseService {
  252. root(className, parent, usePrepend = false) {
  253. const root = document.createElement('div');
  254. root.classList.add(className);
  255. if (parent) {
  256. parent[usePrepend ? 'prepend' : 'append'](root);
  257. }
  258. return root;
  259. }
  260.  
  261. rootBody(className) {
  262. return this.root(className, document.body);
  263. }
  264. }
  265.  
  266. // apps/gitlab-plus/src/services/ClearCacheService.ts
  267. class ClearCacheService extends BaseService {
  268. constructor() {
  269. super(...arguments);
  270. __publicField(this, 'name', ServiceName.ClearCacheService);
  271. __publicField(this, 'cache', new Cache('glp-'));
  272. }
  273.  
  274. init() {
  275. this.cache.clearInvalid();
  276. window.setInterval(this.cache.clearInvalid.bind(this.cache), 60 * 1e3);
  277. }
  278. }
  279.  
  280. // libs/share/src/utils/clsx.ts
  281. function clsx(...args) {
  282. return args
  283. .map((item) => {
  284. if (!item) {
  285. return '';
  286. }
  287. if (typeof item === 'string') {
  288. return item;
  289. }
  290. if (Array.isArray(item)) {
  291. return clsx(...item);
  292. }
  293. if (typeof item === 'object') {
  294. return clsx(
  295. Object.entries(item)
  296. .filter(([_, value]) => value)
  297. .map(([key]) => key)
  298. );
  299. }
  300. return '';
  301. })
  302. .filter(Boolean)
  303. .join(' ');
  304. }
  305.  
  306. // apps/gitlab-plus/src/components/common/GitlabIcon.tsx
  307. const buildId =
  308. '236e3b687d786d9dfe4709143a94d4c53b8d5a1f235775401e5825148297fa84';
  309. const iconUrl = (icon) => {
  310. let _a;
  311. const svgSprite =
  312. ((_a = unsafeWindow.gon) == null ? void 0 : _a.sprite_icons) ||
  313. `/assets/icons-${buildId}.svg`;
  314. return `${svgSprite}#${icon}`;
  315. };
  316.  
  317. function GitlabIcon({ className, icon, size = 12, title }) {
  318. return jsx('svg', {
  319. className: clsx('gl-icon gl-fill-current', `s${size}`, className),
  320. title,
  321. children: jsx('use', { href: iconUrl(icon) }),
  322. });
  323. }
  324.  
  325. // apps/gitlab-plus/src/components/common/GitlabLoader.tsx
  326. function GitlabLoader({ size = 24 }) {
  327. return jsx('span', {
  328. class: 'gl-spinner-container',
  329. role: 'status',
  330. children: jsx('span', {
  331. class: 'gl-spinner gl-spinner-sm gl-spinner-dark !gl-align-text-bottom',
  332. style: {
  333. width: size,
  334. height: size,
  335. },
  336. }),
  337. });
  338. }
  339.  
  340. // apps/gitlab-plus/src/components/common/GitlabButton.tsx
  341. const buttonVariantClass = {
  342. default: 'btn-default',
  343. info: 'btn-confirm',
  344. tertiary: 'btn-default-tertiary',
  345. };
  346.  
  347. function GitlabButton({
  348. children,
  349. className,
  350. icon,
  351. iconSize = 12,
  352. isLoading,
  353. onClick,
  354. size = 'sm',
  355. title,
  356. variant = 'default',
  357. }) {
  358. const IconComponent = useMemo(() => {
  359. if (isLoading) {
  360. return jsx(GitlabLoader, { size: iconSize });
  361. }
  362. if (icon) {
  363. return jsx(GitlabIcon, { icon, size: iconSize });
  364. }
  365. return null;
  366. }, [icon, isLoading]);
  367. return jsxs('button', {
  368. onClick,
  369. title,
  370. type: 'button',
  371. class: clsx(
  372. `btn btn-${size} gl-button`,
  373. buttonVariantClass[variant],
  374. className
  375. ),
  376. children: [
  377. children && jsx('span', { class: 'gl-button-text', children }),
  378. IconComponent,
  379. ],
  380. });
  381. }
  382.  
  383. // apps/gitlab-plus/src/components/common/CloseButton.tsx
  384. function CloseButton({ onClick, title = 'Close' }) {
  385. return jsx(GitlabButton, {
  386. className: 'btn-icon',
  387. icon: 'close-xs',
  388. iconSize: 16,
  389. onClick,
  390. title,
  391. variant: 'tertiary',
  392. });
  393. }
  394.  
  395. // apps/gitlab-plus/src/components/common/modal/GlpModal.tsx
  396. function GlpModal({ children, isVisible, onClose, title }) {
  397. return jsx('div', {
  398. class: clsx('glp-modal', isVisible && 'glp-modal-visible'),
  399. children: jsxs('div', {
  400. className: clsx(
  401. 'glp-modal-content crud gl-border',
  402. 'gl-rounded-form gl-border-section gl-bg-subtle gl-mt-5'
  403. ),
  404. children: [
  405. jsxs('div', {
  406. className: clsx(
  407. 'crud-header gl-border-b gl-flex gl-flex-wrap',
  408. 'gl-justify-between gl-gap-x-5 gl-gap-y-2 gl-rounded-t-form',
  409. 'gl-border-section gl-bg-section gl-px-5 gl-py-4 gl-relative'
  410. ),
  411. children: [
  412. jsx('h2', {
  413. className: clsx(
  414. 'gl-m-0 gl-inline-flex gl-items-center gl-gap-3',
  415. 'gl-text-form gl-font-bold gl-leading-normal'
  416. ),
  417. children: title,
  418. }),
  419. jsx(CloseButton, { onClick: onClose }),
  420. ],
  421. }),
  422. children,
  423. ],
  424. }),
  425. });
  426. }
  427.  
  428. // apps/gitlab-plus/src/components/common/modal/useGlpModal.ts
  429. function useGlpModal(eventName) {
  430. const [isVisible, setIsVisible] = useState(false);
  431. useEffect(() => {
  432. document.addEventListener(eventName, () => setIsVisible(true));
  433. }, []);
  434. return {
  435. isVisible,
  436. onClose: () => setIsVisible(false),
  437. };
  438. }
  439.  
  440. // apps/gitlab-plus/src/components/common/base/Text.tsx
  441. function Text({ children, className, color, size, variant, weight }) {
  442. return jsx('span', {
  443. class: clsx(
  444. size && `gl-text-${size}`,
  445. weight && `gl-font-${weight}`,
  446. variant && `gl-text-${variant}`,
  447. color && `gl-text-${color}`,
  448. className
  449. ),
  450. children,
  451. });
  452. }
  453.  
  454. // apps/gitlab-plus/src/components/common/form/FormField.tsx
  455. function FormField({ children, error, hint, title }) {
  456. return jsxs('fieldset', {
  457. class: clsx(
  458. 'form-group gl-form-group gl-w-full',
  459. error && 'gl-show-field-errors'
  460. ),
  461. children: [
  462. jsx('legend', {
  463. class: 'bv-no-focus-ring col-form-label pt-0 col-form-label',
  464. children: title,
  465. }),
  466. children,
  467. Boolean(!error && hint) && jsx('small', { children: hint }),
  468. Boolean(error) &&
  469. jsx('small', {
  470. class: 'gl-field-error',
  471. children: error,
  472. }),
  473. ],
  474. });
  475. }
  476.  
  477. // apps/gitlab-plus/src/components/common/form/FormRow.tsx
  478. function FormRow({ children }) {
  479. return jsx('div', { class: 'gl-flex gl-gap-x-3', children });
  480. }
  481.  
  482. // libs/share/src/utils/camelizeKeys.ts
  483. function camelizeKeys(data) {
  484. if (!data || ['string', 'number', 'boolean'].includes(typeof data)) {
  485. return data;
  486. }
  487. if (Array.isArray(data)) {
  488. return data.map(camelizeKeys);
  489. }
  490. const camelize = (key) => {
  491. const _key = key.replace(/[-_\s]+(.)?/g, (_, chr) =>
  492. chr ? chr.toUpperCase() : ''
  493. );
  494. return _key.substring(0, 1).toLowerCase() + _key.substring(1);
  495. };
  496. return Object.entries(data).reduce(
  497. (result, [key, value]) => ({
  498. ...result,
  499. [camelize(key)]: camelizeKeys(value),
  500. }),
  501. {}
  502. );
  503. }
  504.  
  505. // apps/gitlab-plus/src/providers/GitlabProvider.ts
  506. class GitlabProvider {
  507. constructor(force = false) {
  508. __publicField(this, 'cache', new Cache('glp-'));
  509. __publicField(this, 'graphqlApi', 'https://gitlab.com/api/graphql');
  510. __publicField(this, 'url', 'https://gitlab.com/api/v4/');
  511. this.force = force;
  512. }
  513.  
  514. async cached(key, getValue, minutes) {
  515. const cacheValue = this.cache.get(key);
  516. if (cacheValue && !this.force) {
  517. return cacheValue;
  518. }
  519. const value = await getValue();
  520. this.cache.set(key, value, minutes);
  521. return value;
  522. }
  523.  
  524. csrf() {
  525. const token = document.querySelector('meta[name=csrf-token]');
  526. if (token) {
  527. return token.getAttribute('content');
  528. }
  529. return '';
  530. }
  531.  
  532. async get(path) {
  533. const response = await fetch(`${this.url}${path}`, {
  534. headers: this.headers(),
  535. method: 'GET',
  536. });
  537. const data = await response.json();
  538. return camelizeKeys(data);
  539. }
  540.  
  541. async getCached(key, path, minutes) {
  542. return this.cached(key, () => this.get(path), minutes);
  543. }
  544.  
  545. headers() {
  546. const headers = {
  547. 'content-type': 'application/json',
  548. };
  549. const csrf = this.csrf();
  550. if (csrf) {
  551. headers['X-CSRF-Token'] = csrf;
  552. }
  553. return headers;
  554. }
  555.  
  556. async post(path, body) {
  557. const response = await fetch(`${this.url}${path}`, {
  558. body: JSON.stringify(body),
  559. headers: this.headers(),
  560. method: 'POST',
  561. });
  562. const data = await response.json();
  563. return camelizeKeys(data);
  564. }
  565.  
  566. async query(query, variables) {
  567. const response = await fetch(this.graphqlApi, {
  568. body: JSON.stringify({ query, variables }),
  569. headers: this.headers(),
  570. method: 'POST',
  571. });
  572. return response.json();
  573. }
  574.  
  575. async queryCached(key, query, variables, minutes) {
  576. return this.cached(key, () => this.query(query, variables), minutes);
  577. }
  578. }
  579.  
  580. // apps/gitlab-plus/src/providers/query/user.ts
  581. const userFragment = `
  582. fragment User on User {
  583. id
  584. avatarUrl
  585. name
  586. username
  587. webUrl
  588. webPath
  589. __typename
  590. }
  591. `;
  592. const userQuery = `
  593. query workspaceAutocompleteUsersSearch($search: String!, $fullPath: ID!, $isProject: Boolean = true) {
  594. groupWorkspace: group(fullPath: $fullPath) @skip(if: $isProject) {
  595. id
  596. users: autocompleteUsers(search: $search) {
  597. ...User
  598. ...UserAvailability
  599. __typename
  600. }
  601. __typename
  602. }
  603. workspace: project(fullPath: $fullPath) {
  604. id
  605. users: autocompleteUsers(search: $search) {
  606. ...User
  607. ...UserAvailability
  608. __typename
  609. }
  610. __typename
  611. }
  612. }
  613.  
  614. ${userFragment}
  615. fragment UserAvailability on User {
  616. status {
  617. availability
  618. __typename
  619. }
  620. __typename
  621. }
  622. `;
  623.  
  624. // apps/gitlab-plus/src/providers/UsersProvider.ts
  625. class UsersProvider extends GitlabProvider {
  626. async getUsers(projectId, search = '') {
  627. return this.queryCached(
  628. `users-${projectId}-${search}`,
  629. userQuery,
  630. {
  631. fullPath: projectId,
  632. search,
  633. },
  634. search === '' ? 20 : 0.5
  635. );
  636. }
  637. }
  638.  
  639. // apps/gitlab-plus/src/components/common/form/autocomplete/useAsyncAutocompleteButton.ts
  640. function useAsyncAutocompleteButton(hide) {
  641. const ref = useRef(null);
  642. useEffect(() => {
  643. document.body.addEventListener('click', (e) => {
  644. if (
  645. ref.current &&
  646. e.target !== ref.current &&
  647. !ref.current.contains(e.target)
  648. ) {
  649. hide();
  650. }
  651. });
  652. }, []);
  653. return ref;
  654. }
  655.  
  656. // apps/gitlab-plus/src/components/common/form/autocomplete/AsyncAutocompleteButton.tsx
  657. function AsyncAutocompleteButton({
  658. isOpen,
  659. renderLabel,
  660. reset,
  661. setIsOpen,
  662. size = 'md',
  663. value,
  664. }) {
  665. const ref = useAsyncAutocompleteButton(() => setIsOpen(false));
  666. const icon = useMemo(() => {
  667. if (value.length) {
  668. return 'close-xs';
  669. }
  670. return isOpen ? 'chevron-lg-up' : 'chevron-lg-down';
  671. }, [isOpen, value]);
  672. return jsx('button', {
  673. class: `btn btn-default btn-${size} btn-block gl-button gl-new-dropdown-toggle`,
  674. ref,
  675. type: 'button',
  676. onClick: (e) => {
  677. e.preventDefault();
  678. setIsOpen(true);
  679. },
  680. children: jsxs('span', {
  681. class: 'gl-button-text gl-w-full',
  682. children: [
  683. jsx('span', {
  684. class: 'gl-new-dropdown-button-text',
  685. children: renderLabel(value),
  686. }),
  687. jsx('span', {
  688. onClick: (e) => {
  689. if (value.length) {
  690. e.preventDefault();
  691. reset();
  692. }
  693. },
  694. children: jsx(GitlabIcon, { icon, size: 16 }),
  695. }),
  696. ],
  697. }),
  698. });
  699. }
  700.  
  701. // apps/gitlab-plus/src/components/common/form/autocomplete/AsyncAutocompleteOption.tsx
  702. function AsyncAutocompleteOption({
  703. hideCheckbox = false,
  704. isActive,
  705. onClick,
  706. option,
  707. removeFromRecent,
  708. renderOption,
  709. selected,
  710. }) {
  711. const selectedIds = selected.map((i) => i.id);
  712. const selectedClass = (id) => selectedIds.includes(id);
  713. return jsx('li', {
  714. onClick: () => onClick(option),
  715. class: clsx(
  716. 'gl-new-dropdown-item', // selectedClass(option.id),
  717. isActive && 'glp-active'
  718. ),
  719. children: jsxs('span', {
  720. class: 'gl-new-dropdown-item-content',
  721. children: [
  722. !hideCheckbox &&
  723. jsx(GitlabIcon, {
  724. className: 'glp-item-check gl-pr-2',
  725. icon: selectedClass(option.id) ? 'mobile-issue-close' : '',
  726. size: 16,
  727. }),
  728. renderOption(option),
  729. removeFromRecent &&
  730. jsx(CloseButton, {
  731. title: 'Remove from recently used',
  732. onClick: (e) => {
  733. e.preventDefault();
  734. e.stopPropagation();
  735. removeFromRecent(option);
  736. },
  737. }),
  738. ],
  739. }),
  740. });
  741. }
  742.  
  743. // apps/gitlab-plus/src/components/common/form/autocomplete/AsyncAutocompleteList.tsx
  744. function AsyncAutocompleteList({
  745. hideCheckbox,
  746. activeIndex,
  747. onClick,
  748. options,
  749. recently,
  750. removeRecently,
  751. renderOption,
  752. value,
  753. }) {
  754. return jsx('div', {
  755. onClick: (e) => e.stopPropagation(),
  756. class:
  757. 'gl-new-dropdown-contents gl-new-dropdown-contents-with-scrim-overlay bottom-scrim-visible gl-new-dropdown-contents',
  758. style: {
  759. maxWidth: '800px',
  760. width: '100%',
  761. left: '0',
  762. top: '100%',
  763. },
  764. children: jsx('div', {
  765. class: 'gl-new-dropdown-inner',
  766. children: jsxs('ul', {
  767. class: 'gl-mb-0 gl-pl-0',
  768. children: [
  769. Boolean(recently.length) &&
  770. jsxs(Fragment, {
  771. children: [
  772. jsx('li', {
  773. class:
  774. 'gl-pb-2 gl-pl-4 gl-pt-3 gl-text-sm gl-font-bold gl-text-strong',
  775. children: 'Recently used',
  776. }),
  777. recently.map((item, index) =>
  778. jsx(
  779. AsyncAutocompleteOption,
  780. {
  781. hideCheckbox,
  782. isActive: index === activeIndex,
  783. onClick,
  784. option: item,
  785. removeFromRecent: removeRecently,
  786. renderOption,
  787. selected: value,
  788. },
  789. item.id
  790. )
  791. ),
  792. ],
  793. }),
  794. Boolean(options.length) &&
  795. jsxs(Fragment, {
  796. children: [
  797. jsx('li', {
  798. class:
  799. 'gl-pb-2 gl-pl-4 gl-pt-3 gl-text-sm gl-font-bold gl-text-strong gl-border-t',
  800. }),
  801. options.map((item, index) =>
  802. jsx(
  803. AsyncAutocompleteOption,
  804. {
  805. hideCheckbox,
  806. isActive: recently.length + index === activeIndex,
  807. onClick,
  808. option: item,
  809. renderOption,
  810. selected: value,
  811. },
  812. item.id
  813. )
  814. ),
  815. ],
  816. }),
  817. options.length + recently.length === 0 &&
  818. jsx('li', { class: 'gl-p-4', children: 'No options' }),
  819. ],
  820. }),
  821. }),
  822. });
  823. }
  824.  
  825. // apps/gitlab-plus/src/components/common/form/autocomplete/AsyncAutocompleteSearch.tsx
  826. function AsyncAutocompleteSearch({ navigate, setValue, value }) {
  827. return jsx('div', {
  828. class: 'gl-border-b-1 gl-border-b-solid gl-border-b-dropdown',
  829. children: jsxs('div', {
  830. class: 'gl-listbox-search gl-listbox-topmost',
  831. children: [
  832. jsx(GitlabIcon, {
  833. className: 'gl-search-box-by-type-search-icon',
  834. icon: 'search',
  835. size: 16,
  836. }),
  837. jsx('input', {
  838. class: 'gl-listbox-search-input',
  839. onInput: (e) => setValue(e.target.value),
  840. onKeyDown: (e) => navigate(e.key),
  841. value,
  842. autofocus: true,
  843. }),
  844. Boolean(value) &&
  845. jsx('div', {
  846. class: 'gl-search-box-by-type-right-icons',
  847. style: { top: '0' },
  848. children: jsx(CloseButton, {
  849. onClick: () => setValue(''),
  850. title: 'Clear input',
  851. }),
  852. }),
  853. ],
  854. }),
  855. });
  856. }
  857.  
  858. // apps/gitlab-plus/src/components/common/form/autocomplete/useListNavigate.ts
  859. function useListNavigate(options, recent, onClick, onClose) {
  860. const [activeIndex, setActiveIndex] = useState(-1);
  861. const navigate = (key) => {
  862. if (['ArrowDown', 'ArrowUp'].includes(key)) {
  863. const total = recent.length + options.length;
  864. const diff = key === 'ArrowDown' ? 1 : -1;
  865. setActiveIndex((activeIndex + diff + total) % total);
  866. } else if (key === 'Enter') {
  867. const allItems = [...recent, ...options];
  868. if (-1 < activeIndex && activeIndex < allItems.length) {
  869. onClick(allItems[activeIndex]);
  870. }
  871. } else if (key === 'Escape') {
  872. onClose();
  873. }
  874. };
  875. return {
  876. activeIndex,
  877. navigate,
  878. };
  879. }
  880.  
  881. // apps/gitlab-plus/src/components/common/form/autocomplete/AsyncAutocompleteDropdown.tsx
  882. function AsyncAutocompleteDropdown({
  883. hideCheckbox,
  884. onClick,
  885. onClose,
  886. options,
  887. recently = [],
  888. removeRecently,
  889. renderOption,
  890. searchTerm,
  891. setSearchTerm,
  892. value,
  893. }) {
  894. const { activeIndex, navigate } = useListNavigate(
  895. options,
  896. recently,
  897. onClick,
  898. onClose
  899. );
  900. return jsx('div', {
  901. class: clsx('gl-new-dropdown-panel gl-absolute !gl-block'),
  902. onClick: (e) => e.stopPropagation(),
  903. style: {
  904. maxWidth: '800px',
  905. width: '100%',
  906. left: 'auto',
  907. right: '0',
  908. top: '100%',
  909. },
  910. children: jsxs('div', {
  911. class: 'gl-new-dropdown-inner',
  912. children: [
  913. jsx(AsyncAutocompleteSearch, {
  914. navigate,
  915. setValue: setSearchTerm,
  916. value: searchTerm,
  917. }),
  918. jsx(AsyncAutocompleteList, {
  919. hideCheckbox,
  920. activeIndex,
  921. onClick,
  922. options,
  923. recently,
  924. removeRecently,
  925. renderOption,
  926. value,
  927. }),
  928. ],
  929. }),
  930. });
  931. }
  932.  
  933. // libs/share/src/utils/useDebounce.ts
  934. function useDebounce(value, delay = 300) {
  935. const [debouncedValue, setDebouncedValue] = useState(value);
  936. useEffect(() => {
  937. const handler = setTimeout(() => {
  938. setDebouncedValue(value);
  939. }, delay);
  940. return () => {
  941. clearTimeout(handler);
  942. };
  943. }, [value, delay]);
  944. return debouncedValue;
  945. }
  946.  
  947. // apps/gitlab-plus/src/components/common/form/autocomplete/useAsyncAutocompleteOptions.ts
  948. function useAsyncAutocompleteOptions(searchTerm, getValues) {
  949. const [options, setOptions] = useState([]);
  950. const term = useDebounce(searchTerm);
  951. const loadOptions = useCallback(
  952. async (term2) => {
  953. const items = await getValues(term2);
  954. setOptions(items);
  955. },
  956. [getValues]
  957. );
  958. useEffect(() => {
  959. loadOptions(term);
  960. }, [term, loadOptions]);
  961. return options;
  962. }
  963.  
  964. // apps/gitlab-plus/src/providers/RecentlyProvider.ts
  965. class RecentlyProvider {
  966. constructor(key) {
  967. __publicField(this, 'cache', new Cache('glp-'));
  968. __publicField(this, 'key');
  969. __publicField(this, 'eventName');
  970. this.key = `recently-${key}`;
  971. this.eventName = `recently-${key}-change`;
  972. }
  973.  
  974. add(...items) {
  975. const itemsId = items.map((i) => i.id);
  976. this.cache.set(
  977. this.key,
  978. [...items, ...this.get().filter((el) => !itemsId.includes(el.id))],
  979. 'lifetime'
  980. );
  981. this.triggerChange();
  982. }
  983.  
  984. get() {
  985. return this.cache.get(this.key) || [];
  986. }
  987.  
  988. onChange(callback) {
  989. document.addEventListener(this.eventName, callback);
  990. }
  991.  
  992. remove(...items) {
  993. const itemsId = items.map((i) => i.id);
  994. this.cache.set(
  995. this.key,
  996. this.get().filter((el) => !itemsId.includes(el.id)),
  997. 'lifetime'
  998. );
  999. this.triggerChange();
  1000. }
  1001.  
  1002. triggerChange() {
  1003. document.dispatchEvent(new CustomEvent(this.eventName));
  1004. }
  1005. }
  1006.  
  1007. // apps/gitlab-plus/src/components/common/form/autocomplete/useAsyncAutocompleteRecently.ts
  1008. function useAsyncAutocompleteRecently(name2) {
  1009. const store = useRef(new RecentlyProvider(name2));
  1010. const [recently, setRecently] = useState(store.current.get());
  1011. useEffect(() => {
  1012. store.current.onChange(() => {
  1013. setRecently(store.current.get());
  1014. });
  1015. }, []);
  1016. return {
  1017. add: store.current.add.bind(store.current),
  1018. recently,
  1019. remove: store.current.remove.bind(store.current),
  1020. };
  1021. }
  1022.  
  1023. // apps/gitlab-plus/src/components/common/form/autocomplete/useAsyncAutocomplete.ts
  1024. function useAsyncAutocomplete(
  1025. name2,
  1026. value,
  1027. getValues,
  1028. onChange,
  1029. isMultiselect
  1030. ) {
  1031. const [searchTerm, setSearchTerm] = useState('');
  1032. const [isOpen, setIsOpen] = useState(false);
  1033. const { recently: allRecently, remove: removeRecently } =
  1034. useAsyncAutocompleteRecently(name2);
  1035. const options = useAsyncAutocompleteOptions(searchTerm, getValues);
  1036. const onClick = (item) => {
  1037. if (isMultiselect) {
  1038. if (value.find((i) => i.id === item.id)) {
  1039. onChange(value.filter((i) => i.id !== item.id));
  1040. } else {
  1041. onChange([...value, item]);
  1042. }
  1043. } else {
  1044. onChange([item]);
  1045. setIsOpen(false);
  1046. }
  1047. };
  1048. const recently = useMemo(() => {
  1049. const optionsIds = options.map((i) => i.id);
  1050. return searchTerm.length
  1051. ? allRecently.filter((i) => optionsIds.includes(i.id))
  1052. : allRecently;
  1053. }, [options, allRecently]);
  1054. return {
  1055. isOpen,
  1056. onClick,
  1057. options: useMemo(() => {
  1058. const recentlyIds = recently.map((i) => i.id);
  1059. return options.filter((i) => !recentlyIds.includes(i.id));
  1060. }, [options, recently]),
  1061. recently,
  1062. removeRecently,
  1063. searchTerm,
  1064. setIsOpen,
  1065. setSearchTerm,
  1066. };
  1067. }
  1068.  
  1069. // apps/gitlab-plus/src/components/common/form/autocomplete/AsyncAutocomplete.tsx
  1070. function AsyncAutocomplete({
  1071. hideCheckbox = false,
  1072. buttonSize,
  1073. getValues,
  1074. isDisabled,
  1075. isMultiselect = false,
  1076. name: name2,
  1077. onChange,
  1078. renderLabel,
  1079. renderOption,
  1080. value,
  1081. }) {
  1082. const {
  1083. isOpen,
  1084. onClick,
  1085. options,
  1086. recently,
  1087. removeRecently,
  1088. searchTerm,
  1089. setIsOpen,
  1090. setSearchTerm,
  1091. } = useAsyncAutocomplete(name2, value, getValues, onChange, isMultiselect);
  1092. return jsxs('div', {
  1093. class: clsx(
  1094. 'gl-relative gl-w-full gl-new-dropdown !gl-block',
  1095. isDisabled && 'gl-pointer-events-none gl-opacity-5'
  1096. ),
  1097. children: [
  1098. jsx(AsyncAutocompleteButton, {
  1099. isOpen,
  1100. renderLabel,
  1101. reset: () => onChange([]),
  1102. setIsOpen,
  1103. size: buttonSize,
  1104. value,
  1105. }),
  1106. isOpen &&
  1107. jsx(AsyncAutocompleteDropdown, {
  1108. hideCheckbox,
  1109. onClick,
  1110. onClose: () => setIsOpen(false),
  1111. options,
  1112. recently,
  1113. removeRecently,
  1114. renderOption,
  1115. searchTerm,
  1116. setSearchTerm,
  1117. value,
  1118. }),
  1119. ],
  1120. });
  1121. }
  1122.  
  1123. // apps/gitlab-plus/src/components/common/GitlabUser.tsx
  1124. function GitlabUser({ showUsername, size = 24, smallText, user, withLink }) {
  1125. const label = useMemo(() => {
  1126. return jsxs(Fragment, {
  1127. children: [
  1128. jsx('span', {
  1129. class: clsx('gl-mr-2 gl-block', smallText && '!gl-text-sm'),
  1130. children: user.name,
  1131. }),
  1132. showUsername &&
  1133. jsx('span', {
  1134. class: 'gl-block gl-text-secondary !gl-text-sm',
  1135. children: user.username,
  1136. }),
  1137. ],
  1138. });
  1139. }, [smallText, showUsername, user]);
  1140. const iconClsx = [
  1141. `gl-avatar gl-avatar-s${size}`,
  1142. smallText ? 'gl-mr-1' : 'gl-mr-3',
  1143. ];
  1144. return jsxs('div', {
  1145. class: 'gl-flex gl-items-center',
  1146. children: [
  1147. user.avatarUrl
  1148. ? jsx('img', {
  1149. alt: `${user.name}'s avatar`,
  1150. class: clsx(...iconClsx, `gl-avatar-circle`),
  1151. src: user.avatarUrl,
  1152. })
  1153. : jsx('div', {
  1154. class: clsx(
  1155. ...iconClsx,
  1156. `gl-avatar-identicon gl-avatar-identicon-bg1`
  1157. ),
  1158. children: user.name[0].toUpperCase(),
  1159. }),
  1160. withLink
  1161. ? jsx('a', { href: user.webUrl, children: label })
  1162. : jsx('div', { children: label }),
  1163. ],
  1164. });
  1165. }
  1166.  
  1167. // apps/gitlab-plus/src/components/create-issue/fields/AssigneesField.tsx
  1168. function AssigneesField({ projectPath, setValue, value }) {
  1169. const getUsers = useCallback(
  1170. async (search) => {
  1171. if (!projectPath) {
  1172. return [];
  1173. }
  1174. const response = await new UsersProvider().getUsers(projectPath, search);
  1175. return response.data.workspace.users;
  1176. },
  1177. [projectPath]
  1178. );
  1179. const renderLabel = useCallback((items) => {
  1180. const label = items.map((i) => i.name).join(', ');
  1181. return jsx('div', {
  1182. title: label,
  1183. children: items.length ? label : 'Select assignee',
  1184. });
  1185. }, []);
  1186. const renderOption = useCallback((item) => {
  1187. return jsx('span', {
  1188. class: 'gl-new-dropdown-item-text-wrapper',
  1189. children: jsx(GitlabUser, { user: item, showUsername: true }),
  1190. });
  1191. }, []);
  1192. return jsx(AsyncAutocomplete, {
  1193. getValues: getUsers,
  1194. isDisabled: !projectPath,
  1195. name: 'assignees',
  1196. onChange: setValue,
  1197. renderLabel,
  1198. renderOption,
  1199. value,
  1200. isMultiselect: true,
  1201. });
  1202. }
  1203.  
  1204. // apps/gitlab-plus/src/components/create-issue/fields/ButtonField.tsx
  1205. function ButtonField({ create, isLoading, reset }) {
  1206. return jsxs(Fragment, {
  1207. children: [
  1208. jsxs('button', {
  1209. class: 'btn btn-confirm btn-sm gl-button gl-gap-2',
  1210. disabled: isLoading,
  1211. onClick: create,
  1212. type: 'button',
  1213. children: [
  1214. jsx('span', {
  1215. class: 'gl-button-text',
  1216. children: 'Add',
  1217. }),
  1218. isLoading
  1219. ? jsx(GitlabLoader, { size: 12 })
  1220. : jsx(GitlabIcon, { icon: 'plus', size: 12 }),
  1221. ],
  1222. }),
  1223. jsx('button', {
  1224. class: 'btn btn-sm gl-button',
  1225. onClick: reset,
  1226. type: 'button',
  1227. children: jsx('span', { class: 'gl-button-text', children: 'Reset' }),
  1228. }),
  1229. ],
  1230. });
  1231. }
  1232.  
  1233. // apps/gitlab-plus/src/providers/query/iteration.ts
  1234. const iterationFragment = `fragment IterationFragment on Iteration {
  1235. id
  1236. title
  1237. startDate
  1238. dueDate
  1239. webUrl
  1240. iterationCadence {
  1241. id
  1242. title
  1243. __typename
  1244. }
  1245. __typename
  1246. }`;
  1247. const iterationQuery = `query issueIterationsAliased($fullPath: ID!, $title: String, $state: IterationState) {
  1248. workspace: group(fullPath: $fullPath) {
  1249. id
  1250. attributes: iterations(
  1251. search: $title
  1252. in: [TITLE, CADENCE_TITLE]
  1253. state: $state
  1254. ) {
  1255. nodes {
  1256. ...IterationFragment
  1257. state
  1258. __typename
  1259. }
  1260. __typename
  1261. }
  1262. __typename
  1263. }
  1264. }
  1265. ${iterationFragment}
  1266. `;
  1267.  
  1268. // apps/gitlab-plus/src/providers/IterationsProvider.ts
  1269. class IterationsProvider extends GitlabProvider {
  1270. async getIterations(projectId, title = '') {
  1271. return this.queryCached(
  1272. `iterations-${projectId}-search-${title}`,
  1273. iterationQuery,
  1274. {
  1275. fullPath: projectId,
  1276. state: 'opened',
  1277. title,
  1278. },
  1279. title !== '' ? 0.5 : 20
  1280. );
  1281. }
  1282. }
  1283.  
  1284. // apps/gitlab-plus/src/components/create-issue/fields/IterationField.tsx
  1285. function iterationName(iteration) {
  1286. const start = new Date(iteration.startDate).toLocaleDateString();
  1287. const end = new Date(iteration.dueDate).toLocaleDateString();
  1288. return `${iteration.iterationCadence.title}: ${start} - ${end}`;
  1289. }
  1290.  
  1291. function IterationField({ link, setValue, value }) {
  1292. const getUsers = useCallback(
  1293. async (search) => {
  1294. const response = await new IterationsProvider().getIterations(
  1295. link.workspacePath,
  1296. search
  1297. );
  1298. return response.data.workspace.attributes.nodes
  1299. .map((iteration) => ({
  1300. ...iteration,
  1301. name: iterationName(iteration),
  1302. }))
  1303. .toSorted((a, b) => a.name.localeCompare(b.name));
  1304. },
  1305. [link]
  1306. );
  1307. const renderLabel = useCallback(([item]) => {
  1308. return item ? item.name : 'Select iteration';
  1309. }, []);
  1310. const renderOption = useCallback((item) => {
  1311. return jsx('span', {
  1312. class: 'gl-new-dropdown-item-text-wrapper',
  1313. children: jsx('span', {
  1314. class: 'gl-flex gl-w-full gl-items-center',
  1315. children: jsx('span', {
  1316. class: 'gl-mr-2 gl-block',
  1317. children: item.name,
  1318. }),
  1319. }),
  1320. });
  1321. }, []);
  1322. return jsx(AsyncAutocomplete, {
  1323. getValues: getUsers,
  1324. name: 'iterations',
  1325. onChange: setValue,
  1326. renderLabel,
  1327. renderOption,
  1328. value,
  1329. });
  1330. }
  1331.  
  1332. // apps/gitlab-plus/src/providers/query/label.ts
  1333. const labelFragment = `
  1334. fragment Label on Label {
  1335. id
  1336. title
  1337. description
  1338. color
  1339. textColor
  1340. __typename
  1341. }
  1342. `;
  1343. const projectLabelsQuery = `query projectLabels($fullPath: ID!, $searchTerm: String) {
  1344. workspace: project(fullPath: $fullPath) {
  1345. id
  1346. labels(
  1347. searchTerm: $searchTerm
  1348. includeAncestorGroups: true
  1349. ) {
  1350. nodes {
  1351. ...Label
  1352. __typename
  1353. }
  1354. __typename
  1355. }
  1356. __typename
  1357. }
  1358. }
  1359. ${labelFragment}
  1360. `;
  1361. const workspaceLabelsQuery = `query groupLabels($fullPath: ID!, $searchTerm: String) {
  1362. workspace: group(fullPath: $fullPath) {
  1363. id
  1364. labels(
  1365. searchTerm: $searchTerm
  1366. onlyGroupLabels: true
  1367. includeAncestorGroups: true
  1368. ) {
  1369. nodes {
  1370. ...Label
  1371. __typename
  1372. }
  1373. __typename
  1374. }
  1375. __typename
  1376. }
  1377. }
  1378.  
  1379. ${labelFragment}
  1380. `;
  1381.  
  1382. // apps/gitlab-plus/src/providers/LabelsProvider.ts
  1383. class LabelsProvider extends GitlabProvider {
  1384. async getProjectLabels(projectPath, search = '') {
  1385. return this.queryCached(
  1386. `project-${projectPath}-labels-${search}`,
  1387. projectLabelsQuery,
  1388. {
  1389. fullPath: projectPath,
  1390. searchTerm: search,
  1391. },
  1392. search === '' ? 20 : 0.5
  1393. );
  1394. }
  1395.  
  1396. async getWorkspaceLabels(workspacePath, search = '') {
  1397. return this.queryCached(
  1398. `workspace-${workspacePath}-labels-${search}`,
  1399. workspaceLabelsQuery,
  1400. {
  1401. fullPath: workspacePath,
  1402. searchTerm: search,
  1403. },
  1404. search === '' ? 20 : 0.5
  1405. );
  1406. }
  1407. }
  1408.  
  1409. // apps/gitlab-plus/src/components/common/GitlabLabel.tsx
  1410. function GitlabLabel({ label, onRemove }) {
  1411. const [scope, text] = label.title.split('::');
  1412. const props = useMemo(() => {
  1413. const className = [
  1414. 'gl-label',
  1415. 'hide-collapsed',
  1416. label.textColor === '#FFFFFF'
  1417. ? 'gl-label-text-light'
  1418. : 'gl-label-text-dark',
  1419. ];
  1420. if (label.title.includes('::')) {
  1421. className.push('gl-label-scoped');
  1422. }
  1423. return {
  1424. class: clsx(className),
  1425. style: {
  1426. '--label-background-color': label.color,
  1427. '--label-inset-border': `inset 0 0 0 2px ${label.color}`,
  1428. },
  1429. };
  1430. }, [label]);
  1431. return jsxs('span', {
  1432. class: props.class,
  1433. style: props.style,
  1434. children: [
  1435. jsxs('span', {
  1436. class: 'gl-link gl-label-link gl-label-link-underline',
  1437. children: [
  1438. jsx('span', {
  1439. class: 'gl-label-text',
  1440. children: scope,
  1441. }),
  1442. text &&
  1443. jsx('span', { class: 'gl-label-text-scoped', children: text }),
  1444. ],
  1445. }),
  1446. onRemove &&
  1447. jsx('button', {
  1448. onClick: onRemove,
  1449. type: 'button',
  1450. class:
  1451. 'btn gl-label-close !gl-p-0 btn-reset btn-sm gl-button btn-reset-tertiary',
  1452. children: jsx('span', {
  1453. class: 'gl-button-text',
  1454. children: jsx(GitlabIcon, { icon: 'close-xs' }),
  1455. }),
  1456. }),
  1457. ],
  1458. });
  1459. }
  1460.  
  1461. // apps/gitlab-plus/src/components/create-issue/fields/LabelsField.tsx
  1462. function LabelField({ copyLabels, projectPath, setValue, value }) {
  1463. const getLabels = useCallback(
  1464. async (search) => {
  1465. if (!projectPath) {
  1466. return [];
  1467. }
  1468. const response = await new LabelsProvider().getProjectLabels(
  1469. projectPath,
  1470. search
  1471. );
  1472. return response.data.workspace.labels.nodes;
  1473. },
  1474. [projectPath]
  1475. );
  1476. const renderLabel = useCallback((items) => {
  1477. return items.length
  1478. ? items.map((i) => i.title).join(', ')
  1479. : 'Select labels';
  1480. }, []);
  1481. const renderOption = useCallback((item) => {
  1482. return jsxs('div', {
  1483. class: 'gl-flex gl-flex-1 gl-break-anywhere gl-pb-3 gl-pl-4 gl-pt-3',
  1484. children: [
  1485. jsx('span', {
  1486. class: 'dropdown-label-box gl-top-0 gl-mr-3 gl-shrink-0',
  1487. style: { backgroundColor: item.color },
  1488. }),
  1489. jsx('span', { children: item.title }),
  1490. ],
  1491. });
  1492. }, []);
  1493. return jsxs(Fragment, {
  1494. children: [
  1495. jsx('div', {
  1496. class: 'gl-mt-1 gl-pb-2 gl-flex gl-flex-wrap gl-gap-2',
  1497. children: value.map((label) =>
  1498. jsx(
  1499. GitlabLabel,
  1500. {
  1501. label,
  1502. onRemove: () =>
  1503. setValue(value.filter((item) => label.id !== item.id)),
  1504. },
  1505. label.id
  1506. )
  1507. ),
  1508. }),
  1509. jsxs('div', {
  1510. className: 'gl-flex gl-gap-1 gl-relative gl-pr-7',
  1511. children: [
  1512. jsx(AsyncAutocomplete, {
  1513. getValues: getLabels,
  1514. isDisabled: !projectPath,
  1515. name: 'labels',
  1516. onChange: setValue,
  1517. renderLabel,
  1518. renderOption,
  1519. value,
  1520. isMultiselect: true,
  1521. }),
  1522. jsx('div', {
  1523. className: 'gl-flex gl-absolute gl-h-full gl-right-0',
  1524. children: jsx(GitlabButton, {
  1525. icon: 'labels',
  1526. onClick: copyLabels,
  1527. title: 'Copy labels from parent',
  1528. }),
  1529. }),
  1530. ],
  1531. }),
  1532. ],
  1533. });
  1534. }
  1535.  
  1536. // apps/gitlab-plus/src/providers/query/milestone.ts
  1537. const milestoneQuery = `query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) {
  1538. workspace: project(fullPath: $fullPath) {
  1539. id
  1540. attributes: milestones(
  1541. searchTitle: $title
  1542. state: $state
  1543. sort: EXPIRED_LAST_DUE_DATE_ASC
  1544. first: 20
  1545. includeAncestors: true
  1546. ) {
  1547. nodes {
  1548. ...MilestoneFragment
  1549. state
  1550. __typename
  1551. }
  1552. __typename
  1553. }
  1554. __typename
  1555. }
  1556. }
  1557.  
  1558. fragment MilestoneFragment on Milestone {
  1559. id
  1560. iid
  1561. title
  1562. webUrl: webPath
  1563. dueDate
  1564. expired
  1565. __typename
  1566. }
  1567.  
  1568. `;
  1569.  
  1570. // apps/gitlab-plus/src/providers/MilestonesProvider.ts
  1571. class MilestonesProvider extends GitlabProvider {
  1572. async getMilestones(projectId, title = '') {
  1573. return this.queryCached(
  1574. `milestones-${projectId}-${title}`,
  1575. milestoneQuery,
  1576. {
  1577. fullPath: projectId,
  1578. state: 'active',
  1579. title,
  1580. },
  1581. title === '' ? 20 : 0.5
  1582. );
  1583. }
  1584. }
  1585.  
  1586. // apps/gitlab-plus/src/components/create-issue/fields/MilestoneField.tsx
  1587. function MilestoneField({ projectPath, setValue, value }) {
  1588. const getMilestones = useCallback(
  1589. async (search) => {
  1590. if (!projectPath) {
  1591. return [];
  1592. }
  1593. const response = await new MilestonesProvider().getMilestones(
  1594. projectPath,
  1595. search
  1596. );
  1597. return response.data.workspace.attributes.nodes;
  1598. },
  1599. [projectPath]
  1600. );
  1601. const renderLabel = useCallback(([item]) => {
  1602. return item ? item.title : 'Select milestone';
  1603. }, []);
  1604. const renderOption = useCallback((item) => {
  1605. return jsx('span', {
  1606. class: 'gl-new-dropdown-item-text-wrapper',
  1607. children: jsx('span', {
  1608. class: 'gl-flex gl-w-full gl-items-center',
  1609. children: jsx('span', {
  1610. class: 'gl-mr-2 gl-block',
  1611. children: item.title,
  1612. }),
  1613. }),
  1614. });
  1615. }, []);
  1616. return jsx(AsyncAutocomplete, {
  1617. getValues: getMilestones,
  1618. isDisabled: !projectPath,
  1619. name: 'milestones',
  1620. onChange: setValue,
  1621. renderLabel,
  1622. renderOption,
  1623. value,
  1624. });
  1625. }
  1626.  
  1627. // apps/gitlab-plus/src/providers/query/project.ts
  1628. const projectsQuery = `query boardsGetGroupProjects($fullPath: ID!, $search: String, $after: String) {
  1629. group(fullPath: $fullPath) {
  1630. id
  1631. projects(search: $search, after: $after, first: 100, includeSubgroups: true) {
  1632. nodes {
  1633. id
  1634. name
  1635. avatarUrl
  1636. fullPath
  1637. nameWithNamespace
  1638. archived
  1639. __typename
  1640. }
  1641. pageInfo {
  1642. ...PageInfo
  1643. __typename
  1644. }
  1645. __typename
  1646. }
  1647. __typename
  1648. }
  1649. }
  1650.  
  1651. fragment PageInfo on PageInfo {
  1652. hasNextPage
  1653. hasPreviousPage
  1654. startCursor
  1655. endCursor
  1656. __typename
  1657. }
  1658.  
  1659. `;
  1660.  
  1661. // apps/gitlab-plus/src/providers/ProjectsProvider.ts
  1662. class ProjectsProvider extends GitlabProvider {
  1663. async getProjects(workspacePath, search = '') {
  1664. return this.queryCached(
  1665. `projects-${workspacePath}-${search}`,
  1666. projectsQuery,
  1667. {
  1668. fullPath: workspacePath,
  1669. search,
  1670. },
  1671. search === '' ? 20 : 0.5
  1672. );
  1673. }
  1674. }
  1675.  
  1676. // apps/gitlab-plus/src/components/common/GitlabProject.tsx
  1677. function GitlabProject({ project, size = 32 }) {
  1678. return jsxs('span', {
  1679. class: 'gl-flex gl-w-full gl-items-center',
  1680. children: [
  1681. project.avatarUrl
  1682. ? jsx('img', {
  1683. alt: project.name,
  1684. class: `gl-mr-3 gl-avatar gl-avatar-s${size}`,
  1685. src: project.avatarUrl,
  1686. })
  1687. : jsx('div', {
  1688. class: `gl-mr-3 gl-avatar gl-avatar-identicon gl-avatar-s${size} gl-avatar-identicon-bg1`,
  1689. children: project.name[0].toUpperCase(),
  1690. }),
  1691. jsxs('span', {
  1692. children: [
  1693. jsx('span', { class: 'gl-mr-2 gl-block', children: project.name }),
  1694. jsx('span', {
  1695. class: 'gl-block gl-text-secondary !gl-text-sm',
  1696. children: project.nameWithNamespace,
  1697. }),
  1698. ],
  1699. }),
  1700. ],
  1701. });
  1702. }
  1703.  
  1704. // apps/gitlab-plus/src/components/create-issue/fields/ProjectField.tsx
  1705. function ProjectField({ link, setValue, value }) {
  1706. const getProjects = useCallback(
  1707. async (search) => {
  1708. const response = await new ProjectsProvider().getProjects(
  1709. link.workspacePath,
  1710. search
  1711. );
  1712. return response.data.group.projects.nodes;
  1713. },
  1714. [link]
  1715. );
  1716. const renderLabel = useCallback(([item]) => {
  1717. return item ? item.nameWithNamespace : 'Select project';
  1718. }, []);
  1719. const renderOption = useCallback((item) => {
  1720. return jsx('span', {
  1721. class: 'gl-new-dropdown-item-text-wrapper',
  1722. children: jsx(GitlabProject, { project: item }),
  1723. });
  1724. }, []);
  1725. return jsx(AsyncAutocomplete, {
  1726. getValues: getProjects,
  1727. name: 'projects',
  1728. onChange: setValue,
  1729. renderLabel,
  1730. renderOption,
  1731. value,
  1732. });
  1733. }
  1734.  
  1735. // apps/gitlab-plus/src/types/Issue.ts
  1736. const issueRelation = ['blocks', 'is_blocked_by', 'relates_to'];
  1737.  
  1738. // apps/gitlab-plus/src/components/create-issue/fields/RelationField.tsx
  1739. const labels = (relation) => {
  1740. switch (relation) {
  1741. case 'blocks':
  1742. return 'blocks current issue';
  1743. case 'is_blocked_by':
  1744. return 'is blocked by current issue';
  1745. case 'relates_to':
  1746. return 'relates to current issue';
  1747. default:
  1748. return 'is not related to current issue';
  1749. }
  1750. };
  1751.  
  1752. function RelationField({ setValue, value }) {
  1753. return jsx('div', {
  1754. class: 'linked-issue-type-radio',
  1755. children: [...issueRelation, null].map((relation) =>
  1756. jsxs(
  1757. 'div',
  1758. {
  1759. class: 'gl-form-radio custom-control custom-radio',
  1760. children: [
  1761. jsx('input', {
  1762. id: `create-related-issue-relation-${relation}`,
  1763. checked: value === relation,
  1764. class: 'custom-control-input',
  1765. name: 'linked-issue-type-radio',
  1766. onChange: () => setValue(relation),
  1767. type: 'radio',
  1768. value: relation ?? '',
  1769. }),
  1770. jsx('label', {
  1771. class: 'custom-control-label',
  1772. for: `create-related-issue-relation-${relation}`,
  1773. children: labels(relation),
  1774. }),
  1775. ],
  1776. },
  1777. relation
  1778. )
  1779. ),
  1780. });
  1781. }
  1782.  
  1783. // apps/gitlab-plus/src/components/create-issue/fields/TitleField.tsx
  1784. function TitleField({ error, onChange, value }) {
  1785. return jsx('input', {
  1786. onInput: (e) => onChange(e.target.value),
  1787. placeholder: 'Add a title',
  1788. value,
  1789. class: clsx(
  1790. 'gl-form-input form-control',
  1791. error && 'gl-field-error-outline'
  1792. ),
  1793. });
  1794. }
  1795.  
  1796. // apps/gitlab-plus/src/helpers/LinkParser.ts
  1797. class LinkParser {
  1798. static isEpicLink(link) {
  1799. return link.epic !== void 0;
  1800. }
  1801.  
  1802. static isIssueLink(link) {
  1803. return link.issue !== void 0;
  1804. }
  1805.  
  1806. static isMrLink(link) {
  1807. return link.mr !== void 0;
  1808. }
  1809.  
  1810. static parseEpicLink(link) {
  1811. if (LinkParser.validateEpicLink(link)) {
  1812. return LinkParser.parseGitlabLink(
  1813. link,
  1814. /\/groups\/(?<workspacePath>.+)\/-\/epics\/(?<epic>\d+)/
  1815. );
  1816. }
  1817. return void 0;
  1818. }
  1819.  
  1820. static parseGitlabLink(link, pattern) {
  1821. const url = new URL(link);
  1822. const result = url.pathname.match(pattern);
  1823. if (result && result.groups) {
  1824. return result.groups;
  1825. }
  1826. return void 0;
  1827. }
  1828.  
  1829. static parseIssueLink(link) {
  1830. if (LinkParser.validateIssueLink(link)) {
  1831. return LinkParser.parseGitlabLink(
  1832. link,
  1833. /\/(?<projectPath>(?<workspacePath>.+)\/[^/]+)\/-\/issues\/(?<issue>\d+)/
  1834. );
  1835. }
  1836. return void 0;
  1837. }
  1838.  
  1839. static parseMrLink(link) {
  1840. if (LinkParser.validateMrLink(link)) {
  1841. return LinkParser.parseGitlabLink(
  1842. link,
  1843. /\/(?<projectPath>(?<workspacePath>.+)\/[^/]+)\/-\/merge_requests\/(?<mr>\d+)\/?$/
  1844. );
  1845. }
  1846. return void 0;
  1847. }
  1848.  
  1849. static validateEpicLink(link) {
  1850. return LinkParser.validateGitlabLink(link, 'epics');
  1851. }
  1852.  
  1853. static validateGitlabLink(link, type) {
  1854. return Boolean(typeof link === 'string' && link.includes(`/-/${type}/`));
  1855. }
  1856.  
  1857. static validateIssueLink(link) {
  1858. return LinkParser.validateGitlabLink(link, 'issues');
  1859. }
  1860.  
  1861. static validateMrLink(link) {
  1862. return LinkParser.validateGitlabLink(link, 'merge_requests');
  1863. }
  1864. }
  1865.  
  1866. // apps/gitlab-plus/src/helpers/Widget.ts
  1867. class WidgetHelper {
  1868. static epicLabels(epic) {
  1869. const labelWidgets = epic.widgets.find((w) => w.type === 'LABELS');
  1870. if (labelWidgets) {
  1871. return labelWidgets.labels.nodes;
  1872. }
  1873. return [];
  1874. }
  1875. }
  1876.  
  1877. // apps/gitlab-plus/src/providers/query/epic.ts
  1878. const epicQuery = `query namespaceWorkItem($fullPath: ID!, $iid: String!) {
  1879. workspace: namespace(fullPath: $fullPath) {
  1880. id
  1881. workItem(iid: $iid) {
  1882. ...WorkItem
  1883. __typename
  1884. }
  1885. __typename
  1886. }
  1887. }
  1888.  
  1889. fragment WorkItem on WorkItem {
  1890. id
  1891. iid
  1892. archived
  1893. title
  1894. state
  1895. description
  1896. confidential
  1897. createdAt
  1898. closedAt
  1899. webUrl
  1900. reference(full: true)
  1901. createNoteEmail
  1902. project {
  1903. id
  1904. __typename
  1905. }
  1906. namespace {
  1907. id
  1908. fullPath
  1909. name
  1910. fullName
  1911. __typename
  1912. }
  1913. author {
  1914. ...Author
  1915. __typename
  1916. }
  1917.  
  1918. workItemType {
  1919. id
  1920. name
  1921. iconName
  1922. __typename
  1923. }
  1924. userPermissions {
  1925. deleteWorkItem
  1926. updateWorkItem
  1927. adminParentLink
  1928. setWorkItemMetadata
  1929. createNote
  1930. adminWorkItemLink
  1931. markNoteAsInternal
  1932. reportSpam
  1933. __typename
  1934. }
  1935. widgets {
  1936. ...WorkItemWidgets
  1937. __typename
  1938. }
  1939. __typename
  1940. }
  1941.  
  1942. fragment WorkItemWidgets on WorkItemWidget {
  1943. type
  1944. ... on WorkItemWidgetHierarchy {
  1945. hasChildren
  1946. children(first: 100) {
  1947. count
  1948. nodes {
  1949. id
  1950. iid
  1951. title
  1952. state
  1953. webUrl
  1954. }
  1955. }
  1956. }
  1957. ... on WorkItemWidgetAssignees {
  1958. assignees {
  1959. nodes {
  1960. ...User
  1961. }
  1962. }
  1963. }
  1964. ... on WorkItemWidgetLabels {
  1965. labels {
  1966. nodes {
  1967. ...Label
  1968. }
  1969. }
  1970. }
  1971. ... on WorkItemWidgetStartAndDueDate {
  1972. dueDate
  1973. startDate
  1974. rollUp
  1975. isFixed
  1976. __typename
  1977. }
  1978. ... on WorkItemWidgetProgress {
  1979. progress
  1980. updatedAt
  1981. __typename
  1982. }
  1983. ... on WorkItemWidgetIteration {
  1984. iteration {
  1985. id
  1986. title
  1987. startDate
  1988. dueDate
  1989. webUrl
  1990. iterationCadence {
  1991. id
  1992. title
  1993. }
  1994. __typename
  1995. }
  1996. __typename
  1997. }
  1998. ... on WorkItemWidgetMilestone {
  1999. milestone {
  2000. ...MilestoneFragment
  2001. __typename
  2002. }
  2003. __typename
  2004. }
  2005. ... on WorkItemWidgetNotes {
  2006. discussionLocked
  2007. __typename
  2008. }
  2009. ... on WorkItemWidgetHealthStatus {
  2010. healthStatus
  2011. rolledUpHealthStatus {
  2012. count
  2013. healthStatus
  2014. __typename
  2015. }
  2016. __typename
  2017. }
  2018. ... on WorkItemWidgetNotifications {
  2019. subscribed
  2020. __typename
  2021. }
  2022. ... on WorkItemWidgetCurrentUserTodos {
  2023. currentUserTodos(state: pending) {
  2024. nodes {
  2025. id
  2026. __typename
  2027. }
  2028. __typename
  2029. }
  2030. __typename
  2031. }
  2032. ... on WorkItemWidgetColor {
  2033. color
  2034. textColor
  2035. __typename
  2036. }
  2037. ... on WorkItemWidgetLinkedItems {
  2038. linkedItems {
  2039. nodes {
  2040. linkId
  2041. linkType
  2042. __typename
  2043. }
  2044. __typename
  2045. }
  2046. __typename
  2047. }
  2048. ... on WorkItemWidgetCrmContacts {
  2049. contacts {
  2050. nodes {
  2051. id
  2052. email
  2053. firstName
  2054. lastName
  2055. phone
  2056. description
  2057. organization {
  2058. id
  2059. name
  2060. description
  2061. defaultRate
  2062. __typename
  2063. }
  2064. __typename
  2065. }
  2066. __typename
  2067. }
  2068. __typename
  2069. }
  2070. __typename
  2071. }
  2072.  
  2073. fragment User on User {
  2074. id
  2075. avatarUrl
  2076. name
  2077. username
  2078. webUrl
  2079. webPath
  2080. __typename
  2081. }
  2082.  
  2083. fragment MilestoneFragment on Milestone {
  2084. expired
  2085. id
  2086. title
  2087. state
  2088. startDate
  2089. dueDate
  2090. webPath
  2091. __typename
  2092. }
  2093.  
  2094. fragment Author on User {
  2095. id
  2096. avatarUrl
  2097. name
  2098. username
  2099. webUrl
  2100. webPath
  2101. __typename
  2102. }
  2103.  
  2104. ${labelFragment}
  2105. `;
  2106. const epicSetLabelsMutation = `
  2107. mutation workItemUpdate($input: WorkItemUpdateInput!) {
  2108. workItemUpdate(input: $input) {
  2109. workItem {
  2110. __typename
  2111. }
  2112. errors
  2113. }
  2114. }
  2115. `;
  2116.  
  2117. // apps/gitlab-plus/src/providers/EpicProvider.ts
  2118. class EpicProvider extends GitlabProvider {
  2119. async getEpic(workspacePath, epicId) {
  2120. return this.queryCached(
  2121. `epic-${workspacePath}-${epicId}`,
  2122. epicQuery,
  2123. {
  2124. iid: epicId,
  2125. cursor: '',
  2126. fullPath: workspacePath,
  2127. pageSize: 50,
  2128. },
  2129. 2
  2130. );
  2131. }
  2132.  
  2133. async updateEpicLabels(id, addLabelIds, removeLabelIds) {
  2134. return await this.query(epicSetLabelsMutation, {
  2135. input: {
  2136. id,
  2137. labelsWidget: {
  2138. addLabelIds,
  2139. removeLabelIds,
  2140. },
  2141. },
  2142. });
  2143. }
  2144. }
  2145.  
  2146. // apps/gitlab-plus/src/providers/query/issue.ts
  2147. const issueQuery = `query issueEE($projectPath: ID!, $iid: String!) {
  2148. project(fullPath: $projectPath) {
  2149. id
  2150. issue(iid: $iid) {
  2151. id
  2152. iid
  2153. title
  2154. description
  2155. createdAt
  2156. state
  2157. confidential
  2158. dueDate
  2159. projectId
  2160. milestone {
  2161. id
  2162. title
  2163. startDate
  2164. dueDate
  2165. __typename
  2166. }
  2167. epic {
  2168. id
  2169. iid
  2170. title
  2171. webUrl
  2172. }
  2173. iteration {
  2174. id
  2175. title
  2176. startDate
  2177. dueDate
  2178. iterationCadence {
  2179. id
  2180. title
  2181. __typename
  2182. }
  2183. __typename
  2184. }
  2185. labels {
  2186. nodes {
  2187. ...Label
  2188. }
  2189. }
  2190. relatedMergeRequests {
  2191. nodes {
  2192. iid
  2193. title
  2194. state
  2195. webUrl
  2196. author {
  2197. ...User
  2198. }
  2199. }
  2200. }
  2201. assignees {
  2202. nodes {
  2203. ...User
  2204. }
  2205. }
  2206. author {
  2207. ...User
  2208. }
  2209. weight
  2210. type
  2211. linkedWorkItems {
  2212. nodes {
  2213. linkType
  2214. workItemState
  2215. workItem {
  2216. id
  2217. iid
  2218. webUrl
  2219. title
  2220. }
  2221. }
  2222. }
  2223. __typename
  2224. }
  2225. __typename
  2226. }
  2227. }
  2228.  
  2229. ${labelFragment}
  2230. ${userFragment}
  2231. `;
  2232. const issueWithRelatedIssuesLabelsQuery = `query issueEE($projectPath: ID!, $iid: String!) {
  2233. project(fullPath: $projectPath) {
  2234. issue(iid: $iid) {
  2235. linkedWorkItems {
  2236. nodes {
  2237. workItem {
  2238. id
  2239. iid
  2240. widgets {
  2241. type
  2242. ...LabelsWidget
  2243. }
  2244. }
  2245. }
  2246. }
  2247. }
  2248. }
  2249. }
  2250.  
  2251. fragment LabelsWidget on WorkItemWidgetLabels {
  2252. labels {
  2253. nodes {
  2254. ...Label
  2255. }
  2256. }
  2257. }
  2258.  
  2259. ${labelFragment}
  2260. `;
  2261. const issuesQuery = `query groupWorkItems($searchTerm: String, $fullPath: ID!, $types: [IssueType!], $in: [IssuableSearchableField!], $includeAncestors: Boolean = false, $includeDescendants: Boolean = false, $iid: String = null, $searchByIid: Boolean = false, $searchByText: Boolean = true, $searchEmpty: Boolean = true) {
  2262. workspace: group(fullPath: $fullPath) {
  2263. id
  2264. workItems(
  2265. search: $searchTerm
  2266. types: $types
  2267. in: $in
  2268. includeAncestors: $includeAncestors
  2269. includeDescendants: $includeDescendants
  2270. ) @include(if: $searchByText) {
  2271. nodes {
  2272. id
  2273. iid
  2274. title
  2275. confidential
  2276. project {
  2277. fullPath
  2278. }
  2279. __typename
  2280. }
  2281. __typename
  2282. }
  2283. workItemsByIid: workItems(
  2284. iid: $iid
  2285. types: $types
  2286. includeAncestors: $includeAncestors
  2287. includeDescendants: $includeDescendants
  2288. ) @include(if: $searchByIid) {
  2289. nodes {
  2290. id
  2291. iid
  2292. title
  2293. confidential
  2294. project {
  2295. fullPath
  2296. }
  2297. __typename
  2298. }
  2299. __typename
  2300. }
  2301. workItemsEmpty: workItems(
  2302. types: $types
  2303. includeAncestors: $includeAncestors
  2304. includeDescendants: $includeDescendants
  2305. ) @include(if: $searchEmpty) {
  2306. nodes {
  2307. id
  2308. iid
  2309. title
  2310. confidential
  2311. project {
  2312. fullPath
  2313. }
  2314. __typename
  2315. }
  2316. __typename
  2317. }
  2318. __typename
  2319. }
  2320. }
  2321. `;
  2322. const issueMutation = `
  2323. mutation CreateIssue($input: CreateIssueInput!) {
  2324. createIssuable: createIssue(input: $input) {
  2325. issuable: issue {
  2326. ...Issue
  2327. __typename
  2328. }
  2329. errors
  2330. __typename
  2331. }
  2332. }
  2333.  
  2334. fragment Issue on Issue {
  2335. ...IssueNode
  2336. id
  2337. weight
  2338. blocked
  2339. blockedByCount
  2340. epic {
  2341. id
  2342. __typename
  2343. }
  2344. iteration {
  2345. id
  2346. title
  2347. startDate
  2348. dueDate
  2349. iterationCadence {
  2350. id
  2351. title
  2352. __typename
  2353. }
  2354. __typename
  2355. }
  2356. healthStatus
  2357. __typename
  2358. }
  2359.  
  2360. fragment IssueNode on Issue {
  2361. id
  2362. iid
  2363. title
  2364. referencePath: reference(full: true)
  2365. closedAt
  2366. dueDate
  2367. timeEstimate
  2368. totalTimeSpent
  2369. humanTimeEstimate
  2370. humanTotalTimeSpent
  2371. emailsDisabled
  2372. confidential
  2373. hidden
  2374. webUrl
  2375. relativePosition
  2376. projectId
  2377. type
  2378. severity
  2379. milestone {
  2380. ...MilestoneFragment
  2381. __typename
  2382. }
  2383. assignees {
  2384. nodes {
  2385. ...User
  2386. __typename
  2387. }
  2388. __typename
  2389. }
  2390. labels {
  2391. nodes {
  2392. id
  2393. title
  2394. color
  2395. description
  2396. __typename
  2397. }
  2398. __typename
  2399. }
  2400. __typename
  2401. }
  2402.  
  2403. fragment MilestoneFragment on Milestone {
  2404. expired
  2405. id
  2406. state
  2407. title
  2408. __typename
  2409. }
  2410.  
  2411. fragment User on User {
  2412. id
  2413. avatarUrl
  2414. name
  2415. username
  2416. webUrl
  2417. webPath
  2418. __typename
  2419. }
  2420. `;
  2421. const issueSetEpicMutation = `
  2422. mutation projectIssueUpdateParent($input: WorkItemUpdateInput!) {
  2423. issuableSetAttribute: workItemUpdate(input: $input) {
  2424. workItem {
  2425. id
  2426. widgets {
  2427. ... on WorkItemWidgetHierarchy {
  2428. type
  2429. parent {
  2430. id
  2431. title
  2432. webUrl
  2433. }
  2434. }
  2435. }
  2436. }
  2437. errors
  2438. }
  2439. }
  2440. `;
  2441. const issueSetLabelsMutation = `
  2442. mutation issueSetLabels($input: UpdateIssueInput!) {
  2443. updateIssuableLabels: updateIssue(input: $input) {
  2444. issuable: issue {
  2445. __typename
  2446. }
  2447. errors
  2448. __typename
  2449. }
  2450. }
  2451. `;
  2452.  
  2453. // apps/gitlab-plus/src/providers/IssueProvider.ts
  2454. class IssueProvider extends GitlabProvider {
  2455. async createIssue(input) {
  2456. return await this.query(issueMutation, { input });
  2457. }
  2458.  
  2459. async createIssueRelation(input) {
  2460. const path = [
  2461. 'projects/:PROJECT_ID',
  2462. '/issues/:ISSUE_ID/links',
  2463. '?target_project_id=:TARGET_PROJECT_ID',
  2464. '&target_issue_iid=:TARGET_ISSUE_IID',
  2465. '&link_type=:LINK_TYPE',
  2466. ]
  2467. .join('')
  2468. .replace(':PROJECT_ID', `${input.projectId}`)
  2469. .replace(':ISSUE_ID', `${input.issueId}`)
  2470. .replace(':TARGET_PROJECT_ID', input.targetProjectId)
  2471. .replace(':TARGET_ISSUE_IID', input.targetIssueIid)
  2472. .replace(':LINK_TYPE', input.linkType);
  2473. return await this.post(path, {});
  2474. }
  2475.  
  2476. async getIssue(projectPath, iid) {
  2477. return this.queryCached(
  2478. `issue-${projectPath}-${iid}`,
  2479. issueQuery,
  2480. {
  2481. iid,
  2482. projectPath,
  2483. },
  2484. 2
  2485. );
  2486. }
  2487.  
  2488. async getIssues(projectPath, search) {
  2489. const searchById = !!search.match(/^\d+$/);
  2490. return await this.query(issuesQuery, {
  2491. iid: searchById ? search : null,
  2492. searchByIid: searchById,
  2493. fullPath: projectPath,
  2494. in: 'TITLE',
  2495. includeAncestors: true,
  2496. includeDescendants: true,
  2497. searchByText: Boolean(search),
  2498. searchEmpty: !search,
  2499. searchTerm: search,
  2500. types: ['ISSUE'],
  2501. });
  2502. }
  2503.  
  2504. async getIssueWithRelatedIssuesLabels(projectPath, iid) {
  2505. return this.queryCached(
  2506. `issue-related-issues-${projectPath}-${iid}`,
  2507. issueWithRelatedIssuesLabelsQuery,
  2508. {
  2509. iid,
  2510. projectPath,
  2511. },
  2512. 0.02
  2513. );
  2514. }
  2515.  
  2516. async issueSetEpic(issueId, epicId) {
  2517. return await this.query(issueSetEpicMutation, {
  2518. input: {
  2519. hierarchyWidget: {
  2520. parentId: epicId,
  2521. },
  2522. id: issueId,
  2523. },
  2524. });
  2525. }
  2526.  
  2527. async issueSetLabels(input) {
  2528. return await this.query(issueSetLabelsMutation, {
  2529. input,
  2530. });
  2531. }
  2532. }
  2533.  
  2534. // apps/gitlab-plus/src/components/create-issue/useCreateIssueForm.ts
  2535. const initialState = () => ({
  2536. assignees: [],
  2537. iteration: null,
  2538. labels: [],
  2539. milestone: null,
  2540. project: null,
  2541. relation: null,
  2542. title: '',
  2543. });
  2544. const initialError = () => ({
  2545. assignees: void 0,
  2546. iteration: void 0,
  2547. labels: void 0,
  2548. milestone: void 0,
  2549. project: void 0,
  2550. relation: void 0,
  2551. title: void 0,
  2552. });
  2553.  
  2554. function useCreateIssueForm({ isVisible, link, onClose }) {
  2555. let _a;
  2556. const [values, setValues] = useState(initialState());
  2557. const [errors, setErrors] = useState(initialError());
  2558. const [parentIssue, setParentIssue] = useState(null);
  2559. const [parentEpic, setParentEpic] = useState(null);
  2560. const [isLoading, setIsLoading] = useState(false);
  2561. const [message, setMessage] = useState('');
  2562. const [error, setError] = useState('');
  2563. const reset = () => {
  2564. setIsLoading(false);
  2565. setValues(initialState());
  2566. setErrors(initialError());
  2567. setMessage('');
  2568. setError('');
  2569. setParentIssue(null);
  2570. setParentEpic(null);
  2571. };
  2572. const createPayload = () => {
  2573. const data = {
  2574. projectPath: values.project.fullPath,
  2575. title: values.title,
  2576. };
  2577. if (values.milestone) {
  2578. data['milestoneId'] = values.milestone.id;
  2579. }
  2580. if (values.iteration) {
  2581. data['iterationId'] = values.iteration.id;
  2582. data['iterationCadenceId'] = values.iteration.iterationCadence.id;
  2583. }
  2584. if (values.assignees) {
  2585. data['assigneeIds'] = values.assignees.map((a) => a.id);
  2586. }
  2587. data['labelIds'] = values.labels.map((label) => label.id);
  2588. return data;
  2589. };
  2590. const persistRecently = () => {
  2591. Object.entries({
  2592. assignees: values.assignees,
  2593. iterations: values.iteration ? [values.iteration] : [],
  2594. labels: values.labels,
  2595. milestones: values.milestone ? [values.milestone] : [],
  2596. projects: values.project ? [values.project] : [],
  2597. }).map(([key, values2]) => {
  2598. new RecentlyProvider(key).add(...values2);
  2599. });
  2600. };
  2601. const validate = () => {
  2602. let isValid = true;
  2603. const errors2 = {};
  2604. if (values.title.length < 1) {
  2605. errors2.title = 'Title is required';
  2606. isValid = false;
  2607. } else if (values.title.length > 255) {
  2608. errors2.title = 'Title is too long';
  2609. isValid = false;
  2610. }
  2611. if (!values.project) {
  2612. errors2.project = 'Project must be selected';
  2613. isValid = false;
  2614. }
  2615. setErrors((prev) => ({ ...prev, ...errors2 }));
  2616. return isValid;
  2617. };
  2618. const createIssue = async (payload) => {
  2619. return await new IssueProvider().createIssue(payload);
  2620. };
  2621. const createRelation = async (issue, targetIssue, relation) => {
  2622. await new IssueProvider().createIssueRelation({
  2623. targetIssueIid: targetIssue.iid,
  2624. issueId: issue.iid,
  2625. linkType: relation,
  2626. projectId: issue.projectId,
  2627. targetProjectId: targetIssue.projectId,
  2628. });
  2629. };
  2630. const setIssueEpic = async (issue, epic) => {
  2631. await new IssueProvider().issueSetEpic(issue.id, epic.id);
  2632. };
  2633. const submit = async () => {
  2634. if (!validate()) {
  2635. return;
  2636. }
  2637. setIsLoading(true);
  2638. try {
  2639. setMessage('Creating issue...');
  2640. const payload = createPayload();
  2641. const response = await createIssue(payload);
  2642. persistRecently();
  2643. if (values.relation && parentIssue) {
  2644. setMessage('Creating relation to parent issue...');
  2645. await createRelation(
  2646. response.data.createIssuable.issuable,
  2647. parentIssue,
  2648. values.relation
  2649. );
  2650. }
  2651. if (parentEpic) {
  2652. setMessage('Linking to epic...');
  2653. await setIssueEpic(response.data.createIssuable.issuable, parentEpic);
  2654. }
  2655. setMessage('Issue was created');
  2656. window.setTimeout(() => onClose(), 2e3);
  2657. } catch (e) {
  2658. setMessage('');
  2659. setError(e.message);
  2660. }
  2661. setIsLoading(false);
  2662. };
  2663. const fetchParent = async () => {
  2664. if (LinkParser.isIssueLink(link)) {
  2665. const issue = await new IssueProvider().getIssue(
  2666. link.projectPath,
  2667. link.issue
  2668. );
  2669. setParentIssue(issue.data.project.issue);
  2670. }
  2671. if (LinkParser.isEpicLink(link)) {
  2672. const epic = await new EpicProvider().getEpic(
  2673. link.workspacePath,
  2674. link.epic
  2675. );
  2676. setParentEpic(epic.data.workspace.workItem);
  2677. }
  2678. };
  2679. useEffect(() => {
  2680. if (isVisible) {
  2681. fetchParent();
  2682. } else {
  2683. reset();
  2684. }
  2685. }, [isVisible]);
  2686. return {
  2687. actions: {
  2688. reset,
  2689. submit,
  2690. },
  2691. error,
  2692. form: {
  2693. assignees: {
  2694. errors: errors.assignees,
  2695. onChange: (assignees) => setValues({ ...values, assignees }),
  2696. value: values.assignees,
  2697. },
  2698. iteration: {
  2699. errors: errors.iteration,
  2700. onChange: ([iteration]) =>
  2701. setValues({ ...values, iteration: iteration ?? null }),
  2702. value: values.iteration ? [values.iteration] : [],
  2703. },
  2704. labels: {
  2705. copy: () => {
  2706. if (parentEpic) {
  2707. setValues({
  2708. ...values,
  2709. labels: WidgetHelper.epicLabels(parentEpic),
  2710. });
  2711. }
  2712. if (parentIssue) {
  2713. setValues({ ...values, labels: parentIssue.labels.nodes });
  2714. }
  2715. },
  2716. errors: errors.labels,
  2717. onChange: (labels2) => setValues({ ...values, labels: labels2 }),
  2718. value: values.labels,
  2719. },
  2720. milestone: {
  2721. errors: errors.milestone,
  2722. onChange: ([milestone]) =>
  2723. setValues({ ...values, milestone: milestone ?? null }),
  2724. value: values.milestone ? [values.milestone] : [],
  2725. },
  2726. project: {
  2727. errors: errors.project,
  2728. onChange: ([project]) =>
  2729. setValues({ ...values, project: project ?? null }),
  2730. value: values.project ? [values.project] : [],
  2731. },
  2732. relation: {
  2733. errors: errors.relation,
  2734. onChange: (relation) => setValues({ ...values, relation }),
  2735. value: values.relation,
  2736. },
  2737. title: {
  2738. copy: () => {
  2739. const parentTitle =
  2740. (parentIssue == null ? void 0 : parentIssue.title) ||
  2741. (parentEpic == null ? void 0 : parentEpic.title);
  2742. if (parentTitle) {
  2743. setValues({
  2744. ...values,
  2745. title: parentTitle,
  2746. });
  2747. }
  2748. },
  2749. errors: errors.title,
  2750. onChange: (title) => setValues({ ...values, title }),
  2751. value: values.title,
  2752. },
  2753. },
  2754. isLoading,
  2755. message,
  2756. parentEpic,
  2757. parentIssue,
  2758. projectPath: (_a = values.project) == null ? void 0 : _a.fullPath,
  2759. };
  2760. }
  2761.  
  2762. // apps/gitlab-plus/src/components/create-issue/CreateIssueForm.tsx
  2763. function CreateIssueForm({ isVisible, link, onClose }) {
  2764. const {
  2765. actions,
  2766. error,
  2767. form,
  2768. isLoading,
  2769. message,
  2770. parentEpic,
  2771. parentIssue,
  2772. projectPath,
  2773. } = useCreateIssueForm({ isVisible, link, onClose });
  2774. return jsxs('form', {
  2775. class: 'crud-body add-tree-form gl-mx-5 gl-my-4 gl-rounded-b-form',
  2776. children: [
  2777. jsx(FormField, {
  2778. error: form.title.errors,
  2779. hint: 'Maximum of 255 characters',
  2780. title: 'Title',
  2781. children: jsxs('div', {
  2782. className: 'gl-flex gl-gap-1',
  2783. children: [
  2784. jsx(TitleField, {
  2785. error: form.title.errors,
  2786. onChange: form.title.onChange,
  2787. value: form.title.value,
  2788. }),
  2789. jsx(GitlabButton, {
  2790. icon: 'title',
  2791. onClick: form.title.copy,
  2792. title: 'Copy from parent title',
  2793. }),
  2794. ],
  2795. }),
  2796. }),
  2797. jsxs(FormRow, {
  2798. children: [
  2799. jsx(FormField, {
  2800. error: form.project.errors,
  2801. title: 'Project',
  2802. children: jsx(ProjectField, {
  2803. link,
  2804. setValue: form.project.onChange,
  2805. value: form.project.value,
  2806. }),
  2807. }),
  2808. jsx(FormField, {
  2809. error: form.assignees.errors,
  2810. title: 'Assignees',
  2811. children: jsx(AssigneesField, {
  2812. projectPath,
  2813. setValue: form.assignees.onChange,
  2814. value: form.assignees.value,
  2815. }),
  2816. }),
  2817. ],
  2818. }),
  2819. jsxs(FormRow, {
  2820. children: [
  2821. jsx(FormField, {
  2822. error: form.iteration.errors,
  2823. title: 'Iteration',
  2824. children: jsx(IterationField, {
  2825. link,
  2826. setValue: form.iteration.onChange,
  2827. value: form.iteration.value,
  2828. }),
  2829. }),
  2830. jsx(FormField, {
  2831. error: form.milestone.errors,
  2832. title: 'Milestone',
  2833. children: jsx(MilestoneField, {
  2834. projectPath,
  2835. setValue: form.milestone.onChange,
  2836. value: form.milestone.value,
  2837. }),
  2838. }),
  2839. ],
  2840. }),
  2841. jsx(FormField, {
  2842. error: form.labels.errors,
  2843. title: 'Labels',
  2844. children: jsx(LabelField, {
  2845. copyLabels: form.labels.copy,
  2846. projectPath,
  2847. setValue: form.labels.onChange,
  2848. value: form.labels.value,
  2849. }),
  2850. }),
  2851. parentIssue &&
  2852. jsxs(FormField, {
  2853. error: form.relation.errors,
  2854. title: 'New issue',
  2855. children: [
  2856. jsx(RelationField, {
  2857. setValue: form.relation.onChange,
  2858. value: form.relation.value,
  2859. }),
  2860. jsxs(Text, {
  2861. size: 'sm',
  2862. variant: 'secondary',
  2863. children: [
  2864. 'Parent issue: #',
  2865. parentIssue.iid,
  2866. ' ',
  2867. parentIssue.title,
  2868. ],
  2869. }),
  2870. ],
  2871. }),
  2872. parentEpic &&
  2873. jsx(FormField, {
  2874. title: '',
  2875. children: jsxs(Text, {
  2876. size: 'sm',
  2877. variant: 'secondary',
  2878. children: ['Parent epic: &', parentEpic.iid, ' ', parentEpic.title],
  2879. }),
  2880. }),
  2881. jsx(FormField, {
  2882. error,
  2883. hint: message,
  2884. title: '',
  2885. children: jsx(FormRow, {
  2886. children: jsx(ButtonField, {
  2887. create: actions.submit,
  2888. isLoading,
  2889. reset: actions.reset,
  2890. }),
  2891. }),
  2892. }),
  2893. ],
  2894. });
  2895. }
  2896.  
  2897. // apps/gitlab-plus/src/components/create-issue/events.ts
  2898. const showRelatedIssueModal = 'glp-show-create-issue-modal';
  2899. const showChildIssueModal = 'glp-show-create-child-issue-modal';
  2900. const ShowRelatedIssueModalEvent = new CustomEvent(showRelatedIssueModal);
  2901. const ShowChildIssueModalEvent = new CustomEvent(showChildIssueModal);
  2902.  
  2903. // apps/gitlab-plus/src/components/create-issue/CreateChildIssueModal.tsx
  2904. function CreateChildIssueModal({ link }) {
  2905. const { isVisible, onClose } = useGlpModal(showChildIssueModal);
  2906. return jsx(GlpModal, {
  2907. isVisible,
  2908. onClose,
  2909. title: 'Create child issue',
  2910. children: jsx(CreateIssueForm, { isVisible, link, onClose }),
  2911. });
  2912. }
  2913.  
  2914. // apps/gitlab-plus/src/services/CreateChildIssue.tsx
  2915. class CreateChildIssue extends BaseService {
  2916. constructor() {
  2917. super(...arguments);
  2918. __publicField(this, 'name', ServiceName.CreateChildIssue);
  2919. __publicField(this, 'isMounted', false);
  2920. }
  2921.  
  2922. init() {
  2923. this.mount();
  2924. setTimeout(this.mount.bind(this), 1e3);
  2925. setTimeout(this.mount.bind(this), 3e3);
  2926. }
  2927.  
  2928. mount() {
  2929. if (this.isMounted) {
  2930. return;
  2931. }
  2932. const link = LinkParser.parseEpicLink(window.location.href);
  2933. const parent = document.querySelector(
  2934. '#childitems [data-testid="crud-actions"]'
  2935. );
  2936. if (!link || !parent) {
  2937. return;
  2938. }
  2939. this.isMounted = true;
  2940. render(
  2941. jsx(GitlabButton, {
  2942. onClick: () => document.dispatchEvent(ShowChildIssueModalEvent),
  2943. children: 'Create child item',
  2944. }),
  2945. this.root('glp-child-issue-button', parent, true)
  2946. );
  2947. render(
  2948. jsx(CreateChildIssueModal, { link }),
  2949. this.rootBody('glp-child-issue-modal')
  2950. );
  2951. }
  2952. }
  2953.  
  2954. // apps/gitlab-plus/src/components/create-issue/CreateRelatedIssueModal.tsx
  2955. function CreateRelatedIssueModal({ link }) {
  2956. const { isVisible, onClose } = useGlpModal(showRelatedIssueModal);
  2957. return jsx(GlpModal, {
  2958. isVisible,
  2959. onClose,
  2960. title: 'Create related issue',
  2961. children: jsx(CreateIssueForm, { isVisible, link, onClose }),
  2962. });
  2963. }
  2964.  
  2965. // apps/gitlab-plus/src/services/CreateRelatedIssue.tsx
  2966. class CreateRelatedIssue extends BaseService {
  2967. constructor() {
  2968. super(...arguments);
  2969. __publicField(this, 'name', ServiceName.CreateRelatedIssue);
  2970. __publicField(this, 'isMounted', false);
  2971. }
  2972.  
  2973. init() {
  2974. this.mount();
  2975. setTimeout(this.mount.bind(this), 1e3);
  2976. setTimeout(this.mount.bind(this), 3e3);
  2977. }
  2978.  
  2979. mount() {
  2980. if (this.isMounted) {
  2981. return;
  2982. }
  2983. const link = LinkParser.parseIssueLink(window.location.href);
  2984. const parent = document.querySelector(
  2985. '#related-issues [data-testid="crud-actions"]'
  2986. );
  2987. if (!link || !parent) {
  2988. return;
  2989. }
  2990. this.isMounted = true;
  2991. render(
  2992. jsx(GitlabButton, {
  2993. onClick: () => document.dispatchEvent(ShowRelatedIssueModalEvent),
  2994. children: 'Create related issue',
  2995. }),
  2996. this.root('glp-related-issue-button', parent)
  2997. );
  2998. render(
  2999. jsx(CreateRelatedIssueModal, { link }),
  3000. this.rootBody('glp-related-issue-modal')
  3001. );
  3002. }
  3003. }
  3004.  
  3005. // apps/gitlab-plus/src/components/common/base/Row.tsx
  3006. function Row({ children, className, gap, items, justify }) {
  3007. return jsx('div', {
  3008. class: clsx(
  3009. 'gl-flex gl-flex-row',
  3010. justify && `gl-justify-${justify}`,
  3011. items && `gl-items-${items}`,
  3012. gap && `gl-gap-${gap}`,
  3013. className
  3014. ),
  3015. children,
  3016. });
  3017. }
  3018.  
  3019. // libs/share/src/ui/Events.ts
  3020. class Events {
  3021. static intendHover(validate, mouseover, mouseleave, timeout = 500) {
  3022. let hover = false;
  3023. let id = 0;
  3024. const onHover = (event) => {
  3025. if (!event.target || !validate(event.target)) {
  3026. return;
  3027. }
  3028. const element = event.target;
  3029. hover = true;
  3030. element.addEventListener(
  3031. 'mouseleave',
  3032. (ev) => {
  3033. mouseleave == null ? void 0 : mouseleave.call(element, ev);
  3034. clearTimeout(id);
  3035. hover = false;
  3036. },
  3037. { once: true }
  3038. );
  3039. clearTimeout(id);
  3040. id = window.setTimeout(() => {
  3041. if (hover) {
  3042. mouseover.call(element, event);
  3043. }
  3044. }, timeout);
  3045. };
  3046. document.body.addEventListener('mouseover', onHover);
  3047. }
  3048. }
  3049.  
  3050. // apps/gitlab-plus/src/components/common/useOnLinkHover.ts
  3051. const modalZIndex = 1e3;
  3052.  
  3053. function useOnLinkHover(parser, validator) {
  3054. const [hoverPosition, setHoverPosition] = useState({ x: 0, y: 0 });
  3055. const [hoverLink, setHoverLink] = useState();
  3056. const [zIndex, setZIndex] = useState(modalZIndex);
  3057. const hoverLinkRef = useRef(false);
  3058. const onHover = (event) => {
  3059. const anchor = event.target;
  3060. const link = parser(anchor.href);
  3061. if (!link) {
  3062. return;
  3063. }
  3064. anchor.title = '';
  3065. setHoverLink(link);
  3066. setZIndex(
  3067. anchor.dataset.zIndex ? Number(anchor.dataset.zIndex) : modalZIndex
  3068. );
  3069. setHoverPosition({
  3070. x: event.clientX + 15,
  3071. y: event.clientY,
  3072. });
  3073. };
  3074. useEffect(() => {
  3075. Events.intendHover(
  3076. (element) => validator(element.href),
  3077. onHover,
  3078. () => {
  3079. setTimeout(() => {
  3080. if (!hoverLinkRef.current) {
  3081. setHoverLink(void 0);
  3082. }
  3083. }, 50);
  3084. }
  3085. );
  3086. }, []);
  3087. return {
  3088. hoverLink,
  3089. hoverPosition,
  3090. onLinkEnter: () => (hoverLinkRef.current = true),
  3091. onLinkLeave: () => {
  3092. hoverLinkRef.current = false;
  3093. setHoverLink(void 0);
  3094. },
  3095. zIndex,
  3096. };
  3097. }
  3098.  
  3099. // apps/gitlab-plus/src/components/common/usePreviewModal.ts
  3100. function usePreviewModal(link, fetch2, reset, isLoading) {
  3101. const [isVisible, setIsVisible] = useState(false);
  3102. const [offset, setOffset] = useState({ x: 0, y: 0 });
  3103. const ref = useRef(null);
  3104. useEffect(() => {
  3105. if (!isLoading) {
  3106. setTimeout(() => {
  3107. const rect = ref.current.getBoundingClientRect();
  3108. const dY = rect.height + rect.top - window.innerHeight;
  3109. const dX = rect.width + rect.left - window.innerWidth;
  3110. setOffset({
  3111. x: dX > 0 ? dX + 15 : 0,
  3112. y: dY > 0 ? dY + 15 : 0,
  3113. });
  3114. }, 300);
  3115. }
  3116. }, [isLoading]);
  3117. useEffect(() => {
  3118. if (!isVisible) {
  3119. setOffset({ x: 0, y: 0 });
  3120. }
  3121. }, [isVisible]);
  3122. useEffect(() => {
  3123. if (link) {
  3124. fetch2(link);
  3125. setIsVisible(true);
  3126. } else {
  3127. setIsVisible(false);
  3128. reset();
  3129. }
  3130. }, [link]);
  3131. return {
  3132. isVisible,
  3133. offset,
  3134. ref,
  3135. };
  3136. }
  3137.  
  3138. // apps/gitlab-plus/src/components/common/PreviewModal.tsx
  3139. function PreviewModal({
  3140. validator,
  3141. children,
  3142. fetch: fetch2,
  3143. isError,
  3144. isLoading = false,
  3145. isRefreshing = false,
  3146. parser,
  3147. reset,
  3148. }) {
  3149. const { hoverLink, hoverPosition, onLinkEnter, onLinkLeave, zIndex } =
  3150. useOnLinkHover(parser, validator);
  3151. const { isVisible, offset, ref } = usePreviewModal(
  3152. hoverLink,
  3153. fetch2,
  3154. reset,
  3155. isLoading
  3156. );
  3157. const content = useMemo(() => {
  3158. if (isLoading || !isVisible) {
  3159. return jsx(Row, {
  3160. className: 'gl-flex-1',
  3161. items: 'center',
  3162. justify: 'center',
  3163. children: jsx(GitlabLoader, { size: '3em' }),
  3164. });
  3165. }
  3166. if (isError) {
  3167. return jsx(Row, {
  3168. className: 'gl-flex-1',
  3169. items: 'center',
  3170. justify: 'center',
  3171. children: 'Error',
  3172. });
  3173. }
  3174. return jsxs('div', {
  3175. className: 'gl-flex gl-w-full gl-flex-col',
  3176. children: [
  3177. children,
  3178. isRefreshing &&
  3179. jsx(Row, {
  3180. className: 'gl-h-full gl-w-full gl-absolute gl-bg-overlay',
  3181. items: 'center',
  3182. justify: 'center',
  3183. children: jsx(GitlabLoader, { size: '3em' }),
  3184. }),
  3185. ],
  3186. });
  3187. }, [isLoading, isRefreshing, isError, isVisible, children]);
  3188. return jsx('div', {
  3189. className: clsx('glp-preview-modal', isVisible && 'glp-modal-visible'),
  3190. onMouseEnter: onLinkEnter,
  3191. onMouseLeave: onLinkLeave,
  3192. ref,
  3193. style: {
  3194. left: hoverPosition.x,
  3195. top: hoverPosition.y,
  3196. transform: `translate(-${offset.x}px, -${offset.y}px )`,
  3197. zIndex,
  3198. },
  3199. children: content,
  3200. });
  3201. }
  3202.  
  3203. // apps/gitlab-plus/src/components/common/block/HeadingBlock.tsx
  3204. function HeadingBlock({
  3205. author,
  3206. badge,
  3207. createdAt,
  3208. entityId,
  3209. icon,
  3210. onRefresh,
  3211. title,
  3212. }) {
  3213. return jsxs('div', {
  3214. className: 'glp-block gl-relative',
  3215. children: [
  3216. jsxs(Row, {
  3217. className: '',
  3218. items: 'center',
  3219. justify: 'between',
  3220. children: [
  3221. jsx('span', {
  3222. className: clsx(
  3223. 'gl-font-bold gl-leading-20 gl-text-gray-900',
  3224. onRefresh && 'gl-pr-5'
  3225. ),
  3226. children: title,
  3227. }),
  3228. onRefresh &&
  3229. jsx('div', {
  3230. onClick: onRefresh,
  3231. className:
  3232. 'gl-absolute gl-right-0 gl-top-0 gl-p-2 gl-cursor-pointer',
  3233. children: jsx(GitlabIcon, { icon: 'repeat' }),
  3234. }),
  3235. ],
  3236. }),
  3237. jsxs(Row, {
  3238. className: 'gl-mt-2',
  3239. gap: 2,
  3240. items: 'center',
  3241. children: [
  3242. jsxs(Row, {
  3243. gap: 2,
  3244. items: 'center',
  3245. children: [
  3246. jsx(GitlabIcon, { icon, size: 16 }),
  3247. jsx(Text, {
  3248. size: 'sm',
  3249. variant: 'secondary',
  3250. weight: 'bold',
  3251. children: entityId,
  3252. }),
  3253. ],
  3254. }),
  3255. badge,
  3256. ],
  3257. }),
  3258. jsxs(Row, {
  3259. className: 'gl-mt-1',
  3260. gap: 2,
  3261. items: 'center',
  3262. children: [
  3263. jsx(Text, {
  3264. size: 'sm',
  3265. variant: 'secondary',
  3266. children: 'Created at',
  3267. }),
  3268. jsx(Text, {
  3269. size: 'sm',
  3270. weight: 'bold',
  3271. children: new Date(createdAt).toLocaleDateString(),
  3272. }),
  3273. jsx(Text, { size: 'sm', variant: 'secondary', children: 'by' }),
  3274. jsx(GitlabUser, {
  3275. size: 16,
  3276. user: author,
  3277. smallText: true,
  3278. withLink: true,
  3279. }),
  3280. ],
  3281. }),
  3282. ],
  3283. });
  3284. }
  3285.  
  3286. // apps/gitlab-plus/src/components/common/GitlabBadge.tsx
  3287. function GitlabBadge({ icon, label, title, variant }) {
  3288. return jsxs('span', {
  3289. className: `gl-badge badge badge-pill badge-${variant}`,
  3290. title,
  3291. children: [
  3292. icon && jsx(GitlabIcon, { icon }),
  3293. label &&
  3294. jsx('span', {
  3295. className: 'gl-badge-content',
  3296. children: label,
  3297. }),
  3298. ],
  3299. });
  3300. }
  3301.  
  3302. // apps/gitlab-plus/src/components/common/IssueStatus.tsx
  3303. function IssueStatus({ isOpen }) {
  3304. return jsx(GitlabBadge, {
  3305. icon: isOpen ? 'issue-open-m' : 'issue-close',
  3306. label: isOpen ? 'Open' : 'Closed',
  3307. variant: isOpen ? 'success' : 'info',
  3308. });
  3309. }
  3310.  
  3311. // apps/gitlab-plus/src/components/epic-preview/blocks/EpicHeading.tsx
  3312. function EpicHeader({ epic, onRefresh }) {
  3313. return jsx(HeadingBlock, {
  3314. author: epic.author,
  3315. badge: jsx(IssueStatus, { isOpen: epic.state === 'OPEN' }),
  3316. createdAt: epic.createdAt,
  3317. entityId: `&${epic.iid}`,
  3318. icon: 'epic',
  3319. onRefresh,
  3320. title: epic.title,
  3321. });
  3322. }
  3323.  
  3324. // apps/gitlab-plus/src/components/common/block/InfoBlock.tsx
  3325. function InfoBlock({ children, className, icon, rightTitle, title }) {
  3326. return jsxs('div', {
  3327. class: 'glp-block gl-relative',
  3328. children: [
  3329. jsxs(Row, {
  3330. items: 'center',
  3331. justify: 'between',
  3332. children: [
  3333. jsxs(Row, {
  3334. gap: 2,
  3335. items: 'center',
  3336. children: [
  3337. icon && jsx(GitlabIcon, { icon, size: 16 }),
  3338. jsx('span', {
  3339. className: 'gl-font-bold gl-leading-20 gl-text-gray-900',
  3340. dangerouslySetInnerHTML: { __html: title },
  3341. }),
  3342. ],
  3343. }),
  3344. rightTitle,
  3345. ],
  3346. }),
  3347. jsx('div', { class: className, children }),
  3348. ],
  3349. });
  3350. }
  3351.  
  3352. // apps/gitlab-plus/src/components/common/block/LabelsBlockChangeStatus.tsx
  3353. function LabelsBlockChangeStatus({
  3354. isLoading,
  3355. name: name2,
  3356. onChange,
  3357. options,
  3358. }) {
  3359. if (isLoading) {
  3360. return jsx(GitlabLoader, {});
  3361. }
  3362. const getValues = useCallback(
  3363. async (search) => {
  3364. return options.filter((option) => option.title.includes(search));
  3365. },
  3366. [options]
  3367. );
  3368. const renderOption = useCallback((item) => {
  3369. return jsxs('div', {
  3370. class: 'gl-flex gl-flex-1 gl-break-anywhere gl-pb-3 gl-pl-4 gl-pt-3',
  3371. children: [
  3372. jsx('span', {
  3373. class: 'dropdown-label-box gl-top-0 gl-mr-3 gl-shrink-0',
  3374. style: { backgroundColor: item.color },
  3375. }),
  3376. jsx('span', { children: item.title }),
  3377. ],
  3378. });
  3379. }, []);
  3380. return jsx('div', {
  3381. className: 'gl-py-2',
  3382. style: { width: 130 },
  3383. children: jsx(AsyncAutocomplete, {
  3384. hideCheckbox: true,
  3385. buttonSize: 'sm',
  3386. getValues,
  3387. name: name2,
  3388. onChange: ([label]) => label && onChange(label),
  3389. renderLabel: () => 'Change status',
  3390. renderOption,
  3391. value: [],
  3392. }),
  3393. });
  3394. }
  3395.  
  3396. // apps/gitlab-plus/src/components/common/block/useLabelBlock.ts
  3397. const name = 'status-labels';
  3398.  
  3399. function useLabelBlock(statusUpdate) {
  3400. const [isLoading, setIsLoading] = useState(false);
  3401. const onSelectStatus = useCallback(async (label) => {
  3402. setIsLoading(true);
  3403. if (statusUpdate) {
  3404. await statusUpdate.update(label);
  3405. new RecentlyProvider(name).add(label);
  3406. }
  3407. setIsLoading(false);
  3408. }, []);
  3409. return {
  3410. isLoading,
  3411. name,
  3412. onSelectStatus,
  3413. showChangeStatusComponent: Boolean(statusUpdate),
  3414. statusLabels: (statusUpdate == null ? void 0 : statusUpdate.labels) || [],
  3415. };
  3416. }
  3417.  
  3418. // apps/gitlab-plus/src/components/common/block/LabelsBlock.tsx
  3419. function LabelsBlock({ labels: labels2, updateStatus }) {
  3420. const {
  3421. isLoading,
  3422. name: name2,
  3423. onSelectStatus,
  3424. showChangeStatusComponent,
  3425. statusLabels,
  3426. } = useLabelBlock(updateStatus);
  3427. if (!labels2.length && !updateStatus) {
  3428. return null;
  3429. }
  3430. return jsx(InfoBlock, {
  3431. className: 'issuable-show-labels',
  3432. icon: 'labels',
  3433. title: 'Labels',
  3434. rightTitle:
  3435. showChangeStatusComponent &&
  3436. jsx(LabelsBlockChangeStatus, {
  3437. isLoading,
  3438. name: name2,
  3439. onChange: onSelectStatus,
  3440. options: statusLabels,
  3441. }),
  3442. children: labels2.map((label) => jsx(GitlabLabel, { label }, label.id)),
  3443. });
  3444. }
  3445.  
  3446. // apps/gitlab-plus/src/components/epic-preview/blocks/useEpicLabels.ts
  3447. function useEpicLabels(epic, refetch) {
  3448. const [statusLabels, setStatusLabels] = useState([]);
  3449. const labels2 = useMemo(() => {
  3450. const labelWidget = epic.widgets.find((widget) => widget.type === 'LABELS');
  3451. if (labelWidget) {
  3452. return labelWidget.labels.nodes;
  3453. }
  3454. return [];
  3455. }, [epic]);
  3456. const onStatusChange = useCallback(
  3457. async (label) => {
  3458. const oldStatus = labels2.filter((l) => l.title.includes('Status::'));
  3459. await new EpicProvider().updateEpicLabels(
  3460. epic.id,
  3461. [label.id],
  3462. oldStatus.map((l) => l.id)
  3463. );
  3464. if (refetch) {
  3465. await refetch();
  3466. }
  3467. },
  3468. [labels2]
  3469. );
  3470. const fetchLabels = useCallback(async (workspacePath) => {
  3471. const response = await new LabelsProvider().getWorkspaceLabels(
  3472. workspacePath,
  3473. 'Status::'
  3474. );
  3475. setStatusLabels(response.data.workspace.labels.nodes);
  3476. }, []);
  3477. useEffect(() => {
  3478. fetchLabels(epic.namespace.fullPath);
  3479. }, []);
  3480. return {
  3481. labels: labels2,
  3482. updateStatus: {
  3483. labels: statusLabels,
  3484. update: onStatusChange,
  3485. },
  3486. };
  3487. }
  3488.  
  3489. // apps/gitlab-plus/src/components/epic-preview/blocks/EpicLabels.tsx
  3490. function EpicLabels({ epic, refresh }) {
  3491. const { labels: labels2, updateStatus } = useEpicLabels(epic, refresh);
  3492. if (!labels2.length) {
  3493. return null;
  3494. }
  3495. return jsx(LabelsBlock, { labels: labels2, updateStatus });
  3496. }
  3497.  
  3498. // apps/gitlab-plus/src/components/common/base/Link.tsx
  3499. function Link({ blockHover, children, className, href, inline, title }) {
  3500. const [zIndex, setZIndex] = useState(modalZIndex + 1);
  3501. const ref = useRef(null);
  3502. const onHover = (e) => {
  3503. e.stopPropagation();
  3504. e.preventDefault();
  3505. return false;
  3506. };
  3507. useLayoutEffect(() => {
  3508. let _a;
  3509. const modal =
  3510. (_a = ref.current) == null ? void 0 : _a.closest('.glp-preview-modal');
  3511. setZIndex(
  3512. (modal == null ? void 0 : modal.style.zIndex)
  3513. ? Number(modal.style.zIndex) + 1
  3514. : modalZIndex + 1
  3515. );
  3516. }, []);
  3517. return jsx('a', {
  3518. 'data-z-index': zIndex,
  3519. href,
  3520. onMouseOver: blockHover ? onHover : void 0,
  3521. ref,
  3522. target: '_blank',
  3523. title,
  3524. class: clsx(
  3525. inline ? 'gl-inline' : 'gl-block',
  3526. 'gl-link sortable-link',
  3527. className
  3528. ),
  3529. style: {
  3530. overflow: 'hidden',
  3531. textOverflow: 'ellipsis',
  3532. },
  3533. children,
  3534. });
  3535. }
  3536.  
  3537. // apps/gitlab-plus/src/components/epic-preview/blocks/EpicRelatedIssues.tsx
  3538. function EpicRelatedIssues({ epic }) {
  3539. const issues = useMemo(() => {
  3540. const hierarchyWidget = epic.widgets.find(
  3541. (widget) => widget.type === 'HIERARCHY'
  3542. );
  3543. if (!hierarchyWidget) {
  3544. return [];
  3545. }
  3546. return hierarchyWidget.children.nodes;
  3547. }, [epic]);
  3548. if (!issues.length) {
  3549. return null;
  3550. }
  3551. return jsx(InfoBlock, {
  3552. icon: 'issue-type-issue',
  3553. title: `Child issues (${issues.length})`,
  3554. children: issues.map((issue) =>
  3555. jsxs(
  3556. Link,
  3557. {
  3558. href: issue.webUrl,
  3559. title: issue.title,
  3560. children: ['#', issue.iid, ' ', issue.title],
  3561. },
  3562. issue.iid
  3563. )
  3564. ),
  3565. });
  3566. }
  3567.  
  3568. // apps/gitlab-plus/src/components/common/useFetchEntity.ts
  3569. function useFetchEntity(fetcher) {
  3570. const [entityData, setEntityData] = useState(null);
  3571. const [isLoading, setIsLoading] = useState(false);
  3572. const [isRefreshing, setIsRefreshing] = useState(false);
  3573. const fetch2 = async (link, force = false) => {
  3574. if (force) {
  3575. setIsRefreshing(true);
  3576. } else {
  3577. setIsLoading(true);
  3578. }
  3579. const entity = await fetcher(link, force);
  3580. setEntityData({ entity, link });
  3581. setIsRefreshing(false);
  3582. setIsLoading(false);
  3583. };
  3584. const reset = () => {
  3585. setEntityData(null);
  3586. setIsRefreshing(false);
  3587. setIsLoading(false);
  3588. };
  3589. return {
  3590. entityData,
  3591. fetch: fetch2,
  3592. isLoading,
  3593. isRefreshing,
  3594. reset,
  3595. };
  3596. }
  3597.  
  3598. // apps/gitlab-plus/src/components/epic-preview/useFetchEpic.ts
  3599. function useFetchEpic() {
  3600. return useFetchEntity(async (link, force = false) => {
  3601. const response = await new EpicProvider(force).getEpic(
  3602. link.workspacePath,
  3603. link.epic
  3604. );
  3605. return response.data.workspace.workItem;
  3606. });
  3607. }
  3608.  
  3609. // apps/gitlab-plus/src/components/epic-preview/EpicPreviewModal.tsx
  3610. function EpicPreviewModal() {
  3611. const {
  3612. entityData,
  3613. fetch: fetch2,
  3614. isLoading,
  3615. isRefreshing,
  3616. reset,
  3617. } = useFetchEpic();
  3618. return jsx(PreviewModal, {
  3619. validator: LinkParser.validateEpicLink,
  3620. fetch: fetch2,
  3621. isError: !entityData,
  3622. isLoading,
  3623. isRefreshing,
  3624. parser: LinkParser.parseEpicLink,
  3625. reset,
  3626. children:
  3627. entityData &&
  3628. jsxs(Fragment, {
  3629. children: [
  3630. jsx(EpicHeader, {
  3631. epic: entityData.entity,
  3632. onRefresh: () => fetch2(entityData.link, true),
  3633. }),
  3634. jsx(EpicLabels, {
  3635. epic: entityData.entity,
  3636. refresh: () => fetch2(entityData.link, true),
  3637. }),
  3638. jsx(EpicRelatedIssues, { epic: entityData.entity }),
  3639. ],
  3640. }),
  3641. });
  3642. }
  3643.  
  3644. // apps/gitlab-plus/src/services/EpicPreview.tsx
  3645. class EpicPreview extends BaseService {
  3646. constructor() {
  3647. super(...arguments);
  3648. __publicField(this, 'name', ServiceName.EpicPreview);
  3649. }
  3650.  
  3651. init() {
  3652. render(jsx(EpicPreviewModal, {}), this.rootBody('glp-epic-preview-root'));
  3653. }
  3654. }
  3655.  
  3656. // apps/gitlab-plus/src/components/image-preview/useImagePreviewModal.ts
  3657. function useImagePreviewModal() {
  3658. const [src, setSrc] = useState('');
  3659. const validate = (element) => {
  3660. return (
  3661. element.classList.contains('no-attachment-icon') &&
  3662. /\.(png|jpg|jpeg|heic)$/.test(element.href.toLowerCase())
  3663. );
  3664. };
  3665. const getAnchor = (element) => {
  3666. if (!element) {
  3667. return void 0;
  3668. }
  3669. if (element instanceof HTMLAnchorElement) {
  3670. return validate(element) ? element : void 0;
  3671. }
  3672. if (
  3673. element instanceof HTMLImageElement &&
  3674. element.parentElement instanceof HTMLAnchorElement
  3675. ) {
  3676. return validate(element.parentElement) ? element.parentElement : void 0;
  3677. }
  3678. return void 0;
  3679. };
  3680. useEffect(() => {
  3681. document.body.addEventListener('click', (ev) => {
  3682. const anchor = getAnchor(ev.target);
  3683. if (anchor) {
  3684. setSrc(anchor.href);
  3685. ev.preventDefault();
  3686. ev.stopPropagation();
  3687. return false;
  3688. }
  3689. });
  3690. }, []);
  3691. return {
  3692. onClose: () => setSrc(''),
  3693. src,
  3694. };
  3695. }
  3696.  
  3697. // apps/gitlab-plus/src/components/image-preview/ImagePreviewModal.tsx
  3698. function ImagePreviewModal() {
  3699. const { onClose, src } = useImagePreviewModal();
  3700. return jsxs('div', {
  3701. className: clsx(
  3702. 'glp-image-preview-modal',
  3703. Boolean(src) && 'glp-modal-visible'
  3704. ),
  3705. children: [
  3706. jsx('img', { alt: 'Image preview', className: 'glp-modal-img', src }),
  3707. jsx('div', {
  3708. className: 'glp-modal-close',
  3709. onClick: onClose,
  3710. children: jsx(GitlabIcon, { icon: 'close-xs', size: 24 }),
  3711. }),
  3712. ],
  3713. });
  3714. }
  3715.  
  3716. // apps/gitlab-plus/src/services/ImagePreview.tsx
  3717. class ImagePreview extends BaseService {
  3718. constructor() {
  3719. super(...arguments);
  3720. __publicField(this, 'name', ServiceName.ImagePreview);
  3721. }
  3722.  
  3723. init() {
  3724. render(jsx(ImagePreviewModal, {}), this.rootBody('glp-image-preview-root'));
  3725. }
  3726. }
  3727.  
  3728. // apps/gitlab-plus/src/components/common/block/UsersBlock.tsx
  3729. function UsersBlock({ assignees, icon, label, pluralIcon, pluralLabel }) {
  3730. if (!assignees || !assignees.length) {
  3731. return null;
  3732. }
  3733. if (assignees.length === 1) {
  3734. return jsx(InfoBlock, {
  3735. className: 'gl-flex gl-flex-col gl-gap-3',
  3736. icon: icon || 'user',
  3737. rightTitle: jsx(GitlabUser, { user: assignees[0], withLink: true }),
  3738. title: `${label}:`,
  3739. });
  3740. }
  3741. return jsx(InfoBlock, {
  3742. className: 'gl-flex gl-flex-col gl-gap-3',
  3743. icon: pluralIcon || icon || 'users',
  3744. title: pluralLabel || `${label}s`,
  3745. children: assignees.map((assignee) =>
  3746. jsx(GitlabUser, { user: assignee, withLink: true }, assignee.id)
  3747. ),
  3748. });
  3749. }
  3750.  
  3751. // apps/gitlab-plus/src/components/issue-preview/blocks/IssueAssignee.tsx
  3752. function IssueAssignee({ issue }) {
  3753. return jsx(UsersBlock, {
  3754. assignees: issue.assignees.nodes,
  3755. icon: 'assignee',
  3756. label: 'Assignee',
  3757. });
  3758. }
  3759.  
  3760. // apps/gitlab-plus/src/components/issue-preview/blocks/IssueEpic.tsx
  3761. function IssueEpic({ issue }) {
  3762. if (!issue.epic) {
  3763. return null;
  3764. }
  3765. return jsx(InfoBlock, {
  3766. icon: 'epic',
  3767. title: 'Epic',
  3768. children: jsx(Link, {
  3769. href: issue.epic.webUrl,
  3770. title: issue.epic.title,
  3771. children: issue.epic.title,
  3772. }),
  3773. });
  3774. }
  3775.  
  3776. // apps/gitlab-plus/src/components/issue-preview/blocks/IssueHeading.tsx
  3777. function IssueHeader({ issue, onRefresh }) {
  3778. return jsx(HeadingBlock, {
  3779. author: issue.author,
  3780. badge: jsx(IssueStatus, { isOpen: issue.state === 'opened' }),
  3781. createdAt: issue.createdAt,
  3782. entityId: `#${issue.iid}`,
  3783. icon: 'issue-type-issue',
  3784. onRefresh,
  3785. title: issue.title,
  3786. });
  3787. }
  3788.  
  3789. // apps/gitlab-plus/src/components/issue-preview/blocks/IssueIteration.tsx
  3790. function IssueIteration({ issue }) {
  3791. const label = useMemo(() => {
  3792. let _a;
  3793. const date = (date2) => {
  3794. return new Intl.DateTimeFormat('en-US', {
  3795. day: 'numeric',
  3796. month: 'short',
  3797. }).format(new Date(date2));
  3798. };
  3799. if (!issue.iteration) {
  3800. return '';
  3801. }
  3802. return [
  3803. (_a = issue.iteration.iterationCadence) == null ? void 0 : _a.title,
  3804. ': ',
  3805. date(issue.iteration.startDate),
  3806. ' - ',
  3807. date(issue.iteration.dueDate),
  3808. ].join('');
  3809. }, [issue]);
  3810. if (!issue.iteration) {
  3811. return null;
  3812. }
  3813. return jsx(InfoBlock, {
  3814. icon: 'iteration',
  3815. rightTitle: label,
  3816. title: 'Iteration',
  3817. });
  3818. }
  3819.  
  3820. // apps/gitlab-plus/src/components/issue-preview/blocks/useIssueLabels.ts
  3821. function useIssueLabels(issue, link, refetch) {
  3822. const [statusLabels, setStatusLabels] = useState([]);
  3823. const onStatusChange = useCallback(
  3824. async (label) => {
  3825. const statusLabel = issue.labels.nodes.find((l) =>
  3826. l.title.includes('Status::')
  3827. );
  3828. const labels2 = statusLabel
  3829. ? issue.labels.nodes.map((l) => (l.id === statusLabel.id ? label : l))
  3830. : [...issue.labels.nodes, label];
  3831. await new IssueProvider().issueSetLabels({
  3832. iid: issue.iid,
  3833. labelIds: labels2.map((l) => l.id),
  3834. projectPath: link.projectPath,
  3835. });
  3836. if (refetch) {
  3837. await refetch();
  3838. }
  3839. },
  3840. [issue]
  3841. );
  3842. const fetchLabels = useCallback(async (projectPath) => {
  3843. const response = await new LabelsProvider().getProjectLabels(
  3844. projectPath,
  3845. 'Status::'
  3846. );
  3847. setStatusLabels(response.data.workspace.labels.nodes);
  3848. }, []);
  3849. useEffect(() => {
  3850. fetchLabels(link.projectPath);
  3851. }, []);
  3852. return {
  3853. labels: issue.labels.nodes,
  3854. updateStatus: {
  3855. labels: statusLabels,
  3856. update: onStatusChange,
  3857. },
  3858. };
  3859. }
  3860.  
  3861. // apps/gitlab-plus/src/components/issue-preview/blocks/IssueLabels.tsx
  3862. function IssueLabels({ issue, link, refetch }) {
  3863. const { labels: labels2, updateStatus } = useIssueLabels(
  3864. issue,
  3865. link,
  3866. refetch
  3867. );
  3868. if (!labels2.length) {
  3869. return null;
  3870. }
  3871. return jsx(LabelsBlock, { labels: labels2, updateStatus });
  3872. }
  3873.  
  3874. // apps/gitlab-plus/src/components/common/MrStatus.tsx
  3875. const iconMap = {
  3876. closed: 'merge-request-close',
  3877. locked: 'search',
  3878. merged: 'merge',
  3879. opened: 'merge-request',
  3880. };
  3881. const classMap = {
  3882. closed: 'danger',
  3883. locked: 'warning',
  3884. merged: 'info',
  3885. opened: 'success',
  3886. };
  3887. const labelMap = {
  3888. closed: 'Closed',
  3889. locked: 'Locked',
  3890. merged: 'Merged',
  3891. opened: 'Opened',
  3892. };
  3893.  
  3894. function MrStatus({ state, withIcon, withLabel }) {
  3895. return jsx(GitlabBadge, {
  3896. icon: withIcon ? iconMap[state] : void 0,
  3897. label: withLabel ? labelMap[state] : void 0,
  3898. variant: classMap[state],
  3899. });
  3900. }
  3901.  
  3902. // apps/gitlab-plus/src/components/common/GitlabMergeRequest.tsx
  3903. function GitlabMergeRequest({ mr }) {
  3904. return jsxs('div', {
  3905. style: { marginTop: 10 },
  3906. children: [
  3907. jsxs(Row, {
  3908. gap: 2,
  3909. children: [
  3910. jsx(MrStatus, { state: mr.state, withIcon: true, withLabel: true }),
  3911. jsxs(Text, {
  3912. variant: 'secondary',
  3913. children: ['!', mr.iid],
  3914. }),
  3915. jsx(GitlabUser, { size: 16, user: mr.author, withLink: true }),
  3916. ],
  3917. }),
  3918. jsx(Link, { href: mr.webUrl, title: mr.title, children: mr.title }),
  3919. ],
  3920. });
  3921. }
  3922.  
  3923. // apps/gitlab-plus/src/components/issue-preview/blocks/IssueMergeRequests.tsx
  3924. function IssueMergeRequests({ issue }) {
  3925. if (!issue.relatedMergeRequests.nodes.length) {
  3926. return null;
  3927. }
  3928. return jsx(InfoBlock, {
  3929. icon: 'merge-request',
  3930. title: 'Merge requests',
  3931. children: issue.relatedMergeRequests.nodes.map((mr) =>
  3932. jsx(GitlabMergeRequest, { mr }, mr.iid)
  3933. ),
  3934. });
  3935. }
  3936.  
  3937. // apps/gitlab-plus/src/components/issue-preview/blocks/IssueMilestone.tsx
  3938. function IssueMilestone({ issue }) {
  3939. if (!issue.milestone) {
  3940. return null;
  3941. }
  3942. return jsx(InfoBlock, {
  3943. icon: 'milestone',
  3944. rightTitle: issue.milestone.title,
  3945. title: 'Milestone',
  3946. });
  3947. }
  3948.  
  3949. // apps/gitlab-plus/src/components/issue-preview/blocks/IssueRelatedIssue.tsx
  3950. const relationMap = {
  3951. blocks: 'Blocks:',
  3952. is_blocked_by: 'Is blocked by:',
  3953. relates_to: 'Related to:',
  3954. };
  3955.  
  3956. function IssueRelatedIssue({ issue }) {
  3957. const groups = useMemo(() => {
  3958. const initValue = {
  3959. blocks: [],
  3960. is_blocked_by: [],
  3961. relates_to: [],
  3962. };
  3963. return Object.entries(
  3964. issue.linkedWorkItems.nodes.reduce(
  3965. (acc, issue2) => ({
  3966. ...acc,
  3967. [issue2.linkType]: [...acc[issue2.linkType], issue2],
  3968. }),
  3969. initValue
  3970. )
  3971. ).filter(([_, issues]) => issues.length);
  3972. }, [issue]);
  3973. if (!issue.linkedWorkItems.nodes.length) {
  3974. return null;
  3975. }
  3976. return jsx(InfoBlock, {
  3977. title: '',
  3978. children: groups.map(([key, issues]) =>
  3979. jsxs(
  3980. 'div',
  3981. {
  3982. style: { marginTop: 10 },
  3983. children: [
  3984. jsx('div', {
  3985. class: 'item-title gl-flex gl-min-w-0 gl-gap-3',
  3986. children: jsx('span', { children: relationMap[key] }),
  3987. }),
  3988. issues.map((issue2) =>
  3989. jsxs(
  3990. Link,
  3991. {
  3992. href: issue2.workItem.webUrl,
  3993. blockHover: true,
  3994. children: [
  3995. '#',
  3996. issue2.workItem.iid,
  3997. ' ',
  3998. issue2.workItem.title,
  3999. ],
  4000. },
  4001. issue2.workItem.iid
  4002. )
  4003. ),
  4004. ],
  4005. },
  4006. key
  4007. )
  4008. ),
  4009. });
  4010. }
  4011.  
  4012. // apps/gitlab-plus/src/components/issue-preview/useFetchIssue.ts
  4013. function useFetchIssue() {
  4014. return useFetchEntity(async (link, force = false) => {
  4015. const response = await new IssueProvider(force).getIssue(
  4016. link.projectPath,
  4017. link.issue
  4018. );
  4019. console.log(response);
  4020. return response.data.project.issue;
  4021. });
  4022. }
  4023.  
  4024. // apps/gitlab-plus/src/components/issue-preview/IssuePreviewModal.tsx
  4025. function IssuePreviewModal() {
  4026. const {
  4027. entityData,
  4028. fetch: fetch2,
  4029. isLoading,
  4030. isRefreshing,
  4031. reset,
  4032. } = useFetchIssue();
  4033. return jsx(PreviewModal, {
  4034. validator: LinkParser.validateIssueLink,
  4035. fetch: fetch2,
  4036. isError: !entityData,
  4037. isLoading,
  4038. isRefreshing,
  4039. parser: LinkParser.parseIssueLink,
  4040. reset,
  4041. children:
  4042. entityData &&
  4043. jsxs(Fragment, {
  4044. children: [
  4045. jsx(IssueHeader, {
  4046. issue: entityData.entity,
  4047. onRefresh: () => fetch2(entityData.link, true),
  4048. }),
  4049. jsx(IssueAssignee, { issue: entityData.entity }),
  4050. jsx(IssueLabels, {
  4051. issue: entityData.entity,
  4052. link: entityData.link,
  4053. refetch: () => fetch2(entityData.link, true),
  4054. }),
  4055. jsx(IssueEpic, { issue: entityData.entity }),
  4056. jsx(IssueMilestone, { issue: entityData.entity }),
  4057. jsx(IssueIteration, { issue: entityData.entity }),
  4058. jsx(IssueMergeRequests, { issue: entityData.entity }),
  4059. jsx(IssueRelatedIssue, { issue: entityData.entity }),
  4060. ],
  4061. }),
  4062. });
  4063. }
  4064.  
  4065. // apps/gitlab-plus/src/services/IssuePreview.tsx
  4066. class IssuePreview extends BaseService {
  4067. constructor() {
  4068. super(...arguments);
  4069. __publicField(this, 'name', ServiceName.IssuePreview);
  4070. }
  4071.  
  4072. init() {
  4073. render(jsx(IssuePreviewModal, {}), this.rootBody('glp-issue-preview-root'));
  4074. }
  4075. }
  4076.  
  4077. // apps/gitlab-plus/src/components/mr-preview/blocks/MrApprovedBy.tsx
  4078. function MrApprovedBy({ mr }) {
  4079. return jsx(UsersBlock, {
  4080. assignees: mr.approvedBy.nodes,
  4081. label: 'Approved by',
  4082. pluralLabel: 'Approved by',
  4083. });
  4084. }
  4085.  
  4086. // apps/gitlab-plus/src/components/mr-preview/blocks/MrAssignee.tsx
  4087. function MrAssignee({ mr }) {
  4088. return jsx(UsersBlock, {
  4089. assignees: mr.assignees.nodes,
  4090. icon: 'assignee',
  4091. label: 'Assignee',
  4092. });
  4093. }
  4094.  
  4095. // apps/gitlab-plus/src/components/mr-preview/blocks/MrBranch.tsx
  4096. function MrBranch({ mr }) {
  4097. return jsx(InfoBlock, {
  4098. icon: 'branch',
  4099. title: 'Merge',
  4100. children: jsxs('span', {
  4101. children: [
  4102. jsx(Text, { children: mr.sourceBranch }),
  4103. jsx(Text, {
  4104. className: 'gl-mx-2',
  4105. variant: 'secondary',
  4106. children: 'in to',
  4107. }),
  4108. jsx(Text, { children: mr.targetBranch }),
  4109. ],
  4110. }),
  4111. });
  4112. }
  4113.  
  4114. // apps/gitlab-plus/src/components/mr-preview/blocks/MrDiff.tsx
  4115. function MrDiff({ mr }) {
  4116. const label = useMemo(() => {
  4117. if (mr.diffStatsSummary.fileCount === 1) {
  4118. return '1 file';
  4119. }
  4120. return `${mr.diffStatsSummary.fileCount} files`;
  4121. }, [mr.diffStatsSummary.fileCount]);
  4122. return jsx(InfoBlock, {
  4123. icon: 'commit',
  4124. title: `Commit: ${mr.commitCount}`,
  4125. rightTitle: jsxs(Row, {
  4126. gap: 2,
  4127. items: 'center',
  4128. children: [
  4129. jsx(GitlabIcon, { icon: 'doc-code', size: 16 }),
  4130. jsx(Text, {
  4131. size: 'subtle',
  4132. weight: 'bold',
  4133. children: label,
  4134. }),
  4135. jsxs(Text, {
  4136. color: 'success',
  4137. weight: 'bold',
  4138. children: ['+', mr.diffStatsSummary.additions],
  4139. }),
  4140. jsxs(Text, {
  4141. color: 'danger',
  4142. weight: 'bold',
  4143. children: ['-', mr.diffStatsSummary.deletions],
  4144. }),
  4145. ],
  4146. }),
  4147. });
  4148. }
  4149.  
  4150. // apps/gitlab-plus/src/components/mr-preview/blocks/MrDiscussion.tsx
  4151. function MrDiscussion({ mr }) {
  4152. const [resolved, total] = [
  4153. mr.resolvedDiscussionsCount,
  4154. mr.resolvableDiscussionsCount,
  4155. ];
  4156. if (!total) {
  4157. return null;
  4158. }
  4159. const { label, title } = useMemo(() => {
  4160. const plural = total !== 1 ? 's' : '';
  4161. return {
  4162. label: `${resolved} of ${total}`,
  4163. title: `${resolved} of ${total} thread${plural} resolved`,
  4164. };
  4165. }, [mr]);
  4166. return jsx(InfoBlock, {
  4167. title: 'Discussion',
  4168. rightTitle: jsx(GitlabBadge, {
  4169. icon: 'comments',
  4170. label,
  4171. title,
  4172. variant: resolved === total ? 'success' : 'muted',
  4173. }),
  4174. });
  4175. }
  4176.  
  4177. // libs/share/src/utils/textWithChild.ts
  4178. function textWithChild(text, pattern, replacer) {
  4179. const matches = text.match(RegExp(pattern, 'g'));
  4180. const parts = text.split(RegExp(pattern, 'g'));
  4181. if (!(matches == null ? void 0 : matches.length)) {
  4182. return text;
  4183. }
  4184. return parts.reduce((items, text2, index) => {
  4185. const textToReplace = index < matches.length ? matches[index] : void 0;
  4186. return [
  4187. ...items,
  4188. text2,
  4189. ...(textToReplace ? [replacer(textToReplace)] : []),
  4190. ];
  4191. }, []);
  4192. }
  4193.  
  4194. // apps/gitlab-plus/src/components/mr-preview/blocks/MrHeading.tsx
  4195. function MrHeader({ mr, onRefresh }) {
  4196. const title = useMemo(() => {
  4197. const issueLink = (id) =>
  4198. `${mr.project.webUrl}/-/issues/${id.replace(/\D+/g, '')}`;
  4199. return textWithChild(mr.title, /#\d+/, (id) =>
  4200. jsx(Link, { href: issueLink(id), inline: true, children: id })
  4201. );
  4202. }, [mr]);
  4203. return jsx(HeadingBlock, {
  4204. author: mr.author,
  4205. createdAt: mr.createdAt,
  4206. entityId: `!${mr.iid}`,
  4207. icon: 'merge-request',
  4208. onRefresh,
  4209. title,
  4210. badge: jsxs(Row, {
  4211. className: 'gl-gap-2',
  4212. items: 'center',
  4213. children: [
  4214. jsx(MrStatus, {
  4215. state: mr.state,
  4216. withIcon: true,
  4217. withLabel: true,
  4218. }),
  4219. Boolean(mr.approvedBy.nodes.length) &&
  4220. jsx(GitlabBadge, {
  4221. icon: 'check-circle',
  4222. label: 'Approved',
  4223. variant: 'success',
  4224. }),
  4225. mr.conflicts &&
  4226. jsx(GitlabIcon, {
  4227. icon: 'warning-solid',
  4228. size: 16,
  4229. title: 'Merge request can not be merged',
  4230. }),
  4231. ],
  4232. }),
  4233. });
  4234. }
  4235.  
  4236. // apps/gitlab-plus/src/components/mr-preview/blocks/MrLabels.tsx
  4237. function MrLabels({ mr }) {
  4238. if (!mr.labels.nodes.length) {
  4239. return null;
  4240. }
  4241. return jsx(InfoBlock, {
  4242. className: 'issuable-show-labels',
  4243. title: 'Labels',
  4244. children: mr.labels.nodes.map((label) =>
  4245. jsx(GitlabLabel, { label }, label.id)
  4246. ),
  4247. });
  4248. }
  4249.  
  4250. // apps/gitlab-plus/src/providers/query/mr.ts
  4251. const mrQuery = `query MergeRequestQuery($fullPath: ID!, $iid: String!) {
  4252. workspace: project(fullPath: $fullPath) {
  4253. mergeRequest(iid: $iid) {
  4254. id
  4255. iid
  4256. assignees {
  4257. nodes {
  4258. ...User
  4259. }
  4260. }
  4261. approvedBy {
  4262. nodes {
  4263. ...User
  4264. }
  4265. }
  4266. author {
  4267. ...User
  4268. }
  4269. project {
  4270. webUrl
  4271. path
  4272. fullPath
  4273. }
  4274. commitCount
  4275. conflicts
  4276. createdAt
  4277. title
  4278. titleHtml
  4279. diffStatsSummary {
  4280. additions
  4281. changes
  4282. deletions
  4283. fileCount
  4284. }
  4285. draft
  4286. labels {
  4287. nodes {
  4288. ...Label
  4289. }
  4290. }
  4291. mergeable
  4292. resolvedDiscussionsCount
  4293. resolvableDiscussionsCount
  4294. reviewers {
  4295. nodes {
  4296. ...User
  4297. }
  4298. }
  4299. shouldBeRebased
  4300. sourceBranch
  4301. targetBranch
  4302. state
  4303. webUrl
  4304. }
  4305. }
  4306. }
  4307.  
  4308. ${userFragment}
  4309. ${labelFragment}
  4310. `;
  4311.  
  4312. // apps/gitlab-plus/src/providers/MrProvider.ts
  4313. class MrProvider extends GitlabProvider {
  4314. async getMr(projectPath, mrId) {
  4315. return this.queryCached(
  4316. `mr-${projectPath}-${mrId}`,
  4317. mrQuery,
  4318. {
  4319. iid: mrId,
  4320. fullPath: projectPath,
  4321. },
  4322. 2
  4323. );
  4324. }
  4325. }
  4326.  
  4327. // apps/gitlab-plus/src/components/mr-preview/useFetchMr.ts
  4328. function useFetchMr() {
  4329. return useFetchEntity(async (link, force = false) => {
  4330. const response = await new MrProvider(force).getMr(
  4331. link.projectPath,
  4332. link.mr
  4333. );
  4334. return response.data.workspace.mergeRequest;
  4335. });
  4336. }
  4337.  
  4338. // apps/gitlab-plus/src/components/mr-preview/MrPreviewModal.tsx
  4339. function MrPreviewModal() {
  4340. const {
  4341. entityData,
  4342. fetch: fetch2,
  4343. isLoading,
  4344. isRefreshing,
  4345. reset,
  4346. } = useFetchMr();
  4347. return jsx(PreviewModal, {
  4348. validator: LinkParser.validateMrLink,
  4349. fetch: fetch2,
  4350. isError: !entityData,
  4351. isLoading,
  4352. isRefreshing,
  4353. parser: LinkParser.parseMrLink,
  4354. reset,
  4355. children:
  4356. entityData &&
  4357. jsxs(Fragment, {
  4358. children: [
  4359. jsx(MrHeader, {
  4360. mr: entityData.entity,
  4361. onRefresh: () => fetch2(entityData.link, true),
  4362. }),
  4363. jsx(MrBranch, { mr: entityData.entity }),
  4364. jsx(MrAssignee, { mr: entityData.entity }),
  4365. jsx(MrApprovedBy, { mr: entityData.entity }),
  4366. jsx(MrLabels, { mr: entityData.entity }),
  4367. jsx(MrDiff, { mr: entityData.entity }),
  4368. jsx(MrDiscussion, { mr: entityData.entity }),
  4369. ],
  4370. }),
  4371. });
  4372. }
  4373.  
  4374. // apps/gitlab-plus/src/services/MrPreview.tsx
  4375. class MrPreview extends BaseService {
  4376. constructor() {
  4377. super(...arguments);
  4378. __publicField(this, 'name', ServiceName.MrPreview);
  4379. }
  4380.  
  4381. init() {
  4382. render(jsx(MrPreviewModal, {}), this.rootBody('glp-mr-preview-root'));
  4383. }
  4384. }
  4385.  
  4386. // apps/gitlab-plus/src/components/related-issue-autocomplete/useRelatedIssuesAutocompleteModal.ts
  4387. function useRelatedIssuesAutocompleteModal(link, input) {
  4388. const [searchTerm, setSearchTerm] = useState('');
  4389. const [isVisible, setIsVisible] = useState(false);
  4390. const searchIssues = useCallback(async (term) => {
  4391. const response = await new IssueProvider().getIssues(
  4392. link.workspacePath,
  4393. term
  4394. );
  4395. return [
  4396. response.data.workspace.workItems,
  4397. response.data.workspace.workItemsByIid,
  4398. response.data.workspace.workItemsEmpty,
  4399. ].flatMap((item) => (item == null ? void 0 : item.nodes) || []);
  4400. }, []);
  4401. const options = useAsyncAutocompleteOptions(searchTerm, searchIssues);
  4402. const onSelect = (item) => {
  4403. input.value = `${item.project.fullPath}#${item.iid} `;
  4404. input.dispatchEvent(new Event('input'));
  4405. input.dispatchEvent(new Event('change'));
  4406. };
  4407. useEffect(() => {
  4408. document.body.addEventListener('click', (e) => {
  4409. if (e.target !== input && !input.contains(e.target)) {
  4410. setIsVisible(false);
  4411. }
  4412. });
  4413. input.addEventListener('click', () => setIsVisible(true));
  4414. }, []);
  4415. return {
  4416. isVisible,
  4417. onClose: () => setIsVisible(false),
  4418. onSelect,
  4419. options,
  4420. searchTerm,
  4421. setSearchTerm,
  4422. };
  4423. }
  4424.  
  4425. // apps/gitlab-plus/src/components/related-issue-autocomplete/RelatedIssuesAutocompleteModal.tsx
  4426. function RelatedIssuesAutocompleteModal({ input, link }) {
  4427. const { isVisible, onClose, onSelect, options, searchTerm, setSearchTerm } =
  4428. useRelatedIssuesAutocompleteModal(link, input);
  4429. if (!isVisible) {
  4430. return null;
  4431. }
  4432. return jsx('div', {
  4433. class: 'gl-relative gl-w-full gl-new-dropdown !gl-block',
  4434. children: jsx(AsyncAutocompleteDropdown, {
  4435. hideCheckbox: true,
  4436. onClick: onSelect,
  4437. onClose,
  4438. options,
  4439. searchTerm,
  4440. setSearchTerm,
  4441. value: [],
  4442. renderOption: (item) =>
  4443. jsxs('div', {
  4444. class: 'gl-flex gl-gap-x-2 gl-py-2',
  4445. children: [
  4446. jsx(GitlabIcon, {
  4447. icon: 'issue-type-issue',
  4448. size: 16,
  4449. }),
  4450. jsx('small', { children: item.iid }),
  4451. jsx('span', {
  4452. class: 'gl-flex gl-flex-wrap',
  4453. children: item.title,
  4454. }),
  4455. ],
  4456. }),
  4457. }),
  4458. });
  4459. }
  4460.  
  4461. // apps/gitlab-plus/src/services/RelatedIssueAutocomplete.tsx
  4462. class RelatedIssueAutocomplete extends BaseService {
  4463. constructor() {
  4464. super(...arguments);
  4465. __publicField(this, 'name', ServiceName.RelatedIssueAutocomplete);
  4466. __publicField(this, 'ready', false);
  4467. __publicField(this, 'readyClass', 'glp-input-ready');
  4468. }
  4469.  
  4470. init() {
  4471. this.initObserver();
  4472. window.setTimeout(this.initObserver.bind(this), 1e3);
  4473. window.setTimeout(this.initObserver.bind(this), 3e3);
  4474. window.setTimeout(this.initObserver.bind(this), 5e3);
  4475. }
  4476.  
  4477. initAutocomplete(section) {
  4478. const input = section.querySelector('#add-related-issues-form-input');
  4479. const link = LinkParser.parseIssueLink(window.location.href);
  4480. if (!input || this.isMounted(input) || !link) {
  4481. return;
  4482. }
  4483. const container = input.closest('.add-issuable-form-input-wrapper');
  4484. if (!container || document.querySelector('.related-issues-autocomplete')) {
  4485. return;
  4486. }
  4487. const root = this.root('related-issues-autocomplete', container);
  4488. render(jsx(RelatedIssuesAutocompleteModal, { input, link }), root);
  4489. }
  4490.  
  4491. initObserver() {
  4492. const section = document.querySelector('#related-issues');
  4493. if (this.ready || !section) {
  4494. return;
  4495. }
  4496. this.ready = true;
  4497. const observer = new MutationObserver((mutations) => {
  4498. mutations.forEach((mutation) => {
  4499. if (mutation.type === 'childList') {
  4500. this.initAutocomplete(section);
  4501. }
  4502. });
  4503. });
  4504. observer.observe(section, {
  4505. childList: true,
  4506. });
  4507. }
  4508.  
  4509. isMounted(input) {
  4510. return input.classList.contains(this.readyClass);
  4511. }
  4512. }
  4513.  
  4514. // apps/gitlab-plus/src/services/RelatedIssuesLabelStatus.tsx
  4515. class RelatedIssuesLabelStatus extends BaseService {
  4516. constructor() {
  4517. super(...arguments);
  4518. __publicField(this, 'name', ServiceName.RelatedIssuesLabelStatus);
  4519. __publicField(this, 'ready', false);
  4520. }
  4521.  
  4522. init() {
  4523. this.initIssuesList();
  4524. window.setTimeout(this.initIssuesList.bind(this), 1e3);
  4525. window.setTimeout(this.initIssuesList.bind(this), 3e3);
  4526. window.setTimeout(this.initIssuesList.bind(this), 5e3);
  4527. }
  4528.  
  4529. initIssuesList() {
  4530. if (this.ready) {
  4531. return;
  4532. }
  4533. const lists = document.querySelectorAll(
  4534. '#related-issues .related-items-list'
  4535. );
  4536. const link = LinkParser.parseIssueLink(window.location.href);
  4537. if (!lists.length || !link) {
  4538. return;
  4539. }
  4540. this.ready = true;
  4541. const items = [...lists].flatMap((list) => [
  4542. ...list.querySelectorAll('li'),
  4543. ]);
  4544. this.updateIssuesItem(link, items);
  4545. }
  4546.  
  4547. async updateIssuesItem(link, items) {
  4548. const response = await new IssueProvider().getIssueWithRelatedIssuesLabels(
  4549. link.projectPath,
  4550. link.issue
  4551. );
  4552. const getStatusLabel = (item) => {
  4553. const labelsWidget = item.workItem.widgets.find(
  4554. (w) => w.type === 'LABELS'
  4555. );
  4556. return labelsWidget == null
  4557. ? void 0
  4558. : labelsWidget.labels.nodes.find(
  4559. (l) =>
  4560. l.title.toLowerCase().startsWith('status::') ||
  4561. l.title.toLowerCase().startsWith('workflow::')
  4562. );
  4563. };
  4564. const issueStatusMap =
  4565. response.data.project.issue.linkedWorkItems.nodes.reduce((acc, value) => {
  4566. return {
  4567. ...acc,
  4568. [value.workItem.id.replace(/\D/g, '')]: getStatusLabel(value),
  4569. };
  4570. }, {});
  4571. items.forEach((item) => {
  4572. if (!item.dataset.key || !issueStatusMap[item.dataset.key]) {
  4573. return;
  4574. }
  4575. const statusLabel = issueStatusMap[item.dataset.key];
  4576. const infoArea = item.querySelector('.item-attributes-area');
  4577. if (infoArea && statusLabel) {
  4578. render(
  4579. jsx(GitlabLabel, { label: statusLabel }),
  4580. this.root('glp-status-label', infoArea, true)
  4581. );
  4582. }
  4583. });
  4584. }
  4585. }
  4586.  
  4587. // libs/share/src/ui/Component.ts
  4588. class Component {
  4589. constructor(tag, props = {}) {
  4590. this.element = Dom.create({ tag, ...props });
  4591. }
  4592.  
  4593. addClassName(...className) {
  4594. this.element.classList.add(...className);
  4595. }
  4596.  
  4597. event(event, callback) {
  4598. this.element.addEventListener(event, callback);
  4599. }
  4600.  
  4601. getElement() {
  4602. return this.element;
  4603. }
  4604.  
  4605. mount(parent) {
  4606. parent.appendChild(this.element);
  4607. }
  4608. }
  4609.  
  4610. // libs/share/src/ui/SvgComponent.ts
  4611. class SvgComponent {
  4612. constructor(tag, props = {}) {
  4613. this.element = Dom.createSvg({ tag, ...props });
  4614. }
  4615.  
  4616. addClassName(...className) {
  4617. this.element.classList.add(...className);
  4618. }
  4619.  
  4620. event(event, callback) {
  4621. this.element.addEventListener(event, callback);
  4622. }
  4623.  
  4624. getElement() {
  4625. return this.element;
  4626. }
  4627.  
  4628. mount(parent) {
  4629. parent.appendChild(this.element);
  4630. }
  4631. }
  4632.  
  4633. // libs/share/src/ui/Dom.ts
  4634. class Dom {
  4635. static appendChildren(element, children, isSvgMode = false) {
  4636. if (children) {
  4637. element.append(
  4638. ...Dom.array(children).map((item) => {
  4639. if (typeof item === 'string') {
  4640. return document.createTextNode(item);
  4641. }
  4642. if (item instanceof HTMLElement || item instanceof SVGElement) {
  4643. return item;
  4644. }
  4645. if (item instanceof Component || item instanceof SvgComponent) {
  4646. return item.getElement();
  4647. }
  4648. const isSvg =
  4649. 'svg' === item.tag
  4650. ? true
  4651. : 'foreignObject' === item.tag
  4652. ? false
  4653. : isSvgMode;
  4654. if (isSvg) {
  4655. return Dom.createSvg(item);
  4656. }
  4657. return Dom.create(item);
  4658. })
  4659. );
  4660. }
  4661. }
  4662.  
  4663. static applyAttrs(element, attrs) {
  4664. if (attrs) {
  4665. Object.entries(attrs).forEach(([key, value]) => {
  4666. if (value === void 0 || value === false) {
  4667. element.removeAttribute(key);
  4668. } else {
  4669. element.setAttribute(key, `${value}`);
  4670. }
  4671. });
  4672. }
  4673. }
  4674.  
  4675. static applyClass(element, classes) {
  4676. if (classes) {
  4677. element.classList.add(...classes.split(' ').filter(Boolean));
  4678. }
  4679. }
  4680.  
  4681. static applyEvents(element, events) {
  4682. if (events) {
  4683. Object.entries(events).forEach(([name2, callback]) => {
  4684. element.addEventListener(name2, callback);
  4685. });
  4686. }
  4687. }
  4688.  
  4689. static applyStyles(element, styles) {
  4690. if (styles) {
  4691. Object.entries(styles).forEach(([key, value]) => {
  4692. const name2 = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`);
  4693. element.style.setProperty(name2, value);
  4694. });
  4695. }
  4696. }
  4697.  
  4698. static array(element) {
  4699. return Array.isArray(element) ? element : [element];
  4700. }
  4701.  
  4702. static create(data) {
  4703. const element = document.createElement(data.tag);
  4704. Dom.appendChildren(element, data.children);
  4705. Dom.applyClass(element, data.classes);
  4706. Dom.applyAttrs(element, data.attrs);
  4707. Dom.applyEvents(element, data.events);
  4708. Dom.applyStyles(element, data.styles);
  4709. return element;
  4710. }
  4711.  
  4712. static createSvg(data) {
  4713. const element = document.createElementNS(
  4714. 'http://www.w3.org/2000/svg',
  4715. data.tag
  4716. );
  4717. Dom.appendChildren(element, data.children, true);
  4718. Dom.applyClass(element, data.classes);
  4719. Dom.applyAttrs(element, data.attrs);
  4720. Dom.applyEvents(element, data.events);
  4721. Dom.applyStyles(element, data.styles);
  4722. return element;
  4723. }
  4724.  
  4725. static element(tag, classes, children) {
  4726. return Dom.create({ tag, children, classes });
  4727. }
  4728.  
  4729. static elementSvg(tag, classes, children) {
  4730. return Dom.createSvg({ tag, children, classes });
  4731. }
  4732. }
  4733.  
  4734. // libs/share/src/ui/Observer.ts
  4735. class Observer {
  4736. start(element, callback, options) {
  4737. this.stop();
  4738. this.observer = new MutationObserver(callback);
  4739. this.observer.observe(
  4740. element,
  4741. options || {
  4742. attributeOldValue: true,
  4743. attributes: true,
  4744. characterData: true,
  4745. characterDataOldValue: true,
  4746. childList: true,
  4747. subtree: true,
  4748. }
  4749. );
  4750. }
  4751.  
  4752. stop() {
  4753. if (this.observer) {
  4754. this.observer.disconnect();
  4755. }
  4756. }
  4757. }
  4758.  
  4759. // apps/gitlab-plus/src/services/SortIssue.ts
  4760. const sortWeight = {
  4761. ['issue']: 4,
  4762. ['label']: 0,
  4763. ['ownIssue']: 10,
  4764. ['ownUserStory']: 8,
  4765. ['unknown']: 2,
  4766. ['userStory']: 6,
  4767. };
  4768.  
  4769. class SortIssue extends BaseService {
  4770. constructor() {
  4771. super(...arguments);
  4772. __publicField(this, 'name', ServiceName.SortIssue);
  4773. }
  4774.  
  4775. init() {
  4776. const observer = new Observer();
  4777. const userName = this.userName();
  4778. const board = document.querySelector('.boards-list');
  4779. if (!userName || !board) {
  4780. return;
  4781. }
  4782. observer.start(board, () => this.run(userName));
  4783. }
  4784.  
  4785. childType(child, userName) {
  4786. if (child instanceof HTMLDivElement) {
  4787. return 'label';
  4788. }
  4789. const title = child.querySelector('[data-testid="board-card-title-link"]');
  4790. if (!title) {
  4791. return 'unknown';
  4792. }
  4793. const isOwn = [...child.querySelectorAll('.gl-avatar-link img')].some(
  4794. (img) => img.alt.includes(userName)
  4795. );
  4796. const isUserStory = [...child.querySelectorAll('.gl-label')].some((span) =>
  4797. span.innerText.includes('User Story')
  4798. );
  4799. if (isUserStory && isOwn) {
  4800. return 'ownUserStory';
  4801. }
  4802. if (isOwn) {
  4803. return 'ownIssue';
  4804. }
  4805. if (isUserStory) {
  4806. return 'userStory';
  4807. }
  4808. return 'issue';
  4809. }
  4810.  
  4811. initBoard(board, userName) {
  4812. Dom.applyClass(board, 'glp-ready');
  4813. const observer = new Observer();
  4814. observer.start(board, () => this.sortBoard(board, userName), {
  4815. childList: true,
  4816. });
  4817. }
  4818.  
  4819. run(userName) {
  4820. [...document.querySelectorAll('.board-list:not(.glp-ready)')].forEach(
  4821. (board) => this.initBoard(board, userName)
  4822. );
  4823. }
  4824.  
  4825. shouldSort(items) {
  4826. return items.some((item) => {
  4827. return ['ownIssue', 'ownUserStory'].includes(item.type);
  4828. });
  4829. }
  4830.  
  4831. sortBoard(board, userName) {
  4832. Dom.applyStyles(board, {
  4833. display: 'flex',
  4834. flexDirection: 'column',
  4835. });
  4836. const children = [...board.children].map((element) => ({
  4837. element,
  4838. type: this.childType(element, userName),
  4839. }));
  4840. if (!this.shouldSort(children)) {
  4841. return;
  4842. }
  4843. this.sortChildren(children).forEach(({ element }, index) => {
  4844. const order =
  4845. index !== children.length - 1 ? index + 1 : children.length + 100;
  4846. element.style.order = `${order}`;
  4847. });
  4848. }
  4849.  
  4850. sortChildren(items) {
  4851. return items.toSorted((a, b) => {
  4852. return Math.sign(sortWeight[b.type] - sortWeight[a.type]);
  4853. });
  4854. }
  4855.  
  4856. userName() {
  4857. const element = document.querySelector(
  4858. '.user-bar-dropdown-toggle .gl-button-text .gl-sr-only'
  4859. );
  4860. const testText = ' user’s menu';
  4861. if (element && element.innerText.includes(testText)) {
  4862. return element.innerText.replace(testText, '');
  4863. }
  4864. return void 0;
  4865. }
  4866. }
  4867.  
  4868. // apps/gitlab-plus/src/components/user-settings/events.ts
  4869. const showUserSettingsModal = 'glp-show-user-settings-modal';
  4870. const ShowUserSettingsModalEvent = new CustomEvent(showUserSettingsModal);
  4871.  
  4872. // apps/gitlab-plus/src/components/user-settings/UserSettingsButton.tsx
  4873. function UserSettingsButton() {
  4874. return jsx('span', {
  4875. className: 'gl-new-dropdown-item-content',
  4876. onClick: () => document.dispatchEvent(ShowUserSettingsModalEvent),
  4877. children: jsxs('span', {
  4878. className: 'gl-new-dropdown-item-text-wrapper',
  4879. children: [
  4880. jsx('span', { style: { color: '#e24329' }, children: 'Gitlab Plus' }),
  4881. ' settings',
  4882. ],
  4883. }),
  4884. });
  4885. }
  4886.  
  4887. // apps/gitlab-plus/src/components/common/base/Column.tsx
  4888. function Column({ children, className, gap, items, justify }) {
  4889. return jsx('div', {
  4890. class: clsx(
  4891. 'gl-flex gl-flex-col',
  4892. justify && `gl-justify-${justify}`,
  4893. items && `gl-items-${items}`,
  4894. gap && `gl-gap-${gap}`,
  4895. className
  4896. ),
  4897. children,
  4898. });
  4899. }
  4900.  
  4901. // apps/gitlab-plus/src/components/common/GitlabSwitch.tsx
  4902. function GitlabSwitch({ checked, disabled, onChange }) {
  4903. return jsx('button', {
  4904. 'aria-checked': checked,
  4905. 'aria-disabled': disabled,
  4906. disabled,
  4907. onClick: () => onChange(!checked),
  4908. role: 'switch',
  4909. type: 'button',
  4910. className: clsx(
  4911. 'gl-toggle gl-shrink-0',
  4912. checked && 'is-checked',
  4913. disabled && 'is-disabled'
  4914. ),
  4915. children: jsx('span', {
  4916. className: 'toggle-icon',
  4917. children: jsx(GitlabIcon, { icon: checked ? 'check-xs' : 'close-xs' }),
  4918. }),
  4919. });
  4920. }
  4921.  
  4922. // apps/gitlab-plus/src/components/user-settings/useUserSettingsModal.tsx
  4923. function useUserSettingsModal() {
  4924. const [refreshFlag, setRefreshFlag] = useState(false);
  4925. const services = useMemo(() => {
  4926. return Object.entries(servicesConfig)
  4927. .map(([name2, config]) => ({
  4928. isActive: Boolean(userSettingsStore.isActive(name2)),
  4929. isExperimental: config.experimental,
  4930. isRequired: config.required,
  4931. label: config.label,
  4932. name: name2,
  4933. }))
  4934. .sort((a, b) => {
  4935. if (a.isRequired || b.isRequired) {
  4936. return a.isRequired ? 1 : -1;
  4937. }
  4938. if (a.isExperimental || b.isExperimental) {
  4939. return a.isExperimental ? 1 : -1;
  4940. }
  4941. return a.name.localeCompare(b.name);
  4942. });
  4943. }, [refreshFlag]);
  4944. return {
  4945. services,
  4946. setServiceState: (name2, value) => {
  4947. userSettingsStore.setIsActive(name2, value);
  4948. setRefreshFlag((flag) => !flag);
  4949. },
  4950. };
  4951. }
  4952.  
  4953. // apps/gitlab-plus/src/components/user-settings/UserSettingsModal.tsx
  4954. function UserSettingModal() {
  4955. const { isVisible, onClose } = useGlpModal(showUserSettingsModal);
  4956. const { services, setServiceState } = useUserSettingsModal();
  4957. return jsx(GlpModal, {
  4958. isVisible,
  4959. onClose,
  4960. title: jsxs(Fragment, {
  4961. children: [
  4962. jsx('span', { style: { color: '#e24329' }, children: 'Gitlab Plus' }),
  4963. ' settings',
  4964. ],
  4965. }),
  4966. children: jsx(Column, {
  4967. className: 'gl-p-4',
  4968. gap: 2,
  4969. children: services.map((service) =>
  4970. jsxs(Row, {
  4971. gap: 2,
  4972. items: 'center',
  4973. children: [
  4974. jsx(GitlabSwitch, {
  4975. checked: service.isActive,
  4976. disabled: service.isRequired,
  4977. onChange: (value) => setServiceState(service.name, value),
  4978. }),
  4979. jsx(Text, {
  4980. variant: service.isRequired ? 'secondary' : void 0,
  4981. children: service.label,
  4982. }),
  4983. service.isExperimental &&
  4984. jsx(GitlabBadge, {
  4985. label: 'Experimental',
  4986. variant: 'warning',
  4987. }),
  4988. service.isRequired &&
  4989. jsx(GitlabBadge, { label: 'Required', variant: 'muted' }),
  4990. ],
  4991. })
  4992. ),
  4993. }),
  4994. });
  4995. }
  4996.  
  4997. // apps/gitlab-plus/src/services/UserSettings.tsx
  4998. class UserSettings extends BaseService {
  4999. constructor() {
  5000. super(...arguments);
  5001. __publicField(this, 'name', ServiceName.UserSettings);
  5002. __publicField(this, 'ready', false);
  5003. }
  5004.  
  5005. init() {
  5006. this.initUserSettings();
  5007. window.setTimeout(this.initUserSettings.bind(this), 1e3);
  5008. window.setTimeout(this.initUserSettings.bind(this), 3e3);
  5009. window.setTimeout(this.initUserSettings.bind(this), 5e3);
  5010. }
  5011.  
  5012. getMenuItem() {
  5013. const userMenu = document.querySelector('[data-testid="preferences-item"]');
  5014. if (!userMenu || !userMenu.parentElement) {
  5015. return void 0;
  5016. }
  5017. const li = document.createElement('li');
  5018. li.className = 'gl-new-dropdown-item';
  5019. userMenu.parentElement.append(li);
  5020. return li;
  5021. }
  5022.  
  5023. initUserSettings() {
  5024. if (this.ready) {
  5025. return;
  5026. }
  5027. const userMenu = this.getMenuItem();
  5028. if (!userMenu) {
  5029. return;
  5030. }
  5031. this.ready = true;
  5032. render(jsx(UserSettingsButton, {}), userMenu);
  5033. render(jsx(UserSettingModal, {}), this.rootBody('glp-user-settings-root'));
  5034. }
  5035. }
  5036.  
  5037. // apps/gitlab-plus/src/main.ts
  5038. [
  5039. ClearCacheService,
  5040. ImagePreview,
  5041. MrPreview,
  5042. EpicPreview,
  5043. IssuePreview,
  5044. CreateRelatedIssue,
  5045. CreateChildIssue,
  5046. RelatedIssueAutocomplete,
  5047. RelatedIssuesLabelStatus,
  5048. SortIssue,
  5049. UserSettings,
  5050. ].forEach((Service) => {
  5051. const service = new Service();
  5052. if (userSettingsStore.isActive(service.name)) {
  5053. service.init();
  5054. }
  5055. });