Gitlab utils
// ==UserScript== // @name Gitlab plus // @namespace https://lukaszmical.pl/ // @version 2025-03-13 // @description Gitlab utils // @author Łukasz Micał // @match https://gitlab.com/* // @require https://cdn.jsdelivr.net/combine/npm/[email protected]/dist/preact.min.umd.min.js,npm/[email protected]/hooks/dist/hooks.umd.min.js,npm/[email protected]/jsx-runtime/dist/jsxRuntime.umd.min.js // @icon https://www.google.com/s2/favicons?sz=64&domain=gitlab.com // ==/UserScript== // Vite helpers const __defProp = Object.defineProperty; const __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value, }) : (obj[key] = value); const __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== 'symbol' ? key + '' : key, value); // App code const { jsx, jsxs, Fragment } = this.jsxRuntime; const { render } = this.preact; const { useMemo, useState, useEffect, useRef, useCallback, useLayoutEffect } = this.preactHooks; // libs/share/src/ui/GlobalStyle.ts class GlobalStyle { static addStyle(key, styles) { const style = document.getElementById(key) || (function () { const style22 = document.createElement('style'); style22.id = key; document.head.appendChild(style22); return style22; })(); style.textContent = styles; } } const style1 = '.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'; const style2 = '.glp-image-preview-modal {\n position: fixed;\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 display: flex;\n justify-content: center;\n align-items: center;\n}\n\n.glp-image-preview-modal.glp-modal-visible {\n visibility: visible;\n opacity: 1;\n pointer-events: auto;\n}\n\n\n.glp-image-preview-modal .glp-modal-close {\n position: absolute;\n z-index: 2;\n top: 5px;\n right: 5px;\n color: black;\n width: 30px;\n height: 30px;\n display: flex;\n justify-content: center;\n align-items: center;\n background: white;\n border-radius: 15px;\n cursor: pointer;\n}\n\n'; const style3 = '.glp-preview-modal {\n position: fixed;\n border-radius: .25rem;\n max-width: 350px;\n width: 350px;\n min-height: 200px;\n visibility: hidden;\n opacity: 0;\n transition: all .2s ease-out;\n transition-property: visibility, opacity, transform;\n\n}\n\n.glp-preview-modal.glp-modal-visible {\n visibility: visible;\n opacity: 1;\n}\n\n.glp-preview-modal ::-webkit-scrollbar {\n width: 5px;\n}\n\n.glp-preview-modal ::-webkit-scrollbar-track {\n background: var(--gl-background-color-overlap);\n}\n\n\n.glp-preview-modal ::-webkit-scrollbar-thumb {\n background: #888;\n border-radius: 5px;\n}\n\n\n.glp-preview-modal ::-webkit-scrollbar-thumb:hover {\n background: #555;\n}\n\n\n.glp-preview-modal * {\n max-width: 100%;\n}\n'; // apps/gitlab-plus/src/styles/index.ts GlobalStyle.addStyle('glp-style', [style1, style2, style3].join('\n')); // libs/share/src/store/Store.ts class Store { constructor(key) { this.key = key; } decode(val) { return JSON.parse(val); } encode(val) { return JSON.stringify(val); } get(defaultValue = void 0) { try { const data = localStorage.getItem(this.key); if (data) { return this.decode(data); } return defaultValue; } catch (e) { return defaultValue; } } remove() { localStorage.removeItem(this.key); } set(value) { try { localStorage.setItem(this.key, this.encode(value)); } catch (e) {} } } // apps/gitlab-plus/src/services/ServiceName.ts var ServiceName = ((ServiceName2) => { ServiceName2['ClearCacheService'] = 'ClearCacheService'; ServiceName2['CreateChildIssue'] = 'CreateChildIssue'; ServiceName2['CreateRelatedIssue'] = 'CreateRelatedIssue'; ServiceName2['EpicPreview'] = 'EpicPreview'; ServiceName2['ImagePreview'] = 'ImagePreview'; ServiceName2['IssuePreview'] = 'IssuePreview'; ServiceName2['MrPreview'] = 'MrPreview'; ServiceName2['RelatedIssueAutocomplete'] = 'RelatedIssueAutocomplete'; ServiceName2['RelatedIssuesLabelStatus'] = 'RelatedIssuesLabelStatus'; ServiceName2['SortIssue'] = 'SortIssue'; ServiceName2['UserSettings'] = 'UserSettings'; return ServiceName2; })(ServiceName || {}); const servicesConfig = { ['ClearCacheService']: { label: 'Clear cache', required: true }, ['CreateChildIssue']: { label: 'Create child issue form on epic page', }, ['CreateRelatedIssue']: { label: 'Create related issue form on issue page', }, ['EpicPreview']: { label: 'Epic preview modal' }, ['ImagePreview']: { label: 'Image preview modal' }, ['IssuePreview']: { label: 'Issue preview modal' }, ['MrPreview']: { label: 'Merge request preview modal' }, ['RelatedIssueAutocomplete']: { label: 'Related issue autocomplete in related issues input', }, ['RelatedIssuesLabelStatus']: { label: 'Label status in related issues list items', }, ['SortIssue']: { experimental: true, label: 'Sort issues in board', }, ['UserSettings']: { label: 'User settings', required: true }, }; // apps/gitlab-plus/src/components/user-settings/UserConfig.ts var UserConfig = ((UserConfig2) => { UserConfig2['StatusLabelPrefix'] = 'StatusLabelPrefix'; return UserConfig2; })(UserConfig || {}); const configLabels = { ['StatusLabelPrefix']: 'Status label prefix', }; const defaultUserConfig = { ['StatusLabelPrefix']: 'Status::', }; // apps/gitlab-plus/src/components/user-settings/UserSettingsStore.ts class UserSettingsStore { constructor() { __publicField(this, 'activeStatusStore', new Store('glp-settings')); __publicField(this, 'configStore', new Store('glp-config')); } getConfig(name2) { return this.getConfigItem(name2); } isActive(name2) { if (!(name2 in servicesConfig)) { return false; } if (servicesConfig[name2].required) { return true; } if (servicesConfig[name2].experimental) { return this.getActiveStatusItem(name2, false); } return this.getActiveStatusItem(name2, true); } setConfig(name2, value) { this.setConfigItem(name2, value); } setIsActive(name2, value) { this.setActiveStatusItem(name2, value); } getActiveStatusItem(key, defaultValue) { const items = this.getActiveStatusItems(); if (items[key] === void 0) { return defaultValue; } return items[key]; } getActiveStatusItems() { return this.activeStatusStore.get() || {}; } getConfigItem(key) { const items = this.getConfigItems(); return items[key]; } getConfigItems() { return { ...defaultUserConfig, ...(this.configStore.get() || {}) }; } setActiveStatusItem(key, value) { const items = this.getActiveStatusItems(); this.activeStatusStore.set({ ...items, [key]: value, }); } setConfigItem(key, value) { const items = this.getConfigItems(); this.configStore.set({ ...items, [key]: value, }); } } const userSettingsStore = new UserSettingsStore(); // libs/share/src/store/Cache.ts class Cache { constructor(prefix) { this.prefix = prefix; } clearInvalid() { for (const key in localStorage) { if (key.startsWith(this.prefix) && !this.isValid(this.getItem(key))) { localStorage.removeItem(key); } } } expirationDate(minutes) { if (typeof minutes === 'string') { return minutes; } const time = new Date(); time.setMinutes(time.getMinutes() + minutes); return time; } get(key) { try { const data = this.getItem(this.key(key)); if (this.isValid(data)) { return data.value; } } catch (e) { return void 0; } return void 0; } key(key) { return `${this.prefix}${key}`; } set(key, value, minutes) { localStorage.setItem( this.key(key), JSON.stringify({ expirationDate: this.expirationDate(minutes), value, }) ); } getItem(key) { try { return JSON.parse(localStorage.getItem(key) || ''); } catch (e) { return void 0; } } isValid(item) { if (item) { return ( item.expirationDate === 'lifetime' || new Date(item.expirationDate) > new Date() ); } return false; } } // apps/gitlab-plus/src/services/BaseService.ts class BaseService { root(className, parent, usePrepend = false) { const root = document.createElement('div'); root.classList.add(className); if (parent) { parent[usePrepend ? 'prepend' : 'append'](root); } return root; } rootBody(className) { return this.root(className, document.body); } } // apps/gitlab-plus/src/services/ClearCacheService.ts class ClearCacheService extends BaseService { constructor() { super(...arguments); __publicField(this, 'name', ServiceName.ClearCacheService); __publicField(this, 'cache', new Cache('glp-')); } init() { this.cache.clearInvalid(); window.setInterval(this.cache.clearInvalid.bind(this.cache), 60 * 1e3); } } // libs/share/src/utils/clsx.ts function clsx(...args) { return args .map((item) => { if (!item) { return ''; } if (typeof item === 'string') { return item; } if (Array.isArray(item)) { return clsx(...item); } if (typeof item === 'object') { return clsx( Object.entries(item) .filter(([_, value]) => value) .map(([key]) => key) ); } return ''; }) .filter(Boolean) .join(' '); } // apps/gitlab-plus/src/components/common/GitlabIcon.tsx const buildId = '236e3b687d786d9dfe4709143a94d4c53b8d5a1f235775401e5825148297fa84'; const iconUrl = (icon) => { let _a; const svgSprite = ((_a = unsafeWindow.gon) == null ? void 0 : _a.sprite_icons) || `/assets/icons-${buildId}.svg`; return `${svgSprite}#${icon}`; }; function GitlabIcon({ className, icon, size = 12, title }) { return jsx('svg', { className: clsx('gl-icon gl-fill-current', `s${size}`, className), title, children: jsx('use', { href: iconUrl(icon) }), }); } // apps/gitlab-plus/src/components/common/base/Row.tsx function Row({ children, className, gap, items, justify }) { return jsx('div', { class: clsx( 'gl-flex gl-flex-row', justify && `gl-justify-${justify}`, items && `gl-items-${items}`, gap && `gl-gap-${gap}`, className ), children, }); } // apps/gitlab-plus/src/components/common/GitlabLoader.tsx function GitlabLoader({ asOverlay, size = 24 }) { const loader = useMemo(() => { return jsx('span', { class: 'gl-spinner-container', role: 'status', children: jsx('span', { class: 'gl-spinner gl-spinner-sm gl-spinner-dark !gl-align-text-bottom', style: { width: size, height: size, }, }), }); }, [size]); if (asOverlay) { return jsx(Row, { className: 'gl-h-full gl-w-full gl-absolute gl-bg-overlay', items: 'center', justify: 'center', children: loader, }); } return loader; } // apps/gitlab-plus/src/components/common/GitlabButton.tsx const buttonVariantClass = { default: 'btn-default', info: 'btn-confirm', tertiary: 'btn-default-tertiary', }; function GitlabButton({ children, className, icon, iconSize = 12, isLoading, onClick, size = 'sm', title, variant = 'default', }) { const IconComponent = useMemo(() => { if (isLoading) { return jsx(GitlabLoader, { size: iconSize }); } if (icon) { return jsx(GitlabIcon, { icon, size: iconSize }); } return null; }, [icon, isLoading]); return jsxs('button', { onClick, title, type: 'button', class: clsx( `btn btn-${size} gl-button`, buttonVariantClass[variant], className ), children: [ children && jsx('span', { class: 'gl-button-text', children }), IconComponent, ], }); } // apps/gitlab-plus/src/components/common/CloseButton.tsx function CloseButton({ onClick, title = 'Close' }) { return jsx(GitlabButton, { className: 'btn-icon', icon: 'close-xs', iconSize: 16, onClick, title, variant: 'tertiary', }); } // apps/gitlab-plus/src/components/common/modal/GlpModal.tsx function GlpModal({ children, isVisible, onClose, title }) { return jsx('div', { class: clsx('glp-modal', isVisible && 'glp-modal-visible'), children: jsxs('div', { className: clsx( 'glp-modal-content crud gl-border', 'gl-rounded-form gl-border-section gl-bg-subtle gl-mt-5' ), children: [ jsxs('div', { className: clsx( 'crud-header gl-border-b gl-flex gl-flex-wrap', 'gl-justify-between gl-gap-x-5 gl-gap-y-2 gl-rounded-t-form', 'gl-border-section gl-bg-section gl-px-5 gl-py-4 gl-relative' ), children: [ jsx('h2', { className: clsx( 'gl-m-0 gl-inline-flex gl-items-center gl-gap-3', 'gl-text-form gl-font-bold gl-leading-normal' ), children: title, }), jsx(CloseButton, { onClick: onClose }), ], }), children, ], }), }); } // apps/gitlab-plus/src/components/common/modal/useGlpModal.ts function useGlpModal(eventName) { const [isVisible, setIsVisible] = useState(false); useEffect(() => { document.addEventListener(eventName, () => setIsVisible(true)); }, []); return { isVisible, onClose: () => setIsVisible(false), }; } // apps/gitlab-plus/src/components/common/base/Text.tsx function Text({ children, className, color, size, variant, weight }) { return jsx('span', { class: clsx( size && `gl-text-${size}`, weight && `gl-font-${weight}`, variant && `gl-text-${variant}`, color && `gl-text-${color}`, className ), children, }); } // apps/gitlab-plus/src/components/common/form/FormField.tsx function FormField({ children, error, hint, title }) { return jsxs('fieldset', { class: clsx( 'form-group gl-form-group gl-w-full', error && 'gl-show-field-errors' ), children: [ jsx('legend', { class: 'bv-no-focus-ring col-form-label pt-0 col-form-label', children: title, }), children, Boolean(!error && hint) && jsx('small', { children: hint }), Boolean(error) && jsx('small', { class: 'gl-field-error', children: error }), ], }); } // apps/gitlab-plus/src/components/common/form/FormRow.tsx function FormRow({ children }) { return jsx('div', { class: 'gl-flex gl-gap-x-3', children }); } // libs/share/src/utils/camelizeKeys.ts function camelizeKeys(data) { if (!data || ['string', 'number', 'boolean'].includes(typeof data)) { return data; } if (Array.isArray(data)) { return data.map(camelizeKeys); } const camelize = (key) => { const _key = key.replace(/[-_\s]+(.)?/g, (_, chr) => chr ? chr.toUpperCase() : '' ); return _key.substring(0, 1).toLowerCase() + _key.substring(1); }; return Object.entries(data).reduce( (r###lt, [key, value]) => ({ ...r###lt, [camelize(key)]: camelizeKeys(value), }), {} ); } // apps/gitlab-plus/src/providers/GitlabProvider.ts class GitlabProvider { constructor(force = false) { __publicField(this, 'cache', new Cache('glp-')); __publicField(this, 'graphqlApi', 'https://gitlab.com/api/graphql'); __publicField(this, 'url', 'https://gitlab.com/api/v4/'); this.force = force; } async cached(key, getValue, minutes) { const cacheValue = this.cache.get(key); if (cacheValue && !this.force) { return cacheValue; } const value = await getValue(); this.cache.set(key, value, minutes); return value; } csrf() { const token = document.querySelector('meta[name=csrf-token]'); if (token) { return token.getAttribute('content'); } return ''; } async get(path) { const response = await fetch(`${this.url}${path}`, { headers: this.headers(), method: 'GET', }); const data = await response.json(); return camelizeKeys(data); } async getCached(key, path, minutes) { return this.cached(key, () => this.get(path), minutes); } headers() { const headers = { 'content-type': 'application/json', }; const csrf = this.csrf(); if (csrf) { headers['X-CSRF-Token'] = csrf; } return headers; } async post(path, body) { const response = await fetch(`${this.url}${path}`, { body: JSON.stringify(body), headers: this.headers(), method: 'POST', }); const data = await response.json(); return camelizeKeys(data); } async query(query, variables) { const response = await fetch(this.graphqlApi, { body: JSON.stringify({ query, variables }), headers: this.headers(), method: 'POST', }); return response.json(); } async queryCached(key, query, variables, minutes) { return this.cached(key, () => this.query(query, variables), minutes); } } // apps/gitlab-plus/src/providers/query/user.ts const userFragment = ` fragment UserFragment on User { id avatarUrl name username webUrl webPath } `; const userQuery = ` query workspaceAutocompleteUsersSearch($search: String!, $fullPath: ID!) { workspace: project(fullPath: $fullPath) { id users: autocompleteUsers(search: $search) { ...UserFragment } } } ${userFragment} `; // apps/gitlab-plus/src/providers/UsersProvider.ts class UsersProvider extends GitlabProvider { async getUsers(projectId, search = '') { return this.queryCached( `users-${projectId}-${search}`, userQuery, { fullPath: projectId, search, }, search === '' ? 20 : 0.5 ); } } // apps/gitlab-plus/src/components/common/form/autocomplete/useAsyncAutocompleteButton.ts function useAsyncAutocompleteButton(hide) { const ref = useRef(null); useEffect(() => { document.body.addEventListener('click', (e) => { if ( ref.current && e.target !== ref.current && !ref.current.contains(e.target) ) { hide(); } }); }, []); return ref; } // apps/gitlab-plus/src/components/common/form/autocomplete/AsyncAutocompleteButton.tsx function AsyncAutocompleteButton({ isOpen, renderLabel, reset, setIsOpen, size = 'md', value, }) { const ref = useAsyncAutocompleteButton(() => setIsOpen(false)); const icon = useMemo(() => { if (value.length) { return 'close-xs'; } return isOpen ? 'chevron-lg-up' : 'chevron-lg-down'; }, [isOpen, value]); return jsx('button', { class: `btn btn-default btn-${size} btn-block gl-button gl-new-dropdown-toggle`, ref, type: 'button', onClick: (e) => { e.preventDefault(); setIsOpen(true); }, children: jsxs('span', { class: 'gl-button-text gl-w-full', children: [ jsx('span', { class: 'gl-new-dropdown-button-text', children: renderLabel(value), }), jsx('span', { onClick: (e) => { if (value.length) { e.preventDefault(); reset(); } }, children: jsx(GitlabIcon, { icon, size: 16 }), }), ], }), }); } // apps/gitlab-plus/src/components/common/form/autocomplete/AsyncAutocompleteOption.tsx function AsyncAutocompleteOption({ hideCheckbox = false, isActive, onClick, option, removeFromRecent, renderOption, selected, }) { const selectedIds = selected.map((i) => i.id); const selectedClass = (id) => selectedIds.includes(id); return jsx('li', { onClick: () => onClick(option), class: clsx( 'gl-new-dropdown-item', // selectedClass(option.id), isActive && 'glp-active' ), children: jsxs('span', { class: 'gl-new-dropdown-item-content', children: [ !hideCheckbox && jsx(GitlabIcon, { className: 'glp-item-check gl-pr-2', icon: selectedClass(option.id) ? 'mobile-issue-close' : '', size: 16, }), renderOption(option), removeFromRecent && jsx(CloseButton, { title: 'Remove from recently used', onClick: (e) => { e.preventDefault(); e.stopPropagation(); removeFromRecent(option); }, }), ], }), }); } // apps/gitlab-plus/src/components/common/form/autocomplete/AsyncAutocompleteList.tsx function AsyncAutocompleteList({ hideCheckbox, activeIndex, onClick, options, recently, removeRecently, renderOption, value, }) { return jsx('div', { onClick: (e) => e.stopPropagation(), class: 'gl-new-dropdown-contents gl-new-dropdown-contents-with-scrim-overlay bottom-scrim-visible gl-new-dropdown-contents', style: { maxWidth: '800px', width: '100%', left: '0', top: '100%', }, children: jsx('div', { class: 'gl-new-dropdown-inner', children: jsxs('ul', { class: 'gl-mb-0 gl-pl-0', children: [ Boolean(recently.length) && jsxs(Fragment, { children: [ jsx('li', { class: 'gl-pb-2 gl-pl-4 gl-pt-3 gl-text-sm gl-font-bold gl-text-strong', children: 'Recently used', }), recently.map((item, index) => jsx( AsyncAutocompleteOption, { hideCheckbox, isActive: index === activeIndex, onClick, option: item, removeFromRecent: removeRecently, renderOption, selected: value, }, item.id ) ), ], }), Boolean(options.length) && jsxs(Fragment, { children: [ jsx('li', { class: 'gl-pb-2 gl-pl-4 gl-pt-3 gl-text-sm gl-font-bold gl-text-strong gl-border-t', }), options.map((item, index) => jsx( AsyncAutocompleteOption, { hideCheckbox, isActive: recently.length + index === activeIndex, onClick, option: item, renderOption, selected: value, }, item.id ) ), ], }), options.length + recently.length === 0 && jsx('li', { class: 'gl-p-4', children: 'No options' }), ], }), }), }); } // apps/gitlab-plus/src/components/common/form/autocomplete/AsyncAutocompleteSearch.tsx function AsyncAutocompleteSearch({ navigate, setValue, value }) { return jsx('div', { class: 'gl-border-b-1 gl-border-b-solid gl-border-b-dropdown', children: jsxs('div', { class: 'gl-listbox-search gl-listbox-topmost', children: [ jsx(GitlabIcon, { className: 'gl-search-box-by-type-search-icon', icon: 'search', size: 16, }), jsx('input', { class: 'gl-listbox-search-input', onInput: (e) => setValue(e.target.value), onKeyDown: (e) => navigate(e.key), value, autofocus: true, }), Boolean(value) && jsx('div', { class: 'gl-search-box-by-type-right-icons', style: { top: '0' }, children: jsx(CloseButton, { onClick: () => setValue(''), title: 'Clear input', }), }), ], }), }); } // apps/gitlab-plus/src/components/common/form/autocomplete/useListNavigate.ts function useListNavigate(options, recent, onClick, onClose) { const [activeIndex, setActiveIndex] = useState(-1); const navigate = (key) => { if (['ArrowDown', 'ArrowUp'].includes(key)) { const total = recent.length + options.length; const diff = key === 'ArrowDown' ? 1 : -1; setActiveIndex((activeIndex + diff + total) % total); } else if (key === 'Enter') { const allItems = [...recent, ...options]; if (-1 < activeIndex && activeIndex < allItems.length) { onClick(allItems[activeIndex]); } } else if (key === 'Escape') { onClose(); } }; return { activeIndex, navigate, }; } // apps/gitlab-plus/src/components/common/form/autocomplete/AsyncAutocompleteDropdown.tsx function AsyncAutocompleteDropdown({ hideCheckbox, onClick, onClose, options, recently = [], removeRecently, renderOption, searchTerm, setSearchTerm, value, }) { const { activeIndex, navigate } = useListNavigate( options, recently, onClick, onClose ); return jsx('div', { class: clsx('gl-new-dropdown-panel gl-absolute !gl-block'), onClick: (e) => e.stopPropagation(), style: { maxWidth: '800px', width: '100%', left: 'auto', right: '0', top: '100%', }, children: jsxs('div', { class: 'gl-new-dropdown-inner', children: [ jsx(AsyncAutocompleteSearch, { navigate, setValue: setSearchTerm, value: searchTerm, }), jsx(AsyncAutocompleteList, { hideCheckbox, activeIndex, onClick, options, recently, removeRecently, renderOption, value, }), ], }), }); } // libs/share/src/utils/useDebounce.ts function useDebounce(value, delay = 300) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay); return () => { clearTimeout(handler); }; }, [value, delay]); return debouncedValue; } // apps/gitlab-plus/src/components/common/form/autocomplete/useAsyncAutocompleteOptions.ts function useAsyncAutocompleteOptions(searchTerm, getValues) { const [options, setOptions] = useState([]); const term = useDebounce(searchTerm); const loadOptions = useCallback( async (term2) => { const items = await getValues(term2); setOptions(items); }, [getValues] ); useEffect(() => { loadOptions(term); }, [term, loadOptions]); return options; } // apps/gitlab-plus/src/providers/RecentlyProvider.ts class RecentlyProvider { constructor(key) { __publicField(this, 'cache', new Cache('glp-')); __publicField(this, 'key'); __publicField(this, 'eventName'); this.key = `recently-${key}`; this.eventName = `recently-${key}-change`; } add(...items) { const itemsId = items.map((i) => i.id); this.cache.set( this.key, [...items, ...this.get().filter((el) => !itemsId.includes(el.id))], 'lifetime' ); this.triggerChange(); } get() { return this.cache.get(this.key) || []; } onChange(callback) { document.addEventListener(this.eventName, callback); } remove(...items) { const itemsId = items.map((i) => i.id); this.cache.set( this.key, this.get().filter((el) => !itemsId.includes(el.id)), 'lifetime' ); this.triggerChange(); } triggerChange() { document.dispatchEvent(new CustomEvent(this.eventName)); } } // apps/gitlab-plus/src/components/common/form/autocomplete/useAsyncAutocompleteRecently.ts function useAsyncAutocompleteRecently(name2) { const store = useRef(new RecentlyProvider(name2)); const [recently, setRecently] = useState(store.current.get()); useEffect(() => { store.current.onChange(() => { setRecently(store.current.get()); }); }, []); return { add: store.current.add.bind(store.current), recently, remove: store.current.remove.bind(store.current), }; } // apps/gitlab-plus/src/components/common/form/autocomplete/useAsyncAutocomplete.ts function useAsyncAutocomplete( name2, value, getValues, onChange, isMultiselect ) { const [searchTerm, setSearchTerm] = useState(''); const [isOpen, setIsOpen] = useState(false); const { recently: allRecently, remove: removeRecently } = useAsyncAutocompleteRecently(name2); const options = useAsyncAutocompleteOptions(searchTerm, getValues); const onClick = (item) => { if (isMultiselect) { if (value.find((i) => i.id === item.id)) { onChange(value.filter((i) => i.id !== item.id)); } else { onChange([...value, item]); } } else { onChange([item]); setIsOpen(false); } }; const recently = useMemo(() => { const optionsIds = options.map((i) => i.id); return searchTerm.length ? allRecently.filter((i) => optionsIds.includes(i.id)) : allRecently; }, [options, allRecently]); return { isOpen, onClick, options: useMemo(() => { const recentlyIds = recently.map((i) => i.id); return options.filter((i) => !recentlyIds.includes(i.id)); }, [options, recently]), recently, removeRecently, searchTerm, setIsOpen, setSearchTerm, }; } // apps/gitlab-plus/src/components/common/form/autocomplete/AsyncAutocomplete.tsx function AsyncAutocomplete({ hideCheckbox = false, buttonSize, getValues, isDisabled, isMultiselect = false, name: name2, onChange, renderLabel, renderOption, value, }) { const { isOpen, onClick, options, recently, removeRecently, searchTerm, setIsOpen, setSearchTerm, } = useAsyncAutocomplete(name2, value, getValues, onChange, isMultiselect); return jsxs('div', { class: clsx( 'gl-relative gl-w-full gl-new-dropdown !gl-block', isDisabled && 'gl-pointer-events-none gl-opacity-5' ), children: [ jsx(AsyncAutocompleteButton, { isOpen, renderLabel, reset: () => onChange([]), setIsOpen, size: buttonSize, value, }), isOpen && jsx(AsyncAutocompleteDropdown, { hideCheckbox, onClick, onClose: () => setIsOpen(false), options, recently, removeRecently, renderOption, searchTerm, setSearchTerm, value, }), ], }); } // apps/gitlab-plus/src/components/common/GitlabUser.tsx function GitlabUser({ showUsername, size = 24, smallText, user, withLink }) { const label = useMemo(() => { return jsxs(Fragment, { children: [ jsx('span', { class: clsx('gl-mr-2 gl-block', smallText && '!gl-text-sm'), children: user.name, }), showUsername && jsx('span', { class: 'gl-block gl-text-secondary !gl-text-sm', children: user.username, }), ], }); }, [smallText, showUsername, user]); const iconClsx = [ `gl-avatar gl-avatar-s${size}`, smallText ? 'gl-mr-1' : 'gl-mr-3', ]; return jsxs('div', { class: 'gl-flex gl-items-center', children: [ user.avatarUrl ? jsx('img', { alt: `${user.name}'s avatar`, class: clsx(...iconClsx, `gl-avatar-circle`), src: user.avatarUrl, }) : jsx('div', { class: clsx( ...iconClsx, `gl-avatar-identicon gl-avatar-identicon-bg1` ), children: user.name[0].toUpperCase(), }), withLink ? jsx('a', { href: user.webUrl, children: label }) : jsx('div', { children: label }), ], }); } // apps/gitlab-plus/src/components/create-issue/fields/AssigneesField.tsx function AssigneesField({ projectPath, setValue, value }) { const getUsers = useCallback( async (search) => { if (!projectPath) { return []; } const response = await new UsersProvider().getUsers(projectPath, search); return response.data.workspace.users; }, [projectPath] ); const renderLabel = useCallback((items) => { const label = items.map((i) => i.name).join(', '); return jsx('div', { title: label, children: items.length ? label : 'Select assignee', }); }, []); const renderOption = useCallback((item) => { return jsx('span', { class: 'gl-new-dropdown-item-text-wrapper', children: jsx(GitlabUser, { user: item, showUsername: true }), }); }, []); return jsx(AsyncAutocomplete, { getValues: getUsers, isDisabled: !projectPath, name: 'assignees', onChange: setValue, renderLabel, renderOption, value, isMultiselect: true, }); } // apps/gitlab-plus/src/components/create-issue/fields/ButtonField.tsx function ButtonField({ create, isLoading, reset }) { return jsxs(Fragment, { children: [ jsxs('button', { class: 'btn btn-confirm btn-sm gl-button gl-gap-2', disabled: isLoading, onClick: create, type: 'button', children: [ jsx('span', { class: 'gl-button-text', children: 'Add' }), isLoading ? jsx(GitlabLoader, { size: 12 }) : jsx(GitlabIcon, { icon: 'plus', size: 12 }), ], }), jsx('button', { class: 'btn btn-sm gl-button', onClick: reset, type: 'button', children: jsx('span', { class: 'gl-button-text', children: 'Reset' }), }), ], }); } // apps/gitlab-plus/src/providers/query/iteration.ts const iterationFragment = `fragment IterationFragment on Iteration { id title startDate dueDate webUrl state iterationCadence { id title } }`; const iterationQuery = `query issueIterationsAliased($fullPath: ID!, $title: String, $state: IterationState) { workspace: group(fullPath: $fullPath) { id attributes: iterations( search: $title in: [TITLE, CADENCE_TITLE] state: $state ) { nodes { ...IterationFragment } } } } ${iterationFragment} `; // apps/gitlab-plus/src/providers/IterationsProvider.ts class IterationsProvider extends GitlabProvider { async getIterations(projectId, title = '') { return this.queryCached( `iterations-${projectId}-search-${title}`, iterationQuery, { fullPath: projectId, state: 'opened', title, }, title !== '' ? 0.5 : 20 ); } } // apps/gitlab-plus/src/components/create-issue/fields/IterationField.tsx function iterationName(iteration) { const start = new Date(iteration.startDate).toLocaleDateString(); const end = new Date(iteration.dueDate).toLocaleDateString(); return `${iteration.iterationCadence.title}: ${start} - ${end}`; } function IterationField({ link, setValue, value }) { const getUsers = useCallback( async (search) => { const response = await new IterationsProvider().getIterations( link.workspacePath, search ); return response.data.workspace.attributes.nodes .map((iteration) => ({ ...iteration, name: iterationName(iteration), })) .toSorted((a, b) => a.name.localeCompare(b.name)); }, [link] ); const renderLabel = useCallback(([item]) => { return item ? item.name : 'Select iteration'; }, []); const renderOption = useCallback((item) => { return jsx('span', { class: 'gl-new-dropdown-item-text-wrapper', children: jsx('span', { class: 'gl-flex gl-w-full gl-items-center', children: jsx('span', { class: 'gl-mr-2 gl-block', children: item.name, }), }), }); }, []); return jsx(AsyncAutocomplete, { getValues: getUsers, name: 'iterations', onChange: setValue, renderLabel, renderOption, value, }); } // apps/gitlab-plus/src/providers/query/label.ts const labelFragment = ` fragment LabelFragment on Label { id title description color textColor } `; const projectLabelsQuery = `query projectLabels($fullPath: ID!, $searchTerm: String) { workspace: project(fullPath: $fullPath) { id labels( searchTerm: $searchTerm includeAncestorGroups: true ) { nodes { ...LabelFragment } } } } ${labelFragment} `; const workspaceLabelsQuery = `query groupLabels($fullPath: ID!, $searchTerm: String) { workspace: group(fullPath: $fullPath) { id labels( searchTerm: $searchTerm onlyGroupLabels: true includeAncestorGroups: true ) { nodes { ...LabelFragment } } } } ${labelFragment} `; // apps/gitlab-plus/src/providers/LabelsProvider.ts class LabelsProvider extends GitlabProvider { async getProjectLabels(projectPath, search = '') { return this.queryCached( `project-${projectPath}-labels-${search}`, projectLabelsQuery, { fullPath: projectPath, searchTerm: search, }, search === '' ? 20 : 2 ); } async getWorkspaceLabels(workspacePath, search = '') { return this.queryCached( `workspace-${workspacePath}-labels-${search}`, workspaceLabelsQuery, { fullPath: workspacePath, searchTerm: search, }, search === '' ? 20 : 2 ); } } // apps/gitlab-plus/src/components/common/GitlabLabel.tsx function GitlabLabel({ label, onRemove }) { const [scope, text] = label.title.split('::'); const props = useMemo(() => { const className = [ 'gl-label', 'hide-collapsed', label.textColor === '#FFFFFF' ? 'gl-label-text-light' : 'gl-label-text-dark', ]; if (label.title.includes('::')) { className.push('gl-label-scoped'); } return { class: clsx(className), style: { '--label-background-color': label.color, '--label-inset-border': `inset 0 0 0 2px ${label.color}`, }, }; }, [label]); return jsxs('span', { class: props.class, style: props.style, children: [ jsxs('span', { class: 'gl-link gl-label-link gl-label-link-underline', children: [ jsx('span', { class: 'gl-label-text', children: scope }), text && jsx('span', { class: 'gl-label-text-scoped', children: text }), ], }), onRemove && jsx('button', { onClick: onRemove, type: 'button', class: 'btn gl-label-close !gl-p-0 btn-reset btn-sm gl-button btn-reset-tertiary', children: jsx('span', { class: 'gl-button-text', children: jsx(GitlabIcon, { icon: 'close-xs' }), }), }), ], }); } // apps/gitlab-plus/src/components/create-issue/fields/LabelsField.tsx function LabelField({ copyLabels, projectPath, setValue, value }) { const getLabels = useCallback( async (search) => { if (!projectPath) { return []; } const response = await new LabelsProvider().getProjectLabels( projectPath, search ); return response.data.workspace.labels.nodes; }, [projectPath] ); const renderLabel = useCallback((items) => { return items.length ? items.map((i) => i.title).join(', ') : 'Select labels'; }, []); const renderOption = useCallback((item) => { return jsxs('div', { class: 'gl-flex gl-flex-1 gl-break-anywhere gl-pb-3 gl-pl-4 gl-pt-3', children: [ jsx('span', { class: 'dropdown-label-box gl-top-0 gl-mr-3 gl-shrink-0', style: { backgroundColor: item.color }, }), jsx('span', { children: item.title }), ], }); }, []); return jsxs(Fragment, { children: [ jsx('div', { class: 'gl-mt-1 gl-pb-2 gl-flex gl-flex-wrap gl-gap-2', children: value.map((label) => jsx( GitlabLabel, { label, onRemove: () => setValue(value.filter((item) => label.id !== item.id)), }, label.id ) ), }), jsxs('div', { className: 'gl-flex gl-gap-1 gl-relative gl-pr-7', children: [ jsx(AsyncAutocomplete, { getValues: getLabels, isDisabled: !projectPath, name: 'labels', onChange: setValue, renderLabel, renderOption, value, isMultiselect: true, }), jsx('div', { className: 'gl-flex gl-absolute gl-h-full gl-right-0', children: jsx(GitlabButton, { icon: 'labels', onClick: copyLabels, title: 'Copy labels from parent', }), }), ], }), ], }); } // apps/gitlab-plus/src/providers/query/milestone.ts const milestoneFragment = ` fragment MilestoneFragment on Milestone { id iid title webUrl: webPath dueDate expired state } `; const milestoneQuery = `query projectMilestones($fullPath: ID!, $title: String, $state: MilestoneStateEnum) { workspace: project(fullPath: $fullPath) { id attributes: milestones( searchTitle: $title state: $state sort: EXPIRED_LAST_DUE_DATE_ASC first: 40 includeAncestors: true ) { nodes { ...MilestoneFragment } } } } ${milestoneFragment} `; // apps/gitlab-plus/src/providers/MilestonesProvider.ts class MilestonesProvider extends GitlabProvider { async getMilestones(projectId, title = '') { return this.queryCached( `milestones-${projectId}-${title}`, milestoneQuery, { fullPath: projectId, state: 'active', title, }, title === '' ? 20 : 0.5 ); } } // apps/gitlab-plus/src/components/create-issue/fields/MilestoneField.tsx function MilestoneField({ projectPath, setValue, value }) { const getMilestones = useCallback( async (search) => { if (!projectPath) { return []; } const response = await new MilestonesProvider().getMilestones( projectPath, search ); return response.data.workspace.attributes.nodes; }, [projectPath] ); const renderLabel = useCallback(([item]) => { return item ? item.title : 'Select milestone'; }, []); const renderOption = useCallback((item) => { return jsx('span', { class: 'gl-new-dropdown-item-text-wrapper', children: jsx('span', { class: 'gl-flex gl-w-full gl-items-center', children: jsx('span', { class: 'gl-mr-2 gl-block', children: item.title, }), }), }); }, []); return jsx(AsyncAutocomplete, { getValues: getMilestones, isDisabled: !projectPath, name: 'milestones', onChange: setValue, renderLabel, renderOption, value, }); } // apps/gitlab-plus/src/providers/query/project.ts const projectsQuery = `query boardsGetGroupProjects($fullPath: ID!, $search: String, $after: String) { workspace: group(fullPath: $fullPath) { id projects(search: $search, after: $after, first: 100, includ###bgroups: true) { nodes { id name avatarUrl fullPath nameWithNamespace archived } } } } `; // apps/gitlab-plus/src/providers/ProjectsProvider.ts class ProjectsProvider extends GitlabProvider { async getProjects(workspacePath, search = '') { return this.queryCached( `projects-${workspacePath}-${search}`, projectsQuery, { fullPath: workspacePath, search, }, search === '' ? 20 : 0.5 ); } } // apps/gitlab-plus/src/components/common/GitlabProject.tsx function GitlabProject({ project, size = 32 }) { return jsxs('span', { class: 'gl-flex gl-w-full gl-items-center', children: [ project.avatarUrl ? jsx('img', { alt: project.name, class: `gl-mr-3 gl-avatar gl-avatar-s${size}`, src: project.avatarUrl, }) : jsx('div', { class: `gl-mr-3 gl-avatar gl-avatar-identicon gl-avatar-s${size} gl-avatar-identicon-bg1`, children: project.name[0].toUpperCase(), }), jsxs('span', { children: [ jsx('span', { class: 'gl-mr-2 gl-block', children: project.name }), jsx('span', { class: 'gl-block gl-text-secondary !gl-text-sm', children: project.nameWithNamespace, }), ], }), ], }); } // apps/gitlab-plus/src/components/create-issue/fields/ProjectField.tsx function ProjectField({ link, setValue, value }) { const getProjects = useCallback( async (search) => { const response = await new ProjectsProvider().getProjects( link.workspacePath, search ); return response.data.workspace.projects.nodes; }, [link] ); const renderLabel = useCallback(([item]) => { return item ? item.nameWithNamespace : 'Select project'; }, []); const renderOption = useCallback((item) => { return jsx('span', { class: 'gl-new-dropdown-item-text-wrapper', children: jsx(GitlabProject, { project: item }), }); }, []); return jsx(AsyncAutocomplete, { getValues: getProjects, name: 'projects', onChange: setValue, renderLabel, renderOption, value, }); } // apps/gitlab-plus/src/types/Issue.ts const issueRelation = ['blocks', 'is_blocked_by', 'relates_to']; // apps/gitlab-plus/src/components/create-issue/fields/RelationField.tsx const labels = (relation) => { switch (relation) { case 'blocks': return 'blocks current issue'; case 'is_blocked_by': return 'is blocked by current issue'; case 'relates_to': return 'relates to current issue'; default: return 'is not related to current issue'; } }; function RelationField({ setValue, value }) { return jsx('div', { class: 'linked-issue-type-radio', children: [...issueRelation, null].map((relation) => jsxs( 'div', { class: 'gl-form-radio custom-control custom-radio', children: [ jsx('input', { id: `create-related-issue-relation-${relation}`, checked: value === relation, class: 'custom-control-input', name: 'linked-issue-type-radio', onChange: () => setValue(relation), type: 'radio', value: relation ?? '', }), jsx('label', { class: 'custom-control-label', for: `create-related-issue-relation-${relation}`, children: labels(relation), }), ], }, relation ) ), }); } // apps/gitlab-plus/src/components/create-issue/fields/TitleField.tsx function TitleField({ error, onChange, value }) { return jsx('input', { onInput: (e) => onChange(e.target.value), placeholder: 'Add a title', value, class: clsx( 'gl-form-input form-control', error && 'gl-field-error-outline' ), }); } // apps/gitlab-plus/src/helpers/LinkParser.ts class LinkParser { static isEpicLink(link) { return link.epic !== void 0; } static isIssueLink(link) { return link.issue !== void 0; } static isMrLink(link) { return link.mr !== void 0; } static parseEpicLink(link) { if (LinkParser.validateEpicLink(link)) { return LinkParser.parseGitlabLink( link, /\/groups\/(?<workspacePath>.+)\/-\/epics\/(?<epic>\d+)/ ); } return void 0; } static parseGitlabLink(link, pattern) { const url = new URL(link); const r###lt = url.pathname.match(pattern); if (r###lt && r###lt.groups) { return r###lt.groups; } return void 0; } static parseIssueLink(link) { if (LinkParser.validateIssueLink(link)) { return LinkParser.parseGitlabLink( link, /\/(?<projectPath>(?<workspacePath>.+)\/[^/]+)\/-\/issues\/(?<issue>\d+)/ ); } return void 0; } static parseMrLink(link) { if (LinkParser.validateMrLink(link)) { return LinkParser.parseGitlabLink( link, /\/(?<projectPath>(?<workspacePath>.+)\/[^/]+)\/-\/merge_requests\/(?<mr>\d+)\/?$/ ); } return void 0; } static validateEpicLink(link) { return LinkParser.validateGitlabLink(link, 'epics'); } static validateGitlabLink(link, type) { return Boolean(typeof link === 'string' && link.includes(`/-/${type}/`)); } static validateIssueLink(link) { return LinkParser.validateGitlabLink(link, 'issues'); } static validateMrLink(link) { return LinkParser.validateGitlabLink(link, 'merge_requests'); } } // apps/gitlab-plus/src/helpers/Widget.ts class WidgetHelper { static epicLabels(epic) { const labelWidgets = epic.widgets.find((w) => w.type === 'LABELS'); if (labelWidgets) { return labelWidgets.labels.nodes; } return []; } } // apps/gitlab-plus/src/providers/query/epic.ts const epicQuery = `query namespaceWorkItem($fullPath: ID!, $iid: String!) { workspace: namespace(fullPath: $fullPath) { id workItem(iid: $iid) { ...WorkItem } } } fragment WorkItem on WorkItem { id iid archived title state description createdAt closedAt webUrl project { id } namespace { id fullPath name fullName } author { ...UserFragment } widgets { ...WorkItemWidgets } } fragment WorkItemWidgets on WorkItemWidget { type ... on WorkItemWidgetHierarchy { hasChildren children(first: 100) { count nodes { id iid title state webUrl widgets { type ...LabelsWidget } } } } ... on WorkItemWidgetAssignees { assignees { nodes { ...UserFragment } } } ... on WorkItemWidgetLabels { labels { nodes { ...LabelFragment } } } ... on WorkItemWidgetIteration { iteration { ...IterationFragment } } ... on WorkItemWidgetMilestone { milestone { ...MilestoneFragment } } ... on WorkItemWidgetColor { color textColor } ... on WorkItemWidgetLinkedItems { linkedItems { nodes { linkId linkType } } } } fragment LabelsWidget on WorkItemWidgetLabels { labels { nodes { ...LabelFragment } } } ${labelFragment} ${userFragment} ${iterationFragment} ${milestoneFragment} `; const epicSetLabelsMutation = ` mutation workItemUpdate($input: WorkItemUpdateInput!) { workItemUpdate(input: $input) { errors } } `; // apps/gitlab-plus/src/providers/EpicProvider.ts class EpicProvider extends GitlabProvider { async getEpic(workspacePath, epicId) { return this.queryCached( `epic-${workspacePath}-${epicId}`, epicQuery, { iid: epicId, cursor: '', fullPath: workspacePath, pageSize: 50, }, 2 ); } async updateEpicLabels(id, addLabelIds, removeLabelIds) { return await this.query(epicSetLabelsMutation, { input: { id, labelsWidget: { addLabelIds, removeLabelIds, }, }, }); } } // apps/gitlab-plus/src/providers/query/issue.ts const issueQuery = `query issueEE($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { id issue(iid: $iid) { id iid title description createdAt state dueDate projectId milestone { id title startDate dueDate } epic { id iid title webUrl } iteration { ...IterationFragment } labels { nodes { ...LabelFragment } } relatedMergeRequests { nodes { iid title state webUrl author { ...UserFragment } } } assignees { nodes { ...UserFragment } } author { ...UserFragment } weight type linkedWorkItems { nodes { linkType workItemState workItem { id iid webUrl title widgets { type ...LabelsWidget } } } } } } } fragment LabelsWidget on WorkItemWidgetLabels { labels { nodes { ...LabelFragment } } } ${labelFragment} ${userFragment} ${iterationFragment} `; const issueWithRelatedIssuesLabelsQuery = `query issueEE($projectPath: ID!, $iid: String!) { project(fullPath: $projectPath) { issue(iid: $iid) { linkedWorkItems { nodes { workItem { id iid widgets { type ...LabelsWidget } } } } } } } fragment LabelsWidget on WorkItemWidgetLabels { labels { nodes { ...LabelFragment } } } ${labelFragment} `; 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) { workspace: group(fullPath: $fullPath) { id workItems( search: $searchTerm types: $types in: $in includeAncestors: $includeAncestors includeDescendants: $includeDescendants ) @include(if: $searchByText) { nodes { ...WorkItemSearchFragment } } workItemsByIid: workItems( iid: $iid types: $types includeAncestors: $includeAncestors includeDescendants: $includeDescendants ) @include(if: $searchByIid) { nodes { ...WorkItemSearchFragment } } workItemsEmpty: workItems( types: $types includeAncestors: $includeAncestors includeDescendants: $includeDescendants ) @include(if: $searchEmpty) { nodes { ...WorkItemSearchFragment } } } } fragment WorkItemSearchFragment on WorkItem { id iid title project { fullPath } } `; const issueMutation = ` mutation CreateIssue($input: CreateIssueInput!) { createIssue(input: $input) { issue { ...CreatedIssue } errors } } fragment CreatedIssue on Issue { id iid projectId } `; const issueSetEpicMutation = ` mutation projectIssueUpdateParent($input: WorkItemUpdateInput!) { issuableSetAttribute: workItemUpdate(input: $input) { errors } } `; const issueSetLabelsMutation = ` mutation issueSetLabels($input: UpdateIssueInput!) { updateIssuableLabels: updateIssue(input: $input) { errors } } `; // apps/gitlab-plus/src/providers/IssueProvider.ts class IssueProvider extends GitlabProvider { async createIssue(input) { return await this.query(issueMutation, { input }); } async createIssueRelation(input) { const path = [ 'projects/:PROJECT_ID', '/issues/:ISSUE_ID/links', '?target_project_id=:TARGET_PROJECT_ID', '&target_issue_iid=:TARGET_ISSUE_IID', '&link_type=:LINK_TYPE', ] .join('') .replace(':PROJECT_ID', `${input.projectId}`) .replace(':ISSUE_ID', `${input.issueId}`) .replace(':TARGET_PROJECT_ID', input.targetProjectId) .replace(':TARGET_ISSUE_IID', input.targetIssueIid) .replace(':LINK_TYPE', input.linkType); return await this.post(path, {}); } async getIssue(projectPath, iid) { return this.queryCached( `issue-${projectPath}-${iid}`, issueQuery, { iid, projectPath, }, 2 ); } async getIssues(projectPath, search) { const searchById = !!search.match(/^\d+$/); return await this.query(issuesQuery, { iid: searchById ? search : null, searchByIid: searchById, fullPath: projectPath, in: 'TITLE', includeAncestors: true, includeDescendants: true, searchByText: Boolean(search), searchEmpty: !search, searchTerm: search, types: ['ISSUE'], }); } async getIssueWithRelatedIssuesLabels(projectPath, iid) { return this.queryCached( `issue-related-issues-${projectPath}-${iid}`, issueWithRelatedIssuesLabelsQuery, { iid, projectPath, }, 2 ); } async issueSetEpic(issueId, epicId) { return await this.query(issueSetEpicMutation, { input: { hierarchyWidget: { parentId: epicId, }, id: issueId, }, }); } async issueSetLabels(input) { return await this.query(issueSetLabelsMutation, { input, }); } } // apps/gitlab-plus/src/components/create-issue/useCreateIssueForm.ts const initialState = () => ({ assignees: [], iteration: null, labels: [], milestone: null, project: null, relation: null, title: '', }); const initialError = () => ({ assignees: void 0, iteration: void 0, labels: void 0, milestone: void 0, project: void 0, relation: void 0, title: void 0, }); function useCreateIssueForm({ isVisible, link, onClose }) { let _a; const [values, setValues] = useState(initialState()); const [errors, setErrors] = useState(initialError()); const [parentIssue, setParentIssue] = useState(null); const [parentEpic, setParentEpic] = useState(null); const [isLoading, setIsLoading] = useState(false); const [message, setMessage] = useState(''); const [error, setError] = useState(''); const reset = () => { setIsLoading(false); setValues(initialState()); setErrors(initialError()); setMessage(''); setError(''); setParentIssue(null); setParentEpic(null); }; const createPayload = () => { const data = { projectPath: values.project.fullPath, title: values.title, }; if (values.milestone) { data['milestoneId'] = values.milestone.id; } if (values.iteration) { data['iterationId'] = values.iteration.id; data['iterationCadenceId'] = values.iteration.iterationCadence.id; } if (values.assignees) { data['assigneeIds'] = values.assignees.map((a) => a.id); } data['labelIds'] = values.labels.map((label) => label.id); return data; }; const persistRecently = () => { Object.entries({ assignees: values.assignees, iterations: values.iteration ? [values.iteration] : [], labels: values.labels, milestones: values.milestone ? [values.milestone] : [], projects: values.project ? [values.project] : [], }).map(([key, values2]) => { new RecentlyProvider(key).add(...values2); }); }; const validate = () => { let isValid = true; const errors2 = {}; if (values.title.length < 1) { errors2.title = 'Title is required'; isValid = false; } else if (values.title.length > 255) { errors2.title = 'Title is too long'; isValid = false; } if (!values.project) { errors2.project = 'Project must be selected'; isValid = false; } setErrors((prev) => ({ ...prev, ...errors2 })); return isValid; }; const createIssue = async (payload) => { return await new IssueProvider().createIssue(payload); }; const createRelation = async (issue, targetIssue, relation) => { await new IssueProvider().createIssueRelation({ targetIssueIid: targetIssue.iid, issueId: issue.iid, linkType: relation, projectId: issue.projectId, targetProjectId: targetIssue.projectId, }); }; const setIssueEpic = async (issue, epic) => { await new IssueProvider().issueSetEpic(issue.id, epic.id); }; const submit = async () => { if (!validate()) { return; } setIsLoading(true); try { setMessage('Creating issue...'); const payload = createPayload(); const response = await createIssue(payload); persistRecently(); if (values.relation && parentIssue) { setMessage('Creating relation to parent issue...'); await createRelation( response.data.createIssue.issue, parentIssue, values.relation ); } if (parentEpic) { setMessage('Linking to epic...'); await setIssueEpic(response.data.createIssue.issue, parentEpic); } setMessage('Issue was created'); window.setTimeout(() => onClose(), 2e3); } catch (e) { setMessage(''); setError(e.message); } setIsLoading(false); }; const fetchParent = async () => { if (LinkParser.isIssueLink(link)) { const issue = await new IssueProvider().getIssue( link.projectPath, link.issue ); setParentIssue(issue.data.project.issue); } if (LinkParser.isEpicLink(link)) { const epic = await new EpicProvider().getEpic( link.workspacePath, link.epic ); setParentEpic(epic.data.workspace.workItem); } }; useEffect(() => { if (isVisible) { fetchParent(); } else { reset(); } }, [isVisible]); return { actions: { reset, submit, }, error, form: { assignees: { errors: errors.assignees, onChange: (assignees) => setValues({ ...values, assignees }), value: values.assignees, }, iteration: { errors: errors.iteration, onChange: ([iteration]) => setValues({ ...values, iteration: iteration ?? null }), value: values.iteration ? [values.iteration] : [], }, labels: { copy: () => { if (parentEpic) { setValues({ ...values, labels: WidgetHelper.epicLabels(parentEpic), }); } if (parentIssue) { setValues({ ...values, labels: parentIssue.labels.nodes }); } }, errors: errors.labels, onChange: (labels2) => setValues({ ...values, labels: labels2 }), value: values.labels, }, milestone: { errors: errors.milestone, onChange: ([milestone]) => setValues({ ...values, milestone: milestone ?? null }), value: values.milestone ? [values.milestone] : [], }, project: { errors: errors.project, onChange: ([project]) => setValues({ ...values, project: project ?? null }), value: values.project ? [values.project] : [], }, relation: { errors: errors.relation, onChange: (relation) => setValues({ ...values, relation }), value: values.relation, }, title: { copy: () => { const parentTitle = (parentIssue == null ? void 0 : parentIssue.title) || (parentEpic == null ? void 0 : parentEpic.title); if (parentTitle) { setValues({ ...values, title: parentTitle, }); } }, errors: errors.title, onChange: (title) => setValues({ ...values, title }), value: values.title, }, }, isLoading, message, parentEpic, parentIssue, projectPath: (_a = values.project) == null ? void 0 : _a.fullPath, }; } // apps/gitlab-plus/src/components/create-issue/CreateIssueForm.tsx function CreateIssueForm({ isVisible, link, onClose }) { const { actions, error, form, isLoading, message, parentEpic, parentIssue, projectPath, } = useCreateIssueForm({ isVisible, link, onClose }); return jsxs('form', { class: 'crud-body add-tree-form gl-mx-5 gl-my-4 gl-rounded-b-form', children: [ jsx(FormField, { error: form.title.errors, hint: 'Maximum of 255 characters', title: 'Title', children: jsxs('div', { className: 'gl-flex gl-gap-1', children: [ jsx(TitleField, { error: form.title.errors, onChange: form.title.onChange, value: form.title.value, }), jsx(GitlabButton, { icon: 'title', onClick: form.title.copy, title: 'Copy from parent title', }), ], }), }), jsxs(FormRow, { children: [ jsx(FormField, { error: form.project.errors, title: 'Project', children: jsx(ProjectField, { link, setValue: form.project.onChange, value: form.project.value, }), }), jsx(FormField, { error: form.assignees.errors, title: 'Assignees', children: jsx(AssigneesField, { projectPath, setValue: form.assignees.onChange, value: form.assignees.value, }), }), ], }), jsxs(FormRow, { children: [ jsx(FormField, { error: form.iteration.errors, title: 'Iteration', children: jsx(IterationField, { link, setValue: form.iteration.onChange, value: form.iteration.value, }), }), jsx(FormField, { error: form.milestone.errors, title: 'Milestone', children: jsx(MilestoneField, { projectPath, setValue: form.milestone.onChange, value: form.milestone.value, }), }), ], }), jsx(FormField, { error: form.labels.errors, title: 'Labels', children: jsx(LabelField, { copyLabels: form.labels.copy, projectPath, setValue: form.labels.onChange, value: form.labels.value, }), }), parentIssue && jsxs(FormField, { error: form.relation.errors, title: 'New issue', children: [ jsx(RelationField, { setValue: form.relation.onChange, value: form.relation.value, }), jsxs(Text, { size: 'sm', variant: 'secondary', children: [ 'Parent issue: #', parentIssue.iid, ' ', parentIssue.title, ], }), ], }), parentEpic && jsx(FormField, { title: '', children: jsxs(Text, { size: 'sm', variant: 'secondary', children: ['Parent epic: &', parentEpic.iid, ' ', parentEpic.title], }), }), jsx(FormField, { error, hint: message, title: '', children: jsx(FormRow, { children: jsx(ButtonField, { create: actions.submit, isLoading, reset: actions.reset, }), }), }), ], }); } // apps/gitlab-plus/src/components/create-issue/events.ts const showRelatedIssueModal = 'glp-show-create-issue-modal'; const showChildIssueModal = 'glp-show-create-child-issue-modal'; const ShowRelatedIssueModalEvent = new CustomEvent(showRelatedIssueModal); const ShowChildIssueModalEvent = new CustomEvent(showChildIssueModal); // apps/gitlab-plus/src/components/create-issue/CreateChildIssueModal.tsx function CreateChildIssueModal({ link }) { const { isVisible, onClose } = useGlpModal(showChildIssueModal); return jsx(GlpModal, { isVisible, onClose, title: 'Create child issue', children: jsx(CreateIssueForm, { isVisible, link, onClose }), }); } // apps/gitlab-plus/src/services/CreateChildIssue.tsx class CreateChildIssue extends BaseService { constructor() { super(...arguments); __publicField(this, 'name', ServiceName.CreateChildIssue); __publicField(this, 'isMounted', false); } init() { this.mount(); setTimeout(this.mount.bind(this), 1e3); setTimeout(this.mount.bind(this), 3e3); } mount() { if (this.isMounted) { return; } const link = LinkParser.parseEpicLink(window.location.href); const parent = document.querySelector( '#childitems [data-testid="crud-actions"]' ); if (!link || !parent) { return; } this.isMounted = true; render( jsx(GitlabButton, { onClick: () => document.dispatchEvent(ShowChildIssueModalEvent), children: 'Create child item', }), this.root('glp-child-issue-button', parent, true) ); render( jsx(CreateChildIssueModal, { link }), this.rootBody('glp-child-issue-modal') ); } } // apps/gitlab-plus/src/components/create-issue/CreateRelatedIssueModal.tsx function CreateRelatedIssueModal({ link }) { const { isVisible, onClose } = useGlpModal(showRelatedIssueModal); return jsx(GlpModal, { isVisible, onClose, title: 'Create related issue', children: jsx(CreateIssueForm, { isVisible, link, onClose }), }); } // apps/gitlab-plus/src/services/CreateRelatedIssue.tsx class CreateRelatedIssue extends BaseService { constructor() { super(...arguments); __publicField(this, 'name', ServiceName.CreateRelatedIssue); __publicField(this, 'isMounted', false); } init() { this.mount(); setTimeout(this.mount.bind(this), 1e3); setTimeout(this.mount.bind(this), 3e3); } mount() { if (this.isMounted) { return; } const link = LinkParser.parseIssueLink(window.location.href); const parent = document.querySelector( '#related-issues [data-testid="crud-actions"]' ); if (!link || !parent) { return; } this.isMounted = true; render( jsx(GitlabButton, { onClick: () => document.dispatchEvent(ShowRelatedIssueModalEvent), children: 'Create related issue', }), this.root('glp-related-issue-button', parent) ); render( jsx(CreateRelatedIssueModal, { link }), this.rootBody('glp-related-issue-modal') ); } } // libs/share/src/ui/Events.ts class Events { static intendHover(validate, mouseover, mouseleave, timeout = 500) { let hover = false; let id = 0; const onHover = (event) => { if (!event.target || !validate(event.target)) { return; } const element = event.target; hover = true; element.addEventListener( 'mouseleave', (ev) => { mouseleave == null ? void 0 : mouseleave.call(element, ev); clearTimeout(id); hover = false; }, { once: true } ); clearTimeout(id); id = window.setTimeout(() => { if (hover) { mouseover.call(element, event); } }, timeout); }; document.body.addEventListener('mouseover', onHover); } } // apps/gitlab-plus/src/components/common/useOnLinkHover.ts const modalZIndex = 1e3; function useOnLinkHover(parser, validator) { const [hoverPosition, setHoverPosition] = useState({ x: 0, y: 0 }); const [hoverLink, setHoverLink] = useState(); const [zIndex, setZIndex] = useState(modalZIndex); const hoverLinkRef = useRef(false); const onHover = (event) => { const anchor = event.target; const link = parser(anchor.href); if (!link) { return; } anchor.title = ''; setHoverLink(link); setZIndex( anchor.dataset.zIndex ? Number(anchor.dataset.zIndex) : modalZIndex ); setHoverPosition({ x: event.clientX + 15, y: event.clientY, }); }; useEffect(() => { Events.intendHover( (element) => validator(element.href), onHover, () => { setTimeout(() => { if (!hoverLinkRef.current) { setHoverLink(void 0); } }, 50); } ); }, []); return { hoverLink, hoverPosition, onLinkEnter: () => (hoverLinkRef.current = true), onLinkLeave: () => { hoverLinkRef.current = false; setHoverLink(void 0); }, zIndex, }; } // apps/gitlab-plus/src/components/common/usePreviewModal.ts function usePreviewModal(link, fetch2, reset, isLoading) { const [isVisible, setIsVisible] = useState(false); const [offset, setOffset] = useState({ x: 0, y: 0 }); const ref = useRef(null); useEffect(() => { if (!isLoading) { setTimeout(() => { const rect = ref.current.getBoundingClientRect(); const dY = rect.height + rect.top - window.innerHeight; const dX = rect.width + rect.left - window.innerWidth; setOffset({ x: dX > 0 ? dX + 15 : 0, y: dY > 0 ? dY + 15 : 0, }); }, 300); } }, [isLoading]); useEffect(() => { if (!isVisible) { setOffset({ x: 0, y: 0 }); } }, [isVisible]); useEffect(() => { if (link) { fetch2(link); setIsVisible(true); } else { setIsVisible(false); reset(); } }, [link]); return { isVisible, offset, ref, }; } // apps/gitlab-plus/src/components/common/PreviewModal.tsx function PreviewModal({ validator, children, fetch: fetch2, isError, isLoading = false, isRefreshing = false, parser, reset, }) { const { hoverLink, hoverPosition, onLinkEnter, onLinkLeave, zIndex } = useOnLinkHover(parser, validator); const { isVisible, offset, ref } = usePreviewModal( hoverLink, fetch2, reset, isLoading ); const content = useMemo(() => { if (isLoading || !isVisible) { return jsx(GitlabLoader, { size: '3em', asOverlay: true }); } if (isError) { return jsx(Row, { className: 'gl-flex-1', items: 'center', justify: 'center', children: 'Error', }); } return jsxs('div', { className: 'gl-flex gl-w-full gl-flex-col', children: [ children, isRefreshing && jsx(GitlabLoader, { size: '3em', asOverlay: true }), ], }); }, [isLoading, isRefreshing, isError, isVisible, children]); return jsx('div', { onMouseEnter: onLinkEnter, onMouseLeave: onLinkLeave, ref, className: clsx( 'popover gl-popover glp-preview-modal', isVisible && 'glp-modal-visible' ), style: { left: hoverPosition.x, top: hoverPosition.y, transform: `translate(-${offset.x}px, -${offset.y}px )`, zIndex, }, children: content, }); } // apps/gitlab-plus/src/components/common/block/InfoBlock.tsx function InfoBlock({ children, className, contentMaxHeight, icon, rightTitle, title, titleClassName, }) { const style = useMemo(() => { if (contentMaxHeight) { return { maxHeight: contentMaxHeight, overflowY: 'auto', }; } return {}; }, [contentMaxHeight]); return jsxs('div', { class: 'gl-relative gl-w-full gl-py-2 gl-border-b gl-border-b-solid', style: { borderColor: 'var(--gl-border-color-subtle)' }, children: [ jsxs(Row, { className: 'gl-px-3', items: 'center', justify: 'between', children: [ jsxs(Row, { gap: 2, items: 'center', children: [ icon && jsx(GitlabIcon, { icon, size: 16 }), jsx('h5', { className: clsx('gl-my-0', titleClassName), children: title, }), ], }), rightTitle, ], }), jsx('div', { style, children: jsx('div', { class: clsx('gl-px-3', className), children }), }), ], }); } // apps/gitlab-plus/src/components/common/block/HeadingBlock.tsx function HeadingBlock({ author, badge, createdAt, entityId, icon, onRefresh, title, }) { return jsxs(InfoBlock, { title, titleClassName: 'gl-pr-2', rightTitle: onRefresh && jsx('div', { onClick: onRefresh, className: 'gl-absolute gl-right-0 gl-top-1 gl-p-2 gl-cursor-pointer', children: jsx(GitlabIcon, { icon: 'repeat' }), }), children: [ jsxs(Row, { className: 'gl-mt-2', gap: 2, items: 'center', children: [ jsxs(Row, { gap: 2, items: 'center', children: [ jsx(GitlabIcon, { icon, size: 16 }), jsx(Text, { size: 'sm', variant: 'secondary', weight: 'bold', children: entityId, }), ], }), badge, ], }), jsxs(Row, { className: 'gl-mt-1', gap: 2, items: 'center', children: [ jsx(Text, { size: 'sm', variant: 'secondary', children: 'Created at', }), jsx(Text, { size: 'sm', weight: 'bold', children: new Date(createdAt).toLocaleDateString(), }), jsx(Text, { size: 'sm', variant: 'secondary', children: 'by' }), jsx(GitlabUser, { size: 16, user: author, smallText: true, withLink: true, }), ], }), ], }); } // apps/gitlab-plus/src/components/common/GitlabBadge.tsx function GitlabBadge({ icon, label, title, variant }) { return jsxs('span', { className: `gl-badge badge badge-pill badge-${variant}`, title, children: [ icon && jsx(GitlabIcon, { icon }), label && jsx('span', { className: 'gl-badge-content', children: label }), ], }); } // apps/gitlab-plus/src/components/common/IssueStatus.tsx function IssueStatus({ isOpen }) { return jsx(GitlabBadge, { icon: isOpen ? 'issue-open-m' : 'issue-close', label: isOpen ? 'Open' : 'Closed', variant: isOpen ? 'success' : 'info', }); } // apps/gitlab-plus/src/components/epic-preview/blocks/EpicHeading.tsx function EpicHeader({ epic, onRefresh }) { return jsx(HeadingBlock, { author: epic.author, badge: jsx(IssueStatus, { isOpen: epic.state === 'OPEN' }), createdAt: epic.createdAt, entityId: `&${epic.iid}`, icon: 'epic', onRefresh, title: epic.title, }); } // apps/gitlab-plus/src/components/common/block/LabelsBlockChangeStatus.tsx function LabelsBlockChangeStatus({ isLoading, name: name2, onChange, options, }) { if (isLoading) { return jsx(GitlabLoader, {}); } const getValues = useCallback( async (search) => { return options.filter((option) => option.title.includes(search)); }, [options] ); const renderOption = useCallback((item) => { return jsxs('div', { class: 'gl-flex gl-flex-1 gl-break-anywhere gl-pb-3 gl-pl-4 gl-pt-3', children: [ jsx('span', { class: 'dropdown-label-box gl-top-0 gl-mr-3 gl-shrink-0', style: { backgroundColor: item.color }, }), jsx('span', { children: item.title }), ], }); }, []); return jsx('div', { className: 'gl-py-2', style: { width: 130 }, children: jsx(AsyncAutocomplete, { hideCheckbox: true, buttonSize: 'sm', getValues, name: name2, onChange: ([label]) => label && onChange(label), renderLabel: () => 'Change status', renderOption, value: [], }), }); } // apps/gitlab-plus/src/components/common/block/useLabelBlock.ts const name = 'status-labels'; function useLabelBlock(statusUpdate) { const [isLoading, setIsLoading] = useState(false); const onSelectStatus = useCallback(async (label) => { setIsLoading(true); if (statusUpdate) { await statusUpdate.update(label); new RecentlyProvider(name).add(label); } setIsLoading(false); }, []); return { isLoading, name, onSelectStatus, showChangeStatusComponent: Boolean(statusUpdate), statusLabels: (statusUpdate == null ? void 0 : statusUpdate.labels) || [], }; } // apps/gitlab-plus/src/components/common/block/LabelsBlock.tsx function LabelsBlock({ labels: labels2, updateStatus }) { const { isLoading, name: name2, onSelectStatus, showChangeStatusComponent, statusLabels, } = useLabelBlock(updateStatus); if (!labels2.length && !updateStatus) { return null; } return jsx(InfoBlock, { className: 'issuable-show-labels', icon: 'labels', title: 'Labels', rightTitle: showChangeStatusComponent && jsx(LabelsBlockChangeStatus, { isLoading, name: name2, onChange: onSelectStatus, options: statusLabels, }), children: labels2.map((label) => jsx(GitlabLabel, { label }, label.id)), }); } // apps/gitlab-plus/src/components/epic-preview/blocks/useEpicLabels.ts function useEpicLabels(epic, refetch) { const [statusLabels, setStatusLabels] = useState([]); const labels2 = useMemo(() => { const labelWidget = epic.widgets.find((widget) => widget.type === 'LABELS'); if (labelWidget) { return labelWidget.labels.nodes; } return []; }, [epic]); const onStatusChange = useCallback( async (label) => { const oldStatus = labels2.filter((l) => l.title.includes( userSettingsStore.getConfig(UserConfig.StatusLabelPrefix) ) ); await new EpicProvider().updateEpicLabels( epic.id, [label.id], oldStatus.map((l) => l.id) ); if (refetch) { await refetch(); } }, [labels2] ); const fetchLabels = useCallback(async (workspacePath) => { const response = await new LabelsProvider().getWorkspaceLabels( workspacePath, userSettingsStore.getConfig(UserConfig.StatusLabelPrefix) ); setStatusLabels(response.data.workspace.labels.nodes); }, []); useEffect(() => { fetchLabels(epic.namespace.fullPath); }, []); return { labels: labels2, updateStatus: { labels: statusLabels, update: onStatusChange, }, }; } // apps/gitlab-plus/src/components/epic-preview/blocks/EpicLabels.tsx function EpicLabels({ epic, refresh }) { const { labels: labels2, updateStatus } = useEpicLabels(epic, refresh); if (!labels2.length) { return null; } return jsx(LabelsBlock, { labels: labels2, updateStatus }); } // apps/gitlab-plus/src/components/common/block/ListBlock.tsx function ListBlock({ itemId, items, maxHeight = 100, renderItem, ...props }) { if (!items.length) { return null; } return jsx(InfoBlock, { contentMaxHeight: maxHeight, ...props, children: items.map((item) => jsx(Fragment, { children: renderItem(item) }, itemId(item)) ), }); } // apps/gitlab-plus/src/components/common/base/Link.tsx function Link({ blockHover, children, className, href, inline, title }) { const [zIndex, setZIndex] = useState(modalZIndex + 1); const ref = useRef(null); const onHover = (e) => { e.stopPropagation(); e.preventDefault(); return false; }; useLayoutEffect(() => { let _a; const modal = (_a = ref.current) == null ? void 0 : _a.closest('.glp-preview-modal'); setZIndex( (modal == null ? void 0 : modal.style.zIndex) ? Number(modal.style.zIndex) + 1 : modalZIndex + 1 ); }, []); return jsx('a', { 'data-z-index': zIndex, href, onMouseOver: blockHover ? onHover : void 0, ref, target: '_blank', title, class: clsx( inline ? 'gl-inline' : 'gl-flex', 'gl-link sortable-link gl-items-center', className ), style: { overflow: 'hidden', textOverflow: 'ellipsis', }, children, }); } // apps/gitlab-plus/src/components/common/GitlabLinkWithLabel.tsx function GitlabLinkWithLabel({ blockHover, children, href, label, title }) { const status = useMemo(() => { if (label) { return jsx('div', { title: label.title, style: { minWidth: 10, width: 10, backgroundColor: label.color, borderRadius: 10, height: 10, marginRight: 2, }, }); } return null; }, [label]); return jsxs(Link, { blockHover, href, title, children: [status, children], }); } // apps/gitlab-plus/src/components/epic-preview/blocks/EpicRelatedIssues.tsx function EpicRelatedIssues({ epic }) { const issues = useMemo(() => { const hierarchyWidget = epic.widgets.find( (widget) => widget.type === 'HIERARCHY' ); if (!hierarchyWidget) { return []; } return hierarchyWidget.children.nodes; }, [epic]); const getStatusLabel = (item) => { const labelsWidget = item.widgets.find((w) => w.type === 'LABELS'); console.log(item.title, item.widgets, labelsWidget); return labelsWidget == null ? void 0 : labelsWidget.labels.nodes.find( (l) => l.title.toLowerCase().startsWith('status::') || l.title.toLowerCase().startsWith('workflow::') ); }; return jsx(ListBlock, { icon: 'issue-type-issue', itemId: (i) => i.iid, items: issues, title: `Child issues (${issues.length})`, renderItem: (issue) => jsxs(GitlabLinkWithLabel, { href: issue.webUrl, label: getStatusLabel(issue), title: issue.title, children: ['#', issue.iid, ' ', issue.title], }), }); } // apps/gitlab-plus/src/components/common/useFetchEntity.ts function useFetchEntity(fetcher) { const [entityData, setEntityData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const fetch2 = async (link, force = false) => { if (force) { setIsRefreshing(true); } else { setIsLoading(true); } const entity = await fetcher(link, force); setEntityData({ entity, link }); setIsRefreshing(false); setIsLoading(false); }; const reset = () => { setEntityData(null); setIsRefreshing(false); setIsLoading(false); }; return { entityData, fetch: fetch2, isLoading, isRefreshing, reset, }; } // apps/gitlab-plus/src/components/epic-preview/useFetchEpic.ts function useFetchEpic() { return useFetchEntity(async (link, force = false) => { const response = await new EpicProvider(force).getEpic( link.workspacePath, link.epic ); return response.data.workspace.workItem; }); } // apps/gitlab-plus/src/components/epic-preview/EpicPreviewModal.tsx function EpicPreviewModal() { const { entityData, fetch: fetch2, isLoading, isRefreshing, reset, } = useFetchEpic(); return jsx(PreviewModal, { validator: LinkParser.validateEpicLink, fetch: fetch2, isError: !entityData, isLoading, isRefreshing, parser: LinkParser.parseEpicLink, reset, children: entityData && jsxs(Fragment, { children: [ jsx(EpicHeader, { epic: entityData.entity, onRefresh: () => fetch2(entityData.link, true), }), jsx(EpicLabels, { epic: entityData.entity, refresh: () => fetch2(entityData.link, true), }), jsx(EpicRelatedIssues, { epic: entityData.entity }), ], }), }); } // apps/gitlab-plus/src/services/EpicPreview.tsx class EpicPreview extends BaseService { constructor() { super(...arguments); __publicField(this, 'name', ServiceName.EpicPreview); } init() { render(jsx(EpicPreviewModal, {}), this.rootBody('glp-epic-preview-root')); } } // apps/gitlab-plus/src/components/image-preview/useImagePreviewModal.ts function useImagePreviewModal() { const [zoom, setZoom] = useState('contains'); const [src, setSrc] = useState(''); const validate = (element) => { return ( element.classList.contains('no-attachment-icon') && /\.(png|jpg|jpeg|heic)$/.test(element.href.toLowerCase()) ); }; const getAnchor = (element) => { if (!element) { return void 0; } if (element instanceof HTMLAnchorElement) { return validate(element) ? element : void 0; } if ( element instanceof HTMLImageElement && element.parentElement instanceof HTMLAnchorElement ) { return validate(element.parentElement) ? element.parentElement : void 0; } return void 0; }; useEffect(() => { document.body.addEventListener('click', (ev) => { const anchor = getAnchor(ev.target); if (anchor) { setSrc(anchor.href); ev.preventDefault(); ev.stopPropagation(); return false; } }); }, []); const style = useMemo(() => { if (zoom === 'auto') { return { cursor: 'zoom-out', display: 'block', margin: '0 auto', padding: 15, }; } return { maxWidth: '95vw', cursor: 'zoom-in', display: 'block', margin: '0 auto', maxHeight: '95vh', }; }, [zoom]); return { onClose: () => { setSrc(''); setZoom('contains'); }, onZoom: () => setZoom(zoom === 'auto' ? 'contains' : 'auto'), src, style, }; } // apps/gitlab-plus/src/components/image-preview/ImagePreviewModal.tsx function ImagePreviewModal() { const { onClose, onZoom, src, style } = useImagePreviewModal(); return jsxs('div', { className: clsx( 'glp-image-preview-modal', Boolean(src) && 'glp-modal-visible' ), children: [ jsx('div', { className: 'gl-flex gl-items-center gl-overflow-auto gl-h-full gl-w-full', children: jsx('img', { alt: 'Image preview', onClick: onZoom, src, style, }), }), jsx('div', { className: 'glp-modal-close', onClick: onClose, children: jsx(GitlabIcon, { icon: 'close-xs', size: 24 }), }), ], }); } // apps/gitlab-plus/src/services/ImagePreview.tsx class ImagePreview extends BaseService { constructor() { super(...arguments); __publicField(this, 'name', ServiceName.ImagePreview); } init() { render(jsx(ImagePreviewModal, {}), this.rootBody('glp-image-preview-root')); } } // apps/gitlab-plus/src/components/common/block/UsersBlock.tsx function UsersBlock({ icon, label, pluralIcon, pluralLabel, users }) { if (!users || !users.length) { return null; } if (users.length === 1) { return jsx(InfoBlock, { icon: icon || 'user', rightTitle: jsx(GitlabUser, { user: users[0], withLink: true }), title: `${label}:`, }); } return jsx(ListBlock, { className: 'gl-flex gl-flex-col gl-gap-3', icon: pluralIcon || icon || 'users', itemId: (u) => u.id, items: users, renderItem: (user) => jsx(GitlabUser, { user, withLink: true }), title: pluralLabel || `${label}s`, }); } // apps/gitlab-plus/src/components/issue-preview/blocks/IssueAssignee.tsx function IssueAssignee({ issue }) { return jsx(UsersBlock, { icon: 'assignee', label: 'Assignee', users: issue.assignees.nodes, }); } // apps/gitlab-plus/src/components/issue-preview/blocks/IssueEpic.tsx function IssueEpic({ issue }) { if (!issue.epic) { return null; } return jsx(InfoBlock, { icon: 'epic', title: 'Epic', children: jsx(Link, { href: issue.epic.webUrl, title: issue.epic.title, children: issue.epic.title, }), }); } // apps/gitlab-plus/src/components/issue-preview/blocks/IssueHeading.tsx function IssueHeader({ issue, onRefresh }) { return jsx(HeadingBlock, { author: issue.author, badge: jsx(IssueStatus, { isOpen: issue.state === 'opened' }), createdAt: issue.createdAt, entityId: `#${issue.iid}`, icon: 'issue-type-issue', onRefresh, title: issue.title, }); } // apps/gitlab-plus/src/components/issue-preview/blocks/IssueIteration.tsx function IssueIteration({ issue }) { const label = useMemo(() => { let _a; const date = (date2) => { return new Intl.DateTimeFormat('en-US', { day: 'numeric', month: 'short', }).format(new Date(date2)); }; if (!issue.iteration) { return ''; } return [ (_a = issue.iteration.iterationCadence) == null ? void 0 : _a.title, ': ', date(issue.iteration.startDate), ' - ', date(issue.iteration.dueDate), ].join(''); }, [issue]); if (!issue.iteration) { return null; } return jsx(InfoBlock, { icon: 'iteration', rightTitle: label, title: 'Iteration', }); } // apps/gitlab-plus/src/components/issue-preview/blocks/useIssueLabels.ts function useIssueLabels(issue, link, refetch) { const [statusLabels, setStatusLabels] = useState([]); const onStatusChange = useCallback( async (label) => { const statusLabel = issue.labels.nodes.find((l) => l.title.includes( userSettingsStore.getConfig(UserConfig.StatusLabelPrefix) ) ); const labels2 = statusLabel ? issue.labels.nodes.map((l) => (l.id === statusLabel.id ? label : l)) : [...issue.labels.nodes, label]; await new IssueProvider().issueSetLabels({ iid: issue.iid, labelIds: labels2.map((l) => l.id), projectPath: link.projectPath, }); if (refetch) { await refetch(); } }, [issue] ); const fetchLabels = useCallback(async (projectPath) => { const response = await new LabelsProvider().getProjectLabels( projectPath, userSettingsStore.getConfig(UserConfig.StatusLabelPrefix) ); setStatusLabels(response.data.workspace.labels.nodes); }, []); useEffect(() => { fetchLabels(link.projectPath); }, []); return { labels: issue.labels.nodes, updateStatus: { labels: statusLabels, update: onStatusChange, }, }; } // apps/gitlab-plus/src/components/issue-preview/blocks/IssueLabels.tsx function IssueLabels({ issue, link, refetch }) { const { labels: labels2, updateStatus } = useIssueLabels( issue, link, refetch ); if (!labels2.length) { return null; } return jsx(LabelsBlock, { labels: labels2, updateStatus }); } // apps/gitlab-plus/src/components/common/MrStatus.tsx const iconMap = { closed: 'merge-request-close', locked: 'search', merged: 'merge', opened: 'merge-request', }; const classMap = { closed: 'danger', locked: 'warning', merged: 'info', opened: 'success', }; const labelMap = { closed: 'Closed', locked: 'Locked', merged: 'Merged', opened: 'Opened', }; function MrStatus({ state, withIcon, withLabel }) { return jsx(GitlabBadge, { icon: withIcon ? iconMap[state] : void 0, label: withLabel ? labelMap[state] : void 0, variant: classMap[state], }); } // apps/gitlab-plus/src/components/common/GitlabMergeRequest.tsx function GitlabMergeRequest({ mr }) { return jsxs('div', { style: { marginTop: 10 }, children: [ jsxs(Row, { gap: 2, children: [ jsx(MrStatus, { state: mr.state, withIcon: true, withLabel: true }), jsxs(Text, { variant: 'secondary', children: ['!', mr.iid], }), jsx(GitlabUser, { size: 16, user: mr.author, withLink: true }), ], }), jsx(Link, { href: mr.webUrl, title: mr.title, children: mr.title }), ], }); } // apps/gitlab-plus/src/components/issue-preview/blocks/IssueMergeRequests.tsx function IssueMergeRequests({ issue }) { return jsx(ListBlock, { icon: 'merge-request', itemId: (mr) => mr.iid, items: issue.relatedMergeRequests.nodes, renderItem: (mr) => jsx(GitlabMergeRequest, { mr }), title: 'Merge requests', }); } // apps/gitlab-plus/src/components/issue-preview/blocks/IssueMilestone.tsx function IssueMilestone({ issue }) { if (!issue.milestone) { return null; } return jsx(InfoBlock, { icon: 'milestone', rightTitle: issue.milestone.title, title: 'Milestone', }); } // apps/gitlab-plus/src/components/issue-preview/blocks/IssueRelatedIssue.tsx const relationMap = { blocks: 'Blocks', is_blocked_by: 'Is blocked by', relates_to: 'Related to', }; function IssueRelatedIssue({ issue }) { const groups = useMemo(() => { const initValue = { blocks: [], is_blocked_by: [], relates_to: [], }; return Object.entries( issue.linkedWorkItems.nodes.reduce( (acc, issue2) => ({ ...acc, [issue2.linkType]: [...acc[issue2.linkType], issue2], }), initValue ) ).filter(([_, issues]) => issues.length); }, [issue]); const getStatusLabel = (item) => { const labelsWidget = item.workItem.widgets.find((w) => w.type === 'LABELS'); return labelsWidget == null ? void 0 : labelsWidget.labels.nodes.find( (l) => l.title.toLowerCase().startsWith('status::') || l.title.toLowerCase().startsWith('workflow::') ); }; if (!issue.linkedWorkItems.nodes.length) { return null; } return jsx(Fragment, { children: groups.map(([key, issues]) => jsx( ListBlock, { itemId: (i) => i.workItem.iid, items: issues, title: `${relationMap[key]} (${issues.length}):`, renderItem: (issue2) => jsxs(GitlabLinkWithLabel, { href: issue2.workItem.webUrl, label: getStatusLabel(issue2), blockHover: true, children: ['#', issue2.workItem.iid, ' ', issue2.workItem.title], }), }, key ) ), }); } // apps/gitlab-plus/src/components/issue-preview/useFetchIssue.ts function useFetchIssue() { return useFetchEntity(async (link, force = false) => { const response = await new IssueProvider(force).getIssue( link.projectPath, link.issue ); return response.data.project.issue; }); } // apps/gitlab-plus/src/components/issue-preview/IssuePreviewModal.tsx function IssuePreviewModal() { const { entityData, fetch: fetch2, isLoading, isRefreshing, reset, } = useFetchIssue(); return jsx(PreviewModal, { validator: LinkParser.validateIssueLink, fetch: fetch2, isError: !entityData, isLoading, isRefreshing, parser: LinkParser.parseIssueLink, reset, children: entityData && jsxs(Fragment, { children: [ jsx(IssueHeader, { issue: entityData.entity, onRefresh: () => fetch2(entityData.link, true), }), jsx(IssueAssignee, { issue: entityData.entity }), jsx(IssueLabels, { issue: entityData.entity, link: entityData.link, refetch: () => fetch2(entityData.link, true), }), jsx(IssueEpic, { issue: entityData.entity }), jsx(IssueMilestone, { issue: entityData.entity }), jsx(IssueIteration, { issue: entityData.entity }), jsx(IssueMergeRequests, { issue: entityData.entity }), jsx(IssueRelatedIssue, { issue: entityData.entity }), ], }), }); } // apps/gitlab-plus/src/services/IssuePreview.tsx class IssuePreview extends BaseService { constructor() { super(...arguments); __publicField(this, 'name', ServiceName.IssuePreview); } init() { render(jsx(IssuePreviewModal, {}), this.rootBody('glp-issue-preview-root')); } } // apps/gitlab-plus/src/components/mr-preview/blocks/MrApprovedBy.tsx function MrApprovedBy({ mr }) { return jsx(UsersBlock, { label: 'Approved by', pluralLabel: 'Approved by', users: mr.approvedBy.nodes, }); } // apps/gitlab-plus/src/components/mr-preview/blocks/MrAssignee.tsx function MrAssignee({ mr }) { return jsx(UsersBlock, { icon: 'assignee', label: 'Assignee', users: mr.assignees.nodes, }); } // apps/gitlab-plus/src/components/mr-preview/blocks/MrBranch.tsx function MrBranch({ mr }) { return jsx(InfoBlock, { icon: 'branch', title: 'Merge', children: jsxs('span', { children: [ jsx(Text, { children: mr.sourceBranch }), jsx(Text, { className: 'gl-mx-2', variant: 'secondary', children: 'in to', }), jsx(Text, { children: mr.targetBranch }), ], }), }); } // apps/gitlab-plus/src/components/mr-preview/blocks/MrDiff.tsx function MrDiff({ mr }) { const label = useMemo(() => { if (mr.diffStatsSummary.fileCount === 1) { return '1 file'; } return `${mr.diffStatsSummary.fileCount} files`; }, [mr.diffStatsSummary.fileCount]); return jsx(InfoBlock, { icon: 'commit', title: `Commit: ${mr.commitCount}`, rightTitle: jsxs(Row, { gap: 2, items: 'center', children: [ jsx(GitlabIcon, { icon: 'doc-code', size: 16 }), jsx(Text, { size: 'subtle', weight: 'bold', children: label }), jsxs(Text, { color: 'success', weight: 'bold', children: ['+', mr.diffStatsSummary.additions], }), jsxs(Text, { color: 'danger', weight: 'bold', children: ['-', mr.diffStatsSummary.deletions], }), ], }), }); } // apps/gitlab-plus/src/components/mr-preview/blocks/MrDiscussion.tsx function MrDiscussion({ mr }) { const [resolved, total] = [ mr.resolvedDiscussionsCount, mr.resolvableDiscussionsCount, ]; if (!total) { return null; } const { label, title } = useMemo(() => { const plural = total !== 1 ? 's' : ''; return { label: `${resolved} of ${total}`, title: `${resolved} of ${total} thread${plural} resolved`, }; }, [mr]); return jsx(InfoBlock, { icon: 'comments', title: 'Discussion', rightTitle: jsx(GitlabBadge, { icon: 'comments', label, title, variant: resolved === total ? 'success' : 'muted', }), }); } // libs/share/src/utils/textWithChild.ts function textWithChild(text, pattern, replacer) { const matches = text.match(RegExp(pattern, 'g')); const parts = text.split(RegExp(pattern, 'g')); if (!(matches == null ? void 0 : matches.length)) { return text; } return parts.reduce((items, text2, index) => { const textToReplace = index < matches.length ? matches[index] : void 0; return [ ...items, text2, ...(textToReplace ? [replacer(textToReplace)] : []), ]; }, []); } // apps/gitlab-plus/src/components/mr-preview/blocks/MrHeading.tsx function MrHeader({ mr, onRefresh }) { const title = useMemo(() => { const issueLink = (id) => `${mr.project.webUrl}/-/issues/${id.replace(/\D+/g, '')}`; return textWithChild(mr.title, /#\d+/, (id) => jsx(Link, { href: issueLink(id), inline: true, children: id }) ); }, [mr]); return jsx(HeadingBlock, { author: mr.author, createdAt: mr.createdAt, entityId: `!${mr.iid}`, icon: 'merge-request', onRefresh, title, badge: jsxs(Row, { className: 'gl-gap-2', items: 'center', children: [ jsx(MrStatus, { state: mr.state, withIcon: true, withLabel: true }), Boolean(mr.approvedBy.nodes.length) && jsx(GitlabBadge, { icon: 'check-circle', label: 'Approved', variant: 'success', }), mr.conflicts && jsx(GitlabIcon, { icon: 'warning-solid', size: 16, title: 'Merge request can not be merged', }), ], }), }); } // apps/gitlab-plus/src/components/mr-preview/blocks/MrLabels.tsx function MrLabels({ mr }) { if (!mr.labels.nodes.length) { return null; } return jsx(InfoBlock, { className: 'issuable-show-labels', title: 'Labels', children: mr.labels.nodes.map((label) => jsx(GitlabLabel, { label }, label.id) ), }); } // apps/gitlab-plus/src/providers/query/mr.ts const mrQuery = `query MergeRequestQuery($fullPath: ID!, $iid: String!) { workspace: project(fullPath: $fullPath) { mergeRequest(iid: $iid) { id iid assignees { nodes { ...UserFragment } } approvedBy { nodes { ...UserFragment } } author { ...UserFragment } project { webUrl path fullPath } commitCount conflicts createdAt title titleHtml diffStatsSummary { additions changes deletions fileCount } draft labels { nodes { ...LabelFragment } } mergeable resolvedDiscussionsCount resolvableDiscussionsCount reviewers { nodes { ...UserFragment } } shouldBeRebased sourceBranch targetBranch state webUrl } } } ${userFragment} ${labelFragment} `; // apps/gitlab-plus/src/providers/MrProvider.ts class MrProvider extends GitlabProvider { async getMr(projectPath, mrId) { return this.queryCached( `mr-${projectPath}-${mrId}`, mrQuery, { iid: mrId, fullPath: projectPath, }, 2 ); } } // apps/gitlab-plus/src/components/mr-preview/useFetchMr.ts function useFetchMr() { return useFetchEntity(async (link, force = false) => { const response = await new MrProvider(force).getMr( link.projectPath, link.mr ); return response.data.workspace.mergeRequest; }); } // apps/gitlab-plus/src/components/mr-preview/MrPreviewModal.tsx function MrPreviewModal() { const { entityData, fetch: fetch2, isLoading, isRefreshing, reset, } = useFetchMr(); return jsx(PreviewModal, { validator: LinkParser.validateMrLink, fetch: fetch2, isError: !entityData, isLoading, isRefreshing, parser: LinkParser.parseMrLink, reset, children: entityData && jsxs(Fragment, { children: [ jsx(MrHeader, { mr: entityData.entity, onRefresh: () => fetch2(entityData.link, true), }), jsx(MrBranch, { mr: entityData.entity }), jsx(MrAssignee, { mr: entityData.entity }), jsx(MrApprovedBy, { mr: entityData.entity }), jsx(MrLabels, { mr: entityData.entity }), jsx(MrDiff, { mr: entityData.entity }), jsx(MrDiscussion, { mr: entityData.entity }), ], }), }); } // apps/gitlab-plus/src/services/MrPreview.tsx class MrPreview extends BaseService { constructor() { super(...arguments); __publicField(this, 'name', ServiceName.MrPreview); } init() { render(jsx(MrPreviewModal, {}), this.rootBody('glp-mr-preview-root')); } } // apps/gitlab-plus/src/components/related-issue-autocomplete/useRelatedIssuesAutocompleteModal.ts function useRelatedIssuesAutocompleteModal(link, input) { const [searchTerm, setSearchTerm] = useState(''); const [isVisible, setIsVisible] = useState(false); const searchIssues = useCallback(async (term) => { const response = await new IssueProvider().getIssues( link.workspacePath, term ); return [ response.data.workspace.workItems, response.data.workspace.workItemsByIid, response.data.workspace.workItemsEmpty, ].flatMap((item) => (item == null ? void 0 : item.nodes) || []); }, []); const options = useAsyncAutocompleteOptions(searchTerm, searchIssues); const onSelect = (item) => { input.value = `${item.project.fullPath}#${item.iid} `; input.dispatchEvent(new Event('input')); input.dispatchEvent(new Event('change')); }; useEffect(() => { document.body.addEventListener('click', (e) => { if (e.target !== input && !input.contains(e.target)) { setIsVisible(false); } }); input.addEventListener('click', () => setIsVisible(true)); }, []); return { isVisible, onClose: () => setIsVisible(false), onSelect, options, searchTerm, setSearchTerm, }; } // apps/gitlab-plus/src/components/related-issue-autocomplete/RelatedIssuesAutocompleteModal.tsx function RelatedIssuesAutocompleteModal({ input, link }) { const { isVisible, onClose, onSelect, options, searchTerm, setSearchTerm } = useRelatedIssuesAutocompleteModal(link, input); if (!isVisible) { return null; } return jsx('div', { class: 'gl-relative gl-w-full gl-new-dropdown !gl-block', children: jsx(AsyncAutocompleteDropdown, { hideCheckbox: true, onClick: onSelect, onClose, options, searchTerm, setSearchTerm, value: [], renderOption: (item) => jsxs('div', { class: 'gl-flex gl-gap-x-2 gl-py-2', children: [ jsx(GitlabIcon, { icon: 'issue-type-issue', size: 16 }), jsx('small', { children: item.iid }), jsx('span', { class: 'gl-flex gl-flex-wrap', children: item.title, }), ], }), }), }); } // apps/gitlab-plus/src/services/RelatedIssueAutocomplete.tsx class RelatedIssueAutocomplete extends BaseService { constructor() { super(...arguments); __publicField(this, 'name', ServiceName.RelatedIssueAutocomplete); __publicField(this, 'ready', false); __publicField(this, 'readyClass', 'glp-input-ready'); } init() { this.initObserver(); window.setTimeout(this.initObserver.bind(this), 1e3); window.setTimeout(this.initObserver.bind(this), 3e3); window.setTimeout(this.initObserver.bind(this), 5e3); } initAutocomplete(section) { const input = section.querySelector('#add-related-issues-form-input'); const link = LinkParser.parseIssueLink(window.location.href); if (!input || this.isMounted(input) || !link) { return; } const container = input.closest('.add-issuable-form-input-wrapper'); if (!container || document.querySelector('.related-issues-autocomplete')) { return; } const root = this.root('related-issues-autocomplete', container); render(jsx(RelatedIssuesAutocompleteModal, { input, link }), root); } initObserver() { const section = document.querySelector('#related-issues'); if (this.ready || !section) { return; } this.ready = true; const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'childList') { this.initAutocomplete(section); } }); }); observer.observe(section, { childList: true, }); } isMounted(input) { return input.classList.contains(this.readyClass); } } // apps/gitlab-plus/src/services/RelatedIssuesLabelStatus.tsx class RelatedIssuesLabelStatus extends BaseService { constructor() { super(...arguments); __publicField(this, 'name', ServiceName.RelatedIssuesLabelStatus); __publicField(this, 'ready', false); } init() { this.initIssuesList(); window.setTimeout(this.initIssuesList.bind(this), 1e3); window.setTimeout(this.initIssuesList.bind(this), 3e3); window.setTimeout(this.initIssuesList.bind(this), 5e3); } initIssuesList() { if (this.ready) { return; } const lists = document.querySelectorAll( '#related-issues .related-items-list' ); const link = LinkParser.parseIssueLink(window.location.href); if (!lists.length || !link) { return; } this.ready = true; const items = [...lists].flatMap((list) => [ ...list.querySelectorAll('li'), ]); this.updateIssuesItem(link, items); } async updateIssuesItem(link, items) { const response = await new IssueProvider().getIssueWithRelatedIssuesLabels( link.projectPath, link.issue ); const getStatusLabel = (item) => { const labelsWidget = item.workItem.widgets.find( (w) => w.type === 'LABELS' ); return labelsWidget == null ? void 0 : labelsWidget.labels.nodes.find( (l) => l.title.toLowerCase().startsWith('status::') || l.title.toLowerCase().startsWith('workflow::') ); }; const issueStatusMap = response.data.project.issue.linkedWorkItems.nodes.reduce((acc, value) => { return { ...acc, [value.workItem.id.replace(/\D/g, '')]: getStatusLabel(value), }; }, {}); items.forEach((item) => { if (!item.dataset.key || !issueStatusMap[item.dataset.key]) { return; } const statusLabel = issueStatusMap[item.dataset.key]; const infoArea = item.querySelector('.item-attributes-area'); if (infoArea && statusLabel) { render( jsx(GitlabLabel, { label: statusLabel }), this.root('glp-status-label', infoArea, true) ); } }); } } // libs/share/src/ui/Component.ts class Component { constructor(tag, props = {}) { this.element = Dom.create({ tag, ...props }); } addClassName(...className) { this.element.classList.add(...className); } event(event, callback) { this.element.addEventListener(event, callback); } getElement() { return this.element; } mount(parent) { parent.appendChild(this.element); } } // libs/share/src/ui/SvgComponent.ts class SvgComponent { constructor(tag, props = {}) { this.element = Dom.createSvg({ tag, ...props }); } addClassName(...className) { this.element.classList.add(...className); } event(event, callback) { this.element.addEventListener(event, callback); } getElement() { return this.element; } mount(parent) { parent.appendChild(this.element); } } // libs/share/src/ui/Dom.ts class Dom { static appendChildren(element, children, isSvgMode = false) { if (children) { element.append( ...Dom.array(children).map((item) => { if (typeof item === 'string') { return document.createTextNode(item); } if (item instanceof HTMLElement || item instanceof SVGElement) { return item; } if (item instanceof Component || item instanceof SvgComponent) { return item.getElement(); } const isSvg = 'svg' === item.tag ? true : 'foreignObject' === item.tag ? false : isSvgMode; if (isSvg) { return Dom.createSvg(item); } return Dom.create(item); }) ); } } static applyAttrs(element, attrs) { if (attrs) { Object.entries(attrs).forEach(([key, value]) => { if (value === void 0 || value === false) { element.removeAttribute(key); } else { element.setAttribute(key, `${value}`); } }); } } static applyClass(element, classes) { if (classes) { element.classList.add(...classes.split(' ').filter(Boolean)); } } static applyEvents(element, events) { if (events) { Object.entries(events).forEach(([name2, callback]) => { element.addEventListener(name2, callback); }); } } static applyStyles(element, styles) { if (styles) { Object.entries(styles).forEach(([key, value]) => { const name2 = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); element.style.setProperty(name2, value); }); } } static array(element) { return Array.isArray(element) ? element : [element]; } static create(data) { const element = document.createElement(data.tag); Dom.appendChildren(element, data.children); Dom.applyClass(element, data.classes); Dom.applyAttrs(element, data.attrs); Dom.applyEvents(element, data.events); Dom.applyStyles(element, data.styles); return element; } static createSvg(data) { const element = document.createElementNS( 'http://www.w3.org/2000/svg', data.tag ); Dom.appendChildren(element, data.children, true); Dom.applyClass(element, data.classes); Dom.applyAttrs(element, data.attrs); Dom.applyEvents(element, data.events); Dom.applyStyles(element, data.styles); return element; } static element(tag, classes, children) { return Dom.create({ tag, children, classes }); } static elementSvg(tag, classes, children) { return Dom.createSvg({ tag, children, classes }); } } // libs/share/src/ui/Observer.ts class Observer { start(element, callback, options) { this.stop(); this.observer = new MutationObserver(callback); this.observer.observe( element, options || { attributeOldValue: true, attributes: true, characterData: true, characterDataOldValue: true, childList: true, subtree: true, } ); } stop() { if (this.observer) { this.observer.disconnect(); } } } // apps/gitlab-plus/src/services/SortIssue.ts const sortWeight = { ['issue']: 4, ['label']: 0, ['ownIssue']: 10, ['ownUserStory']: 8, ['unknown']: 2, ['userStory']: 6, }; class SortIssue extends BaseService { constructor() { super(...arguments); __publicField(this, 'name', ServiceName.SortIssue); } init() { const observer = new Observer(); const userName = this.userName(); const board = document.querySelector('.boards-list'); if (!userName || !board) { return; } observer.start(board, () => this.run(userName)); } childType(child, userName) { if (child instanceof HTMLDivElement) { return 'label'; } const title = child.querySelector('[data-testid="board-card-title-link"]'); if (!title) { return 'unknown'; } const isOwn = [...child.querySelectorAll('.gl-avatar-link img')].some( (img) => img.alt.includes(userName) ); const isUserStory = [...child.querySelectorAll('.gl-label')].some((span) => span.innerText.includes('User Story') ); if (isUserStory && isOwn) { return 'ownUserStory'; } if (isOwn) { return 'ownIssue'; } if (isUserStory) { return 'userStory'; } return 'issue'; } initBoard(board, userName) { Dom.applyClass(board, 'glp-ready'); const observer = new Observer(); observer.start(board, () => this.sortBoard(board, userName), { childList: true, }); } run(userName) { [...document.querySelectorAll('.board-list:not(.glp-ready)')].forEach( (board) => this.initBoard(board, userName) ); } shouldSort(items) { return items.some((item) => { return ['ownIssue', 'ownUserStory'].includes(item.type); }); } sortBoard(board, userName) { Dom.applyStyles(board, { display: 'flex', flexDirection: 'column', }); const children = [...board.children].map((element) => ({ element, type: this.childType(element, userName), })); if (!this.shouldSort(children)) { return; } this.sortChildren(children).forEach(({ element }, index) => { const order = index !== children.length - 1 ? index + 1 : children.length + 100; element.style.order = `${order}`; }); } sortChildren(items) { return items.toSorted((a, b) => { return Math.sign(sortWeight[b.type] - sortWeight[a.type]); }); } userName() { const element = document.querySelector( '.user-bar-dropdown-toggle .gl-button-text .gl-sr-only' ); const testText = ' user’s menu'; if (element && element.innerText.includes(testText)) { return element.innerText.replace(testText, ''); } return void 0; } } // apps/gitlab-plus/src/components/user-settings/events.ts const showUserSettingsModal = 'glp-show-user-settings-modal'; const ShowUserSettingsModalEvent = new CustomEvent(showUserSettingsModal); // apps/gitlab-plus/src/components/user-settings/UserSettingsButton.tsx function UserSettingsButton() { return jsx('span', { className: 'gl-new-dropdown-item-content', onClick: () => document.dispatchEvent(ShowUserSettingsModalEvent), children: jsxs('span', { className: 'gl-new-dropdown-item-text-wrapper', children: [ jsx('span', { style: { color: '#e24329' }, children: 'Gitlab Plus' }), ' settings', ], }), }); } // apps/gitlab-plus/src/components/common/base/Column.tsx function Column({ children, className, gap, items, justify }) { return jsx('div', { class: clsx( 'gl-flex gl-flex-col', justify && `gl-justify-${justify}`, items && `gl-items-${items}`, gap && `gl-gap-${gap}`, className ), children, }); } // apps/gitlab-plus/src/components/common/GitlabSwitch.tsx function GitlabSwitch({ checked, disabled, onChange }) { return jsx('button', { 'aria-checked': checked, 'aria-disabled': disabled, disabled, onClick: () => onChange(!checked), role: 'switch', type: 'button', className: clsx( 'gl-toggle gl-shrink-0', checked && 'is-checked', disabled && 'is-disabled' ), children: jsx('span', { className: 'toggle-icon', children: jsx(GitlabIcon, { icon: checked ? 'check-xs' : 'close-xs' }), }), }); } // apps/gitlab-plus/src/components/user-settings/UserConfigForm.tsx function UserConfigForm({ setValue, value }) { const [isEditable, setIsEditable] = useState(false); const [inputValue, setInputValue] = useState(value); return jsx(Row, { gap: 2, items: 'center', justify: 'end', children: isEditable ? jsxs(Fragment, { children: [ jsx('input', { className: clsx('gl-form-input form-control gl-form-input-sm'), onInput: (e) => setInputValue(e.target.value), value: inputValue, }), jsx(GitlabButton, { className: 'btn-icon', icon: 'check-sm', iconSize: 16, onClick: () => { setIsEditable(false); setValue(inputValue); }, }), jsx(GitlabButton, { className: 'btn-icon', icon: 'close', iconSize: 16, onClick: () => { setIsEditable(false); setInputValue(value); }, }), ], }) : jsxs(Fragment, { children: [ jsx(Text, { weight: 'bold', children: value }), jsx(GitlabButton, { className: 'btn-icon', icon: 'pencil', iconSize: 16, onClick: () => setIsEditable(true), }), ], }), }); } // apps/gitlab-plus/src/components/user-settings/useUserSettingsModal.tsx function useUserSettingsModal() { const [refreshFlag, setRefreshFlag] = useState(false); const services = useMemo(() => { return Object.entries(servicesConfig) .map(([name2, config]) => ({ isActive: Boolean(userSettingsStore.isActive(name2)), isExperimental: config.experimental, isRequired: config.required, label: config.label, name: name2, })) .sort((a, b) => { if (a.isRequired || b.isRequired) { return a.isRequired ? 1 : -1; } if (a.isExperimental || b.isExperimental) { return a.isExperimental ? 1 : -1; } return a.name.localeCompare(b.name); }); }, [refreshFlag]); const configs = useMemo(() => { return Object.values(UserConfig).map((name2) => ({ label: configLabels[name2], name: name2, value: userSettingsStore.getConfig(name2), })); }, [refreshFlag]); return { configs, services, setConfig: (name2, value) => { userSettingsStore.setConfig(name2, value); setRefreshFlag((flag) => !flag); }, setServiceState: (name2, value) => { userSettingsStore.setIsActive(name2, value); setRefreshFlag((flag) => !flag); }, }; } // apps/gitlab-plus/src/components/user-settings/UserSettingsModal.tsx function UserSettingModal() { const { isVisible, onClose } = useGlpModal(showUserSettingsModal); const { configs, services, setConfig, setServiceState } = useUserSettingsModal(); return jsx(GlpModal, { isVisible, onClose, title: jsxs(Fragment, { children: [ jsx('span', { style: { color: '#e24329' }, children: 'Gitlab Plus' }), ' settings', ], }), children: jsxs(Column, { className: 'gl-p-4', gap: 2, children: [ configs.map((config) => jsxs( Row, { gap: 2, items: 'center', justify: 'between', children: [ jsx(Text, { children: config.label }), jsx(UserConfigForm, { setValue: (value) => setConfig(config.name, value), value: config.value, }), ], }, config.name ) ), jsx('hr', { class: 'gl-my-2' }), services.map((service) => jsxs(Row, { gap: 2, items: 'center', children: [ jsx(GitlabSwitch, { checked: service.isActive, disabled: service.isRequired, onChange: (value) => setServiceState(service.name, value), }), jsx(Text, { variant: service.isRequired ? 'secondary' : void 0, children: service.label, }), service.isExperimental && jsx(GitlabBadge, { label: 'Experimental', variant: 'warning' }), service.isRequired && jsx(GitlabBadge, { label: 'Required', variant: 'muted' }), ], }) ), ], }), }); } // apps/gitlab-plus/src/services/UserSettings.tsx class UserSettings extends BaseService { constructor() { super(...arguments); __publicField(this, 'name', ServiceName.UserSettings); __publicField(this, 'ready', false); } init() { this.initUserSettings(); window.setTimeout(this.initUserSettings.bind(this), 1e3); window.setTimeout(this.initUserSettings.bind(this), 3e3); window.setTimeout(this.initUserSettings.bind(this), 5e3); } getMenuItem() { const userMenu = document.querySelector('[data-testid="preferences-item"]'); if (!userMenu || !userMenu.parentElement) { return void 0; } const li = document.createElement('li'); li.className = 'gl-new-dropdown-item'; userMenu.parentElement.append(li); return li; } initUserSettings() { if (this.ready) { return; } const userMenu = this.getMenuItem(); if (!userMenu) { return; } this.ready = true; render(jsx(UserSettingsButton, {}), userMenu); render(jsx(UserSettingModal, {}), this.rootBody('glp-user-settings-root')); } } // apps/gitlab-plus/src/main.ts [ ClearCacheService, ImagePreview, MrPreview, EpicPreview, IssuePreview, CreateRelatedIssue, CreateChildIssue, RelatedIssueAutocomplete, RelatedIssuesLabelStatus, SortIssue, UserSettings, ].forEach((Service) => { const service = new Service(); if (userSettingsStore.isActive(service.name)) { service.init(); } });