aboutsummaryrefslogtreecommitdiffstats
path: root/web_src/js/features
diff options
context:
space:
mode:
Diffstat (limited to 'web_src/js/features')
-rw-r--r--web_src/js/features/admin/common.ts191
-rw-r--r--web_src/js/features/autofocus-end.ts6
-rw-r--r--web_src/js/features/captcha.ts13
-rw-r--r--web_src/js/features/citation.ts4
-rw-r--r--web_src/js/features/common-button.test.ts14
-rw-r--r--web_src/js/features/common-button.ts89
-rw-r--r--web_src/js/features/common-fetch-action.ts101
-rw-r--r--web_src/js/features/common-form.ts4
-rw-r--r--web_src/js/features/common-issue-list.ts6
-rw-r--r--web_src/js/features/common-page.ts105
-rw-r--r--web_src/js/features/comp/ComboMarkdownEditor.ts41
-rw-r--r--web_src/js/features/comp/ConfirmModal.ts37
-rw-r--r--web_src/js/features/comp/Cropper.ts9
-rw-r--r--web_src/js/features/comp/EditorMarkdown.ts13
-rw-r--r--web_src/js/features/comp/EditorUpload.test.ts12
-rw-r--r--web_src/js/features/comp/EditorUpload.ts74
-rw-r--r--web_src/js/features/comp/LabelEdit.ts14
-rw-r--r--web_src/js/features/comp/QuickSubmit.ts2
-rw-r--r--web_src/js/features/comp/ReactionSelector.ts48
-rw-r--r--web_src/js/features/comp/SearchUserBox.ts4
-rw-r--r--web_src/js/features/comp/TextExpander.ts62
-rw-r--r--web_src/js/features/contextpopup.ts4
-rw-r--r--web_src/js/features/copycontent.ts24
-rw-r--r--web_src/js/features/dropzone.ts24
-rw-r--r--web_src/js/features/emoji.ts12
-rw-r--r--web_src/js/features/file-fold.ts6
-rw-r--r--web_src/js/features/file-view.ts76
-rw-r--r--web_src/js/features/heatmap.ts2
-rw-r--r--web_src/js/features/imagediff.ts20
-rw-r--r--web_src/js/features/install.ts5
-rw-r--r--web_src/js/features/org-team.ts2
-rw-r--r--web_src/js/features/pull-view-file.ts15
-rw-r--r--web_src/js/features/repo-actions.ts1
-rw-r--r--web_src/js/features/repo-code.ts21
-rw-r--r--web_src/js/features/repo-commit.ts29
-rw-r--r--web_src/js/features/repo-common.test.ts22
-rw-r--r--web_src/js/features/repo-common.ts84
-rw-r--r--web_src/js/features/repo-diff-filetree.ts9
-rw-r--r--web_src/js/features/repo-diff.ts189
-rw-r--r--web_src/js/features/repo-editor.ts64
-rw-r--r--web_src/js/features/repo-findfile.ts14
-rw-r--r--web_src/js/features/repo-graph.ts5
-rw-r--r--web_src/js/features/repo-home.ts6
-rw-r--r--web_src/js/features/repo-issue-content.ts4
-rw-r--r--web_src/js/features/repo-issue-edit.ts21
-rw-r--r--web_src/js/features/repo-issue-list.ts23
-rw-r--r--web_src/js/features/repo-issue-pr-form.ts10
-rw-r--r--web_src/js/features/repo-issue-pr-status.ts10
-rw-r--r--web_src/js/features/repo-issue-pull.ts133
-rw-r--r--web_src/js/features/repo-issue-sidebar-combolist.ts25
-rw-r--r--web_src/js/features/repo-issue-sidebar.md5
-rw-r--r--web_src/js/features/repo-issue-sidebar.ts4
-rw-r--r--web_src/js/features/repo-issue.ts316
-rw-r--r--web_src/js/features/repo-legacy.ts25
-rw-r--r--web_src/js/features/repo-migrate.ts6
-rw-r--r--web_src/js/features/repo-migration.ts21
-rw-r--r--web_src/js/features/repo-new.ts39
-rw-r--r--web_src/js/features/repo-projects.ts27
-rw-r--r--web_src/js/features/repo-settings.ts78
-rw-r--r--web_src/js/features/repo-view-file-tree.ts37
-rw-r--r--web_src/js/features/repo-wiki.ts7
-rw-r--r--web_src/js/features/scoped-access-token.ts20
-rw-r--r--web_src/js/features/stopwatch.ts8
-rw-r--r--web_src/js/features/tablesort.ts2
-rw-r--r--web_src/js/features/tribute.ts49
-rw-r--r--web_src/js/features/user-auth-webauthn.ts12
-rw-r--r--web_src/js/features/user-settings.ts10
67 files changed, 1409 insertions, 966 deletions
diff --git a/web_src/js/features/admin/common.ts b/web_src/js/features/admin/common.ts
index 6c725a3efe..4ed5d62eee 100644
--- a/web_src/js/features/admin/common.ts
+++ b/web_src/js/features/admin/common.ts
@@ -1,7 +1,7 @@
-import $ from 'jquery';
import {checkAppUrl} from '../common-page.ts';
-import {hideElem, showElem, toggleElem} from '../../utils/dom.ts';
+import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
import {POST} from '../../modules/fetch.ts';
+import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {appSubUrl} = window.config;
@@ -19,32 +19,47 @@ export function initAdminCommon(): void {
// check whether appUrl(ROOT_URL) is correct, if not, show an error message
checkAppUrl();
- // New user
- if ($('.admin.new.user').length > 0 || $('.admin.edit.user').length > 0) {
- document.querySelector<HTMLInputElement>('#login_type')?.addEventListener('change', function () {
- if (this.value?.startsWith('0')) {
- document.querySelector<HTMLInputElement>('#user_name')?.removeAttribute('disabled');
- document.querySelector<HTMLInputElement>('#login_name')?.removeAttribute('required');
- hideElem('.non-local');
- showElem('.local');
- document.querySelector<HTMLInputElement>('#user_name')?.focus();
+ initAdminUser();
+ initAdminAuthentication();
+ initAdminNotice();
+}
- if (this.getAttribute('data-password') === 'required') {
- document.querySelector('#password')?.setAttribute('required', 'required');
- }
- } else {
- if (document.querySelector<HTMLDivElement>('.admin.edit.user')) {
- document.querySelector<HTMLInputElement>('#user_name')?.setAttribute('disabled', 'disabled');
- }
- document.querySelector<HTMLInputElement>('#login_name')?.setAttribute('required', 'required');
- showElem('.non-local');
- hideElem('.local');
- document.querySelector<HTMLInputElement>('#login_name')?.focus();
+function initAdminUser() {
+ const pageContent = document.querySelector('.page-content.admin.edit.user, .page-content.admin.new.user');
+ if (!pageContent) return;
- document.querySelector<HTMLInputElement>('#password')?.removeAttribute('required');
+ document.querySelector<HTMLInputElement>('#login_type')?.addEventListener('change', function () {
+ if (this.value?.startsWith('0')) {
+ document.querySelector<HTMLInputElement>('#user_name')?.removeAttribute('disabled');
+ document.querySelector<HTMLInputElement>('#login_name')?.removeAttribute('required');
+ hideElem('.non-local');
+ showElem('.local');
+ document.querySelector<HTMLInputElement>('#user_name')?.focus();
+
+ if (this.getAttribute('data-password') === 'required') {
+ document.querySelector('#password')?.setAttribute('required', 'required');
}
- });
- }
+ } else {
+ if (document.querySelector<HTMLDivElement>('.admin.edit.user')) {
+ document.querySelector<HTMLInputElement>('#user_name')?.setAttribute('disabled', 'disabled');
+ }
+ document.querySelector<HTMLInputElement>('#login_name')?.setAttribute('required', 'required');
+ showElem('.non-local');
+ hideElem('.local');
+ document.querySelector<HTMLInputElement>('#login_name')?.focus();
+
+ document.querySelector<HTMLInputElement>('#password')?.removeAttribute('required');
+ }
+ });
+}
+
+function initAdminAuthentication() {
+ const pageContent = document.querySelector('.page-content.admin.authentication');
+ if (!pageContent) return;
+
+ const isNewPage = pageContent.classList.contains('new');
+ const isEditPage = pageContent.classList.contains('edit');
+ if (!isNewPage && !isEditPage) return;
function onUsePagedSearchChange() {
const searchPageSizeElements = document.querySelectorAll<HTMLDivElement>('.search-page-size');
@@ -90,7 +105,7 @@ export function initAdminCommon(): void {
onOAuth2UseCustomURLChange(applyDefaultValues);
}
- function onOAuth2UseCustomURLChange(applyDefaultValues) {
+ function onOAuth2UseCustomURLChange(applyDefaultValues: boolean) {
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider').value;
hideElem('.oauth2_use_custom_url_field');
for (const input of document.querySelectorAll<HTMLInputElement>('.oauth2_use_custom_url_field input[required]')) {
@@ -119,9 +134,11 @@ export function initAdminCommon(): void {
toggleElem(document.querySelector('#ldap-group-options'), checked);
}
+ const elAuthType = document.querySelector<HTMLInputElement>('#auth_type');
+
// New authentication
- if (document.querySelector<HTMLDivElement>('.admin.new.authentication')) {
- document.querySelector<HTMLInputElement>('#auth_type')?.addEventListener('change', function () {
+ if (isNewPage) {
+ const onAuthTypeChange = function () {
hideElem('.ldap, .dldap, .smtp, .pam, .oauth2, .has-tls, .search-page-size, .sspi');
for (const input of document.querySelectorAll<HTMLInputElement>('.ldap input[required], .binddnrequired input[required], .dldap input[required], .smtp input[required], .pam input[required], .oauth2 input[required], .has-tls input[required], .sspi input[required]')) {
@@ -130,7 +147,7 @@ export function initAdminCommon(): void {
document.querySelector<HTMLDivElement>('.binddnrequired')?.classList.remove('required');
- const authType = this.value;
+ const authType = elAuthType.value;
switch (authType) {
case '2': // LDAP
showElem('.ldap');
@@ -179,20 +196,23 @@ export function initAdminCommon(): void {
if (authType === '2') {
onUsePagedSearchChange();
}
- });
- $('#auth_type').trigger('change');
+ };
+ elAuthType.addEventListener('change', onAuthTypeChange);
+ onAuthTypeChange();
+
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
document.querySelector<HTMLInputElement>('#oauth2_provider')?.addEventListener('change', () => onOAuth2Change(true));
document.querySelector<HTMLInputElement>('#oauth2_use_custom_url')?.addEventListener('change', () => onOAuth2UseCustomURLChange(true));
- $('.js-ldap-group-toggle').on('change', onEnableLdapGroupsChange);
+
+ document.querySelector('.js-ldap-group-toggle').addEventListener('change', onEnableLdapGroupsChange);
}
// Edit authentication
- if (document.querySelector<HTMLDivElement>('.admin.edit.authentication')) {
- const authType = document.querySelector<HTMLInputElement>('#auth_type')?.value;
+ if (isEditPage) {
+ const authType = elAuthType.value;
if (authType === '2' || authType === '5') {
document.querySelector<HTMLInputElement>('#security_protocol')?.addEventListener('change', onSecurityProtocolChange);
- $('.js-ldap-group-toggle').on('change', onEnableLdapGroupsChange);
+ document.querySelector('.js-ldap-group-toggle').addEventListener('change', onEnableLdapGroupsChange);
onEnableLdapGroupsChange();
if (authType === '2') {
document.querySelector<HTMLInputElement>('#use_paged_search')?.addEventListener('change', onUsePagedSearchChange);
@@ -204,58 +224,63 @@ export function initAdminCommon(): void {
}
}
- if (document.querySelector<HTMLDivElement>('.admin.authentication')) {
- $('#auth_name').on('input', function () {
- // appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
- document.querySelector('#oauth2-callback-url').textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${encodeURIComponent((this as HTMLInputElement).value)}/callback`;
- }).trigger('input');
- }
+ const elAuthName = document.querySelector<HTMLInputElement>('#auth_name');
+ const onAuthNameChange = function () {
+ // appSubUrl is either empty or is a path that starts with `/` and doesn't have a trailing slash.
+ document.querySelector('#oauth2-callback-url').textContent = `${window.location.origin}${appSubUrl}/user/oauth2/${encodeURIComponent(elAuthName.value)}/callback`;
+ };
+ elAuthName.addEventListener('input', onAuthNameChange);
+ onAuthNameChange();
+}
- // Notice
- if (document.querySelector<HTMLDivElement>('.admin.notice')) {
- const detailModal = document.querySelector<HTMLDivElement>('#detail-modal');
+function initAdminNotice() {
+ const pageContent = document.querySelector('.page-content.admin.notice');
+ if (!pageContent) return;
- // Attach view detail modals
- $('.view-detail').on('click', function () {
- const description = this.closest('tr').querySelector('.notice-description').textContent;
- detailModal.querySelector('.content pre').textContent = description;
- $(detailModal).modal('show');
- return false;
- });
+ const detailModal = document.querySelector<HTMLDivElement>('#detail-modal');
- // Select actions
- const checkboxes = document.querySelectorAll<HTMLInputElement>('.select.table .ui.checkbox input');
+ // Attach view detail modals
+ queryElems(pageContent, '.view-detail', (el) => el.addEventListener('click', (e) => {
+ e.preventDefault();
+ const elNoticeDesc = el.closest('tr').querySelector('.notice-description');
+ const elModalDesc = detailModal.querySelector('.content pre');
+ elModalDesc.textContent = elNoticeDesc.textContent;
+ fomanticQuery(detailModal).modal('show');
+ }));
- $('.select.action').on('click', function () {
- switch ($(this).data('action')) {
- case 'select-all':
- for (const checkbox of checkboxes) {
- checkbox.checked = true;
- }
- break;
- case 'deselect-all':
- for (const checkbox of checkboxes) {
- checkbox.checked = false;
- }
- break;
- case 'inverse':
- for (const checkbox of checkboxes) {
- checkbox.checked = !checkbox.checked;
- }
- break;
- }
- });
- document.querySelector<HTMLButtonElement>('#delete-selection')?.addEventListener('click', async function (e) {
- e.preventDefault();
- this.classList.add('is-loading', 'disabled');
- const data = new FormData();
- for (const checkbox of checkboxes) {
- if (checkbox.checked) {
- data.append('ids[]', checkbox.closest('.ui.checkbox').getAttribute('data-id'));
+ // Select actions
+ const checkboxes = document.querySelectorAll<HTMLInputElement>('.select.table .ui.checkbox input');
+
+ queryElems(pageContent, '.select.action', (el) => el.addEventListener('click', () => {
+ switch (el.getAttribute('data-action')) {
+ case 'select-all':
+ for (const checkbox of checkboxes) {
+ checkbox.checked = true;
}
+ break;
+ case 'deselect-all':
+ for (const checkbox of checkboxes) {
+ checkbox.checked = false;
+ }
+ break;
+ case 'inverse':
+ for (const checkbox of checkboxes) {
+ checkbox.checked = !checkbox.checked;
+ }
+ break;
+ }
+ }));
+
+ document.querySelector<HTMLButtonElement>('#delete-selection')?.addEventListener('click', async function (e) {
+ e.preventDefault();
+ this.classList.add('is-loading', 'disabled');
+ const data = new FormData();
+ for (const checkbox of checkboxes) {
+ if (checkbox.checked) {
+ data.append('ids[]', checkbox.closest('.ui.checkbox').getAttribute('data-id'));
}
- await POST(this.getAttribute('data-link'), {data});
- window.location.href = this.getAttribute('data-redirect');
- });
- }
+ }
+ await POST(this.getAttribute('data-link'), {data});
+ window.location.href = this.getAttribute('data-redirect');
+ });
}
diff --git a/web_src/js/features/autofocus-end.ts b/web_src/js/features/autofocus-end.ts
deleted file mode 100644
index 53e475b543..0000000000
--- a/web_src/js/features/autofocus-end.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export function initAutoFocusEnd() {
- for (const el of document.querySelectorAll<HTMLInputElement>('.js-autofocus-end')) {
- el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus.
- el.setSelectionRange(el.value.length, el.value.length);
- }
-}
diff --git a/web_src/js/features/captcha.ts b/web_src/js/features/captcha.ts
index 69b4aa6852..df234d0e5c 100644
--- a/web_src/js/features/captcha.ts
+++ b/web_src/js/features/captcha.ts
@@ -34,13 +34,18 @@ export async function initCaptcha() {
break;
}
case 'm-captcha': {
- const {default: mCaptcha} = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue');
- // @ts-expect-error
+ const mCaptcha = await import(/* webpackChunkName: "mcaptcha-vanilla-glue" */'@mcaptcha/vanilla-glue');
+
+ // FIXME: the mCaptcha code is not right, it's a miracle that the wrong code could run
+ // * the "vanilla-glue" has some problems with es6 module.
+ // * the INPUT_NAME is a "const", it should not be changed.
+ // * the "mCaptcha.default" is actually the "Widget".
+
+ // @ts-expect-error TS2540: Cannot assign to 'INPUT_NAME' because it is a read-only property.
mCaptcha.INPUT_NAME = 'm-captcha-response';
const instanceURL = captchaEl.getAttribute('data-instance-url');
- // @ts-expect-error
- mCaptcha.default({
+ new mCaptcha.default({
siteKey: {
instanceUrl: new URL(instanceURL),
key: siteKey,
diff --git a/web_src/js/features/citation.ts b/web_src/js/features/citation.ts
index fc5bb38f0a..3c9fe0afc8 100644
--- a/web_src/js/features/citation.ts
+++ b/web_src/js/features/citation.ts
@@ -5,9 +5,13 @@ const {pageData} = window.config;
async function initInputCitationValue(citationCopyApa: HTMLButtonElement, citationCopyBibtex: HTMLButtonElement) {
const [{Cite, plugins}] = await Promise.all([
+ // @ts-expect-error: module exports no types
import(/* webpackChunkName: "citation-js-core" */'@citation-js/core'),
+ // @ts-expect-error: module exports no types
import(/* webpackChunkName: "citation-js-formats" */'@citation-js/plugin-software-formats'),
+ // @ts-expect-error: module exports no types
import(/* webpackChunkName: "citation-js-bibtex" */'@citation-js/plugin-bibtex'),
+ // @ts-expect-error: module exports no types
import(/* webpackChunkName: "citation-js-csl" */'@citation-js/plugin-csl'),
]);
const {citationFileContent} = pageData;
diff --git a/web_src/js/features/common-button.test.ts b/web_src/js/features/common-button.test.ts
new file mode 100644
index 0000000000..f41bafbc79
--- /dev/null
+++ b/web_src/js/features/common-button.test.ts
@@ -0,0 +1,14 @@
+import {assignElementProperty} from './common-button.ts';
+
+test('assignElementProperty', () => {
+ const elForm = document.createElement('form');
+ assignElementProperty(elForm, 'action', '/test-link');
+ expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL
+ assignElementProperty(elForm, 'text-content', 'dummy');
+ expect(elForm.textContent).toBe('dummy');
+
+ const elInput = document.createElement('input');
+ expect(elInput.readOnly).toBe(false);
+ assignElementProperty(elInput, 'read-only', 'true');
+ expect(elInput.readOnly).toBe(true);
+});
diff --git a/web_src/js/features/common-button.ts b/web_src/js/features/common-button.ts
index 3162557b9b..22a7890857 100644
--- a/web_src/js/features/common-button.ts
+++ b/web_src/js/features/common-button.ts
@@ -1,5 +1,5 @@
import {POST} from '../modules/fetch.ts';
-import {addDelegatedEventListener, hideElem, queryElems, showElem, toggleElem} from '../utils/dom.ts';
+import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {camelize} from 'vue';
@@ -43,13 +43,16 @@ export function initGlobalDeleteButton(): void {
fomanticQuery(modal).modal({
closable: false,
- onApprove: async () => {
+ onApprove: () => {
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
if (btn.getAttribute('data-type') === 'form') {
const formSelector = btn.getAttribute('data-form');
const form = document.querySelector<HTMLFormElement>(formSelector);
if (!form) throw new Error(`no form named ${formSelector} found`);
+ modal.classList.add('is-loading'); // the form is not in the modal, so also add loading indicator to the modal
+ form.classList.add('is-loading');
form.submit();
+ return false; // prevent modal from closing automatically
}
// prepare an AJAX form by data attributes
@@ -62,34 +65,36 @@ export function initGlobalDeleteButton(): void {
postData.append('id', value);
}
}
-
- const response = await POST(btn.getAttribute('data-url'), {data: postData});
- if (response.ok) {
- const data = await response.json();
- window.location.href = data.redirect;
- }
+ (async () => {
+ const response = await POST(btn.getAttribute('data-url'), {data: postData});
+ if (response.ok) {
+ const data = await response.json();
+ window.location.href = data.redirect;
+ }
+ })();
+ modal.classList.add('is-loading'); // the request is in progress, so also add loading indicator to the modal
+ return false; // prevent modal from closing automatically
},
}).modal('show');
});
}
}
-function onShowPanelClick(e) {
+function onShowPanelClick(el: HTMLElement, e: MouseEvent) {
// a '.show-panel' element can show a panel, by `data-panel="selector"`
// if it has "toggle" class, it toggles the panel
- const el = e.currentTarget;
e.preventDefault();
const sel = el.getAttribute('data-panel');
- if (el.classList.contains('toggle')) {
- toggleElem(sel);
- } else {
- showElem(sel);
+ const elems = el.classList.contains('toggle') ? toggleElem(sel) : showElem(sel);
+ for (const elem of elems) {
+ if (isElemVisible(elem as HTMLElement)) {
+ elem.querySelector<HTMLElement>('[autofocus]')?.focus();
+ }
}
}
-function onHidePanelClick(e) {
+function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
// a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"`
- const el = e.currentTarget;
e.preventDefault();
let sel = el.getAttribute('data-panel');
if (sel) {
@@ -98,21 +103,35 @@ function onHidePanelClick(e) {
}
sel = el.getAttribute('data-panel-closest');
if (sel) {
- hideElem(el.parentNode.closest(sel));
+ hideElem((el.parentNode as HTMLElement).closest(sel));
return;
}
throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code
}
-function onShowModalClick(e) {
+export function assignElementProperty(el: any, name: string, val: string) {
+ name = camelize(name);
+ const old = el[name];
+ if (typeof old === 'boolean') {
+ el[name] = val === 'true';
+ } else if (typeof old === 'number') {
+ el[name] = parseFloat(val);
+ } else if (typeof old === 'string') {
+ el[name] = val;
+ } else {
+ // in the future, we could introduce a better typing system like `data-modal-form.action:string="..."`
+ throw new Error(`cannot assign element property ${name} by value ${val}`);
+ }
+}
+
+function onShowModalClick(el: HTMLElement, e: MouseEvent) {
// A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute.
// Each "data-modal-{target}" attribute will be filled to target element's value or text-content.
// * First, try to query '#target'
// * Then, try to query '[name=target]'
// * Then, try to query '.target'
// * Then, try to query 'target' as HTML tag
- // If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set.
- const el = e.currentTarget;
+ // If there is a ".{prop-name}" part like "data-modal-form.action", the "form" element's "action" property will be set, the "prop-name" will be camel-cased to "propName".
e.preventDefault();
const modalSelector = el.getAttribute('data-modal');
const elModal = document.querySelector(modalSelector);
@@ -125,7 +144,7 @@ function onShowModalClick(e) {
}
const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
- const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.');
+ const [attrTargetName, attrTargetProp] = attrTargetCombo.split('.');
// try to find target by: "#target" -> "[name=target]" -> ".target" -> "<target> tag"
const attrTarget = elModal.querySelector(`#${attrTargetName}`) ||
elModal.querySelector(`[name=${attrTargetName}]`) ||
@@ -136,22 +155,16 @@ function onShowModalClick(e) {
continue;
}
- if (attrTargetAttr) {
- attrTarget[camelize(attrTargetAttr)] = attrib.value;
+ if (attrTargetProp) {
+ assignElementProperty(attrTarget, attrTargetProp, attrib.value);
} else if (attrTarget.matches('input, textarea')) {
- attrTarget.value = attrib.value; // FIXME: add more supports like checkbox
+ (attrTarget as HTMLInputElement | HTMLTextAreaElement).value = attrib.value; // FIXME: add more supports like checkbox
} else {
attrTarget.textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p
}
}
- fomanticQuery(elModal).modal('setting', {
- onApprove: () => {
- // "form-fetch-action" can handle network errors gracefully,
- // so keep the modal dialog to make users can re-submit the form if anything wrong happens.
- if (elModal.querySelector('.form-fetch-action')) return false;
- },
- }).modal('show');
+ fomanticQuery(elModal).modal('show');
}
export function initGlobalButtons(): void {
@@ -160,7 +173,15 @@ export function initGlobalButtons(): void {
// There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content")
addDelegatedEventListener(document, 'click', 'form button.ui.cancel.button', (_ /* el */, e) => e.preventDefault());
- queryElems(document, '.show-panel', (el) => el.addEventListener('click', onShowPanelClick));
- queryElems(document, '.hide-panel', (el) => el.addEventListener('click', onHidePanelClick));
- queryElems(document, '.show-modal', (el) => el.addEventListener('click', onShowModalClick));
+ // Ideally these "button" events should be handled by registerGlobalEventFunc
+ // Refactoring would involve too many changes, so at the moment, just use the global event listener.
+ addDelegatedEventListener(document, 'click', '.show-panel, .hide-panel, .show-modal', (el, e: MouseEvent) => {
+ if (el.classList.contains('show-panel')) {
+ onShowPanelClick(el, e);
+ } else if (el.classList.contains('hide-panel')) {
+ onHidePanelClick(el, e);
+ } else if (el.classList.contains('show-modal')) {
+ onShowModalClick(el, e);
+ }
+ });
}
diff --git a/web_src/js/features/common-fetch-action.ts b/web_src/js/features/common-fetch-action.ts
index bc72f4089a..3ca361b6e2 100644
--- a/web_src/js/features/common-fetch-action.ts
+++ b/web_src/js/features/common-fetch-action.ts
@@ -1,11 +1,11 @@
import {request} from '../modules/fetch.ts';
-import {showErrorToast} from '../modules/toast.ts';
-import {addDelegatedEventListener, submitEventSubmitter} from '../utils/dom.ts';
-import {confirmModal} from './comp/ConfirmModal.ts';
+import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
+import {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts';
+import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts';
import type {RequestOpts} from '../types.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
-const {appSubUrl, i18n} = window.config;
+const {appSubUrl} = window.config;
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
// more details are in the backend's fetch-redirect handler
@@ -23,10 +23,20 @@ function fetchActionDoRedirect(redirect: string) {
}
async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) {
+ const showErrorForResponse = (code: number, message: string) => {
+ showErrorToast(`Error ${code || 'request'}: ${message}`);
+ };
+
+ let respStatus = 0;
+ let respText = '';
try {
+ hideToastsAll();
const resp = await request(url, opt);
- if (resp.status === 200) {
- let {redirect} = await resp.json();
+ respStatus = resp.status;
+ respText = await resp.text();
+ const respJson = JSON.parse(respText);
+ if (respStatus === 200) {
+ let {redirect} = respJson;
redirect = redirect || actionElem.getAttribute('data-redirect');
ignoreAreYouSure(actionElem); // ignore the areYouSure check before reloading
if (redirect) {
@@ -35,29 +45,32 @@ async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: R
window.location.reload();
}
return;
- } else if (resp.status >= 400 && resp.status < 500) {
- const data = await resp.json();
+ }
+
+ if (respStatus >= 400 && respStatus < 500 && respJson?.errorMessage) {
// the code was quite messy, sometimes the backend uses "err", sometimes it uses "error", and even "user_error"
// but at the moment, as a new approach, we only use "errorMessage" here, backend can use JSONError() to respond.
- if (data.errorMessage) {
- showErrorToast(data.errorMessage, {useHtmlBody: data.renderFormat === 'html'});
- } else {
- showErrorToast(`server error: ${resp.status}`);
- }
+ showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
} else {
- showErrorToast(`server error: ${resp.status}`);
+ showErrorForResponse(respStatus, respText);
}
} catch (e) {
- if (e.name !== 'AbortError') {
- console.error('error when doRequest', e);
- showErrorToast(`${i18n.network_error} ${e}`);
+ if (e.name === 'SyntaxError') {
+ showErrorForResponse(respStatus, (respText || '').substring(0, 100));
+ } else if (e.name !== 'AbortError') {
+ console.error('fetchActionDoRequest error', e);
+ showErrorForResponse(respStatus, `${e}`);
}
}
actionElem.classList.remove('is-loading', 'loading-icon-2px');
}
-async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) {
+async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) {
e.preventDefault();
+ await submitFormFetchAction(formEl, submitEventSubmitter(e));
+}
+
+export async function submitFormFetchAction(formEl: HTMLFormElement, formSubmitter?: HTMLElement) {
if (formEl.classList.contains('is-loading')) return;
formEl.classList.add('is-loading');
@@ -66,16 +79,18 @@ async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) {
}
const formMethod = formEl.getAttribute('method') || 'get';
- const formActionUrl = formEl.getAttribute('action');
+ const formActionUrl = formEl.getAttribute('action') || window.location.href;
const formData = new FormData(formEl);
- const formSubmitter = submitEventSubmitter(e);
const [submitterName, submitterValue] = [formSubmitter?.getAttribute('name'), formSubmitter?.getAttribute('value')];
if (submitterName) {
formData.append(submitterName, submitterValue || '');
}
let reqUrl = formActionUrl;
- const reqOpt = {method: formMethod.toUpperCase(), body: null};
+ const reqOpt = {
+ method: formMethod.toUpperCase(),
+ body: null as FormData | null,
+ };
if (formMethod.toLowerCase() === 'get') {
const params = new URLSearchParams();
for (const [key, value] of formData) {
@@ -93,36 +108,52 @@ async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) {
await fetchActionDoRequest(formEl, reqUrl, reqOpt);
}
-async function linkAction(el: HTMLElement, e: Event) {
+async function onLinkActionClick(el: HTMLElement, e: Event) {
// A "link-action" can post AJAX request to its "data-url"
// Then the browser is redirected to: the "redirect" in response, or "data-redirect" attribute, or current URL by reloading.
- // If the "link-action" has "data-modal-confirm" attribute, a confirm modal dialog will be shown before taking action.
+ // If the "link-action" has "data-modal-confirm" attribute, a "confirm modal dialog" will be shown before taking action.
+ // Attribute "data-modal-confirm" can be a modal element by "#the-modal-id", or a string content for the modal dialog.
e.preventDefault();
const url = el.getAttribute('data-url');
const doRequest = async () => {
- if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but A doesn't have disabled attribute
+ if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but "A" doesn't have the "disabled" attribute
await fetchActionDoRequest(el, url, {method: el.getAttribute('data-link-action-method') || 'POST'});
if ('disabled' in el) el.disabled = false;
};
- const modalConfirmContent = el.getAttribute('data-modal-confirm') ||
- el.getAttribute('data-modal-confirm-content') || '';
- if (!modalConfirmContent) {
+ let elModal: HTMLElement | null = null;
+ const dataModalConfirm = el.getAttribute('data-modal-confirm') || '';
+ if (dataModalConfirm.startsWith('#')) {
+ // eslint-disable-next-line unicorn/prefer-query-selector
+ elModal = document.getElementById(dataModalConfirm.substring(1));
+ if (elModal) {
+ elModal = createElementFromHTML(elModal.outerHTML);
+ elModal.removeAttribute('id');
+ }
+ }
+ if (!elModal) {
+ const modalConfirmContent = dataModalConfirm || el.getAttribute('data-modal-confirm-content') || '';
+ if (modalConfirmContent) {
+ const isRisky = el.classList.contains('red') || el.classList.contains('negative');
+ elModal = createConfirmModal({
+ header: el.getAttribute('data-modal-confirm-header') || '',
+ content: modalConfirmContent,
+ confirmButtonColor: isRisky ? 'red' : 'primary',
+ });
+ }
+ }
+
+ if (!elModal) {
await doRequest();
return;
}
- const isRisky = el.classList.contains('red') || el.classList.contains('negative');
- if (await confirmModal({
- header: el.getAttribute('data-modal-confirm-header') || '',
- content: modalConfirmContent,
- confirmButtonColor: isRisky ? 'red' : 'primary',
- })) {
+ if (await confirmModal(elModal)) {
await doRequest();
}
}
export function initGlobalFetchAction() {
- addDelegatedEventListener(document, 'submit', '.form-fetch-action', formFetchAction);
- addDelegatedEventListener(document, 'click', '.link-action', linkAction);
+ addDelegatedEventListener(document, 'submit', '.form-fetch-action', onFormFetchActionSubmit);
+ addDelegatedEventListener(document, 'click', '.link-action', onLinkActionClick);
}
diff --git a/web_src/js/features/common-form.ts b/web_src/js/features/common-form.ts
index 8532d397cd..7321d80c44 100644
--- a/web_src/js/features/common-form.ts
+++ b/web_src/js/features/common-form.ts
@@ -17,13 +17,13 @@ export function initGlobalEnterQuickSubmit() {
if (e.key !== 'Enter') return;
const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
if (hasCtrlOrMeta && e.target.matches('textarea')) {
- if (handleGlobalEnterQuickSubmit(e.target)) {
+ if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
e.preventDefault();
}
} else if (e.target.matches('input') && !e.target.closest('form')) {
// input in a normal form could handle Enter key by default, so we only handle the input outside a form
// eslint-disable-next-line unicorn/no-lonely-if
- if (handleGlobalEnterQuickSubmit(e.target)) {
+ if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
e.preventDefault();
}
}
diff --git a/web_src/js/features/common-issue-list.ts b/web_src/js/features/common-issue-list.ts
index e207364794..037529bd10 100644
--- a/web_src/js/features/common-issue-list.ts
+++ b/web_src/js/features/common-issue-list.ts
@@ -1,4 +1,4 @@
-import {isElemHidden, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.ts';
+import {isElemVisible, onInputDebounce, submitEventSubmitter, toggleElem} from '../utils/dom.ts';
import {GET} from '../modules/fetch.ts';
const {appSubUrl} = window.config;
@@ -28,7 +28,7 @@ export function parseIssueListQuickGotoLink(repoLink: string, searchText: string
}
export function initCommonIssueListQuickGoto() {
- const goto = document.querySelector('#issue-list-quick-goto');
+ const goto = document.querySelector<HTMLElement>('#issue-list-quick-goto');
if (!goto) return;
const form = goto.closest('form');
@@ -37,7 +37,7 @@ export function initCommonIssueListQuickGoto() {
form.addEventListener('submit', (e) => {
// if there is no goto button, or the form is submitted by non-quick-goto elements, submit the form directly
- let doQuickGoto = !isElemHidden(goto);
+ let doQuickGoto = isElemVisible(goto);
const submitter = submitEventSubmitter(e);
if (submitter !== form && submitter !== input && submitter !== goto) doQuickGoto = false;
if (!doQuickGoto) return;
diff --git a/web_src/js/features/common-page.ts b/web_src/js/features/common-page.ts
index 56c5915b6d..5a02ee7a6a 100644
--- a/web_src/js/features/common-page.ts
+++ b/web_src/js/features/common-page.ts
@@ -2,6 +2,8 @@ import {GET} from '../modules/fetch.ts';
import {showGlobalErrorMessage} from '../bootstrap.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {queryElems} from '../utils/dom.ts';
+import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
+import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
const {appUrl} = window.config;
@@ -28,51 +30,78 @@ export function initFootLanguageMenu() {
}
export function initGlobalDropdown() {
- // Semantic UI modules.
- const $uiDropdowns = fomanticQuery('.ui.dropdown');
-
// do not init "custom" dropdowns, "custom" dropdowns are managed by their own code.
- $uiDropdowns.filter(':not(.custom)').dropdown({hideDividers: 'empty'});
+ registerGlobalSelectorFunc('.ui.dropdown:not(.custom)', (el) => {
+ const $dropdown = fomanticQuery(el);
+ if ($dropdown.data('module-dropdown')) return; // do not re-init if other code has already initialized it.
- // 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('setting', {
- action: 'hide',
- onShow() {
- // hide associated tooltip while dropdown is open
- this._tippy?.hide();
- this._tippy?.disable();
- },
- onHide() {
- this._tippy?.enable();
- // eslint-disable-next-line unicorn/no-this-assignment
- const elDropdown = this;
+ $dropdown.dropdown('setting', {hideDividers: 'empty'});
- // hide all tippy elements of items after a while. eg: use Enter to click "Copy Link" in the Issue Context Menu
- setTimeout(() => {
- const $dropdown = fomanticQuery(elDropdown);
- if ($dropdown.dropdown('is hidden')) {
- queryElems(elDropdown, '.menu > .item', (el) => el._tippy?.hide());
- }
- }, 2000);
- },
- });
+ if (el.classList.contains('jump')) {
+ // 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.
+ $dropdown.dropdown('setting', {
+ action: 'hide',
+ onShow() {
+ // hide associated tooltip while dropdown is open
+ this._tippy?.hide();
+ this._tippy?.disable();
+ },
+ 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 = fomanticQuery(elDropdown);
+ if ($dropdown.dropdown('is hidden')) {
+ queryElems(elDropdown, '.menu > .item', (el) => el._tippy?.hide());
+ }
+ }, 2000);
+ },
+ });
+ }
- // Special popup-directions, prevent Fomantic from guessing the popup direction.
- // With default "direction: auto", if the viewport height is small, Fomantic would show the popup upward,
- // if the dropdown is at the beginning of the page, then the top part would be clipped by the window view.
- // eg: Issue List "Sort" dropdown
- // But we can not set "direction: downward" for all dropdowns, because there is a bug in dropdown menu positioning when calculating the "left" position,
- // which would make some dropdown popups slightly shift out of the right viewport edge in some cases.
- // eg: the "Create New Repo" menu on the navbar.
- $uiDropdowns.filter('.upward').dropdown('setting', 'direction', 'upward');
- $uiDropdowns.filter('.downward').dropdown('setting', 'direction', 'downward');
+ // Special popup-directions, prevent Fomantic from guessing the popup direction.
+ // With default "direction: auto", if the viewport height is small, Fomantic would show the popup upward,
+ // if the dropdown is at the beginning of the page, then the top part would be clipped by the window view.
+ // eg: Issue List "Sort" dropdown
+ // But we can not set "direction: downward" for all dropdowns, because there is a bug in dropdown menu positioning when calculating the "left" position,
+ // which would make some dropdown popups slightly shift out of the right viewport edge in some cases.
+ // eg: the "Create New Repo" menu on the navbar.
+ if (el.classList.contains('upward')) $dropdown.dropdown('setting', 'direction', 'upward');
+ if (el.classList.contains('downward')) $dropdown.dropdown('setting', 'direction', 'downward');
+ });
}
export function initGlobalTabularMenu() {
- fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab({autoTabActivation: false});
+ fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab();
+}
+
+export function initGlobalAvatarUploader() {
+ registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper);
+}
+
+// for performance considerations, it only uses performant syntax
+function attachInputDirAuto(el: Partial<HTMLInputElement | HTMLTextAreaElement>) {
+ if (el.type !== 'hidden' &&
+ el.type !== 'checkbox' &&
+ el.type !== 'radio' &&
+ el.type !== 'range' &&
+ el.type !== 'color') {
+ el.dir = 'auto';
+ }
+}
+
+export function initGlobalInput() {
+ registerGlobalSelectorFunc('input, textarea', attachInputDirAuto);
+ registerGlobalInitFunc('initInputAutoFocusEnd', (el: HTMLInputElement) => {
+ el.focus(); // expects only one such element on one page. If there are many, then the last one gets the focus.
+ el.setSelectionRange(el.value.length, el.value.length);
+ });
}
/**
diff --git a/web_src/js/features/comp/ComboMarkdownEditor.ts b/web_src/js/features/comp/ComboMarkdownEditor.ts
index bba50a1296..d3773a89c4 100644
--- a/web_src/js/features/comp/ComboMarkdownEditor.ts
+++ b/web_src/js/features/comp/ComboMarkdownEditor.ts
@@ -29,10 +29,10 @@ let elementIdCounter = 0;
/**
* validate if the given textarea is non-empty.
- * @param {HTMLElement} textarea - The textarea element to be validated.
+ * @param {HTMLTextAreaElement} textarea - The textarea element to be validated.
* @returns {boolean} returns true if validation succeeded.
*/
-export function validateTextareaNonEmpty(textarea) {
+export function validateTextareaNonEmpty(textarea: HTMLTextAreaElement) {
// When using EasyMDE, the original edit area HTML element is hidden, breaking HTML5 input validation.
// The workaround (https://github.com/sparksuite/simplemde-markdown-editor/issues/324) doesn't work with contenteditable, so we just show an alert.
if (!textarea.value) {
@@ -49,16 +49,25 @@ export function validateTextareaNonEmpty(textarea) {
return true;
}
+type Heights = {
+ minHeight?: string,
+ height?: string,
+ maxHeight?: string,
+};
+
type ComboMarkdownEditorOptions = {
- editorHeights?: {minHeight?: string, height?: string, maxHeight?: string},
+ editorHeights?: Heights,
easyMDEOptions?: EasyMDE.Options,
};
+type ComboMarkdownEditorTextarea = HTMLTextAreaElement & {_giteaComboMarkdownEditor: any};
+type ComboMarkdownEditorContainer = HTMLElement & {_giteaComboMarkdownEditor?: any};
+
export class ComboMarkdownEditor {
static EventEditorContentChanged = EventEditorContentChanged;
static EventUploadStateChanged = EventUploadStateChanged;
- public container : HTMLElement;
+ public container: HTMLElement;
options: ComboMarkdownEditorOptions;
@@ -70,7 +79,7 @@ export class ComboMarkdownEditor {
easyMDEToolbarActions: any;
easyMDEToolbarDefault: any;
- textarea: HTMLTextAreaElement & {_giteaComboMarkdownEditor: any};
+ textarea: ComboMarkdownEditorTextarea;
textareaMarkdownToolbar: HTMLElement;
textareaAutosize: any;
@@ -81,7 +90,7 @@ export class ComboMarkdownEditor {
previewUrl: string;
previewContext: string;
- constructor(container, options:ComboMarkdownEditorOptions = {}) {
+ constructor(container: ComboMarkdownEditorContainer, options:ComboMarkdownEditorOptions = {}) {
if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized');
container._giteaComboMarkdownEditor = this;
this.options = options;
@@ -98,7 +107,7 @@ export class ComboMarkdownEditor {
await this.switchToUserPreference();
}
- applyEditorHeights(el, heights) {
+ applyEditorHeights(el: HTMLElement, heights: Heights) {
if (!heights) return;
if (heights.minHeight) el.style.minHeight = heights.minHeight;
if (heights.height) el.style.height = heights.height;
@@ -283,7 +292,7 @@ export class ComboMarkdownEditor {
];
}
- parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions) {
+ parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions: any) {
this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(easyMde, this);
const processed = [];
for (const action of actions) {
@@ -332,21 +341,21 @@ export class ComboMarkdownEditor {
this.easyMDE = new EasyMDE(easyMDEOpt);
this.easyMDE.codemirror.on('change', () => triggerEditorContentChanged(this.container));
this.easyMDE.codemirror.setOption('extraKeys', {
- 'Cmd-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
- 'Ctrl-Enter': (cm) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
- Enter: (cm) => {
+ 'Cmd-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
+ 'Ctrl-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
+ Enter: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
cm.execCommand('newlineAndIndent');
}
},
- Up: (cm) => {
+ Up: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
return cm.execCommand('goLineUp');
}
},
- Down: (cm) => {
+ Down: (cm: any) => {
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
if (!tributeContainer || tributeContainer.style.display === 'none') {
return cm.execCommand('goLineDown');
@@ -354,14 +363,14 @@ export class ComboMarkdownEditor {
},
});
this.applyEditorHeights(this.container.querySelector('.CodeMirror-scroll'), this.options.editorHeights);
- await attachTribute(this.easyMDE.codemirror.getInputField(), {mentions: true, emoji: true});
+ await attachTribute(this.easyMDE.codemirror.getInputField());
if (this.dropzone) {
initEasyMDEPaste(this.easyMDE, this.dropzone);
}
hideElem(this.textareaMarkdownToolbar);
}
- value(v = undefined) {
+ value(v: any = undefined) {
if (v === undefined) {
if (this.easyMDE) {
return this.easyMDE.value();
@@ -402,7 +411,7 @@ export class ComboMarkdownEditor {
}
}
-export function getComboMarkdownEditor(el) {
+export function getComboMarkdownEditor(el: any) {
if (!el) return null;
if (el.length) el = el[0];
return el._giteaComboMarkdownEditor;
diff --git a/web_src/js/features/comp/ConfirmModal.ts b/web_src/js/features/comp/ConfirmModal.ts
index 1ce490ec2e..97a73eace6 100644
--- a/web_src/js/features/comp/ConfirmModal.ts
+++ b/web_src/js/features/comp/ConfirmModal.ts
@@ -1,24 +1,33 @@
import {svg} from '../../svg.ts';
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../../utils/html.ts';
import {createElementFromHTML} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {i18n} = window.config;
-export function confirmModal({header = '', content = '', confirmButtonColor = 'primary'} = {}): Promise<boolean> {
- return new Promise((resolve) => {
- const headerHtml = header ? `<div class="header">${htmlEscape(header)}</div>` : '';
- const modal = createElementFromHTML(`
- <div class="ui g-modal-confirm modal">
- ${headerHtml}
- <div class="content">${htmlEscape(content)}</div>
- <div class="actions">
- <button class="ui cancel button">${svg('octicon-x')} ${htmlEscape(i18n.modal_cancel)}</button>
- <button class="ui ${confirmButtonColor} ok button">${svg('octicon-check')} ${htmlEscape(i18n.modal_confirm)}</button>
- </div>
+type ConfirmModalOptions = {
+ header?: string;
+ content?: string;
+ confirmButtonColor?: 'primary' | 'red' | 'green' | 'blue';
+}
+
+export function createConfirmModal({header = '', content = '', confirmButtonColor = 'primary'}:ConfirmModalOptions = {}): HTMLElement {
+ const headerHtml = header ? html`<div class="header">${header}</div>` : '';
+ return createElementFromHTML(html`
+ <div class="ui g-modal-confirm modal">
+ ${htmlRaw(headerHtml)}
+ <div class="content">${content}</div>
+ <div class="actions">
+ <button class="ui cancel button">${htmlRaw(svg('octicon-x'))} ${i18n.modal_cancel}</button>
+ <button class="ui ${confirmButtonColor} ok button">${htmlRaw(svg('octicon-check'))} ${i18n.modal_confirm}</button>
</div>
- `);
- document.body.append(modal);
+ </div>
+ `.trim());
+}
+
+export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<boolean> {
+ if (!(modal instanceof HTMLElement)) modal = createConfirmModal(modal);
+ return new Promise((resolve) => {
const $modal = fomanticQuery(modal);
$modal.modal({
onApprove() {
diff --git a/web_src/js/features/comp/Cropper.ts b/web_src/js/features/comp/Cropper.ts
index e65dcfbe13..aaa1691152 100644
--- a/web_src/js/features/comp/Cropper.ts
+++ b/web_src/js/features/comp/Cropper.ts
@@ -6,7 +6,7 @@ type CropperOpts = {
fileInput: HTMLInputElement,
}
-export async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
+async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs');
let currentFileName = '';
let currentFileLastModified = 0;
@@ -38,3 +38,10 @@ export async function initCompCropper({container, fileInput, imageSource}: Cropp
}
});
}
+
+export async function initAvatarUploaderWithCropper(fileInput: HTMLInputElement) {
+ const panel = fileInput.nextElementSibling as HTMLElement;
+ if (!panel?.matches('.cropper-panel')) throw new Error('Missing cropper panel for avatar uploader');
+ const imageSource = panel.querySelector<HTMLImageElement>('.cropper-source');
+ await initCompCropper({container: panel, fileInput, imageSource});
+}
diff --git a/web_src/js/features/comp/EditorMarkdown.ts b/web_src/js/features/comp/EditorMarkdown.ts
index d3ed492396..6e66c15763 100644
--- a/web_src/js/features/comp/EditorMarkdown.ts
+++ b/web_src/js/features/comp/EditorMarkdown.ts
@@ -1,10 +1,10 @@
export const EventEditorContentChanged = 'ce-editor-content-changed';
-export function triggerEditorContentChanged(target) {
+export function triggerEditorContentChanged(target: HTMLElement) {
target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
}
-export function textareaInsertText(textarea, value) {
+export function textareaInsertText(textarea: HTMLTextAreaElement, value: string) {
const startPos = textarea.selectionStart;
const endPos = textarea.selectionEnd;
textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos);
@@ -20,7 +20,7 @@ type TextareaValueSelection = {
selEnd: number;
}
-function handleIndentSelection(textarea: HTMLTextAreaElement, e) {
+function handleIndentSelection(textarea: HTMLTextAreaElement, e: KeyboardEvent) {
const selStart = textarea.selectionStart;
const selEnd = textarea.selectionEnd;
if (selEnd === selStart) return; // do not process when no selection
@@ -184,8 +184,13 @@ function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
triggerEditorContentChanged(textarea);
}
-export function initTextareaMarkdown(textarea) {
+function isTextExpanderShown(textarea: HTMLElement): boolean {
+ return Boolean(textarea.closest('text-expander')?.querySelector('.suggestions'));
+}
+
+export function initTextareaMarkdown(textarea: HTMLTextAreaElement) {
textarea.addEventListener('keydown', (e) => {
+ if (isTextExpanderShown(textarea)) return;
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) {
// use Tab/Shift-Tab to indent/unindent the selected lines
handleIndentSelection(textarea, e);
diff --git a/web_src/js/features/comp/EditorUpload.test.ts b/web_src/js/features/comp/EditorUpload.test.ts
index 55f3f74389..e6e5f4de13 100644
--- a/web_src/js/features/comp/EditorUpload.test.ts
+++ b/web_src/js/features/comp/EditorUpload.test.ts
@@ -1,4 +1,4 @@
-import {removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
+import {pasteAsMarkdownLink, removeAttachmentLinksFromMarkdown} from './EditorUpload.ts';
test('removeAttachmentLinksFromMarkdown', () => {
expect(removeAttachmentLinksFromMarkdown('a foo b', 'foo')).toBe('a foo b');
@@ -12,3 +12,13 @@ test('removeAttachmentLinksFromMarkdown', () => {
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo"> b', 'foo')).toBe('a b');
expect(removeAttachmentLinksFromMarkdown('a <img src="/attachments/foo" width="100"/> b', 'foo')).toBe('a b');
});
+
+test('preparePasteAsMarkdownLink', () => {
+ expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'bar')).toBeNull();
+ expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 0}, 'https://gitea.com')).toBeNull();
+ expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'bar')).toBeNull();
+ expect(pasteAsMarkdownLink({value: 'foo', selectionStart: 0, selectionEnd: 3}, 'https://gitea.com')).toBe('[foo](https://gitea.com)');
+ expect(pasteAsMarkdownLink({value: '..(url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBe('[url](https://gitea.com)');
+ expect(pasteAsMarkdownLink({value: '[](url)', selectionStart: 3, selectionEnd: 6}, 'https://gitea.com')).toBeNull();
+ expect(pasteAsMarkdownLink({value: 'https://example.com', selectionStart: 0, selectionEnd: 19}, 'https://gitea.com')).toBeNull();
+});
diff --git a/web_src/js/features/comp/EditorUpload.ts b/web_src/js/features/comp/EditorUpload.ts
index 89982747ea..bf78f58daf 100644
--- a/web_src/js/features/comp/EditorUpload.ts
+++ b/web_src/js/features/comp/EditorUpload.ts
@@ -8,43 +8,46 @@ import {
generateMarkdownLinkForAttachment,
} from '../dropzone.ts';
import type CodeMirror from 'codemirror';
+import type EasyMDE from 'easymde';
+import type {DropzoneFile} from 'dropzone';
let uploadIdCounter = 0;
export const EventUploadStateChanged = 'ce-upload-state-changed';
-export function triggerUploadStateChanged(target) {
+export function triggerUploadStateChanged(target: HTMLElement) {
target.dispatchEvent(new CustomEvent(EventUploadStateChanged, {bubbles: true}));
}
-function uploadFile(dropzoneEl, file) {
+function uploadFile(dropzoneEl: HTMLElement, file: File) {
return new Promise((resolve) => {
const curUploadId = uploadIdCounter++;
- file._giteaUploadId = curUploadId;
+ (file as any)._giteaUploadId = curUploadId;
const dropzoneInst = dropzoneEl.dropzone;
- const onUploadDone = ({file}) => {
+ const onUploadDone = ({file}: {file: any}) => {
if (file._giteaUploadId === curUploadId) {
dropzoneInst.off(DropzoneCustomEventUploadDone, onUploadDone);
resolve(file);
}
};
dropzoneInst.on(DropzoneCustomEventUploadDone, onUploadDone);
- dropzoneInst.handleFiles([file]);
+ // FIXME: this is not entirely correct because `file` does not satisfy DropzoneFile (we have abused the Dropzone for long time)
+ dropzoneInst.addFile(file as DropzoneFile);
});
}
class TextareaEditor {
- editor : HTMLTextAreaElement;
+ editor: HTMLTextAreaElement;
- constructor(editor) {
+ constructor(editor: HTMLTextAreaElement) {
this.editor = editor;
}
- insertPlaceholder(value) {
+ insertPlaceholder(value: string) {
textareaInsertText(this.editor, value);
}
- replacePlaceholder(oldVal, newVal) {
+ replacePlaceholder(oldVal: string, newVal: string) {
const editor = this.editor;
const startPos = editor.selectionStart;
const endPos = editor.selectionEnd;
@@ -65,11 +68,11 @@ class TextareaEditor {
class CodeMirrorEditor {
editor: CodeMirror.EditorFromTextArea;
- constructor(editor) {
+ constructor(editor: CodeMirror.EditorFromTextArea) {
this.editor = editor;
}
- insertPlaceholder(value) {
+ insertPlaceholder(value: string) {
const editor = this.editor;
const startPoint = editor.getCursor('start');
const endPoint = editor.getCursor('end');
@@ -80,7 +83,7 @@ class CodeMirrorEditor {
triggerEditorContentChanged(editor.getTextArea());
}
- replacePlaceholder(oldVal, newVal) {
+ replacePlaceholder(oldVal: string, newVal: string) {
const editor = this.editor;
const endPoint = editor.getCursor('end');
if (editor.getSelection() === oldVal) {
@@ -96,7 +99,7 @@ class CodeMirrorEditor {
}
}
-async function handleUploadFiles(editor, dropzoneEl, files, e) {
+async function handleUploadFiles(editor: CodeMirrorEditor | TextareaEditor, dropzoneEl: HTMLElement, files: Array<File> | FileList, e: Event) {
e.preventDefault();
for (const file of files) {
const name = file.name.slice(0, file.name.lastIndexOf('.'));
@@ -109,29 +112,38 @@ async function handleUploadFiles(editor, dropzoneEl, files, e) {
}
}
-export function removeAttachmentLinksFromMarkdown(text, fileUuid) {
+export function removeAttachmentLinksFromMarkdown(text: string, fileUuid: string) {
text = text.replace(new RegExp(`!?\\[([^\\]]+)\\]\\(/?attachments/${fileUuid}\\)`, 'g'), '');
- text = text.replace(new RegExp(`<img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
+ text = text.replace(new RegExp(`[<]img[^>]+src="/?attachments/${fileUuid}"[^>]*>`, 'g'), '');
return text;
}
-function handleClipboardText(textarea, e, {text, isShiftDown}) {
+export function pasteAsMarkdownLink(textarea: {value: string, selectionStart: number, selectionEnd: number}, pastedText: string): string | null {
+ const {value, selectionStart, selectionEnd} = textarea;
+ const selectedText = value.substring(selectionStart, selectionEnd);
+ const trimmedText = pastedText.trim();
+ const beforeSelection = value.substring(0, selectionStart);
+ const afterSelection = value.substring(selectionEnd);
+ const isInMarkdownLink = beforeSelection.endsWith('](') && afterSelection.startsWith(')');
+ const asMarkdownLink = selectedText && isUrl(trimmedText) && !isUrl(selectedText) && !isInMarkdownLink;
+ return asMarkdownLink ? `[${selectedText}](${trimmedText})` : null;
+}
+
+function handleClipboardText(textarea: HTMLTextAreaElement, e: ClipboardEvent, pastedText: string, isShiftDown: boolean) {
// pasting with "shift" means "paste as original content" in most applications
if (isShiftDown) return; // let the browser handle it
// when pasting links over selected text, turn it into [text](link)
- const {value, selectionStart, selectionEnd} = textarea;
- const selectedText = value.substring(selectionStart, selectionEnd);
- const trimmedText = text.trim();
- if (selectedText && isUrl(trimmedText) && !isUrl(selectedText)) {
+ const pastedAsMarkdown = pasteAsMarkdownLink(textarea, pastedText);
+ if (pastedAsMarkdown) {
e.preventDefault();
- replaceTextareaSelection(textarea, `[${selectedText}](${trimmedText})`);
+ replaceTextareaSelection(textarea, pastedAsMarkdown);
}
// else, let the browser handle it
}
// extract text and images from "paste" event
-function getPastedContent(e) {
+function getPastedContent(e: ClipboardEvent) {
const images = [];
for (const item of e.clipboardData?.items ?? []) {
if (item.type?.startsWith('image/')) {
@@ -142,8 +154,8 @@ function getPastedContent(e) {
return {text, images};
}
-export function initEasyMDEPaste(easyMDE, dropzoneEl) {
- const editor = new CodeMirrorEditor(easyMDE.codemirror);
+export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
+ const editor = new CodeMirrorEditor(easyMDE.codemirror as any);
easyMDE.codemirror.on('paste', (_, e) => {
const {images} = getPastedContent(e);
if (!images.length) return;
@@ -160,28 +172,28 @@ export function initEasyMDEPaste(easyMDE, dropzoneEl) {
});
}
-export function initTextareaEvents(textarea, dropzoneEl) {
+export function initTextareaEvents(textarea: HTMLTextAreaElement, dropzoneEl: HTMLElement) {
let isShiftDown = false;
- textarea.addEventListener('keydown', (e) => {
+ textarea.addEventListener('keydown', (e: KeyboardEvent) => {
if (e.shiftKey) isShiftDown = true;
});
- textarea.addEventListener('keyup', (e) => {
+ textarea.addEventListener('keyup', (e: KeyboardEvent) => {
if (!e.shiftKey) isShiftDown = false;
});
- textarea.addEventListener('paste', (e) => {
+ textarea.addEventListener('paste', (e: ClipboardEvent) => {
const {images, text} = getPastedContent(e);
if (images.length && dropzoneEl) {
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, images, e);
} else if (text) {
- handleClipboardText(textarea, e, {text, isShiftDown});
+ handleClipboardText(textarea, e, text, isShiftDown);
}
});
- textarea.addEventListener('drop', (e) => {
+ textarea.addEventListener('drop', (e: DragEvent) => {
if (!e.dataTransfer.files.length) return;
if (!dropzoneEl) return;
handleUploadFiles(new TextareaEditor(textarea), dropzoneEl, e.dataTransfer.files, e);
});
- dropzoneEl?.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}) => {
+ dropzoneEl?.dropzone.on(DropzoneCustomEventRemovedFile, ({fileUuid}: {fileUuid: string}) => {
const newText = removeAttachmentLinksFromMarkdown(textarea.value, fileUuid);
if (textarea.value !== newText) textarea.value = newText;
});
diff --git a/web_src/js/features/comp/LabelEdit.ts b/web_src/js/features/comp/LabelEdit.ts
index 7bceb636bb..423440129c 100644
--- a/web_src/js/features/comp/LabelEdit.ts
+++ b/web_src/js/features/comp/LabelEdit.ts
@@ -1,5 +1,6 @@
import {toggleElem} from '../../utils/dom.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
+import {submitFormFetchAction} from '../common-fetch-action.ts';
function nameHasScope(name: string): boolean {
return /.*[^/]\/[^/].*/.test(name);
@@ -18,6 +19,8 @@ export function initCompLabelEdit(pageSelector: string) {
const elExclusiveField = elModal.querySelector('.label-exclusive-input-field');
const elExclusiveInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-input');
const elExclusiveWarning = elModal.querySelector('.label-exclusive-warning');
+ const elExclusiveOrderField = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input-field');
+ const elExclusiveOrderInput = elModal.querySelector<HTMLInputElement>('.label-exclusive-order-input');
const elIsArchivedField = elModal.querySelector('.label-is-archived-input-field');
const elIsArchivedInput = elModal.querySelector<HTMLInputElement>('.label-is-archived-input');
const elDescInput = elModal.querySelector<HTMLInputElement>('.label-desc-input');
@@ -29,6 +32,13 @@ export function initCompLabelEdit(pageSelector: string) {
const showExclusiveWarning = hasScope && elExclusiveInput.checked && elModal.hasAttribute('data-need-warn-exclusive');
toggleElem(elExclusiveWarning, showExclusiveWarning);
if (!hasScope) elExclusiveInput.checked = false;
+ toggleElem(elExclusiveOrderField, elExclusiveInput.checked);
+
+ if (parseInt(elExclusiveOrderInput.value) <= 0) {
+ elExclusiveOrderInput.style.color = 'var(--color-placeholder-text) !important';
+ } else {
+ elExclusiveOrderInput.style.color = null;
+ }
};
const showLabelEditModal = (btn:HTMLElement) => {
@@ -36,6 +46,7 @@ export function initCompLabelEdit(pageSelector: string) {
const form = elModal.querySelector<HTMLFormElement>('form');
elLabelId.value = btn.getAttribute('data-label-id') || '';
elNameInput.value = btn.getAttribute('data-label-name') || '';
+ elExclusiveOrderInput.value = btn.getAttribute('data-label-exclusive-order') || '0';
elIsArchivedInput.checked = btn.getAttribute('data-label-is-archived') === 'true';
elExclusiveInput.checked = btn.getAttribute('data-label-exclusive') === 'true';
elDescInput.value = btn.getAttribute('data-label-description') || '';
@@ -60,7 +71,8 @@ export function initCompLabelEdit(pageSelector: string) {
form.reportValidity();
return false;
}
- form.submit();
+ submitFormFetchAction(form);
+ return false;
},
}).modal('show');
};
diff --git a/web_src/js/features/comp/QuickSubmit.ts b/web_src/js/features/comp/QuickSubmit.ts
index 385acb319f..0a41f69132 100644
--- a/web_src/js/features/comp/QuickSubmit.ts
+++ b/web_src/js/features/comp/QuickSubmit.ts
@@ -1,6 +1,6 @@
import {querySingleVisibleElem} from '../../utils/dom.ts';
-export function handleGlobalEnterQuickSubmit(target) {
+export function handleGlobalEnterQuickSubmit(target: HTMLElement) {
let form = target.closest('form');
if (form) {
if (!form.checkValidity()) {
diff --git a/web_src/js/features/comp/ReactionSelector.ts b/web_src/js/features/comp/ReactionSelector.ts
index e93e3b8377..bb54593f11 100644
--- a/web_src/js/features/comp/ReactionSelector.ts
+++ b/web_src/js/features/comp/ReactionSelector.ts
@@ -1,37 +1,31 @@
import {POST} from '../../modules/fetch.ts';
-import {fomanticQuery} from '../../modules/fomantic/base.ts';
import type {DOMEvent} from '../../utils/dom.ts';
+import {registerGlobalEventFunc} from '../../modules/observer.ts';
-export function initCompReactionSelector(parent: ParentNode = document) {
- for (const container of parent.querySelectorAll<HTMLElement>('.issue-content, .diff-file-body')) {
- container.addEventListener('click', async (e: DOMEvent<MouseEvent>) => {
- // there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment
- const target = e.target.closest('.comment-reaction-button');
- if (!target) return;
- e.preventDefault();
+export function initCompReactionSelector() {
+ registerGlobalEventFunc('click', 'onCommentReactionButtonClick', async (target: HTMLElement, e: DOMEvent<MouseEvent>) => {
+ // there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment
+ e.preventDefault();
- if (target.classList.contains('disabled')) return;
+ if (target.classList.contains('disabled')) return;
- const actionUrl = target.closest('[data-action-url]').getAttribute('data-action-url');
- const reactionContent = target.getAttribute('data-reaction-content');
+ const actionUrl = target.closest('[data-action-url]').getAttribute('data-action-url');
+ const reactionContent = target.getAttribute('data-reaction-content');
- const commentContainer = target.closest('.comment-container');
+ const commentContainer = target.closest('.comment-container');
- const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction
- const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`);
- const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true';
+ const bottomReactions = commentContainer.querySelector('.bottom-reactions'); // may not exist if there is no reaction
+ const bottomReactionBtn = bottomReactions?.querySelector(`a[data-reaction-content="${CSS.escape(reactionContent)}"]`);
+ const hasReacted = bottomReactionBtn?.getAttribute('data-has-reacted') === 'true';
- const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, {
- data: new URLSearchParams({content: reactionContent}),
- });
-
- const data = await res.json();
- bottomReactions?.remove();
- if (data.html) {
- commentContainer.insertAdjacentHTML('beforeend', data.html);
- const bottomReactionsDropdowns = commentContainer.querySelectorAll('.bottom-reactions .dropdown.select-reaction');
- fomanticQuery(bottomReactionsDropdowns).dropdown(); // re-init the dropdown
- }
+ const res = await POST(`${actionUrl}/${hasReacted ? 'unreact' : 'react'}`, {
+ data: new URLSearchParams({content: reactionContent}),
});
- }
+
+ const data = await res.json();
+ bottomReactions?.remove();
+ if (data.html) {
+ commentContainer.insertAdjacentHTML('beforeend', data.html);
+ }
+ });
}
diff --git a/web_src/js/features/comp/SearchUserBox.ts b/web_src/js/features/comp/SearchUserBox.ts
index 2e3b3f83be..4b13a2141f 100644
--- a/web_src/js/features/comp/SearchUserBox.ts
+++ b/web_src/js/features/comp/SearchUserBox.ts
@@ -1,4 +1,4 @@
-import {htmlEscape} from 'escape-goat';
+import {htmlEscape} from '../../utils/html.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {appSubUrl} = window.config;
@@ -14,7 +14,7 @@ export function initCompSearchUserBox() {
minCharacters: 2,
apiSettings: {
url: `${appSubUrl}/user/search_candidates?q={query}`,
- onResponse(response) {
+ onResponse(response: any) {
const resultItems = [];
const searchQuery = searchUserBox.querySelector('input').value;
const searchQueryUppercase = searchQuery.toUpperCase();
diff --git a/web_src/js/features/comp/TextExpander.ts b/web_src/js/features/comp/TextExpander.ts
index e0c4abed75..2d79fe5029 100644
--- a/web_src/js/features/comp/TextExpander.ts
+++ b/web_src/js/features/comp/TextExpander.ts
@@ -1,18 +1,25 @@
import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts';
import {emojiString} from '../emoji.ts';
import {svg} from '../../svg.ts';
-import {parseIssueHref, parseIssueNewHref} from '../../utils.ts';
+import {parseIssueHref, parseRepoOwnerPathInfo} from '../../utils.ts';
import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
import {getIssueColor, getIssueIcon} from '../issue.ts';
import {debounce} from 'perfect-debounce';
+import type TextExpanderElement from '@github/text-expander-element';
+import type {TextExpanderChangeEvent, TextExpanderResult} from '@github/text-expander-element';
-const debouncedSuggestIssues = debounce((key: string, text: string) => new Promise<{matched:boolean; fragment?: HTMLElement}>(async (resolve) => {
- let issuePathInfo = parseIssueHref(window.location.href);
- if (!issuePathInfo.ownerName) issuePathInfo = parseIssueNewHref(window.location.href);
- if (!issuePathInfo.ownerName) return resolve({matched: false});
+async function fetchIssueSuggestions(key: string, text: string): Promise<TextExpanderResult> {
+ const issuePathInfo = parseIssueHref(window.location.href);
+ if (!issuePathInfo.ownerName) {
+ const repoOwnerPathInfo = parseRepoOwnerPathInfo(window.location.pathname);
+ issuePathInfo.ownerName = repoOwnerPathInfo.ownerName;
+ issuePathInfo.repoName = repoOwnerPathInfo.repoName;
+ // then no issuePathInfo.indexString here, it is only used to exclude the current issue when "matchIssue"
+ }
+ if (!issuePathInfo.ownerName) return {matched: false};
const matches = await matchIssue(issuePathInfo.ownerName, issuePathInfo.repoName, issuePathInfo.indexString, text);
- if (!matches.length) return resolve({matched: false});
+ if (!matches.length) return {matched: false};
const ul = createElementFromAttrs('ul', {class: 'suggestions'});
for (const issue of matches) {
@@ -24,11 +31,40 @@ const debouncedSuggestIssues = debounce((key: string, text: string) => new Promi
);
ul.append(li);
}
- resolve({matched: true, fragment: ul});
-}), 100);
+ return {matched: true, fragment: ul};
+}
+
+export function initTextExpander(expander: TextExpanderElement) {
+ if (!expander) return;
+
+ const textarea = expander.querySelector<HTMLTextAreaElement>('textarea');
-export function initTextExpander(expander) {
- expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
+ // help to fix the text-expander "multiword+promise" bug: do not show the popup when there is no "#" before current line
+ const shouldShowIssueSuggestions = () => {
+ const posVal = textarea.value.substring(0, textarea.selectionStart);
+ const lineStart = posVal.lastIndexOf('\n');
+ const keyStart = posVal.lastIndexOf('#');
+ return keyStart > lineStart;
+ };
+
+ const debouncedIssueSuggestions = debounce(async (key: string, text: string): Promise<TextExpanderResult> => {
+ // https://github.com/github/text-expander-element/issues/71
+ // Upstream bug: when using "multiword+promise", TextExpander will get wrong "key" position.
+ // To reproduce, comment out the "shouldShowIssueSuggestions" check, use the "await sleep" below,
+ // then use content "close #20\nclose #20\nclose #20" (3 lines), keep changing the last line `#20` part from the end (including removing the `#`)
+ // There will be a JS error: Uncaught (in promise) IndexSizeError: Failed to execute 'setStart' on 'Range': The offset 28 is larger than the node's length (27).
+
+ // check the input before the request, to avoid emitting empty query to backend (still related to the upstream bug)
+ if (!shouldShowIssueSuggestions()) return {matched: false};
+ // await sleep(Math.random() * 1000); // help to reproduce the text-expander bug
+ const ret = await fetchIssueSuggestions(key, text);
+ // check the input again to avoid text-expander using incorrect position (upstream bug)
+ if (!shouldShowIssueSuggestions()) return {matched: false};
+ return ret;
+ }, 300); // to match onInputDebounce delay
+
+ expander.addEventListener('text-expander-change', (e: TextExpanderChangeEvent) => {
+ const {key, text, provide} = e.detail;
if (key === ':') {
const matches = matchEmoji(text);
if (!matches.length) return provide({matched: false});
@@ -61,6 +97,7 @@ export function initTextExpander(expander) {
li.append(img);
const nameSpan = document.createElement('span');
+ nameSpan.classList.add('name');
nameSpan.textContent = name;
li.append(nameSpan);
@@ -76,10 +113,11 @@ export function initTextExpander(expander) {
provide({matched: true, fragment: ul});
} else if (key === '#') {
- provide(debouncedSuggestIssues(key, text));
+ provide(debouncedIssueSuggestions(key, text));
}
});
- expander?.addEventListener('text-expander-value', ({detail}) => {
+
+ expander.addEventListener('text-expander-value', ({detail}: Record<string, any>) => {
if (detail?.item) {
// add a space after @mentions and #issue as it's likely the user wants one
const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
diff --git a/web_src/js/features/contextpopup.ts b/web_src/js/features/contextpopup.ts
index 33eead8431..7477331dbe 100644
--- a/web_src/js/features/contextpopup.ts
+++ b/web_src/js/features/contextpopup.ts
@@ -4,11 +4,11 @@ import {parseIssueHref} from '../utils.ts';
import {createTippy} from '../modules/tippy.ts';
export function initContextPopups() {
- const refIssues = document.querySelectorAll('.ref-issue');
+ const refIssues = document.querySelectorAll<HTMLElement>('.ref-issue');
attachRefIssueContextPopup(refIssues);
}
-export function attachRefIssueContextPopup(refIssues) {
+export function attachRefIssueContextPopup(refIssues: NodeListOf<HTMLElement>) {
for (const refIssue of refIssues) {
if (refIssue.classList.contains('ref-external-issue')) continue;
diff --git a/web_src/js/features/copycontent.ts b/web_src/js/features/copycontent.ts
index af867463b2..0fec2a6235 100644
--- a/web_src/js/features/copycontent.ts
+++ b/web_src/js/features/copycontent.ts
@@ -2,26 +2,24 @@ import {clippie} from 'clippie';
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {convertImage} from '../utils.ts';
import {GET} from '../modules/fetch.ts';
+import {registerGlobalEventFunc} from '../modules/observer.ts';
const {i18n} = window.config;
export function initCopyContent() {
- const btn = document.querySelector('#copy-content');
- if (!btn || btn.classList.contains('disabled')) return;
+ registerGlobalEventFunc('click', 'onCopyContentButtonClick', async (btn: HTMLElement) => {
+ if (btn.classList.contains('disabled') || btn.classList.contains('is-loading')) return;
+ const rawFileLink = btn.getAttribute('data-raw-file-link');
- btn.addEventListener('click', async () => {
- if (btn.classList.contains('is-loading')) return;
- let content;
- let isRasterImage = false;
- const link = btn.getAttribute('data-link');
+ let content, isRasterImage = false;
- // when data-link is present, we perform a fetch. this is either because
- // the text to copy is not in the DOM or it is an image which should be
+ // when "data-raw-link" is present, we perform a fetch. this is either because
+ // the text to copy is not in the DOM, or it is an image that should be
// fetched to copy in full resolution
- if (link) {
+ if (rawFileLink) {
btn.classList.add('is-loading', 'loading-icon-2px');
try {
- const res = await GET(link, {credentials: 'include', redirect: 'follow'});
+ const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
const contentType = res.headers.get('content-type');
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
@@ -40,13 +38,13 @@ export function initCopyContent() {
content = Array.from(lineEls, (el) => el.textContent).join('');
}
- // try copy original first, if that fails and it's an image, convert it to png
+ // try copy original first, if that fails, and it's an image, convert it to png
const success = await clippie(content);
if (success) {
showTemporaryTooltip(btn, i18n.copy_success);
} else {
if (isRasterImage) {
- const success = await clippie(await convertImage(content, 'image/png'));
+ const success = await clippie(await convertImage(content as Blob, 'image/png'));
showTemporaryTooltip(btn, success ? i18n.copy_success : i18n.copy_error);
} else {
showTemporaryTooltip(btn, i18n.copy_error);
diff --git a/web_src/js/features/dropzone.ts b/web_src/js/features/dropzone.ts
index 666c645230..20f7ceb6c3 100644
--- a/web_src/js/features/dropzone.ts
+++ b/web_src/js/features/dropzone.ts
@@ -1,21 +1,23 @@
import {svg} from '../svg.ts';
-import {htmlEscape} from 'escape-goat';
+import {html} from '../utils/html.ts';
import {clippie} from 'clippie';
import {showTemporaryTooltip} from '../modules/tippy.ts';
import {GET, POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
import {isImageFile, isVideoFile} from '../utils.ts';
-import type {DropzoneFile} from 'dropzone/index.js';
+import type {DropzoneFile, DropzoneOptions} from 'dropzone/index.js';
const {csrfToken, i18n} = window.config;
+type CustomDropzoneFile = DropzoneFile & {uuid: string};
+
// dropzone has its owner event dispatcher (emitter)
export const DropzoneCustomEventReloadFiles = 'dropzone-custom-reload-files';
export const DropzoneCustomEventRemovedFile = 'dropzone-custom-removed-file';
export const DropzoneCustomEventUploadDone = 'dropzone-custom-upload-done';
-async function createDropzone(el, opts) {
+async function createDropzone(el: HTMLElement, opts: DropzoneOptions) {
const [{default: Dropzone}] = await Promise.all([
import(/* webpackChunkName: "dropzone" */'dropzone'),
import(/* webpackChunkName: "dropzone" */'dropzone/dist/dropzone.css'),
@@ -23,7 +25,7 @@ async function createDropzone(el, opts) {
return new Dropzone(el, opts);
}
-export function generateMarkdownLinkForAttachment(file, {width, dppx}: {width?: number, dppx?: number} = {}) {
+export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFile>, {width, dppx}: {width?: number, dppx?: number} = {}) {
let fileMarkdown = `[${file.name}](/attachments/${file.uuid})`;
if (isImageFile(file)) {
fileMarkdown = `!${fileMarkdown}`;
@@ -31,19 +33,19 @@ export function generateMarkdownLinkForAttachment(file, {width, dppx}: {width?:
// Scale down images from HiDPI monitors. This uses the <img> tag because it's the only
// method to change image size in Markdown that is supported by all implementations.
// Make the image link relative to the repo path, then the final URL is "/sub-path/owner/repo/attachments/{uuid}"
- fileMarkdown = `<img width="${Math.round(width / dppx)}" alt="${htmlEscape(file.name)}" src="attachments/${htmlEscape(file.uuid)}">`;
+ fileMarkdown = html`<img width="${Math.round(width / dppx)}" alt="${file.name}" src="attachments/${file.uuid}">`;
} else {
// Markdown always renders the image with a relative path, so the final URL is "/sub-path/owner/repo/attachments/{uuid}"
// TODO: it should also use relative path for consistency, because absolute is ambiguous for "/sub-path/attachments" or "/attachments"
fileMarkdown = `![${file.name}](/attachments/${file.uuid})`;
}
} else if (isVideoFile(file)) {
- fileMarkdown = `<video src="attachments/${htmlEscape(file.uuid)}" title="${htmlEscape(file.name)}" controls></video>`;
+ fileMarkdown = html`<video src="attachments/${file.uuid}" title="${file.name}" controls></video>`;
}
return fileMarkdown;
}
-function addCopyLink(file) {
+function addCopyLink(file: Partial<CustomDropzoneFile>) {
// Create a "Copy Link" element, to conveniently copy the image or file link as Markdown to the clipboard
// The "<a>" element has a hardcoded cursor: pointer because the default is overridden by .dropzone
const copyLinkEl = createElementFromHTML(`
@@ -58,6 +60,8 @@ function addCopyLink(file) {
file.previewTemplate.append(copyLinkEl);
}
+type FileUuidDict = Record<string, {submitted: boolean}>;
+
/**
* @param {HTMLElement} dropzoneEl
*/
@@ -67,7 +71,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
const attachmentBaseLinkUrl = dropzoneEl.getAttribute('data-link-url');
let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event
- let fileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone
+ let fileUuidDict: FileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone
const opts: Record<string, any> = {
url: dropzoneEl.getAttribute('data-upload-url'),
headers: {'X-Csrf-Token': csrfToken},
@@ -89,7 +93,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
// "http://localhost:3000/owner/repo/issues/[object%20Event]"
// the reason is that the preview "callback(dataURL)" is assign to "img.onerror" then "thumbnail" uses the error object as the dataURL and generates '<img src="[object Event]">'
const dzInst = await createDropzone(dropzoneEl, opts);
- dzInst.on('success', (file: DropzoneFile & {uuid: string}, resp: any) => {
+ dzInst.on('success', (file: CustomDropzoneFile, resp: any) => {
file.uuid = resp.uuid;
fileUuidDict[file.uuid] = {submitted: false};
const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${resp.uuid}`, value: resp.uuid});
@@ -98,7 +102,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
dzInst.emit(DropzoneCustomEventUploadDone, {file});
});
- dzInst.on('removedfile', async (file: DropzoneFile & {uuid: string}) => {
+ dzInst.on('removedfile', async (file: CustomDropzoneFile) => {
if (disableRemovedfileEvent) return;
dzInst.emit(DropzoneCustomEventRemovedFile, {fileUuid: file.uuid});
diff --git a/web_src/js/features/emoji.ts b/web_src/js/features/emoji.ts
index 933aa951c5..69afe491e2 100644
--- a/web_src/js/features/emoji.ts
+++ b/web_src/js/features/emoji.ts
@@ -1,4 +1,5 @@
import emojis from '../../../assets/emoji.json' with {type: 'json'};
+import {html} from '../utils/html.ts';
const {assetUrlPrefix, customEmojis} = window.config;
@@ -15,24 +16,23 @@ export const emojiKeys = Object.keys(tempMap).sort((a, b) => {
return a.localeCompare(b);
});
-const emojiMap = {};
+const emojiMap: Record<string, string> = {};
for (const key of emojiKeys) {
emojiMap[key] = tempMap[key];
}
// retrieve HTML for given emoji name
-export function emojiHTML(name) {
+export function emojiHTML(name: string) {
let inner;
if (Object.hasOwn(customEmojis, name)) {
- inner = `<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
+ inner = html`<img alt=":${name}:" src="${assetUrlPrefix}/img/emoji/${name}.png">`;
} else {
inner = emojiString(name);
}
-
- return `<span class="emoji" title=":${name}:">${inner}</span>`;
+ return html`<span class="emoji" title=":${name}:">${inner}</span>`;
}
// retrieve string for given emoji name
-export function emojiString(name) {
+export function emojiString(name: string) {
return emojiMap[name] || `:${name}:`;
}
diff --git a/web_src/js/features/file-fold.ts b/web_src/js/features/file-fold.ts
index 6fe068341a..74b36c0096 100644
--- a/web_src/js/features/file-fold.ts
+++ b/web_src/js/features/file-fold.ts
@@ -5,15 +5,15 @@ import {svg} from '../svg.ts';
// The fold arrow is the icon displayed on the upper left of the file box, especially intended for components having the 'fold-file' class.
// The file content box is the box that should be hidden or shown, especially intended for components having the 'file-content' class.
//
-export function setFileFolding(fileContentBox, foldArrow, newFold) {
+export function setFileFolding(fileContentBox: Element, foldArrow: HTMLElement, newFold: boolean) {
foldArrow.innerHTML = svg(`octicon-chevron-${newFold ? 'right' : 'down'}`, 18);
- fileContentBox.setAttribute('data-folded', newFold);
+ fileContentBox.setAttribute('data-folded', String(newFold));
if (newFold && fileContentBox.getBoundingClientRect().top < 0) {
fileContentBox.scrollIntoView();
}
}
// Like `setFileFolding`, except that it automatically inverts the current file folding state.
-export function invertFileFolding(fileContentBox, foldArrow) {
+export function invertFileFolding(fileContentBox:HTMLElement, foldArrow: HTMLElement) {
setFileFolding(fileContentBox, foldArrow, fileContentBox.getAttribute('data-folded') !== 'true');
}
diff --git a/web_src/js/features/file-view.ts b/web_src/js/features/file-view.ts
new file mode 100644
index 0000000000..d803f53c0d
--- /dev/null
+++ b/web_src/js/features/file-view.ts
@@ -0,0 +1,76 @@
+import type {FileRenderPlugin} from '../render/plugin.ts';
+import {newRenderPlugin3DViewer} from '../render/plugins/3d-viewer.ts';
+import {newRenderPluginPdfViewer} from '../render/plugins/pdf-viewer.ts';
+import {registerGlobalInitFunc} from '../modules/observer.ts';
+import {createElementFromHTML, showElem, toggleClass} from '../utils/dom.ts';
+import {html} from '../utils/html.ts';
+import {basename} from '../utils.ts';
+
+const plugins: FileRenderPlugin[] = [];
+
+function initPluginsOnce(): void {
+ if (plugins.length) return;
+ plugins.push(newRenderPlugin3DViewer(), newRenderPluginPdfViewer());
+}
+
+function findFileRenderPlugin(filename: string, mimeType: string): FileRenderPlugin | null {
+ return plugins.find((plugin) => plugin.canHandle(filename, mimeType)) || null;
+}
+
+function showRenderRawFileButton(elFileView: HTMLElement, renderContainer: HTMLElement | null): void {
+ const toggleButtons = elFileView.querySelector('.file-view-toggle-buttons');
+ showElem(toggleButtons);
+ const displayingRendered = Boolean(renderContainer);
+ toggleClass(toggleButtons.querySelectorAll('.file-view-toggle-source'), 'active', !displayingRendered); // it may not exist
+ toggleClass(toggleButtons.querySelector('.file-view-toggle-rendered'), 'active', displayingRendered);
+ // TODO: if there is only one button, hide it?
+}
+
+async function renderRawFileToContainer(container: HTMLElement, rawFileLink: string, mimeType: string) {
+ const elViewRawPrompt = container.querySelector('.file-view-raw-prompt');
+ if (!rawFileLink || !elViewRawPrompt) throw new Error('unexpected file view container');
+
+ let rendered = false, errorMsg = '';
+ try {
+ const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
+ if (plugin) {
+ container.classList.add('is-loading');
+ container.setAttribute('data-render-name', plugin.name); // not used yet
+ await plugin.render(container, rawFileLink);
+ rendered = true;
+ }
+ } catch (e) {
+ errorMsg = `${e}`;
+ } finally {
+ container.classList.remove('is-loading');
+ }
+
+ if (rendered) {
+ elViewRawPrompt.remove();
+ return;
+ }
+
+ // remove all children from the container, and only show the raw file link
+ container.replaceChildren(elViewRawPrompt);
+
+ if (errorMsg) {
+ const elErrorMessage = createElementFromHTML(html`<div class="ui error message">${errorMsg}</div>`);
+ elViewRawPrompt.insertAdjacentElement('afterbegin', elErrorMessage);
+ }
+}
+
+export function initRepoFileView(): void {
+ registerGlobalInitFunc('initRepoFileView', async (elFileView: HTMLElement) => {
+ initPluginsOnce();
+ const rawFileLink = elFileView.getAttribute('data-raw-file-link');
+ const mimeType = elFileView.getAttribute('data-mime-type') || ''; // not used yet
+ // TODO: we should also provide the prefetched file head bytes to let the plugin decide whether to render or not
+ const plugin = findFileRenderPlugin(basename(rawFileLink), mimeType);
+ if (!plugin) return;
+
+ const renderContainer = elFileView.querySelector<HTMLElement>('.file-view-render-container');
+ showRenderRawFileButton(elFileView, renderContainer);
+ // maybe in the future multiple plugins can render the same file, so we should not assume only one plugin will render it
+ if (renderContainer) await renderRawFileToContainer(renderContainer, rawFileLink, mimeType);
+ });
+}
diff --git a/web_src/js/features/heatmap.ts b/web_src/js/features/heatmap.ts
index 53eebc93e5..7cec82108b 100644
--- a/web_src/js/features/heatmap.ts
+++ b/web_src/js/features/heatmap.ts
@@ -7,7 +7,7 @@ export function initHeatmap() {
if (!el) return;
try {
- const heatmap = {};
+ const heatmap: Record<string, number> = {};
for (const {contributions, timestamp} of JSON.parse(el.getAttribute('data-heatmap-data'))) {
// Convert to user timezone and sum contributions by date
const dateStr = new Date(timestamp * 1000).toDateString();
diff --git a/web_src/js/features/imagediff.ts b/web_src/js/features/imagediff.ts
index cd61888f83..20682f74d9 100644
--- a/web_src/js/features/imagediff.ts
+++ b/web_src/js/features/imagediff.ts
@@ -3,7 +3,7 @@ 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) {
+function getDefaultSvgBoundsIfUndefined(text: string, src: string) {
const defaultSize = 300;
const maxSize = 99999;
@@ -38,7 +38,7 @@ function getDefaultSvgBoundsIfUndefined(text, src) {
return null;
}
-function createContext(imageAfter, imageBefore) {
+function createContext(imageAfter: HTMLImageElement, imageBefore: HTMLImageElement) {
const sizeAfter = {
width: imageAfter?.width || 0,
height: imageAfter?.height || 0,
@@ -75,7 +75,7 @@ class ImageDiff {
this.containerEl = containerEl;
containerEl.setAttribute('data-image-diff-loaded', 'true');
- fomanticQuery(containerEl).find('.ui.menu.tabular .item').tab({autoTabActivation: false});
+ fomanticQuery(containerEl).find('.ui.menu.tabular .item').tab();
// 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);
@@ -123,7 +123,7 @@ class ImageDiff {
queryElemChildren(containerEl, '.image-diff-tabs', (el) => el.classList.remove('is-loading'));
}
- initSideBySide(sizes) {
+ initSideBySide(sizes: Record<string, any>) {
let factor = 1;
if (sizes.maxSize.width > (this.diffContainerWidth - 24) / 2) {
factor = (this.diffContainerWidth - 24) / 2 / sizes.maxSize.width;
@@ -176,7 +176,7 @@ class ImageDiff {
}
}
- initSwipe(sizes) {
+ initSwipe(sizes: Record<string, any>) {
let factor = 1;
if (sizes.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / sizes.maxSize.width;
@@ -215,14 +215,14 @@ class ImageDiff {
this.containerEl.querySelector('.swipe-bar').addEventListener('mousedown', (e) => {
e.preventDefault();
- this.initSwipeEventListeners(e.currentTarget);
+ this.initSwipeEventListeners(e.currentTarget as HTMLElement);
});
}
- initSwipeEventListeners(swipeBar) {
- const swipeFrame = swipeBar.parentNode;
+ initSwipeEventListeners(swipeBar: HTMLElement) {
+ const swipeFrame = swipeBar.parentNode as HTMLElement;
const width = swipeFrame.clientWidth;
- const onSwipeMouseMove = (e) => {
+ const onSwipeMouseMove = (e: MouseEvent) => {
e.preventDefault();
const rect = swipeFrame.getBoundingClientRect();
const value = Math.max(0, Math.min(e.clientX - rect.left, width));
@@ -237,7 +237,7 @@ class ImageDiff {
document.addEventListener('mouseup', removeEventListeners);
}
- initOverlay(sizes) {
+ initOverlay(sizes: Record<string, any>) {
let factor = 1;
if (sizes.maxSize.width > this.diffContainerWidth - 12) {
factor = (this.diffContainerWidth - 12) / sizes.maxSize.width;
diff --git a/web_src/js/features/install.ts b/web_src/js/features/install.ts
index dddeb1e954..ca4bcce881 100644
--- a/web_src/js/features/install.ts
+++ b/web_src/js/features/install.ts
@@ -12,11 +12,12 @@ export function initInstall() {
initPreInstall();
}
}
+
function initPreInstall() {
const defaultDbUser = 'gitea';
const defaultDbName = 'gitea';
- const defaultDbHosts = {
+ const defaultDbHosts: Record<string, string> = {
mysql: '127.0.0.1:3306',
postgres: '127.0.0.1:5432',
mssql: '127.0.0.1:1433',
@@ -103,7 +104,7 @@ function initPreInstall() {
}
function initPostInstall() {
- const el = document.querySelector('#goto-user-login');
+ const el = document.querySelector('#goto-after-install');
if (!el) return;
const targetUrl = el.getAttribute('href');
diff --git a/web_src/js/features/org-team.ts b/web_src/js/features/org-team.ts
index e160f07bf2..d07818b0ac 100644
--- a/web_src/js/features/org-team.ts
+++ b/web_src/js/features/org-team.ts
@@ -21,7 +21,7 @@ function initOrgTeamSearchRepoBox() {
minCharacters: 2,
apiSettings: {
url: `${appSubUrl}/repo/search?q={query}&uid=${$searchRepoBox.data('uid')}`,
- onResponse(response) {
+ onResponse(response: any) {
const items = [];
for (const item of response.data) {
items.push({
diff --git a/web_src/js/features/pull-view-file.ts b/web_src/js/features/pull-view-file.ts
index 5202d84b28..1124886238 100644
--- a/web_src/js/features/pull-view-file.ts
+++ b/web_src/js/features/pull-view-file.ts
@@ -1,4 +1,4 @@
-import {diffTreeStore} from '../modules/stores.ts';
+import {diffTreeStore, diffTreeStoreSetViewed} from '../modules/diff-file.ts';
import {setFileFolding} from './file-fold.ts';
import {POST} from '../modules/fetch.ts';
@@ -58,14 +58,11 @@ export function initViewedCheckboxListenerFor() {
const fileName = checkbox.getAttribute('name');
- // check if the file is in our difftreestore and if we find it -> change the IsViewed status
- const fileInPageData = diffTreeStore().files.find((x) => x.Name === fileName);
- if (fileInPageData) {
- fileInPageData.IsViewed = this.checked;
- }
+ // check if the file is in our diffTreeStore and if we find it -> change the IsViewed status
+ diffTreeStoreSetViewed(diffTreeStore(), fileName, this.checked);
// Unfortunately, actual forms cause too many problems, hence another approach is needed
- const files = {};
+ const files: Record<string, boolean> = {};
files[fileName] = this.checked;
const data: Record<string, any> = {files};
const headCommitSHA = form.getAttribute('data-headcommit');
@@ -82,13 +79,13 @@ export function initViewedCheckboxListenerFor() {
export function initExpandAndCollapseFilesButton() {
// expand btn
document.querySelector(expandFilesBtnSelector)?.addEventListener('click', () => {
- for (const box of document.querySelectorAll('.file-content[data-folded="true"]')) {
+ for (const box of document.querySelectorAll<HTMLElement>('.file-content[data-folded="true"]')) {
setFileFolding(box, box.querySelector('.fold-file'), false);
}
});
// collapse btn, need to exclude the div of “show more”
document.querySelector(collapseFilesBtnSelector)?.addEventListener('click', () => {
- for (const box of document.querySelectorAll('.file-content:not([data-folded="true"])')) {
+ for (const box of document.querySelectorAll<HTMLElement>('.file-content:not([data-folded="true"])')) {
if (box.getAttribute('id') === 'diff-incomplete') continue;
setFileFolding(box, box.querySelector('.fold-file'), true);
}
diff --git a/web_src/js/features/repo-actions.ts b/web_src/js/features/repo-actions.ts
index cbd0429c04..8d93fce53f 100644
--- a/web_src/js/features/repo-actions.ts
+++ b/web_src/js/features/repo-actions.ts
@@ -24,6 +24,7 @@ export function initRepositoryActionView() {
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
areYouSure: el.getAttribute('data-locale-are-you-sure'),
+ artifactExpired: el.getAttribute('data-locale-artifact-expired'),
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
diff --git a/web_src/js/features/repo-code.ts b/web_src/js/features/repo-code.ts
index 207022ca42..bf7fd762b0 100644
--- a/web_src/js/features/repo-code.ts
+++ b/web_src/js/features/repo-code.ts
@@ -1,6 +1,5 @@
import {svg} from '../svg.ts';
import {createTippy} from '../modules/tippy.ts';
-import {clippie} from 'clippie';
import {toAbsoluteUrl} from '../utils.ts';
import {addDelegatedEventListener} from '../utils/dom.ts';
@@ -43,7 +42,8 @@ function selectRange(range: string): Element {
if (!copyPermalink) return;
let link = copyPermalink.getAttribute('data-url');
link = `${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
- copyPermalink.setAttribute('data-url', link);
+ copyPermalink.setAttribute('data-clipboard-text', link);
+ copyPermalink.setAttribute('data-clipboard-text-type', 'url');
};
const rangeFields = range ? range.split('-') : [];
@@ -110,10 +110,15 @@ function showLineButton() {
}
export function initRepoCodeView() {
- if (!document.querySelector('.code-view .lines-num')) return;
+ // When viewing a file or blame, there is always a ".file-view" element,
+ // but the ".code-view" class is only present when viewing the "code" of a file; it is not present when viewing a PDF file.
+ // Since the ".file-view" will be dynamically reloaded when navigating via the left file tree (eg: view a PDF file, then view a source code file, etc.)
+ // the "code-view" related event listeners should always be added when the current page contains ".file-view" element.
+ if (!document.querySelector('.repo-view-container .file-view')) return;
+ // "file code view" and "blame" pages need this "line number button" feature
let selRangeStart: string;
- addDelegatedEventListener(document, 'click', '.lines-num span', (el: HTMLElement, e: KeyboardEvent) => {
+ addDelegatedEventListener(document, 'click', '.code-view .lines-num span', (el: HTMLElement, e: KeyboardEvent) => {
if (!selRangeStart || !e.shiftKey) {
selRangeStart = el.getAttribute('id');
selectRange(selRangeStart);
@@ -125,12 +130,14 @@ export function initRepoCodeView() {
showLineButton();
});
+ // apply the selected range from the URL hash
const onHashChange = () => {
if (!window.location.hash) return;
+ if (!document.querySelector('.code-view .lines-num')) return;
const range = window.location.hash.substring(1);
const first = selectRange(range);
if (first) {
- // set scrollRestoration to 'manual' when there is a hash in url, so that the scroll position will not be remembered after refreshing
+ // set scrollRestoration to 'manual' when there is a hash in the URL, so that the scroll position will not be remembered after refreshing
if (window.history.scrollRestoration !== 'manual') window.history.scrollRestoration = 'manual';
first.scrollIntoView({block: 'start'});
showLineButton();
@@ -138,8 +145,4 @@ export function initRepoCodeView() {
};
onHashChange();
window.addEventListener('hashchange', onHashChange);
-
- addDelegatedEventListener(document, 'click', '.copy-line-permalink', (el) => {
- clippie(toAbsoluteUrl(el.getAttribute('data-url')));
- });
}
diff --git a/web_src/js/features/repo-commit.ts b/web_src/js/features/repo-commit.ts
index 8994a57f4a..98ec2328ec 100644
--- a/web_src/js/features/repo-commit.ts
+++ b/web_src/js/features/repo-commit.ts
@@ -1,27 +1,26 @@
import {createTippy} from '../modules/tippy.ts';
import {toggleElem} from '../utils/dom.ts';
+import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
export function initRepoEllipsisButton() {
- for (const button of document.querySelectorAll<HTMLButtonElement>('.js-toggle-commit-body')) {
- button.addEventListener('click', function (e) {
- e.preventDefault();
- const expanded = this.getAttribute('aria-expanded') === 'true';
- toggleElem(this.parentElement.querySelector('.commit-body'));
- this.setAttribute('aria-expanded', String(!expanded));
- });
- }
+ registerGlobalEventFunc('click', 'onRepoEllipsisButtonClick', async (el: HTMLInputElement, e: Event) => {
+ e.preventDefault();
+ const expanded = el.getAttribute('aria-expanded') === 'true';
+ toggleElem(el.parentElement.querySelector('.commit-body'));
+ el.setAttribute('aria-expanded', String(!expanded));
+ });
}
export function initCommitStatuses() {
- for (const element of document.querySelectorAll('[data-tippy="commit-statuses"]')) {
- const top = document.querySelector('.repository.file.list') || document.querySelector('.repository.diff');
-
- createTippy(element, {
- content: element.nextElementSibling,
- placement: top ? 'top-start' : 'bottom-start',
+ registerGlobalInitFunc('initCommitStatuses', (el: HTMLElement) => {
+ const nextEl = el.nextElementSibling;
+ if (!nextEl.matches('.tippy-target')) throw new Error('Expected next element to be a tippy target');
+ createTippy(el, {
+ content: nextEl,
+ placement: 'bottom-start',
interactive: true,
role: 'dialog',
theme: 'box-with-header',
});
- }
+ });
}
diff --git a/web_src/js/features/repo-common.test.ts b/web_src/js/features/repo-common.test.ts
new file mode 100644
index 0000000000..33a29ecb2c
--- /dev/null
+++ b/web_src/js/features/repo-common.test.ts
@@ -0,0 +1,22 @@
+import {sanitizeRepoName, substituteRepoOpenWithUrl} from './repo-common.ts';
+
+test('substituteRepoOpenWithUrl', () => {
+ // For example: "x-github-client://openRepo/https://github.com/go-gitea/gitea"
+ expect(substituteRepoOpenWithUrl('proto://a/{url}', 'https://gitea')).toEqual('proto://a/https://gitea');
+ expect(substituteRepoOpenWithUrl('proto://a?link={url}', 'https://gitea')).toEqual('proto://a?link=https%3A%2F%2Fgitea');
+});
+
+test('sanitizeRepoName', () => {
+ expect(sanitizeRepoName(' a b ')).toEqual('a-b');
+ expect(sanitizeRepoName('a-b_c.git ')).toEqual('a-b_c');
+ expect(sanitizeRepoName('/x.git/')).toEqual('-x.git-');
+ expect(sanitizeRepoName('.profile')).toEqual('.profile');
+ expect(sanitizeRepoName('.profile.')).toEqual('.profile');
+ expect(sanitizeRepoName('.pro..file')).toEqual('.pro.file');
+
+ expect(sanitizeRepoName('foo.rss.atom.git.wiki')).toEqual('foo');
+
+ expect(sanitizeRepoName('.')).toEqual('');
+ expect(sanitizeRepoName('..')).toEqual('');
+ expect(sanitizeRepoName('-')).toEqual('');
+});
diff --git a/web_src/js/features/repo-common.ts b/web_src/js/features/repo-common.ts
index 90860720e4..ebb6881c67 100644
--- a/web_src/js/features/repo-common.ts
+++ b/web_src/js/features/repo-common.ts
@@ -1,4 +1,4 @@
-import {queryElems} from '../utils/dom.ts';
+import {queryElems, type DOMEvent} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {sleep} from '../utils.ts';
@@ -7,10 +7,10 @@ import {createApp} from 'vue';
import {toOriginUrl} from '../utils/url.ts';
import {createTippy} from '../modules/tippy.ts';
-async function onDownloadArchive(e) {
+async function onDownloadArchive(e: DOMEvent<MouseEvent>) {
e.preventDefault();
// there are many places using the "archive-link", eg: the dropdown on the repo code page, the release list
- const el = e.target.closest('a.archive-link[href]');
+ const el = e.target.closest<HTMLAnchorElement>('a.archive-link[href]');
const targetLoading = el.closest('.ui.dropdown') ?? el;
targetLoading.classList.add('is-loading', 'loading-icon-2px');
try {
@@ -42,23 +42,60 @@ export function initRepoActivityTopAuthorsChart() {
}
}
+export function substituteRepoOpenWithUrl(tmpl: string, url: string): string {
+ const pos = tmpl.indexOf('{url}');
+ if (pos === -1) return tmpl;
+ const posQuestionMark = tmpl.indexOf('?');
+ const needEncode = posQuestionMark >= 0 && posQuestionMark < pos;
+ return tmpl.replace('{url}', needEncode ? encodeURIComponent(url) : url);
+}
+
function initCloneSchemeUrlSelection(parent: Element) {
const elCloneUrlInput = parent.querySelector<HTMLInputElement>('.repo-clone-url');
- const tabSsh = parent.querySelector('.repo-clone-ssh');
const tabHttps = parent.querySelector('.repo-clone-https');
+ const tabSsh = parent.querySelector('.repo-clone-ssh');
+ const tabTea = parent.querySelector('.repo-clone-tea');
const updateClonePanelUi = function() {
- const scheme = localStorage.getItem('repo-clone-protocol') || 'https';
- const isSSH = scheme === 'ssh' && Boolean(tabSsh) || scheme !== 'ssh' && !tabHttps;
+ let scheme = localStorage.getItem('repo-clone-protocol');
+ if (!['https', 'ssh', 'tea'].includes(scheme)) {
+ scheme = 'https';
+ }
+
+ // Fallbacks if the scheme preference is not available in the tabs, for example: empty repo page, there are only HTTPS and SSH
+ if (scheme === 'tea' && !tabTea) {
+ scheme = 'https';
+ }
+ if (scheme === 'https' && !tabHttps) {
+ scheme = 'ssh';
+ } else if (scheme === 'ssh' && !tabSsh) {
+ scheme = 'https';
+ }
+
+ const isHttps = scheme === 'https';
+ const isSsh = scheme === 'ssh';
+ const isTea = scheme === 'tea';
+
if (tabHttps) {
tabHttps.textContent = window.origin.split(':')[0].toUpperCase(); // show "HTTP" or "HTTPS"
- tabHttps.classList.toggle('active', !isSSH);
+ tabHttps.classList.toggle('active', isHttps);
}
if (tabSsh) {
- tabSsh.classList.toggle('active', isSSH);
+ tabSsh.classList.toggle('active', isSsh);
+ }
+ if (tabTea) {
+ tabTea.classList.toggle('active', isTea);
+ }
+
+ let tab: Element;
+ if (isHttps) {
+ tab = tabHttps;
+ } else if (isSsh) {
+ tab = tabSsh;
+ } else if (isTea) {
+ tab = tabTea;
}
- const tab = isSSH ? tabSsh : tabHttps;
if (!tab) return;
const link = toOriginUrl(tab.getAttribute('data-link'));
@@ -70,18 +107,22 @@ function initCloneSchemeUrlSelection(parent: Element) {
}
}
for (const el of parent.querySelectorAll<HTMLAnchorElement>('.js-clone-url-editor')) {
- el.href = el.getAttribute('data-href-template').replace('{url}', encodeURIComponent(link));
+ el.href = substituteRepoOpenWithUrl(el.getAttribute('data-href-template'), link);
}
};
updateClonePanelUi();
// tabSsh or tabHttps might not both exist, eg: guest view, or one is disabled by the server
+ tabHttps?.addEventListener('click', () => {
+ localStorage.setItem('repo-clone-protocol', 'https');
+ updateClonePanelUi();
+ });
tabSsh?.addEventListener('click', () => {
localStorage.setItem('repo-clone-protocol', 'ssh');
updateClonePanelUi();
});
- tabHttps?.addEventListener('click', () => {
- localStorage.setItem('repo-clone-protocol', 'https');
+ tabTea?.addEventListener('click', () => {
+ localStorage.setItem('repo-clone-protocol', 'tea');
updateClonePanelUi();
});
elCloneUrlInput.addEventListener('focus', () => {
@@ -99,6 +140,7 @@ function initClonePanelButton(btn: HTMLButtonElement) {
placement: 'bottom-end',
interactive: true,
hideOnClick: true,
+ arrow: false,
});
}
@@ -107,7 +149,7 @@ export function initRepoCloneButtons() {
queryElems(document, '.clone-buttons-combo', initCloneSchemeUrlSelection);
}
-export async function updateIssuesMeta(url, action, issue_ids, id) {
+export async function updateIssuesMeta(url: string, action: string, issue_ids: string, id: string) {
try {
const response = await POST(url, {data: new URLSearchParams({action, issue_ids, id})});
if (!response.ok) {
@@ -117,3 +159,19 @@ export async function updateIssuesMeta(url, action, issue_ids, id) {
console.error(error);
}
}
+
+export function sanitizeRepoName(name: string): string {
+ name = name.trim().replace(/[^-.\w]/g, '-');
+ for (let lastName = ''; lastName !== name;) {
+ lastName = name;
+ name = name.replace(/\.+$/g, '');
+ name = name.replace(/\.{2,}/g, '.');
+ for (const ext of ['.git', '.wiki', '.rss', '.atom']) {
+ if (name.endsWith(ext)) {
+ name = name.substring(0, name.length - ext.length);
+ }
+ }
+ }
+ if (['.', '..', '-'].includes(name)) name = '';
+ return name;
+}
diff --git a/web_src/js/features/repo-diff-filetree.ts b/web_src/js/features/repo-diff-filetree.ts
index bc275a90f6..cc4576a846 100644
--- a/web_src/js/features/repo-diff-filetree.ts
+++ b/web_src/js/features/repo-diff-filetree.ts
@@ -1,6 +1,5 @@
import {createApp} from 'vue';
import DiffFileTree from '../components/DiffFileTree.vue';
-import DiffFileList from '../components/DiffFileList.vue';
export function initDiffFileTree() {
const el = document.querySelector('#diff-file-tree');
@@ -9,11 +8,3 @@ export function initDiffFileTree() {
const fileTreeView = createApp(DiffFileTree);
fileTreeView.mount(el);
}
-
-export function initDiffFileList() {
- const fileListElement = document.querySelector('#diff-file-list');
- if (!fileListElement) return;
-
- const fileListView = createApp(DiffFileList);
- fileListView.mount(fileListElement);
-}
diff --git a/web_src/js/features/repo-diff.ts b/web_src/js/features/repo-diff.ts
index 0cb2e566c0..ad1da5c2fa 100644
--- a/web_src/js/features/repo-diff.ts
+++ b/web_src/js/features/repo-diff.ts
@@ -1,41 +1,31 @@
-import $ from 'jquery';
-import {initCompReactionSelector} from './comp/ReactionSelector.ts';
import {initRepoIssueContentHistory} from './repo-issue-content.ts';
-import {initDiffFileTree, initDiffFileList} from './repo-diff-filetree.ts';
+import {initDiffFileTree} from './repo-diff-filetree.ts';
import {initDiffCommitSelect} from './repo-diff-commitselect.ts';
import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.ts';
import {initViewedCheckboxListenerFor, countAndUpdateViewedFiles, initExpandAndCollapseFilesButton} from './pull-view-file.ts';
import {initImageDiff} from './imagediff.ts';
import {showErrorToast} from '../modules/toast.ts';
-import {
- submitEventSubmitter,
- queryElemSiblings,
- hideElem,
- showElem,
- animateOnce,
- addDelegatedEventListener,
- createElementFromHTML,
-} from '../utils/dom.ts';
+import {submitEventSubmitter, queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts';
import {POST, GET} from '../modules/fetch.ts';
-import {fomanticQuery} from '../modules/fomantic/base.ts';
import {createTippy} from '../modules/tippy.ts';
import {invertFileFolding} from './file-fold.ts';
+import {parseDom} from '../utils.ts';
+import {registerGlobalSelectorFunc} from '../modules/observer.ts';
-const {pageData, i18n} = window.config;
+const {i18n} = window.config;
-function initRepoDiffFileViewToggle() {
- $('.file-view-toggle').on('click', function () {
- for (const el of queryElemSiblings(this)) {
- el.classList.remove('active');
- }
- this.classList.add('active');
+function initRepoDiffFileBox(el: HTMLElement) {
+ // switch between "rendered" and "source", for image and CSV files
+ queryElems(el, '.file-view-toggle', (btn) => btn.addEventListener('click', () => {
+ queryElemSiblings(btn, '.file-view-toggle', (el) => el.classList.remove('active'));
+ btn.classList.add('active');
- const target = document.querySelector(this.getAttribute('data-toggle-selector'));
- if (!target) return;
+ const target = document.querySelector(btn.getAttribute('data-toggle-selector'));
+ if (!target) throw new Error('Target element not found');
hideElem(queryElemSiblings(target));
showElem(target);
- });
+ }));
}
function initRepoDiffConversationForm() {
@@ -83,7 +73,6 @@ function initRepoDiffConversationForm() {
el.classList.add('tw-invisible');
}
}
- fomanticQuery(newConversationHolder.querySelectorAll('.ui.dropdown')).dropdown();
// the default behavior is to add a pending review, so if no submitter, it also means "pending_review"
if (!submitter || submitter?.matches('button[name="pending_review"]')) {
@@ -103,22 +92,21 @@ function initRepoDiffConversationForm() {
}
});
- $(document).on('click', '.resolve-conversation', async function (e) {
+ addDelegatedEventListener(document, 'click', '.resolve-conversation', async (el, e) => {
e.preventDefault();
- const comment_id = $(this).data('comment-id');
- const origin = $(this).data('origin');
- const action = $(this).data('action');
- const url = $(this).data('update-url');
+ const comment_id = el.getAttribute('data-comment-id');
+ const origin = el.getAttribute('data-origin');
+ const action = el.getAttribute('data-action');
+ const url = el.getAttribute('data-update-url');
try {
const response = await POST(url, {data: new URLSearchParams({origin, action, comment_id})});
const data = await response.text();
- if ($(this).closest('.conversation-holder').length) {
- const $conversation = $(data);
- $(this).closest('.conversation-holder').replaceWith($conversation);
- $conversation.find('.dropdown').dropdown();
- initCompReactionSelector($conversation[0]);
+ const elConversationHolder = el.closest('.conversation-holder');
+ if (elConversationHolder) {
+ const elNewConversation = createElementFromHTML(data);
+ elConversationHolder.replaceWith(elNewConversation);
} else {
window.location.reload();
}
@@ -128,24 +116,19 @@ function initRepoDiffConversationForm() {
});
}
-export function initRepoDiffConversationNav() {
+function initRepoDiffConversationNav() {
// Previous/Next code review conversation
- $(document).on('click', '.previous-conversation', (e) => {
- const $conversation = $(e.currentTarget).closest('.comment-code-cloud');
- const $conversations = $('.comment-code-cloud:not(.tw-hidden)');
- const index = $conversations.index($conversation);
- const previousIndex = index > 0 ? index - 1 : $conversations.length - 1;
- const $previousConversation = $conversations.eq(previousIndex);
- const anchor = $previousConversation.find('.comment').first()[0].getAttribute('id');
- window.location.href = `#${anchor}`;
- });
- $(document).on('click', '.next-conversation', (e) => {
- const $conversation = $(e.currentTarget).closest('.comment-code-cloud');
- const $conversations = $('.comment-code-cloud:not(.tw-hidden)');
- const index = $conversations.index($conversation);
- const nextIndex = index < $conversations.length - 1 ? index + 1 : 0;
- const $nextConversation = $conversations.eq(nextIndex);
- const anchor = $nextConversation.find('.comment').first()[0].getAttribute('id');
+ addDelegatedEventListener(document, 'click', '.previous-conversation, .next-conversation', (el, e) => {
+ e.preventDefault();
+ const isPrevious = el.matches('.previous-conversation');
+ const elCurConversation = el.closest('.comment-code-cloud');
+ const elAllConversations = document.querySelectorAll('.comment-code-cloud:not(.tw-hidden)');
+ const index = Array.from(elAllConversations).indexOf(elCurConversation);
+ const previousIndex = index > 0 ? index - 1 : elAllConversations.length - 1;
+ const nextIndex = index < elAllConversations.length - 1 ? index + 1 : 0;
+ const navIndex = isPrevious ? previousIndex : nextIndex;
+ const elNavConversation = elAllConversations[navIndex];
+ const anchor = elNavConversation.querySelector('.comment').id;
window.location.href = `#${anchor}`;
});
}
@@ -161,6 +144,7 @@ function initDiffHeaderPopup() {
// Will be called when the show more (files) button has been pressed
function onShowMoreFiles() {
+ // TODO: replace these calls with the "observer.ts" methods
initRepoIssueContentHistory();
initViewedCheckboxListenerFor();
countAndUpdateViewedFiles();
@@ -168,84 +152,111 @@ function onShowMoreFiles() {
initDiffHeaderPopup();
}
-export async function loadMoreFiles(url) {
- const target = document.querySelector('a#diff-show-more-files');
- if (target?.classList.contains('disabled') || pageData.diffFileInfo.isLoadingNewData) {
- return;
+async function loadMoreFiles(btn: Element): Promise<boolean> {
+ if (btn.classList.contains('disabled')) {
+ return false;
}
- pageData.diffFileInfo.isLoadingNewData = true;
- target?.classList.add('disabled');
-
+ btn.classList.add('disabled');
+ const url = btn.getAttribute('data-href');
try {
const response = await GET(url);
const resp = await response.text();
- const $resp = $(resp);
+ const respDoc = parseDom(resp, 'text/html');
+ const respFileBoxes = respDoc.querySelector('#diff-file-boxes');
// the response is a full HTML page, we need to extract the relevant contents:
- // 1. append the newly loaded file list items to the existing list
- $('#diff-incomplete').replaceWith($resp.find('#diff-file-boxes').children());
- // 2. re-execute the script to append the newly loaded items to the JS variables to refresh the DiffFileTree
- $('body').append($resp.find('script#diff-data-script'));
-
+ // * append the newly loaded file list items to the existing list
+ document.querySelector('#diff-incomplete').replaceWith(...Array.from(respFileBoxes.children));
onShowMoreFiles();
+ return true;
} catch (error) {
console.error('Error:', error);
showErrorToast('An error occurred while loading more files.');
} finally {
- target?.classList.remove('disabled');
- pageData.diffFileInfo.isLoadingNewData = false;
+ btn.classList.remove('disabled');
}
+ return false;
}
function initRepoDiffShowMore() {
- $(document).on('click', 'a#diff-show-more-files', (e) => {
+ addDelegatedEventListener(document, 'click', 'a#diff-show-more-files', (el, e) => {
e.preventDefault();
-
- const linkLoadMore = e.target.getAttribute('data-href');
- loadMoreFiles(linkLoadMore);
+ loadMoreFiles(el);
});
- $(document).on('click', 'a.diff-load-button', async (e) => {
+ addDelegatedEventListener(document, 'click', 'a.diff-load-button', async (el, e) => {
e.preventDefault();
- const $target = $(e.target);
-
- if (e.target.classList.contains('disabled')) {
- return;
- }
+ if (el.classList.contains('disabled')) return;
- e.target.classList.add('disabled');
-
- const url = $target.data('href');
+ el.classList.add('disabled');
+ const url = el.getAttribute('data-href');
try {
const response = await GET(url);
const resp = await response.text();
-
- if (!resp) {
- return;
- }
- $target.parent().replaceWith($(resp).find('#diff-file-boxes .diff-file-body .file-body').children());
+ const respDoc = parseDom(resp, 'text/html');
+ const respFileBody = respDoc.querySelector('#diff-file-boxes .diff-file-body .file-body');
+ const respFileBodyChildren = Array.from(respFileBody.children); // respFileBody.children will be empty after replaceWith
+ el.parentElement.replaceWith(...respFileBodyChildren);
+ for (const el of respFileBodyChildren) window.htmx.process(el);
+ // FIXME: calling onShowMoreFiles is not quite right here.
+ // But since onShowMoreFiles mixes "init diff box" and "init diff body" together,
+ // so it still needs to call it to make the "ImageDiff" and something similar work.
onShowMoreFiles();
} catch (error) {
console.error('Error:', error);
} finally {
- e.target.classList.remove('disabled');
+ el.classList.remove('disabled');
}
});
}
+async function loadUntilFound() {
+ const hashTargetSelector = window.location.hash;
+ if (!hashTargetSelector.startsWith('#diff-') && !hashTargetSelector.startsWith('#issuecomment-')) {
+ return;
+ }
+
+ while (true) {
+ // use getElementById to avoid querySelector throws an error when the hash is invalid
+ // eslint-disable-next-line unicorn/prefer-query-selector
+ const targetElement = document.getElementById(hashTargetSelector.substring(1));
+ if (targetElement) {
+ targetElement.scrollIntoView();
+ return;
+ }
+
+ // the button will be refreshed after each "load more", so query it every time
+ const showMoreButton = document.querySelector('#diff-show-more-files');
+ if (!showMoreButton) {
+ return; // nothing more to load
+ }
+
+ // Load more files, await ensures we don't block progress
+ const ok = await loadMoreFiles(showMoreButton);
+ if (!ok) return; // failed to load more files
+ }
+}
+
+function initRepoDiffHashChangeListener() {
+ window.addEventListener('hashchange', loadUntilFound);
+ loadUntilFound();
+}
+
export function initRepoDiffView() {
- initRepoDiffConversationForm();
- if (!$('#diff-file-list').length) return;
+ initRepoDiffConversationForm(); // such form appears on the "conversation" page and "diff" page
+
+ if (!document.querySelector('#diff-file-boxes')) return;
+ initRepoDiffConversationNav(); // "previous" and "next" buttons only appear on "diff" page
initDiffFileTree();
- initDiffFileList();
initDiffCommitSelect();
initRepoDiffShowMore();
initDiffHeaderPopup();
- initRepoDiffFileViewToggle();
initViewedCheckboxListenerFor();
initExpandAndCollapseFilesButton();
+ initRepoDiffHashChangeListener();
+ registerGlobalSelectorFunc('#diff-file-boxes .diff-file-box', initRepoDiffFileBox);
addDelegatedEventListener(document, 'click', '.fold-file', (el) => {
invertFileFolding(el.closest('.file-content'), el);
});
diff --git a/web_src/js/features/repo-editor.ts b/web_src/js/features/repo-editor.ts
index d7097787d2..f3ca13460c 100644
--- a/web_src/js/features/repo-editor.ts
+++ b/web_src/js/features/repo-editor.ts
@@ -1,13 +1,13 @@
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../utils/html.ts';
import {createCodeEditor} from './codeeditor.ts';
import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts';
-import {initMarkupContent} from '../markup/content.ts';
import {attachRefIssueContextPopup} from './contextpopup.ts';
import {POST} from '../modules/fetch.ts';
import {initDropzone} from './dropzone.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {applyAreYouSure, ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
+import {submitFormFetchAction} from './common-fetch-action.ts';
function initEditPreviewTab(elForm: HTMLFormElement) {
const elTabMenu = elForm.querySelector('.repo-editor-menu');
@@ -87,10 +87,10 @@ export function initRepoEditor() {
if (i < parts.length - 1) {
if (trimValue.length) {
const linkElement = createElementFromHTML(
- `<span class="section"><a href="#">${htmlEscape(value)}</a></span>`,
+ html`<span class="section"><a href="#">${value}</a></span>`,
);
const dividerElement = createElementFromHTML(
- `<div class="breadcrumb-divider">/</div>`,
+ html`<div class="breadcrumb-divider">/</div>`,
);
links.push(linkElement);
dividers.push(dividerElement);
@@ -113,7 +113,7 @@ export function initRepoEditor() {
if (!warningDiv) {
warningDiv = document.createElement('div');
warningDiv.classList.add('ui', 'warning', 'message', 'flash-message', 'flash-warning', 'space-related');
- warningDiv.innerHTML = '<p>File path contains leading or trailing whitespace.</p>';
+ warningDiv.innerHTML = html`<p>File path contains leading or trailing whitespace.</p>`;
// Add display 'block' because display is set to 'none' in formantic\build\semantic.css
warningDiv.style.display = 'block';
const inputContainer = document.querySelector('.repo-editor-header');
@@ -142,38 +142,36 @@ export function initRepoEditor() {
}
});
+ const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form');
+
// on the upload page, there is no editor(textarea)
const editArea = document.querySelector<HTMLTextAreaElement>('.page-content.repository.editor textarea#edit_area');
if (!editArea) return;
- const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form');
+ // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage
+ // to enable or disable the commit button
+ const commitButton = document.querySelector<HTMLButtonElement>('#commit-button');
+ const dirtyFileClass = 'dirty-file';
+
+ const syncCommitButtonState = () => {
+ const dirty = elForm.classList.contains(dirtyFileClass);
+ commitButton.disabled = !dirty;
+ };
+ // Registering a custom listener for the file path and the file content
+ // FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added
+ applyAreYouSure(elForm, {
+ silent: true,
+ dirtyClass: dirtyFileClass,
+ fieldSelector: ':input:not(.commit-form-wrapper :input)',
+ change: syncCommitButtonState,
+ });
+ syncCommitButtonState(); // disable the "commit" button when no content changes
+
initEditPreviewTab(elForm);
(async () => {
const editor = await createCodeEditor(editArea, filenameInput);
- // Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage
- // to enable or disable the commit button
- const commitButton = document.querySelector<HTMLButtonElement>('#commit-button');
- const dirtyFileClass = 'dirty-file';
-
- // Disabling the button at the start
- if (document.querySelector<HTMLInputElement>('input[name="page_has_posted"]').value !== 'true') {
- commitButton.disabled = true;
- }
-
- // Registering a custom listener for the file path and the file content
- // FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added
- applyAreYouSure(elForm, {
- silent: true,
- dirtyClass: dirtyFileClass,
- fieldSelector: ':input:not(.commit-form-wrapper :input)',
- change($form) {
- const dirty = $form[0]?.classList.contains(dirtyFileClass);
- commitButton.disabled = !dirty;
- },
- });
-
// Update the editor from query params, if available,
// only after the dirtyFileClass initialization
const params = new URLSearchParams(window.location.search);
@@ -182,7 +180,7 @@ export function initRepoEditor() {
editor.setValue(value);
}
- commitButton?.addEventListener('click', async (e) => {
+ commitButton.addEventListener('click', async (e) => {
// A modal which asks if an empty file should be committed
if (!editArea.value) {
e.preventDefault();
@@ -191,15 +189,15 @@ export function initRepoEditor() {
content: elForm.getAttribute('data-text-empty-confirm-content'),
})) {
ignoreAreYouSure(elForm);
- elForm.submit();
+ submitFormFetchAction(elForm);
}
}
});
})();
}
-export function renderPreviewPanelContent(previewPanel: Element, content: string) {
- previewPanel.innerHTML = content;
- initMarkupContent();
+export function renderPreviewPanelContent(previewPanel: Element, htmlContent: string) {
+ // the content is from the server, so it is safe to use innerHTML
+ previewPanel.innerHTML = html`<div class="render-content markup">${htmlRaw(htmlContent)}</div>`;
attachRefIssueContextPopup(previewPanel.querySelectorAll('p .ref-issue'));
}
diff --git a/web_src/js/features/repo-findfile.ts b/web_src/js/features/repo-findfile.ts
index 6500978bc8..59c827126f 100644
--- a/web_src/js/features/repo-findfile.ts
+++ b/web_src/js/features/repo-findfile.ts
@@ -4,13 +4,15 @@ import {pathEscapeSegments} from '../utils/url.ts';
import {GET} from '../modules/fetch.ts';
const threshold = 50;
-let files = [];
-let repoFindFileInput, repoFindFileTableBody, repoFindFileNoResult;
+let files: Array<string> = [];
+let repoFindFileInput: HTMLInputElement;
+let repoFindFileTableBody: HTMLElement;
+let repoFindFileNoResult: HTMLElement;
// return the case-insensitive sub-match result as an array: [unmatched, matched, unmatched, matched, ...]
// res[even] is unmatched, res[odd] is matched, see unit tests for examples
// argument subLower must be a lower-cased string.
-export function strSubMatch(full, subLower) {
+export function strSubMatch(full: string, subLower: string) {
const res = [''];
let i = 0, j = 0;
const fullLower = full.toLowerCase();
@@ -38,7 +40,7 @@ export function strSubMatch(full, subLower) {
return res;
}
-export function calcMatchedWeight(matchResult) {
+export function calcMatchedWeight(matchResult: Array<any>) {
let weight = 0;
for (let i = 0; i < matchResult.length; i++) {
if (i % 2 === 1) { // matches are on odd indices, see strSubMatch
@@ -49,7 +51,7 @@ export function calcMatchedWeight(matchResult) {
return weight;
}
-export function filterRepoFilesWeighted(files, filter) {
+export function filterRepoFilesWeighted(files: Array<string>, filter: string) {
let filterResult = [];
if (filter) {
const filterLower = filter.toLowerCase();
@@ -71,7 +73,7 @@ export function filterRepoFilesWeighted(files, filter) {
return filterResult;
}
-function filterRepoFiles(filter) {
+function filterRepoFiles(filter: string) {
const treeLink = repoFindFileInput.getAttribute('data-url-tree-link');
repoFindFileTableBody.innerHTML = '';
diff --git a/web_src/js/features/repo-graph.ts b/web_src/js/features/repo-graph.ts
index 6d1629a1c1..7579ee42c6 100644
--- a/web_src/js/features/repo-graph.ts
+++ b/web_src/js/features/repo-graph.ts
@@ -83,8 +83,8 @@ export function initRepoGraphGit() {
}
const flowSelectRefsDropdown = document.querySelector('#flow-select-refs-dropdown');
- fomanticQuery(flowSelectRefsDropdown).dropdown('set selected', dropdownSelected);
- fomanticQuery(flowSelectRefsDropdown).dropdown({
+ const $dropdown = fomanticQuery(flowSelectRefsDropdown);
+ $dropdown.dropdown({
clearable: true,
fullTextSeach: 'exact',
onRemove(toRemove: string) {
@@ -110,6 +110,7 @@ export function initRepoGraphGit() {
updateGraph();
},
});
+ $dropdown.dropdown('set selected', dropdownSelected);
graphContainer.addEventListener('mouseenter', (e: DOMEvent<MouseEvent>) => {
if (e.target.matches('#rev-list li')) {
diff --git a/web_src/js/features/repo-home.ts b/web_src/js/features/repo-home.ts
index 763f8e503f..04a1288626 100644
--- a/web_src/js/features/repo-home.ts
+++ b/web_src/js/features/repo-home.ts
@@ -92,7 +92,7 @@ export function initRepoTopicBar() {
onResponse(this: any, res: any) {
const formattedResponse = {
success: false,
- results: [],
+ results: [] as Array<Record<string, any>>,
};
const query = stripTags(this.urlData.query.trim());
let found_query = false;
@@ -134,12 +134,12 @@ export function initRepoTopicBar() {
return formattedResponse;
},
},
- onLabelCreate(value) {
+ onLabelCreate(value: string) {
value = value.toLowerCase().trim();
this.attr('data-value', value).contents().first().replaceWith(value);
return fomanticQuery(this);
},
- onAdd(addedValue, _addedText, $addedChoice) {
+ onAdd(addedValue: string, _addedText: any, $addedChoice: any) {
addedValue = addedValue.toLowerCase().trim();
$addedChoice[0].setAttribute('data-value', addedValue);
$addedChoice[0].setAttribute('data-text', addedValue);
diff --git a/web_src/js/features/repo-issue-content.ts b/web_src/js/features/repo-issue-content.ts
index 2279c26beb..056b810be8 100644
--- a/web_src/js/features/repo-issue-content.ts
+++ b/web_src/js/features/repo-issue-content.ts
@@ -33,7 +33,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
$fomanticDropdownOptions.dropdown({
showOnFocus: false,
allowReselection: true,
- async onChange(_value, _text, $item) {
+ async onChange(_value: string, _text: string, $item: any) {
const optionItem = $item.data('option-item');
if (optionItem === 'delete') {
if (window.confirm(i18nTextDeleteFromHistoryConfirm)) {
@@ -115,7 +115,7 @@ function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, co
onHide() {
$fomanticDropdown.dropdown('change values', null);
},
- onChange(value, itemHtml, $item) {
+ onChange(value: string, itemHtml: string, $item: any) {
if (value && !$item.find('[data-history-is-deleted=1]').length) {
showContentHistoryDetail(issueBaseUrl, commentId, value, itemHtml);
}
diff --git a/web_src/js/features/repo-issue-edit.ts b/web_src/js/features/repo-issue-edit.ts
index 38dfea4743..e89e5a787a 100644
--- a/web_src/js/features/repo-issue-edit.ts
+++ b/web_src/js/features/repo-issue-edit.ts
@@ -2,33 +2,32 @@ import {handleReply} from './repo-issue.ts';
import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
-import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.ts';
+import {hideElem, querySingleVisibleElem, showElem, type DOMEvent} 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';
import {applyAreYouSure, reinitializeAreYouSure} from '../vendor/jquery.are-you-sure.ts';
-async function tryOnEditContent(e) {
+async function tryOnEditContent(e: DOMEvent<MouseEvent>) {
const clickTarget = e.target.closest('.edit-content');
if (!clickTarget) return;
e.preventDefault();
- const segment = clickTarget.closest('.header').nextElementSibling;
+ const segment = clickTarget.closest('.comment-header').nextElementSibling;
const editContentZone = segment.querySelector('.edit-content-zone');
const renderContent = segment.querySelector('.render-content');
const rawContent = segment.querySelector('.raw-content');
let comboMarkdownEditor : ComboMarkdownEditor;
- const cancelAndReset = (e) => {
+ const cancelAndReset = (e: Event) => {
e.preventDefault();
showElem(renderContent);
hideElem(editContentZone);
comboMarkdownEditor.dropzoneReloadFiles();
};
- const saveAndRefresh = async (e) => {
+ const saveAndRefresh = async (e: Event) => {
e.preventDefault();
// we are already in a form, do not bubble up to the document otherwise there will be other "form submit handlers"
// at the moment, the form submit event conflicts with initRepoDiffConversationForm (global '.conversation-holder form' event handler)
@@ -60,7 +59,7 @@ async function tryOnEditContent(e) {
} else {
renderContent.innerHTML = data.content;
rawContent.textContent = comboMarkdownEditor.value();
- const refIssues = renderContent.querySelectorAll('p .ref-issue');
+ const refIssues = renderContent.querySelectorAll<HTMLElement>('p .ref-issue');
attachRefIssueContextPopup(refIssues);
}
const content = segment;
@@ -74,8 +73,6 @@ async function tryOnEditContent(e) {
content.querySelector('.dropzone-attachments').outerHTML = data.attachments;
}
comboMarkdownEditor.dropzoneSubmitReload();
- initMarkupContent();
- initCommentContent();
} catch (error) {
showErrorToast(`Failed to save the content: ${error}`);
console.error(error);
@@ -125,7 +122,7 @@ function extractSelectedMarkdown(container: HTMLElement) {
return convertHtmlToMarkdown(el);
}
-async function tryOnQuoteReply(e) {
+async function tryOnQuoteReply(e: Event) {
const clickTarget = (e.target as HTMLElement).closest('.quote-reply');
if (!clickTarget) return;
@@ -135,11 +132,11 @@ async function tryOnQuoteReply(e) {
const targetMarkupToQuote = targetRawToQuote.parentElement.querySelector<HTMLElement>('.render-content.markup');
let contentToQuote = extractSelectedMarkdown(targetMarkupToQuote);
if (!contentToQuote) contentToQuote = targetRawToQuote.textContent;
- const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n`;
+ const quotedContent = `${contentToQuote.replace(/^/mg, '> ')}\n\n`;
let editor;
if (clickTarget.classList.contains('quote-reply-diff')) {
- const replyBtn = clickTarget.closest('.comment-code-cloud').querySelector('button.comment-form-reply');
+ const replyBtn = clickTarget.closest('.comment-code-cloud').querySelector<HTMLElement>('button.comment-form-reply');
editor = await handleReply(replyBtn);
} else {
// for normal issue/comment page
diff --git a/web_src/js/features/repo-issue-list.ts b/web_src/js/features/repo-issue-list.ts
index 74d4362bfd..762fbf51bb 100644
--- a/web_src/js/features/repo-issue-list.ts
+++ b/web_src/js/features/repo-issue-list.ts
@@ -1,12 +1,13 @@
import {updateIssuesMeta} from './repo-common.ts';
-import {toggleElem, isElemHidden, queryElems} from '../utils/dom.ts';
-import {htmlEscape} from 'escape-goat';
+import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
+import {html} from '../utils/html.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {showErrorToast} from '../modules/toast.ts';
import {createSortable} from '../modules/sortable.ts';
import {DELETE, POST} from '../modules/fetch.ts';
import {parseDom} from '../utils.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
+import type {SortableEvent} from 'sortablejs';
function initRepoIssueListCheckboxes() {
const issueSelectAll = document.querySelector<HTMLInputElement>('.issue-checkbox-all');
@@ -32,8 +33,8 @@ function initRepoIssueListCheckboxes() {
toggleElem('#issue-filters', !anyChecked);
toggleElem('#issue-actions', anyChecked);
// there are two panels but only one select-all checkbox, so move the checkbox to the visible panel
- const panels = document.querySelectorAll('#issue-filters, #issue-actions');
- const visiblePanel = Array.from(panels).find((el) => !isElemHidden(el));
+ const panels = document.querySelectorAll<HTMLElement>('#issue-filters, #issue-actions');
+ const visiblePanel = Array.from(panels).find((el) => isElemVisible(el));
const toolbarLeft = visiblePanel.querySelector('.issue-list-toolbar-left');
toolbarLeft.prepend(issueSelectAll);
};
@@ -104,7 +105,7 @@ function initDropdownUserRemoteSearch(el: Element) {
$searchDropdown.dropdown('setting', {
fullTextSearch: true,
selectOnKeydown: false,
- action: (_text, value) => {
+ action: (_text: string, value: string) => {
window.location.href = actionJumpUrl.replace('{username}', encodeURIComponent(value));
},
});
@@ -133,14 +134,14 @@ function initDropdownUserRemoteSearch(el: Element) {
$searchDropdown.dropdown('setting', 'apiSettings', {
cache: false,
url: `${searchUrl}&q={query}`,
- onResponse(resp) {
+ onResponse(resp: any) {
// the content is provided by backend IssuePosters handler
processedResults.length = 0;
for (const item of resp.results) {
- let html = `<img class="ui avatar tw-align-middle" src="${htmlEscape(item.avatar_link)}" aria-hidden="true" alt="" width="20" height="20"><span class="gt-ellipsis">${htmlEscape(item.username)}</span>`;
- if (item.full_name) html += `<span class="search-fullname tw-ml-2">${htmlEscape(item.full_name)}</span>`;
+ let nameHtml = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20"><span class="gt-ellipsis">${item.username}</span>`;
+ if (item.full_name) nameHtml += html`<span class="search-fullname tw-ml-2">${item.full_name}</span>`;
if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
- processedResults.push({value: item.username, name: html});
+ processedResults.push({value: item.username, name: nameHtml});
}
resp.results = processedResults;
return resp;
@@ -153,7 +154,7 @@ function initDropdownUserRemoteSearch(el: Element) {
const dropdownSetup = {...$searchDropdown.dropdown('internal', 'setup')};
const dropdownTemplates = $searchDropdown.dropdown('setting', 'templates');
$searchDropdown.dropdown('internal', 'setup', dropdownSetup);
- dropdownSetup.menu = function (values) {
+ dropdownSetup.menu = function (values: any) {
// remove old dynamic items
for (const el of elMenu.querySelectorAll(':scope > .dynamic-item')) {
el.remove();
@@ -193,7 +194,7 @@ function initPinRemoveButton() {
}
}
-async function pinMoveEnd(e) {
+async function pinMoveEnd(e: SortableEvent) {
const url = e.item.getAttribute('data-move-url');
const id = Number(e.item.getAttribute('data-issue-id'));
await POST(url, {data: {id, position: e.newIndex + 1}});
diff --git a/web_src/js/features/repo-issue-pr-form.ts b/web_src/js/features/repo-issue-pr-form.ts
deleted file mode 100644
index 94a2857340..0000000000
--- a/web_src/js/features/repo-issue-pr-form.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import {createApp} from 'vue';
-import PullRequestMergeForm from '../components/PullRequestMergeForm.vue';
-
-export function initRepoPullRequestMergeForm() {
- const el = document.querySelector('#pull-request-merge-form');
- if (!el) return;
-
- const view = createApp(PullRequestMergeForm);
- view.mount(el);
-}
diff --git a/web_src/js/features/repo-issue-pr-status.ts b/web_src/js/features/repo-issue-pr-status.ts
deleted file mode 100644
index 8426b389f0..0000000000
--- a/web_src/js/features/repo-issue-pr-status.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-export function initRepoPullRequestCommitStatus() {
- for (const btn of document.querySelectorAll('.commit-status-hide-checks')) {
- const panel = btn.closest('.commit-status-panel');
- const list = panel.querySelector<HTMLElement>('.commit-status-list');
- btn.addEventListener('click', () => {
- list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
- btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
- });
- }
-}
diff --git a/web_src/js/features/repo-issue-pull.ts b/web_src/js/features/repo-issue-pull.ts
new file mode 100644
index 0000000000..c415dad08f
--- /dev/null
+++ b/web_src/js/features/repo-issue-pull.ts
@@ -0,0 +1,133 @@
+import {createApp} from 'vue';
+import PullRequestMergeForm from '../components/PullRequestMergeForm.vue';
+import {GET, POST} from '../modules/fetch.ts';
+import {fomanticQuery} from '../modules/fomantic/base.ts';
+import {createElementFromHTML} from '../utils/dom.ts';
+
+function initRepoPullRequestUpdate(el: HTMLElement) {
+ const prUpdateButtonContainer = el.querySelector('#update-pr-branch-with-base');
+ if (!prUpdateButtonContainer) return;
+
+ const prUpdateButton = prUpdateButtonContainer.querySelector<HTMLButtonElement>(':scope > button');
+ const prUpdateDropdown = prUpdateButtonContainer.querySelector(':scope > .ui.dropdown');
+ prUpdateButton.addEventListener('click', async function (e) {
+ e.preventDefault();
+ const redirect = this.getAttribute('data-redirect');
+ this.classList.add('is-loading');
+ let response: Response;
+ try {
+ response = await POST(this.getAttribute('data-do'));
+ } catch (error) {
+ console.error(error);
+ } finally {
+ this.classList.remove('is-loading');
+ }
+ let data: Record<string, any>;
+ try {
+ data = await response?.json(); // the response is probably not a JSON
+ } catch (error) {
+ console.error(error);
+ }
+ if (data?.redirect) {
+ window.location.href = data.redirect;
+ } else if (redirect) {
+ window.location.href = redirect;
+ } else {
+ window.location.reload();
+ }
+ });
+
+ fomanticQuery(prUpdateDropdown).dropdown({
+ onChange(_text: string, _value: string, $choice: any) {
+ const choiceEl = $choice[0];
+ const url = choiceEl.getAttribute('data-do');
+ if (url) {
+ const buttonText = prUpdateButton.querySelector('.button-text');
+ if (buttonText) {
+ buttonText.textContent = choiceEl.textContent;
+ }
+ prUpdateButton.setAttribute('data-do', url);
+ }
+ },
+ });
+}
+
+function initRepoPullRequestCommitStatus(el: HTMLElement) {
+ for (const btn of el.querySelectorAll('.commit-status-hide-checks')) {
+ const panel = btn.closest('.commit-status-panel');
+ const list = panel.querySelector<HTMLElement>('.commit-status-list');
+ btn.addEventListener('click', () => {
+ list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
+ btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
+ });
+ }
+}
+
+function initRepoPullRequestMergeForm(box: HTMLElement) {
+ const el = box.querySelector('#pull-request-merge-form');
+ if (!el) return;
+
+ const view = createApp(PullRequestMergeForm);
+ view.mount(el);
+}
+
+function executeScripts(elem: HTMLElement) {
+ for (const oldScript of elem.querySelectorAll('script')) {
+ // TODO: that's the only way to load the data for the merge form. In the future
+ // we need to completely decouple the page data and embedded script
+ // eslint-disable-next-line github/no-dynamic-script-tag
+ const newScript = document.createElement('script');
+ for (const attr of oldScript.attributes) {
+ if (attr.name === 'type' && attr.value === 'module') continue;
+ newScript.setAttribute(attr.name, attr.value);
+ }
+ newScript.text = oldScript.text;
+ document.body.append(newScript);
+ }
+}
+
+export function initRepoPullMergeBox(el: HTMLElement) {
+ initRepoPullRequestCommitStatus(el);
+ initRepoPullRequestUpdate(el);
+ initRepoPullRequestMergeForm(el);
+
+ const reloadingIntervalValue = el.getAttribute('data-pull-merge-box-reloading-interval');
+ if (!reloadingIntervalValue) return;
+
+ const reloadingInterval = parseInt(reloadingIntervalValue);
+ const pullLink = el.getAttribute('data-pull-link');
+ let timerId: number;
+
+ let reloadMergeBox: () => Promise<void>;
+ const stopReloading = () => {
+ if (!timerId) return;
+ clearTimeout(timerId);
+ timerId = null;
+ };
+ const startReloading = () => {
+ if (timerId) return;
+ setTimeout(reloadMergeBox, reloadingInterval);
+ };
+ const onVisibilityChange = () => {
+ if (document.hidden) {
+ stopReloading();
+ } else {
+ startReloading();
+ }
+ };
+ reloadMergeBox = async () => {
+ const resp = await GET(`${pullLink}/merge_box`);
+ stopReloading();
+ if (!resp.ok) {
+ startReloading();
+ return;
+ }
+ document.removeEventListener('visibilitychange', onVisibilityChange);
+ const newElem = createElementFromHTML(await resp.text());
+ executeScripts(newElem);
+ el.replaceWith(newElem);
+ };
+
+ document.addEventListener('visibilitychange', onVisibilityChange);
+ startReloading();
+}
diff --git a/web_src/js/features/repo-issue-sidebar-combolist.ts b/web_src/js/features/repo-issue-sidebar-combolist.ts
index 24d620547f..f25c0a77c6 100644
--- a/web_src/js/features/repo-issue-sidebar-combolist.ts
+++ b/web_src/js/features/repo-issue-sidebar-combolist.ts
@@ -1,6 +1,6 @@
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {POST} from '../modules/fetch.ts';
-import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
+import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
// if there are draft comments, confirm before reloading, to avoid losing comments
function issueSidebarReloadConfirmDraftComment() {
@@ -22,7 +22,7 @@ function issueSidebarReloadConfirmDraftComment() {
window.location.reload();
}
-class IssueSidebarComboList {
+export class IssueSidebarComboList {
updateUrl: string;
updateAlgo: string;
selectionMode: string;
@@ -30,9 +30,11 @@ class IssueSidebarComboList {
elList: HTMLElement;
elComboValue: HTMLInputElement;
initialValues: string[];
+ container: HTMLElement;
- constructor(private container: HTMLElement) {
- this.updateUrl = this.container.getAttribute('data-update-url');
+ constructor(container: HTMLElement) {
+ this.container = container;
+ this.updateUrl = container.getAttribute('data-update-url');
this.updateAlgo = container.getAttribute('data-update-algo');
this.selectionMode = container.getAttribute('data-selection-mode');
if (!['single', 'multiple'].includes(this.selectionMode)) throw new Error(`Invalid data-update-on: ${this.selectionMode}`);
@@ -46,7 +48,7 @@ class IssueSidebarComboList {
return Array.from(this.elDropdown.querySelectorAll('.menu > .item.checked'), (el) => el.getAttribute('data-value'));
}
- updateUiList(changedValues) {
+ updateUiList(changedValues: Array<string>) {
const elEmptyTip = this.elList.querySelector('.item.empty-list');
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
for (const value of changedValues) {
@@ -60,7 +62,7 @@ class IssueSidebarComboList {
toggleElem(elEmptyTip, !hasItems);
}
- async updateToBackend(changedValues) {
+ async updateToBackend(changedValues: Array<string>) {
if (this.updateAlgo === 'diff') {
for (const value of this.initialValues) {
if (!changedValues.includes(value)) {
@@ -93,9 +95,7 @@ class IssueSidebarComboList {
}
}
- async onItemClick(e) {
- const elItem = (e.target as HTMLElement).closest('.item');
- if (!elItem) return;
+ async onItemClick(elItem: HTMLElement, e: Event) {
e.preventDefault();
if (elItem.hasAttribute('data-can-change') && elItem.getAttribute('data-can-change') !== 'true') return;
@@ -144,16 +144,13 @@ class IssueSidebarComboList {
}
this.initialValues = this.collectCheckedValues();
- this.elDropdown.addEventListener('click', (e) => this.onItemClick(e));
+ addDelegatedEventListener(this.elDropdown, 'click', '.item', (el, e) => this.onItemClick(el, e));
fomanticQuery(this.elDropdown).dropdown('setting', {
action: 'nothing', // do not hide the menu if user presses Enter
fullTextSearch: 'exact',
+ hideDividers: 'empty',
onHide: () => this.onHide(),
});
}
}
-
-export function initIssueSidebarComboList(container: HTMLElement) {
- new IssueSidebarComboList(container).init();
-}
diff --git a/web_src/js/features/repo-issue-sidebar.md b/web_src/js/features/repo-issue-sidebar.md
index 6de013f1c2..e1ce0927e1 100644
--- a/web_src/js/features/repo-issue-sidebar.md
+++ b/web_src/js/features/repo-issue-sidebar.md
@@ -22,10 +22,13 @@ A sidebar combo (dropdown+list) is like this:
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.
+Also, the changed items will be synchronized to the `ui list` items.
The items with the same data-scope only allow one selected at a time.
The dropdown selection could work in 2 modes:
* single: only one item could be selected, it updates immediately when the item is selected.
* multiple: multiple items could be selected, it defers the update until the dropdown is hidden.
+
+When using "scrolling menu", the items must be in the same level,
+otherwise keyboard (ArrowUp/ArrowDown/Enter) won't work.
diff --git a/web_src/js/features/repo-issue-sidebar.ts b/web_src/js/features/repo-issue-sidebar.ts
index f84bed127f..290e1ae000 100644
--- a/web_src/js/features/repo-issue-sidebar.ts
+++ b/web_src/js/features/repo-issue-sidebar.ts
@@ -1,6 +1,6 @@
import {POST} from '../modules/fetch.ts';
import {queryElems, toggleElem} from '../utils/dom.ts';
-import {initIssueSidebarComboList} from './repo-issue-sidebar-combolist.ts';
+import {IssueSidebarComboList} from './repo-issue-sidebar-combolist.ts';
function initBranchSelector() {
// TODO: RemoveIssueRef: see "repo/issue/branch_selector_field.tmpl"
@@ -48,5 +48,5 @@ export function initRepoIssueSidebar() {
initRepoIssueDue();
// init the combo list: a dropdown for selecting items, and a list for showing selected items and related actions
- queryElems<HTMLElement>(document, '.issue-sidebar-combo', (el) => initIssueSidebarComboList(el));
+ queryElems<HTMLElement>(document, '.issue-sidebar-combo', (el) => new IssueSidebarComboList(el).init());
}
diff --git a/web_src/js/features/repo-issue.ts b/web_src/js/features/repo-issue.ts
index d2a89682e8..49e8fc40a2 100644
--- a/web_src/js/features/repo-issue.ts
+++ b/web_src/js/features/repo-issue.ts
@@ -1,5 +1,4 @@
-import $ from 'jquery';
-import {htmlEscape} from 'escape-goat';
+import {html, htmlEscape} from '../utils/html.ts';
import {createTippy, showTemporaryTooltip} from '../modules/tippy.ts';
import {
addDelegatedEventListener,
@@ -18,38 +17,40 @@ import {showErrorToast} from '../modules/toast.ts';
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
+import {registerGlobalInitFunc} from '../modules/observer.ts';
const {appSubUrl} = window.config;
-export function initRepoIssueSidebarList() {
+export function initRepoIssueSidebarDependency() {
+ const elDropdown = document.querySelector('#new-dependency-drop-list');
+ if (!elDropdown) return;
+
const issuePageInfo = parseIssuePageInfo();
- const crossRepoSearch = $('#crossRepoSearch').val();
+ const crossRepoSearch = elDropdown.getAttribute('data-issue-cross-repo-search');
let issueSearchUrl = `${issuePageInfo.repoLink}/issues/search?q={query}&type=${issuePageInfo.issueDependencySearchType}`;
if (crossRepoSearch === 'true') {
issueSearchUrl = `${appSubUrl}/issues/search?q={query}&priority_repo_id=${issuePageInfo.repoId}&type=${issuePageInfo.issueDependencySearchType}`;
}
- fomanticQuery('#new-dependency-drop-list').dropdown({
+ fomanticQuery(elDropdown).dropdown({
fullTextSearch: true,
apiSettings: {
+ cache: false,
+ rawResponse: true,
url: issueSearchUrl,
- onResponse(response) {
- const filteredResponse = {success: true, results: []};
- const currIssueId = $('#new-dependency-drop-list').data('issue-id');
+ onResponse(response: any) {
+ const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
+ const currIssueId = elDropdown.getAttribute('data-issue-id');
// Parse the response from the api to work with our dropdown
- $.each(response, (_i, issue) => {
+ for (const issue of response) {
// Don't list current issue in the dependency list.
- if (issue.id === currIssueId) {
- return;
- }
+ if (String(issue.id) === currIssueId) continue;
filteredResponse.results.push({
- name: `<div class="gt-ellipsis">#${issue.number} ${htmlEscape(issue.title)}</div>
-<div class="text small tw-break-anywhere">${htmlEscape(issue.repository.full_name)}</div>`,
value: issue.id,
+ name: html`<div class="gt-ellipsis">#${issue.number} ${issue.title}</div><div class="text small tw-break-anywhere">${issue.repository.full_name}</div>`,
});
- });
+ }
return filteredResponse;
},
- cache: false,
},
});
}
@@ -181,24 +182,6 @@ export function initRepoIssueCommentDelete() {
});
}
-export function initRepoIssueDependencyDelete() {
- // Delete Issue dependency
- $(document).on('click', '.delete-dependency-button', (e) => {
- const id = e.currentTarget.getAttribute('data-id');
- const type = e.currentTarget.getAttribute('data-type');
-
- $('.remove-dependency').modal({
- closable: false,
- duration: 200,
- onApprove: () => {
- $('#removeDependencyID').val(id);
- $('#dependencyType').val(type);
- $('#removeDependencyForm').trigger('submit');
- },
- }).modal('show');
- });
-}
-
export function initRepoIssueCodeCommentCancel() {
// Cancel inline code comment
document.addEventListener('click', (e: DOMEvent<MouseEvent>) => {
@@ -214,59 +197,6 @@ export function initRepoIssueCodeCommentCancel() {
});
}
-export function initRepoPullRequestUpdate() {
- // Pull Request update button
- const pullUpdateButton = document.querySelector<HTMLButtonElement>('.update-button > button');
- if (!pullUpdateButton) return;
-
- pullUpdateButton.addEventListener('click', async function (e) {
- e.preventDefault();
- const redirect = this.getAttribute('data-redirect');
- this.classList.add('is-loading');
- let response: Response;
- try {
- response = await POST(this.getAttribute('data-do'));
- } catch (error) {
- console.error(error);
- } finally {
- this.classList.remove('is-loading');
- }
- let data: Record<string, any>;
- try {
- data = await response?.json(); // the response is probably not a JSON
- } catch (error) {
- console.error(error);
- }
- if (data?.redirect) {
- window.location.href = data.redirect;
- } else if (redirect) {
- window.location.href = redirect;
- } else {
- window.location.reload();
- }
- });
-
- $('.update-button > .dropdown').dropdown({
- onChange(_text, _value, $choice) {
- const choiceEl = $choice[0];
- const url = choiceEl.getAttribute('data-do');
- if (url) {
- const buttonText = pullUpdateButton.querySelector('.button-text');
- if (buttonText) {
- buttonText.textContent = choiceEl.textContent;
- }
- pullUpdateButton.setAttribute('data-do', url);
- }
- },
- });
-}
-
-export function initRepoPullRequestMergeInstruction() {
- $('.show-instruction').on('click', () => {
- toggleElem($('.instruct-content'));
- });
-}
-
export function initRepoPullRequestAllowMaintainerEdit() {
const wrapper = document.querySelector('#allow-edits-from-maintainers');
if (!wrapper) return;
@@ -293,54 +223,8 @@ export function initRepoPullRequestAllowMaintainerEdit() {
});
}
-export function initRepoIssueReferenceRepositorySearch() {
- $('.issue_reference_repository_search')
- .dropdown({
- apiSettings: {
- url: `${appSubUrl}/repo/search?q={query}&limit=20`,
- onResponse(response) {
- const filteredResponse = {success: true, results: []};
- $.each(response.data, (_r, repo) => {
- filteredResponse.results.push({
- name: htmlEscape(repo.repository.full_name),
- value: repo.repository.full_name,
- });
- });
- return filteredResponse;
- },
- cache: false,
- },
- onChange(_value, _text, $choice) {
- const $form = $choice.closest('form');
- if (!$form.length) return;
-
- $form[0].setAttribute('action', `${appSubUrl}/${_text}/issues/new`);
- },
- fullTextSearch: true,
- });
-}
-
-export function initRepoIssueWipTitle() {
- $('.title_wip_desc > a').on('click', (e) => {
- e.preventDefault();
-
- const $issueTitle = $('#issue_title');
- $issueTitle.trigger('focus');
- const value = ($issueTitle.val() as string).trim().toUpperCase();
-
- const wipPrefixes = $('.title_wip_desc').data('wip-prefixes');
- for (const prefix of wipPrefixes) {
- if (value.startsWith(prefix.toUpperCase())) {
- return;
- }
- }
-
- $issueTitle.val(`${wipPrefixes[0]} ${$issueTitle.val()}`);
- });
-}
-
export function initRepoIssueComments() {
- if (!$('.repository.view.issue .timeline').length) return;
+ if (!document.querySelector('.repository.view.issue .timeline')) return;
document.addEventListener('click', (e: DOMEvent<MouseEvent>) => {
const urlTarget = document.querySelector(':target');
@@ -352,15 +236,15 @@ export function initRepoIssueComments() {
if (!/^(issue|pull)(comment)?-\d+$/.test(urlTargetId)) return;
if (!e.target.closest(`#${urlTargetId}`)) {
- const scrollPosition = $(window).scrollTop();
- window.location.hash = '';
- $(window).scrollTop(scrollPosition);
+ // if the user clicks outside the comment, remove the hash from the url
+ // use empty hash and state to avoid scrolling
+ window.location.hash = ' ';
window.history.pushState(null, null, ' ');
}
});
}
-export async function handleReply(el) {
+export async function handleReply(el: HTMLElement) {
const form = el.closest('.comment-code-cloud').querySelector('.comment-form');
const textarea = form.querySelector('textarea');
@@ -379,7 +263,7 @@ export function initRepoPullRequestReview() {
const groupID = commentDiv.closest('div[id^="code-comments-"]')?.getAttribute('id');
if (groupID && groupID.startsWith('code-comments-')) {
const id = groupID.slice(14);
- const ancestorDiffBox = commentDiv.closest('.diff-file-box');
+ const ancestorDiffBox = commentDiv.closest<HTMLElement>('.diff-file-box');
hideElem(`#show-outdated-${id}`);
showElem(`#code-comments-${id}, #code-preview-${id}, #hide-outdated-${id}`);
@@ -395,39 +279,37 @@ export function initRepoPullRequestReview() {
}
}
- $(document).on('click', '.show-outdated', function (e) {
+ addDelegatedEventListener(document, 'click', '.show-outdated', (el, e) => {
e.preventDefault();
- const id = this.getAttribute('data-comment');
- hideElem(this);
+ const id = el.getAttribute('data-comment');
+ hideElem(el);
showElem(`#code-comments-${id}`);
showElem(`#code-preview-${id}`);
showElem(`#hide-outdated-${id}`);
});
- $(document).on('click', '.hide-outdated', function (e) {
+ addDelegatedEventListener(document, 'click', '.hide-outdated', (el, e) => {
e.preventDefault();
- const id = this.getAttribute('data-comment');
- hideElem(this);
+ const id = el.getAttribute('data-comment');
+ hideElem(el);
hideElem(`#code-comments-${id}`);
hideElem(`#code-preview-${id}`);
showElem(`#show-outdated-${id}`);
});
- $(document).on('click', 'button.comment-form-reply', async function (e) {
+ addDelegatedEventListener(document, 'click', 'button.comment-form-reply', (el, e) => {
e.preventDefault();
- await handleReply(this);
+ handleReply(el);
});
// The following part is only for diff views
- if (!$('.repository.pull.diff').length) return;
-
- const $reviewBtn = $('.js-btn-review');
- const $panel = $reviewBtn.parent().find('.review-box-panel');
- const $closeBtn = $panel.find('.close');
+ if (!document.querySelector('.repository.pull.diff')) return;
- if ($reviewBtn.length && $panel.length) {
- const tippy = createTippy($reviewBtn[0], {
- content: $panel[0],
+ const elReviewBtn = document.querySelector('.js-btn-review');
+ const elReviewPanel = document.querySelector('.review-box-panel.tippy-target');
+ if (elReviewBtn && elReviewPanel) {
+ const tippy = createTippy(elReviewBtn, {
+ content: elReviewPanel,
theme: 'default',
placement: 'bottom',
trigger: 'click',
@@ -435,11 +317,7 @@ export function initRepoPullRequestReview() {
interactive: true,
hideOnClick: true,
});
-
- $closeBtn.on('click', (e) => {
- e.preventDefault();
- tippy.hide();
- });
+ elReviewPanel.querySelector('.close').addEventListener('click', () => tippy.hide());
}
addDelegatedEventListener(document, 'click', '.add-code-comment', async (el, e) => {
@@ -480,43 +358,79 @@ export function initRepoPullRequestReview() {
}
export function initRepoIssueReferenceIssue() {
+ const elDropdown = document.querySelector('.issue_reference_repository_search');
+ if (!elDropdown) return;
+ const form = elDropdown.closest('form');
+ fomanticQuery(elDropdown).dropdown({
+ fullTextSearch: true,
+ apiSettings: {
+ cache: false,
+ rawResponse: true,
+ url: `${appSubUrl}/repo/search?q={query}&limit=20`,
+ onResponse(response: any) {
+ const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
+ for (const repo of response.data) {
+ filteredResponse.results.push({
+ name: htmlEscape(repo.repository.full_name),
+ value: repo.repository.full_name,
+ });
+ }
+ return filteredResponse;
+ },
+ },
+ onChange(_value: string, _text: string, _$choice: any) {
+ form.setAttribute('action', `${appSubUrl}/${_text}/issues/new`);
+ },
+ });
+
// Reference issue
- $(document).on('click', '.reference-issue', function (e) {
- const target = this.getAttribute('data-target');
+ addDelegatedEventListener(document, 'click', '.reference-issue', (el, e) => {
+ e.preventDefault();
+ const target = el.getAttribute('data-target');
const content = document.querySelector(`#${target}`)?.textContent ?? '';
- const poster = this.getAttribute('data-poster-username');
- const reference = toAbsoluteUrl(this.getAttribute('data-reference'));
- const modalSelector = this.getAttribute('data-modal');
+ const poster = el.getAttribute('data-poster-username');
+ const reference = toAbsoluteUrl(el.getAttribute('data-reference'));
+ const modalSelector = el.getAttribute('data-modal');
const modal = document.querySelector(modalSelector);
- const textarea = modal.querySelector('textarea[name="content"]');
+ const textarea = modal.querySelector<HTMLTextAreaElement>('textarea[name="content"]');
textarea.value = `${content}\n\n_Originally posted by @${poster} in ${reference}_`;
- $(modal).modal('show');
- e.preventDefault();
+ fomanticQuery(modal).modal('show');
});
}
+export function initRepoIssueWipNewTitle() {
+ // Toggle WIP for new PR
+ queryElems(document, '.title_wip_desc > a', (el) => el.addEventListener('click', (e) => {
+ e.preventDefault();
+ const wipPrefixes = JSON.parse(el.closest('.title_wip_desc').getAttribute('data-wip-prefixes'));
+ const titleInput = document.querySelector<HTMLInputElement>('#issue_title');
+ const titleValue = titleInput.value;
+ for (const prefix of wipPrefixes) {
+ if (titleValue.startsWith(prefix.toUpperCase())) {
+ return;
+ }
+ }
+ titleInput.value = `${wipPrefixes[0]} ${titleValue}`;
+ }));
+}
+
export function initRepoIssueWipToggle() {
- // Toggle WIP
- $('.toggle-wip a, .toggle-wip button').on('click', async (e) => {
+ // Toggle WIP for existing PR
+ registerGlobalInitFunc('initPullRequestWipToggle', (toggleWip) => toggleWip.addEventListener('click', async (e) => {
e.preventDefault();
- const toggleWip = e.currentTarget.closest('.toggle-wip');
const title = toggleWip.getAttribute('data-title');
const wipPrefix = toggleWip.getAttribute('data-wip-prefix');
const updateUrl = toggleWip.getAttribute('data-update-url');
- try {
- const params = new URLSearchParams();
- params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`);
-
- const response = await POST(updateUrl, {data: params});
- if (!response.ok) {
- throw new Error('Failed to toggle WIP status');
- }
- window.location.reload();
- } catch (error) {
- console.error(error);
+ const params = new URLSearchParams();
+ params.append('title', title?.startsWith(wipPrefix) ? title.slice(wipPrefix.length).trim() : `${wipPrefix.trim()} ${title}`);
+ const response = await POST(updateUrl, {data: params});
+ if (!response.ok) {
+ showErrorToast(`Failed to toggle 'work in progress' status`);
+ return;
}
- });
+ window.location.reload();
+ }));
}
export function initRepoIssueTitleEdit() {
@@ -589,11 +503,11 @@ export function initRepoIssueBranchSelect() {
});
}
-async function initSingleCommentEditor($commentForm) {
+async function initSingleCommentEditor(commentForm: HTMLFormElement) {
// pages:
// * normal new issue/pr page: no status-button, no comment-button (there is only a normal submit button which can submit empty content)
// * issue/pr view page: with comment form, has status-button and comment-button
- const editor = await initComboMarkdownEditor($commentForm[0].querySelector('.combo-markdown-editor'));
+ const editor = await initComboMarkdownEditor(commentForm.querySelector('.combo-markdown-editor'));
const statusButton = document.querySelector<HTMLButtonElement>('#status-button');
const commentButton = document.querySelector<HTMLButtonElement>('#comment-button');
const syncUiState = () => {
@@ -611,27 +525,27 @@ async function initSingleCommentEditor($commentForm) {
syncUiState();
}
-function initIssueTemplateCommentEditors($commentForm) {
+function initIssueTemplateCommentEditors(commentForm: HTMLFormElement) {
// pages:
// * new issue with issue template
- const $comboFields = $commentForm.find('.combo-editor-dropzone');
+ const comboFields = commentForm.querySelectorAll<HTMLElement>('.combo-editor-dropzone');
const initCombo = async (elCombo: HTMLElement) => {
- const $formField = $(elCombo.querySelector('.form-field-real'));
+ const fieldTextarea = elCombo.querySelector<HTMLTextAreaElement>('.form-field-real');
const dropzoneContainer = elCombo.querySelector<HTMLElement>('.form-field-dropzone');
const markdownEditor = elCombo.querySelector<HTMLElement>('.combo-markdown-editor');
const editor = await initComboMarkdownEditor(markdownEditor);
- editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, () => $formField.val(editor.value()));
+ editor.container.addEventListener(ComboMarkdownEditor.EventEditorContentChanged, () => fieldTextarea.value = editor.value());
- $formField.on('focus', async () => {
+ fieldTextarea.addEventListener('focus', async () => {
// deactivate all markdown editors
- showElem($commentForm.find('.combo-editor-dropzone .form-field-real'));
- hideElem($commentForm.find('.combo-editor-dropzone .combo-markdown-editor'));
- hideElem($commentForm.find('.combo-editor-dropzone .form-field-dropzone'));
+ showElem(commentForm.querySelectorAll('.combo-editor-dropzone .form-field-real'));
+ hideElem(commentForm.querySelectorAll('.combo-editor-dropzone .combo-markdown-editor'));
+ hideElem(commentForm.querySelectorAll('.combo-editor-dropzone .form-field-dropzone'));
// activate this markdown editor
- hideElem($formField);
+ hideElem(fieldTextarea);
showElem(markdownEditor);
showElem(dropzoneContainer);
@@ -640,21 +554,21 @@ function initIssueTemplateCommentEditors($commentForm) {
});
};
- for (const el of $comboFields) {
+ for (const el of comboFields) {
initCombo(el);
}
}
export function initRepoCommentFormAndSidebar() {
- const $commentForm = $('.comment.form');
- if (!$commentForm.length) return;
+ const commentForm = document.querySelector<HTMLFormElement>('.comment.form');
+ if (!commentForm) return;
- if ($commentForm.find('.field.combo-editor-dropzone').length) {
+ if (commentForm.querySelector('.field.combo-editor-dropzone')) {
// at the moment, if a form has multiple combo-markdown-editors, it must be an issue template form
- initIssueTemplateCommentEditors($commentForm);
- } else if ($commentForm.find('.combo-markdown-editor').length) {
+ initIssueTemplateCommentEditors(commentForm);
+ } else if (commentForm.querySelector('.combo-markdown-editor')) {
// it's quite unclear about the "comment form" elements, sometimes it's for issue comment, sometimes it's for file editor/uploader message
- initSingleCommentEditor($commentForm);
+ initSingleCommentEditor(commentForm);
}
initRepoIssueSidebar();
diff --git a/web_src/js/features/repo-legacy.ts b/web_src/js/features/repo-legacy.ts
index 33f02be865..249d181b25 100644
--- a/web_src/js/features/repo-legacy.ts
+++ b/web_src/js/features/repo-legacy.ts
@@ -1,30 +1,28 @@
+import {registerGlobalInitFunc} from '../modules/observer.ts';
import {
initRepoCommentFormAndSidebar,
initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
- initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue,
- initRepoIssueTitleEdit, initRepoIssueWipToggle,
- initRepoPullRequestUpdate,
+ initRepoIssueComments, initRepoIssueReferenceIssue,
+ initRepoIssueTitleEdit, initRepoIssueWipNewTitle, initRepoIssueWipToggle,
} from './repo-issue.ts';
import {initUnicodeEscapeButton} from './repo-unicode-escape.ts';
import {initRepoCloneButtons} from './repo-common.ts';
import {initCitationFileCopyContent} from './citation.ts';
import {initCompLabelEdit} from './comp/LabelEdit.ts';
-import {initRepoDiffConversationNav} from './repo-diff.ts';
import {initCompReactionSelector} from './comp/ReactionSelector.ts';
import {initRepoSettings} from './repo-settings.ts';
-import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.ts';
-import {initRepoPullRequestCommitStatus} from './repo-issue-pr-status.ts';
import {hideElem, queryElemChildren, queryElems, showElem} from '../utils/dom.ts';
import {initRepoIssueCommentEdit} from './repo-issue-edit.ts';
import {initRepoMilestone} from './repo-milestone.ts';
import {initRepoNew} from './repo-new.ts';
import {createApp} from 'vue';
import RepoBranchTagSelector from '../components/RepoBranchTagSelector.vue';
+import {initRepoPullMergeBox} from './repo-issue-pull.ts';
-function initRepoBranchTagSelector(selector: string) {
- for (const elRoot of document.querySelectorAll(selector)) {
+function initRepoBranchTagSelector() {
+ registerGlobalInitFunc('initRepoBranchTagSelector', async (elRoot: HTMLInputElement) => {
createApp(RepoBranchTagSelector, {elRoot}).mount(elRoot);
- }
+ });
}
export function initBranchSelectorTabs() {
@@ -43,7 +41,7 @@ export function initRepository() {
const pageContent = document.querySelector('.page-content.repository');
if (!pageContent) return;
- initRepoBranchTagSelector('.js-branch-tag-selector');
+ initRepoBranchTagSelector();
initRepoCommentFormAndSidebar();
// Labels
@@ -54,6 +52,7 @@ export function initRepository() {
initRepoCloneButtons();
initCitationFileCopyContent();
initRepoSettings();
+ initRepoIssueWipNewTitle();
// Issues
if (pageContent.matches('.page-content.repository.view.issue')) {
@@ -64,17 +63,13 @@ export function initRepository() {
initRepoIssueWipToggle();
initRepoIssueComments();
- initRepoDiffConversationNav();
initRepoIssueReferenceIssue();
initRepoIssueCommentDelete();
- initRepoIssueDependencyDelete();
initRepoIssueCodeCommentCancel();
- initRepoPullRequestUpdate();
initCompReactionSelector();
- initRepoPullRequestMergeForm();
- initRepoPullRequestCommitStatus();
+ registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
}
initUnicodeEscapeButton();
diff --git a/web_src/js/features/repo-migrate.ts b/web_src/js/features/repo-migrate.ts
index b75289feec..0788f83215 100644
--- a/web_src/js/features/repo-migrate.ts
+++ b/web_src/js/features/repo-migrate.ts
@@ -1,11 +1,11 @@
-import {hideElem, showElem} from '../utils/dom.ts';
+import {hideElem, showElem, type DOMEvent} from '../utils/dom.ts';
import {GET, POST} from '../modules/fetch.ts';
export function initRepoMigrationStatusChecker() {
const repoMigrating = document.querySelector('#repo_migrating');
if (!repoMigrating) return;
- document.querySelector('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry);
+ document.querySelector<HTMLButtonElement>('#repo_migrating_retry')?.addEventListener('click', doMigrationRetry);
const repoLink = repoMigrating.getAttribute('data-migrating-repo-link');
@@ -55,7 +55,7 @@ export function initRepoMigrationStatusChecker() {
syncTaskStatus(); // no await
}
-async function doMigrationRetry(e) {
+async function doMigrationRetry(e: DOMEvent<MouseEvent>) {
await POST(e.target.getAttribute('data-migrating-task-retry-url'));
window.location.reload();
}
diff --git a/web_src/js/features/repo-migration.ts b/web_src/js/features/repo-migration.ts
index fb9c822f98..4914e47267 100644
--- a/web_src/js/features/repo-migration.ts
+++ b/web_src/js/features/repo-migration.ts
@@ -1,4 +1,5 @@
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
+import {sanitizeRepoName} from './repo-common.ts';
const service = document.querySelector<HTMLInputElement>('#service_type');
const user = document.querySelector<HTMLInputElement>('#auth_username');
@@ -25,13 +26,19 @@ export function initRepoMigration() {
});
lfs?.addEventListener('change', setLFSSettingsVisibility);
- const cloneAddr = document.querySelector<HTMLInputElement>('#clone_addr');
- cloneAddr?.addEventListener('change', () => {
- const repoName = document.querySelector<HTMLInputElement>('#repo_name');
- if (cloneAddr.value && !repoName?.value) { // Only modify if repo_name input is blank
- repoName.value = /^(.*\/)?((.+?)(\.git)?)$/.exec(cloneAddr.value)[3];
- }
- });
+ const elCloneAddr = document.querySelector<HTMLInputElement>('#clone_addr');
+ const elRepoName = document.querySelector<HTMLInputElement>('#repo_name');
+ if (elCloneAddr && elRepoName) {
+ let repoNameChanged = false;
+ elRepoName.addEventListener('input', () => {repoNameChanged = true});
+ elCloneAddr.addEventListener('input', () => {
+ if (repoNameChanged) return;
+ let repoNameFromUrl = elCloneAddr.value.split(/[?#]/)[0];
+ repoNameFromUrl = /^(.*\/)?((.+?)\/?)$/.exec(repoNameFromUrl)[3];
+ repoNameFromUrl = repoNameFromUrl.split(/[?#]/)[0];
+ elRepoName.value = sanitizeRepoName(repoNameFromUrl);
+ });
+ }
}
function checkAuth() {
diff --git a/web_src/js/features/repo-new.ts b/web_src/js/features/repo-new.ts
index 8a77a77b4a..e2aa13f490 100644
--- a/web_src/js/features/repo-new.ts
+++ b/web_src/js/features/repo-new.ts
@@ -1,11 +1,14 @@
-import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
-import {htmlEscape} from 'escape-goat';
+import {hideElem, querySingleVisibleElem, showElem, toggleElem} from '../utils/dom.ts';
+import {htmlEscape} from '../utils/html.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
+import {sanitizeRepoName} from './repo-common.ts';
const {appSubUrl} = window.config;
function initRepoNewTemplateSearch(form: HTMLFormElement) {
- const inputRepoOwnerUid = form.querySelector<HTMLInputElement>('#uid');
+ const elSubmitButton = querySingleVisibleElem<HTMLInputElement>(form, '.ui.primary.button');
+ const elCreateRepoErrorMessage = form.querySelector('#create-repo-error-message');
+ const elRepoOwnerDropdown = form.querySelector('#repo_owner_dropdown');
const elRepoTemplateDropdown = form.querySelector<HTMLInputElement>('#repo_template_search');
const inputRepoTemplate = form.querySelector<HTMLInputElement>('#repo_template');
const elTemplateUnits = form.querySelector('#template_units');
@@ -18,12 +21,24 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) {
inputRepoTemplate.addEventListener('change', checkTemplate);
checkTemplate();
- const $dropdown = fomanticQuery(elRepoTemplateDropdown);
+ const $repoOwnerDropdown = fomanticQuery(elRepoOwnerDropdown);
+ const $repoTemplateDropdown = fomanticQuery(elRepoTemplateDropdown);
const onChangeOwner = function () {
- $dropdown.dropdown('setting', {
+ const ownerId = $repoOwnerDropdown.dropdown('get value');
+ const $ownerItem = $repoOwnerDropdown.dropdown('get item', ownerId);
+ hideElem(elCreateRepoErrorMessage);
+ elSubmitButton.disabled = false;
+ if ($ownerItem?.length) {
+ const elOwnerItem = $ownerItem[0];
+ elCreateRepoErrorMessage.textContent = elOwnerItem.getAttribute('data-create-repo-disallowed-prompt') ?? '';
+ const hasError = Boolean(elCreateRepoErrorMessage.textContent);
+ toggleElem(elCreateRepoErrorMessage, hasError);
+ elSubmitButton.disabled = hasError;
+ }
+ $repoTemplateDropdown.dropdown('setting', {
apiSettings: {
- url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${inputRepoOwnerUid.value}`,
- onResponse(response) {
+ url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${ownerId}`,
+ onResponse(response: any) {
const results = [];
results.push({name: '', value: ''}); // empty item means not using template
for (const tmplRepo of response.data) {
@@ -32,14 +47,14 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) {
value: String(tmplRepo.repository.id),
});
}
- $dropdown.fomanticExt.onResponseKeepSelectedItem($dropdown, inputRepoTemplate.value);
+ $repoTemplateDropdown.fomanticExt.onResponseKeepSelectedItem($repoTemplateDropdown, inputRepoTemplate.value);
return {results};
},
cache: false,
},
});
};
- inputRepoOwnerUid.addEventListener('change', onChangeOwner);
+ $repoOwnerDropdown.dropdown('setting', 'onChange', onChangeOwner);
onChangeOwner();
}
@@ -66,7 +81,7 @@ export function initRepoNew() {
let help = form.querySelector(`.help[data-help-for-repo-name="${CSS.escape(inputRepoName.value)}"]`);
if (!help) help = form.querySelector(`.help[data-help-for-repo-name=""]`);
showElem(help);
- const repoNamePreferPrivate = {'.profile': false, '.profile-private': true};
+ const repoNamePreferPrivate: Record<string, boolean> = {'.profile': false, '.profile-private': true};
const preferPrivate = repoNamePreferPrivate[inputRepoName.value];
// inputPrivate might be disabled because site admin "force private"
if (preferPrivate !== undefined && !inputPrivate.closest('.disabled, [disabled]')) {
@@ -74,6 +89,10 @@ export function initRepoNew() {
}
};
inputRepoName.addEventListener('input', updateUiRepoName);
+ inputRepoName.addEventListener('change', () => {
+ inputRepoName.value = sanitizeRepoName(inputRepoName.value);
+ updateUiRepoName();
+ });
updateUiRepoName();
initRepoNewTemplateSearch(form);
diff --git a/web_src/js/features/repo-projects.ts b/web_src/js/features/repo-projects.ts
index 11f5c19c8d..ad0feb6101 100644
--- a/web_src/js/features/repo-projects.ts
+++ b/web_src/js/features/repo-projects.ts
@@ -2,8 +2,9 @@ import {contrastColor} from '../utils/color.ts';
import {createSortable} from '../modules/sortable.ts';
import {POST, request} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
-import {queryElemChildren, queryElems} from '../utils/dom.ts';
+import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
import type {SortableEvent} from 'sortablejs';
+import {toggleFullScreen} from '../utils.ts';
function updateIssueCount(card: HTMLElement): void {
const parent = card.parentElement;
@@ -34,8 +35,8 @@ async function moveIssue({item, from, to, oldIndex}: SortableEvent): Promise<voi
}
async function initRepoProjectSortable(): Promise<void> {
- // the HTML layout is: #project-board > .board > .project-column .cards > .issue-card
- const mainBoard = document.querySelector('#project-board > .board.sortable');
+ // the HTML layout is: #project-board.board > .project-column .cards > .issue-card
+ const mainBoard = document.querySelector('#project-board');
let boardColumns = mainBoard.querySelectorAll<HTMLElement>('.project-column');
createSortable(mainBoard, {
group: 'project-column',
@@ -113,7 +114,6 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
window.location.reload(); // newly added column, need to reload the page
return;
}
- fomanticQuery(elModal).modal('hide');
// update the newly saved column title and color in the project board (to avoid reload)
const elEditButton = writableProjectBoard.querySelector<HTMLButtonElement>(`.show-project-column-modal-edit[${attrDataColumnId}="${columnId}"]`);
@@ -133,13 +133,32 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
elBoardColumn.style.removeProperty('color');
queryElemChildren<HTMLElement>(elBoardColumn, '.divider', (divider) => divider.style.removeProperty('color'));
}
+
+ fomanticQuery(elModal).modal('hide');
} finally {
elForm.classList.remove('is-loading');
}
});
}
+function initRepoProjectToggleFullScreen(): void {
+ const enterFullscreenBtn = document.querySelector('.screen-full');
+ const exitFullscreenBtn = document.querySelector('.screen-normal');
+ if (!enterFullscreenBtn || !exitFullscreenBtn) return;
+
+ const toggleFullscreenState = (isFullScreen: boolean) => {
+ toggleFullScreen('.projects-view', isFullScreen);
+ toggleElem(enterFullscreenBtn, !isFullScreen);
+ toggleElem(exitFullscreenBtn, isFullScreen);
+ };
+
+ enterFullscreenBtn.addEventListener('click', () => toggleFullscreenState(true));
+ exitFullscreenBtn.addEventListener('click', () => toggleFullscreenState(false));
+}
+
export function initRepoProject(): void {
+ initRepoProjectToggleFullScreen();
+
const writableProjectBoard = document.querySelector('#project-board[data-project-borad-writable="true"]');
if (!writableProjectBoard) return;
diff --git a/web_src/js/features/repo-settings.ts b/web_src/js/features/repo-settings.ts
index 7b3ab504cb..be1821664f 100644
--- a/web_src/js/features/repo-settings.ts
+++ b/web_src/js/features/repo-settings.ts
@@ -1,9 +1,9 @@
-import $ from 'jquery';
import {minimatch} from 'minimatch';
import {createMonaco} from './codeeditor.ts';
-import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
+import {onInputDebounce, queryElems, toggleClass, toggleElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
+import {fomanticQuery} from '../modules/fomantic/base.ts';
const {appSubUrl, csrfToken} = window.config;
@@ -11,11 +11,12 @@ function initRepoSettingsCollaboration() {
// Change collaborator 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) {
+ const $dropdown = fomanticQuery(dropdownEl);
+ $dropdown.dropdown({
+ async action(text: string, value: string) {
dropdownEl.classList.add('is-loading', 'loading-icon-2px');
const lastValue = dropdownEl.getAttribute('data-last-value');
- $(dropdownEl).dropdown('hide');
+ $dropdown.dropdown('hide');
try {
const uid = dropdownEl.getAttribute('data-uid');
await POST(dropdownEl.getAttribute('data-url'), {data: new URLSearchParams({uid, 'mode': value})});
@@ -32,9 +33,9 @@ function initRepoSettingsCollaboration() {
// set to the really selected value, defer to next tick to make sure `action` has finished
// its work because the calling order might be onHide -> action
setTimeout(() => {
- const $item = $(dropdownEl).dropdown('get item', dropdownEl.getAttribute('data-last-value'));
+ const $item = $dropdown.dropdown('get item', dropdownEl.getAttribute('data-last-value'));
if ($item) {
- $(dropdownEl).dropdown('set selected', dropdownEl.getAttribute('data-last-value'));
+ $dropdown.dropdown('set selected', dropdownEl.getAttribute('data-last-value'));
} else {
textEl.textContent = '(none)'; // prevent from misleading users when the access mode is undefined
}
@@ -48,32 +49,32 @@ function initRepoSettingsSearchTeamBox() {
const searchTeamBox = document.querySelector('#search-team-box');
if (!searchTeamBox) return;
- $(searchTeamBox).search({
+ fomanticQuery(searchTeamBox).search({
minCharacters: 2,
+ searchFields: ['name', 'description'],
+ showNoResults: false,
+ rawResponse: true,
apiSettings: {
url: `${appSubUrl}/org/${searchTeamBox.getAttribute('data-org-name')}/teams/-/search?q={query}`,
headers: {'X-Csrf-Token': csrfToken},
- onResponse(response) {
- const items = [];
- $.each(response.data, (_i, item) => {
+ onResponse(response: any) {
+ const items: Array<Record<string, any>> = [];
+ for (const item of response.data) {
items.push({
title: item.name,
description: `${item.permission} access`, // TODO: translate this string
});
- });
-
+ }
return {results: items};
},
},
- searchFields: ['name', 'description'],
- showNoResults: false,
});
}
function initRepoSettingsGitHook() {
- if (!$('.edit.githook').length) return;
+ if (!document.querySelector('.page-content.repository.settings.edit.githook')) return;
const filename = document.querySelector('.hook-filename').textContent;
- createMonaco($('#content')[0] as HTMLTextAreaElement, filename, {language: 'shell'});
+ createMonaco(document.querySelector<HTMLTextAreaElement>('#content'), filename, {language: 'shell'});
}
function initRepoSettingsBranches() {
@@ -120,32 +121,23 @@ function initRepoSettingsBranches() {
}
function initRepoSettingsOptions() {
- if ($('.repository.settings.options').length > 0) {
- // Enable or select internal/external wiki system and issue tracker.
- $('.enable-system').on('change', function (this: HTMLInputElement) { // eslint-disable-line @typescript-eslint/no-deprecated
- if (this.checked) {
- $($(this).data('target')).removeClass('disabled');
- if (!$(this).data('context')) $($(this).data('context')).addClass('disabled');
- } else {
- $($(this).data('target')).addClass('disabled');
- if (!$(this).data('context')) $($(this).data('context')).removeClass('disabled');
- }
- });
- $('.enable-system-radio').on('change', function (this: HTMLInputElement) { // eslint-disable-line @typescript-eslint/no-deprecated
- if (this.value === 'false') {
- $($(this).data('target')).addClass('disabled');
- if ($(this).data('context') !== undefined) $($(this).data('context')).removeClass('disabled');
- } else if (this.value === 'true') {
- $($(this).data('target')).removeClass('disabled');
- if ($(this).data('context') !== undefined) $($(this).data('context')).addClass('disabled');
- }
- });
- const $trackerIssueStyleRadios = $('.js-tracker-issue-style');
- $trackerIssueStyleRadios.on('change input', () => {
- const checkedVal = $trackerIssueStyleRadios.filter(':checked').val();
- $('#tracker-issue-style-regex-box').toggleClass('disabled', checkedVal !== 'regexp');
- });
- }
+ const pageContent = document.querySelector('.page-content.repository.settings.options');
+ if (!pageContent) return;
+
+ // Enable or select internal/external wiki system and issue tracker.
+ queryElems<HTMLInputElement>(pageContent, '.enable-system', (el) => el.addEventListener('change', () => {
+ toggleClass(el.getAttribute('data-target'), 'disabled', !el.checked);
+ toggleClass(el.getAttribute('data-context'), 'disabled', el.checked);
+ }));
+ queryElems<HTMLInputElement>(pageContent, '.enable-system-radio', (el) => el.addEventListener('change', () => {
+ toggleClass(el.getAttribute('data-target'), 'disabled', el.value === 'false');
+ toggleClass(el.getAttribute('data-context'), 'disabled', el.value === 'true');
+ }));
+
+ queryElems<HTMLInputElement>(pageContent, '.js-tracker-issue-style', (el) => el.addEventListener('change', () => {
+ const checkedVal = el.value;
+ pageContent.querySelector('#tracker-issue-style-regex-box').classList.toggle('disabled', checkedVal !== 'regexp');
+ }));
}
export function initRepoSettings() {
diff --git a/web_src/js/features/repo-view-file-tree.ts b/web_src/js/features/repo-view-file-tree.ts
new file mode 100644
index 0000000000..f52b64cc51
--- /dev/null
+++ b/web_src/js/features/repo-view-file-tree.ts
@@ -0,0 +1,37 @@
+import {createApp} from 'vue';
+import {toggleElem} from '../utils/dom.ts';
+import {POST} from '../modules/fetch.ts';
+import ViewFileTree from '../components/ViewFileTree.vue';
+import {registerGlobalEventFunc} from '../modules/observer.ts';
+
+const {appSubUrl} = window.config;
+
+async function toggleSidebar(btn: HTMLElement) {
+ const elToggleShow = document.querySelector('.repo-view-file-tree-toggle-show');
+ const elFileTreeContainer = document.querySelector('.repo-view-file-tree-container');
+ const shouldShow = btn.getAttribute('data-toggle-action') === 'show';
+ toggleElem(elFileTreeContainer, shouldShow);
+ toggleElem(elToggleShow, !shouldShow);
+
+ // FIXME: need to remove "full height" style from parent element
+
+ if (!elFileTreeContainer.hasAttribute('data-user-is-signed-in')) return;
+ await POST(`${appSubUrl}/user/settings/update_preferences`, {
+ data: {codeViewShowFileTree: shouldShow},
+ });
+}
+
+export async function initRepoViewFileTree() {
+ const sidebar = document.querySelector<HTMLElement>('.repo-view-file-tree-container');
+ const repoViewContent = document.querySelector('.repo-view-content');
+ if (!sidebar || !repoViewContent) return;
+
+ registerGlobalEventFunc('click', 'onRepoViewFileTreeToggle', toggleSidebar);
+
+ const fileTree = sidebar.querySelector('#view-file-tree');
+ createApp(ViewFileTree, {
+ repoLink: fileTree.getAttribute('data-repo-link'),
+ treePath: fileTree.getAttribute('data-tree-path'),
+ currentRefNameSubURL: fileTree.getAttribute('data-current-ref-name-sub-url'),
+ }).mount(fileTree);
+}
diff --git a/web_src/js/features/repo-wiki.ts b/web_src/js/features/repo-wiki.ts
index 484c628f9f..6ae0947077 100644
--- a/web_src/js/features/repo-wiki.ts
+++ b/web_src/js/features/repo-wiki.ts
@@ -1,8 +1,8 @@
-import {initMarkupContent} from '../markup/content.ts';
import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {fomanticMobileScreen} from '../modules/fomantic.ts';
import {POST} from '../modules/fetch.ts';
import type {ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
+import {html, htmlRaw} from '../utils/html.ts';
async function initRepoWikiFormEditor() {
const editArea = document.querySelector<HTMLTextAreaElement>('.repository.wiki .combo-markdown-editor textarea');
@@ -31,8 +31,7 @@ async function initRepoWikiFormEditor() {
const response = await POST(editor.previewUrl, {data: formData});
const data = await response.text();
lastContent = newContent;
- previewTarget.innerHTML = `<div class="markup ui segment">${data}</div>`;
- initMarkupContent();
+ previewTarget.innerHTML = html`<div class="render-content markup ui segment">${htmlRaw(data)}</div>`;
} catch (error) {
console.error('Error rendering preview:', error);
} finally {
@@ -70,7 +69,7 @@ async function initRepoWikiFormEditor() {
});
}
-function collapseWikiTocForMobile(collapse) {
+function collapseWikiTocForMobile(collapse: boolean) {
if (collapse) {
document.querySelector('.wiki-content-toc details')?.removeAttribute('open');
}
diff --git a/web_src/js/features/scoped-access-token.ts b/web_src/js/features/scoped-access-token.ts
deleted file mode 100644
index c498d4c011..0000000000
--- a/web_src/js/features/scoped-access-token.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import {createApp} from 'vue';
-
-export async function initScopedAccessTokenCategories() {
- const el = document.querySelector('#scoped-access-token-selector');
- if (!el) return;
-
- const {default: ScopedAccessTokenSelector} = await import(/* webpackChunkName: "scoped-access-token-selector" */'../components/ScopedAccessTokenSelector.vue');
- try {
- const View = createApp(ScopedAccessTokenSelector, {
- isAdmin: JSON.parse(el.getAttribute('data-is-admin')),
- noAccessLabel: el.getAttribute('data-no-access-label'),
- readLabel: el.getAttribute('data-read-label'),
- writeLabel: el.getAttribute('data-write-label'),
- });
- View.mount(el);
- } catch (err) {
- console.error('ScopedAccessTokenSelector failed to load', err);
- el.textContent = el.getAttribute('data-locale-component-failed-to-load');
- }
-}
diff --git a/web_src/js/features/stopwatch.ts b/web_src/js/features/stopwatch.ts
index 46168b2cd7..07f9c435b8 100644
--- a/web_src/js/features/stopwatch.ts
+++ b/web_src/js/features/stopwatch.ts
@@ -38,7 +38,7 @@ export function initStopwatch() {
}
let usingPeriodicPoller = false;
- const startPeriodicPoller = (timeout) => {
+ const startPeriodicPoller = (timeout: number) => {
if (timeout <= 0 || !Number.isFinite(timeout)) return;
usingPeriodicPoller = true;
setTimeout(() => updateStopwatchWithCallback(startPeriodicPoller, timeout), timeout);
@@ -103,7 +103,7 @@ export function initStopwatch() {
startPeriodicPoller(notificationSettings.MinTimeout);
}
-async function updateStopwatchWithCallback(callback, timeout) {
+async function updateStopwatchWithCallback(callback: (timeout: number) => void, timeout: number) {
const isSet = await updateStopwatch();
if (!isSet) {
@@ -125,7 +125,7 @@ async function updateStopwatch() {
return updateStopwatchData(data);
}
-function updateStopwatchData(data) {
+function updateStopwatchData(data: any) {
const watch = data[0];
const btnEls = document.querySelectorAll('.active-stopwatch');
if (!watch) {
@@ -134,7 +134,7 @@ function updateStopwatchData(data) {
const {repo_owner_name, repo_name, issue_index, seconds} = watch;
const issueUrl = `${appSubUrl}/${repo_owner_name}/${repo_name}/issues/${issue_index}`;
document.querySelector('.stopwatch-link')?.setAttribute('href', issueUrl);
- document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/toggle`);
+ document.querySelector('.stopwatch-commit')?.setAttribute('action', `${issueUrl}/times/stopwatch/stop`);
document.querySelector('.stopwatch-cancel')?.setAttribute('action', `${issueUrl}/times/stopwatch/cancel`);
const stopwatchIssue = document.querySelector('.stopwatch-issue');
if (stopwatchIssue) stopwatchIssue.textContent = `${repo_owner_name}/${repo_name}#${issue_index}`;
diff --git a/web_src/js/features/tablesort.ts b/web_src/js/features/tablesort.ts
index 15ea358fa3..0648ffd067 100644
--- a/web_src/js/features/tablesort.ts
+++ b/web_src/js/features/tablesort.ts
@@ -9,7 +9,7 @@ export function initTableSort() {
}
}
-function tableSort(normSort, revSort, isDefault) {
+function tableSort(normSort: string, revSort: string, isDefault: string) {
if (!normSort) return false;
if (!revSort) revSort = '';
diff --git a/web_src/js/features/tribute.ts b/web_src/js/features/tribute.ts
index fa65bcbb28..43c21ebe6d 100644
--- a/web_src/js/features/tribute.ts
+++ b/web_src/js/features/tribute.ts
@@ -1,14 +1,16 @@
import {emojiKeys, emojiHTML, emojiString} from './emoji.ts';
-import {htmlEscape} from 'escape-goat';
+import {html, htmlRaw} from '../utils/html.ts';
-function makeCollections({mentions, emoji}) {
- const collections = [];
+type TributeItem = Record<string, any>;
- if (emoji) {
- collections.push({
+export async function attachTribute(element: HTMLElement) {
+ const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
+
+ const collections = [
+ { // emojis
trigger: ':',
requireLeadingSpace: true,
- values: (query, cb) => {
+ values: (query: string, cb: (matches: Array<string>) => void) => {
const matches = [];
for (const name of emojiKeys) {
if (name.includes(query)) {
@@ -18,39 +20,30 @@ function makeCollections({mentions, emoji}) {
}
cb(matches);
},
- lookup: (item) => item,
- selectTemplate: (item) => {
+ lookup: (item: TributeItem) => item,
+ selectTemplate: (item: TributeItem) => {
if (item === undefined) return null;
return emojiString(item.original);
},
- menuItemTemplate: (item) => {
- return `<div class="tribute-item">${emojiHTML(item.original)}<span>${htmlEscape(item.original)}</span></div>`;
+ menuItemTemplate: (item: TributeItem) => {
+ return html`<div class="tribute-item">${htmlRaw(emojiHTML(item.original))}<span>${item.original}</span></div>`;
},
- });
- }
-
- if (mentions) {
- collections.push({
+ }, { // mentions
values: window.config.mentionValues ?? [],
requireLeadingSpace: true,
- menuItemTemplate: (item) => {
- return `
+ menuItemTemplate: (item: TributeItem) => {
+ const fullNameHtml = item.original.fullname && item.original.fullname !== '' ? html`<span class="fullname">${item.original.fullname}</span>` : '';
+ return html`
<div class="tribute-item">
- <img src="${htmlEscape(item.original.avatar)}" width="21" height="21"/>
- <span class="name">${htmlEscape(item.original.name)}</span>
- ${item.original.fullname && item.original.fullname !== '' ? `<span class="fullname">${htmlEscape(item.original.fullname)}</span>` : ''}
+ <img alt src="${item.original.avatar}" width="21" height="21"/>
+ <span class="name">${item.original.name}</span>
+ ${htmlRaw(fullNameHtml)}
</div>
`;
},
- });
- }
+ },
+ ];
- return collections;
-}
-
-export async function attachTribute(element, {mentions, emoji}) {
- const {default: Tribute} = await import(/* webpackChunkName: "tribute" */'tributejs');
- const collections = makeCollections({mentions, emoji});
// @ts-expect-error TS2351: This expression is not constructable (strange, why)
const tribute = new Tribute({collection: collections, noMatchTemplate: ''});
tribute.attach(element);
diff --git a/web_src/js/features/user-auth-webauthn.ts b/web_src/js/features/user-auth-webauthn.ts
index 70516c280d..1f336b9741 100644
--- a/web_src/js/features/user-auth-webauthn.ts
+++ b/web_src/js/features/user-auth-webauthn.ts
@@ -1,5 +1,5 @@
import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.ts';
-import {showElem} from '../utils/dom.ts';
+import {hideElem, showElem} from '../utils/dom.ts';
import {GET, POST} from '../modules/fetch.ts';
const {appSubUrl} = window.config;
@@ -11,6 +11,12 @@ export async function initUserAuthWebAuthn() {
return;
}
+ // webauthn is only supported on secure contexts
+ if (!window.isSecureContext) {
+ hideElem(elSignInPasskeyBtn);
+ return;
+ }
+
if (!detectWebAuthnSupport()) {
return;
}
@@ -114,7 +120,7 @@ async function login2FA() {
}
}
-async function verifyAssertion(assertedCredential) {
+async function verifyAssertion(assertedCredential: any) { // TODO: Credential type does not work
// Move data into Arrays in case it is super long
const authData = new Uint8Array(assertedCredential.response.authenticatorData);
const clientDataJSON = new Uint8Array(assertedCredential.response.clientDataJSON);
@@ -148,7 +154,7 @@ async function verifyAssertion(assertedCredential) {
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
}
-async function webauthnRegistered(newCredential) {
+async function webauthnRegistered(newCredential: any) { // TODO: Credential type does not work
const attestationObject = new Uint8Array(newCredential.response.attestationObject);
const clientDataJSON = new Uint8Array(newCredential.response.clientDataJSON);
const rawId = new Uint8Array(newCredential.rawId);
diff --git a/web_src/js/features/user-settings.ts b/web_src/js/features/user-settings.ts
index 6312a8b682..6fbb56e540 100644
--- a/web_src/js/features/user-settings.ts
+++ b/web_src/js/features/user-settings.ts
@@ -1,18 +1,8 @@
import {hideElem, showElem} from '../utils/dom.ts';
-import {initCompCropper} from './comp/Cropper.ts';
-
-function initUserSettingsAvatarCropper() {
- const fileInput = document.querySelector<HTMLInputElement>('#new-avatar');
- const container = document.querySelector<HTMLElement>('.user.settings.profile .cropper-panel');
- const imageSource = container.querySelector<HTMLImageElement>('.cropper-source');
- initCompCropper({container, fileInput, imageSource});
-}
export function initUserSettings() {
if (!document.querySelector('.user.settings.profile')) return;
- initUserSettingsAvatarCropper();
-
const usernameInput = document.querySelector<HTMLInputElement>('#username');
if (!usernameInput) return;
usernameInput.addEventListener('input', function () {