Skip to content

Make ContextPopup stateless, improve fetching logic #31181

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 14 commits into
base: main
Choose a base branch
from
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;
}
}