mirror of
https://github.com/go-gitea/gitea
synced 2025-07-13 22:17:20 +00:00
Merge branch 'main' into lunny/automerge_support_delete_branch
This commit is contained in:
@ -2,31 +2,21 @@
|
||||
Please also update the template file above if this vue is modified.
|
||||
action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, unknown
|
||||
-->
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
|
||||
export default {
|
||||
components: {SvgIcon},
|
||||
props: {
|
||||
status: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
size: {
|
||||
type: Number,
|
||||
default: 16,
|
||||
},
|
||||
className: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
localeStatus: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
withDefaults(defineProps<{
|
||||
status: '',
|
||||
size?: number,
|
||||
className?: string,
|
||||
localeStatus?: string,
|
||||
}>(), {
|
||||
size: 16,
|
||||
className: undefined,
|
||||
localeStatus: undefined,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="tw-flex tw-items-center" :data-tooltip-content="localeStatus" v-if="status">
|
||||
<SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class-name="className" v-if="status === 'success'"/>
|
||||
|
@ -1,58 +1,56 @@
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
// TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged
|
||||
import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap';
|
||||
import {onMounted, ref} from 'vue';
|
||||
import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap';
|
||||
|
||||
export default {
|
||||
components: {CalendarHeatmap},
|
||||
props: {
|
||||
values: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
locale: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
colorRange: [
|
||||
'var(--color-secondary-alpha-60)',
|
||||
'var(--color-secondary-alpha-60)',
|
||||
'var(--color-primary-light-4)',
|
||||
'var(--color-primary-light-2)',
|
||||
'var(--color-primary)',
|
||||
'var(--color-primary-dark-2)',
|
||||
'var(--color-primary-dark-4)',
|
||||
],
|
||||
endDate: new Date(),
|
||||
}),
|
||||
mounted() {
|
||||
// work around issue with first legend color being rendered twice and legend cut off
|
||||
const legend = document.querySelector('.vch__external-legend-wrapper');
|
||||
legend.setAttribute('viewBox', '12 0 80 10');
|
||||
legend.style.marginRight = '-12px';
|
||||
},
|
||||
methods: {
|
||||
handleDayClick(e) {
|
||||
// Reset filter if same date is clicked
|
||||
const params = new URLSearchParams(document.location.search);
|
||||
const queryDate = params.get('date');
|
||||
// Timezone has to be stripped because toISOString() converts to UTC
|
||||
const clickedDate = new Date(e.date - (e.date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
|
||||
defineProps<{
|
||||
values?: HeatmapValue[];
|
||||
locale: {
|
||||
textTotalContributions: string;
|
||||
heatMapLocale: Partial<HeatmapLocale>;
|
||||
noDataText: string;
|
||||
tooltipUnit: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
if (queryDate && queryDate === clickedDate) {
|
||||
params.delete('date');
|
||||
} else {
|
||||
params.set('date', clickedDate);
|
||||
}
|
||||
const colorRange = [
|
||||
'var(--color-secondary-alpha-60)',
|
||||
'var(--color-secondary-alpha-60)',
|
||||
'var(--color-primary-light-4)',
|
||||
'var(--color-primary-light-2)',
|
||||
'var(--color-primary)',
|
||||
'var(--color-primary-dark-2)',
|
||||
'var(--color-primary-dark-4)',
|
||||
];
|
||||
|
||||
params.delete('page');
|
||||
const endDate = ref(new Date());
|
||||
|
||||
const newSearch = params.toString();
|
||||
window.location.search = newSearch.length ? `?${newSearch}` : '';
|
||||
},
|
||||
},
|
||||
};
|
||||
onMounted(() => {
|
||||
// work around issue with first legend color being rendered twice and legend cut off
|
||||
const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper');
|
||||
legend.setAttribute('viewBox', '12 0 80 10');
|
||||
legend.style.marginRight = '-12px';
|
||||
});
|
||||
|
||||
function handleDayClick(e: Event & {date: Date}) {
|
||||
// Reset filter if same date is clicked
|
||||
const params = new URLSearchParams(document.location.search);
|
||||
const queryDate = params.get('date');
|
||||
// Timezone has to be stripped because toISOString() converts to UTC
|
||||
const clickedDate = new Date(e.date.getTime() - (e.date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
|
||||
|
||||
if (queryDate && queryDate === clickedDate) {
|
||||
params.delete('date');
|
||||
} else {
|
||||
params.set('date', clickedDate);
|
||||
}
|
||||
|
||||
params.delete('page');
|
||||
|
||||
const newSearch = params.toString();
|
||||
window.location.search = newSearch.length ? `?${newSearch}` : '';
|
||||
}
|
||||
</script>
|
||||
<template>
|
||||
<div class="total-contributions">
|
||||
|
@ -1,100 +1,66 @@
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {getIssueColor, getIssueIcon} from '../features/issue.ts';
|
||||
import {computed, onMounted, ref} from 'vue';
|
||||
import type {IssuePathInfo} from '../types.ts';
|
||||
|
||||
const {appSubUrl, i18n} = window.config;
|
||||
|
||||
export default {
|
||||
components: {SvgIcon},
|
||||
data: () => ({
|
||||
loading: false,
|
||||
issue: null,
|
||||
renderedLabels: '',
|
||||
i18nErrorOccurred: i18n.error_occurred,
|
||||
i18nErrorMessage: null,
|
||||
}),
|
||||
computed: {
|
||||
createdAt() {
|
||||
return new Date(this.issue.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
|
||||
},
|
||||
const loading = ref(false);
|
||||
const issue = ref(null);
|
||||
const renderedLabels = ref('');
|
||||
const i18nErrorOccurred = i18n.error_occurred;
|
||||
const i18nErrorMessage = ref(null);
|
||||
|
||||
body() {
|
||||
const body = this.issue.body.replace(/\n+/g, ' ');
|
||||
if (body.length > 85) {
|
||||
return `${body.substring(0, 85)}…`;
|
||||
}
|
||||
return body;
|
||||
},
|
||||
const createdAt = computed(() => new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'}));
|
||||
const body = computed(() => {
|
||||
const body = issue.value.body.replace(/\n+/g, ' ');
|
||||
if (body.length > 85) {
|
||||
return `${body.substring(0, 85)}…`;
|
||||
}
|
||||
return body;
|
||||
});
|
||||
|
||||
icon() {
|
||||
if (this.issue.pull_request !== null) {
|
||||
if (this.issue.state === 'open') {
|
||||
if (this.issue.pull_request.draft === true) {
|
||||
return 'octicon-git-pull-request-draft'; // WIP PR
|
||||
}
|
||||
return 'octicon-git-pull-request'; // Open PR
|
||||
} else if (this.issue.pull_request.merged === true) {
|
||||
return 'octicon-git-merge'; // Merged PR
|
||||
}
|
||||
return 'octicon-git-pull-request'; // Closed PR
|
||||
} else if (this.issue.state === 'open') {
|
||||
return 'octicon-issue-opened'; // Open Issue
|
||||
}
|
||||
return 'octicon-issue-closed'; // Closed Issue
|
||||
},
|
||||
const root = ref<HTMLElement | null>(null);
|
||||
|
||||
color() {
|
||||
if (this.issue.pull_request !== null) {
|
||||
if (this.issue.pull_request.draft === true) {
|
||||
return 'grey'; // WIP PR
|
||||
} else if (this.issue.pull_request.merged === true) {
|
||||
return 'purple'; // Merged PR
|
||||
}
|
||||
}
|
||||
if (this.issue.state === 'open') {
|
||||
return 'green'; // Open Issue
|
||||
}
|
||||
return 'red'; // Closed Issue
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$refs.root.addEventListener('ce-load-context-popup', (e) => {
|
||||
const data = e.detail;
|
||||
if (!this.loading && this.issue === null) {
|
||||
this.load(data);
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
async load(data) {
|
||||
this.loading = true;
|
||||
this.i18nErrorMessage = null;
|
||||
onMounted(() => {
|
||||
root.value.addEventListener('ce-load-context-popup', (e: CustomEvent) => {
|
||||
const data: IssuePathInfo = e.detail;
|
||||
if (!loading.value && issue.value === null) {
|
||||
load(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await GET(`${appSubUrl}/${data.owner}/${data.repo}/issues/${data.index}/info`); // backend: GetIssueInfo
|
||||
const respJson = await response.json();
|
||||
if (!response.ok) {
|
||||
this.i18nErrorMessage = respJson.message ?? i18n.network_error;
|
||||
return;
|
||||
}
|
||||
this.issue = respJson.convertedIssue;
|
||||
this.renderedLabels = respJson.renderedLabels;
|
||||
} catch {
|
||||
this.i18nErrorMessage = i18n.network_error;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
async function load(issuePathInfo: IssuePathInfo) {
|
||||
loading.value = true;
|
||||
i18nErrorMessage.value = null;
|
||||
|
||||
try {
|
||||
const response = await GET(`${appSubUrl}/${issuePathInfo.ownerName}/${issuePathInfo.repoName}/issues/${issuePathInfo.indexString}/info`); // backend: GetIssueInfo
|
||||
const respJson = await response.json();
|
||||
if (!response.ok) {
|
||||
i18nErrorMessage.value = respJson.message ?? i18n.network_error;
|
||||
return;
|
||||
}
|
||||
issue.value = respJson.convertedIssue;
|
||||
renderedLabels.value = respJson.renderedLabels;
|
||||
} catch {
|
||||
i18nErrorMessage.value = i18n.network_error;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="root">
|
||||
<div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
|
||||
<div v-if="!loading && issue !== null" class="tw-flex tw-flex-col tw-gap-2">
|
||||
<div class="tw-text-12">{{ issue.repository.full_name }} on {{ createdAt }}</div>
|
||||
<div class="flex-text-block">
|
||||
<svg-icon :name="icon" :class="['text', color]"/>
|
||||
<svg-icon :name="getIssueIcon(issue)" :class="['text', getIssueColor(issue)]"/>
|
||||
<span class="issue-title tw-font-semibold tw-break-anywhere">
|
||||
{{ issue.title }}
|
||||
<span class="index">#{{ issue.number }}</span>
|
||||
|
@ -142,11 +142,11 @@ export default {
|
||||
Object.assign(this.locale, results.locale);
|
||||
},
|
||||
showAllChanges() {
|
||||
window.location = `${this.issueLink}/files${this.queryParams}`;
|
||||
window.location.assign(`${this.issueLink}/files${this.queryParams}`);
|
||||
},
|
||||
/** Called when user clicks on since last review */
|
||||
changesSinceLastReviewClick() {
|
||||
window.location = `${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1).id}${this.queryParams}`;
|
||||
window.location.assign(`${this.issueLink}/files/${this.lastReviewCommitSha}..${this.commits.at(-1).id}${this.queryParams}`);
|
||||
},
|
||||
/** Clicking on a single commit opens this specific commit */
|
||||
commitClicked(commitId, newWindow = false) {
|
||||
@ -154,7 +154,7 @@ export default {
|
||||
if (newWindow) {
|
||||
window.open(url);
|
||||
} else {
|
||||
window.location = url;
|
||||
window.location.assign(url);
|
||||
}
|
||||
},
|
||||
/**
|
||||
@ -176,14 +176,14 @@ export default {
|
||||
const lastCommitIdx = this.commits.findLastIndex((x) => x.selected);
|
||||
if (lastCommitIdx === this.commits.length - 1) {
|
||||
// user selected all commits - just show the normal diff page
|
||||
window.location = `${this.issueLink}/files${this.queryParams}`;
|
||||
window.location.assign(`${this.issueLink}/files${this.queryParams}`);
|
||||
} else {
|
||||
window.location = `${this.issueLink}/files/${this.commits[lastCommitIdx].id}${this.queryParams}`;
|
||||
window.location.assign(`${this.issueLink}/files/${this.commits[lastCommitIdx].id}${this.queryParams}`);
|
||||
}
|
||||
} else {
|
||||
const start = this.commits[this.commits.findIndex((x) => x.selected) - 1].id;
|
||||
const end = this.commits.findLast((x) => x.selected).id;
|
||||
window.location = `${this.issueLink}/files/${start}..${end}${this.queryParams}`;
|
||||
window.location.assign(`${this.issueLink}/files/${start}..${end}${this.queryParams}`);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -1,40 +1,42 @@
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {onMounted, onUnmounted} from 'vue';
|
||||
import {loadMoreFiles} from '../features/repo-diff.ts';
|
||||
import {diffTreeStore} from '../modules/stores.ts';
|
||||
|
||||
export default {
|
||||
data: () => {
|
||||
return {store: diffTreeStore()};
|
||||
},
|
||||
mounted() {
|
||||
document.querySelector('#show-file-list-btn').addEventListener('click', this.toggleFileList);
|
||||
},
|
||||
unmounted() {
|
||||
document.querySelector('#show-file-list-btn').removeEventListener('click', this.toggleFileList);
|
||||
},
|
||||
methods: {
|
||||
toggleFileList() {
|
||||
this.store.fileListIsVisible = !this.store.fileListIsVisible;
|
||||
},
|
||||
diffTypeToString(pType) {
|
||||
const diffTypes = {
|
||||
1: 'add',
|
||||
2: 'modify',
|
||||
3: 'del',
|
||||
4: 'rename',
|
||||
5: 'copy',
|
||||
};
|
||||
return diffTypes[pType];
|
||||
},
|
||||
diffStatsWidth(adds, dels) {
|
||||
return `${adds / (adds + dels) * 100}%`;
|
||||
},
|
||||
loadMoreData() {
|
||||
loadMoreFiles(this.store.linkLoadMore);
|
||||
},
|
||||
},
|
||||
};
|
||||
const store = diffTreeStore();
|
||||
|
||||
onMounted(() => {
|
||||
document.querySelector('#show-file-list-btn').addEventListener('click', toggleFileList);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.querySelector('#show-file-list-btn').removeEventListener('click', toggleFileList);
|
||||
});
|
||||
|
||||
function toggleFileList() {
|
||||
store.fileListIsVisible = !store.fileListIsVisible;
|
||||
}
|
||||
|
||||
function diffTypeToString(pType) {
|
||||
const diffTypes = {
|
||||
1: 'add',
|
||||
2: 'modify',
|
||||
3: 'del',
|
||||
4: 'rename',
|
||||
5: 'copy',
|
||||
};
|
||||
return diffTypes[pType];
|
||||
}
|
||||
|
||||
function diffStatsWidth(adds, dels) {
|
||||
return `${adds / (adds + dels) * 100}%`;
|
||||
}
|
||||
|
||||
function loadMoreData() {
|
||||
loadMoreFiles(store.linkLoadMore);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ol class="diff-stats tw-m-0" ref="root" v-if="store.fileListIsVisible">
|
||||
<li v-for="file in store.files" :key="file.NameHash">
|
||||
|
@ -1,130 +1,137 @@
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import DiffFileTreeItem from './DiffFileTreeItem.vue';
|
||||
import {loadMoreFiles} from '../features/repo-diff.ts';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
import {diffTreeStore} from '../modules/stores.ts';
|
||||
import {setFileFolding} from '../features/file-fold.ts';
|
||||
import {computed, onMounted, onUnmounted} from 'vue';
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
|
||||
|
||||
export default {
|
||||
components: {DiffFileTreeItem},
|
||||
data: () => {
|
||||
return {store: diffTreeStore()};
|
||||
},
|
||||
computed: {
|
||||
fileTree() {
|
||||
const result = [];
|
||||
for (const file of this.store.files) {
|
||||
// Split file into directories
|
||||
const splits = file.Name.split('/');
|
||||
let index = 0;
|
||||
let parent = null;
|
||||
let isFile = false;
|
||||
for (const split of splits) {
|
||||
index += 1;
|
||||
// reached the end
|
||||
if (index === splits.length) {
|
||||
isFile = true;
|
||||
}
|
||||
let newParent = {
|
||||
name: split,
|
||||
children: [],
|
||||
isFile,
|
||||
};
|
||||
const store = diffTreeStore();
|
||||
|
||||
if (isFile === true) {
|
||||
newParent.file = file;
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
// check if the folder already exists
|
||||
const existingFolder = parent.children.find(
|
||||
(x) => x.name === split,
|
||||
);
|
||||
if (existingFolder) {
|
||||
newParent = existingFolder;
|
||||
} else {
|
||||
parent.children.push(newParent);
|
||||
}
|
||||
} else {
|
||||
const existingFolder = result.find((x) => x.name === split);
|
||||
if (existingFolder) {
|
||||
newParent = existingFolder;
|
||||
} else {
|
||||
result.push(newParent);
|
||||
}
|
||||
}
|
||||
parent = newParent;
|
||||
}
|
||||
const fileTree = computed(() => {
|
||||
const result = [];
|
||||
for (const file of store.files) {
|
||||
// Split file into directories
|
||||
const splits = file.Name.split('/');
|
||||
let index = 0;
|
||||
let parent = null;
|
||||
let isFile = false;
|
||||
for (const split of splits) {
|
||||
index += 1;
|
||||
// reached the end
|
||||
if (index === splits.length) {
|
||||
isFile = true;
|
||||
}
|
||||
const mergeChildIfOnlyOneDir = (entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.children) {
|
||||
mergeChildIfOnlyOneDir(entry.children);
|
||||
}
|
||||
if (entry.children.length === 1 && entry.children[0].isFile === false) {
|
||||
// Merge it to the parent
|
||||
entry.name = `${entry.name}/${entry.children[0].name}`;
|
||||
entry.children = entry.children[0].children;
|
||||
}
|
||||
}
|
||||
let newParent = {
|
||||
name: split,
|
||||
children: [],
|
||||
isFile,
|
||||
} as {
|
||||
name: string,
|
||||
children: any[],
|
||||
isFile: boolean,
|
||||
file?: any,
|
||||
};
|
||||
// Merge folders with just a folder as children in order to
|
||||
// reduce the depth of our tree.
|
||||
mergeChildIfOnlyOneDir(result);
|
||||
return result;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
// Default to true if unset
|
||||
this.store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
|
||||
document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', this.toggleVisibility);
|
||||
|
||||
this.hashChangeListener = () => {
|
||||
this.store.selectedItem = window.location.hash;
|
||||
this.expandSelectedFile();
|
||||
};
|
||||
this.hashChangeListener();
|
||||
window.addEventListener('hashchange', this.hashChangeListener);
|
||||
},
|
||||
unmounted() {
|
||||
document.querySelector('.diff-toggle-file-tree-button').removeEventListener('click', this.toggleVisibility);
|
||||
window.removeEventListener('hashchange', this.hashChangeListener);
|
||||
},
|
||||
methods: {
|
||||
expandSelectedFile() {
|
||||
// expand file if the selected file is folded
|
||||
if (this.store.selectedItem) {
|
||||
const box = document.querySelector(this.store.selectedItem);
|
||||
const folded = box?.getAttribute('data-folded') === 'true';
|
||||
if (folded) setFileFolding(box, box.querySelector('.fold-file'), false);
|
||||
if (isFile === true) {
|
||||
newParent.file = file;
|
||||
}
|
||||
},
|
||||
toggleVisibility() {
|
||||
this.updateVisibility(!this.store.fileTreeIsVisible);
|
||||
},
|
||||
updateVisibility(visible) {
|
||||
this.store.fileTreeIsVisible = visible;
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, this.store.fileTreeIsVisible);
|
||||
this.updateState(this.store.fileTreeIsVisible);
|
||||
},
|
||||
updateState(visible) {
|
||||
const btn = document.querySelector('.diff-toggle-file-tree-button');
|
||||
const [toShow, toHide] = btn.querySelectorAll('.icon');
|
||||
const tree = document.querySelector('#diff-file-tree');
|
||||
const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text');
|
||||
btn.setAttribute('data-tooltip-content', newTooltip);
|
||||
toggleElem(tree, visible);
|
||||
toggleElem(toShow, !visible);
|
||||
toggleElem(toHide, visible);
|
||||
},
|
||||
loadMoreData() {
|
||||
loadMoreFiles(this.store.linkLoadMore);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
if (parent) {
|
||||
// check if the folder already exists
|
||||
const existingFolder = parent.children.find(
|
||||
(x) => x.name === split,
|
||||
);
|
||||
if (existingFolder) {
|
||||
newParent = existingFolder;
|
||||
} else {
|
||||
parent.children.push(newParent);
|
||||
}
|
||||
} else {
|
||||
const existingFolder = result.find((x) => x.name === split);
|
||||
if (existingFolder) {
|
||||
newParent = existingFolder;
|
||||
} else {
|
||||
result.push(newParent);
|
||||
}
|
||||
}
|
||||
parent = newParent;
|
||||
}
|
||||
}
|
||||
const mergeChildIfOnlyOneDir = (entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.children) {
|
||||
mergeChildIfOnlyOneDir(entry.children);
|
||||
}
|
||||
if (entry.children.length === 1 && entry.children[0].isFile === false) {
|
||||
// Merge it to the parent
|
||||
entry.name = `${entry.name}/${entry.children[0].name}`;
|
||||
entry.children = entry.children[0].children;
|
||||
}
|
||||
}
|
||||
};
|
||||
// Merge folders with just a folder as children in order to
|
||||
// reduce the depth of our tree.
|
||||
mergeChildIfOnlyOneDir(result);
|
||||
return result;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
// Default to true if unset
|
||||
store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
|
||||
document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', toggleVisibility);
|
||||
|
||||
hashChangeListener();
|
||||
window.addEventListener('hashchange', hashChangeListener);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.querySelector('.diff-toggle-file-tree-button').removeEventListener('click', toggleVisibility);
|
||||
window.removeEventListener('hashchange', hashChangeListener);
|
||||
});
|
||||
|
||||
function hashChangeListener() {
|
||||
store.selectedItem = window.location.hash;
|
||||
expandSelectedFile();
|
||||
}
|
||||
|
||||
function expandSelectedFile() {
|
||||
// expand file if the selected file is folded
|
||||
if (store.selectedItem) {
|
||||
const box = document.querySelector(store.selectedItem);
|
||||
const folded = box?.getAttribute('data-folded') === 'true';
|
||||
if (folded) setFileFolding(box, box.querySelector('.fold-file'), false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleVisibility() {
|
||||
updateVisibility(!store.fileTreeIsVisible);
|
||||
}
|
||||
|
||||
function updateVisibility(visible) {
|
||||
store.fileTreeIsVisible = visible;
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible);
|
||||
updateState(store.fileTreeIsVisible);
|
||||
}
|
||||
|
||||
function updateState(visible) {
|
||||
const btn = document.querySelector('.diff-toggle-file-tree-button');
|
||||
const [toShow, toHide] = btn.querySelectorAll('.icon');
|
||||
const tree = document.querySelector('#diff-file-tree');
|
||||
const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text');
|
||||
btn.setAttribute('data-tooltip-content', newTooltip);
|
||||
toggleElem(tree, visible);
|
||||
toggleElem(toShow, !visible);
|
||||
toggleElem(toHide, visible);
|
||||
}
|
||||
|
||||
function loadMoreData() {
|
||||
loadMoreFiles(store.linkLoadMore);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
|
||||
<!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
|
||||
@ -134,6 +141,7 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.diff-file-tree-items {
|
||||
display: flex;
|
||||
|
@ -1,33 +1,41 @@
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {diffTreeStore} from '../modules/stores.ts';
|
||||
import {ref} from 'vue';
|
||||
|
||||
export default {
|
||||
components: {SvgIcon},
|
||||
props: {
|
||||
item: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
store: diffTreeStore(),
|
||||
collapsed: false,
|
||||
}),
|
||||
methods: {
|
||||
getIconForDiffType(pType) {
|
||||
const diffTypes = {
|
||||
1: {name: 'octicon-diff-added', classes: ['text', 'green']},
|
||||
2: {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
|
||||
3: {name: 'octicon-diff-removed', classes: ['text', 'red']},
|
||||
4: {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
|
||||
5: {name: 'octicon-diff-renamed', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
|
||||
};
|
||||
return diffTypes[pType];
|
||||
},
|
||||
},
|
||||
type File = {
|
||||
Name: string;
|
||||
NameHash: string;
|
||||
Type: number;
|
||||
IsViewed: boolean;
|
||||
}
|
||||
|
||||
type Item = {
|
||||
name: string;
|
||||
isFile: boolean;
|
||||
file?: File;
|
||||
children?: Item[];
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
item: Item,
|
||||
}>();
|
||||
|
||||
const store = diffTreeStore();
|
||||
const collapsed = ref(false);
|
||||
|
||||
function getIconForDiffType(pType) {
|
||||
const diffTypes = {
|
||||
1: {name: 'octicon-diff-added', classes: ['text', 'green']},
|
||||
2: {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
|
||||
3: {name: 'octicon-diff-removed', classes: ['text', 'red']},
|
||||
4: {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
|
||||
5: {name: 'octicon-diff-renamed', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
|
||||
};
|
||||
return diffTypes[pType];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--title instead of tooltip above as the tooltip needs too much work with the current methods, i.e. not being loaded or staying open for "too long"-->
|
||||
<a
|
||||
|
@ -1,84 +1,83 @@
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {toggleElem} from '../utils/dom.ts';
|
||||
|
||||
const {csrfToken, pageData} = window.config;
|
||||
|
||||
export default {
|
||||
components: {SvgIcon},
|
||||
data: () => ({
|
||||
csrfToken,
|
||||
mergeForm: pageData.pullRequestMergeForm,
|
||||
const mergeForm = ref(pageData.pullRequestMergeForm);
|
||||
|
||||
mergeTitleFieldValue: '',
|
||||
mergeMessageFieldValue: '',
|
||||
deleteBranchAfterMerge: false,
|
||||
autoMergeWhenSucceed: false,
|
||||
const mergeTitleFieldValue = ref('');
|
||||
const mergeMessageFieldValue = ref('');
|
||||
const deleteBranchAfterMerge = ref(false);
|
||||
const autoMergeWhenSucceed = ref(false);
|
||||
|
||||
mergeStyle: '',
|
||||
mergeStyleDetail: { // dummy only, these values will come from one of the mergeForm.mergeStyles
|
||||
hideMergeMessageTexts: false,
|
||||
textDoMerge: '',
|
||||
mergeTitleFieldText: '',
|
||||
mergeMessageFieldText: '',
|
||||
hideAutoMerge: false,
|
||||
},
|
||||
mergeStyleAllowedCount: 0,
|
||||
const mergeStyle = ref('');
|
||||
const mergeStyleDetail = ref({
|
||||
hideMergeMessageTexts: false,
|
||||
textDoMerge: '',
|
||||
mergeTitleFieldText: '',
|
||||
mergeMessageFieldText: '',
|
||||
hideAutoMerge: false,
|
||||
});
|
||||
|
||||
showMergeStyleMenu: false,
|
||||
showActionForm: false,
|
||||
}),
|
||||
computed: {
|
||||
mergeButtonStyleClass() {
|
||||
if (this.mergeForm.allOverridableChecksOk) return 'primary';
|
||||
return this.autoMergeWhenSucceed ? 'primary' : 'red';
|
||||
},
|
||||
forceMerge() {
|
||||
return this.mergeForm.canMergeNow && !this.mergeForm.allOverridableChecksOk;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
mergeStyle(val) {
|
||||
this.mergeStyleDetail = this.mergeForm.mergeStyles.find((e) => e.name === val);
|
||||
for (const elem of document.querySelectorAll('[data-pull-merge-style]')) {
|
||||
toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val);
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.mergeStyleAllowedCount = this.mergeForm.mergeStyles.reduce((v, msd) => v + (msd.allowed ? 1 : 0), 0);
|
||||
const mergeStyleAllowedCount = ref(0);
|
||||
|
||||
let mergeStyle = this.mergeForm.mergeStyles.find((e) => e.allowed && e.name === this.mergeForm.defaultMergeStyle)?.name;
|
||||
if (!mergeStyle) mergeStyle = this.mergeForm.mergeStyles.find((e) => e.allowed)?.name;
|
||||
this.switchMergeStyle(mergeStyle, !this.mergeForm.canMergeNow);
|
||||
},
|
||||
mounted() {
|
||||
document.addEventListener('mouseup', this.hideMergeStyleMenu);
|
||||
},
|
||||
unmounted() {
|
||||
document.removeEventListener('mouseup', this.hideMergeStyleMenu);
|
||||
},
|
||||
methods: {
|
||||
hideMergeStyleMenu() {
|
||||
this.showMergeStyleMenu = false;
|
||||
},
|
||||
toggleActionForm(show) {
|
||||
this.showActionForm = show;
|
||||
if (!show) return;
|
||||
this.deleteBranchAfterMerge = this.mergeForm.defaultDeleteBranchAfterMerge;
|
||||
this.mergeTitleFieldValue = this.mergeStyleDetail.mergeTitleFieldText;
|
||||
this.mergeMessageFieldValue = this.mergeStyleDetail.mergeMessageFieldText;
|
||||
},
|
||||
switchMergeStyle(name, autoMerge = false) {
|
||||
this.mergeStyle = name;
|
||||
this.autoMergeWhenSucceed = autoMerge;
|
||||
},
|
||||
clearMergeMessage() {
|
||||
this.mergeMessageFieldValue = this.mergeForm.defaultMergeMessage;
|
||||
},
|
||||
},
|
||||
};
|
||||
const showMergeStyleMenu = ref(false);
|
||||
const showActionForm = ref(false);
|
||||
|
||||
const mergeButtonStyleClass = computed(() => {
|
||||
if (mergeForm.value.allOverridableChecksOk) return 'primary';
|
||||
return autoMergeWhenSucceed.value ? 'primary' : 'red';
|
||||
});
|
||||
|
||||
const forceMerge = computed(() => {
|
||||
return mergeForm.value.canMergeNow && !mergeForm.value.allOverridableChecksOk;
|
||||
});
|
||||
|
||||
watch(mergeStyle, (val) => {
|
||||
mergeStyleDetail.value = mergeForm.value.mergeStyles.find((e) => e.name === val);
|
||||
for (const elem of document.querySelectorAll('[data-pull-merge-style]')) {
|
||||
toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val);
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
mergeStyleAllowedCount.value = mergeForm.value.mergeStyles.reduce((v, msd) => v + (msd.allowed ? 1 : 0), 0);
|
||||
|
||||
let mergeStyle = mergeForm.value.mergeStyles.find((e) => e.allowed && e.name === mergeForm.value.defaultMergeStyle)?.name;
|
||||
if (!mergeStyle) mergeStyle = mergeForm.value.mergeStyles.find((e) => e.allowed)?.name;
|
||||
switchMergeStyle(mergeStyle, !mergeForm.value.canMergeNow);
|
||||
|
||||
document.addEventListener('mouseup', hideMergeStyleMenu);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('mouseup', hideMergeStyleMenu);
|
||||
});
|
||||
|
||||
function hideMergeStyleMenu() {
|
||||
showMergeStyleMenu.value = false;
|
||||
}
|
||||
|
||||
function toggleActionForm(show: boolean) {
|
||||
showActionForm.value = show;
|
||||
if (!show) return;
|
||||
deleteBranchAfterMerge.value = mergeForm.value.defaultDeleteBranchAfterMerge;
|
||||
mergeTitleFieldValue.value = mergeStyleDetail.value.mergeTitleFieldText;
|
||||
mergeMessageFieldValue.value = mergeStyleDetail.value.mergeMessageFieldText;
|
||||
}
|
||||
|
||||
function switchMergeStyle(name, autoMerge = false) {
|
||||
mergeStyle.value = name;
|
||||
autoMergeWhenSucceed.value = autoMerge;
|
||||
}
|
||||
|
||||
function clearMergeMessage() {
|
||||
mergeMessageFieldValue.value = mergeForm.value.defaultMergeMessage;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
if this component is shown, either the user is an admin (can do a merge without checks), or they are a writer who has the permission to do a merge
|
||||
@ -186,6 +185,7 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* to keep UI the same, at the moment we are still using some Fomantic UI styles, but we do not use their scripts, so we need to fine tune some styles */
|
||||
.ui.dropdown .menu.show {
|
||||
|
@ -1,68 +1,62 @@
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {VueBarGraph} from 'vue-bar-graph';
|
||||
import {createApp} from 'vue';
|
||||
import {computed, onMounted, ref} from 'vue';
|
||||
|
||||
const sfc = {
|
||||
components: {VueBarGraph},
|
||||
data: () => ({
|
||||
colors: {
|
||||
barColor: 'green',
|
||||
textColor: 'black',
|
||||
textAltColor: 'white',
|
||||
},
|
||||
const colors = ref({
|
||||
barColor: 'green',
|
||||
textColor: 'black',
|
||||
textAltColor: 'white',
|
||||
});
|
||||
|
||||
// possible keys:
|
||||
// * avatar_link: (...)
|
||||
// * commits: (...)
|
||||
// * home_link: (...)
|
||||
// * login: (...)
|
||||
// * name: (...)
|
||||
activityTopAuthors: window.config.pageData.repoActivityTopAuthors || [],
|
||||
}),
|
||||
computed: {
|
||||
graphPoints() {
|
||||
return this.activityTopAuthors.map((item) => {
|
||||
return {
|
||||
value: item.commits,
|
||||
label: item.name,
|
||||
};
|
||||
});
|
||||
},
|
||||
graphAuthors() {
|
||||
return this.activityTopAuthors.map((item, idx) => {
|
||||
return {
|
||||
position: idx + 1,
|
||||
...item,
|
||||
};
|
||||
});
|
||||
},
|
||||
graphWidth() {
|
||||
return this.activityTopAuthors.length * 40;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const refStyle = window.getComputedStyle(this.$refs.style);
|
||||
const refAltStyle = window.getComputedStyle(this.$refs.altStyle);
|
||||
// possible keys:
|
||||
// * avatar_link: (...)
|
||||
// * commits: (...)
|
||||
// * home_link: (...)
|
||||
// * login: (...)
|
||||
// * name: (...)
|
||||
const activityTopAuthors = window.config.pageData.repoActivityTopAuthors || [];
|
||||
|
||||
this.colors.barColor = refStyle.backgroundColor;
|
||||
this.colors.textColor = refStyle.color;
|
||||
this.colors.textAltColor = refAltStyle.color;
|
||||
},
|
||||
};
|
||||
const graphPoints = computed(() => {
|
||||
return activityTopAuthors.map((item) => {
|
||||
return {
|
||||
value: item.commits,
|
||||
label: item.name,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
export function initRepoActivityTopAuthorsChart() {
|
||||
const el = document.querySelector('#repo-activity-top-authors-chart');
|
||||
if (el) {
|
||||
createApp(sfc).mount(el);
|
||||
}
|
||||
}
|
||||
const graphAuthors = computed(() => {
|
||||
return activityTopAuthors.map((item, idx) => {
|
||||
return {
|
||||
position: idx + 1,
|
||||
...item,
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
export default sfc; // activate the IDE's Vue plugin
|
||||
const graphWidth = computed(() => {
|
||||
return activityTopAuthors.length * 40;
|
||||
});
|
||||
|
||||
const styleElement = ref<HTMLElement | null>(null);
|
||||
const altStyleElement = ref<HTMLElement | null>(null);
|
||||
|
||||
onMounted(() => {
|
||||
const refStyle = window.getComputedStyle(styleElement.value);
|
||||
const refAltStyle = window.getComputedStyle(altStyleElement.value);
|
||||
|
||||
colors.value = {
|
||||
barColor: refStyle.backgroundColor,
|
||||
textColor: refStyle.color,
|
||||
textAltColor: refAltStyle.color,
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="activity-bar-graph" ref="style" style="width: 0; height: 0;"/>
|
||||
<div class="activity-bar-graph-alt" ref="altStyle" style="width: 0; height: 0;"/>
|
||||
<div class="activity-bar-graph" ref="styleElement" style="width: 0; height: 0;"/>
|
||||
<div class="activity-bar-graph-alt" ref="altStyleElement" style="width: 0; height: 0;"/>
|
||||
<vue-bar-graph
|
||||
:points="graphPoints"
|
||||
:show-x-axis="true"
|
||||
|
@ -1,4 +1,4 @@
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {
|
||||
Chart,
|
||||
@ -15,10 +15,12 @@ import {
|
||||
startDaysBetween,
|
||||
firstStartDateAfterDate,
|
||||
fillEmptyStartDaysWithZeroes,
|
||||
type DayData,
|
||||
} from '../utils/time.ts';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {onMounted, ref} from 'vue';
|
||||
|
||||
const {pageData} = window.config;
|
||||
|
||||
@ -34,114 +36,110 @@ Chart.register(
|
||||
Filler,
|
||||
);
|
||||
|
||||
export default {
|
||||
components: {ChartLine, SvgIcon},
|
||||
props: {
|
||||
locale: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
isLoading: false,
|
||||
errorText: '',
|
||||
repoLink: pageData.repoLink || [],
|
||||
data: [],
|
||||
}),
|
||||
mounted() {
|
||||
this.fetchGraphData();
|
||||
},
|
||||
methods: {
|
||||
async fetchGraphData() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
let response;
|
||||
do {
|
||||
response = await GET(`${this.repoLink}/activity/code-frequency/data`);
|
||||
if (response.status === 202) {
|
||||
await sleep(1000); // wait for 1 second before retrying
|
||||
}
|
||||
} while (response.status === 202);
|
||||
if (response.ok) {
|
||||
this.data = await response.json();
|
||||
const weekValues = Object.values(this.data);
|
||||
const start = weekValues[0].week;
|
||||
const end = firstStartDateAfterDate(new Date());
|
||||
const startDays = startDaysBetween(start, end);
|
||||
this.data = fillEmptyStartDaysWithZeroes(startDays, this.data);
|
||||
this.errorText = '';
|
||||
} else {
|
||||
this.errorText = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
this.errorText = err.message;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
defineProps<{
|
||||
locale: {
|
||||
loadingTitle: string;
|
||||
loadingTitleFailed: string;
|
||||
loadingInfo: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const errorText = ref('');
|
||||
const repoLink = ref(pageData.repoLink || []);
|
||||
const data = ref<DayData[]>([]);
|
||||
|
||||
onMounted(() => {
|
||||
fetchGraphData();
|
||||
});
|
||||
|
||||
async function fetchGraphData() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
let response: Response;
|
||||
do {
|
||||
response = await GET(`${repoLink.value}/activity/code-frequency/data`);
|
||||
if (response.status === 202) {
|
||||
await sleep(1000); // wait for 1 second before retrying
|
||||
}
|
||||
},
|
||||
} while (response.status === 202);
|
||||
if (response.ok) {
|
||||
data.value = await response.json();
|
||||
const weekValues = Object.values(data.value);
|
||||
const start = weekValues[0].week;
|
||||
const end = firstStartDateAfterDate(new Date());
|
||||
const startDays = startDaysBetween(start, end);
|
||||
data.value = fillEmptyStartDaysWithZeroes(startDays, data.value);
|
||||
errorText.value = '';
|
||||
} else {
|
||||
errorText.value = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
errorText.value = err.message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
toGraphData(data) {
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
data: data.map((i) => ({x: i.week, y: i.additions})),
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 0,
|
||||
fill: true,
|
||||
label: 'Additions',
|
||||
backgroundColor: chartJsColors['additions'],
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
data: data.map((i) => ({x: i.week, y: -i.deletions})),
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 0,
|
||||
fill: true,
|
||||
label: 'Deletions',
|
||||
backgroundColor: chartJsColors['deletions'],
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
function toGraphData(data) {
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
data: data.map((i) => ({x: i.week, y: i.additions})),
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 0,
|
||||
fill: true,
|
||||
label: 'Additions',
|
||||
backgroundColor: chartJsColors['additions'],
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
},
|
||||
{
|
||||
data: data.map((i) => ({x: i.week, y: -i.deletions})),
|
||||
pointRadius: 0,
|
||||
pointHitRadius: 0,
|
||||
fill: true,
|
||||
label: 'Deletions',
|
||||
backgroundColor: chartJsColors['deletions'],
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
getOptions() {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
time: {
|
||||
minUnit: 'month',
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: 12,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
maxTicksLimit: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: true,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
time: {
|
||||
minUnit: 'month',
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: 12,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
maxTicksLimit: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="ui header tw-flex tw-items-center tw-justify-between">
|
||||
@ -160,11 +158,12 @@ export default {
|
||||
</div>
|
||||
<ChartLine
|
||||
v-memo="data" v-if="data.length !== 0"
|
||||
:data="toGraphData(data)" :options="getOptions()"
|
||||
:data="toGraphData(data)" :options="options"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.main-graph {
|
||||
height: 440px;
|
||||
|
@ -1,4 +1,4 @@
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {SvgIcon} from '../svg.ts';
|
||||
import {
|
||||
Chart,
|
||||
@ -6,6 +6,7 @@ import {
|
||||
BarElement,
|
||||
LinearScale,
|
||||
TimeScale,
|
||||
type ChartOptions,
|
||||
} from 'chart.js';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import {Bar} from 'vue-chartjs';
|
||||
@ -13,10 +14,12 @@ import {
|
||||
startDaysBetween,
|
||||
firstStartDateAfterDate,
|
||||
fillEmptyStartDaysWithZeroes,
|
||||
type DayData,
|
||||
} from '../utils/time.ts';
|
||||
import {chartJsColors} from '../utils/color.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
|
||||
import {onMounted, ref} from 'vue';
|
||||
|
||||
const {pageData} = window.config;
|
||||
|
||||
@ -30,95 +33,91 @@ Chart.register(
|
||||
Tooltip,
|
||||
);
|
||||
|
||||
export default {
|
||||
components: {Bar, SvgIcon},
|
||||
props: {
|
||||
locale: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
data: () => ({
|
||||
isLoading: false,
|
||||
errorText: '',
|
||||
repoLink: pageData.repoLink || [],
|
||||
data: [],
|
||||
}),
|
||||
mounted() {
|
||||
this.fetchGraphData();
|
||||
},
|
||||
methods: {
|
||||
async fetchGraphData() {
|
||||
this.isLoading = true;
|
||||
try {
|
||||
let response;
|
||||
do {
|
||||
response = await GET(`${this.repoLink}/activity/recent-commits/data`);
|
||||
if (response.status === 202) {
|
||||
await sleep(1000); // wait for 1 second before retrying
|
||||
}
|
||||
} while (response.status === 202);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const start = Object.values(data)[0].week;
|
||||
const end = firstStartDateAfterDate(new Date());
|
||||
const startDays = startDaysBetween(start, end);
|
||||
this.data = fillEmptyStartDaysWithZeroes(startDays, data).slice(-52);
|
||||
this.errorText = '';
|
||||
} else {
|
||||
this.errorText = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
this.errorText = err.message;
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
defineProps<{
|
||||
locale: {
|
||||
loadingTitle: string;
|
||||
loadingTitleFailed: string;
|
||||
loadingInfo: string;
|
||||
};
|
||||
}>();
|
||||
|
||||
const isLoading = ref(false);
|
||||
const errorText = ref('');
|
||||
const repoLink = ref(pageData.repoLink || []);
|
||||
const data = ref<DayData[]>([]);
|
||||
|
||||
onMounted(() => {
|
||||
fetchGraphData();
|
||||
});
|
||||
|
||||
async function fetchGraphData() {
|
||||
isLoading.value = true;
|
||||
try {
|
||||
let response: Response;
|
||||
do {
|
||||
response = await GET(`${repoLink.value}/activity/recent-commits/data`);
|
||||
if (response.status === 202) {
|
||||
await sleep(1000); // wait for 1 second before retrying
|
||||
}
|
||||
},
|
||||
} while (response.status === 202);
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
const start = Object.values(data)[0].week;
|
||||
const end = firstStartDateAfterDate(new Date());
|
||||
const startDays = startDaysBetween(start, end);
|
||||
data.value = fillEmptyStartDaysWithZeroes(startDays, data).slice(-52);
|
||||
errorText.value = '';
|
||||
} else {
|
||||
errorText.value = response.statusText;
|
||||
}
|
||||
} catch (err) {
|
||||
errorText.value = err.message;
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
toGraphData(data) {
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
data: data.map((i) => ({x: i.week, y: i.commits})),
|
||||
label: 'Commits',
|
||||
backgroundColor: chartJsColors['commits'],
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
function toGraphData(data) {
|
||||
return {
|
||||
datasets: [
|
||||
{
|
||||
data: data.map((i) => ({x: i.week, y: i.commits})),
|
||||
label: 'Commits',
|
||||
backgroundColor: chartJsColors['commits'],
|
||||
borderWidth: 0,
|
||||
tension: 0.3,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
getOptions() {
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: true,
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
time: {
|
||||
minUnit: 'week',
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: 52,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
maxTicksLimit: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: true,
|
||||
scales: {
|
||||
x: {
|
||||
type: 'time',
|
||||
grid: {
|
||||
display: false,
|
||||
},
|
||||
time: {
|
||||
minUnit: 'week',
|
||||
},
|
||||
ticks: {
|
||||
maxRotation: 0,
|
||||
maxTicksLimit: 52,
|
||||
},
|
||||
},
|
||||
y: {
|
||||
ticks: {
|
||||
maxTicksLimit: 6,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
} satisfies ChartOptions;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="ui header tw-flex tw-items-center tw-justify-between">
|
||||
@ -137,7 +136,7 @@ export default {
|
||||
</div>
|
||||
<Bar
|
||||
v-memo="data" v-if="data.length !== 0"
|
||||
:data="toGraphData(data)" :options="getOptions()"
|
||||
:data="toGraphData(data)" :options="options"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,78 +1,60 @@
|
||||
<script lang="ts">
|
||||
<script lang="ts" setup>
|
||||
import {computed, onMounted, onUnmounted} from 'vue';
|
||||
import {hideElem, showElem} from '../utils/dom.ts';
|
||||
|
||||
const sfc = {
|
||||
props: {
|
||||
isAdmin: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
noAccessLabel: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
readLabel: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
writeLabel: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
const props = defineProps<{
|
||||
isAdmin: boolean;
|
||||
noAccessLabel: string;
|
||||
readLabel: string;
|
||||
writeLabel: string;
|
||||
}>();
|
||||
|
||||
computed: {
|
||||
categories() {
|
||||
const categories = [
|
||||
'activitypub',
|
||||
];
|
||||
if (this.isAdmin) {
|
||||
categories.push('admin');
|
||||
}
|
||||
categories.push(
|
||||
'issue',
|
||||
'misc',
|
||||
'notification',
|
||||
'organization',
|
||||
'package',
|
||||
'repository',
|
||||
'user');
|
||||
return categories;
|
||||
},
|
||||
},
|
||||
const categories = computed(() => {
|
||||
const categories = [
|
||||
'activitypub',
|
||||
];
|
||||
if (props.isAdmin) {
|
||||
categories.push('admin');
|
||||
}
|
||||
categories.push(
|
||||
'issue',
|
||||
'misc',
|
||||
'notification',
|
||||
'organization',
|
||||
'package',
|
||||
'repository',
|
||||
'user');
|
||||
return categories;
|
||||
});
|
||||
|
||||
mounted() {
|
||||
document.querySelector('#scoped-access-submit').addEventListener('click', this.onClickSubmit);
|
||||
},
|
||||
onMounted(() => {
|
||||
document.querySelector('#scoped-access-submit').addEventListener('click', onClickSubmit);
|
||||
});
|
||||
|
||||
unmounted() {
|
||||
document.querySelector('#scoped-access-submit').removeEventListener('click', this.onClickSubmit);
|
||||
},
|
||||
onUnmounted(() => {
|
||||
document.querySelector('#scoped-access-submit').removeEventListener('click', onClickSubmit);
|
||||
});
|
||||
|
||||
methods: {
|
||||
onClickSubmit(e) {
|
||||
e.preventDefault();
|
||||
function onClickSubmit(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const warningEl = document.querySelector('#scoped-access-warning');
|
||||
// check that at least one scope has been selected
|
||||
for (const el of document.querySelectorAll('.access-token-select')) {
|
||||
if (el.value) {
|
||||
// Hide the error if it was visible from previous attempt.
|
||||
hideElem(warningEl);
|
||||
// Submit the form.
|
||||
document.querySelector('#scoped-access-form').submit();
|
||||
// Don't show the warning.
|
||||
return;
|
||||
}
|
||||
}
|
||||
// no scopes selected, show validation error
|
||||
showElem(warningEl);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default sfc;
|
||||
const warningEl = document.querySelector('#scoped-access-warning');
|
||||
// check that at least one scope has been selected
|
||||
for (const el of document.querySelectorAll<HTMLInputElement>('.access-token-select')) {
|
||||
if (el.value) {
|
||||
// Hide the error if it was visible from previous attempt.
|
||||
hideElem(warningEl);
|
||||
// Submit the form.
|
||||
document.querySelector<HTMLFormElement>('#scoped-access-form').submit();
|
||||
// Don't show the warning.
|
||||
return;
|
||||
}
|
||||
}
|
||||
// no scopes selected, show validation error
|
||||
showElem(warningEl);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-for="category in categories" :key="category" class="field tw-pl-1 tw-pb-1 access-token-category">
|
||||
<label class="category-label" :for="'access-token-scope-' + category">
|
||||
|
@ -15,8 +15,14 @@ import {easyMDEToolbarActions} from './EasyMDEToolbarActions.ts';
|
||||
import {initTextExpander} from './TextExpander.ts';
|
||||
import {showErrorToast} from '../../modules/toast.ts';
|
||||
import {POST} from '../../modules/fetch.ts';
|
||||
import {EventEditorContentChanged, initTextareaMarkdown, triggerEditorContentChanged} from './EditorMarkdown.ts';
|
||||
import {
|
||||
EventEditorContentChanged,
|
||||
initTextareaMarkdown,
|
||||
textareaInsertText,
|
||||
triggerEditorContentChanged,
|
||||
} from './EditorMarkdown.ts';
|
||||
import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
|
||||
import {createTippy} from '../../modules/tippy.ts';
|
||||
|
||||
let elementIdCounter = 0;
|
||||
|
||||
@ -122,8 +128,7 @@ export class ComboMarkdownEditor {
|
||||
const monospaceText = monospaceButton.getAttribute(monospaceEnabled ? 'data-disable-text' : 'data-enable-text');
|
||||
monospaceButton.setAttribute('data-tooltip-content', monospaceText);
|
||||
monospaceButton.setAttribute('aria-checked', String(monospaceEnabled));
|
||||
|
||||
monospaceButton?.addEventListener('click', (e) => {
|
||||
monospaceButton.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
const enabled = localStorage?.getItem('markdown-editor-monospace') !== 'true';
|
||||
localStorage.setItem('markdown-editor-monospace', String(enabled));
|
||||
@ -134,12 +139,14 @@ export class ComboMarkdownEditor {
|
||||
});
|
||||
|
||||
const easymdeButton = this.container.querySelector('.markdown-switch-easymde');
|
||||
easymdeButton?.addEventListener('click', async (e) => {
|
||||
easymdeButton.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
this.userPreferredEditor = 'easymde';
|
||||
await this.switchToEasyMDE();
|
||||
});
|
||||
|
||||
this.initMarkdownButtonTableAdd();
|
||||
|
||||
initTextareaMarkdown(this.textarea);
|
||||
initTextareaEvents(this.textarea, this.dropzone);
|
||||
}
|
||||
@ -219,6 +226,42 @@ export class ComboMarkdownEditor {
|
||||
});
|
||||
}
|
||||
|
||||
generateMarkdownTable(rows: number, cols: number): string {
|
||||
const tableLines = [];
|
||||
tableLines.push(
|
||||
`| ${'Header '.repeat(cols).trim().split(' ').join(' | ')} |`,
|
||||
`| ${'--- '.repeat(cols).trim().split(' ').join(' | ')} |`,
|
||||
);
|
||||
for (let i = 0; i < rows; i++) {
|
||||
tableLines.push(`| ${'Cell '.repeat(cols).trim().split(' ').join(' | ')} |`);
|
||||
}
|
||||
return tableLines.join('\n');
|
||||
}
|
||||
|
||||
initMarkdownButtonTableAdd() {
|
||||
const addTableButton = this.container.querySelector('.markdown-button-table-add');
|
||||
const addTablePanel = this.container.querySelector('.markdown-add-table-panel');
|
||||
// here the tippy can't attach to the button because the button already owns a tippy for tooltip
|
||||
const addTablePanelTippy = createTippy(addTablePanel, {
|
||||
content: addTablePanel,
|
||||
trigger: 'manual',
|
||||
placement: 'bottom',
|
||||
hideOnClick: true,
|
||||
interactive: true,
|
||||
getReferenceClientRect: () => addTableButton.getBoundingClientRect(),
|
||||
});
|
||||
addTableButton.addEventListener('click', () => addTablePanelTippy.show());
|
||||
|
||||
addTablePanel.querySelector('.ui.button.primary').addEventListener('click', () => {
|
||||
let rows = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=rows]').value);
|
||||
let cols = parseInt(addTablePanel.querySelector<HTMLInputElement>('[name=cols]').value);
|
||||
rows = Math.max(1, Math.min(100, rows));
|
||||
cols = Math.max(1, Math.min(100, cols));
|
||||
textareaInsertText(this.textarea, `\n${this.generateMarkdownTable(rows, cols)}\n\n`);
|
||||
addTablePanelTippy.hide();
|
||||
});
|
||||
}
|
||||
|
||||
switchTabToEditor() {
|
||||
this.tabEditor.click();
|
||||
}
|
||||
|
27
web_src/js/features/comp/EditorMarkdown.test.ts
Normal file
27
web_src/js/features/comp/EditorMarkdown.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import {initTextareaMarkdown} from './EditorMarkdown.ts';
|
||||
|
||||
test('EditorMarkdown', () => {
|
||||
const textarea = document.createElement('textarea');
|
||||
initTextareaMarkdown(textarea);
|
||||
|
||||
const testInput = (value, expected) => {
|
||||
textarea.value = value;
|
||||
textarea.setSelectionRange(value.length, value.length);
|
||||
const e = new KeyboardEvent('keydown', {key: 'Enter', cancelable: true});
|
||||
textarea.dispatchEvent(e);
|
||||
if (!e.defaultPrevented) textarea.value += '\n';
|
||||
expect(textarea.value).toEqual(expected);
|
||||
};
|
||||
|
||||
testInput('-', '-\n');
|
||||
testInput('1.', '1.\n');
|
||||
|
||||
testInput('- ', '');
|
||||
testInput('1. ', '');
|
||||
|
||||
testInput('- x', '- x\n- ');
|
||||
testInput('- [ ]', '- [ ]\n- ');
|
||||
testInput('- [ ] foo', '- [ ] foo\n- [ ] ');
|
||||
testInput('* [x] foo', '* [x] foo\n* [ ] ');
|
||||
testInput('1. [x] foo', '1. [x] foo\n1. [ ] ');
|
||||
});
|
@ -4,6 +4,16 @@ export function triggerEditorContentChanged(target) {
|
||||
target.dispatchEvent(new CustomEvent(EventEditorContentChanged, {bubbles: true}));
|
||||
}
|
||||
|
||||
export function textareaInsertText(textarea, value) {
|
||||
const startPos = textarea.selectionStart;
|
||||
const endPos = textarea.selectionEnd;
|
||||
textarea.value = textarea.value.substring(0, startPos) + value + textarea.value.substring(endPos);
|
||||
textarea.selectionStart = startPos;
|
||||
textarea.selectionEnd = startPos + value.length;
|
||||
textarea.focus();
|
||||
triggerEditorContentChanged(textarea);
|
||||
}
|
||||
|
||||
function handleIndentSelection(textarea, e) {
|
||||
const selStart = textarea.selectionStart;
|
||||
const selEnd = textarea.selectionEnd;
|
||||
@ -46,7 +56,7 @@ function handleIndentSelection(textarea, e) {
|
||||
triggerEditorContentChanged(textarea);
|
||||
}
|
||||
|
||||
function handleNewline(textarea, e) {
|
||||
function handleNewline(textarea: HTMLTextAreaElement, e: Event) {
|
||||
const selStart = textarea.selectionStart;
|
||||
const selEnd = textarea.selectionEnd;
|
||||
if (selEnd !== selStart) return; // do not process when there is a selection
|
||||
@ -66,9 +76,9 @@ function handleNewline(textarea, e) {
|
||||
const indention = /^\s*/.exec(line)[0];
|
||||
line = line.slice(indention.length);
|
||||
|
||||
// parse the prefixes: "1. ", "- ", "* ", "[ ] ", "[x] "
|
||||
// parse the prefixes: "1. ", "- ", "* ", there could also be " [ ] " or " [x] " for task lists
|
||||
// there must be a space after the prefix because none of "1.foo" / "-foo" is a list item
|
||||
const prefixMatch = /^([0-9]+\.|[-*]|\[ \]|\[x\])\s/.exec(line);
|
||||
const prefixMatch = /^([0-9]+\.|[-*])(\s\[([ x])\])?\s/.exec(line);
|
||||
let prefix = '';
|
||||
if (prefixMatch) {
|
||||
prefix = prefixMatch[0];
|
||||
@ -85,8 +95,9 @@ function handleNewline(textarea, e) {
|
||||
} else {
|
||||
// start a new line with the same indention and prefix
|
||||
let newPrefix = prefix;
|
||||
if (newPrefix === '[x]') newPrefix = '[ ]';
|
||||
if (/^\d+\./.test(newPrefix)) newPrefix = `1. `; // a simple approach, otherwise it needs to parse the lines after the current line
|
||||
// a simple approach, otherwise it needs to parse the lines after the current line
|
||||
if (/^\d+\./.test(prefix)) newPrefix = `1. ${newPrefix.slice(newPrefix.indexOf('.') + 2)}`;
|
||||
newPrefix = newPrefix.replace('[x]', '[ ]');
|
||||
const newLine = `\n${indention}${newPrefix}`;
|
||||
textarea.value = value.slice(0, selStart) + newLine + value.slice(selEnd);
|
||||
textarea.setSelectionRange(selStart + newLine.length, selStart + newLine.length);
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {imageInfo} from '../../utils/image.ts';
|
||||
import {replaceTextareaSelection} from '../../utils/dom.ts';
|
||||
import {isUrl} from '../../utils/url.ts';
|
||||
import {triggerEditorContentChanged} from './EditorMarkdown.ts';
|
||||
import {textareaInsertText, triggerEditorContentChanged} from './EditorMarkdown.ts';
|
||||
import {
|
||||
DropzoneCustomEventRemovedFile,
|
||||
DropzoneCustomEventUploadDone,
|
||||
@ -41,14 +41,7 @@ class TextareaEditor {
|
||||
}
|
||||
|
||||
insertPlaceholder(value) {
|
||||
const editor = this.editor;
|
||||
const startPos = editor.selectionStart;
|
||||
const endPos = editor.selectionEnd;
|
||||
editor.value = editor.value.substring(0, startPos) + value + editor.value.substring(endPos);
|
||||
editor.selectionStart = startPos;
|
||||
editor.selectionEnd = startPos + value.length;
|
||||
editor.focus();
|
||||
triggerEditorContentChanged(editor);
|
||||
textareaInsertText(this.editor, value);
|
||||
}
|
||||
|
||||
replacePlaceholder(oldVal, newVal) {
|
||||
|
@ -1,5 +1,31 @@
|
||||
import {matchEmoji, matchMention} from '../../utils/match.ts';
|
||||
import {matchEmoji, matchMention, matchIssue} from '../../utils/match.ts';
|
||||
import {emojiString} from '../emoji.ts';
|
||||
import {svg} from '../../svg.ts';
|
||||
import {parseIssueHref, parseIssueNewHref} from '../../utils.ts';
|
||||
import {createElementFromAttrs, createElementFromHTML} from '../../utils/dom.ts';
|
||||
import {getIssueColor, getIssueIcon} from '../issue.ts';
|
||||
import {debounce} from 'perfect-debounce';
|
||||
|
||||
const debouncedSuggestIssues = debounce((key: string, text: string) => new Promise<{matched:boolean; fragment?: HTMLElement}>(async (resolve) => {
|
||||
let issuePathInfo = parseIssueHref(window.location.href);
|
||||
if (!issuePathInfo.ownerName) issuePathInfo = parseIssueNewHref(window.location.href);
|
||||
if (!issuePathInfo.ownerName) return resolve({matched: false});
|
||||
|
||||
const matches = await matchIssue(issuePathInfo.ownerName, issuePathInfo.repoName, issuePathInfo.indexString, text);
|
||||
if (!matches.length) return resolve({matched: false});
|
||||
|
||||
const ul = createElementFromAttrs('ul', {class: 'suggestions'});
|
||||
for (const issue of matches) {
|
||||
const li = createElementFromAttrs(
|
||||
'li', {role: 'option', class: 'tw-flex tw-gap-2', 'data-value': `${key}${issue.number}`},
|
||||
createElementFromHTML(svg(getIssueIcon(issue), 16, ['text', getIssueColor(issue)])),
|
||||
createElementFromAttrs('span', null, `#${issue.number}`),
|
||||
createElementFromAttrs('span', null, issue.title),
|
||||
);
|
||||
ul.append(li);
|
||||
}
|
||||
resolve({matched: true, fragment: ul});
|
||||
}), 100);
|
||||
|
||||
export function initTextExpander(expander) {
|
||||
expander?.addEventListener('text-expander-change', ({detail: {key, provide, text}}) => {
|
||||
@ -49,12 +75,14 @@ export function initTextExpander(expander) {
|
||||
}
|
||||
|
||||
provide({matched: true, fragment: ul});
|
||||
} else if (key === '#') {
|
||||
provide(debouncedSuggestIssues(key, text));
|
||||
}
|
||||
});
|
||||
expander?.addEventListener('text-expander-value', ({detail}) => {
|
||||
if (detail?.item) {
|
||||
// add a space after @mentions as it's likely the user wants one
|
||||
const suffix = detail.key === '@' ? ' ' : '';
|
||||
// add a space after @mentions and #issue as it's likely the user wants one
|
||||
const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
|
||||
detail.value = `${detail.item.getAttribute('data-value')}${suffix}`;
|
||||
}
|
||||
});
|
||||
|
@ -10,12 +10,10 @@ export function initContextPopups() {
|
||||
|
||||
export function attachRefIssueContextPopup(refIssues) {
|
||||
for (const refIssue of refIssues) {
|
||||
if (refIssue.classList.contains('ref-external-issue')) {
|
||||
return;
|
||||
}
|
||||
if (refIssue.classList.contains('ref-external-issue')) continue;
|
||||
|
||||
const {owner, repo, index} = parseIssueHref(refIssue.getAttribute('href'));
|
||||
if (!owner) return;
|
||||
const issuePathInfo = parseIssueHref(refIssue.getAttribute('href'));
|
||||
if (!issuePathInfo.ownerName) continue;
|
||||
|
||||
const el = document.createElement('div');
|
||||
el.classList.add('tw-p-3');
|
||||
@ -38,7 +36,7 @@ export function attachRefIssueContextPopup(refIssues) {
|
||||
role: 'dialog',
|
||||
interactiveBorder: 5,
|
||||
onShow: () => {
|
||||
el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: {owner, repo, index}}));
|
||||
el.firstChild.dispatchEvent(new CustomEvent('ce-load-context-popup', {detail: issuePathInfo}));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
32
web_src/js/features/issue.ts
Normal file
32
web_src/js/features/issue.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import type {Issue} from '../types.ts';
|
||||
|
||||
export function getIssueIcon(issue: Issue) {
|
||||
if (issue.pull_request) {
|
||||
if (issue.state === 'open') {
|
||||
if (issue.pull_request.draft === true) {
|
||||
return 'octicon-git-pull-request-draft'; // WIP PR
|
||||
}
|
||||
return 'octicon-git-pull-request'; // Open PR
|
||||
} else if (issue.pull_request.merged === true) {
|
||||
return 'octicon-git-merge'; // Merged PR
|
||||
}
|
||||
return 'octicon-git-pull-request'; // Closed PR
|
||||
} else if (issue.state === 'open') {
|
||||
return 'octicon-issue-opened'; // Open Issue
|
||||
}
|
||||
return 'octicon-issue-closed'; // Closed Issue
|
||||
}
|
||||
|
||||
export function getIssueColor(issue: Issue) {
|
||||
if (issue.pull_request) {
|
||||
if (issue.pull_request.draft === true) {
|
||||
return 'grey'; // WIP PR
|
||||
} else if (issue.pull_request.merged === true) {
|
||||
return 'purple'; // Merged PR
|
||||
}
|
||||
}
|
||||
if (issue.state === 'open') {
|
||||
return 'green'; // Open Issue
|
||||
}
|
||||
return 'red'; // Closed Issue
|
||||
}
|
@ -3,6 +3,8 @@ import {hideElem, queryElems, showElem} from '../utils/dom.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {sleep} from '../utils.ts';
|
||||
import RepoActivityTopAuthors from '../components/RepoActivityTopAuthors.vue';
|
||||
import {createApp} from 'vue';
|
||||
|
||||
async function onDownloadArchive(e) {
|
||||
e.preventDefault();
|
||||
@ -32,6 +34,13 @@ export function initRepoArchiveLinks() {
|
||||
queryElems('a.archive-link[href]', (el) => el.addEventListener('click', onDownloadArchive));
|
||||
}
|
||||
|
||||
export function initRepoActivityTopAuthorsChart() {
|
||||
const el = document.querySelector('#repo-activity-top-authors-chart');
|
||||
if (el) {
|
||||
createApp(RepoActivityTopAuthors).mount(el);
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoCloneLink() {
|
||||
const $repoCloneSsh = $('#repo-clone-ssh');
|
||||
const $repoCloneHttps = $('#repo-clone-https');
|
||||
@ -81,3 +90,14 @@ export function initRepoCommonFilterSearchDropdown(selector) {
|
||||
message: {noResults: $dropdown[0].getAttribute('data-no-results')},
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateIssuesMeta(url, action, issue_ids, id) {
|
||||
try {
|
||||
const response = await POST(url, {data: new URLSearchParams({action, issue_ids, id})});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update issues meta');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import $ from 'jquery';
|
||||
import {updateIssuesMeta} from './repo-issue.ts';
|
||||
import {updateIssuesMeta} from './repo-common.ts';
|
||||
import {toggleElem, hideElem, isElemHidden} from '../utils/dom.ts';
|
||||
import {htmlEscape} from 'escape-goat';
|
||||
import {confirmModal} from './comp/ConfirmModal.ts';
|
||||
|
274
web_src/js/features/repo-issue-sidebar.ts
Normal file
274
web_src/js/features/repo-issue-sidebar.ts
Normal file
@ -0,0 +1,274 @@
|
||||
import $ from 'jquery';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {updateIssuesMeta} from './repo-common.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
import {htmlEscape} from 'escape-goat';
|
||||
|
||||
// if there are draft comments, confirm before reloading, to avoid losing comments
|
||||
function reloadConfirmDraftComment() {
|
||||
const commentTextareas = [
|
||||
document.querySelector('.edit-content-zone:not(.tw-hidden) textarea'),
|
||||
document.querySelector('#comment-form textarea'),
|
||||
];
|
||||
for (const textarea of commentTextareas) {
|
||||
// Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
|
||||
// But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
|
||||
if (textarea && textarea.value.trim().length > 10) {
|
||||
textarea.parentElement.scrollIntoView();
|
||||
if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function initBranchSelector() {
|
||||
const elSelectBranch = document.querySelector('.ui.dropdown.select-branch');
|
||||
if (!elSelectBranch) return;
|
||||
|
||||
const urlUpdateIssueRef = elSelectBranch.getAttribute('data-url-update-issueref');
|
||||
const $selectBranch = $(elSelectBranch);
|
||||
const $branchMenu = $selectBranch.find('.reference-list-menu');
|
||||
$branchMenu.find('.item:not(.no-select)').on('click', async function (e) {
|
||||
e.preventDefault();
|
||||
const selectedValue = this.getAttribute('data-id'); // eg: "refs/heads/my-branch"
|
||||
const selectedText = this.getAttribute('data-name'); // eg: "my-branch"
|
||||
if (urlUpdateIssueRef) {
|
||||
// for existing issue, send request to update issue ref, and reload page
|
||||
try {
|
||||
await POST(urlUpdateIssueRef, {data: new URLSearchParams({ref: selectedValue})});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
// for new issue, only update UI&form, do not send request/reload
|
||||
const selectedHiddenSelector = this.getAttribute('data-id-selector');
|
||||
document.querySelector(selectedHiddenSelector).value = selectedValue;
|
||||
elSelectBranch.querySelector('.text-branch-name').textContent = selectedText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// List submits
|
||||
function initListSubmits(selector, outerSelector) {
|
||||
const $list = $(`.ui.${outerSelector}.list`);
|
||||
const $noSelect = $list.find('.no-select');
|
||||
const $listMenu = $(`.${selector} .menu`);
|
||||
let hasUpdateAction = $listMenu.data('action') === 'update';
|
||||
const items = {};
|
||||
|
||||
$(`.${selector}`).dropdown({
|
||||
'action': 'nothing', // do not hide the menu if user presses Enter
|
||||
fullTextSearch: 'exact',
|
||||
async onHide() {
|
||||
hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var
|
||||
if (hasUpdateAction) {
|
||||
// TODO: Add batch functionality and make this 1 network request.
|
||||
const itemEntries = Object.entries(items);
|
||||
for (const [elementId, item] of itemEntries) {
|
||||
await updateIssuesMeta(
|
||||
item['update-url'],
|
||||
item.action,
|
||||
item['issue-id'],
|
||||
elementId,
|
||||
);
|
||||
}
|
||||
if (itemEntries.length) {
|
||||
reloadConfirmDraftComment();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
$listMenu.find('.item:not(.no-select)').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (this.classList.contains('ban-change')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var
|
||||
|
||||
const clickedItem = this; // eslint-disable-line unicorn/no-this-assignment
|
||||
const scope = this.getAttribute('data-scope');
|
||||
|
||||
$(this).parent().find('.item').each(function () {
|
||||
if (scope) {
|
||||
// Enable only clicked item for scoped labels
|
||||
if (this.getAttribute('data-scope') !== scope) {
|
||||
return true;
|
||||
}
|
||||
if (this !== clickedItem && !this.classList.contains('checked')) {
|
||||
return true;
|
||||
}
|
||||
} else if (this !== clickedItem) {
|
||||
// Toggle for other labels
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.classList.contains('checked')) {
|
||||
$(this).removeClass('checked');
|
||||
$(this).find('.octicon-check').addClass('tw-invisible');
|
||||
if (hasUpdateAction) {
|
||||
if (!($(this).data('id') in items)) {
|
||||
items[$(this).data('id')] = {
|
||||
'update-url': $listMenu.data('update-url'),
|
||||
action: 'detach',
|
||||
'issue-id': $listMenu.data('issue-id'),
|
||||
};
|
||||
} else {
|
||||
delete items[$(this).data('id')];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$(this).addClass('checked');
|
||||
$(this).find('.octicon-check').removeClass('tw-invisible');
|
||||
if (hasUpdateAction) {
|
||||
if (!($(this).data('id') in items)) {
|
||||
items[$(this).data('id')] = {
|
||||
'update-url': $listMenu.data('update-url'),
|
||||
action: 'attach',
|
||||
'issue-id': $listMenu.data('issue-id'),
|
||||
};
|
||||
} else {
|
||||
delete items[$(this).data('id')];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Which thing should be done for choosing review requests
|
||||
// to make chosen items be shown on time here?
|
||||
if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const listIds = [];
|
||||
$(this).parent().find('.item').each(function () {
|
||||
if (this.classList.contains('checked')) {
|
||||
listIds.push($(this).data('id'));
|
||||
$($(this).data('id-selector')).removeClass('tw-hidden');
|
||||
} else {
|
||||
$($(this).data('id-selector')).addClass('tw-hidden');
|
||||
}
|
||||
});
|
||||
if (!listIds.length) {
|
||||
$noSelect.removeClass('tw-hidden');
|
||||
} else {
|
||||
$noSelect.addClass('tw-hidden');
|
||||
}
|
||||
$($(this).parent().data('id')).val(listIds.join(','));
|
||||
return false;
|
||||
});
|
||||
$listMenu.find('.no-select.item').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (hasUpdateAction) {
|
||||
(async () => {
|
||||
await updateIssuesMeta(
|
||||
$listMenu.data('update-url'),
|
||||
'clear',
|
||||
$listMenu.data('issue-id'),
|
||||
'',
|
||||
);
|
||||
reloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
$(this).parent().find('.item').each(function () {
|
||||
$(this).removeClass('checked');
|
||||
$(this).find('.octicon-check').addClass('tw-invisible');
|
||||
});
|
||||
|
||||
if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$list.find('.item').each(function () {
|
||||
$(this).addClass('tw-hidden');
|
||||
});
|
||||
$noSelect.removeClass('tw-hidden');
|
||||
$($(this).parent().data('id')).val('');
|
||||
});
|
||||
}
|
||||
|
||||
function selectItem(select_id, input_id) {
|
||||
const $menu = $(`${select_id} .menu`);
|
||||
const $list = $(`.ui${select_id}.list`);
|
||||
const hasUpdateAction = $menu.data('action') === 'update';
|
||||
|
||||
$menu.find('.item:not(.no-select)').on('click', function () {
|
||||
$(this).parent().find('.item').each(function () {
|
||||
$(this).removeClass('selected active');
|
||||
});
|
||||
|
||||
$(this).addClass('selected active');
|
||||
if (hasUpdateAction) {
|
||||
(async () => {
|
||||
await updateIssuesMeta(
|
||||
$menu.data('update-url'),
|
||||
'',
|
||||
$menu.data('issue-id'),
|
||||
$(this).data('id'),
|
||||
);
|
||||
reloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
let icon = '';
|
||||
if (input_id === '#milestone_id') {
|
||||
icon = svg('octicon-milestone', 18, 'tw-mr-2');
|
||||
} else if (input_id === '#project_id') {
|
||||
icon = svg('octicon-project', 18, 'tw-mr-2');
|
||||
} else if (input_id === '#assignee_id') {
|
||||
icon = `<img class="ui avatar image tw-mr-2" alt="avatar" src=${$(this).data('avatar')}>`;
|
||||
}
|
||||
|
||||
$list.find('.selected').html(`
|
||||
<a class="item muted sidebar-item-link" href="${htmlEscape(this.getAttribute('data-href'))}">
|
||||
${icon}
|
||||
${htmlEscape(this.textContent)}
|
||||
</a>
|
||||
`);
|
||||
|
||||
$(`.ui${select_id}.list .no-select`).addClass('tw-hidden');
|
||||
$(input_id).val($(this).data('id'));
|
||||
});
|
||||
$menu.find('.no-select.item').on('click', function () {
|
||||
$(this).parent().find('.item:not(.no-select)').each(function () {
|
||||
$(this).removeClass('selected active');
|
||||
});
|
||||
|
||||
if (hasUpdateAction) {
|
||||
(async () => {
|
||||
await updateIssuesMeta(
|
||||
$menu.data('update-url'),
|
||||
'',
|
||||
$menu.data('issue-id'),
|
||||
$(this).data('id'),
|
||||
);
|
||||
reloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
$list.find('.selected').html('');
|
||||
$list.find('.no-select').removeClass('tw-hidden');
|
||||
$(input_id).val('');
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoIssueSidebar() {
|
||||
initBranchSelector();
|
||||
|
||||
// Init labels and assignees
|
||||
initListSubmits('select-label', 'labels');
|
||||
initListSubmits('select-assignees', 'assignees');
|
||||
initListSubmits('select-assignees-modify', 'assignees');
|
||||
initListSubmits('select-reviewers-modify', 'assignees');
|
||||
|
||||
// Milestone, Assignee, Project
|
||||
selectItem('.select-project', '#project_id');
|
||||
selectItem('.select-milestone', '#milestone_id');
|
||||
selectItem('.select-assignee', '#assignee_id');
|
||||
}
|
@ -7,6 +7,8 @@ import {ComboMarkdownEditor, getComboMarkdownEditor, initComboMarkdownEditor} fr
|
||||
import {toAbsoluteUrl} from '../utils.ts';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import {showErrorToast} from '../modules/toast.ts';
|
||||
import {initRepoIssueSidebar} from './repo-issue-sidebar.ts';
|
||||
import {updateIssuesMeta} from './repo-common.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
@ -97,7 +99,7 @@ function excludeLabel(item) {
|
||||
const regStr = `labels=((?:-?[0-9]+%2c)*)(${id})((?:%2c-?[0-9]+)*)&`;
|
||||
const newStr = 'labels=$1-$2$3&';
|
||||
|
||||
window.location = href.replace(new RegExp(regStr), newStr);
|
||||
window.location.assign(href.replace(new RegExp(regStr), newStr));
|
||||
}
|
||||
|
||||
export function initRepoIssueSidebarList() {
|
||||
@ -369,17 +371,6 @@ export function initRepoIssueWipTitle() {
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateIssuesMeta(url, action, issue_ids, id) {
|
||||
try {
|
||||
const response = await POST(url, {data: new URLSearchParams({action, issue_ids, id})});
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update issues meta');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoIssueComments() {
|
||||
if (!$('.repository.view.issue .timeline').length) return;
|
||||
|
||||
@ -665,7 +656,7 @@ export function initRepoIssueBranchSelect() {
|
||||
});
|
||||
}
|
||||
|
||||
export async function initSingleCommentEditor($commentForm) {
|
||||
async function initSingleCommentEditor($commentForm) {
|
||||
// pages:
|
||||
// * normal new issue/pr page: no status-button, no comment-button (there is only a normal submit button which can submit empty content)
|
||||
// * issue/pr view page: with comment form, has status-button and comment-button
|
||||
@ -687,7 +678,7 @@ export async function initSingleCommentEditor($commentForm) {
|
||||
syncUiState();
|
||||
}
|
||||
|
||||
export function initIssueTemplateCommentEditors($commentForm) {
|
||||
function initIssueTemplateCommentEditors($commentForm) {
|
||||
// pages:
|
||||
// * new issue with issue template
|
||||
const $comboFields = $commentForm.find('.combo-editor-dropzone');
|
||||
@ -733,3 +724,18 @@ export function initArchivedLabelHandler() {
|
||||
toggleElem(label, label.classList.contains('checked'));
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoCommentFormAndSidebar() {
|
||||
const $commentForm = $('.comment.form');
|
||||
if (!$commentForm.length) return;
|
||||
|
||||
if ($commentForm.find('.field.combo-editor-dropzone').length) {
|
||||
// at the moment, if a form has multiple combo-markdown-editors, it must be an issue template form
|
||||
initIssueTemplateCommentEditors($commentForm);
|
||||
} else if ($commentForm.find('.combo-markdown-editor').length) {
|
||||
// it's quite unclear about the "comment form" elements, sometimes it's for issue comment, sometimes it's for file editor/uploader message
|
||||
initSingleCommentEditor($commentForm);
|
||||
}
|
||||
|
||||
initRepoIssueSidebar();
|
||||
}
|
||||
|
@ -1,13 +1,12 @@
|
||||
import $ from 'jquery';
|
||||
import {
|
||||
initRepoCommentFormAndSidebar,
|
||||
initRepoIssueBranchSelect, initRepoIssueCodeCommentCancel, initRepoIssueCommentDelete,
|
||||
initRepoIssueComments, initRepoIssueDependencyDelete, initRepoIssueReferenceIssue,
|
||||
initRepoIssueTitleEdit, initRepoIssueWipToggle,
|
||||
initRepoPullRequestUpdate, updateIssuesMeta, initIssueTemplateCommentEditors, initSingleCommentEditor,
|
||||
initRepoPullRequestUpdate,
|
||||
} from './repo-issue.ts';
|
||||
import {initUnicodeEscapeButton} from './repo-unicode-escape.ts';
|
||||
import {svg} from '../svg.ts';
|
||||
import {htmlEscape} from 'escape-goat';
|
||||
import {initRepoBranchTagSelector} from '../components/RepoBranchTagSelector.vue';
|
||||
import {
|
||||
initRepoCloneLink, initRepoCommonBranchOrTagDropdown, initRepoCommonFilterSearchDropdown,
|
||||
@ -16,32 +15,13 @@ import {initCitationFileCopyContent} from './citation.ts';
|
||||
import {initCompLabelEdit} from './comp/LabelEdit.ts';
|
||||
import {initRepoDiffConversationNav} from './repo-diff.ts';
|
||||
import {initCompReactionSelector} from './comp/ReactionSelector.ts';
|
||||
import {initRepoSettingBranches} from './repo-settings.ts';
|
||||
import {initRepoSettings} from './repo-settings.ts';
|
||||
import {initRepoPullRequestMergeForm} from './repo-issue-pr-form.ts';
|
||||
import {initRepoPullRequestCommitStatus} from './repo-issue-pr-status.ts';
|
||||
import {hideElem, queryElemChildren, showElem} from '../utils/dom.ts';
|
||||
import {POST} from '../modules/fetch.ts';
|
||||
import {initRepoIssueCommentEdit} from './repo-issue-edit.ts';
|
||||
|
||||
// if there are draft comments, confirm before reloading, to avoid losing comments
|
||||
function reloadConfirmDraftComment() {
|
||||
const commentTextareas = [
|
||||
document.querySelector('.edit-content-zone:not(.tw-hidden) textarea'),
|
||||
document.querySelector('#comment-form textarea'),
|
||||
];
|
||||
for (const textarea of commentTextareas) {
|
||||
// Most users won't feel too sad if they lose a comment with 10 chars, they can re-type these in seconds.
|
||||
// But if they have typed more (like 50) chars and the comment is lost, they will be very unhappy.
|
||||
if (textarea && textarea.value.trim().length > 10) {
|
||||
textarea.parentElement.scrollIntoView();
|
||||
if (!window.confirm('Page will be reloaded, but there are draft comments. Continuing to reload will discard the comments. Continue?')) {
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
window.location.reload();
|
||||
}
|
||||
import {initRepoMilestone} from './repo-milestone.ts';
|
||||
import {initRepoNew} from './repo-new.ts';
|
||||
|
||||
export function initBranchSelectorTabs() {
|
||||
const elSelectBranch = document.querySelector('.ui.dropdown.select-branch');
|
||||
@ -56,320 +36,16 @@ export function initBranchSelectorTabs() {
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoCommentForm() {
|
||||
const $commentForm = $('.comment.form');
|
||||
if (!$commentForm.length) return;
|
||||
|
||||
if ($commentForm.find('.field.combo-editor-dropzone').length) {
|
||||
// at the moment, if a form has multiple combo-markdown-editors, it must be an issue template form
|
||||
initIssueTemplateCommentEditors($commentForm);
|
||||
} else if ($commentForm.find('.combo-markdown-editor').length) {
|
||||
// it's quite unclear about the "comment form" elements, sometimes it's for issue comment, sometimes it's for file editor/uploader message
|
||||
initSingleCommentEditor($commentForm);
|
||||
}
|
||||
|
||||
function initBranchSelector() {
|
||||
const elSelectBranch = document.querySelector('.ui.dropdown.select-branch');
|
||||
if (!elSelectBranch) return;
|
||||
|
||||
const urlUpdateIssueRef = elSelectBranch.getAttribute('data-url-update-issueref');
|
||||
const $selectBranch = $(elSelectBranch);
|
||||
const $branchMenu = $selectBranch.find('.reference-list-menu');
|
||||
$branchMenu.find('.item:not(.no-select)').on('click', async function (e) {
|
||||
e.preventDefault();
|
||||
const selectedValue = this.getAttribute('data-id'); // eg: "refs/heads/my-branch"
|
||||
const selectedText = this.getAttribute('data-name'); // eg: "my-branch"
|
||||
if (urlUpdateIssueRef) {
|
||||
// for existing issue, send request to update issue ref, and reload page
|
||||
try {
|
||||
await POST(urlUpdateIssueRef, {data: new URLSearchParams({ref: selectedValue})});
|
||||
window.location.reload();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
} else {
|
||||
// for new issue, only update UI&form, do not send request/reload
|
||||
const selectedHiddenSelector = this.getAttribute('data-id-selector');
|
||||
document.querySelector(selectedHiddenSelector).value = selectedValue;
|
||||
elSelectBranch.querySelector('.text-branch-name').textContent = selectedText;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initBranchSelector();
|
||||
|
||||
// List submits
|
||||
function initListSubmits(selector, outerSelector) {
|
||||
const $list = $(`.ui.${outerSelector}.list`);
|
||||
const $noSelect = $list.find('.no-select');
|
||||
const $listMenu = $(`.${selector} .menu`);
|
||||
let hasUpdateAction = $listMenu.data('action') === 'update';
|
||||
const items = {};
|
||||
|
||||
$(`.${selector}`).dropdown({
|
||||
'action': 'nothing', // do not hide the menu if user presses Enter
|
||||
fullTextSearch: 'exact',
|
||||
async onHide() {
|
||||
hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var
|
||||
if (hasUpdateAction) {
|
||||
// TODO: Add batch functionality and make this 1 network request.
|
||||
const itemEntries = Object.entries(items);
|
||||
for (const [elementId, item] of itemEntries) {
|
||||
await updateIssuesMeta(
|
||||
item['update-url'],
|
||||
item.action,
|
||||
item['issue-id'],
|
||||
elementId,
|
||||
);
|
||||
}
|
||||
if (itemEntries.length) {
|
||||
reloadConfirmDraftComment();
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
$listMenu.find('.item:not(.no-select)').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (this.classList.contains('ban-change')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
hasUpdateAction = $listMenu.data('action') === 'update'; // Update the var
|
||||
|
||||
const clickedItem = this; // eslint-disable-line unicorn/no-this-assignment
|
||||
const scope = this.getAttribute('data-scope');
|
||||
|
||||
$(this).parent().find('.item').each(function () {
|
||||
if (scope) {
|
||||
// Enable only clicked item for scoped labels
|
||||
if (this.getAttribute('data-scope') !== scope) {
|
||||
return true;
|
||||
}
|
||||
if (this !== clickedItem && !this.classList.contains('checked')) {
|
||||
return true;
|
||||
}
|
||||
} else if (this !== clickedItem) {
|
||||
// Toggle for other labels
|
||||
return true;
|
||||
}
|
||||
|
||||
if (this.classList.contains('checked')) {
|
||||
$(this).removeClass('checked');
|
||||
$(this).find('.octicon-check').addClass('tw-invisible');
|
||||
if (hasUpdateAction) {
|
||||
if (!($(this).data('id') in items)) {
|
||||
items[$(this).data('id')] = {
|
||||
'update-url': $listMenu.data('update-url'),
|
||||
action: 'detach',
|
||||
'issue-id': $listMenu.data('issue-id'),
|
||||
};
|
||||
} else {
|
||||
delete items[$(this).data('id')];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$(this).addClass('checked');
|
||||
$(this).find('.octicon-check').removeClass('tw-invisible');
|
||||
if (hasUpdateAction) {
|
||||
if (!($(this).data('id') in items)) {
|
||||
items[$(this).data('id')] = {
|
||||
'update-url': $listMenu.data('update-url'),
|
||||
action: 'attach',
|
||||
'issue-id': $listMenu.data('issue-id'),
|
||||
};
|
||||
} else {
|
||||
delete items[$(this).data('id')];
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// TODO: Which thing should be done for choosing review requests
|
||||
// to make chosen items be shown on time here?
|
||||
if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const listIds = [];
|
||||
$(this).parent().find('.item').each(function () {
|
||||
if (this.classList.contains('checked')) {
|
||||
listIds.push($(this).data('id'));
|
||||
$($(this).data('id-selector')).removeClass('tw-hidden');
|
||||
} else {
|
||||
$($(this).data('id-selector')).addClass('tw-hidden');
|
||||
}
|
||||
});
|
||||
if (!listIds.length) {
|
||||
$noSelect.removeClass('tw-hidden');
|
||||
} else {
|
||||
$noSelect.addClass('tw-hidden');
|
||||
}
|
||||
$($(this).parent().data('id')).val(listIds.join(','));
|
||||
return false;
|
||||
});
|
||||
$listMenu.find('.no-select.item').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
if (hasUpdateAction) {
|
||||
(async () => {
|
||||
await updateIssuesMeta(
|
||||
$listMenu.data('update-url'),
|
||||
'clear',
|
||||
$listMenu.data('issue-id'),
|
||||
'',
|
||||
);
|
||||
reloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
$(this).parent().find('.item').each(function () {
|
||||
$(this).removeClass('checked');
|
||||
$(this).find('.octicon-check').addClass('tw-invisible');
|
||||
});
|
||||
|
||||
if (selector === 'select-reviewers-modify' || selector === 'select-assignees-modify') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$list.find('.item').each(function () {
|
||||
$(this).addClass('tw-hidden');
|
||||
});
|
||||
$noSelect.removeClass('tw-hidden');
|
||||
$($(this).parent().data('id')).val('');
|
||||
});
|
||||
}
|
||||
|
||||
// Init labels and assignees
|
||||
initListSubmits('select-label', 'labels');
|
||||
initListSubmits('select-assignees', 'assignees');
|
||||
initListSubmits('select-assignees-modify', 'assignees');
|
||||
initListSubmits('select-reviewers-modify', 'assignees');
|
||||
|
||||
function selectItem(select_id, input_id) {
|
||||
const $menu = $(`${select_id} .menu`);
|
||||
const $list = $(`.ui${select_id}.list`);
|
||||
const hasUpdateAction = $menu.data('action') === 'update';
|
||||
|
||||
$menu.find('.item:not(.no-select)').on('click', function () {
|
||||
$(this).parent().find('.item').each(function () {
|
||||
$(this).removeClass('selected active');
|
||||
});
|
||||
|
||||
$(this).addClass('selected active');
|
||||
if (hasUpdateAction) {
|
||||
(async () => {
|
||||
await updateIssuesMeta(
|
||||
$menu.data('update-url'),
|
||||
'',
|
||||
$menu.data('issue-id'),
|
||||
$(this).data('id'),
|
||||
);
|
||||
reloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
let icon = '';
|
||||
if (input_id === '#milestone_id') {
|
||||
icon = svg('octicon-milestone', 18, 'tw-mr-2');
|
||||
} else if (input_id === '#project_id') {
|
||||
icon = svg('octicon-project', 18, 'tw-mr-2');
|
||||
} else if (input_id === '#assignee_id') {
|
||||
icon = `<img class="ui avatar image tw-mr-2" alt="avatar" src=${$(this).data('avatar')}>`;
|
||||
}
|
||||
|
||||
$list.find('.selected').html(`
|
||||
<a class="item muted sidebar-item-link" href="${htmlEscape(this.getAttribute('data-href'))}">
|
||||
${icon}
|
||||
${htmlEscape(this.textContent)}
|
||||
</a>
|
||||
`);
|
||||
|
||||
$(`.ui${select_id}.list .no-select`).addClass('tw-hidden');
|
||||
$(input_id).val($(this).data('id'));
|
||||
});
|
||||
$menu.find('.no-select.item').on('click', function () {
|
||||
$(this).parent().find('.item:not(.no-select)').each(function () {
|
||||
$(this).removeClass('selected active');
|
||||
});
|
||||
|
||||
if (hasUpdateAction) {
|
||||
(async () => {
|
||||
await updateIssuesMeta(
|
||||
$menu.data('update-url'),
|
||||
'',
|
||||
$menu.data('issue-id'),
|
||||
$(this).data('id'),
|
||||
);
|
||||
reloadConfirmDraftComment();
|
||||
})();
|
||||
}
|
||||
|
||||
$list.find('.selected').html('');
|
||||
$list.find('.no-select').removeClass('tw-hidden');
|
||||
$(input_id).val('');
|
||||
});
|
||||
}
|
||||
|
||||
// Milestone, Assignee, Project
|
||||
selectItem('.select-project', '#project_id');
|
||||
selectItem('.select-milestone', '#milestone_id');
|
||||
selectItem('.select-assignee', '#assignee_id');
|
||||
}
|
||||
|
||||
export function initRepository() {
|
||||
if (!$('.page-content.repository').length) return;
|
||||
|
||||
initRepoBranchTagSelector('.js-branch-tag-selector');
|
||||
|
||||
// Options
|
||||
if ($('.repository.settings.options').length > 0) {
|
||||
// Enable or select internal/external wiki system and issue tracker.
|
||||
$('.enable-system').on('change', function () {
|
||||
if (this.checked) {
|
||||
$($(this).data('target')).removeClass('disabled');
|
||||
if (!$(this).data('context')) $($(this).data('context')).addClass('disabled');
|
||||
} else {
|
||||
$($(this).data('target')).addClass('disabled');
|
||||
if (!$(this).data('context')) $($(this).data('context')).removeClass('disabled');
|
||||
}
|
||||
});
|
||||
$('.enable-system-radio').on('change', function () {
|
||||
if (this.value === 'false') {
|
||||
$($(this).data('target')).addClass('disabled');
|
||||
if ($(this).data('context') !== undefined) $($(this).data('context')).removeClass('disabled');
|
||||
} else if (this.value === 'true') {
|
||||
$($(this).data('target')).removeClass('disabled');
|
||||
if ($(this).data('context') !== undefined) $($(this).data('context')).addClass('disabled');
|
||||
}
|
||||
});
|
||||
const $trackerIssueStyleRadios = $('.js-tracker-issue-style');
|
||||
$trackerIssueStyleRadios.on('change input', () => {
|
||||
const checkedVal = $trackerIssueStyleRadios.filter(':checked').val();
|
||||
$('#tracker-issue-style-regex-box').toggleClass('disabled', checkedVal !== 'regexp');
|
||||
});
|
||||
}
|
||||
initRepoCommentFormAndSidebar();
|
||||
|
||||
// Labels
|
||||
initCompLabelEdit('.repository.labels');
|
||||
|
||||
// Milestones
|
||||
if ($('.repository.new.milestone').length > 0) {
|
||||
$('#clear-date').on('click', () => {
|
||||
$('#deadline').val('');
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// Repo Creation
|
||||
if ($('.repository.new.repo').length > 0) {
|
||||
$('input[name="gitignores"], input[name="license"]').on('change', () => {
|
||||
const gitignores = $('input[name="gitignores"]').val();
|
||||
const license = $('input[name="license"]').val();
|
||||
if (gitignores || license) {
|
||||
document.querySelector('input[name="auto_init"]').checked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
initRepoMilestone();
|
||||
initRepoNew();
|
||||
|
||||
// Compare or pull request
|
||||
const $repoDiff = $('.repository.diff');
|
||||
@ -380,7 +56,7 @@ export function initRepository() {
|
||||
|
||||
initRepoCloneLink();
|
||||
initCitationFileCopyContent();
|
||||
initRepoSettingBranches();
|
||||
initRepoSettings();
|
||||
|
||||
// Issues
|
||||
if ($('.repository.view.issue').length > 0) {
|
||||
|
11
web_src/js/features/repo-milestone.ts
Normal file
11
web_src/js/features/repo-milestone.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import $ from 'jquery';
|
||||
|
||||
export function initRepoMilestone() {
|
||||
// Milestones
|
||||
if ($('.repository.new.milestone').length > 0) {
|
||||
$('#clear-date').on('click', () => {
|
||||
$('#deadline').val('');
|
||||
return false;
|
||||
});
|
||||
}
|
||||
}
|
14
web_src/js/features/repo-new.ts
Normal file
14
web_src/js/features/repo-new.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import $ from 'jquery';
|
||||
|
||||
export function initRepoNew() {
|
||||
// Repo Creation
|
||||
if ($('.repository.new.repo').length > 0) {
|
||||
$('input[name="gitignores"], input[name="license"]').on('change', () => {
|
||||
const gitignores = $('input[name="gitignores"]').val();
|
||||
const license = $('input[name="license"]').val();
|
||||
if (gitignores || license) {
|
||||
document.querySelector('input[name="auto_init"]').checked = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -6,7 +6,7 @@ import {POST} from '../modules/fetch.ts';
|
||||
|
||||
const {appSubUrl, csrfToken} = window.config;
|
||||
|
||||
export function initRepoSettingsCollaboration() {
|
||||
function initRepoSettingsCollaboration() {
|
||||
// Change collaborator access mode
|
||||
for (const dropdownEl of queryElems('.page-content.repository .ui.dropdown.access-mode')) {
|
||||
const textEl = dropdownEl.querySelector(':scope > .text');
|
||||
@ -43,7 +43,7 @@ export function initRepoSettingsCollaboration() {
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoSettingSearchTeamBox() {
|
||||
function initRepoSettingsSearchTeamBox() {
|
||||
const searchTeamBox = document.querySelector('#search-team-box');
|
||||
if (!searchTeamBox) return;
|
||||
|
||||
@ -69,13 +69,13 @@ export function initRepoSettingSearchTeamBox() {
|
||||
});
|
||||
}
|
||||
|
||||
export function initRepoSettingGitHook() {
|
||||
function initRepoSettingsGitHook() {
|
||||
if (!$('.edit.githook').length) return;
|
||||
const filename = document.querySelector('.hook-filename').textContent;
|
||||
const _promise = createMonaco($('#content')[0], filename, {language: 'shell'});
|
||||
createMonaco($('#content')[0], filename, {language: 'shell'});
|
||||
}
|
||||
|
||||
export function initRepoSettingBranches() {
|
||||
function initRepoSettingsBranches() {
|
||||
if (!document.querySelector('.repository.settings.branches')) return;
|
||||
|
||||
for (const el of document.querySelectorAll('.toggle-target-enabled')) {
|
||||
@ -117,3 +117,41 @@ export function initRepoSettingBranches() {
|
||||
markMatchedStatusChecks();
|
||||
document.querySelector('#status_check_contexts').addEventListener('input', onInputDebounce(markMatchedStatusChecks));
|
||||
}
|
||||
|
||||
function initRepoSettingsOptions() {
|
||||
if ($('.repository.settings.options').length > 0) {
|
||||
// Enable or select internal/external wiki system and issue tracker.
|
||||
$('.enable-system').on('change', function () {
|
||||
if (this.checked) {
|
||||
$($(this).data('target')).removeClass('disabled');
|
||||
if (!$(this).data('context')) $($(this).data('context')).addClass('disabled');
|
||||
} else {
|
||||
$($(this).data('target')).addClass('disabled');
|
||||
if (!$(this).data('context')) $($(this).data('context')).removeClass('disabled');
|
||||
}
|
||||
});
|
||||
$('.enable-system-radio').on('change', function () {
|
||||
if (this.value === 'false') {
|
||||
$($(this).data('target')).addClass('disabled');
|
||||
if ($(this).data('context') !== undefined) $($(this).data('context')).removeClass('disabled');
|
||||
} else if (this.value === 'true') {
|
||||
$($(this).data('target')).removeClass('disabled');
|
||||
if ($(this).data('context') !== undefined) $($(this).data('context')).addClass('disabled');
|
||||
}
|
||||
});
|
||||
const $trackerIssueStyleRadios = $('.js-tracker-issue-style');
|
||||
$trackerIssueStyleRadios.on('change input', () => {
|
||||
const checkedVal = $trackerIssueStyleRadios.filter(':checked').val();
|
||||
$('#tracker-issue-style-regex-box').toggleClass('disabled', checkedVal !== 'regexp');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function initRepoSettings() {
|
||||
if (!document.querySelector('.page-content.repository.settings')) return;
|
||||
initRepoSettingsOptions();
|
||||
initRepoSettingsBranches();
|
||||
initRepoSettingsCollaboration();
|
||||
initRepoSettingsSearchTeamBox();
|
||||
initRepoSettingsGitHook();
|
||||
}
|
||||
|
@ -2,7 +2,6 @@
|
||||
import './bootstrap.ts';
|
||||
import './htmx.ts';
|
||||
|
||||
import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue';
|
||||
import {initDashboardRepoList} from './components/DashboardRepoList.vue';
|
||||
|
||||
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
|
||||
@ -42,13 +41,8 @@ import {initRepoTemplateSearch} from './features/repo-template.ts';
|
||||
import {initRepoCodeView} from './features/repo-code.ts';
|
||||
import {initSshKeyFormParser} from './features/sshkey-helper.ts';
|
||||
import {initUserSettings} from './features/user-settings.ts';
|
||||
import {initRepoArchiveLinks} from './features/repo-common.ts';
|
||||
import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts';
|
||||
import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts';
|
||||
import {
|
||||
initRepoSettingGitHook,
|
||||
initRepoSettingsCollaboration,
|
||||
initRepoSettingSearchTeamBox,
|
||||
} from './features/repo-settings.ts';
|
||||
import {initRepoDiffView} from './features/repo-diff.ts';
|
||||
import {initOrgTeamSearchRepoBox, initOrgTeamSettings} from './features/org-team.ts';
|
||||
import {initUserAuthWebAuthn, initUserAuthWebAuthnRegister} from './features/user-auth-webauthn.ts';
|
||||
@ -60,7 +54,7 @@ import {initCompWebHookEditor} from './features/comp/WebHookEditor.ts';
|
||||
import {initRepoBranchButton} from './features/repo-branch.ts';
|
||||
import {initCommonOrganization} from './features/common-organization.ts';
|
||||
import {initRepoWikiForm} from './features/repo-wiki.ts';
|
||||
import {initRepoCommentForm, initRepository, initBranchSelectorTabs} from './features/repo-legacy.ts';
|
||||
import {initRepository, initBranchSelectorTabs} from './features/repo-legacy.ts';
|
||||
import {initCopyContent} from './features/copycontent.ts';
|
||||
import {initCaptcha} from './features/captcha.ts';
|
||||
import {initRepositoryActionView} from './components/RepoActionView.vue';
|
||||
@ -181,7 +175,6 @@ onDomReady(() => {
|
||||
initRepoArchiveLinks,
|
||||
initRepoBranchButton,
|
||||
initRepoCodeView,
|
||||
initRepoCommentForm,
|
||||
initBranchSelectorTabs,
|
||||
initRepoEllipsisButton,
|
||||
initRepoDiffCommitBranchesAndTags,
|
||||
@ -203,9 +196,6 @@ onDomReady(() => {
|
||||
initRepoPullRequestReview,
|
||||
initRepoRelease,
|
||||
initRepoReleaseNew,
|
||||
initRepoSettingGitHook,
|
||||
initRepoSettingSearchTeamBox,
|
||||
initRepoSettingsCollaboration,
|
||||
initRepoTemplateSearch,
|
||||
initRepoTopicBar,
|
||||
initRepoWikiForm,
|
||||
|
@ -11,7 +11,7 @@ type TippyOpts = {
|
||||
const visibleInstances = new Set<Instance>();
|
||||
const arrowSvg = `<svg width="16" height="7"><path d="m0 7 8-7 8 7Z" class="tippy-svg-arrow-outer"/><path d="m0 8 8-7 8 7Z" class="tippy-svg-arrow-inner"/></svg>`;
|
||||
|
||||
export function createTippy(target: Element, opts: TippyOpts = {}) {
|
||||
export function createTippy(target: Element, opts: TippyOpts = {}): Instance {
|
||||
// the callback functions should be destructured from opts,
|
||||
// because we should use our own wrapper functions to handle them, do not let the user override them
|
||||
const {onHide, onShow, onDestroy, role, theme, arrow, ...other} = opts;
|
||||
|
@ -153,7 +153,8 @@ export type SvgName = keyof typeof svgs;
|
||||
// most of the SVG icons in assets couldn't be used directly.
|
||||
|
||||
// retrieve an HTML string for given SVG icon name, size and additional classes
|
||||
export function svg(name: SvgName, size = 16, className = '') {
|
||||
export function svg(name: SvgName, size = 16, classNames: string|string[]): string {
|
||||
const className = Array.isArray(classNames) ? classNames.join(' ') : classNames;
|
||||
if (!(name in svgs)) throw new Error(`Unknown SVG icon: ${name}`);
|
||||
if (size === 16 && !className) return svgs[name];
|
||||
|
||||
|
@ -30,9 +30,20 @@ export type RequestOpts = {
|
||||
data?: RequestData,
|
||||
} & RequestInit;
|
||||
|
||||
export type IssueData = {
|
||||
owner: string,
|
||||
repo: string,
|
||||
type: string,
|
||||
index: string,
|
||||
export type IssuePathInfo = {
|
||||
ownerName: string,
|
||||
repoName: string,
|
||||
pathType: string,
|
||||
indexString?: string,
|
||||
}
|
||||
|
||||
export type Issue = {
|
||||
id: number;
|
||||
number: number;
|
||||
title: string;
|
||||
state: 'open' | 'closed';
|
||||
pull_request?: {
|
||||
draft: boolean;
|
||||
merged: boolean;
|
||||
};
|
||||
};
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {
|
||||
basename, extname, isObject, stripTags, parseIssueHref,
|
||||
parseUrl, translateMonth, translateDay, blobToDataURI,
|
||||
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile,
|
||||
toAbsoluteUrl, encodeURLEncodedBase64, decodeURLEncodedBase64, isImageFile, isVideoFile, parseIssueNewHref,
|
||||
} from './utils.ts';
|
||||
|
||||
test('basename', () => {
|
||||
@ -28,21 +28,27 @@ test('stripTags', () => {
|
||||
});
|
||||
|
||||
test('parseIssueHref', () => {
|
||||
expect(parseIssueHref('/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
|
||||
expect(parseIssueHref('/owner/repo/pulls/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'});
|
||||
expect(parseIssueHref('/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
|
||||
expect(parseIssueHref('/sub/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
|
||||
expect(parseIssueHref('/sub/sub2/owner/repo/pulls/1')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'});
|
||||
expect(parseIssueHref('/sub/sub2/owner/repo/issues/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
|
||||
expect(parseIssueHref('/sub/sub2/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
|
||||
expect(parseIssueHref('https://example.com/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
|
||||
expect(parseIssueHref('https://example.com/owner/repo/pulls/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'});
|
||||
expect(parseIssueHref('https://example.com/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
|
||||
expect(parseIssueHref('https://example.com/sub/owner/repo/issues/1')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
|
||||
expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/pulls/1')).toEqual({owner: 'owner', repo: 'repo', type: 'pulls', index: '1'});
|
||||
expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/issues/1?query')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
|
||||
expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/issues/1#hash')).toEqual({owner: 'owner', repo: 'repo', type: 'issues', index: '1'});
|
||||
expect(parseIssueHref('')).toEqual({owner: undefined, repo: undefined, type: undefined, index: undefined});
|
||||
expect(parseIssueHref('/owner/repo/issues/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
|
||||
expect(parseIssueHref('/owner/repo/pulls/1?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls', indexString: '1'});
|
||||
expect(parseIssueHref('/owner/repo/issues/1#hash')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
|
||||
expect(parseIssueHref('/sub/owner/repo/issues/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
|
||||
expect(parseIssueHref('/sub/sub2/owner/repo/pulls/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls', indexString: '1'});
|
||||
expect(parseIssueHref('/sub/sub2/owner/repo/issues/1?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
|
||||
expect(parseIssueHref('/sub/sub2/owner/repo/issues/1#hash')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
|
||||
expect(parseIssueHref('https://example.com/owner/repo/issues/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
|
||||
expect(parseIssueHref('https://example.com/owner/repo/pulls/1?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls', indexString: '1'});
|
||||
expect(parseIssueHref('https://example.com/owner/repo/issues/1#hash')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
|
||||
expect(parseIssueHref('https://example.com/sub/owner/repo/issues/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
|
||||
expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/pulls/1')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'pulls', indexString: '1'});
|
||||
expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/issues/1?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
|
||||
expect(parseIssueHref('https://example.com/sub/sub2/owner/repo/issues/1#hash')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues', indexString: '1'});
|
||||
expect(parseIssueHref('')).toEqual({ownerName: undefined, repoName: undefined, type: undefined, index: undefined});
|
||||
});
|
||||
|
||||
test('parseIssueNewHref', () => {
|
||||
expect(parseIssueNewHref('/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'});
|
||||
expect(parseIssueNewHref('/owner/repo/issues/new?query')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'});
|
||||
expect(parseIssueNewHref('/sub/owner/repo/issues/new#hash')).toEqual({ownerName: 'owner', repoName: 'repo', pathType: 'issues'});
|
||||
});
|
||||
|
||||
test('parseUrl', () => {
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {encode, decode} from 'uint8-to-base64';
|
||||
import type {IssueData} from './types.ts';
|
||||
import type {IssuePathInfo} from './types.ts';
|
||||
|
||||
// transform /path/to/file.ext to file.ext
|
||||
export function basename(path: string): string {
|
||||
@ -31,10 +31,16 @@ export function stripTags(text: string): string {
|
||||
return text.replace(/<[^>]*>?/g, '');
|
||||
}
|
||||
|
||||
export function parseIssueHref(href: string): IssueData {
|
||||
export function parseIssueHref(href: string): IssuePathInfo {
|
||||
const path = (href || '').replace(/[#?].*$/, '');
|
||||
const [_, owner, repo, type, index] = /([^/]+)\/([^/]+)\/(issues|pulls)\/([0-9]+)/.exec(path) || [];
|
||||
return {owner, repo, type, index};
|
||||
const [_, ownerName, repoName, pathType, indexString] = /([^/]+)\/([^/]+)\/(issues|pulls)\/([0-9]+)/.exec(path) || [];
|
||||
return {ownerName, repoName, pathType, indexString};
|
||||
}
|
||||
|
||||
export function parseIssueNewHref(href: string): IssuePathInfo {
|
||||
const path = (href || '').replace(/[#?].*$/, '');
|
||||
const [_, ownerName, repoName, pathType, indexString] = /([^/]+)\/([^/]+)\/(issues|pulls)\/new/.exec(path) || [];
|
||||
return {ownerName, repoName, pathType, indexString};
|
||||
}
|
||||
|
||||
// parse a URL, either relative '/path' or absolute 'https://localhost/path'
|
||||
|
@ -8,11 +8,11 @@ test('createElementFromAttrs', () => {
|
||||
const el = createElementFromAttrs('button', {
|
||||
id: 'the-id',
|
||||
class: 'cls-1 cls-2',
|
||||
'data-foo': 'the-data',
|
||||
disabled: true,
|
||||
checked: false,
|
||||
required: null,
|
||||
tabindex: 0,
|
||||
});
|
||||
expect(el.outerHTML).toEqual('<button id="the-id" class="cls-1 cls-2" data-foo="the-data" disabled="" tabindex="0"></button>');
|
||||
'data-foo': 'the-data',
|
||||
}, 'txt', createElementFromHTML('<span>inner</span>'));
|
||||
expect(el.outerHTML).toEqual('<button id="the-id" class="cls-1 cls-2" disabled="" tabindex="0" data-foo="the-data">txt<span>inner</span></button>');
|
||||
});
|
||||
|
@ -92,7 +92,7 @@ export function onDomReady(cb: () => Promisable<void>) {
|
||||
|
||||
// checks whether an element is owned by the current document, and whether it is a document fragment or element node
|
||||
// if it is, it means it is a "normal" element managed by us, which can be modified safely.
|
||||
export function isDocumentFragmentOrElementNode(el: Element) {
|
||||
export function isDocumentFragmentOrElementNode(el: Element | Node) {
|
||||
try {
|
||||
return el.ownerDocument === document && el.nodeType === Node.ELEMENT_NODE || el.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
|
||||
} catch {
|
||||
@ -167,7 +167,7 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom =
|
||||
const isBorderBox = computedStyle.boxSizing === 'border-box';
|
||||
const borderAddOn = isBorderBox ? topBorderWidth + bottomBorderWidth : 0;
|
||||
|
||||
const adjustedViewportMarginBottom = bottom < viewportMarginBottom ? bottom : viewportMarginBottom;
|
||||
const adjustedViewportMarginBottom = Math.min(bottom, viewportMarginBottom);
|
||||
const curHeight = parseFloat(computedStyle.height);
|
||||
const maxHeight = curHeight + bottom - adjustedViewportMarginBottom;
|
||||
|
||||
@ -281,7 +281,7 @@ export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: st
|
||||
|
||||
textarea.contentEditable = 'true';
|
||||
try {
|
||||
success = document.execCommand('insertText', false, text); // eslint-disable-line deprecation/deprecation
|
||||
success = document.execCommand('insertText', false, text); // eslint-disable-line @typescript-eslint/no-deprecated
|
||||
} catch {
|
||||
success = false;
|
||||
}
|
||||
@ -298,22 +298,24 @@ export function replaceTextareaSelection(textarea: HTMLTextAreaElement, text: st
|
||||
}
|
||||
|
||||
// Warning: Do not enter any unsanitized variables here
|
||||
export function createElementFromHTML(htmlString: string) {
|
||||
export function createElementFromHTML(htmlString: string): HTMLElement {
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = htmlString.trim();
|
||||
return div.firstChild as Element;
|
||||
return div.firstChild as HTMLElement;
|
||||
}
|
||||
|
||||
export function createElementFromAttrs(tagName: string, attrs: Record<string, any>) {
|
||||
export function createElementFromAttrs(tagName: string, attrs: Record<string, any>, ...children: (Node|string)[]): HTMLElement {
|
||||
const el = document.createElement(tagName);
|
||||
for (const [key, value] of Object.entries(attrs)) {
|
||||
for (const [key, value] of Object.entries(attrs || {})) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (typeof value === 'boolean') {
|
||||
el.toggleAttribute(key, value);
|
||||
} else {
|
||||
el.setAttribute(key, String(value));
|
||||
}
|
||||
// TODO: in the future we could make it also support "textContent" and "innerHTML" properties if needed
|
||||
}
|
||||
for (const child of children) {
|
||||
el.append(child instanceof Node ? child : document.createTextNode(child));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
import emojis from '../../../assets/emoji.json';
|
||||
import {GET} from '../modules/fetch.ts';
|
||||
import type {Issue} from '../features/issue.ts';
|
||||
|
||||
const maxMatches = 6;
|
||||
|
||||
function sortAndReduce(map: Map<string, number>) {
|
||||
function sortAndReduce<T>(map: Map<T, number>): T[] {
|
||||
const sortedMap = new Map(Array.from(map.entries()).sort((a, b) => a[1] - b[1]));
|
||||
return Array.from(sortedMap.keys()).slice(0, maxMatches);
|
||||
}
|
||||
@ -27,11 +29,12 @@ export function matchEmoji(queryText: string): string[] {
|
||||
return sortAndReduce(results);
|
||||
}
|
||||
|
||||
export function matchMention(queryText: string): string[] {
|
||||
type MentionSuggestion = {value: string; name: string; fullname: string; avatar: string};
|
||||
export function matchMention(queryText: string): MentionSuggestion[] {
|
||||
const query = queryText.toLowerCase();
|
||||
|
||||
// results is a map of weights, lower is better
|
||||
const results = new Map();
|
||||
const results = new Map<MentionSuggestion, number>();
|
||||
for (const obj of window.config.mentionValues ?? []) {
|
||||
const index = obj.key.toLowerCase().indexOf(query);
|
||||
if (index === -1) continue;
|
||||
@ -41,3 +44,13 @@ export function matchMention(queryText: string): string[] {
|
||||
|
||||
return sortAndReduce(results);
|
||||
}
|
||||
|
||||
export async function matchIssue(owner: string, repo: string, issueIndexStr: string, query: string): Promise<Issue[]> {
|
||||
const res = await GET(`${window.config.appSubUrl}/${owner}/${repo}/issues/suggestions?q=${encodeURIComponent(query)}`);
|
||||
|
||||
const issues: Issue[] = await res.json();
|
||||
const issueNumber = parseInt(issueIndexStr);
|
||||
|
||||
// filter out issue with same id
|
||||
return issues.filter((i) => i.number !== issueNumber);
|
||||
}
|
||||
|
@ -42,14 +42,14 @@ export function firstStartDateAfterDate(inputDate: Date): number {
|
||||
return resultDate.valueOf();
|
||||
}
|
||||
|
||||
type DayData = {
|
||||
export type DayData = {
|
||||
week: number,
|
||||
additions: number,
|
||||
deletions: number,
|
||||
commits: number,
|
||||
}
|
||||
|
||||
export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayData): DayData[] {
|
||||
export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayData[]): DayData[] {
|
||||
const result = {};
|
||||
|
||||
for (const startDay of startDays) {
|
||||
|
1
web_src/js/vendor/jquery.are-you-sure.ts
vendored
1
web_src/js/vendor/jquery.are-you-sure.ts
vendored
@ -1,3 +1,4 @@
|
||||
// @ts-nocheck
|
||||
// Fork of the upstream module. The only changes are:
|
||||
// * use export to make it work with ES6 modules.
|
||||
// * the addition of `const` to make it strict mode compatible.
|
||||
|
@ -7,9 +7,20 @@ test('toAbsoluteLocaleDate', () => {
|
||||
day: 'numeric',
|
||||
})).toEqual('March 15, 2024');
|
||||
|
||||
expect(toAbsoluteLocaleDate('2024-03-15', 'de-DE', {
|
||||
expect(toAbsoluteLocaleDate('2024-03-15T01:02:03', 'de-DE', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})).toEqual('15. März 2024');
|
||||
|
||||
// these cases shouldn't happen
|
||||
expect(toAbsoluteLocaleDate('2024-03-15 01:02:03', '', {})).toEqual('Invalid Date');
|
||||
expect(toAbsoluteLocaleDate('10000-01-01', '', {})).toEqual('Invalid Date');
|
||||
|
||||
// test different timezone
|
||||
const oldTZ = process.env.TZ;
|
||||
process.env.TZ = 'America/New_York';
|
||||
expect(new Date('2024-03-15').toLocaleString()).toEqual('3/14/2024, 8:00:00 PM');
|
||||
expect(toAbsoluteLocaleDate('2024-03-15')).toEqual('3/15/2024, 12:00:00 AM');
|
||||
process.env.TZ = oldTZ;
|
||||
});
|
||||
|
@ -1,33 +1,32 @@
|
||||
import {Temporal} from 'temporal-polyfill';
|
||||
|
||||
export function toAbsoluteLocaleDate(dateStr, lang, opts) {
|
||||
return Temporal.PlainDate.from(dateStr).toLocaleString(lang ?? [], opts);
|
||||
export function toAbsoluteLocaleDate(date: string, lang?: string, opts?: Intl.DateTimeFormatOptions) {
|
||||
// only use the date part, it is guaranteed to be in ISO format (YYYY-MM-DDTHH:mm:ss.sssZ) or (YYYY-MM-DD)
|
||||
// if there is an "Invalid Date" error, there must be something wrong in code and should be fixed.
|
||||
// TODO: there is a root problem in backend code: the date "YYYY-MM-DD" is passed to backend without timezone (eg: deadline),
|
||||
// then backend parses it in server's timezone and stores the parsed timestamp into database.
|
||||
// If the user's timezone is different from the server's, the date might be displayed in the wrong day.
|
||||
const dateSep = date.indexOf('T');
|
||||
date = dateSep === -1 ? date : date.substring(0, dateSep);
|
||||
return new Date(`${date}T00:00:00`).toLocaleString(lang || [], opts);
|
||||
}
|
||||
|
||||
window.customElements.define('absolute-date', class extends HTMLElement {
|
||||
static observedAttributes = ['date', 'year', 'month', 'weekday', 'day'];
|
||||
|
||||
initialized = false;
|
||||
|
||||
update = () => {
|
||||
const year = this.getAttribute('year') ?? '';
|
||||
const month = this.getAttribute('month') ?? '';
|
||||
const weekday = this.getAttribute('weekday') ?? '';
|
||||
const day = this.getAttribute('day') ?? '';
|
||||
const opt: Intl.DateTimeFormatOptions = {};
|
||||
for (const attr of ['year', 'month', 'weekday', 'day']) {
|
||||
if (this.getAttribute(attr)) opt[attr] = this.getAttribute(attr);
|
||||
}
|
||||
const lang = this.closest('[lang]')?.getAttribute('lang') ||
|
||||
this.ownerDocument.documentElement.getAttribute('lang') || '';
|
||||
|
||||
// only use the first 10 characters, e.g. the `yyyy-mm-dd` part
|
||||
const dateStr = this.getAttribute('date').substring(0, 10);
|
||||
|
||||
if (!this.shadowRoot) this.attachShadow({mode: 'open'});
|
||||
this.shadowRoot.textContent = toAbsoluteLocaleDate(dateStr, lang, {
|
||||
...(year && {year}),
|
||||
...(month && {month}),
|
||||
...(weekday && {weekday}),
|
||||
...(day && {day}),
|
||||
});
|
||||
this.shadowRoot.textContent = toAbsoluteLocaleDate(this.getAttribute('date'), lang, opt);
|
||||
};
|
||||
|
||||
attributeChangedCallback(_name, oldValue, newValue) {
|
||||
attributeChangedCallback(_name: string, oldValue: string | null, newValue: string | null) {
|
||||
if (!this.initialized || oldValue === newValue) return;
|
||||
this.update();
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
import {toOriginUrl} from './origin-url.ts';
|
||||
|
||||
test('toOriginUrl', () => {
|
||||
const oldLocation = window.location;
|
||||
const oldLocation = String(window.location);
|
||||
for (const origin of ['https://example.com', 'https://example.com:3000']) {
|
||||
window.location = new URL(`${origin}/`);
|
||||
window.location.assign(`${origin}/`);
|
||||
expect(toOriginUrl('/')).toEqual(`${origin}/`);
|
||||
expect(toOriginUrl('/org/repo.git')).toEqual(`${origin}/org/repo.git`);
|
||||
expect(toOriginUrl('https://another.com')).toEqual(`${origin}/`);
|
||||
@ -13,5 +13,5 @@ test('toOriginUrl', () => {
|
||||
expect(toOriginUrl('https://another.com:4000/')).toEqual(`${origin}/`);
|
||||
expect(toOriginUrl('https://another.com:4000/org/repo.git')).toEqual(`${origin}/org/repo.git`);
|
||||
}
|
||||
window.location = oldLocation;
|
||||
window.location.assign(oldLocation);
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
// Convert an absolute or relative URL to an absolute URL with the current origin. It only
|
||||
// processes absolute HTTP/HTTPS URLs or relative URLs like '/xxx' or '//host/xxx'.
|
||||
// NOTE: Keep this function in sync with clone_script.tmpl
|
||||
export function toOriginUrl(urlStr) {
|
||||
export function toOriginUrl(urlStr: string) {
|
||||
try {
|
||||
if (urlStr.startsWith('http://') || urlStr.startsWith('https://') || urlStr.startsWith('/')) {
|
||||
const {origin, protocol, hostname, port} = window.location;
|
||||
|
@ -4,6 +4,14 @@ import {isDocumentFragmentOrElementNode} from '../utils/dom.ts';
|
||||
import octiconKebabHorizontal from '../../../public/assets/img/svg/octicon-kebab-horizontal.svg';
|
||||
|
||||
window.customElements.define('overflow-menu', class extends HTMLElement {
|
||||
tippyContent: HTMLDivElement;
|
||||
tippyItems: Array<HTMLElement>;
|
||||
button: HTMLButtonElement;
|
||||
menuItemsEl: HTMLElement;
|
||||
resizeObserver: ResizeObserver;
|
||||
mutationObserver: MutationObserver;
|
||||
lastWidth: number;
|
||||
|
||||
updateItems = throttle(100, () => { // eslint-disable-line unicorn/consistent-function-scoping -- https://github.com/sindresorhus/eslint-plugin-unicorn/issues/2088
|
||||
if (!this.tippyContent) {
|
||||
const div = document.createElement('div');
|
||||
@ -11,7 +19,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
||||
div.tabIndex = -1; // for initial focus, programmatic focus only
|
||||
div.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Tab') {
|
||||
const items = this.tippyContent.querySelectorAll('[role="menuitem"]');
|
||||
const items = this.tippyContent.querySelectorAll<HTMLElement>('[role="menuitem"]');
|
||||
if (e.shiftKey) {
|
||||
if (document.activeElement === items[0]) {
|
||||
e.preventDefault();
|
||||
@ -32,27 +40,27 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
||||
if (document.activeElement?.matches('[role="menuitem"]')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
document.activeElement.click();
|
||||
(document.activeElement as HTMLElement).click();
|
||||
}
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
if (document.activeElement?.matches('.tippy-target')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
document.activeElement.querySelector('[role="menuitem"]:first-of-type').focus();
|
||||
document.activeElement.querySelector<HTMLElement>('[role="menuitem"]:first-of-type').focus();
|
||||
} else if (document.activeElement?.matches('[role="menuitem"]')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
document.activeElement.nextElementSibling?.focus();
|
||||
(document.activeElement.nextElementSibling as HTMLElement)?.focus();
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
if (document.activeElement?.matches('.tippy-target')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
document.activeElement.querySelector('[role="menuitem"]:last-of-type').focus();
|
||||
document.activeElement.querySelector<HTMLElement>('[role="menuitem"]:last-of-type').focus();
|
||||
} else if (document.activeElement?.matches('[role="menuitem"]')) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
document.activeElement.previousElementSibling?.focus();
|
||||
(document.activeElement.previousElementSibling as HTMLElement)?.focus();
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -60,8 +68,8 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
||||
this.tippyContent = div;
|
||||
}
|
||||
|
||||
const itemFlexSpace = this.menuItemsEl.querySelector('.item-flex-space');
|
||||
const itemOverFlowMenuButton = this.querySelector('.overflow-menu-button');
|
||||
const itemFlexSpace = this.menuItemsEl.querySelector<HTMLSpanElement>('.item-flex-space');
|
||||
const itemOverFlowMenuButton = this.querySelector<HTMLButtonElement>('.overflow-menu-button');
|
||||
|
||||
// move items in tippy back into the menu items for subsequent measurement
|
||||
for (const item of this.tippyItems || []) {
|
||||
@ -78,7 +86,7 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
||||
itemOverFlowMenuButton?.style.setProperty('display', 'none', 'important');
|
||||
this.tippyItems = [];
|
||||
const menuRight = this.offsetLeft + this.offsetWidth;
|
||||
const menuItems = this.menuItemsEl.querySelectorAll('.item, .item-flex-space');
|
||||
const menuItems = this.menuItemsEl.querySelectorAll<HTMLElement>('.item, .item-flex-space');
|
||||
let afterFlexSpace = false;
|
||||
for (const item of menuItems) {
|
||||
if (item.classList.contains('item-flex-space')) {
|
||||
@ -189,14 +197,14 @@ window.customElements.define('overflow-menu', class extends HTMLElement {
|
||||
// template rendering, wait for its addition.
|
||||
// The eslint rule is not sophisticated enough or aware of this problem, see
|
||||
// https://github.com/43081j/eslint-plugin-wc/pull/130
|
||||
const menuItemsEl = this.querySelector('.overflow-menu-items'); // eslint-disable-line wc/no-child-traversal-in-connectedcallback
|
||||
const menuItemsEl = this.querySelector<HTMLElement>('.overflow-menu-items'); // eslint-disable-line wc/no-child-traversal-in-connectedcallback
|
||||
if (menuItemsEl) {
|
||||
this.menuItemsEl = menuItemsEl;
|
||||
this.init();
|
||||
} else {
|
||||
this.mutationObserver = new MutationObserver((mutations) => {
|
||||
for (const mutation of mutations) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
for (const node of mutation.addedNodes as NodeListOf<HTMLElement>) {
|
||||
if (!isDocumentFragmentOrElementNode(node)) continue;
|
||||
if (node.classList.contains('overflow-menu-items')) {
|
||||
this.menuItemsEl = node;
|
||||
|
@ -4,10 +4,11 @@ try {
|
||||
new Intl.NumberFormat('en', {style: 'unit', unit: 'minute'}).format(1);
|
||||
} catch {
|
||||
const intlNumberFormat = Intl.NumberFormat;
|
||||
Intl.NumberFormat = function(locales, options) {
|
||||
// @ts-expect-error - polyfill is incomplete
|
||||
Intl.NumberFormat = function(locales: string | string[], options: Intl.NumberFormatOptions) {
|
||||
if (options.style === 'unit') {
|
||||
return {
|
||||
format(value) {
|
||||
format(value: number | bigint | string) {
|
||||
return ` ${value} ${options.unit}`;
|
||||
},
|
||||
};
|
||||
|
Reference in New Issue
Block a user