// ==UserScript==
// @name Gitlab plus
// @namespace https://lukaszmical.pl/
// @version 2025-02-21
// @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 display: flex;\n justify-content: center;\n align-items: center;\n top: 0;\n left: 0;\n right: 0;\n bottom: 0;\n background: rgba(0, 0, 0, 0.6);\n visibility: hidden;\n opacity: 0;\n pointer-events: none;\n z-index: 99999;\n}\n\n.glp-image-preview-modal.glp-modal-visible {\n visibility: visible;\n opacity: 1;\n pointer-events: auto;\n}\n\n.glp-image-preview-modal .glp-modal-img {\n max-width: 95%;\n max-height: 95%;\n}\n\n.glp-image-preview-modal .glp-modal-close {\n position: absolute;\n z-index: 2;\n top: 20px;\n right: 20px;\n color: black;\n width: 40px;\n height: 40px;\n display: flex;\n justify-content: center;\n align-items: center;\n background: white;\n border-radius: 20px;\n cursor: pointer;\n}\n\n';
const style3 =
'.glp-preview-modal {\n position: fixed;\n display: flex;\n background-color: var(--gl-background-color-default, var(--gl-color-neutral-0, #fff));\n border: 1px solid var(--gl-border-color-default);\n border-radius: .25rem;\n width: 350px;\n min-height: 200px;\n z-index: 99999;\n visibility: hidden;\n top: 0;\n left: 0;\n opacity: 0;\n transition: all .2s ease-out;\n transition-property: visibility, opacity, transform;\n}\n\n.glp-preview-modal.glp-modal-visible {\n visibility: visible;\n opacity: 1;\n}\n \n.glp-preview-modal .glp-block {\n padding: .4rem .5em;\n border-bottom-style: solid;\n border-bottom-color: var(--gl-border-color-subtle, var(--gl-color-neutral-50, #ececef));\n border-bottom-width: 1px;\n width: 100%;\n}\n\n\n.glp-preview-modal * {\n max-width: 100%;\n}\n';
// 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/UserSettingsStore.ts
class UserSettingsStore {
constructor() {
__publicField(this, 'settings', {});
__publicField(this, 'store', new Store('gitlab-plus-settings'));
this.load();
}
isActive(name2) {
if (!(name2 in servicesConfig)) {
return false;
}
if (servicesConfig[name2].required) {
return true;
}
if (servicesConfig[name2].experimental) {
return this.getItem(name2, false);
}
return this.getItem(name2, true);
}
setIsActive(name2, value) {
this.setItem(name2, value);
}
getItem(key, defaultValue) {
if (this.settings[key] === void 0) {
return defaultValue;
}
return this.settings[key];
}
load() {
this.settings = this.store.get() || {};
}
persist() {
this.store.set(this.settings);
}
setItem(key, value) {
this.settings[key] = value;
this.persist();
}
}
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/GitlabLoader.tsx
function GitlabLoader({ size = 24 }) {
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,
},
}),
});
}
// 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(
(result, [key, value]) => ({
...result,
[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 User on User {
id
avatarUrl
name
username
webUrl
webPath
__typename
}
`;
const userQuery = `
query workspaceAutocompleteUsersSearch($search: String!, $fullPath: ID!, $isProject: Boolean = true) {
groupWorkspace: group(fullPath: $fullPath) @skip(if: $isProject) {
id
users: autocompleteUsers(search: $search) {
...User
...UserAvailability
__typename
}
__typename
}
workspace: project(fullPath: $fullPath) {
id
users: autocompleteUsers(search: $search) {
...User
...UserAvailability
__typename
}
__typename
}
}
${userFragment}
fragment UserAvailability on User {
status {
availability
__typename
}
__typename
}
`;
// 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
iterationCadence {
id
title
__typename
}
__typename
}`;
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
state
__typename
}
__typename
}
__typename
}
}
${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 Label on Label {
id
title
description
color
textColor
__typename
}
`;
const projectLabelsQuery = `query projectLabels($fullPath: ID!, $searchTerm: String) {
workspace: project(fullPath: $fullPath) {
id
labels(
searchTerm: $searchTerm
includeAncestorGroups: true
) {
nodes {
...Label
__typename
}
__typename
}
__typename
}
}
${labelFragment}
`;
const workspaceLabelsQuery = `query groupLabels($fullPath: ID!, $searchTerm: String) {
workspace: group(fullPath: $fullPath) {
id
labels(
searchTerm: $searchTerm
onlyGroupLabels: true
includeAncestorGroups: true
) {
nodes {
...Label
__typename
}
__typename
}
__typename
}
}
${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 : 0.5
);
}
async getWorkspaceLabels(workspacePath, search = '') {
return this.queryCached(
`workspace-${workspacePath}-labels-${search}`,
workspaceLabelsQuery,
{
fullPath: workspacePath,
searchTerm: search,
},
search === '' ? 20 : 0.5
);
}
}
// 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 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: 20
includeAncestors: true
) {
nodes {
...MilestoneFragment
state
__typename
}
__typename
}
__typename
}
}
fragment MilestoneFragment on Milestone {
id
iid
title
webUrl: webPath
dueDate
expired
__typename
}
`;
// 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) {
group(fullPath: $fullPath) {
id
projects(search: $search, after: $after, first: 100, includeSubgroups: true) {
nodes {
id
name
avatarUrl
fullPath
nameWithNamespace
archived
__typename
}
pageInfo {
...PageInfo
__typename
}
__typename
}
__typename
}
}
fragment PageInfo on PageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
__typename
}
`;
// 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.group.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 result = url.pathname.match(pattern);
if (result && result.groups) {
return result.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
__typename
}
__typename
}
}
fragment WorkItem on WorkItem {
id
iid
archived
title
state
description
confidential
createdAt
closedAt
webUrl
reference(full: true)
createNoteEmail
project {
id
__typename
}
namespace {
id
fullPath
name
fullName
__typename
}
author {
...Author
__typename
}
workItemType {
id
name
iconName
__typename
}
userPermissions {
deleteWorkItem
updateWorkItem
adminParentLink
setWorkItemMetadata
createNote
adminWorkItemLink
markNoteAsInternal
reportSpam
__typename
}
widgets {
...WorkItemWidgets
__typename
}
__typename
}
fragment WorkItemWidgets on WorkItemWidget {
type
... on WorkItemWidgetHierarchy {
hasChildren
children(first: 100) {
count
nodes {
id
iid
title
state
webUrl
}
}
}
... on WorkItemWidgetAssignees {
assignees {
nodes {
...User
}
}
}
... on WorkItemWidgetLabels {
labels {
nodes {
...Label
}
}
}
... on WorkItemWidgetStartAndDueDate {
dueDate
startDate
rollUp
isFixed
__typename
}
... on WorkItemWidgetProgress {
progress
updatedAt
__typename
}
... on WorkItemWidgetIteration {
iteration {
id
title
startDate
dueDate
webUrl
iterationCadence {
id
title
}
__typename
}
__typename
}
... on WorkItemWidgetMilestone {
milestone {
...MilestoneFragment
__typename
}
__typename
}
... on WorkItemWidgetNotes {
discussionLocked
__typename
}
... on WorkItemWidgetHealthStatus {
healthStatus
rolledUpHealthStatus {
count
healthStatus
__typename
}
__typename
}
... on WorkItemWidgetNotifications {
subscribed
__typename
}
... on WorkItemWidgetCurrentUserTodos {
currentUserTodos(state: pending) {
nodes {
id
__typename
}
__typename
}
__typename
}
... on WorkItemWidgetColor {
color
textColor
__typename
}
... on WorkItemWidgetLinkedItems {
linkedItems {
nodes {
linkId
linkType
__typename
}
__typename
}
__typename
}
... on WorkItemWidgetCrmContacts {
contacts {
nodes {
id
email
firstName
lastName
phone
description
organization {
id
name
description
defaultRate
__typename
}
__typename
}
__typename
}
__typename
}
__typename
}
fragment User on User {
id
avatarUrl
name
username
webUrl
webPath
__typename
}
fragment MilestoneFragment on Milestone {
expired
id
title
state
startDate
dueDate
webPath
__typename
}
fragment Author on User {
id
avatarUrl
name
username
webUrl
webPath
__typename
}
${labelFragment}
`;
const epicSetLabelsMutation = `
mutation workItemUpdate($input: WorkItemUpdateInput!) {
workItemUpdate(input: $input) {
workItem {
__typename
}
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
confidential
dueDate
projectId
milestone {
id
title
startDate
dueDate
__typename
}
epic {
id
iid
title
webUrl
}
iteration {
id
title
startDate
dueDate
iterationCadence {
id
title
__typename
}
__typename
}
labels {
nodes {
...Label
}
}
relatedMergeRequests {
nodes {
iid
title
state
webUrl
author {
...User
}
}
}
assignees {
nodes {
...User
}
}
author {
...User
}
weight
type
linkedWorkItems {
nodes {
linkType
workItemState
workItem {
id
iid
webUrl
title
}
}
}
__typename
}
__typename
}
}
${labelFragment}
${userFragment}
`;
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 {
...Label
}
}
}
${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 {
id
iid
title
confidential
project {
fullPath
}
__typename
}
__typename
}
workItemsByIid: workItems(
iid: $iid
types: $types
includeAncestors: $includeAncestors
includeDescendants: $includeDescendants
) @include(if: $searchByIid) {
nodes {
id
iid
title
confidential
project {
fullPath
}
__typename
}
__typename
}
workItemsEmpty: workItems(
types: $types
includeAncestors: $includeAncestors
includeDescendants: $includeDescendants
) @include(if: $searchEmpty) {
nodes {
id
iid
title
confidential
project {
fullPath
}
__typename
}
__typename
}
__typename
}
}
`;
const issueMutation = `
mutation CreateIssue($input: CreateIssueInput!) {
createIssuable: createIssue(input: $input) {
issuable: issue {
...Issue
__typename
}
errors
__typename
}
}
fragment Issue on Issue {
...IssueNode
id
weight
blocked
blockedByCount
epic {
id
__typename
}
iteration {
id
title
startDate
dueDate
iterationCadence {
id
title
__typename
}
__typename
}
healthStatus
__typename
}
fragment IssueNode on Issue {
id
iid
title
referencePath: reference(full: true)
closedAt
dueDate
timeEstimate
totalTimeSpent
humanTimeEstimate
humanTotalTimeSpent
emailsDisabled
confidential
hidden
webUrl
relativePosition
projectId
type
severity
milestone {
...MilestoneFragment
__typename
}
assignees {
nodes {
...User
__typename
}
__typename
}
labels {
nodes {
id
title
color
description
__typename
}
__typename
}
__typename
}
fragment MilestoneFragment on Milestone {
expired
id
state
title
__typename
}
fragment User on User {
id
avatarUrl
name
username
webUrl
webPath
__typename
}
`;
const issueSetEpicMutation = `
mutation projectIssueUpdateParent($input: WorkItemUpdateInput!) {
issuableSetAttribute: workItemUpdate(input: $input) {
workItem {
id
widgets {
... on WorkItemWidgetHierarchy {
type
parent {
id
title
webUrl
}
}
}
}
errors
}
}
`;
const issueSetLabelsMutation = `
mutation issueSetLabels($input: UpdateIssueInput!) {
updateIssuableLabels: updateIssue(input: $input) {
issuable: issue {
__typename
}
errors
__typename
}
}
`;
// 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,
},
0.02
);
}
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.createIssuable.issuable,
parentIssue,
values.relation
);
}
if (parentEpic) {
setMessage('Linking to epic...');
await setIssueEpic(response.data.createIssuable.issuable, 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')
);
}
}
// 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,
});
}
// 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(Row, {
className: 'gl-flex-1',
items: 'center',
justify: 'center',
children: jsx(GitlabLoader, { size: '3em' }),
});
}
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(Row, {
className: 'gl-h-full gl-w-full gl-absolute gl-bg-overlay',
items: 'center',
justify: 'center',
children: jsx(GitlabLoader, { size: '3em' }),
}),
],
});
}, [isLoading, isRefreshing, isError, isVisible, children]);
return jsx('div', {
className: clsx('glp-preview-modal', isVisible && 'glp-modal-visible'),
onMouseEnter: onLinkEnter,
onMouseLeave: onLinkLeave,
ref,
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/HeadingBlock.tsx
function HeadingBlock({
author,
badge,
createdAt,
entityId,
icon,
onRefresh,
title,
}) {
return jsxs('div', {
className: 'glp-block gl-relative',
children: [
jsxs(Row, {
className: '',
items: 'center',
justify: 'between',
children: [
jsx('span', {
className: clsx(
'gl-font-bold gl-leading-20 gl-text-gray-900',
onRefresh && 'gl-pr-5'
),
children: title,
}),
onRefresh &&
jsx('div', {
onClick: onRefresh,
className:
'gl-absolute gl-right-0 gl-top-0 gl-p-2 gl-cursor-pointer',
children: jsx(GitlabIcon, { icon: 'repeat' }),
}),
],
}),
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/InfoBlock.tsx
function InfoBlock({ children, className, icon, rightTitle, title }) {
return jsxs('div', {
class: 'glp-block gl-relative',
children: [
jsxs(Row, {
items: 'center',
justify: 'between',
children: [
jsxs(Row, {
gap: 2,
items: 'center',
children: [
icon && jsx(GitlabIcon, { icon, size: 16 }),
jsx('span', {
className: 'gl-font-bold gl-leading-20 gl-text-gray-900',
dangerouslySetInnerHTML: { __html: title },
}),
],
}),
rightTitle,
],
}),
jsx('div', { class: className, children }),
],
});
}
// 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('Status::'));
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,
'Status::'
);
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/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-block',
'gl-link sortable-link',
className
),
style: {
overflow: 'hidden',
textOverflow: 'ellipsis',
},
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]);
if (!issues.length) {
return null;
}
return jsx(InfoBlock, {
icon: 'issue-type-issue',
title: `Child issues (${issues.length})`,
children: issues.map((issue) =>
jsxs(
Link,
{
href: issue.webUrl,
title: issue.title,
children: ['#', issue.iid, ' ', issue.title],
},
issue.iid
)
),
});
}
// 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 [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;
}
});
}, []);
return {
onClose: () => setSrc(''),
src,
};
}
// apps/gitlab-plus/src/components/image-preview/ImagePreviewModal.tsx
function ImagePreviewModal() {
const { onClose, src } = useImagePreviewModal();
return jsxs('div', {
className: clsx(
'glp-image-preview-modal',
Boolean(src) && 'glp-modal-visible'
),
children: [
jsx('img', { alt: 'Image preview', className: 'glp-modal-img', src }),
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({ assignees, icon, label, pluralIcon, pluralLabel }) {
if (!assignees || !assignees.length) {
return null;
}
if (assignees.length === 1) {
return jsx(InfoBlock, {
className: 'gl-flex gl-flex-col gl-gap-3',
icon: icon || 'user',
rightTitle: jsx(GitlabUser, { user: assignees[0], withLink: true }),
title: `${label}:`,
});
}
return jsx(InfoBlock, {
className: 'gl-flex gl-flex-col gl-gap-3',
icon: pluralIcon || icon || 'users',
title: pluralLabel || `${label}s`,
children: assignees.map((assignee) =>
jsx(GitlabUser, { user: assignee, withLink: true }, assignee.id)
),
});
}
// apps/gitlab-plus/src/components/issue-preview/blocks/IssueAssignee.tsx
function IssueAssignee({ issue }) {
return jsx(UsersBlock, {
assignees: issue.assignees.nodes,
icon: 'assignee',
label: 'Assignee',
});
}
// 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('Status::')
);
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,
'Status::'
);
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 }) {
if (!issue.relatedMergeRequests.nodes.length) {
return null;
}
return jsx(InfoBlock, {
icon: 'merge-request',
title: 'Merge requests',
children: issue.relatedMergeRequests.nodes.map((mr) =>
jsx(GitlabMergeRequest, { mr }, mr.iid)
),
});
}
// 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]);
if (!issue.linkedWorkItems.nodes.length) {
return null;
}
return jsx(InfoBlock, {
title: '',
children: groups.map(([key, issues]) =>
jsxs(
'div',
{
style: { marginTop: 10 },
children: [
jsx('div', {
class: 'item-title gl-flex gl-min-w-0 gl-gap-3',
children: jsx('span', { children: relationMap[key] }),
}),
issues.map((issue2) =>
jsxs(
Link,
{
href: issue2.workItem.webUrl,
blockHover: true,
children: [
'#',
issue2.workItem.iid,
' ',
issue2.workItem.title,
],
},
issue2.workItem.iid
)
),
],
},
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
);
console.log(response);
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, {
assignees: mr.approvedBy.nodes,
label: 'Approved by',
pluralLabel: 'Approved by',
});
}
// apps/gitlab-plus/src/components/mr-preview/blocks/MrAssignee.tsx
function MrAssignee({ mr }) {
return jsx(UsersBlock, {
assignees: mr.assignees.nodes,
icon: 'assignee',
label: 'Assignee',
});
}
// 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, {
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 {
...User
}
}
approvedBy {
nodes {
...User
}
}
author {
...User
}
project {
webUrl
path
fullPath
}
commitCount
conflicts
createdAt
title
titleHtml
diffStatsSummary {
additions
changes
deletions
fileCount
}
draft
labels {
nodes {
...Label
}
}
mergeable
resolvedDiscussionsCount
resolvableDiscussionsCount
reviewers {
nodes {
...User
}
}
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/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]);
return {
services,
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 { services, setServiceState } = useUserSettingsModal();
return jsx(GlpModal, {
isVisible,
onClose,
title: jsxs(Fragment, {
children: [
jsx('span', { style: { color: '#e24329' }, children: 'Gitlab Plus' }),
' settings',
],
}),
children: jsx(Column, {
className: 'gl-p-4',
gap: 2,
children: 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();
}
});