aboutsummaryrefslogtreecommitdiffstats
path: root/web_src
diff options
context:
space:
mode:
authorAnbraten <6918444+anbraten@users.noreply.github.com>2024-10-29 10:20:49 +0100
committerGitHub <noreply@github.com>2024-10-29 17:20:49 +0800
commitb7fb20e73e63b8edc9b90c52073e248bef428fcc (patch)
treef6eac93c56a59e2f017de7e7c9c65dee02e46feb /web_src
parent348d1d0f322ca57c459acd902f54821d687ca804 (diff)
downloadgitea-b7fb20e73e63b8edc9b90c52073e248bef428fcc.tar.gz
gitea-b7fb20e73e63b8edc9b90c52073e248bef428fcc.zip
Suggestions for issues (#32327)
closes #16872
Diffstat (limited to 'web_src')
-rw-r--r--web_src/js/components/ContextPopup.vue33
-rw-r--r--web_src/js/features/comp/TextExpander.ts44
-rw-r--r--web_src/js/features/issue.ts32
-rw-r--r--web_src/js/utils/match.ts19
4 files changed, 90 insertions, 38 deletions
diff --git a/web_src/js/components/ContextPopup.vue b/web_src/js/components/ContextPopup.vue
index 9fb03dcb7d..8c56af858e 100644
--- a/web_src/js/components/ContextPopup.vue
+++ b/web_src/js/components/ContextPopup.vue
@@ -1,8 +1,8 @@
<script lang="ts" setup>
import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts';
+import {getIssueColor, getIssueIcon} from '../features/issue.ts';
import {computed, onMounted, ref} from 'vue';
-import type {Issue} from '../types';
const {appSubUrl, i18n} = window.config;
@@ -21,37 +21,6 @@ const body = computed(() => {
return body;
});
-function getIssueIcon(issue: Issue) {
- if (issue.pull_request) {
- if (issue.state === 'open') {
- if (issue.pull_request.draft === true) {
- return 'octicon-git-pull-request-draft'; // WIP PR
- }
- return 'octicon-git-pull-request'; // Open PR
- } else if (issue.pull_request.merged === true) {
- return 'octicon-git-merge'; // Merged PR
- }
- return 'octicon-git-pull-request'; // Closed PR
- } else if (issue.state === 'open') {
- return 'octicon-issue-opened'; // Open Issue
- }
- return 'octicon-issue-closed'; // Closed Issue
-}
-
-function getIssueColor(issue: Issue) {
- if (issue.pull_request) {
- if (issue.pull_request.draft === true) {
- return 'grey'; // WIP PR
- } else if (issue.pull_request.merged === true) {
- return 'purple'; // Merged PR
- }
- }
- if (issue.state === 'open') {
- return 'green'; // Open Issue
- }
- return 'red'; // Closed Issue
-}
-
const root = ref<HTMLElement | null>(null);
onMounted(() => {
diff --git a/web_src/js/features/comp/TextExpander.ts b/web_src/js/features/comp/TextExpander.ts
index 5afe025d38..6ac4c4bf32 100644
--- a/web_src/js/features/comp/TextExpander.ts
+++ b/web_src/js/features/comp/TextExpander.ts
@@ -1,5 +1,41 @@
-import {matchEmoji, matchMention} from '../../utils/match.ts';
+import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts';
import {emojiString} from '../emoji.ts';
+import {svg} from '../../svg.ts';
+import {parseIssueHref} from '../../utils.ts';
+import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
+import {getIssueColor, getIssueIcon} from '../issue.ts';
+import {debounce} from 'perfect-debounce';
+
+const debouncedSuggestIssues = debounce((key: string, text: string) => new Promise<{matched:boolean; fragment?: HTMLElement}>(async (resolve) => {
+ const {owner, repo, index} = parseIssueHref(window.location.href);
+ const matches = await matchIssue(owner, repo, index, text);
+ if (!matches.length) return resolve({matched: false});
+
+ const ul = document.createElement('ul');
+ ul.classList.add('suggestions');
+ for (const issue of matches) {
+ const li = createElementFromAttrs('li', {
+ role: 'option',
+ 'data-value': `${key}${issue.id}`,
+ class: 'tw-flex tw-gap-2',
+ });
+
+ const icon = svg(getIssueIcon(issue), 16, ['text', getIssueColor(issue)].join(' '));
+ li.append(createElementFromHTML(icon));
+
+ const id = document.createElement('span');
+ id.textContent = issue.id.toString();
+ li.append(id);
+
+ const nameSpan = document.createElement('span');
+ nameSpan.textContent = issue.title;
+ li.append(nameSpan);
+
+ ul.append(li);
+ }
+
+ resolve({matched: true, fragment: ul});
+}), 100);
export function initTextExpander(expander) {
expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
@@ -49,12 +85,14 @@ export function initTextExpander(expander) {
}
provide({matched: true, fragment: ul});
+ } else if (key === '#') {
+ provide(debouncedSuggestIssues(key, text));
}
});
expander?.addEventListener('text-expander-value', ({detail}) => {
if (detail?.item) {
- // add a space after @mentions as it's likely the user wants one
- const suffix = detail.key === '@' ? ' ' : '';
+ // add a space after @mentions and #issue as it's likely the user wants one
+ const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
detail.value = `${detail.item.getAttribute('data-value')}${suffix}`;
}
});
diff --git a/web_src/js/features/issue.ts b/web_src/js/features/issue.ts
new file mode 100644
index 0000000000..a56015a2a2
--- /dev/null
+++ b/web_src/js/features/issue.ts
@@ -0,0 +1,32 @@
+import type {Issue} from '../types.ts';
+
+export function getIssueIcon(issue: Issue) {
+ if (issue.pull_request) {
+ if (issue.state === 'open') {
+ if (issue.pull_request.draft === true) {
+ return 'octicon-git-pull-request-draft'; // WIP PR
+ }
+ return 'octicon-git-pull-request'; // Open PR
+ } else if (issue.pull_request.merged === true) {
+ return 'octicon-git-merge'; // Merged PR
+ }
+ return 'octicon-git-pull-request'; // Closed PR
+ } else if (issue.state === 'open') {
+ return 'octicon-issue-opened'; // Open Issue
+ }
+ return 'octicon-issue-closed'; // Closed Issue
+}
+
+export function getIssueColor(issue: Issue) {
+ if (issue.pull_request) {
+ if (issue.pull_request.draft === true) {
+ return 'grey'; // WIP PR
+ } else if (issue.pull_request.merged === true) {
+ return 'purple'; // Merged PR
+ }
+ }
+ if (issue.state === 'open') {
+ return 'green'; // Open Issue
+ }
+ return 'red'; // Closed Issue
+}
diff --git a/web_src/js/utils/match.ts b/web_src/js/utils/match.ts
index 0ce7e2b1a2..2c7271f16e 100644
--- a/web_src/js/utils/match.ts
+++ b/web_src/js/utils/match.ts
@@ -1,8 +1,10 @@
import emojis from '../../../assets/emoji.json';
+import type {Issue} from '../features/issue.ts';
+import {GET} from '../modules/fetch.ts';
const maxMatches = 6;
-function sortAndReduce(map: Map<string, number>) {
+function sortAndReduce<T>(map: Map<T, number>): T[] {
const sortedMap = new Map(Array.from(map.entries()).sort((a, b) => a[1] - b[1]));
return Array.from(sortedMap.keys()).slice(0, maxMatches);
}
@@ -27,11 +29,12 @@ export function matchEmoji(queryText: string): string[] {
return sortAndReduce(results);
}
-export function matchMention(queryText: string): string[] {
+type MentionSuggestion = {value: string; name: string; fullname: string; avatar: string};
+export function matchMention(queryText: string): MentionSuggestion[] {
const query = queryText.toLowerCase();
// results is a map of weights, lower is better
- const results = new Map();
+ const results = new Map<MentionSuggestion, number>();
for (const obj of window.config.mentionValues ?? []) {
const index = obj.key.toLowerCase().indexOf(query);
if (index === -1) continue;
@@ -41,3 +44,13 @@ export function matchMention(queryText: string): string[] {
return sortAndReduce(results);
}
+
+export async function matchIssue(owner: string, repo: string, issueIndexStr: string, query: string): Promise<Issue[]> {
+ const res = await GET(`${window.config.appSubUrl}/${owner}/${repo}/issues/suggestions?q=${encodeURIComponent(query)}`);
+
+ const issues: Issue[] = await res.json();
+ const issueIndex = parseInt(issueIndexStr);
+
+ // filter out issue with same id
+ return issues.filter((i) => i.id !== issueIndex);
+}