1
1
mirror of https://github.com/go-gitea/gitea synced 2025-07-21 09:48:37 +00:00

Merge branch 'main' into lunny/automerge_support_delete_branch

This commit is contained in:
Lunny Xiao
2024-12-05 10:54:05 -08:00
385 changed files with 9812 additions and 5827 deletions

View File

@@ -51,7 +51,7 @@ function getIconForDiffType(pType) {
<div v-else class="item-directory" :title="item.name" @click.stop="collapsed = !collapsed">
<!-- directory -->
<SvgIcon :name="collapsed ? 'octicon-chevron-right' : 'octicon-chevron-down'"/>
<SvgIcon class="text primary" name="octicon-file-directory-fill"/>
<SvgIcon class="text primary" :name="collapsed ? 'octicon-file-directory-fill' : 'octicon-file-directory-open-fill'"/>
<span class="gt-ellipsis">{{ item.name }}</span>
</div>
@@ -87,12 +87,16 @@ a, a:hover {
color: var(--color-text-light-3);
}
.item-directory {
user-select: none;
}
.item-file,
.item-directory {
display: flex;
align-items: center;
gap: 0.25em;
padding: 3px 6px;
padding: 6px;
}
.item-file:hover,

View File

@@ -1,244 +1,220 @@
<script lang="ts">
import {createApp, nextTick} from 'vue';
import $ from 'jquery';
import {SvgIcon} from '../svg.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import {showErrorToast} from '../modules/toast.ts';
import {GET} from '../modules/fetch.ts';
import {pathEscapeSegments} from '../utils/url.ts';
import type {GitRefType} from '../types.ts';
type ListItem = {
selected: boolean;
refShortName: string;
refType: GitRefType;
rssFeedLink: string;
};
type SelectedTab = 'branches' | 'tags';
type TabLoadingStates = Record<SelectedTab, '' | 'loading' | 'done'>
let currentElRoot: HTMLElement;
const sfc = {
components: {SvgIcon},
// no `data()`, at the moment, the `data()` is provided by the init code, which is not ideal and should be fixed in the future
computed: {
filteredItems() {
const items = this.items.filter((item) => {
return ((this.mode === 'branches' && item.branch) || (this.mode === 'tags' && item.tag)) &&
(!this.searchTerm || item.name.toLowerCase().includes(this.searchTerm.toLowerCase()));
searchFieldPlaceholder() {
return this.selectedTab === 'branches' ? this.textFilterBranch : this.textFilterTag;
},
filteredItems(): ListItem[] {
const searchTermLower = this.searchTerm.toLowerCase();
const items = this.allItems.filter((item: ListItem) => {
const typeMatched = (this.selectedTab === 'branches' && item.refType === 'branch') || (this.selectedTab === 'tags' && item.refType === 'tag');
if (!typeMatched) return false;
if (!this.searchTerm) return true; // match all
return item.refShortName.toLowerCase().includes(searchTermLower);
});
// TODO: fix this anti-pattern: side-effects-in-computed-properties
this.active = !items.length && this.showCreateNewBranch ? 0 : -1;
this.activeItemIndex = !items.length && this.showCreateNewRef ? 0 : -1;
return items;
},
showNoResults() {
return !this.filteredItems.length && !this.showCreateNewBranch;
if (this.tabLoadingStates[this.selectedTab] !== 'done') return false;
return !this.filteredItems.length && !this.showCreateNewRef;
},
showCreateNewBranch() {
if (this.disableCreateBranch || !this.searchTerm) {
showCreateNewRef() {
if (!this.allowCreateNewRef || !this.searchTerm) {
return false;
}
return !this.items.filter((item) => {
return item.name.toLowerCase() === this.searchTerm.toLowerCase();
return !this.allItems.filter((item: ListItem) => {
return item.refShortName === this.searchTerm; // FIXME: not quite right here, it mixes "branch" and "tag" names
}).length;
},
formActionUrl() {
return `${this.repoLink}/branches/_new/${this.branchNameSubURL}`;
},
shouldCreateTag() {
return this.mode === 'tags';
createNewRefFormActionUrl() {
return `${this.currentRepoLink}/branches/_new/${this.currentRefType}/${pathEscapeSegments(this.currentRefShortName)}`;
},
},
watch: {
menuVisible(visible) {
if (visible) {
this.focusSearchField();
this.fetchBranchesOrTags();
}
if (!visible) return;
this.focusSearchField();
this.loadTabItems();
},
},
data() {
const elRoot = currentElRoot;
const shouldShowTabBranches = elRoot.getAttribute('data-show-tab-branches') === 'true';
return {
csrfToken: window.config.csrfToken,
allItems: [] as ListItem[],
selectedTab: (shouldShowTabBranches ? 'branches' : 'tags') as SelectedTab,
searchTerm: '',
menuVisible: false,
activeItemIndex: 0,
tabLoadingStates: {} as TabLoadingStates,
textReleaseCompare: elRoot.getAttribute('data-text-release-compare'),
textBranches: elRoot.getAttribute('data-text-branches'),
textTags: elRoot.getAttribute('data-text-tags'),
textFilterBranch: elRoot.getAttribute('data-text-filter-branch'),
textFilterTag: elRoot.getAttribute('data-text-filter-tag'),
textDefaultBranchLabel: elRoot.getAttribute('data-text-default-branch-label'),
textCreateTag: elRoot.getAttribute('data-text-create-tag'),
textCreateBranch: elRoot.getAttribute('data-text-create-branch'),
textCreateRefFrom: elRoot.getAttribute('data-text-create-ref-from'),
textNoResults: elRoot.getAttribute('data-text-no-results'),
textViewAllBranches: elRoot.getAttribute('data-text-view-all-branches'),
textViewAllTags: elRoot.getAttribute('data-text-view-all-tags'),
currentRepoDefaultBranch: elRoot.getAttribute('data-current-repo-default-branch'),
currentRepoLink: elRoot.getAttribute('data-current-repo-link'),
currentTreePath: elRoot.getAttribute('data-current-tree-path'),
currentRefType: elRoot.getAttribute('data-current-ref-type'),
currentRefShortName: elRoot.getAttribute('data-current-ref-short-name'),
refLinkTemplate: elRoot.getAttribute('data-ref-link-template'),
refFormActionTemplate: elRoot.getAttribute('data-ref-form-action-template'),
dropdownFixedText: elRoot.getAttribute('data-dropdown-fixed-text'),
showTabBranches: shouldShowTabBranches,
showTabTags: elRoot.getAttribute('data-show-tab-tags') === 'true',
allowCreateNewRef: elRoot.getAttribute('data-allow-create-new-ref') === 'true',
showViewAllRefsEntry: elRoot.getAttribute('data-show-view-all-refs-entry') === 'true',
enableFeed: elRoot.getAttribute('data-enable-feed') === 'true',
};
},
beforeMount() {
if (this.viewType === 'tree') {
this.isViewTree = true;
this.refNameText = this.commitIdShort;
} else if (this.viewType === 'tag') {
this.isViewTag = true;
this.refNameText = this.tagName;
} else {
this.isViewBranch = true;
this.refNameText = this.branchName;
}
document.body.addEventListener('click', (event) => {
if (this.$el.contains(event.target)) return;
if (this.menuVisible) {
this.menuVisible = false;
}
document.body.addEventListener('click', (e) => {
if (this.$el.contains(e.target)) return;
if (this.menuVisible) this.menuVisible = false;
});
},
methods: {
selectItem(item) {
const prev = this.getSelected();
if (prev !== null) {
prev.selected = false;
}
item.selected = true;
const url = (item.tag) ? this.tagURLPrefix + item.url + this.tagURLSuffix : this.branchURLPrefix + item.url + this.branchURLSuffix;
if (!this.branchForm) {
window.location.href = url;
selectItem(item: ListItem) {
this.menuVisible = false;
if (this.refFormActionTemplate) {
this.currentRefType = item.refType;
this.currentRefShortName = item.refShortName;
let actionLink = this.refFormActionTemplate;
actionLink = actionLink.replace('{RepoLink}', this.currentRepoLink);
actionLink = actionLink.replace('{RefType}', pathEscapeSegments(item.refType));
actionLink = actionLink.replace('{RefShortName}', pathEscapeSegments(item.refShortName));
this.$el.closest('form').action = actionLink;
} else {
this.isViewTree = false;
this.isViewTag = false;
this.isViewBranch = false;
this.$refs.dropdownRefName.textContent = item.name;
if (this.setAction) {
document.querySelector(`#${this.branchForm}`)?.setAttribute('action', url);
} else {
$(`#${this.branchForm} input[name="refURL"]`).val(url);
}
$(`#${this.branchForm} input[name="ref"]`).val(item.name);
if (item.tag) {
this.isViewTag = true;
$(`#${this.branchForm} input[name="refType"]`).val('tag');
} else {
this.isViewBranch = true;
$(`#${this.branchForm} input[name="refType"]`).val('branch');
}
if (this.submitForm) {
$(`#${this.branchForm}`).trigger('submit');
}
this.menuVisible = false;
let link = this.refLinkTemplate;
link = link.replace('{RepoLink}', this.currentRepoLink);
link = link.replace('{RefType}', pathEscapeSegments(item.refType));
link = link.replace('{RefShortName}', pathEscapeSegments(item.refShortName));
link = link.replace('{TreePath}', pathEscapeSegments(this.currentTreePath));
window.location.href = link;
}
},
createNewBranch() {
if (!this.showCreateNewBranch) return;
$(this.$refs.newBranchForm).trigger('submit');
createNewRef() {
this.$refs.createNewRefForm?.submit();
},
focusSearchField() {
nextTick(() => {
this.$refs.searchField.focus();
});
},
getSelected() {
for (let i = 0, j = this.items.length; i < j; ++i) {
if (this.items[i].selected) return this.items[i];
}
return null;
},
getSelectedIndexInFiltered() {
for (let i = 0, j = this.filteredItems.length; i < j; ++i) {
for (let i = 0; i < this.filteredItems.length; ++i) {
if (this.filteredItems[i].selected) return i;
}
return -1;
},
scrollToActive() {
let el = this.$refs[`listItem${this.active}`]; // eslint-disable-line no-jquery/variable-pattern
if (!el || !el.length) return;
if (Array.isArray(el)) {
el = el[0];
}
const cont = this.$refs.scrollContainer;
if (el.offsetTop < cont.scrollTop) {
cont.scrollTop = el.offsetTop;
} else if (el.offsetTop + el.clientHeight > cont.scrollTop + cont.clientHeight) {
cont.scrollTop = el.offsetTop + el.clientHeight - cont.clientHeight;
}
getActiveItem() {
const el = this.$refs[`listItem${this.activeItemIndex}`]; // eslint-disable-line no-jquery/variable-pattern
return (el && el.length) ? el[0] : null;
},
keydown(event) {
if (event.keyCode === 40) { // arrow down
event.preventDefault();
keydown(e) {
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
e.preventDefault();
if (this.active === -1) {
this.active = this.getSelectedIndexInFiltered();
if (this.activeItemIndex === -1) {
this.activeItemIndex = this.getSelectedIndexInFiltered();
}
if (this.active + (this.showCreateNewBranch ? 0 : 1) >= this.filteredItems.length) {
const nextIndex = e.key === 'ArrowDown' ? this.activeItemIndex + 1 : this.activeItemIndex - 1;
if (nextIndex < 0) {
return;
}
this.active++;
this.scrollToActive();
} else if (event.keyCode === 38) { // arrow up
event.preventDefault();
if (this.active === -1) {
this.active = this.getSelectedIndexInFiltered();
}
if (this.active <= 0) {
if (nextIndex + (this.showCreateNewRef ? 0 : 1) > this.filteredItems.length) {
return;
}
this.active--;
this.scrollToActive();
} else if (event.keyCode === 13) { // enter
event.preventDefault();
if (this.active >= this.filteredItems.length) {
this.createNewBranch();
} else if (this.active >= 0) {
this.selectItem(this.filteredItems[this.active]);
}
} else if (event.keyCode === 27) { // escape
event.preventDefault();
this.activeItemIndex = nextIndex;
this.getActiveItem().scrollIntoView({block: 'nearest'});
} else if (e.key === 'Enter') {
e.preventDefault();
this.getActiveItem()?.click();
} else if (e.key === 'Escape') {
e.preventDefault();
this.menuVisible = false;
}
},
handleTabSwitch(mode) {
if (this.isLoading) return;
this.mode = mode;
handleTabSwitch(selectedTab) {
this.selectedTab = selectedTab;
this.focusSearchField();
this.fetchBranchesOrTags();
this.loadTabItems();
},
async fetchBranchesOrTags() {
if (!['branches', 'tags'].includes(this.mode) || this.isLoading) return;
// only fetch when branch/tag list has not been initialized
if (this.hasListInitialized[this.mode] ||
(this.mode === 'branches' && !this.showBranchesInDropdown) ||
(this.mode === 'tags' && this.noTag)
) {
return;
}
this.isLoading = true;
async loadTabItems() {
const tab = this.selectedTab;
if (this.tabLoadingStates[tab] === 'loading' || this.tabLoadingStates[tab] === 'done') return;
const refType = this.selectedTab === 'branches' ? 'branch' : 'tag';
this.tabLoadingStates[tab] = 'loading';
try {
const resp = await GET(`${this.repoLink}/${this.mode}/list`);
const url = refType === 'branch' ? `${this.currentRepoLink}/branches/list` : `${this.currentRepoLink}/tags/list`;
const resp = await GET(url);
const {results} = await resp.json();
for (const result of results) {
let selected = false;
if (this.mode === 'branches') {
selected = result === this.defaultSelectedRefName;
} else {
selected = result === (this.release ? this.release.tagName : this.defaultSelectedRefName);
}
this.items.push({name: result, url: pathEscapeSegments(result), branch: this.mode === 'branches', tag: this.mode === 'tags', selected});
for (const refShortName of results) {
const item: ListItem = {
refType,
refShortName,
selected: refType === this.currentRefType && refShortName === this.currentRefShortName,
rssFeedLink: `${this.currentRepoLink}/rss/${refType}/${pathEscapeSegments(refShortName)}`,
};
this.allItems.push(item);
}
this.hasListInitialized[this.mode] = true;
this.tabLoadingStates[tab] = 'done';
} catch (e) {
showErrorToast(`Network error when fetching ${this.mode}, error: ${e}`);
} finally {
this.isLoading = false;
this.tabLoadingStates[tab] = '';
showErrorToast(`Network error when fetching items for ${tab}, error: ${e}`);
console.error(e);
}
},
},
};
export function initRepoBranchTagSelector(selector) {
for (const [elIndex, elRoot] of document.querySelectorAll(selector).entries()) {
const data = {
csrfToken: window.config.csrfToken,
items: [],
searchTerm: '',
refNameText: '',
menuVisible: false,
release: null,
isViewTag: false,
isViewBranch: false,
isViewTree: false,
active: 0,
isLoading: false,
// This means whether branch list/tag list has initialized
hasListInitialized: {
'branches': false,
'tags': false,
},
...window.config.pageData.branchDropdownDataList[elIndex],
};
const comp = {...sfc, data() { return data }};
for (const elRoot of document.querySelectorAll(selector)) {
// it is very hacky, but it is the only way to pass the elRoot to the "data()" function
// it could be improved in the future to do more rewriting.
currentElRoot = elRoot;
const comp = {...sfc};
createApp(comp).mount(elRoot);
}
}
@@ -247,13 +223,13 @@ export default sfc; // activate IDE's Vue plugin
</script>
<template>
<div class="ui dropdown custom branch-selector-dropdown ellipsis-items-nowrap">
<div class="ui button branch-dropdown-button" @click="menuVisible = !menuVisible" @keyup.enter="menuVisible = !menuVisible">
<div tabindex="0" class="ui button branch-dropdown-button" @click="menuVisible = !menuVisible">
<span class="flex-text-block gt-ellipsis">
<template v-if="release">{{ textReleaseCompare }}</template>
<template v-if="dropdownFixedText">{{ dropdownFixedText }}</template>
<template v-else>
<svg-icon v-if="isViewTag" name="octicon-tag"/>
<svg-icon v-if="currentRefType === 'tag'" name="octicon-tag"/>
<svg-icon v-else name="octicon-git-branch"/>
<strong ref="dropdownRefName" class="tw-ml-2 tw-inline-block gt-ellipsis">{{ refNameText }}</strong>
<strong ref="dropdownRefName" class="tw-ml-2 tw-inline-block gt-ellipsis">{{ currentRefShortName }}</strong>
</template>
</span>
<svg-icon name="octicon-triangle-down" :size="14" class-name="dropdown icon"/>
@@ -263,55 +239,56 @@ export default sfc; // activate IDE's Vue plugin
<i class="icon"><svg-icon name="octicon-filter" :size="16"/></i>
<input name="search" ref="searchField" autocomplete="off" v-model="searchTerm" @keydown="keydown($event)" :placeholder="searchFieldPlaceholder">
</div>
<div v-if="showBranchesInDropdown" class="branch-tag-tab">
<a class="branch-tag-item muted" :class="{active: mode === 'branches'}" href="#" @click="handleTabSwitch('branches')">
<div v-if="showTabBranches" class="branch-tag-tab">
<a class="branch-tag-item muted" :class="{active: selectedTab === 'branches'}" href="#" @click="handleTabSwitch('branches')">
<svg-icon name="octicon-git-branch" :size="16" class-name="tw-mr-1"/>{{ textBranches }}
</a>
<a v-if="!noTag" class="branch-tag-item muted" :class="{active: mode === 'tags'}" href="#" @click="handleTabSwitch('tags')">
<a v-if="showTabTags" class="branch-tag-item muted" :class="{active: selectedTab === 'tags'}" href="#" @click="handleTabSwitch('tags')">
<svg-icon name="octicon-tag" :size="16" class-name="tw-mr-1"/>{{ textTags }}
</a>
</div>
<div class="branch-tag-divider"/>
<div class="scrolling menu" ref="scrollContainer">
<svg-icon name="octicon-rss" symbol-id="svg-symbol-octicon-rss"/>
<div class="loading-indicator is-loading" v-if="isLoading"/>
<div v-for="(item, index) in filteredItems" :key="item.name" class="item" :class="{selected: item.selected, active: active === index}" @click="selectItem(item)" :ref="'listItem' + index">
{{ item.name }}
<div class="ui label" v-if="item.name===repoDefaultBranch && mode === 'branches'">
<div class="loading-indicator is-loading" v-if="tabLoadingStates[selectedTab] === 'loading'"/>
<div v-for="(item, index) in filteredItems" :key="item.refShortName" class="item" :class="{selected: item.selected, active: activeItemIndex === index}" @click="selectItem(item)" :ref="'listItem' + index">
{{ item.refShortName }}
<div class="ui label" v-if="item.refType === 'branch' && item.refShortName === currentRepoDefaultBranch">
{{ textDefaultBranchLabel }}
</div>
<a v-show="enableFeed && mode === 'branches'" role="button" class="rss-icon" :href="rssURLPrefix + item.url" target="_blank" @click.stop>
<a v-if="enableFeed && selectedTab === 'branches'" role="button" class="rss-icon" target="_blank" @click.stop :href="item.rssFeedLink">
<!-- creating a lot of Vue component is pretty slow, so we use a static SVG here -->
<svg width="14" height="14" class="svg octicon-rss"><use href="#svg-symbol-octicon-rss"/></svg>
</a>
</div>
<div class="item" v-if="showCreateNewBranch" :class="{active: active === filteredItems.length}" :ref="'listItem' + filteredItems.length">
<a href="#" @click="createNewBranch()">
<div v-show="shouldCreateTag">
<i class="reference tags icon"/>
<span v-text="textCreateTag.replace('%s', searchTerm)"/>
</div>
<div v-show="!shouldCreateTag">
<svg-icon name="octicon-git-branch"/>
<span v-text="textCreateBranch.replace('%s', searchTerm)"/>
</div>
<div class="text small">
<span v-if="isViewBranch || release">{{ textCreateBranchFrom.replace('%s', branchName) }}</span>
<span v-else-if="isViewTag">{{ textCreateBranchFrom.replace('%s', tagName) }}</span>
<span v-else>{{ textCreateBranchFrom.replace('%s', commitIdShort) }}</span>
</div>
</a>
<form ref="newBranchForm" :action="formActionUrl" method="post">
<div class="item" v-if="showCreateNewRef" :class="{active: activeItemIndex === filteredItems.length}" :ref="'listItem' + filteredItems.length" @click="createNewRef()">
<div v-if="selectedTab === 'tags'">
<svg-icon name="octicon-tag" class="tw-mr-1"/>
<span v-text="textCreateTag.replace('%s', searchTerm)"/>
</div>
<div v-else>
<svg-icon name="octicon-git-branch" class="tw-mr-1"/>
<span v-text="textCreateBranch.replace('%s', searchTerm)"/>
</div>
<div class="text small">
{{ textCreateRefFrom.replace('%s', currentRefShortName) }}
</div>
<form ref="createNewRefForm" method="post" :action="createNewRefFormActionUrl">
<input type="hidden" name="_csrf" :value="csrfToken">
<input type="hidden" name="new_branch_name" v-model="searchTerm">
<input type="hidden" name="create_tag" v-model="shouldCreateTag">
<input type="hidden" name="current_path" v-model="treePath" v-if="treePath">
<input type="hidden" name="new_branch_name" :value="searchTerm">
<input type="hidden" name="create_tag" :value="String(selectedTab === 'tags')">
<input type="hidden" name="current_path" :value="currentTreePath">
</form>
</div>
</div>
<div class="message" v-if="showNoResults && !isLoading">
{{ noResults }}
<div class="message" v-if="showNoResults">
{{ textNoResults }}
</div>
<template v-if="showViewAllRefsEntry">
<div class="divider tw-m-0"/>
<a v-if="selectedTab === 'branches'" class="item" :href="currentRepoLink + '/branches'">{{ textViewAllBranches }}</a>
<a v-if="selectedTab === 'tags'" class="item" :href="currentRepoLink + '/tags'">{{ textViewAllTags }}</a>
</template>
</div>
</div>
</template>

View File

@@ -1,13 +0,0 @@
import $ from 'jquery';
export function initAdminEmails(): void {
$('.link-email-action').on('click', (e) => {
const $this = $(this);
$('#form-uid').val($this.data('uid'));
$('#form-email').val($this.data('email'));
$('#form-primary').val($this.data('primary'));
$('#form-activate').val($this.data('activate'));
$('#change-email-modal').modal('show');
e.preventDefault();
});
}

View File

@@ -134,19 +134,17 @@ function getFileBasedOptions(filename: string, lineWrapExts: string[]) {
}
function togglePreviewDisplay(previewable: boolean) {
const previewTab = document.querySelector('a[data-tab="preview"]');
const previewTab = document.querySelector<HTMLElement>('a[data-tab="preview"]');
if (!previewTab) return;
if (previewable) {
const newUrl = (previewTab.getAttribute('data-url') || '').replace(/(.*)\/.*/, `$1/markup`);
previewTab.setAttribute('data-url', newUrl);
previewTab.style.display = '';
} else {
previewTab.style.display = 'none';
// If the "preview" tab was active, user changes the filename to a non-previewable one,
// then the "preview" tab becomes inactive (hidden), so the "write" tab should become active
if (previewTab.classList.contains('active')) {
const writeTab = document.querySelector('a[data-tab="write"]');
const writeTab = document.querySelector<HTMLElement>('a[data-tab="write"]');
writeTab.click();
}
}

View File

@@ -1,13 +1,13 @@
import $ from 'jquery';
import {POST} from '../modules/fetch.ts';
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
import {showErrorToast} from '../modules/toast.ts';
import {addDelegatedEventListener, hideElem, queryElems, showElem, toggleElem} from '../utils/dom.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {camelize} from 'vue';
export function initGlobalButtonClickOnEnter(): void {
$(document).on('keypress', 'div.ui.button,span.ui.button', (e) => {
if (e.code === ' ' || e.code === 'Enter') {
$(e.target).trigger('click');
addDelegatedEventListener(document, 'keypress', 'div.ui.button, span.ui.button', (el, e: KeyboardEvent) => {
if (e.code === 'Space' || e.code === 'Enter') {
e.preventDefault();
el.click();
}
});
}
@@ -40,7 +40,7 @@ export function initGlobalDeleteButton(): void {
}
}
$(modal).modal({
fomanticQuery(modal).modal({
closable: false,
onApprove: async () => {
// if `data-type="form"` exists, then submit the form by the selector provided by `data-form="..."`
@@ -73,87 +73,93 @@ export function initGlobalDeleteButton(): void {
}
}
function onShowPanelClick(e) {
// a '.show-panel' element can show a panel, by `data-panel="selector"`
// if it has "toggle" class, it toggles the panel
const el = e.currentTarget;
e.preventDefault();
const sel = el.getAttribute('data-panel');
if (el.classList.contains('toggle')) {
toggleElem(sel);
} else {
showElem(sel);
}
}
function onHidePanelClick(e) {
// a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"`
const el = e.currentTarget;
e.preventDefault();
let sel = el.getAttribute('data-panel');
if (sel) {
hideElem(sel);
return;
}
sel = el.getAttribute('data-panel-closest');
if (sel) {
hideElem(el.parentNode.closest(sel));
return;
}
throw new Error('no panel to hide'); // should never happen, otherwise there is a bug in code
}
function onShowModalClick(e) {
// A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute.
// Each "data-modal-{target}" attribute will be filled to target element's value or text-content.
// * First, try to query '#target'
// * Then, try to query '[name=target]'
// * Then, try to query '.target'
// * Then, try to query 'target' as HTML tag
// If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set.
const el = e.currentTarget;
e.preventDefault();
const modalSelector = el.getAttribute('data-modal');
const elModal = document.querySelector(modalSelector);
if (!elModal) throw new Error('no modal for this action');
const modalAttrPrefix = 'data-modal-';
for (const attrib of el.attributes) {
if (!attrib.name.startsWith(modalAttrPrefix)) {
continue;
}
const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.');
// try to find target by: "#target" -> "[name=target]" -> ".target" -> "<target> tag"
const attrTarget = elModal.querySelector(`#${attrTargetName}`) ||
elModal.querySelector(`[name=${attrTargetName}]`) ||
elModal.querySelector(`.${attrTargetName}`) ||
elModal.querySelector(`${attrTargetName}`);
if (!attrTarget) {
if (!window.config.runModeIsProd) throw new Error(`attr target "${attrTargetCombo}" not found for modal`);
continue;
}
if (attrTargetAttr) {
attrTarget[camelize(attrTargetAttr)] = attrib.value;
} else if (attrTarget.matches('input, textarea')) {
attrTarget.value = attrib.value; // FIXME: add more supports like checkbox
} else {
attrTarget.textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p
}
}
fomanticQuery(elModal).modal('setting', {
onApprove: () => {
// "form-fetch-action" can handle network errors gracefully,
// so keep the modal dialog to make users can re-submit the form if anything wrong happens.
if (elModal.querySelector('.form-fetch-action')) return false;
},
}).modal('show');
}
export function initGlobalButtons(): void {
// There are many "cancel button" elements in modal dialogs, Fomantic UI expects they are button-like elements but never submit a form.
// However, Gitea misuses the modal dialog and put the cancel buttons inside forms, so we must prevent the form submission.
// There are a few cancel buttons in non-modal forms, and there are some dynamically created forms (eg: the "Edit Issue Content")
$(document).on('click', 'form button.ui.cancel.button', (e) => {
e.preventDefault();
});
addDelegatedEventListener(document, 'click', 'form button.ui.cancel.button', (_ /* el */, e) => e.preventDefault());
$('.show-panel').on('click', function (e) {
// a '.show-panel' element can show a panel, by `data-panel="selector"`
// if it has "toggle" class, it toggles the panel
e.preventDefault();
const sel = this.getAttribute('data-panel');
if (this.classList.contains('toggle')) {
toggleElem(sel);
} else {
showElem(sel);
}
});
$('.hide-panel').on('click', function (e) {
// a `.hide-panel` element can hide a panel, by `data-panel="selector"` or `data-panel-closest="selector"`
e.preventDefault();
let sel = this.getAttribute('data-panel');
if (sel) {
hideElem($(sel));
return;
}
sel = this.getAttribute('data-panel-closest');
if (sel) {
hideElem($(this).closest(sel));
return;
}
// should never happen, otherwise there is a bug in code
showErrorToast('Nothing to hide');
});
}
export function initGlobalShowModal() {
// A ".show-modal" button will show a modal dialog defined by its "data-modal" attribute.
// Each "data-modal-{target}" attribute will be filled to target element's value or text-content.
// * First, try to query '#target'
// * Then, try to query '.target'
// * Then, try to query 'target' as HTML tag
// If there is a ".{attr}" part like "data-modal-form.action", then the form's "action" attribute will be set.
$('.show-modal').on('click', function (e) {
e.preventDefault();
const modalSelector = this.getAttribute('data-modal');
const $modal = $(modalSelector);
if (!$modal.length) {
throw new Error('no modal for this action');
}
const modalAttrPrefix = 'data-modal-';
for (const attrib of this.attributes) {
if (!attrib.name.startsWith(modalAttrPrefix)) {
continue;
}
const attrTargetCombo = attrib.name.substring(modalAttrPrefix.length);
const [attrTargetName, attrTargetAttr] = attrTargetCombo.split('.');
// try to find target by: "#target" -> ".target" -> "target tag"
let $attrTarget = $modal.find(`#${attrTargetName}`);
if (!$attrTarget.length) $attrTarget = $modal.find(`.${attrTargetName}`);
if (!$attrTarget.length) $attrTarget = $modal.find(`${attrTargetName}`);
if (!$attrTarget.length) continue; // TODO: show errors in dev mode to remind developers that there is a bug
if (attrTargetAttr) {
$attrTarget[0][attrTargetAttr] = attrib.value;
} else if ($attrTarget[0].matches('input, textarea')) {
$attrTarget.val(attrib.value); // FIXME: add more supports like checkbox
} else {
$attrTarget[0].textContent = attrib.value; // FIXME: it should be more strict here, only handle div/span/p
}
}
$modal.modal('setting', {
onApprove: () => {
// "form-fetch-action" can handle network errors gracefully,
// so keep the modal dialog to make users can re-submit the form if anything wrong happens.
if ($modal.find('.form-fetch-action').length) return false;
},
}).modal('show');
});
queryElems(document, '.show-panel', (el) => el.addEventListener('click', onShowPanelClick));
queryElems(document, '.hide-panel', (el) => el.addEventListener('click', onHidePanelClick));
queryElems(document, '.show-modal', (el) => el.addEventListener('click', onShowModalClick));
}

View File

@@ -1,14 +1,14 @@
import {request} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {submitEventSubmitter} from '../utils/dom.ts';
import {htmlEscape} from 'escape-goat';
import {addDelegatedEventListener, submitEventSubmitter} from '../utils/dom.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import type {RequestOpts} from '../types.ts';
const {appSubUrl, i18n} = window.config;
// 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) {
function fetchActionDoRedirect(redirect: string) {
const form = document.createElement('form');
const input = document.createElement('input');
form.method = 'post';
@@ -21,7 +21,7 @@ function fetchActionDoRedirect(redirect) {
form.submit();
}
async function fetchActionDoRequest(actionElem, url, opt) {
async function fetchActionDoRequest(actionElem: HTMLElement, url: string, opt: RequestOpts) {
try {
const resp = await request(url, opt);
if (resp.status === 200) {
@@ -55,11 +55,8 @@ async function fetchActionDoRequest(actionElem, url, opt) {
actionElem.classList.remove('is-loading', 'loading-icon-2px');
}
async function formFetchAction(e) {
if (!e.target.classList.contains('form-fetch-action')) return;
async function formFetchAction(formEl: HTMLFormElement, e: SubmitEvent) {
e.preventDefault();
const formEl = e.target;
if (formEl.classList.contains('is-loading')) return;
formEl.classList.add('is-loading');
@@ -77,7 +74,7 @@ async function formFetchAction(e) {
}
let reqUrl = formActionUrl;
const reqOpt = {method: formMethod.toUpperCase()};
const reqOpt = {method: formMethod.toUpperCase(), body: null};
if (formMethod.toLowerCase() === 'get') {
const params = new URLSearchParams();
for (const [key, value] of formData) {
@@ -95,34 +92,36 @@ async function formFetchAction(e) {
await fetchActionDoRequest(formEl, reqUrl, reqOpt);
}
async function linkAction(e) {
async function linkAction(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.
const el = e.target.closest('.link-action');
if (!el) return;
e.preventDefault();
const url = el.getAttribute('data-url');
const doRequest = async () => {
el.disabled = true;
if ('disabled' in el) el.disabled = true; // el could be A or BUTTON, but A doesn't have disabled attribute
await fetchActionDoRequest(el, url, {method: 'POST'});
el.disabled = false;
if ('disabled' in el) el.disabled = false;
};
const modalConfirmContent = htmlEscape(el.getAttribute('data-modal-confirm') || '');
const modalConfirmContent = el.getAttribute('data-modal-confirm') ||
el.getAttribute('data-modal-confirm-content') || '';
if (!modalConfirmContent) {
await doRequest();
return;
}
const isRisky = el.classList.contains('red') || el.classList.contains('negative');
if (await confirmModal(modalConfirmContent, {confirmButtonColor: isRisky ? 'red' : 'primary'})) {
if (await confirmModal({
header: el.getAttribute('data-modal-confirm-header') || '',
content: modalConfirmContent,
confirmButtonColor: isRisky ? 'red' : 'primary',
})) {
await doRequest();
}
}
export function initGlobalFetchAction() {
document.addEventListener('submit', formFetchAction);
document.addEventListener('click', linkAction);
addDelegatedEventListener(document, 'submit', '.form-fetch-action', formFetchAction);
addDelegatedEventListener(document, 'click', '.link-action', linkAction);
}

View File

@@ -1,5 +1,7 @@
import {applyAreYouSure, initAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {handleGlobalEnterQuickSubmit} from './comp/QuickSubmit.ts';
import {queryElems} from '../utils/dom.ts';
import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
export function initGlobalFormDirtyLeaveConfirm() {
initAreYouSure(window.jQuery);
@@ -11,7 +13,7 @@ export function initGlobalFormDirtyLeaveConfirm() {
}
export function initGlobalEnterQuickSubmit() {
document.addEventListener('keydown', (e) => {
document.addEventListener('keydown', (e: KeyboardEvent & {target: HTMLElement}) => {
if (e.key !== 'Enter') return;
const hasCtrlOrMeta = ((e.ctrlKey || e.metaKey) && !e.altKey);
if (hasCtrlOrMeta && e.target.matches('textarea')) {
@@ -27,3 +29,7 @@ export function initGlobalEnterQuickSubmit() {
}
});
}
export function initGlobalComboMarkdownEditor() {
queryElems<HTMLElement>(document, '.combo-markdown-editor:not(.custom-init)', (el) => initComboMarkdownEditor(el));
}

View File

@@ -1,6 +1,5 @@
import '@github/markdown-toolbar-element';
import '@github/text-expander-element';
import $ from 'jquery';
import {attachTribute} from '../tribute.ts';
import {hideElem, showElem, autosize, isElemVisible} from '../../utils/dom.ts';
import {
@@ -23,6 +22,8 @@ import {
} from './EditorMarkdown.ts';
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
import {createTippy} from '../../modules/tippy.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
import type EasyMDE from 'easymde';
let elementIdCounter = 0;
@@ -48,18 +49,23 @@ export function validateTextareaNonEmpty(textarea) {
return true;
}
type ComboMarkdownEditorOptions = {
editorHeights?: {minHeight?: string, height?: string, maxHeight?: string},
easyMDEOptions?: EasyMDE.Options,
};
export class ComboMarkdownEditor {
static EventEditorContentChanged = EventEditorContentChanged;
static EventUploadStateChanged = EventUploadStateChanged;
public container : HTMLElement;
// TODO: use correct types to replace these "any" types
options: any;
options: ComboMarkdownEditorOptions;
tabEditor: HTMLElement;
tabPreviewer: HTMLElement;
supportEasyMDE: boolean;
easyMDE: any;
easyMDEToolbarActions: any;
easyMDEToolbarDefault: any;
@@ -71,11 +77,12 @@ export class ComboMarkdownEditor {
dropzone: HTMLElement;
attachedDropzoneInst: any;
previewMode: string;
previewUrl: string;
previewContext: string;
previewMode: string;
constructor(container, options = {}) {
constructor(container, options:ComboMarkdownEditorOptions = {}) {
if (container._giteaComboMarkdownEditor) throw new Error('ComboMarkdownEditor already initialized');
container._giteaComboMarkdownEditor = this;
this.options = options;
this.container = container;
@@ -99,6 +106,10 @@ export class ComboMarkdownEditor {
}
setupContainer() {
this.supportEasyMDE = this.container.getAttribute('data-support-easy-mde') === 'true';
this.previewMode = this.container.getAttribute('data-content-mode');
this.previewUrl = this.container.getAttribute('data-preview-url');
this.previewContext = this.container.getAttribute('data-preview-context');
initTextExpander(this.container.querySelector('text-expander'));
}
@@ -137,12 +148,14 @@ export class ComboMarkdownEditor {
monospaceButton.setAttribute('aria-checked', String(enabled));
});
const easymdeButton = this.container.querySelector('.markdown-switch-easymde');
easymdeButton.addEventListener('click', async (e) => {
e.preventDefault();
this.userPreferredEditor = 'easymde';
await this.switchToEasyMDE();
});
if (this.supportEasyMDE) {
const easymdeButton = this.container.querySelector('.markdown-switch-easymde');
easymdeButton.addEventListener('click', async (e) => {
e.preventDefault();
this.userPreferredEditor = 'easymde';
await this.switchToEasyMDE();
});
}
this.initMarkdownButtonTableAdd();
@@ -187,6 +200,7 @@ export class ComboMarkdownEditor {
setupTab() {
const tabs = this.container.querySelectorAll<HTMLElement>('.tabular.menu > .item');
if (!tabs.length) return;
// 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.
@@ -207,11 +221,8 @@ export class ComboMarkdownEditor {
});
});
$(tabs).tab();
fomanticQuery(tabs).tab();
this.previewUrl = this.tabPreviewer.getAttribute('data-preview-url');
this.previewContext = this.tabPreviewer.getAttribute('data-preview-context');
this.previewMode = this.options.previewMode ?? 'comment';
this.tabPreviewer.addEventListener('click', async () => {
const formData = new FormData();
formData.append('mode', this.previewMode);
@@ -219,7 +230,7 @@ export class ComboMarkdownEditor {
formData.append('text', this.value());
const response = await POST(this.previewUrl, {data: formData});
const data = await response.text();
renderPreviewPanelContent($(panelPreviewer), data);
renderPreviewPanelContent(panelPreviewer, data);
});
}
@@ -284,7 +295,7 @@ export class ComboMarkdownEditor {
}
async switchToUserPreference() {
if (this.userPreferredEditor === 'easymde') {
if (this.userPreferredEditor === 'easymde' && this.supportEasyMDE) {
await this.switchToEasyMDE();
} else {
this.switchToTextarea();
@@ -304,7 +315,7 @@ export class ComboMarkdownEditor {
if (this.easyMDE) return;
// EasyMDE's CSS should be loaded via webpack config, otherwise our own styles can not overwrite the default styles.
const {default: EasyMDE} = await import(/* webpackChunkName: "easymde" */'easymde');
const easyMDEOpt = {
const easyMDEOpt: EasyMDE.Options = {
autoDownloadFontAwesome: false,
element: this.textarea,
forceSync: true,
@@ -384,19 +395,20 @@ export class ComboMarkdownEditor {
}
get userPreferredEditor() {
return window.localStorage.getItem(`markdown-editor-${this.options.useScene ?? 'default'}`);
return window.localStorage.getItem(`markdown-editor-${this.previewMode ?? 'default'}`);
}
set userPreferredEditor(s) {
window.localStorage.setItem(`markdown-editor-${this.options.useScene ?? 'default'}`, s);
window.localStorage.setItem(`markdown-editor-${this.previewMode ?? 'default'}`, s);
}
}
export function getComboMarkdownEditor(el) {
if (el instanceof $) el = el[0];
return el?._giteaComboMarkdownEditor;
if (!el) return null;
if (el.length) el = el[0];
return el._giteaComboMarkdownEditor;
}
export async function initComboMarkdownEditor(container: HTMLElement, options = {}) {
export async function initComboMarkdownEditor(container: HTMLElement, options:ComboMarkdownEditorOptions = {}) {
if (!container) {
throw new Error('initComboMarkdownEditor: container is null');
}

View File

@@ -5,10 +5,12 @@ import {fomanticQuery} from '../../modules/fomantic/base.ts';
const {i18n} = window.config;
export function confirmModal(content, {confirmButtonColor = 'primary'} = {}) {
export function confirmModal({header = '', content = '', confirmButtonColor = 'primary'} = {}): Promise<boolean> {
return new Promise((resolve) => {
const headerHtml = header ? `<div class="header">${htmlEscape(header)}</div>` : '';
const modal = createElementFromHTML(`
<div class="ui g-modal-confirm modal">
${headerHtml}
<div class="content">${htmlEscape(content)}</div>
<div class="actions">
<button class="ui cancel button">${svg('octicon-x')} ${htmlEscape(i18n.modal_cancel)}</button>

View File

@@ -0,0 +1,40 @@
import {showElem} from '../../utils/dom.ts';
type CropperOpts = {
container: HTMLElement,
imageSource: HTMLImageElement,
fileInput: HTMLInputElement,
}
export async function initCompCropper({container, fileInput, imageSource}: CropperOpts) {
const {default: Cropper} = await import(/* webpackChunkName: "cropperjs" */'cropperjs');
let currentFileName = '';
let currentFileLastModified = 0;
const cropper = new Cropper(imageSource, {
aspectRatio: 1,
viewMode: 2,
autoCrop: false,
crop() {
const canvas = cropper.getCroppedCanvas();
canvas.toBlob((blob) => {
const croppedFileName = currentFileName.replace(/\.[^.]{3,4}$/, '.png');
const croppedFile = new File([blob], croppedFileName, {type: 'image/png', lastModified: currentFileLastModified});
const dataTransfer = new DataTransfer();
dataTransfer.items.add(croppedFile);
fileInput.files = dataTransfer.files;
});
},
});
fileInput.addEventListener('input', (e: Event & {target: HTMLInputElement}) => {
const files = e.target.files;
if (files?.length > 0) {
currentFileName = files[0].name;
currentFileLastModified = files[0].lastModified;
const fileURL = URL.createObjectURL(files[0]);
imageSource.src = fileURL;
cropper.replace(fileURL);
showElem(container);
}
});
}

View File

@@ -1,8 +1,8 @@
import {POST} from '../../modules/fetch.ts';
import {fomanticQuery} from '../../modules/fomantic/base.ts';
export function initCompReactionSelector() {
for (const container of document.querySelectorAll('.issue-content, .diff-file-body')) {
export function initCompReactionSelector(parent: ParentNode = document) {
for (const container of parent.querySelectorAll('.issue-content, .diff-file-body')) {
container.addEventListener('click', async (e) => {
// there are 2 places for the "reaction" buttons, one is the top-right reaction menu, one is the bottom of the comment
const target = e.target.closest('.comment-reaction-button');

View File

@@ -18,6 +18,7 @@ import {
} from '../utils/dom.ts';
import {POST, GET} from '../modules/fetch.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
import {createTippy} from '../modules/tippy.ts';
const {pageData, i18n} = window.config;
@@ -37,7 +38,7 @@ function initRepoDiffFileViewToggle() {
}
function initRepoDiffConversationForm() {
addDelegatedEventListener<HTMLFormElement>(document, 'submit', '.conversation-holder form', async (form, e) => {
addDelegatedEventListener<HTMLFormElement, SubmitEvent>(document, 'submit', '.conversation-holder form', async (form, e) => {
e.preventDefault();
const textArea = form.querySelector<HTMLTextAreaElement>('textarea');
if (!validateTextareaNonEmpty(textArea)) return;
@@ -54,7 +55,9 @@ function initRepoDiffConversationForm() {
formData.append(submitter.name, submitter.value);
}
const trLineType = form.closest('tr').getAttribute('data-line-type');
// on the diff page, the form is inside a "tr" and need to get the line-type ahead
// but on the conversation page, there is no parent "tr"
const trLineType = form.closest('tr')?.getAttribute('data-line-type');
const response = await POST(form.getAttribute('action'), {data: formData});
const newConversationHolder = createElementFromHTML(await response.text());
const path = newConversationHolder.getAttribute('data-path');
@@ -64,14 +67,18 @@ function initRepoDiffConversationForm() {
form.closest('.conversation-holder').replaceWith(newConversationHolder);
form = null; // prevent further usage of the form because it should have been replaced
let selector;
if (trLineType === 'same') {
selector = `[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`;
} else {
selector = `[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`;
}
for (const el of document.querySelectorAll(selector)) {
el.classList.add('tw-invisible'); // TODO need to figure out why
if (trLineType) {
// if there is a line-type for the "tr", it means the form is on the diff page
// then hide the "add-code-comment" [+] button for current code line by adding "tw-invisible" because the conversation has been added
let selector;
if (trLineType === 'same') {
selector = `[data-path="${path}"] .add-code-comment[data-idx="${idx}"]`;
} else {
selector = `[data-path="${path}"] .add-code-comment[data-side="${side}"][data-idx="${idx}"]`;
}
for (const el of document.querySelectorAll(selector)) {
el.classList.add('tw-invisible');
}
}
fomanticQuery(newConversationHolder.querySelectorAll('.ui.dropdown')).dropdown();
@@ -108,7 +115,7 @@ function initRepoDiffConversationForm() {
const $conversation = $(data);
$(this).closest('.conversation-holder').replaceWith($conversation);
$conversation.find('.dropdown').dropdown();
initCompReactionSelector($conversation);
initCompReactionSelector($conversation[0]);
} else {
window.location.reload();
}
@@ -140,12 +147,22 @@ export function initRepoDiffConversationNav() {
});
}
function initDiffHeaderPopup() {
for (const btn of document.querySelectorAll('.diff-header-popup-btn:not([data-header-popup-initialized])')) {
btn.setAttribute('data-header-popup-initialized', '');
const popup = btn.nextElementSibling;
if (!popup?.matches('.tippy-target')) throw new Error('Popup element not found');
createTippy(btn, {content: popup, theme: 'menu', placement: 'bottom', trigger: 'click', interactive: true, hideOnClick: true});
}
}
// Will be called when the show more (files) button has been pressed
function onShowMoreFiles() {
initRepoIssueContentHistory();
initViewedCheckboxListenerFor();
countAndUpdateViewedFiles();
initImageDiff();
initDiffHeaderPopup();
}
export async function loadMoreFiles(url) {
@@ -221,6 +238,7 @@ export function initRepoDiffView() {
initDiffFileList();
initDiffCommitSelect();
initRepoDiffShowMore();
initDiffHeaderPopup();
initRepoDiffFileViewToggle();
initViewedCheckboxListenerFor();
initExpandAndCollapseFilesButton();

View File

@@ -1,4 +1,3 @@
import $ from 'jquery';
import {htmlEscape} from 'escape-goat';
import {createCodeEditor} from './codeeditor.ts';
import {hideElem, queryElems, showElem, createElementFromHTML} from '../utils/dom.ts';
@@ -6,39 +5,33 @@ import {initMarkupContent} from '../markup/content.ts';
import {attachRefIssueContextPopup} from './contextpopup.ts';
import {POST} from '../modules/fetch.ts';
import {initDropzone} from './dropzone.ts';
import {confirmModal} from './comp/ConfirmModal.ts';
import {applyAreYouSure} from '../vendor/jquery.are-you-sure.ts';
import {fomanticQuery} from '../modules/fomantic/base.ts';
function initEditPreviewTab($form) {
const $tabMenu = $form.find('.repo-editor-menu');
$tabMenu.find('.item').tab();
const $previewTab = $tabMenu.find('a[data-tab="preview"]');
if ($previewTab.length) {
$previewTab.on('click', async function () {
const $this = $(this);
let context = `${$this.data('context')}/`;
const mode = $this.data('markup-mode') || 'comment';
const $treePathEl = $form.find('input#tree_path');
if ($treePathEl.length > 0) {
context += $treePathEl.val();
}
context = context.substring(0, context.lastIndexOf('/'));
function initEditPreviewTab(elForm: HTMLFormElement) {
const elTabMenu = elForm.querySelector('.repo-editor-menu');
fomanticQuery(elTabMenu.querySelectorAll('.item')).tab();
const formData = new FormData();
formData.append('mode', mode);
formData.append('context', context);
formData.append('text', $form.find('.tab[data-tab="write"] textarea').val());
formData.append('file_path', $treePathEl.val());
try {
const response = await POST($this.data('url'), {data: formData});
const data = await response.text();
const $previewPanel = $form.find('.tab[data-tab="preview"]');
if ($previewPanel.length) {
renderPreviewPanelContent($previewPanel, data);
}
} catch (error) {
console.error('Error:', error);
}
});
}
const elPreviewTab = elTabMenu.querySelector('a[data-tab="preview"]');
const elPreviewPanel = elForm.querySelector('.tab[data-tab="preview"]');
if (!elPreviewTab || !elPreviewPanel) return;
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('/'));
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('file_path', elTreePath.value);
const response = await POST(previewUrl, {data: formData});
const data = await response.text();
renderPreviewPanelContent(elPreviewPanel, data);
});
}
export function initRepoEditor() {
@@ -151,8 +144,8 @@ export function initRepoEditor() {
}
});
const $form = $('.repository.editor .edit.form');
initEditPreviewTab($form);
const elForm = document.querySelector<HTMLFormElement>('.repository.editor .edit.form');
initEditPreviewTab(elForm);
(async () => {
const editor = await createCodeEditor(editArea, filenameInput);
@@ -160,16 +153,16 @@ export function initRepoEditor() {
// Using events from https://github.com/codedance/jquery.AreYouSure#advanced-usage
// to enable or disable the commit button
const commitButton = document.querySelector<HTMLButtonElement>('#commit-button');
const $editForm = $('.ui.edit.form');
const dirtyFileClass = 'dirty-file';
// Disabling the button at the start
if ($('input[name="page_has_posted"]').val() !== 'true') {
if (document.querySelector<HTMLInputElement>('input[name="page_has_posted"]').value !== 'true') {
commitButton.disabled = true;
}
// Registering a custom listener for the file path and the file content
$editForm.areYouSure({
// FIXME: it is not quite right here (old bug), it causes double-init, the global areYouSure "dirty" class will also be added
applyAreYouSure(elForm, {
silent: true,
dirtyClass: dirtyFileClass,
fieldSelector: ':input:not(.commit-form-wrapper :input)',
@@ -187,24 +180,24 @@ export function initRepoEditor() {
editor.setValue(value);
}
commitButton?.addEventListener('click', (e) => {
commitButton?.addEventListener('click', async (e) => {
// A modal which asks if an empty file should be committed
if (!editArea.value) {
$('#edit-empty-content-modal').modal({
onApprove() {
$('.edit.form').trigger('submit');
},
}).modal('show');
e.preventDefault();
if (await confirmModal({
header: elForm.getAttribute('data-text-empty-confirm-header'),
content: elForm.getAttribute('data-text-empty-confirm-content'),
})) {
elForm.classList.remove('dirty');
elForm.submit();
}
}
});
})();
}
export function renderPreviewPanelContent($previewPanel, data) {
$previewPanel.html(data);
export function renderPreviewPanelContent(previewPanel: Element, content: string) {
previewPanel.innerHTML = content;
initMarkupContent();
const $refIssues = $previewPanel.find('p .ref-issue');
attachRefIssueContextPopup($refIssues);
attachRefIssueContextPopup(previewPanel.querySelectorAll('p .ref-issue'));
}

View File

@@ -90,6 +90,7 @@ function filterRepoFiles(filter) {
const span = document.createElement('span');
// safely escape by using textContent
span.textContent = part;
span.title = span.textContent;
// if the target file path is "abc/xyz", to search "bx", then the matchResult is ['a', 'b', 'c/', 'x', 'yz']
// the matchResult[odd] is matched and highlighted to red.
if (index % 2 === 1) span.classList.add('ui', 'text', 'red');

View File

@@ -76,7 +76,7 @@ function initRepoIssueListCheckboxes() {
// for delete
if (action === 'delete') {
const confirmText = e.target.getAttribute('data-action-delete-confirm');
if (!await confirmModal(confirmText, {confirmButtonColor: 'red'})) {
if (!await confirmModal({content: confirmText, confirmButtonColor: 'red'})) {
return;
}
}
@@ -196,7 +196,11 @@ async function initIssuePinSort() {
createSortable(pinDiv, {
group: 'shared',
onEnd: pinMoveEnd, // eslint-disable-line @typescript-eslint/no-misused-promises
onEnd: (e) => {
(async () => {
await pinMoveEnd(e);
})();
},
});
}

View File

@@ -11,37 +11,6 @@ import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
const {appSubUrl} = window.config;
export function initRepoIssueTimeTracking() {
$(document).on('click', '.issue-add-time', () => {
$('.issue-start-time-modal').modal({
duration: 200,
onApprove() {
$('#add_time_manual_form').trigger('submit');
},
}).modal('show');
$('.issue-start-time-modal input').on('keydown', (e) => {
if (e.key === 'Enter') {
$('#add_time_manual_form').trigger('submit');
}
});
});
$(document).on('click', '.issue-start-time, .issue-stop-time', () => {
$('#toggle_stopwatch_form').trigger('submit');
});
$(document).on('click', '.issue-cancel-time', () => {
$('#cancel_stopwatch_form').trigger('submit');
});
$(document).on('click', 'button.issue-delete-time', function () {
const sel = `.issue-delete-time-modal[data-id="${$(this).data('id')}"]`;
$(sel).modal({
duration: 200,
onApprove() {
$(`${sel} form`).trigger('submit');
},
}).modal('show');
});
}
/**
* @param {HTMLElement} item
*/
@@ -414,11 +383,6 @@ export function initRepoPullRequestReview() {
await handleReply(this);
});
const elReviewBox = document.querySelector('.review-box-panel');
if (elReviewBox) {
initComboMarkdownEditor(elReviewBox.querySelector('.combo-markdown-editor'));
}
// The following part is only for diff views
if (!$('.repository.pull.diff').length) return;

View File

@@ -1,5 +1,4 @@
import {hideElem, showElem} from '../utils/dom.ts';
import {initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
export function initRepoRelease() {
document.addEventListener('click', (e) => {
@@ -16,7 +15,6 @@ export function initRepoReleaseNew() {
if (!document.querySelector('.repository.new.release')) return;
initTagNameEditor();
initRepoReleaseEditor();
}
function initTagNameEditor() {
@@ -48,11 +46,3 @@ function initTagNameEditor() {
hideTargetInput(e.target);
});
}
function initRepoReleaseEditor() {
const editor = document.querySelector<HTMLElement>('.repository.new.release .combo-markdown-editor');
if (!editor) {
return;
}
initComboMarkdownEditor(editor);
}

View File

@@ -0,0 +1,71 @@
import {beforeEach, describe, expect, test, vi} from 'vitest';
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
import {POST} from '../modules/fetch.ts';
import {createSortable} from '../modules/sortable.ts';
vi.mock('../modules/fetch.ts', () => ({
POST: vi.fn(),
}));
vi.mock('../modules/sortable.ts', () => ({
createSortable: vi.fn(),
}));
describe('Repository Branch Settings', () => {
beforeEach(() => {
document.body.innerHTML = `
<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="drag-handle"></div>
</div>
<div class="flex-item tw-items-center item" data-id="2" >
<div class="drag-handle"></div>
</div>
<div class="flex-item tw-items-center item" data-id="3" >
<div class="drag-handle"></div>
</div>
</div>
`;
vi.clearAllMocks();
});
test('should initialize sortable for protected branches list', () => {
initRepoSettingsBranchesDrag();
expect(createSortable).toHaveBeenCalledWith(
document.querySelector('#protected-branches-list'),
expect.objectContaining({
handle: '.drag-handle',
animation: 150,
}),
);
});
test('should not initialize if protected branches list is not present', () => {
document.body.innerHTML = '';
initRepoSettingsBranchesDrag();
expect(createSortable).not.toHaveBeenCalled();
});
test('should post new order after sorting', async () => {
vi.mocked(POST).mockResolvedValue({ok: true} as Response);
// Mock createSortable to capture and execute the onEnd callback
vi.mocked(createSortable).mockImplementation((_el, options) => {
options.onEnd();
return {destroy: vi.fn()};
});
initRepoSettingsBranchesDrag();
expect(POST).toHaveBeenCalledWith(
'some/repo/branches/priority',
expect.objectContaining({
data: {ids: [1, 2, 3]},
}),
);
});
});

View File

@@ -0,0 +1,32 @@
import {createSortable} from '../modules/sortable.ts';
import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts';
import {queryElemChildren} from '../utils/dom.ts';
export function initRepoSettingsBranchesDrag() {
const protectedBranchesList = document.querySelector('#protected-branches-list');
if (!protectedBranchesList) return;
createSortable(protectedBranchesList, {
handle: '.drag-handle',
animation: 150,
onEnd: () => {
(async () => {
const itemElems = queryElemChildren(protectedBranchesList, '.item[data-id]');
const itemIds = Array.from(itemElems, (el) => parseInt(el.getAttribute('data-id')));
try {
await POST(protectedBranchesList.getAttribute('data-update-priority-url'), {
data: {
ids: itemIds,
},
});
} catch (err) {
const errorMessage = String(err);
showErrorToast(`Failed to update branch protection rule priority:, error: ${errorMessage}`);
}
})();
},
});
}

View File

@@ -3,6 +3,7 @@ import {minimatch} from 'minimatch';
import {createMonaco} from './codeeditor.ts';
import {onInputDebounce, queryElems, toggleElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts';
import {initRepoSettingsBranchesDrag} from './repo-settings-branches.ts';
const {appSubUrl, csrfToken} = window.config;
@@ -154,4 +155,5 @@ export function initRepoSettings() {
initRepoSettingsCollaboration();
initRepoSettingsSearchTeamBox();
initRepoSettingsGitHook();
initRepoSettingsBranchesDrag();
}

View File

@@ -1,13 +1,13 @@
import {hideElem, queryElemSiblings, showElem, toggleElem} from '../utils/dom.ts';
import {addDelegatedEventListener, hideElem, queryElemSiblings, showElem, toggleElem} from '../utils/dom.ts';
export function initUnicodeEscapeButton() {
document.addEventListener('click', (e) => {
const btn = e.target.closest('.escape-button, .unescape-button, .toggle-escape-button');
if (!btn) return;
addDelegatedEventListener(document, 'click', '.escape-button, .unescape-button, .toggle-escape-button', (btn, e) => {
e.preventDefault();
const fileContent = btn.closest('.file-content, .non-diff-file-content');
const fileContentElemId = btn.getAttribute('data-file-content-elem-id');
const fileContent = fileContentElemId ?
document.querySelector(`#${fileContentElemId}`) :
btn.closest('.file-content, .non-diff-file-content');
const fileView = fileContent?.querySelectorAll('.file-code, .file-view');
if (btn.matches('.escape-button')) {
for (const el of fileView) el.classList.add('unicode-escaped');

View File

@@ -2,6 +2,7 @@ import {initMarkupContent} from '../markup/content.ts';
import {validateTextareaNonEmpty, initComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
import {fomanticMobileScreen} from '../modules/fomantic.ts';
import {POST} from '../modules/fetch.ts';
import type {ComboMarkdownEditor} from './comp/ComboMarkdownEditor.ts';
async function initRepoWikiFormEditor() {
const editArea = document.querySelector<HTMLTextAreaElement>('.repository.wiki .combo-markdown-editor textarea');
@@ -9,7 +10,7 @@ async function initRepoWikiFormEditor() {
const form = document.querySelector('.repository.wiki.new .ui.form');
const editorContainer = form.querySelector<HTMLElement>('.combo-markdown-editor');
let editor;
let editor: ComboMarkdownEditor;
let renderRequesting = false;
let lastContent;
@@ -45,12 +46,10 @@ async function initRepoWikiFormEditor() {
renderEasyMDEPreview();
editor = await initComboMarkdownEditor(editorContainer, {
useScene: 'wiki',
// EasyMDE has some problems of height definition, it has inline style height 300px by default, so we also use inline styles to override it.
// And another benefit is that we only need to write the style once for both editors.
// TODO: Move height style to CSS after EasyMDE removal.
editorHeights: {minHeight: '300px', height: 'calc(100vh - 600px)'},
previewMode: 'wiki',
easyMDEOptions: {
previewRender: (_content, previewTarget) => previewTarget.innerHTML, // disable builtin preview render
toolbar: ['bold', 'italic', 'strikethrough', '|',
@@ -59,7 +58,7 @@ async function initRepoWikiFormEditor() {
'unordered-list', 'ordered-list', '|',
'link', 'image', 'table', 'horizontal-rule', '|',
'preview', 'fullscreen', 'side-by-side', '|', 'gitea-switch-to-textarea',
],
] as any, // to use custom toolbar buttons
},
});

View File

@@ -40,14 +40,15 @@ async function loginPasskey() {
try {
const credential = await navigator.credentials.get({
publicKey: options.publicKey,
});
}) as PublicKeyCredential;
const credResp = credential.response as AuthenticatorAssertionResponse;
// Move data into Arrays in case it is super long
const authData = new Uint8Array(credential.response.authenticatorData);
const clientDataJSON = new Uint8Array(credential.response.clientDataJSON);
const authData = new Uint8Array(credResp.authenticatorData);
const clientDataJSON = new Uint8Array(credResp.clientDataJSON);
const rawId = new Uint8Array(credential.rawId);
const sig = new Uint8Array(credential.response.signature);
const userHandle = new Uint8Array(credential.response.userHandle);
const sig = new Uint8Array(credResp.signature);
const userHandle = new Uint8Array(credResp.userHandle);
const res = await POST(`${appSubUrl}/user/webauthn/passkey/login`, {
data: {
@@ -175,7 +176,7 @@ async function webauthnRegistered(newCredential) {
window.location.reload();
}
function webAuthnError(errorType, message) {
function webAuthnError(errorType: string, message:string = '') {
const elErrorMsg = document.querySelector(`#webauthn-error-msg`);
if (errorType === 'general') {
@@ -207,10 +208,9 @@ function detectWebAuthnSupport() {
}
export function initUserAuthWebAuthnRegister() {
const elRegister = document.querySelector('#register-webauthn');
if (!elRegister) {
return;
}
const elRegister = document.querySelector<HTMLInputElement>('#register-webauthn');
if (!elRegister) return;
if (!detectWebAuthnSupport()) {
elRegister.disabled = true;
return;
@@ -222,7 +222,7 @@ export function initUserAuthWebAuthnRegister() {
}
async function webAuthnRegisterRequest() {
const elNickname = document.querySelector('#nickname');
const elNickname = document.querySelector<HTMLInputElement>('#nickname');
const formData = new FormData();
formData.append('name', elNickname.value);

View File

@@ -1,7 +1,17 @@
import {hideElem, showElem} from '../utils/dom.ts';
import {initCompCropper} from './comp/Cropper.ts';
function initUserSettingsAvatarCropper() {
const fileInput = document.querySelector<HTMLInputElement>('#new-avatar');
const container = document.querySelector<HTMLElement>('.user.settings.profile .cropper-panel');
const imageSource = container.querySelector<HTMLImageElement>('.cropper-source');
initCompCropper({container, fileInput, imageSource});
}
export function initUserSettings() {
if (!document.querySelectorAll('.user.settings.profile').length) return;
if (!document.querySelector('.user.settings.profile')) return;
initUserSettingsAvatarCropper();
const usernameInput = document.querySelector('#username');
if (!usernameInput) return;

View File

@@ -26,7 +26,6 @@ import {initPdfViewer} from './render/pdf.ts';
import {initUserAuthOauth2, initUserCheckAppUrl} from './features/user-auth.ts';
import {
initRepoIssueReferenceRepositorySearch,
initRepoIssueTimeTracking,
initRepoIssueWipTitle,
initRepoPullRequestMergeInstruction,
initRepoPullRequestAllowMaintainerEdit,
@@ -34,7 +33,6 @@ import {
} from './features/repo-issue.ts';
import {initRepoEllipsisButton, initCommitStatuses} from './features/repo-commit.ts';
import {initRepoTopicBar} from './features/repo-home.ts';
import {initAdminEmails} from './features/admin/emails.ts';
import {initAdminCommon} from './features/admin/common.ts';
import {initRepoTemplateSearch} from './features/repo-template.ts';
import {initRepoCodeView} from './features/repo-code.ts';
@@ -83,9 +81,12 @@ import {
initGlobalButtonClickOnEnter,
initGlobalButtons,
initGlobalDeleteButton,
initGlobalShowModal,
} from './features/common-button.ts';
import {initGlobalEnterQuickSubmit, initGlobalFormDirtyLeaveConfirm} from './features/common-form.ts';
import {
initGlobalComboMarkdownEditor,
initGlobalEnterQuickSubmit,
initGlobalFormDirtyLeaveConfirm,
} from './features/common-form.ts';
initGiteaFomantic();
initDirAuto();
@@ -122,7 +123,6 @@ onDomReady(() => {
callInitFunctions([
initGlobalDropdown,
initGlobalTabularMenu,
initGlobalShowModal,
initGlobalFetchAction,
initGlobalTooltips,
initGlobalButtonClickOnEnter,
@@ -130,6 +130,7 @@ onDomReady(() => {
initGlobalCopyToClipboardListener,
initGlobalEnterQuickSubmit,
initGlobalFormDirtyLeaveConfirm,
initGlobalComboMarkdownEditor,
initGlobalDeleteButton,
initCommonOrganization,
@@ -157,7 +158,6 @@ onDomReady(() => {
initCopyContent,
initAdminCommon,
initAdminEmails,
initAdminUserListSearchForm,
initAdminConfigs,
initAdminSelfCheck,
@@ -183,7 +183,6 @@ onDomReady(() => {
initRepoIssueList,
initRepoIssueSidebarList,
initRepoIssueReferenceRepositorySearch,
initRepoIssueTimeTracking,
initRepoIssueWipTitle,
initRepoMigration,
initRepoMigrationStatusChecker,

View File

@@ -58,16 +58,12 @@ export async function renderMermaid(): Promise<void> {
mermaidBlock.append(btn);
const updateIframeHeight = () => {
iframe.style.height = `${iframe.contentWindow.document.body.clientHeight}px`;
const body = iframe.contentWindow?.document?.body;
if (body) {
iframe.style.height = `${body.clientHeight}px`;
}
};
// update height when element's visibility state changes, for example when the diagram is inside
// a <details> + <summary> block and the <details> block becomes visible upon user interaction, it
// would initially set a incorrect height and the correct height is set during this callback.
(new IntersectionObserver(() => {
updateIframeHeight();
}, {root: document.documentElement})).observe(iframe);
iframe.addEventListener('load', () => {
pre.replaceWith(mermaidBlock);
mermaidBlock.classList.remove('tw-hidden');
@@ -76,6 +72,13 @@ export async function renderMermaid(): Promise<void> {
mermaidBlock.classList.remove('is-loading');
iframe.classList.remove('tw-invisible');
}, 0);
// update height when element's visibility state changes, for example when the diagram is inside
// a <details> + <summary> block and the <details> block becomes visible upon user interaction, it
// would initially set a incorrect height and the correct height is set during this callback.
(new IntersectionObserver(() => {
updateIframeHeight();
}, {root: document.documentElement})).observe(iframe);
});
document.body.append(mermaidBlock);

View File

@@ -1,5 +1,5 @@
import {isObject} from '../utils.ts';
import type {RequestData, RequestOpts} from '../types.ts';
import type {RequestOpts} from '../types.ts';
const {csrfToken} = window.config;
@@ -10,7 +10,7 @@ const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
// which will automatically set an appropriate headers. For json content, only object
// and array types are currently supported.
export function request(url: string, {method = 'GET', data, headers = {}, ...other}: RequestOpts = {}): Promise<Response> {
let body: RequestData;
let body: string | FormData | URLSearchParams;
let contentType: string;
if (data instanceof FormData || data instanceof URLSearchParams) {
body = data;

View File

@@ -1,6 +1,6 @@
import type {SortableOptions, SortableEvent} from 'sortablejs';
export async function createSortable(el: HTMLElement, opts: {handle?: string} & SortableOptions = {}) {
export async function createSortable(el: Element, opts: {handle?: string} & SortableOptions = {}) {
// @ts-expect-error: wrong type derived by typescript
const {Sortable} = await import(/* webpackChunkName: "sortablejs" */'sortablejs');

View File

@@ -27,6 +27,7 @@ import octiconDownload from '../../public/assets/img/svg/octicon-download.svg';
import octiconEye from '../../public/assets/img/svg/octicon-eye.svg';
import octiconFile from '../../public/assets/img/svg/octicon-file.svg';
import octiconFileDirectoryFill from '../../public/assets/img/svg/octicon-file-directory-fill.svg';
import octiconFileDirectoryOpenFill from '../../public/assets/img/svg/octicon-file-directory-open-fill.svg';
import octiconFilter from '../../public/assets/img/svg/octicon-filter.svg';
import octiconGear from '../../public/assets/img/svg/octicon-gear.svg';
import octiconGitBranch from '../../public/assets/img/svg/octicon-git-branch.svg';
@@ -34,6 +35,7 @@ import octiconGitCommit from '../../public/assets/img/svg/octicon-git-commit.svg
import octiconGitMerge from '../../public/assets/img/svg/octicon-git-merge.svg';
import octiconGitPullRequest from '../../public/assets/img/svg/octicon-git-pull-request.svg';
import octiconGitPullRequestDraft from '../../public/assets/img/svg/octicon-git-pull-request-draft.svg';
import octiconGrabber from '../../public/assets/img/svg/octicon-grabber.svg';
import octiconHeading from '../../public/assets/img/svg/octicon-heading.svg';
import octiconHorizontalRule from '../../public/assets/img/svg/octicon-horizontal-rule.svg';
import octiconImage from '../../public/assets/img/svg/octicon-image.svg';
@@ -100,6 +102,7 @@ const svgs = {
'octicon-eye': octiconEye,
'octicon-file': octiconFile,
'octicon-file-directory-fill': octiconFileDirectoryFill,
'octicon-file-directory-open-fill': octiconFileDirectoryOpenFill,
'octicon-filter': octiconFilter,
'octicon-gear': octiconGear,
'octicon-git-branch': octiconGitBranch,
@@ -107,6 +110,7 @@ const svgs = {
'octicon-git-merge': octiconGitMerge,
'octicon-git-pull-request': octiconGitPullRequest,
'octicon-git-pull-request-draft': octiconGitPullRequestDraft,
'octicon-grabber': octiconGrabber,
'octicon-heading': octiconHeading,
'octicon-horizontal-rule': octiconHorizontalRule,
'octicon-image': octiconImage,

View File

@@ -24,7 +24,7 @@ export type Config = {
export type Intent = 'error' | 'warning' | 'info';
export type RequestData = string | FormData | URLSearchParams;
export type RequestData = string | FormData | URLSearchParams | Record<string, any>;
export type RequestOpts = {
data?: RequestData,
@@ -59,3 +59,5 @@ export type FomanticInitFunction = {
settings?: Record<string, any>,
(...args: any[]): any,
}
export type GitRefType = 'branch' | 'tag';

View File

@@ -1,4 +1,4 @@
import {createElementFromAttrs, createElementFromHTML, querySingleVisibleElem} from './dom.ts';
import {createElementFromAttrs, createElementFromHTML, queryElemChildren, querySingleVisibleElem} from './dom.ts';
test('createElementFromHTML', () => {
expect(createElementFromHTML('<a>foo<span>bar</span></a>').outerHTML).toEqual('<a>foo<span>bar</span></a>');
@@ -26,3 +26,9 @@ test('querySingleVisibleElem', () => {
el = createElementFromHTML('<div><span>foo</span><span>bar</span></div>');
expect(() => querySingleVisibleElem(el, 'span')).toThrowError('Expected exactly one visible element');
});
test('queryElemChildren', () => {
const el = createElementFromHTML('<div><span class="a">a</span><span class="b">b</span></div>');
const children = queryElemChildren(el, '.a');
expect(children.length).toEqual(1);
});

View File

@@ -1,11 +1,12 @@
import {debounce} from 'throttle-debounce';
import type {Promisable} from 'type-fest';
import type $ from 'jquery';
import {isInFrontendUnitTest} from './testhelper.ts';
type ElementArg = Element | string | NodeListOf<Element> | Array<Element> | ReturnType<typeof $>;
type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array
type ElementArg = Element | string | ArrayLikeIterable<Element> | ReturnType<typeof $>;
type ElementsCallback<T extends Element> = (el: T) => Promisable<any>;
type ElementsCallbackWithArgs = (el: Element, ...args: any[]) => Promisable<any>;
type ArrayLikeIterable<T> = ArrayLike<T> & Iterable<T>; // for NodeListOf and Array
function elementsCall(el: ElementArg, func: ElementsCallbackWithArgs, ...args: any[]) {
if (typeof el === 'string' || el instanceof String) {
@@ -76,6 +77,11 @@ export function queryElemSiblings<T extends Element>(el: Element, selector = '*'
// it works like jQuery.children: only the direct children are selected
export function queryElemChildren<T extends Element>(parent: Element | ParentNode, selector = '*', fn?: ElementsCallback<T>): ArrayLikeIterable<T> {
if (isInFrontendUnitTest()) {
// https://github.com/capricorn86/happy-dom/issues/1620 : ":scope" doesn't work
const selected = Array.from<T>(parent.children as any).filter((child) => child.matches(selector));
return applyElemsCallback<T>(selected, fn);
}
return applyElemsCallback<T>(parent.querySelectorAll(`:scope > ${selector}`), fn);
}
@@ -348,10 +354,10 @@ export function querySingleVisibleElem<T extends HTMLElement>(parent: Element, s
return candidates.length ? candidates[0] as T : null;
}
export function addDelegatedEventListener<T extends HTMLElement>(parent: Node, type: string, selector: string, listener: (elem: T, e: Event) => void | Promise<any>, options?: boolean | AddEventListenerOptions) {
export function addDelegatedEventListener<T extends HTMLElement, E extends Event>(parent: Node, type: string, selector: string, listener: (elem: T, e: E) => void | Promise<any>, options?: boolean | AddEventListenerOptions) {
parent.addEventListener(type, (e: Event) => {
const elem = (e.target as HTMLElement).closest(selector);
if (!elem) return;
listener(elem as T, e);
listener(elem as T, e as E);
}, options);
}

View File

@@ -0,0 +1,6 @@
// there could be different "testing" concepts, for example: backend's "setting.IsInTesting"
// even if backend is in testing mode, frontend could be complied in production mode
// so this function only checks if the frontend is in unit testing mode (usually from *.test.ts files)
export function isInFrontendUnitTest() {
return process.env.TEST === 'true';
}

View File

@@ -196,6 +196,6 @@ export function initAreYouSure($) {
};
}
export function applyAreYouSure(selector: string) {
$(selector).areYouSure();
export function applyAreYouSure(selectorOrEl: string|Element|$, opts = {}) {
$(selectorOrEl).areYouSure(opts);
}