// ==UserScript== // @name Gitlab plus // @namespace https://lukaszmical.pl/ // @version 2024-12-30 // @description Gitlab utils // @author Łukasz Micał // @match https://gitlab.com/* // @require https://cdn.jsdelivr.net/combine/npm/preact@10.25.4/dist/preact.min.umd.min.js,npm/preact@10.25.4/hooks/dist/hooks.umd.min.js,npm/preact@10.25.4/jsx-runtime/dist/jsxRuntime.umd.min.js // @icon https://www.google.com/s2/favicons?sz=64&gitlab.com // @downloadURL none // ==/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, useRef, useEffect, useCallback } = 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-create-related-issue-layer {\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-create-related-issue-layer.glp-modal-visible {\n display: flex;\n}\n\n.glp-create-related-issue-layer .glp-create-related-issue-modal {\n width: 700px;\n max-width: 95vw;\n}\n\n.gl-new-dropdown-item .glp-item-check {\n opacity: 0;\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.gl-new-dropdown-item.glp-selected .glp-item-check {\n opacity: 1;\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-issue-preview-modal {\n position: fixed;\n display: flex;\n padding: 0 15px;\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: 300px;\n min-height: 300px;\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-issue-preview-modal.glp-modal-visible {\n visibility: visible;\n opacity: 1;\n}\n\n.glp-issue-preview-modal .glp-issue-modal-inner {\n display: flex;\n flex-direction: column;\n max-width: 100%;\n}\n\n.glp-issue-preview-modal .glp-block {\n padding: .5rem 0 .5rem;\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.glp-issue-preview-modal .assignee-grid {\n margin-top: 4px;\n gap: 4px\n}\n\n\n.glp-issue-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/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/GitlabLoader.tsx function GitlabLoader({ size = 24 }) { return jsx('span', { role: 'status', class: 'gl-spinner-container', children: jsx('span', { style: { width: size, height: size, }, class: 'gl-spinner gl-spinner-sm gl-spinner-dark !gl-align-text-bottom', }), }); } // apps/gitlab-plus/src/components/common/GitlabUser.tsx function GitlabUser({ showUsername, size = 24, user, withLink }) { const label = useMemo(() => { return jsxs(Fragment, { children: [ jsx('span', { class: 'gl-mr-2 gl-block', children: user.name }), showUsername && jsx('span', { class: 'gl-block gl-text-secondary !gl-text-sm', children: user.username, }), ], }); }, [withLink, showUsername, user]); return jsxs('div', { class: 'gl-flex gl-w-full gl-items-center', children: [ user.avatarUrl ? jsx('img', { alt: `${user.name}'s avatar`, class: `gl-mr-3 gl-avatar gl-avatar-circle gl-avatar-s${size}`, src: user.avatarUrl, }) : jsx('div', { class: `gl-mr-3 gl-avatar gl-avatar-identicon gl-avatar-s${size} 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/issue-preview/blocks/IssueBlock.tsx function IssueBlock({ children, className, tile }) { return jsxs('div', { class: 'glp-block', children: [ jsx('div', { class: 'gl-flex gl-items-center gl-font-bold gl-leading-20 gl-text-gray-900', children: tile, }), jsx('div', { class: className, children }), ], }); } // apps/gitlab-plus/src/components/issue-preview/blocks/IssueAssignee.tsx function IssueAssignee({ issue }) { if (!issue.assignees.nodes.length) { return null; } return jsx(IssueBlock, { className: 'gl-flex gl-flex-col gl-gap-3', tile: 'Assignee', children: issue.assignees.nodes.map((assignee) => jsx(GitlabUser, { withLink: true, user: assignee }, assignee.id) ), }); } // 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 }) { return jsx('svg', { className: clsx('gl-icon gl-fill-current', `s${size}`, className), children: jsx('use', { href: iconUrl(icon) }), }); } // apps/gitlab-plus/src/components/common/IssueStatus.tsx function IssueStatus({ isOpen }) { return jsxs('span', { class: clsx( 'gl-badge badge badge-pill', isOpen ? 'badge-success' : 'badge-info' ), children: [ jsx(GitlabIcon, { icon: isOpen ? 'issue-open-m' : 'issue-close', size: 16, }), jsx('span', { class: 'gl-badge-content', children: isOpen ? 'Open' : 'Closed', }), ], }); } // apps/gitlab-plus/src/components/issue-preview/blocks/IssueHeading.tsx function IssueHeader({ issue }) { return jsx(IssueBlock, { tile: issue.title, children: jsx('div', { children: jsxs('div', { class: 'gl-flex', children: [ jsx(GitlabIcon, { icon: 'issue-type-issue', className: 'gl-mr-2', size: 16, }), jsxs('span', { class: 'gl-text-sm gl-text-secondary gl-mr-4', children: ['#', issue.iid], }), jsx(IssueStatus, { isOpen: issue.state === 'opened' }), ], }), }), }); } // 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 jsxs(IssueBlock, { tile: 'Iteration', children: [ jsx(GitlabIcon, { icon: 'iteration', className: 'gl-mr-2', size: 16 }), jsx('span', { children: label }), ], }); } // 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', { class: 'btn gl-label-close !gl-p-0 btn-reset btn-sm gl-button btn-reset-tertiary', onClick: onRemove, type: 'button', children: jsx('span', { class: 'gl-button-text', children: jsx(GitlabIcon, { icon: 'close-xs' }), }), }), ], }); } // apps/gitlab-plus/src/components/issue-preview/blocks/IssueLabels.tsx function IssueLabels({ issue }) { if (!issue.labels.nodes.length) { return null; } return jsx(IssueBlock, { className: 'issuable-show-labels', tile: 'Labels', children: issue.labels.nodes.map((label) => jsx(GitlabLabel, { label }, label.id) ), }); } // apps/gitlab-plus/src/components/common/GitlabMergeRequest.tsx const iconMap = { closed: 'merge-request-close', locked: 'search', merged: 'merge', opened: 'merge-request', }; function GitlabMergeRequest({ mr }) { return jsxs('div', { style: { marginTop: 10 }, children: [ jsxs('div', { class: 'item-title gl-flex gl-min-w-0 gl-gap-3', children: [ jsx(GitlabIcon, { icon: iconMap[mr.state] || 'empty', className: 'merge-request-status', size: 16, }), jsxs('span', { class: 'gl-text-gray-500', children: ['!', mr.iid], }), jsx(GitlabUser, { withLink: true, size: 16, user: mr.author }), ], }), jsx('a', { style: { overflow: 'hidden', textOverflow: 'ellipsis', }, class: 'gl-block gl-link sortable-link', href: mr.webUrl, 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(IssueBlock, { tile: '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 jsxs(IssueBlock, { tile: 'Milestone', children: [ jsx(GitlabIcon, { icon: 'milestone', className: 'gl-mr-2', size: 16 }), jsx('span', { children: issue.milestone.title }), ], }); } // 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({ isLoading, relatedIssues }) { const groups = useMemo(() => { const initValue = { blocks: [], is_blocked_by: [], relates_to: [], }; return Object.entries( relatedIssues.reduce( (acc, issue) => ({ ...acc, [issue.linkType]: [...acc[issue.linkType], issue], }), initValue ) ).filter(([_, issues]) => issues.length); }, [relatedIssues]); const onHover = (e) => { e.stopPropagation(); e.preventDefault(); return false; }; if (isLoading) { return jsx('div', { className: 'gl-flex gl-items-center gl-justify-center', children: jsx(GitlabLoader, {}), }); } if (!relatedIssues.length) { return null; } return jsx(IssueBlock, { tile: '', 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((issue) => jsxs( 'a', { style: { overflow: 'hidden', textOverflow: 'ellipsis', }, onMouseOver: onHover, class: 'gl-block gl-link sortable-link', href: issue.webUrl, children: ['#', issue.iid, ' ', issue.title], }, issue.iid ) ), ], }, key ) ), }); } // apps/gitlab-plus/src/components/issue-preview/IssueModalContent.tsx function IssueModalContent({ issue, issueLoading, relatedIssues, relatedIssuesLoading, }) { if (issueLoading) { return jsx('div', { class: 'gl-flex gl-flex-1 gl-items-center gl-justify-center', children: jsx(GitlabLoader, { size: '3em' }), }); } if (!issue) { return jsx('div', { class: 'gl-flex gl-flex-1 gl-items-center gl-justify-center', children: jsx('span', { children: 'Error' }), }); } return jsxs('div', { class: 'gl-flex gl-w-full gl-flex-col', children: [ jsx(IssueHeader, { issue }), jsx(IssueAssignee, { issue }), jsx(IssueLabels, { issue }), jsx(IssueMilestone, { issue }), jsx(IssueIteration, { issue }), jsx(IssueMergeRequests, { issue }), jsx(IssueRelatedIssue, { isLoading: relatedIssuesLoading, relatedIssues, }), ], }); } // libs/share/src/utils/delay.ts async function delay(ms = 1e3) { return await new Promise((resolve) => { setTimeout(resolve, ms); }); } // 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; } } // 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() { __publicField(this, 'cache', new Cache('glp-')); __publicField(this, 'url', 'https://gitlab.com/api/v4/'); __publicField(this, 'graphqlApi', 'https://gitlab.com/api/graphql'); } async get(path) { const response = await fetch(`${this.url}${path}`, { method: 'GET', headers: this.headers(), }); const data = await response.json(); return camelizeKeys(data); } async post(path, body) { const response = await fetch(`${this.url}${path}`, { method: 'POST', body: JSON.stringify(body), headers: this.headers(), }); const data = await response.json(); return camelizeKeys(data); } async query(query, variables) { const response = await fetch(this.graphqlApi, { method: 'POST', body: JSON.stringify({ variables, query }), headers: this.headers(), }); return response.json(); } async queryCached(key, query, variables, minutes) { return this.cached(key, () => this.query(query, variables), minutes); } async getCached(key, path, minutes) { return this.cached(key, () => this.get(path), minutes); } async cached(key, getValue, minutes) { const cacheValue = this.cache.get(key); if (cacheValue) { 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 ''; } headers() { const headers = { 'content-type': 'application/json', }; const csrf = this.csrf(); if (csrf) { headers['X-CSRF-Token'] = csrf; } return headers; } } // apps/gitlab-plus/src/providers/query/label.ts const labelFragment = ` fragment Label on Label { id title description color textColor __typename } `; const labelsQuery = `query projectLabels($fullPath: ID!, $searchTerm: String) { workspace: project(fullPath: $fullPath) { id labels(searchTerm: $searchTerm, includeAncestorGroups: true) { nodes { ...Label __typename } __typename } __typename } } ${labelFragment} `; // 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/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 milestone { id title startDate dueDate __typename } iteration { id title startDate dueDate iterationCadence { id title __typename } __typename } labels { nodes { ...Label } } relatedMergeRequests { nodes { iid title state webUrl author { ...User } } } assignees { nodes { ...User } } weight type __typename } __typename } } ${labelFragment} ${userFragment} `; 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 } `; // apps/gitlab-plus/src/providers/IssueProvider.ts class IssueProvider extends GitlabProvider { async getIssue(projectId, issueId) { return this.queryCached( `issue-${projectId}-${issueId}`, issueQuery, { projectPath: projectId, iid: issueId, }, 2 ); } async getIssues(projectId, search) { const searchById = !!search.match(/^\d+$/); return await this.query(issuesQuery, { iid: searchById ? search : null, searchByIid: searchById, searchEmpty: !search, searchByText: Boolean(search), fullPath: projectId, searchTerm: search, includeAncestors: true, includeDescendants: true, types: ['ISSUE'], in: 'TITLE', }); } 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 getIssueLinks(projectId, issueId) { const path = 'projects/:PROJECT_ID/issues/:ISSUE_ID/links' .replace(':PROJECT_ID', `${projectId}`) .replace(':ISSUE_ID', `${issueId}`); return await this.getCached(`issue-${projectId}-${issueId}-links`, path, 2); } } // apps/gitlab-plus/src/components/issue-preview/useFetchIssue.ts const initialIssueData = { isLoading: false, issue: null, }; const initialRelatedIssuesData = { isLoading: false, issues: [], }; const issueProvider = new IssueProvider(); function useFetchIssue() { const [issue, setIssue] = useState(initialIssueData); const [relatedIssues, setRelatedIssues] = useState(initialRelatedIssuesData); const fetch2 = async (link) => { setIssue({ ...initialIssueData, isLoading: true }); setRelatedIssues({ ...initialRelatedIssuesData, isLoading: true }); const response = await issueProvider.getIssue(link.projectPath, link.issue); setIssue({ isLoading: false, issue: response.data.project.issue, }); const relatedIssues2 = await issueProvider.getIssueLinks( response.data.project.id.replace(/\D/g, ''), response.data.project.issue.iid ); setRelatedIssues({ isLoading: false, issues: relatedIssues2, }); }; const reset = () => { setIssue(initialIssueData); setRelatedIssues(initialRelatedIssuesData); }; return { fetch: fetch2, issue, relatedIssues, reset, }; } // 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.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/helpers/IssueLink.ts class IssueLink { static parseLink(link) { if (!IssueLink.validateLink(link)) { return void 0; } const [projectPath, issue] = new URL(link).pathname .replace(/^\//, '') .split('/-/issues/'); const slashCount = (projectPath.match(/\//g) || []).length; const workspacePath = slashCount === 1 ? projectPath : projectPath.replace(/\/[^/]+$/, ''); return { issue: issue.replace(/\D/g, ''), projectPath, workspacePath, }; } static validateLink(link) { return Boolean(typeof link === 'string' && link.includes('/-/issues/')); } } // apps/gitlab-plus/src/components/issue-preview/useOnIssueHover.ts function useOnIssueHover() { const [hoverLink, setHoverLink] = useState(); const hoverIssueRef = useRef(false); const [hoverPosition, setHoverPosition] = useState({ x: 0, y: 0 }); const onHover = (event) => { const anchor = event.target; const link = IssueLink.parseLink(anchor.href); if (!link) { return; } anchor.title = ''; setHoverLink(link); setHoverPosition({ x: event.clientX, y: event.clientY, }); }; useEffect(() => { Events.intendHover( (element) => IssueLink.validateLink(element.href), onHover, () => { setTimeout(() => { if (!hoverIssueRef.current) { setHoverLink(void 0); } }, 50); } ); }, []); return { hoverLink, hoverPosition, onIssueEnter: () => (hoverIssueRef.current = true), onIssueLeave: () => { hoverIssueRef.current = false; setHoverLink(void 0); }, }; } // apps/gitlab-plus/src/components/issue-preview/useIssuePreviewModal.ts function useIssuePreviewModal() { const ref = useRef(null); const [isVisible, setIsVisible] = useState(false); const [offset, setOffset] = useState(0); const { hoverLink, hoverPosition, onIssueEnter, onIssueLeave } = useOnIssueHover(); const { fetch: fetch2, issue, relatedIssues, reset } = useFetchIssue(); const fetchData = async (link) => { await fetch2(link); await delay(300); const rect = ref.current.getBoundingClientRect(); const dY = rect.height + rect.top - window.innerHeight; if (dY > 0) { setOffset(dY + 15); } }; useEffect(() => { if (hoverLink) { fetchData(hoverLink); setIsVisible(true); } else { setIsVisible(false); reset(); setOffset(0); } }, [hoverLink]); return { issue, isVisible, onIssueEnter, onIssueLeave, position: { ...hoverPosition, offset, }, ref, relatedIssues, }; } // apps/gitlab-plus/src/components/issue-preview/IssuePreviewModal.tsx function IssuePreviewModal() { const { issue, isVisible, onIssueEnter, onIssueLeave, position, ref, relatedIssues, } = useIssuePreviewModal(); return jsx('div', { style: { left: position.x, top: position.y, transform: `translateY(-${position.offset}px)`, }, onMouseEnter: onIssueEnter, onMouseLeave: onIssueLeave, class: clsx('glp-issue-preview-modal', isVisible && 'glp-modal-visible'), ref, children: jsx(IssueModalContent, { issueLoading: issue.isLoading, relatedIssuesLoading: relatedIssues.isLoading, issue: issue.issue, relatedIssues: relatedIssues.issues, }), }); } // apps/gitlab-plus/src/types/Service.ts class Service { root(className, parent) { const root = document.createElement('div'); root.classList.add(className); if (parent) { parent.append(root); } return root; } rootBody(className) { return this.root(className, document.body); } } // apps/gitlab-plus/src/services/IssuePreview.tsx class IssuePreview extends Service { init() { render(jsx(IssuePreviewModal, {}), this.rootBody('glp-issue-preview-root')); } } // apps/gitlab-plus/src/components/image-preview/useImagePreviewModal.ts function useImagePreviewModal() { const [data, setData] = useState(false); 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; } }); }, []); useEffect(() => { setData(true); }, [data]); 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', { onClick: onClose, className: 'glp-modal-close', children: jsx(GitlabIcon, { icon: 'close-xs', size: 24 }), }), ], }); } // apps/gitlab-plus/src/services/ImagePreview.tsx class ImagePreview extends Service { init() { render(jsx(ImagePreviewModal, {}), this.rootBody('glp-image-preview-root')); } } // apps/gitlab-plus/src/components/create-related-issue/event.ts const showModalEventName = 'glp-show-create-issue-modal'; const ShowModalEvent = new CustomEvent(showModalEventName); // apps/gitlab-plus/src/components/create-related-issue/CreateIssueButton.tsx function CreateIssueButton() { const onClick = () => { document.dispatchEvent(ShowModalEvent); }; return jsx('button', { onClick, class: 'btn btn-default btn-sm gl-button', type: 'button', children: jsx('span', { class: 'gl-button-text', children: 'Create related issue', }), }); } // apps/gitlab-plus/src/components/common/CloseButton.tsx function CloseButton({ onClick, title = 'Close' }) { return jsx('button', { class: 'btn js-issue-item-remove-button gl-mr-2 btn-default btn-sm gl-button btn-default-tertiary btn-icon', onClick, title, children: jsx(GitlabIcon, { icon: 'close-xs', size: 16 }), }); } // 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 }); } // 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, 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-md btn-block gl-button gl-new-dropdown-toggle', onClick: (e) => { e.preventDefault(); setIsOpen(true); }, ref, type: 'button', 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({ isActive, onClick, option, removeFromRecent, renderOption, selected, }) { const selectedIds = selected.map((i) => i.id); const selectedClass = (id) => selectedIds.includes(id) ? 'glp-selected' : ''; return jsx('li', { class: clsx( 'gl-new-dropdown-item', selectedClass(option.id), isActive && 'glp-active' ), onClick: () => onClick(option), children: jsxs('span', { class: 'gl-new-dropdown-item-content', children: [ jsx(GitlabIcon, { icon: 'mobile-issue-close', className: 'glp-item-check gl-pr-2', size: 16, }), renderOption(option), removeFromRecent && jsx(CloseButton, { onClick: (e) => { e.preventDefault(); e.stopPropagation(); removeFromRecent(option); }, title: 'Remove from recently used', }), ], }), }); } // apps/gitlab-plus/src/components/common/form/autocomplete/AsyncAutocompleteList.tsx function AsyncAutocompleteList({ activeIndex, onClick, options, recently, removeRecently, renderOption, value, }) { return jsx('div', { 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%', }, onClick: (e) => e.stopPropagation(), 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, { onClick, option: item, removeFromRecent: removeRecently, renderOption, isActive: index === activeIndex, 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, { onClick, option: item, renderOption, isActive: recently.length + index === activeIndex, 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, { icon: 'search', className: 'gl-search-box-by-type-search-icon', size: 16, }), jsx('input', { autofocus: true, onInput: (e) => setValue(e.target.value), onKeyDown: (e) => navigate(e.key), class: 'gl-listbox-search-input', value, }), 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({ onClick, onClose, options, recently = [], removeRecently, renderOption, searchTerm, setSearchTerm, value, }) { const { activeIndex, navigate } = useListNavigate( options, recently, onClick, onClose ); return jsx('div', { style: { maxWidth: '800px', width: '100%', left: '0', top: '100%', }, onClick: (e) => e.stopPropagation(), class: clsx('gl-new-dropdown-panel gl-absolute !gl-block'), children: jsxs('div', { class: 'gl-new-dropdown-inner', children: [ jsx(AsyncAutocompleteSearch, { navigate, setValue: setSearchTerm, value: searchTerm, }), jsx(AsyncAutocompleteList, { onClick, options, removeRecently, renderOption, activeIndex, recently, value, }), ], }), }); } // libs/share/src/utils/useDebounce.ts function useDebounce(value, delay2 = 300) { const [debouncedValue, setDebouncedValue] = useState(value); useEffect(() => { const handler = setTimeout(() => { setDebouncedValue(value); }, delay2); return () => { clearTimeout(handler); }; }, [value, delay2]); 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); }, []); useEffect(() => { loadOptions(term); }, [term]); 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(name) { const store = useRef(new RecentlyProvider(name)); 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(name, value, getValues, onChange, isMultiselect) { const [searchTerm, setSearchTerm] = useState(''); const [isOpen, setIsOpen] = useState(false); const { recently: allRecently, remove: removeRecently } = useAsyncAutocompleteRecently(name); 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({ getValues, isMultiselect = false, name, onChange, renderLabel, renderOption, value, }) { const { isOpen, onClick, options, recently, removeRecently, searchTerm, setIsOpen, setSearchTerm, } = useAsyncAutocomplete(name, value, getValues, onChange, isMultiselect); return jsxs('div', { class: 'gl-relative gl-w-full gl-new-dropdown !gl-block', children: [ jsx(AsyncAutocompleteButton, { isOpen, renderLabel, reset: () => onChange([]), setIsOpen, value, }), isOpen && jsx(AsyncAutocompleteDropdown, { onClick, onClose: () => setIsOpen(false), options, removeRecently, renderOption, recently, searchTerm, setSearchTerm, value, }), ], }); } // apps/gitlab-plus/src/components/create-related-issue/fields/AssigneesField.tsx function AssigneesField({ link, setValue, value }) { const getUsers = useCallback( async (search) => { const response = await new UsersProvider().getUsers( link.projectPath, search ); return response.data.workspace.users; }, [link] ); 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, { showUsername: true, user: item }), }); }, []); return jsx(AsyncAutocomplete, { isMultiselect: true, onChange: setValue, renderOption, getValues: getUsers, name: 'assignees', renderLabel, value, }); } // apps/gitlab-plus/src/components/create-related-issue/fields/ButtonField.tsx function ButtonField({ create, isLoading, reset }) { return jsxs(Fragment, { children: [ jsxs('button', { onClick: create, class: 'btn btn-confirm btn-sm gl-button gl-gap-2', disabled: isLoading, type: 'button', children: [ jsx('span', { class: 'gl-button-text', children: 'Add' }), isLoading ? jsx(GitlabLoader, { size: 12 }) : jsx(GitlabIcon, { icon: 'plus', size: 12 }), ], }), jsx('button', { onClick: reset, class: 'btn btn-sm gl-button', 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} `, iterationQuery, { fullPath: projectId, title, state: 'opened', }, title !== '' ? 0.5 : 20 ); } } // apps/gitlab-plus/src/components/create-related-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, { onChange: setValue, renderOption, getValues: getUsers, name: 'iterations', renderLabel, value, }); } // apps/gitlab-plus/src/providers/LabelsProvider.ts class LabelsProvider extends GitlabProvider { async getLabels(projectId, search = '') { return this.queryCached( `labels-${projectId}-${search}`, labelsQuery, { fullPath: projectId, searchTerm: search, }, search === '' ? 20 : 0.5 ); } } // apps/gitlab-plus/src/components/create-related-issue/fields/LabelsField.tsx function LabelField({ link, setValue, value }) { const getLabels = useCallback( async (search) => { const response = await new LabelsProvider().getLabels( link.projectPath, search ); return response.data.workspace.labels.nodes; }, [link] ); 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, { onRemove: () => setValue(value.filter((item) => label.id !== item.id)), label, }, label.id ) ), }), jsx(AsyncAutocomplete, { isMultiselect: true, onChange: setValue, renderOption, getValues: getLabels, name: 'labels', renderLabel, value, }), ], }); } // 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-related-issue/fields/MilestoneField.tsx function MilestoneField({ link, setValue, value }) { const getMilestones = useCallback( async (search) => { const response = await new MilestonesProvider().getMilestones( link.projectPath, search ); return response.data.workspace.attributes.nodes; }, [link] ); 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, { onChange: setValue, renderOption, getValues: getMilestones, name: 'milestones', renderLabel, 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-related-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, { onChange: setValue, renderOption, getValues: getProjects, name: 'projects', renderLabel, value, }); } // apps/gitlab-plus/src/types/Issue.ts const issueRelation = ['blocks', 'is_blocked_by', 'relates_to']; // apps/gitlab-plus/src/components/create-related-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}`, onChange: () => setValue(relation), checked: value === relation, class: 'custom-control-input', name: 'linked-issue-type-radio', type: 'radio', value: relation ?? '', }), jsx('label', { for: `create-related-issue-relation-${relation}`, class: 'custom-control-label', children: labels(relation), }), ], }, relation ) ), }); } // apps/gitlab-plus/src/components/create-related-issue/fields/TitleField.tsx function TitleField({ error, onChange, value }) { return jsx('input', { class: clsx( 'gl-form-input form-control', error && 'gl-field-error-outline' ), onInput: (e) => onChange(e.target.value), placeholder: 'Add a title', value, }); } // apps/gitlab-plus/src/components/create-related-issue/useCreateRelatedIssueForm.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 useCreateRelatedIssueForm(link, onClose, isVisible) { const [values, setValues] = useState(initialState()); const [errors, setErrors] = useState(initialError()); const [isLoading, setIsLoading] = useState(false); const [message, setMessage] = useState(''); const [error, setError] = useState(''); const reset = () => { setIsLoading(false); setValues(initialState()); setErrors(initialError()); }; useEffect(() => { if (!isVisible) { reset(); } }, [isVisible]); 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, relation) => { await new IssueProvider().createIssueRelation({ targetIssueIid: link.issue, issueId: issue.iid, linkType: relation, projectId: issue.projectId, targetProjectId: link.projectPath.replace(/\//g, '%2F'), }); }; const submit = async () => { setIsLoading(true); if (!validate()) { setIsLoading(false); return; } try { const payload = createPayload(); const response = await createIssue(payload); persistRecently(); if (values.relation) { await createRelation( response.data.createIssuable.issuable, values.relation ); } } catch (e) { setError(e.message); } setIsLoading(false); setMessage('Issue was created'); window.setTimeout(() => onClose(), 3e3); }; 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: { 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: { errors: errors.title, onChange: (title) => setValues({ ...values, title }), value: values.title, }, }, isLoading, message, }; } // apps/gitlab-plus/src/components/create-related-issue/CreateRelatedIssueModalContent.tsx function CreateRelatedIssueModalContent({ isVisible, link, onClose }) { const { actions, error, form, isLoading, message } = useCreateRelatedIssueForm(link, onClose, isVisible); 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: jsx(TitleField, { error: form.title.errors, onChange: form.title.onChange, value: form.title.value, }), }), 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, { link, 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, { link, setValue: form.milestone.onChange, value: form.milestone.value, }), }), ], }), jsx(FormField, { error: form.labels.errors, title: 'Labels', children: jsx(LabelField, { link, setValue: form.labels.onChange, value: form.labels.value, }), }), jsx(FormField, { error: form.relation.errors, title: 'New issue', children: jsx(RelationField, { setValue: form.relation.onChange, value: form.relation.value, }), }), jsx(FormField, { error, hint: message, title: '', children: jsx(FormRow, { children: jsx(ButtonField, { isLoading, create: actions.submit, reset: actions.reset, }), }), }), ], }); } // apps/gitlab-plus/src/components/create-related-issue/CreateRelatedIssueModal.tsx function CreateRelatedIssueModal({ link }) { const [isVisible, setIsVisible] = useState(false); useEffect(() => { document.addEventListener(showModalEventName, () => setIsVisible(true)); }, []); return jsx('div', { class: clsx( 'glp-create-related-issue-layer', isVisible && 'glp-modal-visible' ), children: jsxs('div', { className: clsx( 'glp-create-related-issue-modal 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: 'Create related issue', }), jsx(CloseButton, { onClick: () => setIsVisible(false) }), ], }), jsx(CreateRelatedIssueModalContent, { onClose: () => setIsVisible(false), isVisible, link, }), ], }), }); } // apps/gitlab-plus/src/services/CreateRelatedIssue.tsx class CreateRelatedIssue extends Service { constructor() { super(); __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 = IssueLink.parseLink(window.location.href); const parent = document.querySelector( '#related-issues [data-testid="crud-actions"]' ); if (!link || !parent) { return; } this.isMounted = true; render( jsx(CreateIssueButton, {}), this.root('glp-related-issue-button', parent) ); render( jsx(CreateRelatedIssueModal, { link }), this.rootBody('glp-related-issue-modal') ); } } // apps/gitlab-plus/src/components/related-issue-autocomplete/useRelatedIssuesAutocompleteModal.ts function useRelatedIssuesAutocompleteModal(link, input) { const [searchTerm, setSearchTerm] = useState(''); const [isVisible, setIsVisible] = useState(false); const options = useAsyncAutocompleteOptions(searchTerm, 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 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); return isVisible ? jsx('div', { class: 'gl-relative gl-w-full gl-new-dropdown !gl-block', children: jsx(AsyncAutocompleteDropdown, { onClick: onSelect, onClose, options, 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, }), ], }), searchTerm, setSearchTerm, value: [], }), }) : null; } // apps/gitlab-plus/src/services/RelatedIssueAutocomplete.tsx class RelatedIssueAutocomplete extends Service { constructor() { super(); __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 = IssueLink.parseLink(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/ClearCacheService.ts class ClearCacheService extends Service { constructor() { super(); __publicField(this, 'cache', new Cache('glp-')); } init() { this.cache.clearInvalid(); window.setInterval(this.cache.clearInvalid.bind(this.cache), 60 * 1e3); } } // 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(([name, callback]) => { element.addEventListener(name, callback); }); } } static applyStyles(element, styles) { if (styles) { Object.entries(styles).forEach(([key, value]) => { const name = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); element.style.setProperty(name, 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 Service { 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/main.ts [ ClearCacheService, ImagePreview, IssuePreview, CreateRelatedIssue, RelatedIssueAutocomplete, SortIssue, ].forEach((Service2) => new Service2().init());