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:
@@ -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,
|
||||
|
@@ -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>
|
||||
|
@@ -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();
|
||||
});
|
||||
}
|
@@ -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();
|
||||
}
|
||||
}
|
||||
|
@@ -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));
|
||||
}
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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));
|
||||
}
|
||||
|
@@ -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');
|
||||
}
|
||||
|
@@ -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>
|
||||
|
40
web_src/js/features/comp/Cropper.ts
Normal file
40
web_src/js/features/comp/Cropper.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
@@ -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');
|
||||
|
@@ -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();
|
||||
|
@@ -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'));
|
||||
}
|
||||
|
@@ -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');
|
||||
|
@@ -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);
|
||||
})();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
@@ -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;
|
||||
|
||||
|
@@ -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);
|
||||
}
|
||||
|
71
web_src/js/features/repo-settings-branches.test.ts
Normal file
71
web_src/js/features/repo-settings-branches.test.ts
Normal 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]},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
32
web_src/js/features/repo-settings-branches.ts
Normal file
32
web_src/js/features/repo-settings-branches.ts
Normal 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}`);
|
||||
}
|
||||
})();
|
||||
},
|
||||
});
|
||||
}
|
@@ -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();
|
||||
}
|
||||
|
@@ -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');
|
||||
|
@@ -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
|
||||
},
|
||||
});
|
||||
|
||||
|
@@ -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);
|
||||
|
@@ -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;
|
||||
|
@@ -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,
|
||||
|
@@ -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);
|
||||
|
@@ -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;
|
||||
|
@@ -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');
|
||||
|
||||
|
@@ -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,
|
||||
|
@@ -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';
|
||||
|
@@ -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);
|
||||
});
|
||||
|
@@ -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);
|
||||
}
|
||||
|
6
web_src/js/utils/testhelper.ts
Normal file
6
web_src/js/utils/testhelper.ts
Normal 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';
|
||||
}
|
4
web_src/js/vendor/jquery.are-you-sure.ts
vendored
4
web_src/js/vendor/jquery.are-you-sure.ts
vendored
@@ -196,6 +196,6 @@ export function initAreYouSure($) {
|
||||
};
|
||||
}
|
||||
|
||||
export function applyAreYouSure(selector: string) {
|
||||
$(selector).areYouSure();
|
||||
export function applyAreYouSure(selectorOrEl: string|Element|$, opts = {}) {
|
||||
$(selectorOrEl).areYouSure(opts);
|
||||
}
|
||||
|
Reference in New Issue
Block a user