Skip to content
Draft
4 changes: 2 additions & 2 deletions routers/web/repo/issue.go
Original file line number Diff line number Diff line change
Expand Up @@ -2181,8 +2181,8 @@ func GetIssueInfo(ctx *context.Context) {
}

ctx.JSON(http.StatusOK, map[string]any{
"convertedIssue": convert.ToIssue(ctx, ctx.Doer, issue),
"renderedLabels": templates.RenderLabels(ctx, ctx.Locale, issue.Labels, ctx.Repo.RepoLink, issue),
"issue": convert.ToIssue(ctx, ctx.Doer, issue),
"labelsHtml": templates.RenderLabels(ctx, ctx.Locale, issue.Labels, ctx.Repo.RepoLink, issue),
})
}

Expand Down
1 change: 0 additions & 1 deletion templates/base/head_script.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ If you introduce mistakes in it, Gitea JavaScript code wouldn't run correctly.
i18n: {
copy_success: {{ctx.Locale.Tr "copy_success"}},
copy_error: {{ctx.Locale.Tr "copy_error"}},
error_occurred: {{ctx.Locale.Tr "error.occurred"}},
network_error: {{ctx.Locale.Tr "error.network_error"}},
remove_label_str: {{ctx.Locale.Tr "remove_label_str"}},
modal_confirm: {{ctx.Locale.Tr "modal.confirm"}},
Expand Down
8 changes: 4 additions & 4 deletions tests/integration/issue_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -641,13 +641,13 @@ func TestGetIssueInfo(t *testing.T) {
req := NewRequest(t, "GET", urlStr)
resp := session.MakeRequest(t, req, http.StatusOK)
var respStruct struct {
ConvertedIssue api.Issue
RenderedLabels template.HTML
Issue api.Issue `json:"issue"`
LabelsHTML template.HTML `json:"labelsHtml"`
}
DecodeJSON(t, resp, &respStruct)

assert.EqualValues(t, issue.ID, respStruct.ConvertedIssue.ID)
assert.Contains(t, string(respStruct.RenderedLabels), `"labels-list"`)
assert.EqualValues(t, issue.ID, respStruct.Issue.ID)
assert.Contains(t, string(respStruct.LabelsHTML), `"labels-list"`)
}

func TestUpdateIssueDeadline(t *testing.T) {
Expand Down
78 changes: 21 additions & 57 deletions web_src/js/components/ContextPopup.vue
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
<script>
import {SvgIcon} from '../svg.js';
import {GET} from '../modules/fetch.js';

const {appSubUrl, i18n} = window.config;

export default {
components: {SvgIcon},
data: () => ({
loading: false,
issue: null,
renderedLabels: '',
i18nErrorOccurred: i18n.error_occurred,
i18nErrorMessage: null,
}),
props: {
issue: {
type: Object,
default: null,
},
labelsHtml: {
type: String,
default: '',
},
},
computed: {
createdAt() {
return new Date(this.issue.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
Expand Down Expand Up @@ -57,56 +57,20 @@ export default {
return 'red'; // Closed Issue
},
},
mounted() {
this.$refs.root.addEventListener('ce-load-context-popup', (e) => {
const data = e.detail;
if (!this.loading && this.issue === null) {
this.load(data);
}
});
},
methods: {
async load(data) {
this.loading = true;
this.i18nErrorMessage = null;

try {
const response = await GET(`${appSubUrl}/${data.owner}/${data.repo}/issues/${data.index}/info`); // backend: GetIssueInfo
const respJson = await response.json();
if (!response.ok) {
this.i18nErrorMessage = respJson.message ?? i18n.network_error;
return;
}
this.issue = respJson.convertedIssue;
this.renderedLabels = respJson.renderedLabels;
} catch {
this.i18nErrorMessage = i18n.network_error;
} finally {
this.loading = false;
}
},
},
};
</script>
<template>
<div ref="root">
<div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
<div v-if="!loading && issue !== null" class="tw-flex tw-flex-col tw-gap-2">
<div class="tw-text-12">{{ issue.repository.full_name }} on {{ createdAt }}</div>
<div class="flex-text-block">
<svg-icon :name="icon" :class="['text', color]"/>
<span class="issue-title tw-font-semibold tw-break-anywhere">
{{ issue.title }}
<span class="index">#{{ issue.number }}</span>
</span>
</div>
<div v-if="body">{{ body }}</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="issue.labels.length" v-html="renderedLabels"/>
</div>
<div class="tw-flex tw-flex-col tw-gap-2" v-if="!loading && issue === null">
<div class="tw-text-12">{{ i18nErrorOccurred }}</div>
<div>{{ i18nErrorMessage }}</div>
<div class="tw-p-3 tw-flex tw-flex-col tw-gap-2">
<div class="tw-text-12">{{ issue.repository.full_name }} on {{ createdAt }}</div>
<div class="flex-text-block tw-gap-2">
<svg-icon :name="icon" :class="['text', color]"/>
<span class="issue-title tw-font-semibold tw-break-anywhere">
{{ issue.title }}
<span class="index">#{{ issue.number }}</span>
</span>
</div>
<div v-if="body">{{ body }}</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-if="issue.labels.length" v-html="labelsHtml"/>
</div>
</template>
83 changes: 46 additions & 37 deletions web_src/js/features/contextpopup.js
Original file line number Diff line number Diff line change
@@ -1,45 +1,54 @@
import {createApp} from 'vue';
import ContextPopup from '../components/ContextPopup.vue';
import {createVueRoot} from '../utils/vue.js';
import {parseIssueHref} from '../utils.js';
import {createTippy} from '../modules/tippy.js';
import {GET} from '../modules/fetch.js';

export function initContextPopups() {
const refIssues = document.querySelectorAll('.ref-issue');
attachRefIssueContextPopup(refIssues);
const {appSubUrl} = window.config;
const initAttr = 'data-contextpopup-init-done';

async function init(e) {
const link = e.currentTarget;
if (link.hasAttribute(initAttr)) return;
link.setAttribute(initAttr, 'true');

const {owner, repo, index} = parseIssueHref(link.getAttribute('href'));
if (!owner) return;

const res = await GET(`${appSubUrl}/${owner}/${repo}/issues/${index}/info`); // backend: GetIssueInfo
if (!res.ok) return;

let issue, labelsHtml;
try {
({issue, labelsHtml} = await res.json());
} catch {}
if (!issue) return;

const content = createVueRoot(ContextPopup, {issue, labelsHtml});
if (!content) return;

const tippy = createTippy(link, {
theme: 'default',
trigger: 'mouseenter focus',
content,
placement: 'top-start',
interactive: true,
role: 'tooltip',
interactiveBorder: 15,
});

// show immediately because this runs during mouseenter and focus
tippy.show();
}

export function attachRefIssueContextPopup(refIssues) {
for (const refIssue of refIssues) {
if (refIssue.classList.contains('ref-external-issue')) {
return;
}

const {owner, repo, index} = parseIssueHref(refIssue.getAttribute('href'));
if (!owner) return;

const el = document.createElement('div');
el.classList.add('tw-p-3');
refIssue.parentNode.insertBefore(el, refIssue.nextSibling);

const view = createApp(ContextPopup);

try {
view.mount(el);
} catch (err) {
console.error(err);
el.textContent = 'ContextPopup failed to load';
}

createTippy(refIssue, {
theme: 'default',
content: el,
placement: 'top-start',
interactive: true,
role: 'dialog',
interactiveBorder: 5,
onShow: () => {
el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: {owner, repo, index}}));
},
});
export function attachRefIssueContextPopup(els) {
for (const el of els) {
el.addEventListener('mouseenter', init, {once: true});
el.addEventListener('focus', init, {once: true});
}
}

export function initContextPopups() {
// TODO: Use MutationObserver to detect newly inserted .ref-issue
attachRefIssueContextPopup(document.querySelectorAll('.ref-issue:not(.ref-external-issue)'));
}
14 changes: 14 additions & 0 deletions web_src/js/utils/vue.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import {createApp} from 'vue';

// create a new vue root and container and mount a component into it
export function createVueRoot(component, props) {
const container = document.createElement('div');
const view = createApp(component, props);
try {
view.mount(container);
return container;
} catch (err) {
console.error(err);
return null;
}
}