This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
import {checkAppUrl} from '../common-page.ts';
|
||||
import {hideElem, queryElems, showElem, toggleElem} from '../../utils/dom.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
||||
import {pathEscape} from '../../utils/url.ts';
|
||||
import {registerGlobalInitFunc} from '../../modules/observer.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
@@ -23,6 +24,33 @@ export function initAdminCommon(): void {
|
||||
initAdminUser();
|
||||
initAdminAuthentication();
|
||||
initAdminNotice();
|
||||
registerGlobalInitFunc('initRunnerBulkToolbar', initAdminRunnerBulk);
|
||||
}
|
||||
|
||||
function initAdminRunnerBulk(toolbar: HTMLElement) {
|
||||
const actionButtons = toolbar.querySelectorAll<HTMLButtonElement>('.runner-bulk-action');
|
||||
const formRunnerIds = toolbar.querySelector<HTMLInputElement>('form input[name="ids"]')!;
|
||||
const rowCheckboxes = document.querySelectorAll<HTMLInputElement>('.runner-bulk-select');
|
||||
const selectAll = document.querySelector<HTMLInputElement>('.runner-bulk-select-all');
|
||||
if (!selectAll) return;
|
||||
|
||||
const refresh = () => {
|
||||
const checked = Array.from(rowCheckboxes).filter((c) => c.checked);
|
||||
formRunnerIds.value = checked.map((c) => c.getAttribute('data-runner-id')!).join(',');
|
||||
toggleElem(toolbar, checked.length > 0);
|
||||
for (const btn of actionButtons) {
|
||||
btn.querySelector<HTMLElement>('.runner-bulk-count')!.textContent = `(${checked.length})`;
|
||||
}
|
||||
selectAll.checked = checked.length > 0 && checked.length === rowCheckboxes.length;
|
||||
selectAll.indeterminate = checked.length > 0 && checked.length < rowCheckboxes.length;
|
||||
};
|
||||
|
||||
selectAll.addEventListener('change', () => {
|
||||
for (const cb of rowCheckboxes) cb.checked = selectAll.checked;
|
||||
refresh();
|
||||
});
|
||||
for (const cb of rowCheckboxes) cb.addEventListener('change', refresh);
|
||||
refresh();
|
||||
}
|
||||
|
||||
function initAdminUser() {
|
||||
@@ -78,7 +106,7 @@ function initAdminAuthentication() {
|
||||
}
|
||||
|
||||
function onOAuth2Change(applyDefaultValues: boolean) {
|
||||
hideElem('.open_id_connect_auto_discovery_url, .oauth2_use_custom_url');
|
||||
hideElem('.open_id_connect_auto_discovery_url, .open_id_connect_external_id_claim, .oauth2_use_custom_url');
|
||||
for (const input of document.querySelectorAll<HTMLInputElement>('.open_id_connect_auto_discovery_url input[required]')) {
|
||||
input.removeAttribute('required');
|
||||
}
|
||||
@@ -86,8 +114,10 @@ function initAdminAuthentication() {
|
||||
const provider = document.querySelector<HTMLInputElement>('#oauth2_provider')!.value;
|
||||
switch (provider) {
|
||||
case 'openidConnect':
|
||||
case 'aws-cognito':
|
||||
document.querySelector<HTMLInputElement>('.open_id_connect_auto_discovery_url input')!.setAttribute('required', 'required');
|
||||
showElem('.open_id_connect_auto_discovery_url');
|
||||
showElem('.open_id_connect_external_id_claim');
|
||||
break;
|
||||
default: {
|
||||
const elProviderCustomUrlSettings = document.querySelector<HTMLInputElement>(`#${provider}_customURLSettings`);
|
||||
@@ -249,7 +279,7 @@ function initAdminNotice() {
|
||||
const elNoticeDesc = el.closest('tr')!.querySelector('.notice-description')!;
|
||||
const elModalDesc = detailModal.querySelector('.content pre')!;
|
||||
elModalDesc.textContent = elNoticeDesc.textContent;
|
||||
fomanticQuery(detailModal).modal('show');
|
||||
showFomanticModal(detailModal);
|
||||
}));
|
||||
|
||||
// Select actions
|
||||
@@ -285,6 +315,6 @@ function initAdminNotice() {
|
||||
}
|
||||
}
|
||||
await POST(this.getAttribute('data-link')!, {data});
|
||||
window.location.href = this.getAttribute('data-redirect')!;
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,10 +29,10 @@ test('ConfigFormValueMapper', () => {
|
||||
mapper.fillFromSystemConfig();
|
||||
const formData = mapper.collectToFormData();
|
||||
const result: Record<string, string> = {};
|
||||
const keys = [], values = [];
|
||||
for (const [key, value] of formData.entries()) {
|
||||
const keys: string[] = [], values: string[] = [];
|
||||
for (const [key, value] of formData) {
|
||||
if (key === 'key') keys.push(value as string);
|
||||
if (key === 'value') values.push(value as string);
|
||||
else if (key === 'value') values.push(value as string);
|
||||
}
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
result[keys[i]] = values[i];
|
||||
|
||||
@@ -2,7 +2,9 @@ import {showTemporaryTooltip} from '../../modules/tippy.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {registerGlobalInitFunc} from '../../modules/observer.ts';
|
||||
import {queryElems} from '../../utils/dom.ts';
|
||||
import {errorMessage} from '../../modules/errors.ts';
|
||||
import {submitFormFetchAction} from '../common-fetch-action.ts';
|
||||
import {cutString} from '../../utils/string.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
@@ -26,7 +28,7 @@ function initSystemConfigAutoCheckbox(el: HTMLInputElement) {
|
||||
const json: Record<string, any> = await resp.json();
|
||||
if (json.errorMessage) throw new Error(json.errorMessage);
|
||||
} catch (ex) {
|
||||
showTemporaryTooltip(el, ex.toString());
|
||||
showTemporaryTooltip(el, errorMessage(ex));
|
||||
el.checked = !el.checked;
|
||||
}
|
||||
});
|
||||
@@ -101,9 +103,7 @@ export class ConfigFormValueMapper {
|
||||
} else if (el.matches('[type="datetime-local"]')) {
|
||||
if (valType !== 'timestamp') requireExplicitValueType(el);
|
||||
if (val) el.value = toDatetimeLocalValue(val);
|
||||
} else if (el.matches('textarea')) {
|
||||
el.value = String(val ?? el.value);
|
||||
} else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') {
|
||||
} else if (el.matches('textarea') || (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text')) {
|
||||
el.value = String(val ?? el.value);
|
||||
} else {
|
||||
unsupportedElement(el);
|
||||
@@ -122,9 +122,7 @@ export class ConfigFormValueMapper {
|
||||
} else if (el.matches('[type="datetime-local"]')) {
|
||||
if (valType !== 'timestamp') requireExplicitValueType(el);
|
||||
val = Math.floor(new Date(el.value).getTime() / 1000) ?? 0; // NaN is fine to JSON.stringify, it becomes null.
|
||||
} else if (el.matches('textarea')) {
|
||||
val = el.value;
|
||||
} else if (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text') {
|
||||
} else if (el.matches('textarea') || (el.matches('input') && (el.getAttribute('type') ?? 'text') === 'text')) {
|
||||
val = el.value;
|
||||
} else {
|
||||
unsupportedElement(el);
|
||||
@@ -161,7 +159,7 @@ export class ConfigFormValueMapper {
|
||||
const apps: Array<{DisplayName: string, OpenURL: string}> = [];
|
||||
const lines = cfgVal.split('\n');
|
||||
for (const line of lines) {
|
||||
let [displayName, openUrl] = line.split('=', 2);
|
||||
let [displayName, openUrl] = cutString(line, '=');
|
||||
displayName = displayName.trim();
|
||||
openUrl = openUrl?.trim() ?? '';
|
||||
if (!displayName || !openUrl) continue;
|
||||
@@ -177,7 +175,7 @@ export class ConfigFormValueMapper {
|
||||
|
||||
collectToFormData(): FormData {
|
||||
const namedElems: Array<GeneralFormFieldElement | null> = [];
|
||||
queryElems(this.form, '[name]', (el) => namedElems.push(el as GeneralFormFieldElement));
|
||||
queryElems(this.form, '[name]', (el) => { namedElems.push(el as GeneralFormFieldElement) });
|
||||
|
||||
// first, process the config options with sub values, for example:
|
||||
// merge "foo.bar.Enabled", "foo.bar.Message" to "foo.bar"
|
||||
|
||||
@@ -5,14 +5,14 @@ export function initAdminUserListSearchForm(): void {
|
||||
const form = document.querySelector<HTMLFormElement>('#user-list-search-form');
|
||||
if (!form) return;
|
||||
|
||||
for (const button of form.querySelectorAll(`button[name=sort][value="${searchForm.SortType}"]`)) {
|
||||
for (const button of form.querySelectorAll(`button[name=sort][value="${CSS.escape(searchForm.SortType)}"]`)) {
|
||||
button.classList.add('active');
|
||||
}
|
||||
|
||||
if (searchForm.StatusFilterMap) {
|
||||
for (const [k, v] of Object.entries(searchForm.StatusFilterMap)) {
|
||||
if (!v) continue;
|
||||
for (const input of form.querySelectorAll<HTMLInputElement>(`input[name="status_filter[${k}]"][value="${v}"]`)) {
|
||||
for (const input of form.querySelectorAll<HTMLInputElement>(`input[name="status_filter[${CSS.escape(k)}]"][value="${CSS.escape(v)}"]`)) {
|
||||
input.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {getCurrentLocale} from '../utils.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {localUserSettings} from '../modules/user-settings.ts';
|
||||
|
||||
const {pageData} = window.config;
|
||||
@@ -46,7 +47,7 @@ export async function initCitationFileCopyContent() {
|
||||
try {
|
||||
await initInputCitationValue(citationCopyApa, citationCopyBibtex);
|
||||
} catch (e) {
|
||||
console.error(`initCitationFileCopyContent error: ${e}`, e);
|
||||
console.error(`initCitationFileCopyContent error: ${errorMessage(e)}`, e);
|
||||
return;
|
||||
}
|
||||
updateUi();
|
||||
@@ -65,6 +66,6 @@ export async function initCitationFileCopyContent() {
|
||||
inputContent.select();
|
||||
});
|
||||
|
||||
fomanticQuery('#cite-repo-modal').modal('show');
|
||||
showFomanticModal(document.querySelector('#cite-repo-modal'));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
||||
import {toAbsoluteUrl} from '../utils.ts';
|
||||
import {clippie} from 'clippie';
|
||||
|
||||
const {copy_success, copy_error} = window.config.i18n;
|
||||
|
||||
// Enable clipboard copy from HTML attributes. These properties are supported:
|
||||
// - data-clipboard-text: Direct text to copy
|
||||
// - data-clipboard-target: Holds a selector for an element. "value" of <input> or <textarea>, or "textContent" of <div> will be copied
|
||||
// - data-clipboard-text-type: When set to 'url' will convert relative to absolute urls
|
||||
export function initGlobalCopyToClipboardListener() {
|
||||
document.addEventListener('click', async (e) => {
|
||||
const target = (e.target as HTMLElement).closest('[data-clipboard-text], [data-clipboard-target]');
|
||||
if (!target) return;
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
let text = target.getAttribute('data-clipboard-text');
|
||||
if (text === null) {
|
||||
const textSelector = target.getAttribute('data-clipboard-target')!;
|
||||
const textTarget = document.querySelector(textSelector)!;
|
||||
if (textTarget.nodeName === 'INPUT' || textTarget.nodeName === 'TEXTAREA') {
|
||||
text = (textTarget as HTMLInputElement | HTMLTextAreaElement).value;
|
||||
} else if (textTarget.nodeName === 'DIV') {
|
||||
text = textTarget.textContent;
|
||||
} else {
|
||||
throw new Error(`Unsupported element for clipboard target: ${textSelector}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (text && target.getAttribute('data-clipboard-text-type') === 'url') {
|
||||
text = toAbsoluteUrl(text);
|
||||
}
|
||||
|
||||
if (text) {
|
||||
const success = await clippie(text);
|
||||
showTemporaryTooltip(target, success ? copy_success : copy_error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -2,9 +2,10 @@ import {assignElementProperty, type ElementWithAssignableProperties} from './com
|
||||
|
||||
test('assignElementProperty', () => {
|
||||
const elForm = document.createElement('form');
|
||||
assignElementProperty(elForm, 'action', '/test-link');
|
||||
expect(elForm.action).contains('/test-link'); // the DOM always returns absolute URL
|
||||
expect(elForm.getAttribute('action')).eq('/test-link');
|
||||
expect(() => assignElementProperty(elForm, 'action', '/test-link')).toThrow();
|
||||
assignElementProperty(elForm, 'url', '/test-url');
|
||||
expect(elForm.action).contains('/test-url'); // the DOM always returns absolute URL
|
||||
expect(elForm.getAttribute('action')).eq('/test-url');
|
||||
assignElementProperty(elForm, 'text-content', 'dummy');
|
||||
expect(elForm.textContent).toBe('dummy');
|
||||
|
||||
@@ -14,8 +15,9 @@ test('assignElementProperty', () => {
|
||||
_attrs: Record<string, string> = {};
|
||||
setAttribute(name: string, value: string) { this._attrs[name] = value }
|
||||
getAttribute(name: string): string | null { return this._attrs[name] }
|
||||
nodeName = 'FORM';
|
||||
}();
|
||||
assignElementProperty(elFormWithAction, 'action', '/bar');
|
||||
assignElementProperty(elFormWithAction, 'url', '/bar');
|
||||
expect(elFormWithAction.getAttribute('action')).eq('/bar');
|
||||
|
||||
const elInput = document.createElement('input');
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {addDelegatedEventListener, hideElem, isElemVisible, showElem, toggleElem} from '../utils/dom.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {camelize} from 'vue';
|
||||
import {applyAutoFocus} from './common-page.ts';
|
||||
|
||||
@@ -13,74 +12,6 @@ export function initGlobalButtonClickOnEnter(): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function initGlobalDeleteButton(): void {
|
||||
// ".delete-button" shows a confirmation modal defined by `data-modal-id` attribute.
|
||||
// Some model/form elements will be filled by `data-id` / `data-name` / `data-data-xxx` attributes.
|
||||
// If there is a form defined by `data-form`, then the form will be submitted as-is (without any modification).
|
||||
// If there is no form, then the data will be posted to `data-url`.
|
||||
// TODO: do not use this method in new code. `show-modal` / `link-action(data-modal-confirm)` does far better than this.
|
||||
// FIXME: all legacy `delete-button` should be refactored to use `show-modal` or `link-action`
|
||||
for (const btn of document.querySelectorAll<HTMLElement>('.delete-button')) {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
// eslint-disable-next-line github/no-dataset -- code depends on the camel-casing
|
||||
const dataObj = btn.dataset;
|
||||
|
||||
const modalId = btn.getAttribute('data-modal-id');
|
||||
const modal = document.querySelector(`.delete.modal${modalId ? `#${modalId}` : ''}`)!;
|
||||
|
||||
// set the modal "display name" by `data-name`
|
||||
const modalNameEl = modal.querySelector('.name');
|
||||
if (modalNameEl) modalNameEl.textContent = btn.getAttribute('data-name');
|
||||
|
||||
// fill the modal elements with data-xxx attributes: `data-data-organization-name="..."` => `<span class="dataOrganizationName">...</span>`
|
||||
for (const [key, value] of Object.entries(dataObj)) {
|
||||
if (key.startsWith('data')) {
|
||||
const textEl = modal.querySelector(`.${key}`);
|
||||
if (textEl) textEl.textContent = value ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
fomanticQuery(modal).modal({
|
||||
closable: false,
|
||||
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
|
||||
const postData = new FormData();
|
||||
for (const [key, value] of Object.entries(dataObj)) {
|
||||
if (key.startsWith('data')) { // for data-data-xxx (HTML) -> dataXxx (form)
|
||||
postData.append(key.slice(4), String(value));
|
||||
}
|
||||
if (key === 'id') { // for data-id="..."
|
||||
postData.append('id', String(value));
|
||||
}
|
||||
}
|
||||
(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(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
|
||||
@@ -111,11 +42,20 @@ function onHidePanelClick(el: HTMLElement, e: MouseEvent) {
|
||||
}
|
||||
|
||||
export type ElementWithAssignableProperties = {
|
||||
nodeName: string;
|
||||
getAttribute: (name: string) => string | null;
|
||||
setAttribute: (name: string, value: string) => void;
|
||||
} & Record<string, any>;
|
||||
|
||||
export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) {
|
||||
if (el.nodeName === 'FORM') {
|
||||
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: a special case for Golang HTML template escaping.
|
||||
// Golang HTML template only handles some "known" attribute names as URL (e.g.: when the name is "action" or contains "url")
|
||||
// To prevent template developers from making mistakes like `data-modal-form.action="?k={{ValueWithSpecialChars}}" (no escaping),
|
||||
// here we use `data-modal-form.url="?k={{ValueWithSpecialChars}}", then the value gets correctly escaped by Golang HTML template.
|
||||
if (kebabName === 'action') throw new Error(`don't assign element property "action" by value, use "data-modal-form.url" instead`);
|
||||
if (kebabName === 'url') kebabName = 'action';
|
||||
}
|
||||
const camelizedName = camelize(kebabName);
|
||||
const old = el[camelizedName];
|
||||
if (typeof old === 'boolean') {
|
||||
@@ -128,7 +68,7 @@ export function assignElementProperty(el: ElementWithAssignableProperties, kebab
|
||||
// "form" has an edge case: its "<input name=action>" element overwrites the "action" property, we can only set attribute
|
||||
el.setAttribute(kebabName, val);
|
||||
} else {
|
||||
// in the future, we could introduce a better typing system like `data-modal-form.action:string="..."`
|
||||
// in the future, maybe we could introduce a better typing system if it is really needed
|
||||
throw new Error(`cannot assign element property "${camelizedName}" by value "${val}"`);
|
||||
}
|
||||
}
|
||||
@@ -140,7 +80,11 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
|
||||
// * Then, try to query '[name=target]'
|
||||
// * Then, try to query '.target'
|
||||
// * Then, try to query 'target' as HTML tag
|
||||
// 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".
|
||||
// If there's a ".{prop-name}" part like "data-modal-input.value", the "input" element's "value" property will be set,
|
||||
// the "prop-name" will be camel-cased to "propName" (e.g.: "data-modal-input.read-only" for "readOnly" property).
|
||||
//
|
||||
// HINT: GOLANG-HTML-TEMPLATE-URL-ESCAPING: Form element's "action" property must be set by "data-modal-form.url"
|
||||
// to make the template variables get correctly escaped in the URL.
|
||||
e.preventDefault();
|
||||
const modalSelector = el.getAttribute('data-modal')!;
|
||||
const elModal = document.querySelector(modalSelector);
|
||||
@@ -156,10 +100,10 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
|
||||
const [attrTargetName, attrTargetProp] = attrTargetCombo.split('.');
|
||||
// try to find target by: "#target" -> "[name=target]" -> ".target" -> "<target> tag", and then try the modal itself
|
||||
const attrTarget = elModal.querySelector(`#${attrTargetName}`) ||
|
||||
elModal.querySelector(`[name=${attrTargetName}]`) ||
|
||||
elModal.querySelector(`[name=${CSS.escape(attrTargetName)}]`) ||
|
||||
elModal.querySelector(`.${attrTargetName}`) ||
|
||||
elModal.querySelector(`${attrTargetName}`) ||
|
||||
(elModal.matches(`${attrTargetName}`) || elModal.matches(`#${attrTargetName}`) || elModal.matches(`.${attrTargetName}`) ? elModal : null);
|
||||
elModal.querySelector(attrTargetName) ||
|
||||
(elModal.matches(attrTargetName) || elModal.matches(`#${attrTargetName}`) || elModal.matches(`.${attrTargetName}`) ? elModal : null);
|
||||
if (!attrTarget) {
|
||||
if (!window.config.runModeIsProd) throw new Error(`attr target "${attrTargetCombo}" not found for modal`);
|
||||
continue;
|
||||
@@ -174,7 +118,7 @@ function onShowModalClick(el: HTMLElement, e: MouseEvent) {
|
||||
}
|
||||
}
|
||||
|
||||
fomanticQuery(elModal).modal('show');
|
||||
showFomanticModal(elModal);
|
||||
}
|
||||
|
||||
export function initGlobalButtons(): void {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import {execPseudoSelectorCommands, handleFetchActionSuccessJson} from './common-fetch-action.ts';
|
||||
|
||||
test('execPseudoSelectorCommands', () => {
|
||||
window.document.body.innerHTML = `
|
||||
<div id="d1">
|
||||
<ul id="u1">
|
||||
<li class="x"></li>
|
||||
</ul>
|
||||
<ul id="u2">
|
||||
<li class="x"></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="d2">
|
||||
<ul id="u3">
|
||||
<li class="x"></li>
|
||||
</ul>
|
||||
</div>`;
|
||||
|
||||
let ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '');
|
||||
expect(ret.targets).toEqual([document.querySelector('#u1')]);
|
||||
|
||||
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$this');
|
||||
expect(ret.targets).toEqual([document.querySelector('#u1')]);
|
||||
expect(ret.cmdInnerHTML).toBeFalsy();
|
||||
expect(ret.cmdMorph).toBeFalsy();
|
||||
|
||||
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body $morph $innerHTML');
|
||||
expect(ret.targets).toEqual([document.body]);
|
||||
expect(ret.cmdInnerHTML).toBeTruthy();
|
||||
expect(ret.cmdMorph).toBeTruthy();
|
||||
|
||||
ret = execPseudoSelectorCommands(document.querySelector('#u1')!, '$body .x');
|
||||
expect(ret.targets.length).toEqual(3);
|
||||
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('.x')));
|
||||
|
||||
ret = execPseudoSelectorCommands(document.querySelector('#u1 .x')!, '$closest(div) .x');
|
||||
expect(ret.targets.length).toEqual(2);
|
||||
expect(ret.targets).toEqual(Array.from(document.querySelectorAll('#d1 .x')));
|
||||
});
|
||||
|
||||
test('handleFetchActionSuccessJson', async () => {
|
||||
const spyAssign = vi.spyOn(window.location, 'assign').mockImplementation(() => {});
|
||||
const spyReload = vi.spyOn(window.location, 'reload').mockImplementation(() => {});
|
||||
|
||||
await handleFetchActionSuccessJson(document.body, {redirect: '/'});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(1);
|
||||
expect(spyReload).toHaveBeenCalledTimes(0);
|
||||
vi.resetAllMocks();
|
||||
|
||||
await handleFetchActionSuccessJson(document.body, {redirect: ''});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(0);
|
||||
expect(spyReload).toHaveBeenCalledTimes(1);
|
||||
vi.resetAllMocks();
|
||||
|
||||
await handleFetchActionSuccessJson(document.body, {});
|
||||
expect(spyAssign).toHaveBeenCalledTimes(0);
|
||||
expect(spyReload).toHaveBeenCalledTimes(1);
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
@@ -1,89 +1,161 @@
|
||||
import {request} from '../modules/fetch.ts';
|
||||
import {GET, request} from '../modules/fetch.ts';
|
||||
import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
|
||||
import {addDelegatedEventListener, createElementFromHTML, submitEventSubmitter} from '../utils/dom.ts';
|
||||
import {addDelegatedEventListener, createElementFromHTML} from '../utils/dom.ts';
|
||||
import {errorMessage, errorName} from '../modules/errors.ts';
|
||||
import {confirmModal, createConfirmModal} from './comp/ConfirmModal.ts';
|
||||
import type {RequestOpts} from '../types.ts';
|
||||
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {Idiomorph} from 'idiomorph';
|
||||
import {parseDom} from '../utils.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
const {appSubUrl, runModeIsProd} = window.config;
|
||||
|
||||
type FetchActionOpts = {
|
||||
method: string;
|
||||
url: string;
|
||||
headers?: HeadersInit;
|
||||
body?: FormData;
|
||||
formSubmitter?: HTMLElement | null;
|
||||
|
||||
// pseudo selectors/commands to update the current page with the response text when the response is text (html)
|
||||
// e.g.: "$this", "$innerHTML", "$closest(tr) td .the-class", "$body #the-id"
|
||||
successSync: string;
|
||||
|
||||
// the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
|
||||
// empty means no loading indicator, "$this" means the element itself
|
||||
loadingIndicator: string;
|
||||
};
|
||||
|
||||
// fetchActionDoRedirect does real redirection to bypass the browser's limitations of "location"
|
||||
// more details are in the backend's fetch-redirect handler
|
||||
function fetchActionDoRedirect(redirect: string) {
|
||||
const form = document.createElement('form');
|
||||
const input = document.createElement('input');
|
||||
form.method = 'post';
|
||||
form.action = `${appSubUrl}/-/fetch-redirect`;
|
||||
input.type = 'hidden';
|
||||
input.name = 'redirect';
|
||||
input.value = redirect;
|
||||
form.append(input);
|
||||
// In production, if the link can be directly navigated by browser, we just do normal redirection, which is faster.
|
||||
// Otherwise, need to use backend to do redirection:
|
||||
// * Also do so in development, to make sure the redirection logic is always tested by real users
|
||||
const needBackendHelp = redirect.includes('#');
|
||||
if (runModeIsProd && !needBackendHelp) {
|
||||
window.location.assign(redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
// use backend to do redirection, which can bypass the browser's limitations of "location"
|
||||
const form = createElementFromHTML<HTMLFormElement>(html`<form method="post"></form>`);
|
||||
form.action = `${appSubUrl}/-/fetch-redirect?redirect=${encodeURIComponent(redirect)}`;
|
||||
document.body.append(form);
|
||||
form.submit();
|
||||
}
|
||||
|
||||
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);
|
||||
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) {
|
||||
fetchActionDoRedirect(redirect);
|
||||
function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading: boolean) {
|
||||
const loadingIndicatorElems = opt.loadingIndicator ? execPseudoSelectorCommands(el, opt.loadingIndicator).targets : [];
|
||||
for (const indicatorEl of loadingIndicatorElems) {
|
||||
if (isLoading) {
|
||||
// for button or input element, we can directly disable it, it looks better than adding a loading spinner
|
||||
if ('disabled' in indicatorEl) {
|
||||
indicatorEl.disabled = true;
|
||||
} else {
|
||||
window.location.reload();
|
||||
indicatorEl.classList.add('is-loading');
|
||||
if (indicatorEl.clientHeight < 50) indicatorEl.classList.add('loading-icon-2px');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
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.
|
||||
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
|
||||
} else {
|
||||
showErrorForResponse(respStatus, respText);
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name === 'SyntaxError') {
|
||||
showErrorForResponse(respStatus, (respText || '').substring(0, 100));
|
||||
} else if (e.name !== 'AbortError') {
|
||||
console.error('fetchActionDoRequest error', e);
|
||||
showErrorForResponse(respStatus, `${e}`);
|
||||
if ('disabled' in indicatorEl) {
|
||||
indicatorEl.disabled = false;
|
||||
} else {
|
||||
indicatorEl.classList.remove('is-loading', 'loading-icon-2px');
|
||||
}
|
||||
}
|
||||
}
|
||||
actionElem.classList.remove('is-loading', 'loading-icon-2px');
|
||||
}
|
||||
|
||||
async function onFormFetchActionSubmit(formEl: HTMLFormElement, e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
await submitFormFetchAction(formEl, {formSubmitter: submitEventSubmitter(e)});
|
||||
export async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
|
||||
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
|
||||
const redirect = respJson?.redirect;
|
||||
if (typeof redirect === 'string' && redirect) {
|
||||
fetchActionDoRedirect(redirect);
|
||||
} else {
|
||||
// reserved behavior, in the future, there can be more fields to introduce more behaviors
|
||||
window.location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchActionSuccess(el: HTMLElement, opt: FetchActionOpts, resp: Response) {
|
||||
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
|
||||
const respText = await resp.text();
|
||||
const respJson = isRespJson ? JSON.parse(respText) : null;
|
||||
if (isRespJson) {
|
||||
await handleFetchActionSuccessJson(el, respJson);
|
||||
} else if (opt.successSync) {
|
||||
await handleFetchActionSuccessSync(el, opt.successSync, respText);
|
||||
} else {
|
||||
showErrorToast(`Unsupported fetch action response, expected JSON but got: ${respText.substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleFetchActionError(resp: Response) {
|
||||
const isRespJson = resp.headers.get('content-type')?.includes('application/json');
|
||||
const respText = await resp.text();
|
||||
const respJson = isRespJson ? JSON.parse(respText) : null;
|
||||
if (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.
|
||||
showErrorToast(respJson.errorMessage, {useHtmlBody: respJson.renderFormat === 'html'});
|
||||
} else {
|
||||
showErrorToast(`Error ${resp.status} ${resp.statusText}. Response: ${respText.substring(0, 200)}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildFetchActionUrl(el: HTMLElement, opt: FetchActionOpts) {
|
||||
let url = opt.url;
|
||||
if ('name' in el && 'value' in el) {
|
||||
// ref: https://htmx.org/attributes/hx-get/
|
||||
// If the element with the hx-get attribute also has a value, this will be included as a parameter
|
||||
const name = (el as HTMLInputElement).name;
|
||||
const val = (el as HTMLInputElement).value;
|
||||
const u = new URL(url, window.location.href);
|
||||
if (name && !u.searchParams.has(name)) {
|
||||
u.searchParams.set(name, val);
|
||||
url = u.href;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function performActionRequest(el: HTMLElement, opt: FetchActionOpts) {
|
||||
const attrIsLoading = 'data-fetch-is-loading';
|
||||
if (el.getAttribute(attrIsLoading)) return;
|
||||
if (!await confirmFetchAction(opt.formSubmitter ?? el)) return;
|
||||
|
||||
el.setAttribute(attrIsLoading, 'true');
|
||||
toggleLoadingIndicator(el, opt, true);
|
||||
|
||||
try {
|
||||
const url = buildFetchActionUrl(el, opt);
|
||||
const headers = new Headers(opt.headers);
|
||||
headers.set('X-Gitea-Fetch-Action', '1');
|
||||
const resp = await request(url, {method: opt.method, body: opt.body, headers});
|
||||
if (resp.ok) {
|
||||
await handleFetchActionSuccess(el, opt, resp);
|
||||
return;
|
||||
}
|
||||
await handleFetchActionError(resp);
|
||||
} catch (err) {
|
||||
if (errorName(err) !== 'AbortError') {
|
||||
console.error(`Fetch action request error:`, err);
|
||||
showErrorToast(`Error: ${errorMessage(err)}`);
|
||||
}
|
||||
} finally {
|
||||
toggleLoadingIndicator(el, opt, false);
|
||||
el.removeAttribute(attrIsLoading);
|
||||
}
|
||||
}
|
||||
|
||||
type SubmitFormFetchActionOpts = {
|
||||
formSubmitter?: HTMLElement;
|
||||
formSubmitter?: HTMLElement | null;
|
||||
formData?: FormData;
|
||||
};
|
||||
|
||||
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
|
||||
if (formEl.classList.contains('is-loading')) return;
|
||||
|
||||
formEl.classList.add('is-loading');
|
||||
if (formEl.clientHeight < 50) {
|
||||
formEl.classList.add('loading-icon-2px');
|
||||
}
|
||||
|
||||
const formMethod = formEl.getAttribute('method') || 'get';
|
||||
function prepareFormFetchActionOpts(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}): FetchActionOpts {
|
||||
const formMethodUpper = formEl.getAttribute('method')?.toUpperCase() || 'GET';
|
||||
const formActionUrl = formEl.getAttribute('action') || window.location.href;
|
||||
const formData = opts.formData ?? new FormData(formEl);
|
||||
const [submitterName, submitterValue] = [opts.formSubmitter?.getAttribute('name'), opts.formSubmitter?.getAttribute('value')];
|
||||
@@ -92,11 +164,8 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
|
||||
}
|
||||
|
||||
let reqUrl = formActionUrl;
|
||||
const reqOpt = {
|
||||
method: formMethod.toUpperCase(),
|
||||
body: null as FormData | null,
|
||||
};
|
||||
if (formMethod.toLowerCase() === 'get') {
|
||||
let reqBody: FormData | undefined;
|
||||
if (formMethodUpper === 'GET') {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of formData) {
|
||||
params.append(key, value as string);
|
||||
@@ -107,25 +176,24 @@ export async function submitFormFetchAction(formEl: HTMLFormElement, opts: Submi
|
||||
}
|
||||
reqUrl += `?${params.toString()}`;
|
||||
} else {
|
||||
reqOpt.body = formData;
|
||||
reqBody = formData;
|
||||
}
|
||||
|
||||
await fetchActionDoRequest(formEl, reqUrl, reqOpt);
|
||||
return {
|
||||
method: formMethodUpper,
|
||||
url: reqUrl,
|
||||
body: reqBody,
|
||||
formSubmitter: opts.formSubmitter,
|
||||
loadingIndicator: '$this', // for form submit, by default, the loading indicator is the whole form
|
||||
successSync: formEl.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for form submit
|
||||
};
|
||||
}
|
||||
|
||||
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.
|
||||
// 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 the "disabled" attribute
|
||||
await fetchActionDoRequest(el, url, {method: el.getAttribute('data-link-action-method') || 'POST'});
|
||||
if ('disabled' in el) el.disabled = false;
|
||||
};
|
||||
export async function submitFormFetchAction(formEl: HTMLFormElement, opts: SubmitFormFetchActionOpts = {}) {
|
||||
hideToastsAll();
|
||||
await performActionRequest(formEl, prepareFormFetchActionOpts(formEl, opts));
|
||||
}
|
||||
|
||||
async function confirmFetchAction(el: HTMLElement) {
|
||||
let elModal: HTMLElement | null = null;
|
||||
const dataModalConfirm = el.getAttribute('data-modal-confirm') || '';
|
||||
if (dataModalConfirm.startsWith('#')) {
|
||||
@@ -147,18 +215,204 @@ async function onLinkActionClick(el: HTMLElement, e: Event) {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!elModal) return true;
|
||||
return await confirmModal(elModal);
|
||||
}
|
||||
|
||||
if (!elModal) {
|
||||
await doRequest();
|
||||
async function performLinkFetchAction(el: HTMLElement) {
|
||||
hideToastsAll();
|
||||
await performActionRequest(el, {
|
||||
method: el.getAttribute('data-fetch-method') || 'POST', // by default, the method is POST for link-action
|
||||
url: el.getAttribute('data-url')!,
|
||||
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? '$this', // by default, the link-action itself is the loading indicator
|
||||
successSync: el.getAttribute('data-fetch-sync') ?? '', // by default, no fetch sync for link-action
|
||||
});
|
||||
}
|
||||
|
||||
type FetchActionTriggerType = 'click' | 'change' | 'every' | 'load' | 'fetch-reload';
|
||||
|
||||
export async function performFetchActionTrigger(el: HTMLElement, triggerType: FetchActionTriggerType) {
|
||||
const isUserInitiated = triggerType === 'click' || triggerType === 'change';
|
||||
// for user initiated action, by default, the loading indicator is the element itself, otherwise no loading indicator
|
||||
const defaultLoadingIndicator = isUserInitiated ? '$this' : '';
|
||||
|
||||
if (isUserInitiated) hideToastsAll();
|
||||
await performActionRequest(el, {
|
||||
method: el.getAttribute('data-fetch-method') || 'GET', // by default, the method is GET for fetch trigger action
|
||||
url: el.getAttribute('data-fetch-url')!,
|
||||
loadingIndicator: el.getAttribute('data-fetch-indicator') ?? defaultLoadingIndicator,
|
||||
successSync: el.getAttribute('data-fetch-sync') ?? '$this', // by default, the response will replace the current element
|
||||
});
|
||||
}
|
||||
|
||||
type PseudoSelectorCommandResult = {
|
||||
targets: Element[];
|
||||
cmdInnerHTML: boolean;
|
||||
cmdMorph: boolean;
|
||||
};
|
||||
|
||||
export function execPseudoSelectorCommands(el: Element, fullCommand: string): PseudoSelectorCommandResult {
|
||||
const cmds = fullCommand.split(' ').map((s) => s.trim()).filter(Boolean) || [];
|
||||
let targets = [el], cmdInnerHTML = false, cmdMorph = false;
|
||||
for (const cmd of cmds) {
|
||||
if (cmd === '$this') {
|
||||
targets = [el];
|
||||
} else if (cmd === '$body') {
|
||||
targets = [document.body];
|
||||
} else if (cmd === '$innerHTML') {
|
||||
cmdInnerHTML = true;
|
||||
} else if (cmd === '$morph') {
|
||||
cmdMorph = true;
|
||||
} else if (cmd.startsWith('$closest(') && cmd.endsWith(')')) {
|
||||
const selector = cmd.substring('$closest('.length, cmd.length - 1);
|
||||
const newTargets: Element[] = [];
|
||||
for (const target of targets) {
|
||||
const closest = target.closest(selector);
|
||||
if (closest) newTargets.push(closest);
|
||||
}
|
||||
targets = newTargets;
|
||||
} else {
|
||||
const newTargets: Element[] = [];
|
||||
for (const target of targets) {
|
||||
newTargets.push(...target.querySelectorAll(cmd));
|
||||
}
|
||||
targets = newTargets;
|
||||
}
|
||||
}
|
||||
return {targets, cmdInnerHTML, cmdMorph};
|
||||
}
|
||||
|
||||
async function handleFetchActionSuccessSync(el: Element, successSync: string, respText: string) {
|
||||
const res = execPseudoSelectorCommands(el, successSync);
|
||||
if (!res.targets.length) throw new Error(`Fetch-sync command "${successSync}" did not find any target element to update`);
|
||||
if (res.targets.length > 1) throw new Error(`Fetch-sync command "${successSync}" found multiple target elements, which is not supported`);
|
||||
const target = res.targets[0];
|
||||
if (res.cmdMorph) {
|
||||
Idiomorph.morph(target, respText, {morphStyle: res.cmdInnerHTML ? 'innerHTML' : 'outerHTML'});
|
||||
} else if (res.cmdInnerHTML) {
|
||||
target.innerHTML = respText;
|
||||
} else {
|
||||
target.outerHTML = respText;
|
||||
}
|
||||
await fetchActionReloadOutdatedElements();
|
||||
}
|
||||
|
||||
async function fetchActionReloadOutdatedElements() {
|
||||
const outdatedElems: HTMLElement[] = [];
|
||||
for (const outdated of document.querySelectorAll<HTMLElement>('[data-fetch-trigger~="fetch-reload"]')) {
|
||||
if (!outdated.id) throw new Error(`Elements with "fetch-reload" trigger must have an id to be reloaded after fetch sync: ${outdated.outerHTML.substring(0, 100)}`);
|
||||
outdatedElems.push(outdated);
|
||||
}
|
||||
if (!outdatedElems.length) return;
|
||||
|
||||
const resp = await GET(window.location.href);
|
||||
if (!resp.ok) {
|
||||
showErrorToast(`Failed to reload page content after fetch action: ${resp.status} ${resp.statusText}`);
|
||||
return;
|
||||
}
|
||||
const newPageHtml = await resp.text();
|
||||
const newPageDom = parseDom(newPageHtml, 'text/html');
|
||||
for (const oldEl of outdatedElems) {
|
||||
// eslint-disable-next-line unicorn/prefer-query-selector
|
||||
const newEl = newPageDom.getElementById(oldEl.id);
|
||||
if (newEl) {
|
||||
oldEl.replaceWith(newEl);
|
||||
} else {
|
||||
oldEl.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (await confirmModal(elModal)) {
|
||||
await doRequest();
|
||||
function initFetchActionTriggerEvery(el: HTMLElement, trigger: string) {
|
||||
const interval = trigger.substring('every '.length);
|
||||
const match = /^(\d+)(ms|s)$/.exec(interval);
|
||||
if (!match) throw new Error(`Invalid interval format: ${interval}`);
|
||||
|
||||
const num = parseInt(match[1], 10), unit = match[2];
|
||||
const intervalMs = unit === 's' ? num * 1000 : num;
|
||||
const fn = async () => {
|
||||
try {
|
||||
await performFetchActionTrigger(el, 'every');
|
||||
} finally {
|
||||
// only continue if the element is still in the document
|
||||
if (document.contains(el)) {
|
||||
setTimeout(fn, intervalMs);
|
||||
}
|
||||
}
|
||||
};
|
||||
setTimeout(fn, intervalMs);
|
||||
}
|
||||
|
||||
function initFetchActionTrigger(el: HTMLElement) {
|
||||
const trigger = el.getAttribute('data-fetch-trigger');
|
||||
|
||||
// this trigger is managed internally, only triggered after fetch sync success, not triggered by event or timer
|
||||
if (trigger === 'fetch-reload') return;
|
||||
|
||||
if (trigger === 'load') {
|
||||
performFetchActionTrigger(el, trigger);
|
||||
} else if (trigger === 'change') {
|
||||
el.addEventListener('change', () => performFetchActionTrigger(el, trigger));
|
||||
} else if (trigger?.startsWith('every ')) {
|
||||
initFetchActionTriggerEvery(el, trigger);
|
||||
} else if (!trigger || trigger === 'click') {
|
||||
el.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
performFetchActionTrigger(el, 'click');
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported fetch trigger: ${trigger}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function initGlobalFetchAction() {
|
||||
addDelegatedEventListener(document, 'submit', '.form-fetch-action', onFormFetchActionSubmit);
|
||||
addDelegatedEventListener(document, 'click', '.link-action', onLinkActionClick);
|
||||
// The "fetch-action" framework is a general approach for elements to trigger fetch requests:
|
||||
// show confirm dialog (if any), show loading indicators, send fetch request, and redirect or update UI after success.
|
||||
//
|
||||
// If you need more fine-grained control more details, sometimes it's clearer to write the logic in JavaScript, instead of using this generic framework.
|
||||
//
|
||||
// Attributes:
|
||||
//
|
||||
// * data-fetch-method: the HTTP method to use
|
||||
// * default to "GET" for "data-fetch-url" actions, "POST" for "link-action" elements
|
||||
// * this attribute is ignored, the method will be determined by the form's "method" attribute, and default to "GET"
|
||||
//
|
||||
// * data-fetch-url: the URL for the request
|
||||
//
|
||||
// * data-fetch-trigger: the event to trigger the fetch action, can be:
|
||||
// * "click", "change" (user-initiated events)
|
||||
// * "load" (triggered on page load)
|
||||
// * "every 5s" (also support "ms" unit)
|
||||
// * "fetch-reload" (only triggered by fetch sync success to reload outdated content)
|
||||
//
|
||||
// * data-fetch-indicator: the loading indicator element selector, it uses the same syntax as "data-fetch-sync" to find the element(s)
|
||||
//
|
||||
// * data-fetch-sync: when the response is text (html), the pseudo selectors/commands defined in "data-fetch-sync"
|
||||
// will be used to update the content in the current page. It only supports some simple syntaxes that we need.
|
||||
// "$" prefix means it is our private command (for special logic), the selectors are run one by one from current element.
|
||||
// * "$this": replace the current element with the response
|
||||
// * "$innerHTML": replace innerHTML of the current element with the response, instead of replacing the whole element (outerHTML)
|
||||
// * "$morph": use morph algorithm to update the target element
|
||||
// * "$body #the-id .the-class": query the selector one by one from body
|
||||
// * "$closest(tr) td": pseudo command can help to find the target element in a more flexible way
|
||||
//
|
||||
// * data-modal-confirm: a "confirm modal dialog" will be shown before taking action.
|
||||
// * it can be a string for the content of the modal dialog
|
||||
// * it has "-header" and "-content" variants to set the header and content of the "confirm modal"
|
||||
// * it can refer an existing modal element by "#the-modal-id"
|
||||
|
||||
addDelegatedEventListener<HTMLFormElement, SubmitEvent>(document, 'submit', '.form-fetch-action', async (el, e) => {
|
||||
// "fetch-action" will use the form's data to send the request
|
||||
e.preventDefault();
|
||||
await submitFormFetchAction(el, {formSubmitter: e.submitter});
|
||||
});
|
||||
|
||||
addDelegatedEventListener(document, 'click', '.link-action', async (el, e) => {
|
||||
// `<a class="link-action" data-url="...">` is a shorthand for
|
||||
// `<a data-fetch-trigger="click" data-fetch-method="post" data-fetch-url="..." data-fetch-indicator="$this">`
|
||||
e.preventDefault();
|
||||
await performLinkFetchAction(el);
|
||||
});
|
||||
|
||||
registerGlobalSelectorFunc('[data-fetch-url]', initFetchActionTrigger);
|
||||
}
|
||||
|
||||
@@ -16,17 +16,13 @@ export function initGlobalEnterQuickSubmit() {
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.isComposing) return;
|
||||
if (e.key !== 'Enter') return;
|
||||
const el = e.target as HTMLElement;
|
||||
const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
|
||||
if (hasCtrlOrMeta && (e.target as HTMLElement).matches('textarea')) {
|
||||
if (handleGlobalEnterQuickSubmit(e.target as HTMLElement)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
} else if ((e.target as HTMLElement).matches('input') && !(e.target as HTMLElement).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 as HTMLElement)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
const isCtrlEnterInTextarea = hasCtrlOrMeta && el.matches('textarea');
|
||||
// an input in a normal form could handle Enter key by default, so we only handle the input outside a form
|
||||
const isEnterInBareInput = el.matches('input') && !el.closest('form');
|
||||
if ((isCtrlEnterInTextarea || isEnterInBareInput) && handleGlobalEnterQuickSubmit(el)) {
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function initCommonIssueListQuickGoto() {
|
||||
const repoLink = elGotoButton.getAttribute('data-repo-link') || '';
|
||||
|
||||
elGotoButton.addEventListener('click', () => {
|
||||
window.location.href = elGotoButton.getAttribute('data-issue-goto-link')!;
|
||||
window.location.assign(elGotoButton.getAttribute('data-issue-goto-link')!);
|
||||
});
|
||||
|
||||
const onInput = async () => {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showGlobalErrorMessage} from '../modules/errors.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {initTabSwitcher} from '../modules/fomantic/tab.ts';
|
||||
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
|
||||
import {registerGlobalInitFunc, registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {initAvatarUploaderWithCropper} from './comp/Cropper.ts';
|
||||
import {initCompSearchRepoBox} from './comp/SearchRepoBox.ts';
|
||||
import {initScopedWorkflowRequired} from './comp/ScopedWorkflows.ts';
|
||||
|
||||
const {appUrl, appSubUrl} = window.config;
|
||||
|
||||
@@ -100,9 +102,10 @@ export function initGlobalDropdown() {
|
||||
}
|
||||
|
||||
export function initGlobalComponent() {
|
||||
fomanticQuery('.ui.menu.tabular:not(.custom) .item').tab();
|
||||
registerGlobalInitFunc('initTabSwitcher', initTabSwitcher);
|
||||
registerGlobalInitFunc('initAvatarUploader', initAvatarUploaderWithCropper);
|
||||
registerGlobalInitFunc('initSearchRepoBox', initCompSearchRepoBox);
|
||||
registerGlobalInitFunc('initScopedWorkflowRequired', initScopedWorkflowRequired);
|
||||
}
|
||||
|
||||
// for performance considerations, it only uses performant syntax
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
} from './EditorMarkdown.ts';
|
||||
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
|
||||
import {createTippy} from '../../modules/tippy.ts';
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {initTabSwitcher} from '../../modules/fomantic/tab.ts';
|
||||
import type EasyMDE from 'easymde';
|
||||
import {localUserSettings} from '../../modules/user-settings.ts';
|
||||
|
||||
@@ -71,26 +71,26 @@ export class ComboMarkdownEditor {
|
||||
|
||||
options: ComboMarkdownEditorOptions;
|
||||
|
||||
tabEditor: HTMLElement;
|
||||
tabPreviewer: HTMLElement;
|
||||
tabEditor?: HTMLElement;
|
||||
tabPreviewer?: HTMLElement;
|
||||
|
||||
supportEasyMDE: boolean;
|
||||
supportEasyMDE!: boolean;
|
||||
easyMDE: any;
|
||||
easyMDEToolbarActions: any;
|
||||
easyMDEToolbarDefault: any;
|
||||
|
||||
textarea: ComboMarkdownEditorTextarea;
|
||||
textareaMarkdownToolbar: HTMLElement;
|
||||
textarea!: ComboMarkdownEditorTextarea;
|
||||
textareaMarkdownToolbar!: HTMLElement;
|
||||
textareaAutosize: any;
|
||||
|
||||
buttonMonospace: HTMLButtonElement;
|
||||
buttonMonospace!: HTMLButtonElement;
|
||||
|
||||
dropzone: HTMLElement | null;
|
||||
dropzone: HTMLElement | null = null;
|
||||
attachedDropzoneInst: any;
|
||||
|
||||
previewMode: string;
|
||||
previewUrl: string;
|
||||
previewContext: string;
|
||||
previewMode!: string;
|
||||
previewUrl!: string;
|
||||
previewContext!: string;
|
||||
|
||||
constructor(container: ComboMarkdownEditorContainer, options:ComboMarkdownEditorOptions = {}) {
|
||||
if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized');
|
||||
@@ -204,22 +204,21 @@ export class ComboMarkdownEditor {
|
||||
}
|
||||
|
||||
setupTab() {
|
||||
const tabs = this.container.querySelectorAll<HTMLElement>('.tabular.menu > .item');
|
||||
if (!tabs.length) return;
|
||||
const elTabular = this.container.querySelector('.ui.tabular');
|
||||
if (!elTabular) return;
|
||||
this.tabEditor = this.container.querySelector('[data-tab-for="markdown-writer"]')!;
|
||||
this.tabPreviewer = this.container.querySelector('[data-tab-for="markdown-previewer"]')!;
|
||||
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]')!;
|
||||
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]')!;
|
||||
|
||||
// Fomantic Tab requires the "data-tab" to be globally unique.
|
||||
// So here it uses our defined "data-tab-for" and "data-tab-panel" to generate the "data-tab" attribute for Fomantic.
|
||||
const tabIdSuffix = generateElemId();
|
||||
const tabsArr = Array.from(tabs);
|
||||
this.tabEditor = tabsArr.find((tab) => tab.getAttribute('data-tab-for') === 'markdown-writer')!;
|
||||
this.tabPreviewer = tabsArr.find((tab) => tab.getAttribute('data-tab-for') === 'markdown-previewer')!;
|
||||
this.tabEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
|
||||
this.tabPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
|
||||
|
||||
const panelEditor = this.container.querySelector('.ui.tab[data-tab-panel="markdown-writer"]')!;
|
||||
const panelPreviewer = this.container.querySelector('.ui.tab[data-tab-panel="markdown-previewer"]')!;
|
||||
panelEditor.setAttribute('data-tab', `markdown-writer-${tabIdSuffix}`);
|
||||
panelPreviewer.setAttribute('data-tab', `markdown-previewer-${tabIdSuffix}`);
|
||||
initTabSwitcher(elTabular);
|
||||
|
||||
this.tabEditor.addEventListener('click', () => {
|
||||
requestAnimationFrame(() => {
|
||||
@@ -227,8 +226,6 @@ export class ComboMarkdownEditor {
|
||||
});
|
||||
});
|
||||
|
||||
fomanticQuery(tabs).tab();
|
||||
|
||||
this.tabPreviewer.addEventListener('click', async () => {
|
||||
const formData = new FormData();
|
||||
formData.append('mode', this.previewMode);
|
||||
@@ -291,7 +288,7 @@ export class ComboMarkdownEditor {
|
||||
}
|
||||
|
||||
switchTabToEditor() {
|
||||
this.tabEditor.click();
|
||||
this.tabEditor!.click(); // when this function is called, the tab must exist
|
||||
}
|
||||
|
||||
prepareEasyMDEToolbarActions() {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {svg} from '../../svg.ts';
|
||||
import {html, htmlRaw} from '../../utils/html.ts';
|
||||
import {createElementFromHTML} from '../../utils/dom.ts';
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
||||
import {hideToastsAll} from '../../modules/toast.ts';
|
||||
|
||||
const {i18n} = window.config;
|
||||
@@ -32,15 +32,14 @@ export function confirmModal(modal: HTMLElement | ConfirmModalOptions): Promise<
|
||||
// it's fine to do so because the modal is triggered by user's explicit action, so the user should already have read the toast messages
|
||||
hideToastsAll();
|
||||
return new Promise((resolve) => {
|
||||
const $modal = fomanticQuery(modal);
|
||||
$modal.modal({
|
||||
showFomanticModal(modal, {
|
||||
onApprove() {
|
||||
resolve(true);
|
||||
},
|
||||
onHidden() {
|
||||
$modal.remove();
|
||||
modal.remove();
|
||||
resolve(false);
|
||||
},
|
||||
}).modal('show');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import {subscribe} from '@github/paste-markdown';
|
||||
import type CodeMirror from 'codemirror';
|
||||
import type EasyMDE from 'easymde';
|
||||
import type {DropzoneFile} from 'dropzone';
|
||||
import type Dropzone from '@deltablot/dropzone';
|
||||
|
||||
let uploadIdCounter = 0;
|
||||
|
||||
@@ -31,7 +31,7 @@ function uploadFile(dropzoneEl: HTMLElement, file: File) {
|
||||
};
|
||||
dropzoneInst.on(DropzoneCustomEventUploadDone, onUploadDone);
|
||||
// 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);
|
||||
dropzoneInst.addFile(file as Dropzone.DropzoneFile);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {toggleElem} from '../../utils/dom.ts';
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../../modules/fomantic/modal.ts';
|
||||
import {submitFormFetchAction} from '../common-fetch-action.ts';
|
||||
|
||||
function nameHasScope(name: string): boolean {
|
||||
@@ -65,7 +65,7 @@ export function initCompLabelEdit(pageSelector: string) {
|
||||
form.action = isEdit ? `${curPageLink}/edit` : `${curPageLink}/new`;
|
||||
toggleElem(elIsArchivedField, isEdit);
|
||||
syncModalUi();
|
||||
fomanticQuery(elModal).modal({
|
||||
showFomanticModal(elModal, {
|
||||
onApprove() {
|
||||
if (!form.checkValidity()) {
|
||||
form.reportValidity();
|
||||
@@ -74,7 +74,7 @@ export function initCompLabelEdit(pageSelector: string) {
|
||||
submitFormFetchAction(form);
|
||||
return false;
|
||||
},
|
||||
}).modal('show');
|
||||
});
|
||||
};
|
||||
|
||||
elModal.addEventListener('input', () => syncModalUi());
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import {initScopedWorkflowRequired} from './ScopedWorkflows.ts';
|
||||
|
||||
function setupForm(required = false) {
|
||||
window.document.body.innerHTML = `
|
||||
<form>
|
||||
<table><tbody>
|
||||
<tr>
|
||||
<td>ci.yaml<input type="hidden" name="workflow_ids" value="ci.yaml"></td>
|
||||
<td><div class="ui checkbox"><input type="checkbox" class="js-scoped-required-toggle" ${required ? 'checked' : ''}><label></label></div></td>
|
||||
<td>
|
||||
<textarea class="js-scoped-required-patterns${required ? '' : ' tw-hidden'}" data-default-pattern="org/src: CI / *">${required ? 'org/src: CI / *' : ''}</textarea>
|
||||
<span class="js-scoped-required-hint${required ? ' tw-hidden' : ''}">hint</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</form>`;
|
||||
const form = document.querySelector('form')!;
|
||||
const checkbox = form.querySelector<HTMLInputElement>('.js-scoped-required-toggle')!;
|
||||
const textarea = form.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
|
||||
const hint = form.querySelector<HTMLElement>('.js-scoped-required-hint')!;
|
||||
return {form, checkbox, textarea, hint};
|
||||
}
|
||||
|
||||
test('required toggle shows/prefills the patterns textarea (and hides the hint) and reverses otherwise, keeping the value', () => {
|
||||
const {form, checkbox, textarea, hint} = setupForm();
|
||||
initScopedWorkflowRequired(form);
|
||||
expect(textarea.classList.contains('tw-hidden')).toBe(true); // initial: not required -> textarea hidden
|
||||
expect(hint.classList.contains('tw-hidden')).toBe(false); // ... and the hint shown in its place
|
||||
|
||||
// check -> textarea shown and prefilled; hint hidden
|
||||
checkbox.checked = true;
|
||||
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
expect(textarea.classList.contains('tw-hidden')).toBe(false);
|
||||
expect(hint.classList.contains('tw-hidden')).toBe(true);
|
||||
expect(textarea.value).toBe('org/src: CI / *');
|
||||
|
||||
// admin edits the pattern
|
||||
textarea.value = 'org/src: CI / build (pull_request)';
|
||||
|
||||
// uncheck -> textarea hidden (value kept, still submits as history), hint shown again
|
||||
checkbox.checked = false;
|
||||
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
expect(textarea.classList.contains('tw-hidden')).toBe(true);
|
||||
expect(hint.classList.contains('tw-hidden')).toBe(false);
|
||||
expect(textarea.value).toBe('org/src: CI / build (pull_request)');
|
||||
|
||||
// re-check -> shown again with the same value (not re-prefilled to the default)
|
||||
checkbox.checked = true;
|
||||
checkbox.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
expect(textarea.classList.contains('tw-hidden')).toBe(false);
|
||||
expect(textarea.value).toBe('org/src: CI / build (pull_request)');
|
||||
});
|
||||
|
||||
test('an already-required row stays shown with its stored patterns (not re-prefilled)', () => {
|
||||
const {form, textarea} = setupForm(true);
|
||||
textarea.value = 'org/src: custom / build (push)'; // a stored, admin-edited pattern
|
||||
initScopedWorkflowRequired(form);
|
||||
expect(textarea.classList.contains('tw-hidden')).toBe(false);
|
||||
expect(textarea.value).toBe('org/src: custom / build (push)');
|
||||
});
|
||||
|
||||
function setupFormWithContexts(patterns: string) {
|
||||
window.document.body.innerHTML = `
|
||||
<form>
|
||||
<table><tbody>
|
||||
<tr>
|
||||
<td>ci.yaml<input type="hidden" name="workflow_ids" value="ci.yaml"></td>
|
||||
<td><div class="ui checkbox"><input type="checkbox" class="js-scoped-required-toggle" checked><label></label></div></td>
|
||||
<td>
|
||||
<textarea class="js-scoped-required-patterns" data-default-pattern="org/src: CI / *">${patterns}</textarea>
|
||||
<span class="js-scoped-required-hint tw-hidden">hint</span>
|
||||
<table class="js-scoped-required-contexts"><tbody>
|
||||
<tr><td><span class="js-scoped-context" data-context="org/src: CI / lint (push)"></span><span class="js-scoped-context-matched tw-hidden">Matched</span></td></tr>
|
||||
<tr><td><span class="js-scoped-context" data-context="org/src: CI / build (push)"></span><span class="js-scoped-context-matched tw-hidden">Matched</span></td></tr>
|
||||
</tbody></table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</form>`;
|
||||
const form = document.querySelector('form')!;
|
||||
const [lintMark, buildMark] = Array.from(form.querySelectorAll<HTMLElement>('.js-scoped-context-matched'));
|
||||
return {form, lintMark, buildMark};
|
||||
}
|
||||
|
||||
test('an exact pattern marks only the context it matches', () => {
|
||||
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: CI / lint (push)');
|
||||
initScopedWorkflowRequired(form);
|
||||
expect(lintMark.classList.contains('tw-hidden')).toBe(false); // matched
|
||||
expect(buildMark.classList.contains('tw-hidden')).toBe(true); // not matched
|
||||
});
|
||||
|
||||
test('a wildcard pattern marks every matching context', () => {
|
||||
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: CI / *');
|
||||
initScopedWorkflowRequired(form);
|
||||
expect(lintMark.classList.contains('tw-hidden')).toBe(false);
|
||||
expect(buildMark.classList.contains('tw-hidden')).toBe(false);
|
||||
});
|
||||
|
||||
test('a wildcard crossing "/" matches every matching context', () => {
|
||||
const {form, lintMark, buildMark} = setupFormWithContexts('org/src: *');
|
||||
initScopedWorkflowRequired(form);
|
||||
expect(lintMark.classList.contains('tw-hidden')).toBe(false);
|
||||
expect(buildMark.classList.contains('tw-hidden')).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import {addDelegatedEventListener, onInputDebounce, toggleElem} from '../../utils/dom.ts';
|
||||
import {globMatch} from '../../utils/glob.ts';
|
||||
|
||||
// markRowMatchedContexts marks each expected status-check context whose row's textarea patterns match it.
|
||||
function markRowMatchedContexts(row: HTMLElement) {
|
||||
const textarea = row.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
|
||||
const patterns = textarea.value.split(/[\r\n]+/).map((p) => p.trim()).filter(Boolean);
|
||||
for (const ctxEl of row.querySelectorAll<HTMLElement>('.js-scoped-context')) {
|
||||
const context = ctxEl.getAttribute('data-context')!;
|
||||
const matched = patterns.some((p) => globMatch(context, p));
|
||||
toggleElem(ctxEl.parentElement!.querySelector('.js-scoped-context-matched')!, matched);
|
||||
}
|
||||
}
|
||||
|
||||
// syncScopedRequiredRow shows a scoped workflow's status-check patterns textarea (and its expected-checks preview) only while the workflow is required.
|
||||
function syncScopedRequiredRow(checkbox: HTMLInputElement) {
|
||||
const row = checkbox.closest('tr')!;
|
||||
const textarea = row.querySelector<HTMLTextAreaElement>('.js-scoped-required-patterns')!;
|
||||
toggleElem(textarea, checkbox.checked);
|
||||
toggleElem(row.querySelector('.js-scoped-required-hint')!, !checkbox.checked); // the "mark as required" hint shown in the textarea's place
|
||||
const contexts = row.querySelector('.js-scoped-required-contexts'); // only rendered when the workflow has expected checks
|
||||
if (contexts) toggleElem(contexts, checkbox.checked);
|
||||
if (checkbox.checked && !textarea.value.trim()) {
|
||||
textarea.value = textarea.getAttribute('data-default-pattern')!;
|
||||
}
|
||||
if (checkbox.checked) markRowMatchedContexts(row);
|
||||
}
|
||||
|
||||
export function initScopedWorkflowRequired(form: HTMLElement) {
|
||||
for (const checkbox of form.querySelectorAll<HTMLInputElement>('.js-scoped-required-toggle')) {
|
||||
syncScopedRequiredRow(checkbox);
|
||||
}
|
||||
for (const textarea of form.querySelectorAll<HTMLTextAreaElement>('.js-scoped-required-patterns')) {
|
||||
const row = textarea.closest('tr')!;
|
||||
textarea.addEventListener('input', onInputDebounce(() => markRowMatchedContexts(row)));
|
||||
}
|
||||
addDelegatedEventListener(form, 'change', '.js-scoped-required-toggle', (checkbox: HTMLInputElement) => syncScopedRequiredRow(checkbox));
|
||||
}
|
||||
@@ -1,31 +1,18 @@
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {htmlEscape} from '../../utils/html.ts';
|
||||
import {attachSearchBox} from '../../modules/search.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
type RepoSearchResponse = {data: Array<{repository: {full_name: string}}>};
|
||||
|
||||
export function initCompSearchRepoBox(el: HTMLElement) {
|
||||
const uid = el.getAttribute('data-uid');
|
||||
const exclusive = el.getAttribute('data-exclusive');
|
||||
// when set, the selected value is the full "owner/name" rather than the bare repo name, so a cross-owner search can be resolved unambiguously
|
||||
const fullName = el.getAttribute('data-full-name') === 'true';
|
||||
let url = `${appSubUrl}/repo/search?q={query}&uid=${uid}`;
|
||||
if (exclusive === 'true') {
|
||||
url += `&exclusive=true`;
|
||||
}
|
||||
fomanticQuery(el).search({
|
||||
minCharacters: 2,
|
||||
apiSettings: {
|
||||
url,
|
||||
onResponse(response: any) {
|
||||
const items = [];
|
||||
for (const item of response.data) {
|
||||
items.push({
|
||||
title: htmlEscape(item.repository.full_name.split('/')[1]),
|
||||
description: htmlEscape(item.repository.full_name),
|
||||
});
|
||||
}
|
||||
return {results: items};
|
||||
},
|
||||
},
|
||||
searchFields: ['full_name'],
|
||||
showNoResults: false,
|
||||
});
|
||||
if (exclusive === 'true') url += `&exclusive=true`;
|
||||
attachSearchBox(el, url, (response: RepoSearchResponse) => response.data.map((item) => ({
|
||||
title: fullName ? item.repository.full_name : item.repository.full_name.split('/')[1],
|
||||
description: item.repository.full_name,
|
||||
})));
|
||||
}
|
||||
|
||||
@@ -1,49 +1,30 @@
|
||||
import {htmlEscape} from '../../utils/html.ts';
|
||||
import {fomanticQuery} from '../../modules/fomantic/base.ts';
|
||||
import {attachSearchBox, type SearchResult} from '../../modules/search.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
const looksLikeEmailAddressCheck = /^\S+@\S+$/;
|
||||
|
||||
type UserSearchResponse = {data: Array<{login: string; avatar_url: string; full_name: string}>};
|
||||
|
||||
export function initCompSearchUserBox() {
|
||||
const searchUserBox = document.querySelector('#search-user-box');
|
||||
if (!searchUserBox) return;
|
||||
const box = document.querySelector<HTMLElement>('#search-user-box');
|
||||
if (!box) return;
|
||||
|
||||
const allowEmailInput = searchUserBox.getAttribute('data-allow-email') === 'true';
|
||||
const allowEmailDescription = searchUserBox.getAttribute('data-allow-email-description') ?? undefined;
|
||||
const includeOrgs = searchUserBox.getAttribute('data-include-orgs') === 'true';
|
||||
fomanticQuery(searchUserBox).search({
|
||||
minCharacters: 2,
|
||||
apiSettings: {
|
||||
url: `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`,
|
||||
onResponse(response: any) {
|
||||
const resultItems = [];
|
||||
const searchQuery = searchUserBox.querySelector('input')!.value;
|
||||
const searchQueryUppercase = searchQuery.toUpperCase();
|
||||
for (const item of response.data) {
|
||||
const resultItem = {
|
||||
title: item.login,
|
||||
image: item.avatar_url,
|
||||
description: htmlEscape(item.full_name),
|
||||
};
|
||||
if (searchQueryUppercase === item.login.toUpperCase()) {
|
||||
resultItems.unshift(resultItem); // add the exact match to the top
|
||||
} else {
|
||||
resultItems.push(resultItem);
|
||||
}
|
||||
}
|
||||
const allowEmailInput = box.getAttribute('data-allow-email') === 'true';
|
||||
const allowEmailDescription = box.getAttribute('data-allow-email-description') ?? undefined;
|
||||
const includeOrgs = box.getAttribute('data-include-orgs') === 'true';
|
||||
const url = `${appSubUrl}/user/search_candidates?q={query}&orgs=${includeOrgs}`;
|
||||
|
||||
if (allowEmailInput && !resultItems.length && looksLikeEmailAddressCheck.test(searchQuery)) {
|
||||
const resultItem = {
|
||||
title: searchQuery,
|
||||
description: allowEmailDescription,
|
||||
};
|
||||
resultItems.push(resultItem);
|
||||
}
|
||||
|
||||
return {results: resultItems};
|
||||
},
|
||||
},
|
||||
searchFields: ['login', 'full_name'],
|
||||
showNoResults: false,
|
||||
attachSearchBox(box, url, (response: UserSearchResponse, query) => {
|
||||
const items: SearchResult[] = [];
|
||||
const queryUpper = query.toUpperCase();
|
||||
for (const item of response.data) {
|
||||
const result: SearchResult = {title: item.login, image: item.avatar_url, description: item.full_name};
|
||||
if (queryUpper === item.login.toUpperCase()) items.unshift(result); // exact match floats to top
|
||||
else items.push(result);
|
||||
}
|
||||
if (allowEmailInput && !items.length && looksLikeEmailAddressCheck.test(query)) {
|
||||
items.push({title: query, description: allowEmailDescription});
|
||||
}
|
||||
return items;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -37,11 +37,6 @@ export function initCompWebHookEditor() {
|
||||
document.querySelector<HTMLButtonElement>('#test-delivery')?.addEventListener('click', async function () {
|
||||
this.classList.add('is-loading', 'disabled');
|
||||
await POST(this.getAttribute('data-link')!);
|
||||
setTimeout(() => {
|
||||
const redirectUrl = this.getAttribute('data-redirect');
|
||||
if (redirectUrl) {
|
||||
window.location.href = redirectUrl;
|
||||
}
|
||||
}, 5000);
|
||||
setTimeout(() => window.location.reload(), 5000);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export async function initRepoContributors() {
|
||||
loadingTitle: el.getAttribute('data-locale-loading-title'),
|
||||
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
|
||||
loadingInfo: el.getAttribute('data-locale-loading-info'),
|
||||
chartZoomHint: el.getAttribute('data-locale-chart-zoom-hint'),
|
||||
},
|
||||
});
|
||||
View.mount(el);
|
||||
|
||||
@@ -1,54 +1,25 @@
|
||||
import {clippie} from 'clippie';
|
||||
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
||||
import {copyToClipboardWithFeedback} from '../modules/clipboard.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() {
|
||||
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');
|
||||
|
||||
let content, isRasterImage = false;
|
||||
|
||||
// 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 (rawFileLink) {
|
||||
btn.classList.add('is-loading', 'loading-icon-2px');
|
||||
try {
|
||||
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
|
||||
const contentType = res.headers.get('content-type')!;
|
||||
|
||||
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
|
||||
isRasterImage = true;
|
||||
content = await res.blob();
|
||||
} else {
|
||||
content = await res.text();
|
||||
}
|
||||
} catch {
|
||||
return showTemporaryTooltip(btn, i18n.copy_error);
|
||||
} finally {
|
||||
btn.classList.remove('is-loading', 'loading-icon-2px');
|
||||
await copyToClipboardWithFeedback(btn, async () => {
|
||||
const rawFileLink = btn.getAttribute('data-raw-file-link');
|
||||
if (!rawFileLink) {
|
||||
const lineEls = document.querySelectorAll('.file-view .lines-code');
|
||||
return Array.from(lineEls, (el) => el.textContent).join('');
|
||||
}
|
||||
} else { // text, read from DOM
|
||||
const lineEls = document.querySelectorAll('.file-view .lines-code');
|
||||
content = Array.from(lineEls, (el) => el.textContent).join('');
|
||||
}
|
||||
|
||||
// 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 as Blob, 'image/png'));
|
||||
showTemporaryTooltip(btn, success ? i18n.copy_success : i18n.copy_error);
|
||||
} else {
|
||||
showTemporaryTooltip(btn, i18n.copy_error);
|
||||
const res = await GET(rawFileLink, {credentials: 'include', redirect: 'follow'});
|
||||
const contentType = res.headers.get('content-type')!;
|
||||
if (contentType.startsWith('image/') && !contentType.startsWith('image/svg')) {
|
||||
// browsers only accept image/png in the clipboard, convert other raster formats
|
||||
const blob = await res.blob();
|
||||
return contentType === 'image/png' ? blob : convertImage(blob, 'image/png');
|
||||
}
|
||||
}
|
||||
return await res.text();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,26 +1,24 @@
|
||||
import {svg} from '../svg.ts';
|
||||
import {svgRaw} from '../svg.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {clippie} from 'clippie';
|
||||
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
||||
import {copyToClipboardWithFeedback} from '../modules/clipboard.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {createElementFromHTML, createElementFromAttrs} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {isImageFile, isVideoFile} from '../utils.ts';
|
||||
import type {DropzoneFile, DropzoneOptions} from 'dropzone/index.js';
|
||||
import type Dropzone from '@deltablot/dropzone';
|
||||
|
||||
const {i18n} = window.config;
|
||||
|
||||
type CustomDropzoneFile = DropzoneFile & {uuid: string};
|
||||
type CustomDropzoneFile = Dropzone.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: HTMLElement, opts: DropzoneOptions) {
|
||||
async function createDropzone(el: HTMLElement, opts: Dropzone.DropzoneOptions) {
|
||||
const [{default: Dropzone}] = await Promise.all([
|
||||
import('dropzone'),
|
||||
import('dropzone/dist/dropzone.css'),
|
||||
import('@deltablot/dropzone'),
|
||||
import('@deltablot/dropzone/dist/dropzone.css'),
|
||||
]);
|
||||
return new Dropzone(el, opts);
|
||||
}
|
||||
@@ -47,14 +45,14 @@ export function generateMarkdownLinkForAttachment(file: Partial<CustomDropzoneFi
|
||||
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(`
|
||||
<div class="tw-text-center">
|
||||
<a href="#" class="tw-cursor-pointer">${svg('octicon-copy', 14)} Copy link</a>
|
||||
</div>`);
|
||||
const copyLinkEl = createElementFromHTML<HTMLDivElement>(html`
|
||||
<div class="tw-text-center">
|
||||
<a href="#" class="tw-cursor-pointer">${svgRaw('octicon-copy', 14)} Copy link</a>
|
||||
</div>
|
||||
`);
|
||||
copyLinkEl.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const success = await clippie(generateMarkdownLinkForAttachment(file));
|
||||
showTemporaryTooltip(e.target as Element, success ? i18n.copy_success : i18n.copy_error);
|
||||
await copyToClipboardWithFeedback(copyLinkEl, generateMarkdownLinkForAttachment(file));
|
||||
});
|
||||
file.previewTemplate!.append(copyLinkEl);
|
||||
}
|
||||
@@ -112,8 +110,8 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
|
||||
});
|
||||
|
||||
dzInst.on('submit', () => {
|
||||
for (const fileUuid of Object.keys(fileUuidDict)) {
|
||||
fileUuidDict[fileUuid].submitted = true;
|
||||
for (const value of Object.values(fileUuidDict)) {
|
||||
value.submitted = true;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -149,7 +147,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
|
||||
} catch (error) {
|
||||
// TODO: if listing the existing attachments failed, it should stop from operating the content or attachments,
|
||||
// otherwise the attachments might be lost.
|
||||
showErrorToast(`Failed to load attachments: ${error}`);
|
||||
showErrorToast(`Failed to load attachments: ${errorMessage(error)}`);
|
||||
console.error(error);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {InplaceRenderPlugin} from '../render/plugin.ts';
|
||||
import {newInplacePluginPdfViewer} from '../render/plugins/inplace-pdf-viewer.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
import {basename} from '../utils.ts';
|
||||
|
||||
@@ -30,7 +31,7 @@ async function renderRawFileToContainer(container: HTMLElement, rawFileLink: str
|
||||
rendered = true;
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg = `${e}`;
|
||||
errorMsg = errorMessage(e);
|
||||
} finally {
|
||||
container.classList.remove('is-loading');
|
||||
}
|
||||
|
||||
@@ -24,8 +24,8 @@ export async function initHeatmap() {
|
||||
heatmap[dateStr] = (heatmap[dateStr] || 0) + contributions;
|
||||
}
|
||||
|
||||
const values = Object.keys(heatmap).map((v) => {
|
||||
return {date: new Date(v), count: heatmap[v]};
|
||||
const values = Object.entries(heatmap).map(([dateStr, count]) => {
|
||||
return {date: new Date(dateStr), count};
|
||||
});
|
||||
|
||||
const totalFormatted = totalContributions.toLocaleString();
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {hideElem, loadElem, queryElemChildren, queryElems} from '../utils/dom.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
|
||||
type ImageContext = {
|
||||
imageBefore: HTMLImageElement | undefined,
|
||||
@@ -94,15 +93,13 @@ function createContext(imageAfter: HTMLImageElement, imageBefore: HTMLImageEleme
|
||||
}
|
||||
|
||||
class ImageDiff {
|
||||
containerEl: HTMLElement;
|
||||
diffContainerWidth: number;
|
||||
containerEl!: HTMLElement;
|
||||
diffContainerWidth!: number;
|
||||
|
||||
async init(containerEl: HTMLElement) {
|
||||
this.containerEl = containerEl;
|
||||
containerEl.setAttribute('data-image-diff-loaded', 'true');
|
||||
|
||||
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);
|
||||
|
||||
@@ -294,7 +291,7 @@ class ImageDiff {
|
||||
|
||||
function updateOpacity() {
|
||||
if (ctx.imageAfter) {
|
||||
(ctx.imageAfter.parentNode as HTMLElement).style.opacity = `${Number(rangeInput.value) / 100}`;
|
||||
(ctx.imageAfter.parentNode as HTMLElement).style.opacity = String(Number(rangeInput.value) / 100);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -95,7 +95,7 @@ function initPostInstall() {
|
||||
if (tid && resp.status === 200) {
|
||||
clearInterval(tid);
|
||||
tid = null;
|
||||
window.location.href = targetUrl;
|
||||
window.location.assign(targetUrl);
|
||||
}
|
||||
} catch {}
|
||||
}, 1000);
|
||||
|
||||
@@ -10,7 +10,7 @@ async function receiveUpdateCount(event: MessageEvent<{type: string, data: strin
|
||||
const data = JSON.parse(event.data.data);
|
||||
for (const count of document.querySelectorAll('.notification_count')) {
|
||||
count.classList.toggle('tw-hidden', data.Count === 0);
|
||||
count.textContent = `${data.Count}`;
|
||||
count.textContent = String(data.Count);
|
||||
}
|
||||
await updateNotificationTable();
|
||||
} catch (error) {
|
||||
@@ -76,28 +76,26 @@ async function updateNotificationCountWithCallback(callback: (timeout: number, n
|
||||
}
|
||||
|
||||
async function updateNotificationTable() {
|
||||
let notificationDiv = document.querySelector('#notification_div');
|
||||
if (notificationDiv) {
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('div-only', 'true');
|
||||
params.set('sequence-number', String(++notificationSequenceNumber));
|
||||
const response = await GET(`${appSubUrl}/notifications?${params.toString()}`);
|
||||
const notificationDiv = document.querySelector('#notification_div');
|
||||
if (!notificationDiv) return;
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch notification table');
|
||||
}
|
||||
try {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
params.set('div-only', 'true');
|
||||
params.set('sequence-number', String(++notificationSequenceNumber));
|
||||
const response = await GET(`${appSubUrl}/notifications?${params.toString()}`);
|
||||
|
||||
const data = await response.text();
|
||||
const el = createElementFromHTML(data);
|
||||
if (parseInt(el.getAttribute('data-sequence-number')!) === notificationSequenceNumber) {
|
||||
notificationDiv.outerHTML = data;
|
||||
notificationDiv = document.querySelector('#notification_div')!;
|
||||
window.htmx.process(notificationDiv); // when using htmx, we must always remember to process the new content changed by us
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch notification table');
|
||||
}
|
||||
|
||||
const data = await response.text();
|
||||
const el = createElementFromHTML(data);
|
||||
if (parseInt(el.getAttribute('data-sequence-number')!) === notificationSequenceNumber) {
|
||||
notificationDiv.outerHTML = data;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -114,7 +112,7 @@ async function updateNotificationCount(): Promise<number> {
|
||||
toggleElem('.notification_count', data.new !== 0);
|
||||
|
||||
for (const el of document.querySelectorAll('.notification_count')) {
|
||||
el.textContent = `${data.new}`;
|
||||
el.textContent = String(data.new);
|
||||
}
|
||||
|
||||
return data.new as number;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import {parseIssueHref} from '../utils.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {createApp} from 'vue';
|
||||
import {createTippy, getAttachedTippyInstance} from '../modules/tippy.ts';
|
||||
import {addDelegatedEventListener} from '../utils/dom.ts';
|
||||
import type {Issue} from '../types.ts';
|
||||
|
||||
type IssueInfo = {
|
||||
convertedIssue: Issue,
|
||||
renderedLabels: string,
|
||||
};
|
||||
|
||||
const issueInfoCache = new Map<string, IssueInfo>();
|
||||
|
||||
async function getIssueInfo(url: string): Promise<IssueInfo> {
|
||||
if (issueInfoCache.has(url)) return issueInfoCache.get(url)!;
|
||||
const resp = await GET(url);
|
||||
if (!resp.ok) throw new Error(resp.statusText || 'Unknown network error');
|
||||
const data = await resp.json();
|
||||
issueInfoCache.set(url, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
async function showRefIssuePopup(link: HTMLAnchorElement) {
|
||||
const [data, {default: ContextPopup}] = await Promise.all([
|
||||
getIssueInfo(`${link.pathname}/info`),
|
||||
import('../components/ContextPopup.vue'),
|
||||
]);
|
||||
const el = document.createElement('div');
|
||||
const app = createApp(ContextPopup, {
|
||||
issue: data.convertedIssue,
|
||||
renderedLabels: data.renderedLabels,
|
||||
});
|
||||
app.mount(el);
|
||||
// suppress ancestor title like from .commit-summary to prevent double tooltip
|
||||
link.title = '';
|
||||
createTippy(link, {
|
||||
theme: 'default',
|
||||
content: el,
|
||||
trigger: 'mouseenter focus',
|
||||
placement: 'top-start',
|
||||
interactive: true,
|
||||
role: 'dialog',
|
||||
interactiveBorder: 5,
|
||||
onDestroy: () => app.unmount(),
|
||||
}).show();
|
||||
}
|
||||
|
||||
export function initRefIssueContextPopup() {
|
||||
const selector = 'a[href]:not([data-ref-issue-popup]):not(.ref-external-issue)';
|
||||
addDelegatedEventListener<HTMLAnchorElement, MouseEvent>(document, 'mouseover', selector, (link) => {
|
||||
if (!parseIssueHref(link.getAttribute('href')!).ownerName) return;
|
||||
if (!link.classList.contains('ref-issue') && !link.closest('[data-ref-issue-container]')) return;
|
||||
if (getAttachedTippyInstance(link)) return;
|
||||
link.setAttribute('data-ref-issue-popup', '');
|
||||
|
||||
// delay so a mouse passing over the link doesn't fire a fetch
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const cancel = () => {
|
||||
clearTimeout(timer);
|
||||
link.removeAttribute('data-ref-issue-popup');
|
||||
link.removeEventListener('mouseleave', cancel);
|
||||
};
|
||||
timer = setTimeout(async () => {
|
||||
link.removeEventListener('mouseleave', cancel);
|
||||
try {
|
||||
await showRefIssuePopup(link);
|
||||
} catch (err) {
|
||||
console.error('Failed to load issue info:', err);
|
||||
link.removeAttribute('data-ref-issue-popup');
|
||||
}
|
||||
}, 300);
|
||||
link.addEventListener('mouseleave', cancel);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import {updateWorkflowBadgeFields} from './repo-actions.ts';
|
||||
|
||||
test('updateWorkflowBadgeFields updates badge snippets for selected branch', () => {
|
||||
document.body.innerHTML = `
|
||||
<div
|
||||
data-badge-url="https://gitea.example.com/user1/repo1/actions/workflows/build/test%20workflow.yml/badge.svg?branch=main"
|
||||
data-workflow-url="https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml"
|
||||
data-workflow-display-name="CI [prod]\\build "fast" <ok>"
|
||||
>
|
||||
<img data-workflow-badge-image src="">
|
||||
<input id="workflow-badge-url" readonly>
|
||||
<textarea id="workflow-badge-markdown" readonly></textarea>
|
||||
<textarea id="workflow-badge-html" readonly></textarea>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const form = document.querySelector<HTMLElement>('[data-badge-url]')!;
|
||||
|
||||
updateWorkflowBadgeFields(form, 'release/1.0 & hotfix');
|
||||
|
||||
const badgeURL = 'https://gitea.example.com/user1/repo1/actions/workflows/build/test%20workflow.yml/badge.svg?branch=release%2F1.0+%26+hotfix';
|
||||
expect(form.querySelector<HTMLImageElement>('[data-workflow-badge-image]')!.src).toBe(badgeURL);
|
||||
expect(form.querySelector<HTMLInputElement>('#workflow-badge-url')!.value).toBe(badgeURL);
|
||||
expect(form.querySelector<HTMLTextAreaElement>('#workflow-badge-markdown')!.value).toBe(
|
||||
`[![CI \\[prod\\]\\\\build "fast" <ok>](${badgeURL})](https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml)`,
|
||||
);
|
||||
expect(form.querySelector<HTMLTextAreaElement>('#workflow-badge-html')!.value).toBe(
|
||||
`<a href="https://gitea.example.com/user1/repo1/actions?workflow=build%2Ftest+workflow.yml"><img src="${badgeURL}" alt="CI [prod]\\build "fast" <ok>"></a>`,
|
||||
);
|
||||
});
|
||||
@@ -1,11 +1,37 @@
|
||||
import {createApp} from 'vue';
|
||||
import RepoActionView from '../components/RepoActionView.vue';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {html} from '../utils/html.ts';
|
||||
|
||||
export function initRepositoryActionView() {
|
||||
export function updateWorkflowBadgeFields(form: HTMLElement, branch: string): void {
|
||||
const badgeURLParsed = new URL(form.getAttribute('data-badge-url')!);
|
||||
badgeURLParsed.searchParams.set('branch', branch);
|
||||
|
||||
const badgeURL = badgeURLParsed.href;
|
||||
const workflowURL = form.getAttribute('data-workflow-url')!;
|
||||
const displayName = form.getAttribute('data-workflow-display-name')!;
|
||||
const markdownAltText = displayName.replaceAll(/[\\[\]]/g, (c) => `\\${c}`);
|
||||
|
||||
form.querySelector<HTMLImageElement>('[data-workflow-badge-image]')!.src = badgeURL;
|
||||
form.querySelector<HTMLInputElement>('#workflow-badge-url')!.value = badgeURL;
|
||||
form.querySelector<HTMLTextAreaElement>('#workflow-badge-markdown')!.value = `[](${workflowURL})`;
|
||||
form.querySelector<HTMLTextAreaElement>('#workflow-badge-html')!.value = html`<a href="${workflowURL}"><img src="${badgeURL}" alt="${displayName}"></a>`;
|
||||
}
|
||||
|
||||
function initWorkflowBadgeForm(form: HTMLElement): void {
|
||||
const branchInput = form.querySelector<HTMLInputElement>('[data-workflow-badge-branch]')!;
|
||||
branchInput.addEventListener('change', () => updateWorkflowBadgeFields(form, branchInput.value));
|
||||
updateWorkflowBadgeFields(form, branchInput.value);
|
||||
}
|
||||
|
||||
export function initRepositoryActions() {
|
||||
registerGlobalInitFunc('initWorkflowBadgeForm', initWorkflowBadgeForm);
|
||||
initRepositoryActionsView();
|
||||
}
|
||||
|
||||
function initRepositoryActionsView() {
|
||||
const el = document.querySelector('#repo-action-view');
|
||||
if (!el) return;
|
||||
const runId = parseInt(el.getAttribute('data-run-id')!);
|
||||
const jobId = parseInt(el.getAttribute('data-job-id')!);
|
||||
|
||||
// TODO: the parent element's full height doesn't work well now,
|
||||
// but we can not pollute the global style at the moment, only fix the height problem for pages with this component
|
||||
@@ -13,35 +39,46 @@ export function initRepositoryActionView() {
|
||||
if (parentFullHeight) parentFullHeight.classList.add('tw-pb-0');
|
||||
|
||||
const view = createApp(RepoActionView, {
|
||||
runId,
|
||||
jobId,
|
||||
actionsUrl: el.getAttribute('data-actions-url'),
|
||||
jobId: parseInt(el.getAttribute('data-job-id')!),
|
||||
actionsViewUrl: el.getAttribute('data-actions-view-url'),
|
||||
locale: {
|
||||
approve: el.getAttribute('data-locale-approve'),
|
||||
cancel: el.getAttribute('data-locale-cancel'),
|
||||
rerun: el.getAttribute('data-locale-rerun'),
|
||||
rerun_all: el.getAttribute('data-locale-rerun-all'),
|
||||
rerun_failed: el.getAttribute('data-locale-rerun-failed'),
|
||||
latest: el.getAttribute('data-locale-latest'),
|
||||
latestAttempt: el.getAttribute('data-locale-latest-attempt'),
|
||||
attempt: el.getAttribute('data-locale-attempt'),
|
||||
scheduled: el.getAttribute('data-locale-runs-scheduled'),
|
||||
commit: el.getAttribute('data-locale-runs-commit'),
|
||||
pushedBy: el.getAttribute('data-locale-runs-pushed-by'),
|
||||
workflowGraph: el.getAttribute('data-locale-runs-workflow-graph'),
|
||||
summary: el.getAttribute('data-locale-summary'),
|
||||
allJobs: el.getAttribute('data-locale-all-jobs'),
|
||||
jobSummaries: el.getAttribute('data-locale-job-summaries'),
|
||||
expandCallerJobs: el.getAttribute('data-locale-expand-caller-jobs'),
|
||||
collapseCallerJobs: el.getAttribute('data-locale-collapse-caller-jobs'),
|
||||
triggeredVia: el.getAttribute('data-locale-triggered-via'),
|
||||
rerunTriggered: el.getAttribute('data-locale-rerun-triggered'),
|
||||
backToPullRequest: el.getAttribute('data-locale-back-to-pull-request'),
|
||||
backToWorkflow: el.getAttribute('data-locale-back-to-workflow'),
|
||||
statusLabel: el.getAttribute('data-locale-status-label'),
|
||||
totalDuration: el.getAttribute('data-locale-total-duration'),
|
||||
artifactsTitle: el.getAttribute('data-locale-artifacts-title'),
|
||||
areYouSure: el.getAttribute('data-locale-are-you-sure'),
|
||||
artifactExpired: el.getAttribute('data-locale-artifact-expired'),
|
||||
artifactExpiresAt: el.getAttribute('data-locale-artifact-expires-at'),
|
||||
artifactExpiredAt: el.getAttribute('data-locale-artifact-expired-at'),
|
||||
confirmDeleteArtifact: el.getAttribute('data-locale-confirm-delete-artifact'),
|
||||
showTimeStamps: el.getAttribute('data-locale-show-timestamps'),
|
||||
showLogSeconds: el.getAttribute('data-locale-show-log-seconds'),
|
||||
showFullScreen: el.getAttribute('data-locale-show-full-screen'),
|
||||
downloadLogs: el.getAttribute('data-locale-download-logs'),
|
||||
copyOutput: el.getAttribute('data-locale-copy-output'),
|
||||
status: {
|
||||
unknown: el.getAttribute('data-locale-status-unknown'),
|
||||
waiting: el.getAttribute('data-locale-status-waiting'),
|
||||
running: el.getAttribute('data-locale-status-running'),
|
||||
cancelling: el.getAttribute('data-locale-status-cancelling'),
|
||||
success: el.getAttribute('data-locale-status-success'),
|
||||
failure: el.getAttribute('data-locale-status-failure'),
|
||||
cancelled: el.getAttribute('data-locale-status-cancelled'),
|
||||
@@ -51,7 +88,18 @@ export function initRepositoryActionView() {
|
||||
logsAlwaysAutoScroll: el.getAttribute('data-locale-logs-always-auto-scroll'),
|
||||
logsAlwaysExpandRunning: el.getAttribute('data-locale-logs-always-expand-running'),
|
||||
workflowFile: el.getAttribute('data-locale-workflow-file'),
|
||||
workflowFileNoPermission: el.getAttribute('data-locale-workflow-file-no-permission'),
|
||||
runDetails: el.getAttribute('data-locale-run-details'),
|
||||
workflowDependencies: el.getAttribute('data-locale-workflow-dependencies'),
|
||||
graphJobsCount1: el.getAttribute('data-locale-graph-jobs-count-1'),
|
||||
graphJobsCountN: el.getAttribute('data-locale-graph-jobs-count-n'),
|
||||
graphDependenciesCount1: el.getAttribute('data-locale-graph-dependencies-count-1'),
|
||||
graphDependenciesCountN: el.getAttribute('data-locale-graph-dependencies-count-n'),
|
||||
graphSuccessRate: el.getAttribute('data-locale-graph-success-rate'),
|
||||
graphZoomIn: el.getAttribute('data-locale-graph-zoom-in'),
|
||||
graphZoomMax: el.getAttribute('data-locale-graph-zoom-max'),
|
||||
graphZoomOut: el.getAttribute('data-locale-graph-zoom-out'),
|
||||
graphResetView: el.getAttribute('data-locale-graph-reset-view'),
|
||||
},
|
||||
});
|
||||
view.mount(el);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
|
||||
export function initRepoBranchButton() {
|
||||
initRepoCreateBranchButton();
|
||||
@@ -18,7 +18,7 @@ function initRepoCreateBranchButton() {
|
||||
const fromSpanName = el.getAttribute('data-modal-from-span') || '#modal-create-branch-from-span';
|
||||
document.querySelector(fromSpanName)!.textContent = el.getAttribute('data-branch-from');
|
||||
|
||||
fomanticQuery(el.getAttribute('data-modal')!).modal('show');
|
||||
showFomanticModal(document.querySelector(el.getAttribute('data-modal')!));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import {svg} from '../svg.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {toAbsoluteUrl} from '../utils.ts';
|
||||
import {addDelegatedEventListener} from '../utils/dom.ts';
|
||||
|
||||
function changeHash(hash: string) {
|
||||
@@ -24,16 +23,16 @@ function selectRange(range: string): Element | null {
|
||||
if (!refInNewIssue) return;
|
||||
const urlIssueNew = refInNewIssue.getAttribute('data-url-issue-new');
|
||||
const urlParamBodyLink = refInNewIssue.getAttribute('data-url-param-body-link')!;
|
||||
const issueContent = `${toAbsoluteUrl(urlParamBodyLink)}#${anchor}`; // the default content for issue body
|
||||
const issueContent = `${urlParamBodyLink}#${anchor}`; // the default content for issue body
|
||||
refInNewIssue.setAttribute('href', `${urlIssueNew}?body=${encodeURIComponent(issueContent)}`);
|
||||
};
|
||||
|
||||
const updateViewGitBlameFragment = function (anchor: string) {
|
||||
if (!viewGitBlame) return;
|
||||
let href = viewGitBlame.getAttribute('href')!;
|
||||
href = `${href.replace(/#L\d+$|#L\d+-L\d+$/, '')}`;
|
||||
href = href.replace(/#L\d+$|#L\d+-L\d+$/, '');
|
||||
if (anchor.length !== 0) {
|
||||
href = `${href}#${anchor}`;
|
||||
href += `#${anchor}`;
|
||||
}
|
||||
viewGitBlame.setAttribute('href', href);
|
||||
};
|
||||
@@ -41,9 +40,8 @@ function selectRange(range: string): Element | null {
|
||||
const updateCopyPermalinkUrl = function (anchor: string) {
|
||||
if (!copyPermalink) return;
|
||||
let link = copyPermalink.getAttribute('data-url')!;
|
||||
link = `${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
|
||||
link = `${window.location.origin}${link.replace(/#L\d+$|#L\d+-L\d+$/, '')}#${anchor}`;
|
||||
copyPermalink.setAttribute('data-clipboard-text', link);
|
||||
copyPermalink.setAttribute('data-clipboard-text-type', 'url');
|
||||
};
|
||||
|
||||
const rangeFields = range ? range.split('-') : [];
|
||||
|
||||
@@ -24,3 +24,29 @@ export function initCommitStatuses() {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function initAvatarStackPopup() {
|
||||
registerGlobalInitFunc('initAvatarStackPopup', (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: 'menu',
|
||||
trigger: 'click',
|
||||
hideOnClick: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function initCommitFileHistoryFollowRename() {
|
||||
registerGlobalInitFunc('initCommitHistoryFollowRename', (el: HTMLInputElement) => {
|
||||
el.addEventListener('change', () => {
|
||||
const url = new URL(window.location.toString());
|
||||
url.searchParams.set('follow-rename', String(el.checked));
|
||||
window.location.assign(url.href);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {queryElems} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
@@ -22,10 +23,10 @@ async function onDownloadArchive(e: Event) {
|
||||
if (data.complete) break;
|
||||
await sleep(Math.min((tryCount + 1) * 750, 2000));
|
||||
}
|
||||
window.location.href = el.href; // the archive is ready, start real downloading
|
||||
window.location.assign(el.href); // the archive is ready, start real downloading
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showErrorToast(`Failed to download the archive: ${e}`, {duration: 2500});
|
||||
showErrorToast(`Failed to download the archive: ${errorMessage(e)}`, {duration: 2500});
|
||||
} finally {
|
||||
targetLoading.classList.remove('is-loading', 'loading-icon-2px');
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import {GET} from '../modules/fetch.ts';
|
||||
async function loadBranchesAndTags(area: Element, loadingButton: Element) {
|
||||
loadingButton.classList.add('disabled');
|
||||
try {
|
||||
const res = await GET(loadingButton.getAttribute('data-fetch-url')!);
|
||||
const res = await GET(loadingButton.getAttribute('data-url')!);
|
||||
const data = await res.json();
|
||||
hideElem(loadingButton);
|
||||
addTags(area, data.tags);
|
||||
|
||||
@@ -5,12 +5,14 @@ import {validateTextareaNonEmpty} from './comp/ComboMarkdownEditor.ts';
|
||||
import {initViewedCheckboxListenerFor, 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, queryElems} from '../utils/dom.ts';
|
||||
import {queryElemSiblings, hideElem, showElem, animateOnce, addDelegatedEventListener, createElementFromHTML, queryElems} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {POST, GET} from '../modules/fetch.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {invertFileFolding} from './file-fold.ts';
|
||||
import {parseDom, sleep} from '../utils.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
import {registerGlobalSelectorFunc} from '../modules/observer.ts';
|
||||
import {performFetchActionTrigger} from './common-fetch-action.ts';
|
||||
|
||||
function initRepoDiffFileBox(el: HTMLElement) {
|
||||
// switch between "rendered" and "source", for image and CSV files
|
||||
@@ -40,8 +42,8 @@ function initRepoDiffConversationForm() {
|
||||
const formData = new FormData(form);
|
||||
|
||||
// if the form is submitted by a button, append the button's name and value to the form data
|
||||
const submitter = submitEventSubmitter(e);
|
||||
const isSubmittedByButton = (submitter?.nodeName === 'BUTTON') || (submitter?.nodeName === 'INPUT' && submitter.type === 'submit');
|
||||
const submitter = e.submitter;
|
||||
const isSubmittedByButton = submitter instanceof HTMLButtonElement || (submitter instanceof HTMLInputElement && submitter.type === 'submit');
|
||||
if (isSubmittedByButton && submitter.name) {
|
||||
formData.append(submitter.name, submitter.value);
|
||||
}
|
||||
@@ -84,7 +86,7 @@ function initRepoDiffConversationForm() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
showErrorToast(`Submit form failed: ${error}`);
|
||||
showErrorToast(`Submit form failed: ${errorMessage(error)}`);
|
||||
} finally {
|
||||
form?.classList.remove('is-loading');
|
||||
}
|
||||
@@ -127,7 +129,7 @@ function initRepoDiffConversationNav() {
|
||||
const navIndex = isPrevious ? previousIndex : nextIndex;
|
||||
const elNavConversation = elAllConversations[navIndex];
|
||||
const anchor = elNavConversation.querySelector('.comment')!.id;
|
||||
window.location.href = `#${anchor}`;
|
||||
window.location.assign(`#${anchor}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -172,7 +174,6 @@ async function loadMoreFiles(btn: Element): Promise<boolean> {
|
||||
// * append the newly loaded file list items to the existing list
|
||||
const respFileBoxesChildren = Array.from(respFileBoxes.children); // "children:HTMLCollection" will be empty after replaceWith
|
||||
document.querySelector('#diff-incomplete')!.replaceWith(...respFileBoxesChildren);
|
||||
for (const el of respFileBoxesChildren) window.htmx.process(el);
|
||||
onShowMoreFiles();
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -204,7 +205,6 @@ function initRepoDiffShowMore() {
|
||||
const respFileBody = respDoc.querySelector('#diff-file-boxes .diff-file-body .file-body')!;
|
||||
const respFileBodyChildren = Array.from(respFileBody.children); // "children:HTMLCollection" 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.
|
||||
@@ -245,14 +245,14 @@ async function onLocationHashChange() {
|
||||
const issueCommentPrefix = '#issuecomment-';
|
||||
if (currentHash.startsWith(issueCommentPrefix)) {
|
||||
const commentId = currentHash.substring(issueCommentPrefix.length);
|
||||
const expandButton = document.querySelector<HTMLElement>(`.code-expander-button[data-hidden-comment-ids*=",${commentId},"]`);
|
||||
const expandButton = document.querySelector<HTMLElement>(`.code-expander-button[data-hidden-comment-ids*=",${CSS.escape(commentId)},"]`);
|
||||
if (expandButton) {
|
||||
// avoid infinite loop, do not re-click the button if already clicked
|
||||
const attrAutoLoadClicked = 'data-auto-load-clicked';
|
||||
if (expandButton.hasAttribute(attrAutoLoadClicked)) return;
|
||||
expandButton.setAttribute(attrAutoLoadClicked, 'true');
|
||||
expandButton.click();
|
||||
await sleep(500); // Wait for HTMX to load the content. FIXME: need to drop htmx in the future
|
||||
// trigger the fetch action to load the hidden comments, after loading, it will try to find the target element again
|
||||
await performFetchActionTrigger(expandButton, 'load');
|
||||
continue; // Try again to find the element
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,32 +6,58 @@ 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';
|
||||
import {dirname} from '../utils.ts';
|
||||
import {pathEscapeSegments} from '../utils/url.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
|
||||
function initEditPreviewTab(elForm: HTMLFormElement) {
|
||||
const elTabMenu = elForm.querySelector('.repo-editor-menu')!;
|
||||
fomanticQuery(elTabMenu.querySelectorAll('.item')).tab();
|
||||
const elTabMenu = elForm.querySelector('.repo-editor-menu');
|
||||
if (!elTabMenu) return;
|
||||
|
||||
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]');
|
||||
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]');
|
||||
if (!elPreviewTab || !elPreviewPanel) return;
|
||||
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path');
|
||||
const elTextarea = elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea');
|
||||
if (!elTreePath || !elTextarea) return;
|
||||
|
||||
const repoLink = elTabMenu.getAttribute('data-repo-link')!;
|
||||
const refSubUrl = elTabMenu.getAttribute('data-ref-sub-url')!;
|
||||
const branchName = elTabMenu.getAttribute('data-branch-name')!;
|
||||
|
||||
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]')!;
|
||||
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]')!;
|
||||
elPreviewTab.addEventListener('click', async () => {
|
||||
const elTreePath = elForm.querySelector<HTMLInputElement>('input#tree_path')!;
|
||||
const previewUrl = elPreviewTab.getAttribute('data-preview-url')!;
|
||||
const previewContextRef = elPreviewTab.getAttribute('data-preview-context-ref');
|
||||
let previewContext = `${previewContextRef}/${elTreePath.value}`;
|
||||
previewContext = previewContext.substring(0, previewContext.lastIndexOf('/'));
|
||||
// "preview context" is the request path directory of the file, the rendered links will be resolved based on this path
|
||||
// TODO: MARKUP-RENDER-CONTEXT: due to various hacky patches, this logic is unnecessarily complicated, see the backend
|
||||
const previewContext = dirname(`${repoLink}/src/${refSubUrl}/${pathEscapeSegments(elTreePath.value)}`);
|
||||
const formData = new FormData();
|
||||
formData.append('mode', 'file');
|
||||
formData.append('context', previewContext);
|
||||
formData.append('text', elForm.querySelector<HTMLTextAreaElement>('.tab[data-tab="write"] textarea')!.value);
|
||||
formData.append('text', elTextarea.value);
|
||||
formData.append('file_path', elTreePath.value);
|
||||
const response = await POST(previewUrl, {data: formData});
|
||||
const data = await response.text();
|
||||
const resp = await POST(`${repoLink}/markup`, {data: formData});
|
||||
if (!resp.ok) {
|
||||
showErrorToast(`Failed to render preview: ${resp.status} ${resp.statusText}`);
|
||||
return;
|
||||
}
|
||||
const data = await resp.text();
|
||||
renderPreviewPanelContent(elPreviewPanel, data);
|
||||
});
|
||||
|
||||
const elDiffTab = elTabMenu.querySelector('a[data-tab="diff"]');
|
||||
const elDiffPanel = elForm.querySelector('.tab[data-tab="diff"]');
|
||||
if (elDiffTab && elDiffPanel) {
|
||||
// the "diff" tab only exists for an existing file, but not for a new file
|
||||
elDiffTab.addEventListener('click', async () => {
|
||||
const diffUrl = `${repoLink}/_preview/${pathEscapeSegments(branchName)}/${pathEscapeSegments(elTreePath.value)}`;
|
||||
// don't use FormData, because FormData sends "\r\n" line endings, backend assumes "\n" line endings
|
||||
const resp = await POST(diffUrl, {data: new URLSearchParams({content: elTextarea.value})});
|
||||
if (!resp.ok) {
|
||||
showErrorToast(`Failed to render diff: ${resp.status} ${resp.statusText}`);
|
||||
return;
|
||||
}
|
||||
elDiffPanel.innerHTML = await resp.text();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoEditor() {
|
||||
@@ -54,6 +80,8 @@ export function initRepoEditor() {
|
||||
// ATTENTION: two pages have this filename input
|
||||
// * new/edit file page: there is a code editor
|
||||
// * upload page: there is no code editor, but a uploader
|
||||
// FIXME: the related logic is totally a mess, need to completely rewrite, that's also the root reason for
|
||||
// why the "migrate to CodeMirror" PR took very long time on the legacy code and introduced "#file-name (filenameInput)" regressions many times
|
||||
const filenameInput = document.querySelector<HTMLInputElement>('#file-name')!;
|
||||
if (!filenameInput) return;
|
||||
filenameInput.value = filenameInput.defaultValue; // prevent browser from restoring form values on refresh
|
||||
|
||||
@@ -42,7 +42,7 @@ export function initRepoGraphGit() {
|
||||
|
||||
elGraphBody.classList.add('is-loading');
|
||||
try {
|
||||
const resp = await GET(ajaxUrl.toString());
|
||||
const resp = await GET(ajaxUrl.href);
|
||||
elGraphBody.innerHTML = await resp.text();
|
||||
} finally {
|
||||
elGraphBody.classList.remove('is-loading');
|
||||
@@ -51,7 +51,7 @@ export function initRepoGraphGit() {
|
||||
|
||||
const dropdownSelected = params.getAll('branch');
|
||||
if (params.has('hide-pr-refs') && params.get('hide-pr-refs') === 'true') {
|
||||
dropdownSelected.splice(0, 0, '...flow-hide-pr-refs');
|
||||
dropdownSelected.unshift('...flow-hide-pr-refs');
|
||||
}
|
||||
|
||||
const $dropdown = fomanticQuery('#flow-select-refs-dropdown');
|
||||
|
||||
@@ -96,10 +96,7 @@ export function initRepoTopicBar() {
|
||||
};
|
||||
const query = stripTags(this.urlData.query.trim());
|
||||
let found_query = false;
|
||||
const current_topics = [];
|
||||
for (const el of queryElemChildren(topicDropdown, 'a.ui.label.visible')) {
|
||||
current_topics.push(el.getAttribute('data-value'));
|
||||
}
|
||||
const current_topics = Array.from(queryElemChildren(topicDropdown, 'a.ui.label.visible'), (el) => el.getAttribute('data-value'));
|
||||
|
||||
if (res.topics) {
|
||||
let found = false;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import {svg} from '../svg.ts';
|
||||
import {svgRaw} from '../svg.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {createElementFromHTML, showElem} from '../utils/dom.ts';
|
||||
import {parseIssuePageInfo} from '../utils.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
|
||||
let i18nTextEdited: string;
|
||||
let i18nTextOptions: string;
|
||||
@@ -11,24 +13,24 @@ let i18nTextDeleteFromHistory: string;
|
||||
let i18nTextDeleteFromHistoryConfirm: string;
|
||||
|
||||
function showContentHistoryDetail(issueBaseUrl: string, commentId: string, historyId: string, itemTitleHtml: string) {
|
||||
const elDetailDialog = createElementFromHTML(`
|
||||
<div class="ui modal content-history-detail-dialog">
|
||||
${svg('octicon-x', 16, 'close icon inside')}
|
||||
<div class="header tw-flex tw-items-center tw-justify-between">
|
||||
<div>${itemTitleHtml}</div>
|
||||
<div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden">
|
||||
${i18nTextOptions}
|
||||
${svg('octicon-triangle-down', 14, 'dropdown icon')}
|
||||
<div class="menu">
|
||||
<div class="item tw-text-red" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
|
||||
const elDetailDialog = createElementFromHTML(html`
|
||||
<div class="ui modal content-history-detail-dialog">
|
||||
${svgRaw('octicon-x', 16, 'close icon inside')}
|
||||
<div class="header flex-left-right">
|
||||
<div>${htmlRaw(itemTitleHtml)}</div>
|
||||
<div class="ui dropdown dialog-header-options tw-mr-8 tw-hidden">
|
||||
${i18nTextOptions}
|
||||
${svgRaw('octicon-triangle-down', 14, 'dropdown icon')}
|
||||
<div class="menu">
|
||||
<div class="item tw-text-red" data-option-item="delete">${i18nTextDeleteFromHistory}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-diff-data is-loading"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="comment-diff-data is-loading"></div>
|
||||
</div>`);
|
||||
`);
|
||||
document.body.append(elDetailDialog);
|
||||
const elOptionsDropdown = elDetailDialog.querySelector('.ui.dropdown.dialog-header-options')!;
|
||||
const $fomanticDialog = fomanticQuery(elDetailDialog);
|
||||
const $fomanticDropdownOptions = fomanticQuery(elOptionsDropdown);
|
||||
$fomanticDropdownOptions.dropdown({
|
||||
showOnFocus: false,
|
||||
@@ -46,7 +48,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
|
||||
const resp = await response.json();
|
||||
|
||||
if (resp.ok) {
|
||||
$fomanticDialog.modal('hide');
|
||||
hideFomanticModal(elDetailDialog);
|
||||
} else {
|
||||
showErrorToast(resp.message);
|
||||
}
|
||||
@@ -63,7 +65,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
|
||||
$fomanticDropdownOptions.dropdown('clear', true);
|
||||
},
|
||||
});
|
||||
$fomanticDialog.modal({
|
||||
showFomanticModal(elDetailDialog, {
|
||||
async onShow() {
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
@@ -86,19 +88,20 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
|
||||
}
|
||||
},
|
||||
onHidden() {
|
||||
$fomanticDialog.remove();
|
||||
elDetailDialog.remove();
|
||||
},
|
||||
}).modal('show');
|
||||
});
|
||||
}
|
||||
|
||||
function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, commentId: string) {
|
||||
const elHeaderLeft = elCommentItem.querySelector('.comment-header-left')!;
|
||||
const menuHtml = `
|
||||
<div class="ui dropdown interact-fg content-history-menu" data-comment-id="${commentId}">
|
||||
• ${i18nTextEdited}${svg('octicon-triangle-down', 14, 'dropdown icon')}
|
||||
<div class="menu">
|
||||
const menuHtml = html`
|
||||
<div class="ui dropdown interact-fg content-history-menu tw-flex-shrink-0" data-comment-id="${commentId}">
|
||||
• ${i18nTextEdited}${svgRaw('octicon-triangle-down', 14, 'dropdown icon')}
|
||||
<div class="menu">
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
`;
|
||||
|
||||
elHeaderLeft.querySelector(`.ui.dropdown.content-history-menu`)?.remove(); // remove the old one if exists
|
||||
elHeaderLeft.append(createElementFromHTML(menuHtml));
|
||||
@@ -127,7 +130,7 @@ export async function initRepoIssueContentHistory() {
|
||||
const issuePageInfo = parseIssuePageInfo();
|
||||
if (!issuePageInfo.issueNumber) return;
|
||||
|
||||
const elIssueDescription = document.querySelector('.repository.issue .timeline-item.comment.first'); // issue(PR) main content
|
||||
const elIssueDescription = document.querySelector('.repository.issue .timeline-item.comment.issue-content-comment'); // issue(PR) main content
|
||||
const elComments = document.querySelectorAll('.repository.issue .comment-list .comment'); // includes: issue(PR) comments, review comments, code comments
|
||||
if (!elIssueDescription && !elComments.length) return;
|
||||
|
||||
@@ -145,7 +148,7 @@ export async function initRepoIssueContentHistory() {
|
||||
if (resp.editedHistoryCountMap[0] && elIssueDescription) {
|
||||
showContentHistoryMenu(issueBaseUrl, elIssueDescription, '0');
|
||||
}
|
||||
for (const [commentId, _editedCount] of Object.entries(resp.editedHistoryCountMap)) {
|
||||
for (const commentId of Object.keys(resp.editedHistoryCountMap)) {
|
||||
if (commentId === '0') continue;
|
||||
const elIssueComment = document.querySelector(`#issuecomment-${commentId}`);
|
||||
if (elIssueComment) showContentHistoryMenu(issueBaseUrl, elIssueComment, commentId);
|
||||
|
||||
@@ -3,6 +3,7 @@ import {getComboMarkdownEditor, initComboMarkdownEditor, ComboMarkdownEditor} fr
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {hideElem, querySingleVisibleElem, showElem} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {triggerUploadStateChanged} from './comp/EditorUpload.ts';
|
||||
import {convertHtmlToMarkdown} from '../markup/html2markdown.ts';
|
||||
import {applyAreYouSure, reinitializeAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
@@ -73,7 +74,7 @@ async function tryOnEditContent(e: Event) {
|
||||
}
|
||||
comboMarkdownEditor.dropzoneSubmitReload();
|
||||
} catch (error) {
|
||||
showErrorToast(`Failed to save the content: ${error}`);
|
||||
showErrorToast(`Failed to save the content: ${errorMessage(error)}`);
|
||||
console.error(error);
|
||||
} finally {
|
||||
renderContent.classList.remove('is-loading');
|
||||
|
||||
@@ -2,6 +2,7 @@ import {updateIssuesMeta} from './repo-common.ts';
|
||||
import {toggleElem, queryElems, isElemVisible} from '../utils/dom.ts';
|
||||
import {html, htmlRaw} from '../utils/html.ts';
|
||||
import {confirmModal} from './comp/ConfirmModal.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {createSortable} from '../modules/sortable.ts';
|
||||
import {DELETE, POST} from '../modules/fetch.ts';
|
||||
@@ -56,21 +57,12 @@ function initRepoIssueListCheckboxes() {
|
||||
|
||||
const url = el.getAttribute('data-url')!;
|
||||
let action = el.getAttribute('data-action')!;
|
||||
let elementId = el.getAttribute('data-element-id')!;
|
||||
const issueIDList: string[] = [];
|
||||
for (const el of document.querySelectorAll('.issue-checkbox:checked')) {
|
||||
issueIDList.push(el.getAttribute('data-issue-id')!);
|
||||
}
|
||||
const elementId = el.getAttribute('data-element-id')!;
|
||||
const issueIDList: string[] = Array.from(document.querySelectorAll('.issue-checkbox:checked'), (el) => (el.getAttribute('data-issue-id')!));
|
||||
const issueIDs = issueIDList.join(',');
|
||||
if (!issueIDs) return;
|
||||
|
||||
// for assignee
|
||||
if (elementId === '0' && url.endsWith('/assignee')) {
|
||||
elementId = '';
|
||||
action = 'clear';
|
||||
}
|
||||
|
||||
// for toggle
|
||||
// for label toggle
|
||||
if (action === 'toggle' && e.altKey) {
|
||||
action = 'toggle-alt';
|
||||
}
|
||||
@@ -87,7 +79,9 @@ function initRepoIssueListCheckboxes() {
|
||||
await updateIssuesMeta(url, action, issueIDs, elementId);
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
showErrorToast(err.responseJSON?.error ?? err.message);
|
||||
// FIXME: this logic (including updateIssuesMeta) is not right, should refactor to our JSONError framework
|
||||
const e = err as {responseJSON?: {error: string}};
|
||||
showErrorToast(e.responseJSON?.error ?? errorMessage(err));
|
||||
}
|
||||
},
|
||||
));
|
||||
@@ -106,7 +100,7 @@ function initDropdownUserRemoteSearch(el: Element) {
|
||||
fullTextSearch: true,
|
||||
selectOnKeydown: false,
|
||||
action: (_text: string, value: string) => {
|
||||
window.location.href = actionJumpUrl.replace('{username}', encodeURIComponent(value));
|
||||
window.location.assign(actionJumpUrl.replace('{username}', encodeURIComponent(value)));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -139,7 +133,7 @@ function initDropdownUserRemoteSearch(el: Element) {
|
||||
processedResults.length = 0;
|
||||
for (const item of resp.results) {
|
||||
const htmlAvatar = html`<img class="ui avatar tw-align-middle" src="${item.avatar_link}" aria-hidden="true" alt width="20" height="20">`;
|
||||
const htmlFullName = item.full_name ? html`<span class="username-fullname gt-ellipsis">(${item.full_name})</span>` : '';
|
||||
const htmlFullName = item.full_name ? html`<span class="username-fullname">(${item.full_name})</span>` : '';
|
||||
const htmlItem = html`<span class="username-display">${htmlRaw(htmlAvatar)}<span>${item.username}</span>${htmlRaw(htmlFullName)}</span>`;
|
||||
if (selectedUsername.toLowerCase() === item.username.toLowerCase()) selectedUsername = item.username;
|
||||
processedResults.push({value: item.username, name: htmlItem});
|
||||
@@ -189,7 +183,7 @@ function initPinRemoveButton() {
|
||||
// Delete the tooltip
|
||||
el._tippy.destroy();
|
||||
// Remove the Card
|
||||
el.closest(`div.issue-card[data-issue-id="${id}"]`)!.remove();
|
||||
el.closest(`div.issue-card[data-issue-id="${CSS.escape(String(id))}"]`)!.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,94 +1,42 @@
|
||||
import {createApp} from 'vue';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {createElementFromHTML} from '../utils/dom.ts';
|
||||
import {registerGlobalEventFunc} from '../modules/observer.ts';
|
||||
|
||||
function initRepoPullRequestUpdate(el: HTMLElement) {
|
||||
const prUpdateButtonContainer = el.querySelector('#update-pr-branch-with-base');
|
||||
if (!prUpdateButtonContainer) return;
|
||||
export function initRepoPullRequestUpdate(el: HTMLElement) {
|
||||
const elDropdown = el.querySelector(':scope > .ui.dropdown');
|
||||
if (!elDropdown) return;
|
||||
const elButton = el.querySelector<HTMLButtonElement>(':scope > button')!;
|
||||
|
||||
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 | undefined;
|
||||
try {
|
||||
response = await POST(this.getAttribute('data-do')!);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
this.classList.remove('is-loading');
|
||||
}
|
||||
let data: Record<string, any> | undefined;
|
||||
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({
|
||||
fomanticQuery(elDropdown).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);
|
||||
}
|
||||
elButton.textContent = choiceEl.textContent;
|
||||
elButton.setAttribute('data-url', choiceEl.getAttribute('data-update-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 onCommitStatusChecksToggle(btn: HTMLElement) {
|
||||
const panel = btn.closest('.commit-status-toggle')!.parentElement!;
|
||||
const list = panel.querySelector<HTMLElement>('.commit-status-list')!;
|
||||
list.style.maxHeight = list.style.maxHeight ? '' : '0px'; // toggle
|
||||
btn.textContent = btn.getAttribute(list.style.maxHeight ? 'data-show-all' : 'data-hide-all');
|
||||
}
|
||||
|
||||
async function initRepoPullRequestMergeForm(box: HTMLElement) {
|
||||
const el = box.querySelector('#pull-request-merge-form');
|
||||
if (!el) return;
|
||||
|
||||
const data = JSON.parse(el.getAttribute('data-merge-form-props')!);
|
||||
const {default: PullRequestMergeForm} = await import('../components/PullRequestMergeForm.vue');
|
||||
const view = createApp(PullRequestMergeForm);
|
||||
view.mount(el);
|
||||
}
|
||||
|
||||
function executeScripts(elem: Element) {
|
||||
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);
|
||||
}
|
||||
const view = createApp(PullRequestMergeForm, {mergeFormProps: data});
|
||||
view.mount(el); // TODO: can unmount when reloaded?
|
||||
}
|
||||
|
||||
export function initRepoPullMergeBox(el: HTMLElement) {
|
||||
initRepoPullRequestCommitStatus(el);
|
||||
initRepoPullRequestUpdate(el);
|
||||
registerGlobalEventFunc('click', 'onCommitStatusChecksToggle', onCommitStatusChecksToggle);
|
||||
initRepoPullRequestMergeForm(el);
|
||||
|
||||
const reloadingIntervalValue = el.getAttribute('data-pull-merge-box-reloading-interval');
|
||||
@@ -98,6 +46,14 @@ export function initRepoPullMergeBox(el: HTMLElement) {
|
||||
const pullLink = el.getAttribute('data-pull-link');
|
||||
let timerId: number | null;
|
||||
|
||||
// The merge box has complex buttons & form, if the user has interacted with any element, don't refresh.
|
||||
// Otherwise, the user won't be able to merge or schedule a merge (auto-merge) when the PR status is not ready.
|
||||
let interacted = false;
|
||||
const interactionEvents = ['focusin', 'mousedown', 'click', 'keydown', 'input'];
|
||||
for (const event of interactionEvents) {
|
||||
el.addEventListener(event, () => { interacted = true }, {capture: true});
|
||||
}
|
||||
|
||||
let reloadMergeBox: () => Promise<void>;
|
||||
const stopReloading = () => {
|
||||
if (!timerId) return;
|
||||
@@ -105,7 +61,7 @@ export function initRepoPullMergeBox(el: HTMLElement) {
|
||||
timerId = null;
|
||||
};
|
||||
const startReloading = () => {
|
||||
if (timerId) return;
|
||||
if (timerId || interacted) return;
|
||||
setTimeout(reloadMergeBox, reloadingInterval);
|
||||
};
|
||||
const onVisibilityChange = () => {
|
||||
@@ -116,6 +72,7 @@ export function initRepoPullMergeBox(el: HTMLElement) {
|
||||
}
|
||||
};
|
||||
reloadMergeBox = async () => {
|
||||
if (interacted) return;
|
||||
const resp = await GET(`${pullLink}/merge_box`);
|
||||
stopReloading();
|
||||
if (!resp.ok) {
|
||||
@@ -123,8 +80,13 @@ export function initRepoPullMergeBox(el: HTMLElement) {
|
||||
return;
|
||||
}
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange);
|
||||
const newElem = createElementFromHTML(await resp.text());
|
||||
executeScripts(newElem);
|
||||
|
||||
const respText = (await resp.text()).trim();
|
||||
if (!respText) {
|
||||
el.remove(); // merge box might not exist if the PR has changed (e.g.: merged and the head branch has been deleted)
|
||||
return;
|
||||
}
|
||||
const newElem = createElementFromHTML(respText);
|
||||
el.replaceWith(newElem);
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {addDelegatedEventListener, queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {parseDom} from '../utils.ts';
|
||||
|
||||
export function syncIssueMainContentTimelineItems(oldMainContent: Element, newMainContent: Element) {
|
||||
@@ -28,12 +29,10 @@ export function syncIssueMainContentTimelineItems(oldMainContent: Element, newMa
|
||||
// for event item (e.g.: "add & remove labels"), we want to replace the existing one if exists
|
||||
// because the label operations can be merged into one event item, so the new item might be different from the old one
|
||||
oldItem.replaceWith(newItem);
|
||||
window.htmx.process(newItem);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
timelineEnd.insertAdjacentElement('beforebegin', newItem);
|
||||
window.htmx.process(newItem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +43,7 @@ export class IssueSidebarComboList {
|
||||
elDropdown: HTMLElement;
|
||||
elList: HTMLElement | null;
|
||||
elComboValue: HTMLInputElement;
|
||||
initialValues: string[];
|
||||
initialValues: string[] = [];
|
||||
container: HTMLElement;
|
||||
|
||||
elIssueMainContent: HTMLElement;
|
||||
@@ -71,7 +70,7 @@ export class IssueSidebarComboList {
|
||||
|
||||
updateUiList(changedValues: Array<string>) {
|
||||
if (!this.elList) return;
|
||||
const elEmptyTip = this.elList.querySelector('.item.empty-list')!;
|
||||
const elEmptyTip = this.elList.querySelector(':scope > .item.empty-list')!;
|
||||
queryElemChildren(this.elList, '.item:not(.empty-list)', (el) => el.remove());
|
||||
for (const value of changedValues) {
|
||||
const el = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
||||
@@ -92,7 +91,6 @@ export class IssueSidebarComboList {
|
||||
// we can safely replace the whole right part (sidebar) because there are only some dropdowns and lists
|
||||
const newSidebar = doc.querySelector('.issue-content-right')!;
|
||||
this.elIssueSidebar.replaceWith(newSidebar);
|
||||
window.htmx.process(newSidebar);
|
||||
|
||||
// for the main content (left side), at the moment we only support handling known timeline items
|
||||
const newMainContent = doc.querySelector('.issue-content-left')!;
|
||||
@@ -132,7 +130,7 @@ export class IssueSidebarComboList {
|
||||
await this.reloadPagePartially();
|
||||
} catch (e) {
|
||||
console.error('Failed to update to backend', e);
|
||||
showErrorToast(`Failed to update to backend: ${e}`);
|
||||
showErrorToast(`Failed to update to backend: ${errorMessage(e)}`);
|
||||
} finally {
|
||||
this.elIssueSidebar.classList.remove('is-loading');
|
||||
}
|
||||
@@ -141,7 +139,7 @@ export class IssueSidebarComboList {
|
||||
async doUpdate() {
|
||||
const changedValues = this.collectCheckedValues();
|
||||
if (this.initialValues.join(',') === changedValues.join(',')) return;
|
||||
this.updateUiList(changedValues);
|
||||
if (!this.updateUrl) this.updateUiList(changedValues);
|
||||
if (this.updateUrl) await this.updateToBackend(changedValues);
|
||||
this.initialValues = changedValues;
|
||||
}
|
||||
@@ -198,7 +196,9 @@ export class IssueSidebarComboList {
|
||||
const elItem = this.elDropdown.querySelector<HTMLElement>(`.menu > .item[data-value="${CSS.escape(value)}"]`);
|
||||
elItem?.classList.add('checked');
|
||||
}
|
||||
this.updateUiList(values);
|
||||
if (this.elList && this.elList.getAttribute('data-combo-list-inited') !== 'true') {
|
||||
this.updateUiList(values);
|
||||
}
|
||||
}
|
||||
this.initialValues = this.collectCheckedValues();
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {html, htmlEscape, htmlRaw} from '../utils/html.ts';
|
||||
import {createTippy} from '../modules/tippy.ts';
|
||||
import {
|
||||
addDelegatedEventListener,
|
||||
@@ -10,11 +11,11 @@ import {
|
||||
} from '../utils/dom.ts';
|
||||
import {setFileFolding} from './file-fold.ts';
|
||||
import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {toAbsoluteUrl} from '../utils.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
|
||||
@@ -26,7 +27,7 @@ function initRepoIssueLabelFilter(elDropdown: HTMLElement) {
|
||||
const queryLabels = url.searchParams.get('labels') || '';
|
||||
const selectedLabelIds = new Set<string>();
|
||||
for (const id of queryLabels ? queryLabels.split(',') : []) {
|
||||
selectedLabelIds.add(`${Math.abs(parseInt(id))}`); // "labels" contains negative ids, which are excluded
|
||||
selectedLabelIds.add(String(Math.abs(parseInt(id)))); // "labels" contains negative ids, which are excluded
|
||||
}
|
||||
|
||||
const excludeLabel = (e: MouseEvent | KeyboardEvent, item: Element) => {
|
||||
@@ -128,9 +129,9 @@ export function initRepoIssueCommentDelete() {
|
||||
// on the Conversation page, there is no parent "tr", so no need to do anything for "add-code-comment"
|
||||
if (lineType) {
|
||||
if (lineType === 'same') {
|
||||
document.querySelector(`[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`)!.classList.remove('tw-invisible');
|
||||
document.querySelector(`[data-path="${CSS.escape(String(path))}"] .add-code-comment[data-idx="${CSS.escape(String(idx))}"]`)!.classList.remove('tw-invisible');
|
||||
} else {
|
||||
document.querySelector(`[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`)!.classList.remove('tw-invisible');
|
||||
document.querySelector(`[data-path="${CSS.escape(String(path))}"] .add-code-comment[data-side="${CSS.escape(String(side))}"][data-idx="${CSS.escape(String(idx))}"]`)!.classList.remove('tw-invisible');
|
||||
}
|
||||
}
|
||||
conversationHolder.remove();
|
||||
@@ -196,8 +197,9 @@ export async function handleReply(el: HTMLElement) {
|
||||
}
|
||||
|
||||
export function initRepoPullRequestReview() {
|
||||
if (window.location.hash && window.location.hash.startsWith('#issuecomment-')) {
|
||||
const commentDiv = document.querySelector(window.location.hash);
|
||||
const currentHash = window.location.hash;
|
||||
if (currentHash.startsWith('#issuecomment-') || currentHash.startsWith('#pullrequestreview-')) {
|
||||
const commentDiv = document.querySelector(currentHash);
|
||||
if (commentDiv) {
|
||||
// get the name of the parent id
|
||||
const groupID = commentDiv.closest('div[id^="code-comments-"]')?.getAttribute('id');
|
||||
@@ -272,15 +274,13 @@ export function initRepoPullRequestReview() {
|
||||
|
||||
let ntr = tr.nextElementSibling;
|
||||
if (!ntr?.classList.contains('add-comment')) {
|
||||
ntr = createElementFromHTML(`
|
||||
<tr class="add-comment" data-line-type="${htmlEscape(lineType)}">
|
||||
${isSplit ? `
|
||||
<td class="add-comment-left" colspan="4"></td>
|
||||
<td class="add-comment-right" colspan="4"></td>
|
||||
` : `
|
||||
<td class="add-comment-left add-comment-right" colspan="5"></td>
|
||||
`}
|
||||
</tr>`);
|
||||
const tdSplit = html`<td class="add-comment-left" colspan="4"></td><td class="add-comment-right" colspan="4"></td>`;
|
||||
const tdUnified = html`<td class="add-comment-left add-comment-right" colspan="5"></td>`;
|
||||
ntr = createElementFromHTML(html`
|
||||
<tr class="add-comment" data-line-type="${lineType}">
|
||||
${isSplit ? htmlRaw(tdSplit) : htmlRaw(tdUnified)}
|
||||
</tr>
|
||||
`);
|
||||
tr.after(ntr);
|
||||
}
|
||||
const td = ntr.querySelector(`.add-comment-${side}`)!;
|
||||
@@ -329,12 +329,12 @@ export function initRepoIssueReferenceIssue() {
|
||||
const target = el.getAttribute('data-target');
|
||||
const content = document.querySelector(`#${target}`)?.textContent ?? '';
|
||||
const poster = el.getAttribute('data-poster-username');
|
||||
const reference = toAbsoluteUrl(el.getAttribute('data-reference')!);
|
||||
const reference = el.getAttribute('data-reference')!;
|
||||
const modalSelector = el.getAttribute('data-modal')!;
|
||||
const modal = document.querySelector(modalSelector)!;
|
||||
const textarea = modal.querySelector<HTMLTextAreaElement>('textarea[name="content"]')!;
|
||||
textarea.value = `${content}\n\n_Originally posted by @${poster} in ${reference}_`;
|
||||
fomanticQuery(modal).modal('show');
|
||||
showFomanticModal(modal);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -425,7 +425,7 @@ export function initRepoIssueTitleEdit() {
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
showErrorToast(error.message);
|
||||
showErrorToast(errorMessage(error));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ 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';
|
||||
import {initRepoPullMergeBox, initRepoPullRequestUpdate} from './repo-issue-pull.ts';
|
||||
|
||||
function initRepoBranchTagSelector() {
|
||||
registerGlobalInitFunc('initRepoBranchTagSelector', async (elRoot: HTMLInputElement) => {
|
||||
@@ -38,6 +38,9 @@ export function initBranchSelectorTabs() {
|
||||
}
|
||||
|
||||
export function initRepository() {
|
||||
registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
|
||||
registerGlobalInitFunc('initRepoPullRequestUpdate', initRepoPullRequestUpdate);
|
||||
|
||||
const pageContent = document.querySelector('.page-content.repository');
|
||||
if (!pageContent) return;
|
||||
|
||||
@@ -68,8 +71,6 @@ export function initRepository() {
|
||||
initRepoIssueCommentDelete();
|
||||
initRepoIssueCodeCommentCancel();
|
||||
initCompReactionSelector();
|
||||
|
||||
registerGlobalInitFunc('initRepoPullMergeBox', initRepoPullMergeBox);
|
||||
}
|
||||
|
||||
initUnicodeEscapeButton();
|
||||
|
||||
@@ -47,7 +47,6 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) {
|
||||
value: String(tmplRepo.repository.id),
|
||||
});
|
||||
}
|
||||
$repoTemplateDropdown.fomanticExt.onResponseKeepSelectedItem($repoTemplateDropdown, inputRepoTemplate.value);
|
||||
return {results};
|
||||
},
|
||||
cache: false,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
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 {hideFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {queryElemChildren, queryElems, toggleElem} from '../utils/dom.ts';
|
||||
import type {SortableEvent} from 'sortablejs';
|
||||
import {toggleFullScreen} from '../utils.ts';
|
||||
@@ -120,11 +120,11 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
|
||||
}
|
||||
|
||||
// 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}"]`)!;
|
||||
const elEditButton = writableProjectBoard.querySelector<HTMLButtonElement>(`.show-project-column-modal-edit[${CSS.escape(attrDataColumnId)}="${CSS.escape(columnId)}"]`)!;
|
||||
elEditButton.setAttribute(attrDataColumnTitle, elColumnTitle.value);
|
||||
elEditButton.setAttribute(attrDataColumnColor, elColumnColor.value);
|
||||
|
||||
const elBoardColumn = writableProjectBoard.querySelector<HTMLElement>(`.project-column[data-id="${columnId}"]`)!;
|
||||
const elBoardColumn = writableProjectBoard.querySelector<HTMLElement>(`.project-column[data-id="${CSS.escape(columnId)}"]`)!;
|
||||
const elBoardColumnTitle = elBoardColumn.querySelector<HTMLElement>(`.project-column-title-text`)!;
|
||||
elBoardColumnTitle.textContent = elColumnTitle.value;
|
||||
if (elColumnColor.value) {
|
||||
@@ -138,7 +138,7 @@ function initRepoProjectColumnEdit(writableProjectBoard: Element): void {
|
||||
queryElemChildren(elBoardColumn, '.divider', (divider: HTMLElement) => divider.style.removeProperty('color'));
|
||||
}
|
||||
|
||||
fomanticQuery(elModal).modal('hide');
|
||||
hideFomanticModal(elModal);
|
||||
} finally {
|
||||
elForm.classList.remove('is-loading');
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {hideToastsAll, showErrorToast} from '../modules/toast.ts';
|
||||
import {getComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
|
||||
import {hideElem} from '../utils/dom.ts';
|
||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||
import {registerGlobalEventFunc, registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {htmlEscape} from '../utils/html.ts';
|
||||
import {compareVersions} from 'compare-versions';
|
||||
@@ -11,7 +12,7 @@ export function initRepoReleaseNew() {
|
||||
registerGlobalEventFunc('click', 'onReleaseEditAttachmentDelete', (el) => {
|
||||
const uuid = el.getAttribute('data-uuid')!;
|
||||
const id = el.getAttribute('data-id')!;
|
||||
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${uuid}']`)!.value = 'true';
|
||||
document.querySelector<HTMLInputElement>(`input[name='attachment-del-${CSS.escape(uuid)}']`)!.value = 'true';
|
||||
hideElem(`#attachment-${id}`);
|
||||
});
|
||||
registerGlobalInitFunc('initReleaseEditForm', (elForm: HTMLFormElement) => {
|
||||
@@ -112,7 +113,7 @@ function initGenerateReleaseNotes(elForm: HTMLFormElement) {
|
||||
}
|
||||
} finally {
|
||||
elModal.classList.remove('loading', 'disabled');
|
||||
fomanticQuery(elModal).modal('hide');
|
||||
hideFomanticModal(elModal);
|
||||
comboEditor.focus();
|
||||
}
|
||||
};
|
||||
@@ -139,12 +140,12 @@ function initGenerateReleaseNotes(elForm: HTMLFormElement) {
|
||||
}
|
||||
$dropdown.dropdown('set selected', guessPreviousReleaseTag(tagName, existingTags));
|
||||
|
||||
fomanticQuery(elModal).modal({
|
||||
showFomanticModal(elModal, {
|
||||
onApprove: () => {
|
||||
doSubmit(tagName); // don't await, need to return false to keep the modal
|
||||
return false;
|
||||
},
|
||||
}).modal('show');
|
||||
});
|
||||
};
|
||||
|
||||
buttonShowModal.addEventListener('click', doShowModal);
|
||||
|
||||
@@ -1,23 +1,12 @@
|
||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||
import {addDelegatedEventListener, queryElems} from '../utils/dom.ts';
|
||||
|
||||
export function initRepositorySearch() {
|
||||
const repositorySearchForm = document.querySelector<HTMLFormElement>('#repo-search-form');
|
||||
if (!repositorySearchForm) return;
|
||||
|
||||
repositorySearchForm.addEventListener('change', (e: Event) => {
|
||||
e.preventDefault();
|
||||
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of new FormData(repositorySearchForm).entries()) {
|
||||
params.set(key, value as string);
|
||||
}
|
||||
if ((e.target as HTMLInputElement).name === 'clear-filter') {
|
||||
params.delete('archived');
|
||||
params.delete('fork');
|
||||
params.delete('mirror');
|
||||
params.delete('template');
|
||||
params.delete('private');
|
||||
}
|
||||
|
||||
params.delete('clear-filter');
|
||||
window.location.search = params.toString();
|
||||
registerGlobalInitFunc('initRepositorySearch', (form: HTMLFormElement) => {
|
||||
addDelegatedEventListener(form, 'change', 'input[type="radio"]', () => form.submit());
|
||||
form.querySelector('.repo-search-filter-reset')!.addEventListener('click', () => {
|
||||
queryElems(form, 'input[type="radio"]', (el: HTMLInputElement) => el.checked = false);
|
||||
form.submit();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,13 +13,13 @@ vi.mock('../modules/sortable.ts', () => ({
|
||||
|
||||
const branchesHTML = `
|
||||
<div id="protected-branches-list" data-update-priority-url="some/repo/branches/priority">
|
||||
<div class="flex-item tw-items-center item" data-id="1">
|
||||
<div class="item" data-id="1">
|
||||
<div class="drag-handle"></div>
|
||||
</div>
|
||||
<div class="flex-item tw-items-center item" data-id="2">
|
||||
<div class="item" data-id="2">
|
||||
<div class="drag-handle"></div>
|
||||
</div>
|
||||
<div class="flex-item tw-items-center item" data-id="3">
|
||||
<div class="item" data-id="3">
|
||||
<div class="drag-handle"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import {createSortable} from '../modules/sortable.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {queryElemChildren} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
|
||||
export function initRepoSettingsBranchesDrag() {
|
||||
const protectedBranchesList = document.querySelector<HTMLElement>('#protected-branches-list');
|
||||
@@ -23,8 +24,7 @@ export function initRepoSettingsBranchesDrag() {
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMessage = String(err);
|
||||
showErrorToast(`Failed to update branch protection rule priority:, error: ${errorMessage}`);
|
||||
showErrorToast(`Failed to update branch protection rule priority: ${errorMessage(err)}`);
|
||||
}
|
||||
})();
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import {onInputDebounce, queryElems, 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';
|
||||
import {attachSearchBox} from '../modules/search.ts';
|
||||
import {globMatch} from '../utils/glob.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
@@ -17,16 +18,21 @@ function initRepoSettingsCollaboration() {
|
||||
dropdownEl.classList.add('is-loading', 'loading-icon-2px');
|
||||
const lastValue = dropdownEl.getAttribute('data-last-value')!;
|
||||
$dropdown.dropdown('hide');
|
||||
let respOk = false;
|
||||
try {
|
||||
const uid = dropdownEl.getAttribute('data-uid')!;
|
||||
await POST(dropdownEl.getAttribute('data-url')!, {data: new URLSearchParams({uid, 'mode': value})});
|
||||
textEl.textContent = text;
|
||||
dropdownEl.setAttribute('data-last-value', value);
|
||||
} catch {
|
||||
textEl.textContent = '(error)'; // prevent from misleading users when error occurs
|
||||
dropdownEl.setAttribute('data-last-value', lastValue);
|
||||
const resp = await POST(dropdownEl.getAttribute('data-url')!, {data: new URLSearchParams({uid, 'mode': value})});
|
||||
respOk = resp.ok;
|
||||
if (respOk) {
|
||||
textEl.textContent = text;
|
||||
dropdownEl.setAttribute('data-last-value', value);
|
||||
}
|
||||
} finally {
|
||||
dropdownEl.classList.remove('is-loading');
|
||||
if (!respOk) {
|
||||
textEl.textContent = '(error)'; // prevent from misleading users when error occurs
|
||||
dropdownEl.setAttribute('data-last-value', lastValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
onHide() {
|
||||
@@ -45,29 +51,17 @@ function initRepoSettingsCollaboration() {
|
||||
}
|
||||
}
|
||||
|
||||
function initRepoSettingsSearchTeamBox() {
|
||||
const searchTeamBox = document.querySelector('#search-team-box');
|
||||
if (!searchTeamBox) return;
|
||||
type TeamSearchResponse = {data: Array<{name: string; permission: string}>};
|
||||
|
||||
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}`,
|
||||
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};
|
||||
},
|
||||
},
|
||||
});
|
||||
function initRepoSettingsSearchTeamBox() {
|
||||
const box = document.querySelector<HTMLElement>('#search-team-box');
|
||||
if (!box) return;
|
||||
|
||||
const url = `${appSubUrl}/org/${box.getAttribute('data-org-name')}/teams/-/search?q={query}`;
|
||||
attachSearchBox(box, url, (response: TeamSearchResponse) => response.data.map((item) => ({
|
||||
title: item.name,
|
||||
description: `${item.permission} access`, // TODO: translate this string
|
||||
})));
|
||||
}
|
||||
|
||||
function initRepoSettingsGitHook() {
|
||||
|
||||
@@ -50,9 +50,9 @@ export async function attachTribute(element: HTMLElement) {
|
||||
|
||||
const tribute = new Tribute({
|
||||
collection: [
|
||||
emojiCollection as TributeCollection<any>,
|
||||
mentionCollection as TributeCollection<any>,
|
||||
],
|
||||
emojiCollection,
|
||||
mentionCollection,
|
||||
] as TributeCollection<any>[],
|
||||
noMatchTemplate: () => '',
|
||||
});
|
||||
tribute.attach(element);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {encodeURLEncodedBase64, decodeURLEncodedBase64} from '../utils.ts';
|
||||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
import {errorMessage} from '../modules/errors.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
@@ -78,9 +79,9 @@ async function loginPasskey() {
|
||||
}
|
||||
const reply = await res.json();
|
||||
|
||||
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
|
||||
window.location.assign(reply?.redirect ?? `${appSubUrl}/`);
|
||||
} catch (err) {
|
||||
webAuthnError('general', err.message);
|
||||
webAuthnError('general', errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,7 +105,7 @@ async function login2FA() {
|
||||
await verifyAssertion(credential);
|
||||
} catch (err) {
|
||||
if (!options.publicKey.extensions?.appid) {
|
||||
webAuthnError('general', err.message);
|
||||
webAuthnError('general', errorMessage(err));
|
||||
return;
|
||||
}
|
||||
delete options.publicKey.extensions.appid;
|
||||
@@ -114,7 +115,7 @@ async function login2FA() {
|
||||
});
|
||||
await verifyAssertion(credential);
|
||||
} catch (err) {
|
||||
webAuthnError('general', err.message);
|
||||
webAuthnError('general', errorMessage(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -150,7 +151,7 @@ async function verifyAssertion(assertedCredential: any) { // TODO: Credential ty
|
||||
}
|
||||
const reply = await res.json();
|
||||
|
||||
window.location.href = reply?.redirect ?? `${appSubUrl}/`;
|
||||
window.location.assign(reply?.redirect ?? `${appSubUrl}/`);
|
||||
}
|
||||
|
||||
async function webauthnRegistered(newCredential: any) { // TODO: Credential type does not work
|
||||
@@ -187,7 +188,7 @@ function webAuthnError(errorType: ErrorType, message:string = '') {
|
||||
if (errorType === 'general') {
|
||||
elErrorMsg.textContent = message || 'unknown error';
|
||||
} else {
|
||||
const elTypedError = document.querySelector(`#webauthn-error [data-webauthn-error-msg=${errorType}]`);
|
||||
const elTypedError = document.querySelector(`#webauthn-error [data-webauthn-error-msg=${CSS.escape(errorType)}]`);
|
||||
if (elTypedError) {
|
||||
elErrorMsg.textContent = `${elTypedError.textContent}${message ? ` ${message}` : ''}`;
|
||||
} else {
|
||||
@@ -262,6 +263,11 @@ async function webAuthnRegisterRequest() {
|
||||
});
|
||||
await webauthnRegistered(credential);
|
||||
} catch (err) {
|
||||
webAuthnError('unknown', err);
|
||||
// an already registered authenticator raises this
|
||||
if (err instanceof DOMException && err.name === 'InvalidStateError') {
|
||||
webAuthnError('duplicated');
|
||||
return;
|
||||
}
|
||||
webAuthnError('unknown', errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user