{{template "repo/header" .}}
diff --git a/templates/swagger/v1_json.tmpl b/templates/swagger/v1_json.tmpl index 01b12460ac11f..8fed15b516fbc 100644 --- a/templates/swagger/v1_json.tmpl +++ b/templates/swagger/v1_json.tmpl @@ -5095,7 +5095,7 @@ "tags": [ "repository" ], - "summary": "Add a collaborator to a repository", + "summary": "Add or Update a collaborator to a repository", "operationId": "repoAddCollaborator", "parameters": [ { @@ -20095,6 +20095,20 @@ "format": "int64", "x-go-name": "Milestone" }, + "reviewers": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "Reviewers" + }, + "team_reviewers": { + "type": "array", + "items": { + "type": "string" + }, + "x-go-name": "TeamReviewers" + }, "title": { "type": "string", "x-go-name": "Title" diff --git a/tests/integration/actions_trigger_test.go b/tests/integration/actions_trigger_test.go index c254c90958314..4718aa7e7352c 100644 --- a/tests/integration/actions_trigger_test.go +++ b/tests/integration/actions_trigger_test.go @@ -145,7 +145,8 @@ func TestPullRequestTargetEvent(t *testing.T) { BaseRepo: baseRepo, Type: issues_model.PullRequestGitea, } - err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil) + prOpts := &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest} + err = pull_service.NewPullRequest(git.DefaultContext, prOpts) assert.NoError(t, err) // load and compare ActionRun @@ -199,7 +200,8 @@ func TestPullRequestTargetEvent(t *testing.T) { BaseRepo: baseRepo, Type: issues_model.PullRequestGitea, } - err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil) + prOpts = &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest} + err = pull_service.NewPullRequest(git.DefaultContext, prOpts) assert.NoError(t, err) // the new pull request cannot trigger actions, so there is still only 1 record diff --git a/tests/integration/api_pull_review_test.go b/tests/integration/api_pull_review_test.go index cadb0765c341e..ba6b62d0d7013 100644 --- a/tests/integration/api_pull_review_test.go +++ b/tests/integration/api_pull_review_test.go @@ -11,6 +11,7 @@ import ( auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" issues_model "code.gitea.io/gitea/models/issues" + access_model "code.gitea.io/gitea/models/perm/access" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" @@ -422,7 +423,9 @@ func TestAPIPullReviewStayDismissed(t *testing.T) { pullIssue.ID, user8.ID, 1, 1, 2, false) // user8 dismiss review - _, err = issue_service.ReviewRequest(db.DefaultContext, pullIssue, user8, user8, false) + permUser8, err := access_model.GetUserRepoPermission(db.DefaultContext, pullIssue.Repo, user8) + assert.NoError(t, err) + _, err = issue_service.ReviewRequest(db.DefaultContext, pullIssue, user8, &permUser8, user8, false) assert.NoError(t, err) reviewsCountCheck(t, diff --git a/tests/integration/pull_merge_test.go b/tests/integration/pull_merge_test.go index 43210e852e53b..2351e169bc520 100644 --- a/tests/integration/pull_merge_test.go +++ b/tests/integration/pull_merge_test.go @@ -520,7 +520,8 @@ func TestConflictChecking(t *testing.T) { BaseRepo: baseRepo, Type: issues_model.PullRequestGitea, } - err = pull.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil) + prOpts := &pull.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest} + err = pull.NewPullRequest(git.DefaultContext, prOpts) assert.NoError(t, err) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "PR with conflict!"}) diff --git a/tests/integration/pull_update_test.go b/tests/integration/pull_update_test.go index 5ae241f3af720..dfe47c1053123 100644 --- a/tests/integration/pull_update_test.go +++ b/tests/integration/pull_update_test.go @@ -173,7 +173,8 @@ func createOutdatedPR(t *testing.T, actor, forkOrg *user_model.User) *issues_mod BaseRepo: baseRepo, Type: issues_model.PullRequestGitea, } - err = pull_service.NewPullRequest(git.DefaultContext, baseRepo, pullIssue, nil, nil, pullRequest, nil) + prOpts := &pull_service.NewPullRequestOptions{Repo: baseRepo, Issue: pullIssue, PullRequest: pullRequest} + err = pull_service.NewPullRequest(git.DefaultContext, prOpts) assert.NoError(t, err) issue := unittest.AssertExistsAndLoadBean(t, &issues_model.Issue{Title: "Test Pull -to-update-"}) diff --git a/tests/integration/repo_tag_test.go b/tests/integration/repo_tag_test.go index d649f041ccf57..0cd49ee4cd3d3 100644 --- a/tests/integration/repo_tag_test.go +++ b/tests/integration/repo_tag_test.go @@ -4,17 +4,20 @@ package integration import ( + "fmt" "net/http" "net/url" "testing" "code.gitea.io/gitea/models" + auth_model "code.gitea.io/gitea/models/auth" "code.gitea.io/gitea/models/db" git_model "code.gitea.io/gitea/models/git" repo_model "code.gitea.io/gitea/models/repo" "code.gitea.io/gitea/models/unittest" user_model "code.gitea.io/gitea/models/user" "code.gitea.io/gitea/modules/git" + api "code.gitea.io/gitea/modules/structs" "code.gitea.io/gitea/services/release" "code.gitea.io/gitea/tests" @@ -117,3 +120,47 @@ func TestCreateNewTagProtected(t *testing.T) { assert.NoError(t, err) } } + +func TestRepushTag(t *testing.T) { + onGiteaRun(t, func(t *testing.T, u *url.URL) { + repo := unittest.AssertExistsAndLoadBean(t, &repo_model.Repository{ID: 1}) + owner := unittest.AssertExistsAndLoadBean(t, &user_model.User{ID: repo.OwnerID}) + session := loginUser(t, owner.LowerName) + token := getTokenForLoggedInUser(t, session, auth_model.AccessTokenScopeWriteRepository) + + httpContext := NewAPITestContext(t, owner.Name, repo.Name) + + dstPath := t.TempDir() + + u.Path = httpContext.GitPath() + u.User = url.UserPassword(owner.Name, userPassword) + + doGitClone(dstPath, u)(t) + + // create and push a tag + _, _, err := git.NewCommand(git.DefaultContext, "tag", "v2.0").RunStdString(&git.RunOpts{Dir: dstPath}) + assert.NoError(t, err) + _, _, err = git.NewCommand(git.DefaultContext, "push", "origin", "--tags", "v2.0").RunStdString(&git.RunOpts{Dir: dstPath}) + assert.NoError(t, err) + // create a release for the tag + createdRelease := createNewReleaseUsingAPI(t, token, owner, repo, "v2.0", "", "Release of v2.0", "desc") + assert.False(t, createdRelease.IsDraft) + // delete the tag + _, _, err = git.NewCommand(git.DefaultContext, "push", "origin", "--delete", "v2.0").RunStdString(&git.RunOpts{Dir: dstPath}) + assert.NoError(t, err) + // query the release by API and it should be a draft + req := NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, "v2.0")) + resp := MakeRequest(t, req, http.StatusOK) + var respRelease *api.Release + DecodeJSON(t, resp, &respRelease) + assert.True(t, respRelease.IsDraft) + // re-push the tag + _, _, err = git.NewCommand(git.DefaultContext, "push", "origin", "--tags", "v2.0").RunStdString(&git.RunOpts{Dir: dstPath}) + assert.NoError(t, err) + // query the release by API and it should not be a draft + req = NewRequest(t, "GET", fmt.Sprintf("/api/v1/repos/%s/%s/releases/tags/%s", owner.Name, repo.Name, "v2.0")) + resp = MakeRequest(t, req, http.StatusOK) + DecodeJSON(t, resp, &respRelease) + assert.False(t, respRelease.IsDraft) + }) +} diff --git a/web_src/css/base.css b/web_src/css/base.css index 8d9f810ef8fae..b5a39c7af6fbb 100644 --- a/web_src/css/base.css +++ b/web_src/css/base.css @@ -1381,6 +1381,7 @@ table th[data-sortt-desc] .svg { align-items: stretch; } +.ui.list.flex-items-block > .item, .flex-items-block > .item, .flex-text-block { display: flex; diff --git a/web_src/css/home.css b/web_src/css/home.css index 28992ef31f340..77d2ecf92be94 100644 --- a/web_src/css/home.css +++ b/web_src/css/home.css @@ -73,7 +73,7 @@ margin-left: 5px; } -.page-footer .ui.dropdown.language .menu { +.page-footer .ui.dropdown .menu.language-menu { max-height: min(500px, calc(100vh - 60px)); overflow-y: auto; margin-bottom: 10px; diff --git a/web_src/css/repo.css b/web_src/css/repo.css index 85f33f858e2ec..ff8342d29aae7 100644 --- a/web_src/css/repo.css +++ b/web_src/css/repo.css @@ -50,9 +50,20 @@ width: 300px; } +.issue-sidebar-combo .ui.dropdown .item:not(.checked) .item-check-mark { + visibility: hidden; +} +/* ideally, we should move these styles to ".ui.dropdown .menu.flex-items-menu > .item ...", could be done later */ +.issue-sidebar-combo .ui.dropdown .menu > .item > img, +.issue-sidebar-combo .ui.dropdown .menu > .item > svg { + margin: 0; +} + .issue-content-right .dropdown > .menu { max-width: 270px; min-width: 0; + max-height: 500px; + overflow-x: auto; } @media (max-width: 767.98px) { @@ -62,23 +73,6 @@ } } -.repository .issue-content-right .ui.list .dependency { - padding: 0; - white-space: nowrap; -} - -.repository .issue-content-right .ui.list .title { - overflow: hidden; - text-overflow: ellipsis; -} - -.repository .issue-content-right #deadlineForm input { - width: 12.8rem; - border-radius: var(--border-radius) 0 0 var(--border-radius); - border-right: 0; - white-space: nowrap; -} - .repository .issue-content-right .filter.menu { max-height: 500px; overflow-x: auto; @@ -118,10 +112,6 @@ left: 0; } -.repository .select-label .desc { - padding-left: 23px; -} - /* For the secondary pointing menu, respect its own border-bottom */ /* style reference: https://semantic-ui.com/collections/menu.html#pointing */ .repository .ui.tabs.container .ui.menu:not(.secondary.pointing) { diff --git a/web_src/css/repo/issue-label.css b/web_src/css/repo/issue-label.css index 9b4b144a0090d..0a25d31da9e05 100644 --- a/web_src/css/repo/issue-label.css +++ b/web_src/css/repo/issue-label.css @@ -47,6 +47,7 @@ } .archived-label-hint { - float: right; - margin: -12px; + position: absolute; + top: 10px; + right: 5px; } diff --git a/web_src/js/features/common-page.ts b/web_src/js/features/common-page.ts index 77fe2cc1ca71a..56c5915b6dbf8 100644 --- a/web_src/js/features/common-page.ts +++ b/web_src/js/features/common-page.ts @@ -1,6 +1,7 @@ -import $ from 'jquery'; import {GET} from '../modules/fetch.ts'; import {showGlobalErrorMessage} from '../bootstrap.ts'; +import {fomanticQuery} from '../modules/fomantic/base.ts'; +import {queryElems} from '../utils/dom.ts'; const {appUrl} = window.config; @@ -17,27 +18,27 @@ export function initHeadNavbarContentToggle() { } export function initFootLanguageMenu() { - async function linkLanguageAction() { - const $this = $(this); - await GET($this.data('url')); + document.querySelector('.ui.dropdown .menu.language-menu')?.addEventListener('click', async (e) => { + const item = (e.target as HTMLElement).closest('.item'); + if (!item) return; + e.preventDefault(); + await GET(item.getAttribute('data-url')); window.location.reload(); - } - - $('.language-menu a[lang]').on('click', linkLanguageAction); + }); } export function initGlobalDropdown() { // Semantic UI modules. - const $uiDropdowns = $('.ui.dropdown'); + const $uiDropdowns = fomanticQuery('.ui.dropdown'); // do not init "custom" dropdowns, "custom" dropdowns are managed by their own code. - $uiDropdowns.filter(':not(.custom)').dropdown(); + $uiDropdowns.filter(':not(.custom)').dropdown({hideDividers: 'empty'}); // The "jump" means this dropdown is mainly used for "menu" purpose, // clicking an item will jump to somewhere else or trigger an action/function. // When a dropdown is used for non-refresh actions with tippy, // it must have this "jump" class to hide the tippy when dropdown is closed. - $uiDropdowns.filter('.jump').dropdown({ + $uiDropdowns.filter('.jump').dropdown('setting', { action: 'hide', onShow() { // hide associated tooltip while dropdown is open @@ -46,14 +47,14 @@ export function initGlobalDropdown() { }, onHide() { this._tippy?.enable(); + // eslint-disable-next-line unicorn/no-this-assignment + const elDropdown = this; // hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu setTimeout(() => { - const $dropdown = $(this); + const $dropdown = fomanticQuery(elDropdown); if ($dropdown.dropdown('is hidden')) { - $(this).find('.menu > .item').each((_, item) => { - item._tippy?.hide(); - }); + queryElems(elDropdown, '.menu > .item', (el) => el._tippy?.hide()); } }, 2000); }, @@ -71,7 +72,7 @@ export function initGlobalDropdown() { } export function initGlobalTabularMenu() { - $('.ui.menu.tabular:not(.custom) .item').tab({autoTabActivation: false}); + fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab({autoTabActivation: false}); } /** diff --git a/web_src/js/features/imagediff.ts b/web_src/js/features/imagediff.ts index a6b1f48fb38f9..cd61888f83b7b 100644 --- a/web_src/js/features/imagediff.ts +++ b/web_src/js/features/imagediff.ts @@ -1,14 +1,14 @@ -import $ from 'jquery'; import {GET} from '../modules/fetch.ts'; import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.ts'; import {parseDom} from '../utils.ts'; +import {fomanticQuery} from '../modules/fomantic/base.ts'; function getDefaultSvgBoundsIfUndefined(text, src) { const defaultSize = 300; const maxSize = 99999; const svgDoc = parseDom(text, 'image/svg+xml'); - const svg = svgDoc.documentElement; + const svg = (svgDoc.documentElement as unknown) as SVGSVGElement; const width = svg?.width?.baseVal; const height = svg?.height?.baseVal; if (width === undefined || height === undefined) { @@ -68,12 +68,14 @@ function createContext(imageAfter, imageBefore) { } class ImageDiff { - async init(containerEl) { + containerEl: HTMLElement; + diffContainerWidth: number; + + async init(containerEl: HTMLElement) { this.containerEl = containerEl; containerEl.setAttribute('data-image-diff-loaded', 'true'); - // the only jQuery usage in this file - $(containerEl).find('.ui.menu.tabular .item').tab({autoTabActivation: false}); + fomanticQuery(containerEl).find('.ui.menu.tabular .item').tab({autoTabActivation: false}); // the container may be hidden by "viewed" checkbox, so use the parent's width for reference this.diffContainerWidth = Math.max(containerEl.closest('.diff-file-box').clientWidth - 300, 100); @@ -81,12 +83,12 @@ class ImageDiff { const imageInfos = [{ path: containerEl.getAttribute('data-path-after'), mime: containerEl.getAttribute('data-mime-after'), - images: containerEl.querySelectorAll('img.image-after'), // matches 3 + images: containerEl.querySelectorAll('img.image-after'), // matches 3 boundsInfo: containerEl.querySelector('.bounds-info-after'), }, { path: containerEl.getAttribute('data-path-before'), mime: containerEl.getAttribute('data-mime-before'), - images: containerEl.querySelectorAll('img.image-before'), // matches 3 + images: containerEl.querySelectorAll('img.image-before'), // matches 3 boundsInfo: containerEl.querySelector('.bounds-info-before'), }]; @@ -102,8 +104,8 @@ class ImageDiff { const bounds = getDefaultSvgBoundsIfUndefined(text, info.path); if (bounds) { for (const el of info.images) { - el.setAttribute('width', bounds.width); - el.setAttribute('height', bounds.height); + el.setAttribute('width', String(bounds.width)); + el.setAttribute('height', String(bounds.height)); } hideElem(info.boundsInfo); } @@ -151,7 +153,7 @@ class ImageDiff { const boundsInfoBeforeHeight = this.containerEl.querySelector('.bounds-info-before .bounds-info-height'); if (boundsInfoBeforeHeight) { boundsInfoBeforeHeight.textContent = `${sizes.imageBefore.naturalHeight}px`; - boundsInfoBeforeHeight.classList.add('red', heightChanged); + boundsInfoBeforeHeight.classList.toggle('red', heightChanged); } } @@ -205,7 +207,7 @@ class ImageDiff { } // extra height for inner "position: absolute" elements - const swipe = this.containerEl.querySelector('.diff-swipe'); + const swipe = this.containerEl.querySelector('.diff-swipe'); if (swipe) { swipe.style.width = `${sizes.maxSize.width * factor + 2}px`; swipe.style.height = `${sizes.maxSize.height * factor + 30}px`; @@ -225,7 +227,7 @@ class ImageDiff { const rect = swipeFrame.getBoundingClientRect(); const value = Math.max(0, Math.min(e.clientX - rect.left, width)); swipeBar.style.left = `${value}px`; - this.containerEl.querySelector('.swipe-container').style.width = `${swipeFrame.clientWidth - value}px`; + this.containerEl.querySelector('.swipe-container').style.width = `${swipeFrame.clientWidth - value}px`; }; const removeEventListeners = () => { document.removeEventListener('mousemove', onSwipeMouseMove); @@ -264,11 +266,11 @@ class ImageDiff { overlayFrame.style.height = `${sizes.maxSize.height * factor + 2}px`; } - const rangeInput = this.containerEl.querySelector('input[type="range"]'); + const rangeInput = this.containerEl.querySelector('input[type="range"]'); function updateOpacity() { if (sizes.imageAfter) { - sizes.imageAfter.parentNode.style.opacity = `${rangeInput.value / 100}`; + sizes.imageAfter.parentNode.style.opacity = `${Number(rangeInput.value) / 100}`; } } @@ -278,7 +280,7 @@ class ImageDiff { } export function initImageDiff() { - for (const el of queryElems('.image-diff:not([data-image-diff-loaded])')) { + for (const el of queryElems(document, '.image-diff:not([data-image-diff-loaded])')) { (new ImageDiff()).init(el); // it is async, but we don't need to await for it } } diff --git a/web_src/js/features/repo-common.ts b/web_src/js/features/repo-common.ts index c7d84de9f09b1..c246d5b4b0548 100644 --- a/web_src/js/features/repo-common.ts +++ b/web_src/js/features/repo-common.ts @@ -31,7 +31,7 @@ async function onDownloadArchive(e) { } export function initRepoArchiveLinks() { - queryElems('a.archive-link[href]', (el) => el.addEventListener('click', onDownloadArchive)); + queryElems(document, 'a.archive-link[href]', (el) => el.addEventListener('click', onDownloadArchive)); } export function initRepoActivityTopAuthorsChart() { diff --git a/web_src/js/features/repo-editor.ts b/web_src/js/features/repo-editor.ts index e4d179d3aeaba..6ea9347eba42e 100644 --- a/web_src/js/features/repo-editor.ts +++ b/web_src/js/features/repo-editor.ts @@ -45,17 +45,17 @@ export function initRepoEditor() { const dropzoneUpload = document.querySelector('.page-content.repository.editor.upload .dropzone'); if (dropzoneUpload) initDropzone(dropzoneUpload); - const editArea = document.querySelector('.page-content.repository.editor textarea#edit_area'); + const editArea = document.querySelector('.page-content.repository.editor textarea#edit_area'); if (!editArea) return; - for (const el of queryElems('.js-quick-pull-choice-option')) { + for (const el of queryElems(document, '.js-quick-pull-choice-option')) { el.addEventListener('input', () => { if (el.value === 'commit-to-new-branch') { showElem('.quick-pull-branch-name'); - document.querySelector('.quick-pull-branch-name input').required = true; + document.querySelector('.quick-pull-branch-name input').required = true; } else { hideElem('.quick-pull-branch-name'); - document.querySelector('.quick-pull-branch-name input').required = false; + document.querySelector('.quick-pull-branch-name input').required = false; } document.querySelector('#commit-button').textContent = el.getAttribute('data-button-text'); }); @@ -71,13 +71,13 @@ export function initRepoEditor() { if (filenameInput.value) { parts.push(filenameInput.value); } - document.querySelector('#tree_path').value = parts.join('/'); + document.querySelector('#tree_path').value = parts.join('/'); } filenameInput.addEventListener('input', function () { const parts = filenameInput.value.split('/'); const links = Array.from(document.querySelectorAll('.breadcrumb span.section')); const dividers = Array.from(document.querySelectorAll('.breadcrumb .breadcrumb-divider')); - let warningDiv = document.querySelector('.ui.warning.message.flash-message.flash-warning.space-related'); + let warningDiv = document.querySelector('.ui.warning.message.flash-message.flash-warning.space-related'); let containSpace = false; if (parts.length > 1) { for (let i = 0; i < parts.length; ++i) { @@ -110,14 +110,14 @@ export function initRepoEditor() { filenameInput.value = value; } this.setSelectionRange(0, 0); - containSpace |= (trimValue !== value && trimValue !== ''); + containSpace = containSpace || (trimValue !== value && trimValue !== ''); } } - containSpace |= Array.from(links).some((link) => { + containSpace = containSpace || Array.from(links).some((link) => { const value = link.querySelector('a').textContent; return value.trim() !== value; }); - containSpace |= parts[parts.length - 1].trim() !== parts[parts.length - 1]; + containSpace = containSpace || parts[parts.length - 1].trim() !== parts[parts.length - 1]; if (containSpace) { if (!warningDiv) { warningDiv = document.createElement('div'); @@ -135,8 +135,8 @@ export function initRepoEditor() { joinTreePath(); }); filenameInput.addEventListener('keydown', function (e) { - const sections = queryElems('.breadcrumb span.section'); - const dividers = queryElems('.breadcrumb .breadcrumb-divider'); + const sections = queryElems(document, '.breadcrumb span.section'); + const dividers = queryElems(document, '.breadcrumb .breadcrumb-divider'); // Jump back to last directory once the filename is empty if (e.code === 'Backspace' && filenameInput.selectionStart === 0 && sections.length > 0) { e.preventDefault(); @@ -159,7 +159,7 @@ export function initRepoEditor() { // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage // to enable or disable the commit button - const commitButton = document.querySelector('#commit-button'); + const commitButton = document.querySelector('#commit-button'); const $editForm = $('.ui.edit.form'); const dirtyFileClass = 'dirty-file'; diff --git a/web_src/js/features/repo-issue-content.ts b/web_src/js/features/repo-issue-content.ts index c941d77d5fa1b..9e4c6aa159b64 100644 --- a/web_src/js/features/repo-issue-content.ts +++ b/web_src/js/features/repo-issue-content.ts @@ -3,8 +3,8 @@ import {svg} from '../svg.ts'; import {showErrorToast} from '../modules/toast.ts'; import {GET, POST} from '../modules/fetch.ts'; import {showElem} from '../utils/dom.ts'; +import {parseIssuePageInfo} from '../utils.ts'; -const {appSubUrl} = window.config; let i18nTextEdited; let i18nTextOptions; let i18nTextDeleteFromHistory; @@ -122,15 +122,14 @@ function showContentHistoryMenu(issueBaseUrl, $item, commentId) { } export async function initRepoIssueContentHistory() { - const issueIndex = $('#issueIndex').val(); - if (!issueIndex) return; + const issuePageInfo = parseIssuePageInfo(); + if (!issuePageInfo.issueNumber) return; const $itemIssue = $('.repository.issue .timeline-item.comment.first'); // issue(PR) main content const $comments = $('.repository.issue .comment-list .comment'); // includes: issue(PR) comments, review comments, code comments if (!$itemIssue.length && !$comments.length) return; - const repoLink = $('#repolink').val(); - const issueBaseUrl = `${appSubUrl}/${repoLink}/issues/${issueIndex}`; + const issueBaseUrl = `${issuePageInfo.repoLink}/issues/${issuePageInfo.issueNumber}`; try { const response = await GET(`${issueBaseUrl}/content-history/overview`); diff --git a/web_src/js/features/repo-issue-edit.ts b/web_src/js/features/repo-issue-edit.ts index af97ee4eab140..9d146951bd42f 100644 --- a/web_src/js/features/repo-issue-edit.ts +++ b/web_src/js/features/repo-issue-edit.ts @@ -1,4 +1,3 @@ -import $ from 'jquery'; import {handleReply} from './repo-issue.ts'; import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts'; import {POST} from '../modules/fetch.ts'; @@ -7,11 +6,14 @@ import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.ts'; import {attachRefIssueContextPopup} from './contextpopup.ts'; import {initCommentContent, initMarkupContent} from '../markup/content.ts'; import {triggerUploadStateChanged} from './comp/EditorUpload.ts'; +import {convertHtmlToMarkdown} from '../markup/html2markdown.ts'; -async function onEditContent(event) { - event.preventDefault(); +async function tryOnEditContent(e) { + const clickTarget = e.target.closest('.edit-content'); + if (!clickTarget) return; - const segment = this.closest('.header').nextElementSibling; + e.preventDefault(); + const segment = clickTarget.closest('.header').nextElementSibling; const editContentZone = segment.querySelector('.edit-content-zone'); const renderContent = segment.querySelector('.render-content'); const rawContent = segment.querySelector('.raw-content'); @@ -102,33 +104,53 @@ async function onEditContent(event) { triggerUploadStateChanged(comboMarkdownEditor.container); } +function extractSelectedMarkdown(container: HTMLElement) { + const selection = window.getSelection(); + if (!selection.rangeCount) return ''; + const range = selection.getRangeAt(0); + if (!container.contains(range.commonAncestorContainer)) return ''; + + // todo: if commonAncestorContainer parent has "[data-markdown-original-content]" attribute, use the parent's markdown content + // otherwise, use the selected HTML content and respect all "[data-markdown-original-content]/[data-markdown-generated-content]" attributes + const contents = selection.getRangeAt(0).cloneContents(); + const el = document.createElement('div'); + el.append(contents); + return convertHtmlToMarkdown(el); +} + +async function tryOnQuoteReply(e) { + const clickTarget = (e.target as HTMLElement).closest('.quote-reply'); + if (!clickTarget) return; + + e.preventDefault(); + const contentToQuoteId = clickTarget.getAttribute('data-target'); + const targetRawToQuote = document.querySelector(`#${contentToQuoteId}.raw-content`); + const targetMarkupToQuote = targetRawToQuote.parentElement.querySelector('.render-content.markup'); + let contentToQuote = extractSelectedMarkdown(targetMarkupToQuote); + if (!contentToQuote) contentToQuote = targetRawToQuote.textContent; + const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n`; + + let editor; + if (clickTarget.classList.contains('quote-reply-diff')) { + const replyBtn = clickTarget.closest('.comment-code-cloud').querySelector('button.comment-form-reply'); + editor = await handleReply(replyBtn); + } else { + // for normal issue/comment page + editor = getComboMarkdownEditor(document.querySelector('#comment-form .combo-markdown-editor')); + } + + if (editor.value()) { + editor.value(`${editor.value()}\n\n${quotedContent}`); + } else { + editor.value(quotedContent); + } + editor.focus(); + editor.moveCursorToEnd(); +} + export function initRepoIssueCommentEdit() { - // Edit issue or comment content - $(document).on('click', '.edit-content', onEditContent); - - // Quote reply - $(document).on('click', '.quote-reply', async function (event) { - event.preventDefault(); - const target = this.getAttribute('data-target'); - const quote = document.querySelector(`#${target}`).textContent.replace(/\n/g, '\n> '); - const content = `> ${quote}\n\n`; - - let editor; - if (this.classList.contains('quote-reply-diff')) { - const replyBtn = this.closest('.comment-code-cloud').querySelector('button.comment-form-reply'); - editor = await handleReply(replyBtn); - } else { - // for normal issue/comment page - editor = getComboMarkdownEditor($('#comment-form .combo-markdown-editor')); - } - if (editor) { - if (editor.value()) { - editor.value(`${editor.value()}\n\n${content}`); - } else { - editor.value(content); - } - editor.focus(); - editor.moveCursorToEnd(); - } + document.addEventListener('click', (e) => { + tryOnEditContent(e); // Edit issue or comment content + tryOnQuoteReply(e); // Quote reply to the comment editor }); } diff --git a/web_src/js/features/repo-issue-sidebar-combolist.ts b/web_src/js/features/repo-issue-sidebar-combolist.ts new file mode 100644 index 0000000000000..f408eb43ba0d8 --- /dev/null +++ b/web_src/js/features/repo-issue-sidebar-combolist.ts @@ -0,0 +1,105 @@ +import {fomanticQuery} from '../modules/fomantic/base.ts'; +import {POST} from '../modules/fetch.ts'; +import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts'; + +// if there are draft comments, confirm before reloading, to avoid losing comments +export function issueSidebarReloadConfirmDraftComment() { + const commentTextareas = [ + document.querySelector('.edit-content-zone:not(.tw-hidden) textarea'), + document.querySelector('#comment-form textarea'), + ]; + for (const textarea of commentTextareas) { + // Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds. + // But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy. + if (textarea && textarea.value.trim().length > 10) { + textarea.parentElement.scrollIntoView(); + if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) { + return; + } + break; + } + } + window.location.reload(); +} + +function collectCheckedValues(elDropdown: HTMLElement) { + return Array.from(elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value')); +} + +export function initIssueSidebarComboList(container: HTMLElement) { + const updateUrl = container.getAttribute('data-update-url'); + const elDropdown = container.querySelector(':scope > .ui.dropdown'); + const elList = container.querySelector(':scope > .ui.list'); + const elComboValue = container.querySelector(':scope > .combo-value'); + let initialValues = collectCheckedValues(elDropdown); + + elDropdown.addEventListener('click', (e) => { + const elItem = (e.target as HTMLElement).closest('.item'); + if (!elItem) return; + e.preventDefault(); + if (elItem.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return; + + if (elItem.matches('.clear-selection')) { + queryElems(elDropdown, '.menu > .item', (el) => el.classList.remove('checked')); + elComboValue.value = ''; + return; + } + + const scope = elItem.getAttribute('data-scope'); + if (scope) { + // scoped items could only be checked one at a time + const elSelected = elDropdown.querySelector(`.menu > .item.checked[data-scope="${CSS.escape(scope)}"]`); + if (elSelected === elItem) { + elItem.classList.toggle('checked'); + } else { + queryElems(elDropdown, `.menu > .item[data-scope="${CSS.escape(scope)}"]`, (el) => el.classList.remove('checked')); + elItem.classList.toggle('checked', true); + } + } else { + elItem.classList.toggle('checked'); + } + elComboValue.value = collectCheckedValues(elDropdown).join(','); + }); + + const updateToBackend = async (changedValues) => { + let changed = false; + for (const value of initialValues) { + if (!changedValues.includes(value)) { + await POST(updateUrl, {data: new URLSearchParams({action: 'detach', id: value})}); + changed = true; + } + } + for (const value of changedValues) { + if (!initialValues.includes(value)) { + await POST(updateUrl, {data: new URLSearchParams({action: 'attach', id: value})}); + changed = true; + } + } + if (changed) issueSidebarReloadConfirmDraftComment(); + }; + + const syncUiList = (changedValues) => { + const elEmptyTip = elList.querySelector('.item.empty-list'); + queryElemChildren(elList, '.item:not(.empty-list)', (el) => el.remove()); + for (const value of changedValues) { + const el = elDropdown.querySelector(`.menu > .item[data-value="${CSS.escape(value)}"]`); + const listItem = el.cloneNode(true) as HTMLElement; + queryElems(listItem, '.item-check-mark, .item-secondary-info', (el) => el.remove()); + elList.append(listItem); + } + const hasItems = Boolean(elList.querySelector('.item:not(.empty-list)')); + toggleElem(elEmptyTip, !hasItems); + }; + + fomanticQuery(elDropdown).dropdown('setting', { + action: 'nothing', // do not hide the menu if user presses Enter + fullTextSearch: 'exact', + async onHide() { + // TODO: support "Esc" to cancel the selection. Use partial page loading to avoid losing inputs. + const changedValues = collectCheckedValues(elDropdown); + syncUiList(changedValues); + if (updateUrl) await updateToBackend(changedValues); + initialValues = changedValues; + }, + }); +} diff --git a/web_src/js/features/repo-issue-sidebar.md b/web_src/js/features/repo-issue-sidebar.md new file mode 100644 index 0000000000000..3022b52d05bec --- /dev/null +++ b/web_src/js/features/repo-issue-sidebar.md @@ -0,0 +1,27 @@ +A sidebar combo (dropdown+list) is like this: + +```html +
+ + +
+ no item + ... +
+
+``` + +When the selected items change, the `combo-value` input will be updated. +If there is `data-update-url`, it also calls backend to attach/detach the changed items. + +Also, the changed items will be syncronized to the `ui list` items. + +The items with the same data-scope only allow one selected at a time. diff --git a/web_src/js/features/repo-issue-sidebar.ts b/web_src/js/features/repo-issue-sidebar.ts index 0d30d8103c19f..52878848e8cb3 100644 --- a/web_src/js/features/repo-issue-sidebar.ts +++ b/web_src/js/features/repo-issue-sidebar.ts @@ -3,27 +3,8 @@ import {POST} from '../modules/fetch.ts'; import {updateIssuesMeta} from './repo-common.ts'; import {svg} from '../svg.ts'; import {htmlEscape} from 'escape-goat'; -import {toggleElem} from '../utils/dom.ts'; - -// if there are draft comments, confirm before reloading, to avoid losing comments -function reloadConfirmDraftComment() { - const commentTextareas = [ - document.querySelector('.edit-content-zone:not(.tw-hidden) textarea'), - document.querySelector('#comment-form textarea'), - ]; - for (const textarea of commentTextareas) { - // Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds. - // But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy. - if (textarea && textarea.value.trim().length > 10) { - textarea.parentElement.scrollIntoView(); - if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) { - return; - } - break; - } - } - window.location.reload(); -} +import {queryElems, toggleElem} from '../utils/dom.ts'; +import {initIssueSidebarComboList, issueSidebarReloadConfirmDraftComment} from './repo-issue-sidebar-combolist.ts'; function initBranchSelector() { const elSelectBranch = document.querySelector('.ui.dropdown.select-branch'); @@ -47,7 +28,7 @@ function initBranchSelector() { } else { // for new issue, only update UI&form, do not send request/reload const selectedHiddenSelector = this.getAttribute('data-id-selector'); - document.querySelector(selectedHiddenSelector).value = selectedValue; + document.querySelector(selectedHiddenSelector).value = selectedValue; elSelectBranch.querySelector('.text-branch-name').textContent = selectedText; } }); @@ -72,13 +53,13 @@ function initListSubmits(selector, outerSelector) { for (const [elementId, item] of itemEntries) { await updateIssuesMeta( item['update-url'], - item.action, + item['action'], item['issue-id'], elementId, ); } if (itemEntries.length) { - reloadConfirmDraftComment(); + issueSidebarReloadConfirmDraftComment(); } } }, @@ -99,14 +80,14 @@ function initListSubmits(selector, outerSelector) { if (scope) { // Enable only clicked item for scoped labels if (this.getAttribute('data-scope') !== scope) { - return true; + return; } if (this !== clickedItem && !this.classList.contains('checked')) { - return true; + return; } } else if (this !== clickedItem) { // Toggle for other labels - return true; + return; } if (this.classList.contains('checked')) { @@ -142,7 +123,7 @@ function initListSubmits(selector, outerSelector) { // TODO: Which thing should be done for choosing review requests // to make chosen items be shown on time here? - if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') { + if (selector === 'select-assignees-modify') { return false; } @@ -173,7 +154,7 @@ function initListSubmits(selector, outerSelector) { $listMenu.data('issue-id'), '', ); - reloadConfirmDraftComment(); + issueSidebarReloadConfirmDraftComment(); })(); } @@ -182,7 +163,7 @@ function initListSubmits(selector, outerSelector) { $(this).find('.octicon-check').addClass('tw-invisible'); }); - if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') { + if (selector === 'select-assignees-modify') { return false; } @@ -213,7 +194,7 @@ function selectItem(select_id, input_id) { $menu.data('issue-id'), $(this).data('id'), ); - reloadConfirmDraftComment(); + issueSidebarReloadConfirmDraftComment(); })(); } @@ -249,7 +230,7 @@ function selectItem(select_id, input_id) { $menu.data('issue-id'), $(this).data('id'), ); - reloadConfirmDraftComment(); + issueSidebarReloadConfirmDraftComment(); })(); } @@ -276,14 +257,14 @@ export function initRepoIssueSidebar() { initBranchSelector(); initRepoIssueDue(); - // Init labels and assignees - initListSubmits('select-label', 'labels'); + // TODO: refactor the legacy initListSubmits&selectItem to initIssueSidebarComboList initListSubmits('select-assignees', 'assignees'); initListSubmits('select-assignees-modify', 'assignees'); - initListSubmits('select-reviewers-modify', 'assignees'); + selectItem('.select-assignee', '#assignee_id'); - // Milestone, Assignee, Project selectItem('.select-project', '#project_id'); selectItem('.select-milestone', '#milestone_id'); - selectItem('.select-assignee', '#assignee_id'); + + // init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions + queryElems(document, '.issue-sidebar-combo', (el) => initIssueSidebarComboList(el)); } diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts index 392af776f8804..7457531ece47d 100644 --- a/web_src/js/features/repo-issue.ts +++ b/web_src/js/features/repo-issue.ts @@ -4,11 +4,10 @@ import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts'; import {hideElem, showElem, toggleElem} from '../utils/dom.ts'; import {setFileFolding} from './file-fold.ts'; import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts'; -import {toAbsoluteUrl} from '../utils.ts'; +import {parseIssuePageInfo, toAbsoluteUrl} from '../utils.ts'; import {GET, POST} from '../modules/fetch.ts'; import {showErrorToast} from '../modules/toast.ts'; import {initRepoIssueSidebar} from './repo-issue-sidebar.ts'; -import {updateIssuesMeta} from './repo-common.ts'; const {appSubUrl} = window.config; @@ -57,13 +56,11 @@ function excludeLabel(item) { } export function initRepoIssueSidebarList() { - const repolink = $('#repolink').val(); - const repoId = $('#repoId').val(); + const issuePageInfo = parseIssuePageInfo(); const crossRepoSearch = $('#crossRepoSearch').val(); - const tp = $('#type').val(); - let issueSearchUrl = `${appSubUrl}/${repolink}/issues/search?q={query}&type=${tp}`; + let issueSearchUrl = `${issuePageInfo.repoLink}/issues/search?q={query}&type=${issuePageInfo.issueDependencySearchType}`; if (crossRepoSearch === 'true') { - issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${repoId}&type=${tp}`; + issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${issuePageInfo.repoId}&type=${issuePageInfo.issueDependencySearchType}`; } $('#new-dependency-drop-list') .dropdown({ @@ -101,6 +98,7 @@ export function initRepoIssueSidebarList() { }); }); + // FIXME: it is wrong place to init ".ui.dropdown.label-filter" $('.menu .ui.dropdown.label-filter').on('keydown', (e) => { if (e.altKey && e.key === 'Enter') { const selectedItem = document.querySelector('.menu .ui.dropdown.label-filter .menu .item.selected'); @@ -109,7 +107,6 @@ export function initRepoIssueSidebarList() { } } }); - $('.ui.dropdown.label-filter, .ui.dropdown.select-label').dropdown('setting', {'hideDividers': 'empty'}).dropdown('refreshItems'); } export function initRepoIssueCommentDelete() { @@ -328,17 +325,6 @@ export function initRepoIssueWipTitle() { export function initRepoIssueComments() { if (!$('.repository.view.issue .timeline').length) return; - $('.re-request-review').on('click', async function (e) { - e.preventDefault(); - const url = this.getAttribute('data-update-url'); - const issueId = this.getAttribute('data-issue-id'); - const id = this.getAttribute('data-id'); - const isChecked = this.classList.contains('checked'); - - await updateIssuesMeta(url, isChecked ? 'detach' : 'attach', issueId, id); - window.location.reload(); - }); - document.addEventListener('click', (e) => { const urlTarget = document.querySelector(':target'); if (!urlTarget) return; @@ -666,19 +652,6 @@ function initIssueTemplateCommentEditors($commentForm) { } } -// This function used to show and hide archived label on issue/pr -// page in the sidebar where we select the labels -// If we have any archived label tagged to issue and pr. We will show that -// archived label with checked classed otherwise we will hide it -// with the help of this function. -// This function runs globally. -export function initArchivedLabelHandler() { - if (!document.querySelector('.archived-label-hint')) return; - for (const label of document.querySelectorAll('[data-is-archived]')) { - toggleElem(label, label.classList.contains('checked')); - } -} - export function initRepoCommentFormAndSidebar() { const $commentForm = $('.comment.form'); if (!$commentForm.length) return; diff --git a/web_src/js/features/repo-settings.ts b/web_src/js/features/repo-settings.ts index 34a3b635b2439..72213f794a49c 100644 --- a/web_src/js/features/repo-settings.ts +++ b/web_src/js/features/repo-settings.ts @@ -8,7 +8,7 @@ const {appSubUrl, csrfToken} = window.config; function initRepoSettingsCollaboration() { // Change collaborator access mode - for (const dropdownEl of queryElems('.page-content.repository .ui.dropdown.access-mode')) { + for (const dropdownEl of queryElems(document, '.page-content.repository .ui.dropdown.access-mode')) { const textEl = dropdownEl.querySelector(':scope > .text'); $(dropdownEl).dropdown({ async action(text, value) { diff --git a/web_src/js/index.ts b/web_src/js/index.ts index 487aac97aa3a5..eeead37333bd8 100644 --- a/web_src/js/index.ts +++ b/web_src/js/index.ts @@ -30,7 +30,7 @@ import { initRepoIssueWipTitle, initRepoPullRequestMergeInstruction, initRepoPullRequestAllowMaintainerEdit, - initRepoPullRequestReview, initRepoIssueSidebarList, initArchivedLabelHandler, + initRepoPullRequestReview, initRepoIssueSidebarList, } from './features/repo-issue.ts'; import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts'; import {initRepoTopicBar} from './features/repo-home.ts'; @@ -182,7 +182,6 @@ onDomReady(() => { initRepoIssueContentHistory, initRepoIssueList, initRepoIssueSidebarList, - initArchivedLabelHandler, initRepoIssueReferenceRepositorySearch, initRepoIssueTimeTracking, initRepoIssueWipTitle, diff --git a/web_src/js/markup/html2markdown.test.ts b/web_src/js/markup/html2markdown.test.ts new file mode 100644 index 0000000000000..99a63956a031b --- /dev/null +++ b/web_src/js/markup/html2markdown.test.ts @@ -0,0 +1,24 @@ +import {convertHtmlToMarkdown} from './html2markdown.ts'; +import {createElementFromHTML} from '../utils/dom.ts'; + +const h = createElementFromHTML; + +test('convertHtmlToMarkdown', () => { + expect(convertHtmlToMarkdown(h(`

h

`))).toBe('# h'); + expect(convertHtmlToMarkdown(h(`txt`))).toBe('**txt**'); + expect(convertHtmlToMarkdown(h(`txt`))).toBe('_txt_'); + expect(convertHtmlToMarkdown(h(`txt`))).toBe('~~txt~~'); + + expect(convertHtmlToMarkdown(h(`txt`))).toBe('[txt](link)'); + expect(convertHtmlToMarkdown(h(`https://link`))).toBe('https://link'); + + expect(convertHtmlToMarkdown(h(``))).toBe('![image](link)'); + expect(convertHtmlToMarkdown(h(`name`))).toBe('![name](link)'); + expect(convertHtmlToMarkdown(h(``))).toBe('image'); + + expect(convertHtmlToMarkdown(h(`

txt

`))).toBe('txt\n'); + expect(convertHtmlToMarkdown(h(`
a\nb
`))).toBe('> a\n> b\n'); + + expect(convertHtmlToMarkdown(h(`
  1. a
    • b
`))).toBe('1. a\n * b\n\n'); + expect(convertHtmlToMarkdown(h(`
  1. a
`))).toBe('1. [x] a\n'); +}); diff --git a/web_src/js/markup/html2markdown.ts b/web_src/js/markup/html2markdown.ts new file mode 100644 index 0000000000000..c690e0c8b112c --- /dev/null +++ b/web_src/js/markup/html2markdown.ts @@ -0,0 +1,119 @@ +import {htmlEscape} from 'escape-goat'; + +type Processors = { + [tagName: string]: (el: HTMLElement) => string | HTMLElement | void; +} + +type ProcessorContext = { + elementIsFirst: boolean; + elementIsLast: boolean; + listNestingLevel: number; +} + +function prepareProcessors(ctx:ProcessorContext): Processors { + const processors = { + H1(el) { + const level = parseInt(el.tagName.slice(1)); + el.textContent = `${'#'.repeat(level)} ${el.textContent.trim()}`; + }, + STRONG(el) { + return `**${el.textContent}**`; + }, + EM(el) { + return `_${el.textContent}_`; + }, + DEL(el) { + return `~~${el.textContent}~~`; + }, + + A(el) { + const text = el.textContent || 'link'; + const href = el.getAttribute('href'); + if (/^https?:/.test(text) && text === href) { + return text; + } + return href ? `[${text}](${href})` : text; + }, + IMG(el) { + const alt = el.getAttribute('alt') || 'image'; + const src = el.getAttribute('src'); + const widthAttr = el.hasAttribute('width') ? ` width="${htmlEscape(el.getAttribute('width') || '')}"` : ''; + const heightAttr = el.hasAttribute('height') ? ` height="${htmlEscape(el.getAttribute('height') || '')}"` : ''; + if (widthAttr || heightAttr) { + return `${htmlEscape(alt)}`; + } + return `![${alt}](${src})`; + }, + + P(el) { + el.textContent = `${el.textContent}\n`; + }, + BLOCKQUOTE(el) { + el.textContent = `${el.textContent.replace(/^/mg, '> ')}\n`; + }, + + OL(el) { + const preNewLine = ctx.listNestingLevel ? '\n' : ''; + el.textContent = `${preNewLine}${el.textContent}\n`; + }, + LI(el) { + const parent = el.parentNode; + const bullet = parent.tagName === 'OL' ? `1. ` : '* '; + const nestingIdentLevel = Math.max(0, ctx.listNestingLevel - 1); + el.textContent = `${' '.repeat(nestingIdentLevel * 4)}${bullet}${el.textContent}${ctx.elementIsLast ? '' : '\n'}`; + return el; + }, + INPUT(el) { + return el.checked ? '[x] ' : '[ ] '; + }, + + CODE(el) { + const text = el.textContent; + if (el.parentNode && el.parentNode.tagName === 'PRE') { + el.textContent = `\`\`\`\n${text}\n\`\`\`\n`; + return el; + } + if (text.includes('`')) { + return `\`\` ${text} \`\``; + } + return `\`${text}\``; + }, + }; + processors['UL'] = processors.OL; + for (let level = 2; level <= 6; level++) { + processors[`H${level}`] = processors.H1; + } + return processors; +} + +function processElement(ctx :ProcessorContext, processors: Processors, el: HTMLElement) { + if (el.hasAttribute('data-markdown-generated-content')) return el.textContent; + if (el.tagName === 'A' && el.children.length === 1 && el.children[0].tagName === 'IMG') { + return processElement(ctx, processors, el.children[0] as HTMLElement); + } + + const isListContainer = el.tagName === 'OL' || el.tagName === 'UL'; + if (isListContainer) ctx.listNestingLevel++; + for (let i = 0; i < el.children.length; i++) { + ctx.elementIsFirst = i === 0; + ctx.elementIsLast = i === el.children.length - 1; + processElement(ctx, processors, el.children[i] as HTMLElement); + } + if (isListContainer) ctx.listNestingLevel--; + + if (processors[el.tagName]) { + const ret = processors[el.tagName](el); + if (ret && ret !== el) { + el.replaceWith(typeof ret === 'string' ? document.createTextNode(ret) : ret); + } + } +} + +export function convertHtmlToMarkdown(el: HTMLElement): string { + const div = document.createElement('div'); + div.append(el); + const ctx = {} as ProcessorContext; + ctx.listNestingLevel = 0; + processElement(ctx, prepareProcessors(ctx), el); + return div.textContent; +} diff --git a/web_src/js/markup/mermaid.ts b/web_src/js/markup/mermaid.ts index f2754e659baa6..5c27d6ca1ceaa 100644 --- a/web_src/js/markup/mermaid.ts +++ b/web_src/js/markup/mermaid.ts @@ -57,10 +57,21 @@ export async function renderMermaid() { btn.setAttribute('data-clipboard-text', source); mermaidBlock.append(btn); + const updateIframeHeight = () => { + iframe.style.height = `${iframe.contentWindow.document.body.clientHeight}px`; + }; + + // update height when element's visibility state changes, for example when the diagram is inside + // a
+ block and the
block becomes visible upon user interaction, it + // would initially set a incorrect height and the correct height is set during this callback. + (new IntersectionObserver(() => { + updateIframeHeight(); + }, {root: document.documentElement})).observe(iframe); + iframe.addEventListener('load', () => { pre.replaceWith(mermaidBlock); mermaidBlock.classList.remove('tw-hidden'); - iframe.style.height = `${iframe.contentWindow.document.body.clientHeight}px`; + updateIframeHeight(); setTimeout(() => { // avoid flash of iframe background mermaidBlock.classList.remove('is-loading'); iframe.classList.remove('tw-invisible'); diff --git a/web_src/js/modules/fomantic/base.ts b/web_src/js/modules/fomantic/base.ts index 7574fdd25cce7..10b5ed014f99e 100644 --- a/web_src/js/modules/fomantic/base.ts +++ b/web_src/js/modules/fomantic/base.ts @@ -1,3 +1,4 @@ +import $ from 'jquery'; let ariaIdCounter = 0; export function generateAriaId() { @@ -16,3 +17,6 @@ export function linkLabelAndInput(label, input) { label.setAttribute('for', id); } } + +// eslint-disable-next-line no-jquery/variable-pattern +export const fomanticQuery = $; diff --git a/web_src/js/modules/tippy.ts b/web_src/js/modules/tippy.ts index d75015f69efcf..7948e3ecbcd62 100644 --- a/web_src/js/modules/tippy.ts +++ b/web_src/js/modules/tippy.ts @@ -179,11 +179,9 @@ export function initGlobalTooltips() { } export function showTemporaryTooltip(target: Element, content: Content) { - // if the target is inside a dropdown, don't show the tooltip because when the dropdown - // closes, the tippy would be pushed unsightly to the top-left of the screen like seen - // on the issue comment menu. - if (target.closest('.ui.dropdown > .menu')) return; - + // if the target is inside a dropdown, the menu will be hidden soon + // so display the tooltip on the dropdown instead + target = target.closest('.ui.dropdown') || target; const tippy = target._tippy ?? attachTooltip(target, content); tippy.setContent(content); if (!tippy.state.isShown) tippy.show(); diff --git a/web_src/js/types.ts b/web_src/js/types.ts index 9c601456bd9f1..f5c4a40bcac95 100644 --- a/web_src/js/types.ts +++ b/web_src/js/types.ts @@ -37,6 +37,13 @@ export type IssuePathInfo = { indexString?: string, } +export type IssuePageInfo = { + repoLink: string, + repoId: number, + issueNumber: number, + issueDependencySearchType: string, +} + export type Issue = { id: number; number: number; diff --git a/web_src/js/utils.ts b/web_src/js/utils.ts index 066a7c7b5460e..4fed74e20f02e 100644 --- a/web_src/js/utils.ts +++ b/web_src/js/utils.ts @@ -1,5 +1,5 @@ -import {encode, decode} from 'uint8-to-base64'; -import type {IssuePathInfo} from './types.ts'; +import {decode, encode} from 'uint8-to-base64'; +import type {IssuePageInfo, IssuePathInfo} from './types.ts'; // transform /path/to/file.ext to file.ext export function basename(path: string): string { @@ -43,6 +43,16 @@ export function parseIssueNewHref(href: string): IssuePathInfo { return {ownerName, repoName, pathType, indexString}; } +export function parseIssuePageInfo(): IssuePageInfo { + const el = document.querySelector('#issue-page-info'); + return { + issueNumber: parseInt(el?.getAttribute('data-issue-index')), + issueDependencySearchType: el?.getAttribute('data-issue-dependency-search-type') || '', + repoId: parseInt(el?.getAttribute('data-issue-repo-id')), + repoLink: el?.getAttribute('data-issue-repo-link') || '', + }; +} + // parse a URL, either relative '/path' or absolute 'https://localhost/path' export function parseUrl(str: string): URL { return new URL(str, str.startsWith('http') ? undefined : window.location.origin); diff --git a/web_src/js/utils/dom.ts b/web_src/js/utils/dom.ts index e2a4c60e84aad..29b34dd1e3f42 100644 --- a/web_src/js/utils/dom.ts +++ b/web_src/js/utils/dom.ts @@ -3,9 +3,9 @@ import type {Promisable} from 'type-fest'; import type $ from 'jquery'; type ElementArg = Element | string | NodeListOf | Array | ReturnType; -type ElementsCallback = (el: Element) => Promisable; +type ElementsCallback = (el: T) => Promisable; type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable; -type IterableElements = NodeListOf | Array; +type ArrayLikeIterable = ArrayLike & Iterable; // for NodeListOf and Array function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]) { if (typeof el === 'string' || el instanceof String) { @@ -15,7 +15,7 @@ function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: a func(el, ...args); } else if (el.length !== undefined) { // this works for: NodeList, HTMLCollection, Array, jQuery - for (const e of (el as IterableElements)) { + for (const e of (el as ArrayLikeIterable)) { func(e, ...args); } } else { @@ -58,7 +58,7 @@ export function isElemHidden(el: ElementArg) { return res[0]; } -function applyElemsCallback(elems: IterableElements, fn?: ElementsCallback) { +function applyElemsCallback(elems: ArrayLikeIterable, fn?: ElementsCallback): ArrayLikeIterable { if (fn) { for (const el of elems) { fn(el); @@ -67,19 +67,22 @@ function applyElemsCallback(elems: IterableElements, fn?: ElementsCallback) { return elems; } -export function queryElemSiblings(el: Element, selector = '*', fn?: ElementsCallback) { - return applyElemsCallback(Array.from(el.parentNode.children).filter((child: Element) => { +export function queryElemSiblings(el: Element, selector = '*', fn?: ElementsCallback): ArrayLikeIterable { + const elems = Array.from(el.parentNode.children) as T[]; + return applyElemsCallback(elems.filter((child: Element) => { return child !== el && child.matches(selector); }), fn); } // it works like jQuery.children: only the direct children are selected -export function queryElemChildren(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback) { - return applyElemsCallback(parent.querySelectorAll(`:scope > ${selector}`), fn); +export function queryElemChildren(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback): ArrayLikeIterable { + return applyElemsCallback(parent.querySelectorAll(`:scope > ${selector}`), fn); } -export function queryElems(selector: string, fn?: ElementsCallback) { - return applyElemsCallback(document.querySelectorAll(selector), fn); +// it works like parent.querySelectorAll: all descendants are selected +// in the future, all "queryElems(document, ...)" should be refactored to use a more specific parent +export function queryElems(parent: Element | ParentNode, selector: string, fn?: ElementsCallback): ArrayLikeIterable { + return applyElemsCallback(parent.querySelectorAll(selector), fn); } export function onDomReady(cb: () => Promisable) { @@ -92,7 +95,7 @@ export function onDomReady(cb: () => Promisable) { // checks whether an element is owned by the current document, and whether it is a document fragment or element node // if it is, it means it is a "normal" element managed by us, which can be modified safely. -export function isDocumentFragmentOrElementNode(el: Element | Node) { +export function isDocumentFragmentOrElementNode(el: Node) { try { return el.ownerDocument === document && el.nodeType === Node.ELEMENT_NODE || el.nodeType === Node.DOCUMENT_FRAGMENT_NODE; } catch {