mirror of
https://github.com/go-gitea/gitea.git
synced 2026-09-11 00:10:38 -05:00
refactor: improve types in the frontend, misc fixes (#39142)
This commit is contained in:
@@ -47,8 +47,8 @@ function processAssetsSvgFiles(pattern: string, opts: Opts = {}) {
|
|||||||
return glob(pattern).map((path) => processAssetsSvgFile(path, opts));
|
return glob(pattern).map((path) => processAssetsSvgFile(path, opts));
|
||||||
}
|
}
|
||||||
|
|
||||||
function lowercaseKeys(obj: Record<string, any>) {
|
function lowercaseKeys<T extends Record<string, unknown>>(obj: T): T {
|
||||||
return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value]));
|
return Object.fromEntries(Object.entries(obj).map(([key, value]) => [key.toLowerCase(), value])) as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function processMaterialFileIcons() {
|
async function processMaterialFileIcons() {
|
||||||
|
|||||||
@@ -25,6 +25,17 @@ function isLogElementInViewport(el: Element, {extraViewPortHeight}={extraViewPor
|
|||||||
return 0 <= rect.bottom && rect.bottom <= window.innerHeight + extraViewPortHeight;
|
return 0 <= rect.bottom && rect.bottom <= window.innerHeight + extraViewPortHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ActionRunJobViewLocale = {
|
||||||
|
status: Record<ActionsStatus, string>,
|
||||||
|
showTimeStamps: string,
|
||||||
|
showLogSeconds: string,
|
||||||
|
showFullScreen: string,
|
||||||
|
logsAlwaysAutoScroll: string,
|
||||||
|
logsAlwaysExpandRunning: string,
|
||||||
|
downloadLogs: string,
|
||||||
|
copyOutput: string,
|
||||||
|
};
|
||||||
|
|
||||||
type Step = {
|
type Step = {
|
||||||
summary: string,
|
summary: string,
|
||||||
duration: string,
|
duration: string,
|
||||||
@@ -84,7 +95,7 @@ const props = defineProps<{
|
|||||||
store: ActionRunViewStore,
|
store: ActionRunViewStore,
|
||||||
jobId: number;
|
jobId: number;
|
||||||
actionsViewUrl: string;
|
actionsViewUrl: string;
|
||||||
locale: Record<string, any>;
|
locale: ActionRunJobViewLocale;
|
||||||
}>();
|
}>();
|
||||||
const store = props.store;
|
const store = props.store;
|
||||||
const {currentRun: run} = toRefs(store.viewData);
|
const {currentRun: run} = toRefs(store.viewData);
|
||||||
|
|||||||
@@ -1,16 +1,26 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import WorkflowGraph from './WorkflowGraph.vue';
|
import WorkflowGraph, {type WorkflowGraphLocale} from './WorkflowGraph.vue';
|
||||||
import type {ActionRunViewStore} from './ActionRunView.ts';
|
import type {ActionRunViewStore} from './ActionRunView.ts';
|
||||||
import {computed, onBeforeUnmount, onMounted, toRefs} from 'vue';
|
import {computed, onBeforeUnmount, onMounted, toRefs} from 'vue';
|
||||||
import {trString} from '../modules/i18n.ts';
|
import {trString} from '../modules/i18n.ts';
|
||||||
|
import type {ActionsStatus} from '../modules/gitea-actions.ts';
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'ActionRunSummaryView',
|
name: 'ActionRunSummaryView',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export type ActionRunSummaryViewLocale = WorkflowGraphLocale & {
|
||||||
|
status: Record<ActionsStatus, string>,
|
||||||
|
statusLabel: string,
|
||||||
|
totalDuration: string,
|
||||||
|
artifactsTitle: string,
|
||||||
|
triggeredVia: string,
|
||||||
|
rerunTriggered: string,
|
||||||
|
};
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
store: ActionRunViewStore;
|
store: ActionRunViewStore;
|
||||||
locale: Record<string, any>;
|
locale: ActionRunSummaryViewLocale;
|
||||||
artifactCount: number;
|
artifactCount: number;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import {getIssueColorClass, getIssueIcon} from '../features/issue.ts';
|
|||||||
import {computed} from 'vue';
|
import {computed} from 'vue';
|
||||||
import type {Issue} from '../types.ts';
|
import type {Issue} from '../types.ts';
|
||||||
|
|
||||||
|
const {appSubUrl} = window.config;
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
issue?: Issue | null,
|
issue?: Issue | null,
|
||||||
renderedLabels?: string,
|
renderedLabels?: string,
|
||||||
@@ -26,7 +28,7 @@ const body = computed(() => {
|
|||||||
<div class="tw-p-4">
|
<div class="tw-p-4">
|
||||||
<div v-if="issue" class="tw-flex tw-flex-col tw-gap-2">
|
<div v-if="issue" class="tw-flex tw-flex-col tw-gap-2">
|
||||||
<div class="tw-text-12">
|
<div class="tw-text-12">
|
||||||
<a :href="issue.repository.html_url" class="muted">{{ issue.repository.full_name }}</a>
|
<a :href="`${appSubUrl}/${issue.repository.full_name}`" class="muted">{{ issue.repository.full_name }}</a>
|
||||||
on {{ createdAt }}
|
on {{ createdAt }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-text-block">
|
<div class="flex-text-block">
|
||||||
|
|||||||
@@ -24,6 +24,15 @@ type DashboardRepo = {
|
|||||||
|
|
||||||
type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning' | 'skipped';
|
type CommitStatus = 'pending' | 'success' | 'error' | 'failure' | 'warning' | 'skipped';
|
||||||
|
|
||||||
|
type WebSearchRepo = {
|
||||||
|
repository: DashboardRepo,
|
||||||
|
latest_commit_status: {
|
||||||
|
State: CommitStatus,
|
||||||
|
TargetURL: string,
|
||||||
|
} | null,
|
||||||
|
locale_latest_commit_status: string,
|
||||||
|
};
|
||||||
|
|
||||||
type CommitStatusMap = {
|
type CommitStatusMap = {
|
||||||
[status in CommitStatus]: {
|
[status in CommitStatus]: {
|
||||||
name: SvgName,
|
name: SvgName,
|
||||||
@@ -263,7 +272,7 @@ async function searchRepos() {
|
|||||||
const searchedURL = searchURL.value;
|
const searchedURL = searchURL.value;
|
||||||
const searchedQuery = searchQuery.value;
|
const searchedQuery = searchQuery.value;
|
||||||
|
|
||||||
let response: Response, json: any;
|
let response: Response, json: {data: WebSearchRepo[]};
|
||||||
try {
|
try {
|
||||||
const firstLoad = reposTotalCount.value === null;
|
const firstLoad = reposTotalCount.value === null;
|
||||||
// independent of the search, so both requests go out together
|
// independent of the search, so both requests go out together
|
||||||
@@ -293,7 +302,7 @@ async function searchRepos() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (searchedURL === searchURL.value) {
|
if (searchedURL === searchURL.value) {
|
||||||
repos.value = json.data.map((webSearchRepo: any) => {
|
repos.value = json.data.map((webSearchRepo) => {
|
||||||
return {
|
return {
|
||||||
...webSearchRepo.repository,
|
...webSearchRepo.repository,
|
||||||
latest_commit_status_state: webSearchRepo.latest_commit_status?.State, // if latest_commit_status is null, it means there is no commit status
|
latest_commit_status_state: webSearchRepo.latest_commit_status?.State, // if latest_commit_status is null, it means there is no commit status
|
||||||
|
|||||||
@@ -3,27 +3,55 @@ import {computed, onMounted, onUnmounted, shallowRef, watch} from 'vue';
|
|||||||
import SvgIcon from './SvgIcon.vue';
|
import SvgIcon from './SvgIcon.vue';
|
||||||
import {toggleElem} from '../utils/dom.ts';
|
import {toggleElem} from '../utils/dom.ts';
|
||||||
|
|
||||||
|
type MergeStyle = {
|
||||||
|
name: string,
|
||||||
|
allowed: boolean,
|
||||||
|
textDoMerge: string,
|
||||||
|
mergeTitleFieldText?: string,
|
||||||
|
mergeMessageFieldText?: string,
|
||||||
|
hideMergeMessageTexts?: boolean,
|
||||||
|
hideAutoMerge: boolean,
|
||||||
|
};
|
||||||
|
|
||||||
|
type MergeForm = {
|
||||||
|
allOverridableChecksOk: boolean,
|
||||||
|
baseLink: string,
|
||||||
|
canMergeNow: boolean,
|
||||||
|
defaultDeleteBranchAfterMerge: boolean,
|
||||||
|
defaultMergeMessage: string,
|
||||||
|
defaultMergeStyle: string,
|
||||||
|
emptyCommit: boolean,
|
||||||
|
hasPendingPullRequestMerge: boolean,
|
||||||
|
hasPendingPullRequestMergeTip: string,
|
||||||
|
isPullBranchDeletable: boolean,
|
||||||
|
mergeMessageFieldPlaceHolder: string,
|
||||||
|
mergeStyles: MergeStyle[],
|
||||||
|
pullHeadCommitID: string,
|
||||||
|
textAutoMergeButtonWhenSucceed: string,
|
||||||
|
textAutoMergeCancelSchedule: string,
|
||||||
|
textAutoMergeWhenSucceed: string,
|
||||||
|
textCancel: string,
|
||||||
|
textClearMergeMessage: string,
|
||||||
|
textClearMergeMessageHint: string,
|
||||||
|
textDeleteBranch: string,
|
||||||
|
textMergeCommitId: string,
|
||||||
|
};
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
mergeFormProps: any, // TODO: this is a huge object, need to be refactored in the future
|
mergeFormProps: MergeForm,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const mergeStyleManuallyMerged = 'manually-merged';
|
const mergeStyleManuallyMerged = 'manually-merged';
|
||||||
|
|
||||||
const mergeForm = props.mergeFormProps;
|
const mergeForm = props.mergeFormProps;
|
||||||
|
|
||||||
const mergeTitleFieldValue = shallowRef('');
|
const mergeTitleFieldValue = shallowRef<string | undefined>('');
|
||||||
const mergeMessageFieldValue = shallowRef('');
|
const mergeMessageFieldValue = shallowRef<string | undefined>('');
|
||||||
const deleteBranchAfterMerge = shallowRef(false);
|
const deleteBranchAfterMerge = shallowRef(false);
|
||||||
const autoMergeWhenSucceed = shallowRef(false);
|
const autoMergeWhenSucceed = shallowRef(false);
|
||||||
|
|
||||||
const mergeStyle = shallowRef('');
|
const mergeStyle = shallowRef('');
|
||||||
const mergeStyleDetail = shallowRef({
|
const mergeStyleDetail = shallowRef<MergeStyle>({name: '', allowed: false, textDoMerge: '', hideAutoMerge: false});
|
||||||
hideMergeMessageTexts: false,
|
|
||||||
textDoMerge: '',
|
|
||||||
mergeTitleFieldText: '',
|
|
||||||
mergeMessageFieldText: '',
|
|
||||||
hideAutoMerge: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const mergeStyleAllowedCount = shallowRef(0);
|
const mergeStyleAllowedCount = shallowRef(0);
|
||||||
|
|
||||||
@@ -48,18 +76,18 @@ const forceMerge = computed(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
watch(mergeStyle, (val) => {
|
watch(mergeStyle, (val) => {
|
||||||
mergeStyleDetail.value = mergeForm.mergeStyles.find((e: any) => e.name === val);
|
mergeStyleDetail.value = mergeForm.mergeStyles.find((e) => e.name === val)!;
|
||||||
for (const elem of document.querySelectorAll('[data-pull-merge-style]')) {
|
for (const elem of document.querySelectorAll('[data-pull-merge-style]')) {
|
||||||
toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val);
|
toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
mergeStyleAllowedCount.value = mergeForm.mergeStyles.reduce((v: any, msd: any) => v + (msd.allowed ? 1 : 0), 0);
|
mergeStyleAllowedCount.value = mergeForm.mergeStyles.reduce((v, msd) => v + (msd.allowed ? 1 : 0), 0);
|
||||||
|
|
||||||
let mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed && e.name === mergeForm.defaultMergeStyle)?.name;
|
let mergeStyle = mergeForm.mergeStyles.find((e) => e.allowed && e.name === mergeForm.defaultMergeStyle)?.name;
|
||||||
if (!mergeStyle) mergeStyle = mergeForm.mergeStyles.find((e: any) => e.allowed)?.name;
|
if (!mergeStyle) mergeStyle = mergeForm.mergeStyles.find((e) => e.allowed)?.name;
|
||||||
switchMergeStyle(mergeStyle, !mergeForm.canMergeNow);
|
if (mergeStyle) switchMergeStyle(mergeStyle, !mergeForm.canMergeNow);
|
||||||
|
|
||||||
document.addEventListener('mouseup', hideMergeStyleMenu);
|
document.addEventListener('mouseup', hideMergeStyleMenu);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import ActionStatusIcon from './ActionStatusIcon.vue';
|
|||||||
import {computed, onBeforeUnmount, ref, toRefs, watch} from 'vue';
|
import {computed, onBeforeUnmount, ref, toRefs, watch} from 'vue';
|
||||||
import {resetActionFavicon, syncActionRunFavicon} from '../modules/favicon-status.ts';
|
import {resetActionFavicon, syncActionRunFavicon} from '../modules/favicon-status.ts';
|
||||||
import {POST, DELETE} from '../modules/fetch.ts';
|
import {POST, DELETE} from '../modules/fetch.ts';
|
||||||
import ActionRunSummaryView from './ActionRunSummaryView.vue';
|
import ActionRunSummaryView, {type ActionRunSummaryViewLocale} from './ActionRunSummaryView.vue';
|
||||||
import ActionRunJobView from './ActionRunJobView.vue';
|
import ActionRunJobView, {type ActionRunJobViewLocale} from './ActionRunJobView.vue';
|
||||||
import type {ActionsJob, ActionsRunAttempt} from '../modules/gitea-actions.ts';
|
import type {ActionsJob, ActionsRunAttempt} from '../modules/gitea-actions.ts';
|
||||||
import {buildJobsByParentJobID, createActionRunViewStore} from './ActionRunView.ts';
|
import {buildJobsByParentJobID, createActionRunViewStore} from './ActionRunView.ts';
|
||||||
import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts';
|
import {buildArtifactTooltipHtml} from './ActionRunArtifacts.ts';
|
||||||
@@ -15,10 +15,35 @@ defineOptions({
|
|||||||
name: 'RepoActionView',
|
name: 'RepoActionView',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type RepoActionViewLocale = ActionRunSummaryViewLocale & ActionRunJobViewLocale & {
|
||||||
|
approve: string,
|
||||||
|
cancel: string,
|
||||||
|
rerun: string,
|
||||||
|
rerun_all: string,
|
||||||
|
rerun_failed: string,
|
||||||
|
latest: string,
|
||||||
|
latestAttempt: string,
|
||||||
|
attempt: string,
|
||||||
|
summary: string,
|
||||||
|
allJobs: string,
|
||||||
|
jobSummaries: string,
|
||||||
|
expandCallerJobs: string,
|
||||||
|
collapseCallerJobs: string,
|
||||||
|
backToPullRequest: string,
|
||||||
|
backToWorkflow: string,
|
||||||
|
artifactExpired: string,
|
||||||
|
artifactExpiresAt: string,
|
||||||
|
artifactExpiredAt: string,
|
||||||
|
confirmDeleteArtifact: string,
|
||||||
|
workflowFile: string,
|
||||||
|
workflowFileNoPermission: string,
|
||||||
|
runDetails: string,
|
||||||
|
};
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
jobId: number;
|
jobId: number;
|
||||||
actionsViewUrl: string;
|
actionsViewUrl: string;
|
||||||
locale: Record<string, any>;
|
locale: RepoActionViewLocale;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const locale = props.locale;
|
const locale = props.locale;
|
||||||
|
|||||||
@@ -15,15 +15,7 @@ const colors = shallowRef({
|
|||||||
textAltColor: 'white',
|
textAltColor: 'white',
|
||||||
});
|
});
|
||||||
|
|
||||||
type ActivityAuthorData = {
|
const activityTopAuthors = window.config.pageData.repoActivityTopAuthors || [];
|
||||||
avatar_link: string;
|
|
||||||
commits: number;
|
|
||||||
home_link: string;
|
|
||||||
login: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activityTopAuthors: Array<ActivityAuthorData> = window.config.pageData.repoActivityTopAuthors || [];
|
|
||||||
|
|
||||||
const graphWidth = activityTopAuthors.length * barSlotWidth;
|
const graphWidth = activityTopAuthors.length * barSlotWidth;
|
||||||
const maxCommits = Math.max(...activityTopAuthors.map((author) => author.commits));
|
const maxCommits = Math.max(...activityTopAuthors.map((author) => author.commits));
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ import {
|
|||||||
startDaysBetween,
|
startDaysBetween,
|
||||||
firstStartDateAfterDate,
|
firstStartDateAfterDate,
|
||||||
fillEmptyStartDaysWithZeroes,
|
fillEmptyStartDaysWithZeroes,
|
||||||
|
type DayData,
|
||||||
|
type DayDataObject,
|
||||||
} from '../utils/time.ts';
|
} from '../utils/time.ts';
|
||||||
import {errorMessage} from '../modules/errors.ts';
|
import {errorMessage} from '../modules/errors.ts';
|
||||||
import {sleep} from '../utils.ts';
|
import {sleep} from '../utils.ts';
|
||||||
@@ -67,12 +69,22 @@ function roundUpMax(maxValue: number) {
|
|||||||
return Math.ceil(coefficient) * 10 ** exp;
|
return Math.ceil(coefficient) * 10 ** exp;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ContributorsData = {
|
type ContributorInfo = {
|
||||||
total: {
|
name: string,
|
||||||
weeks: Record<string, any>,
|
avatar_link: string,
|
||||||
},
|
home_link: string,
|
||||||
[other: string]: Record<string, Record<string, any>>,
|
};
|
||||||
}
|
|
||||||
|
type ContributorStats = ContributorInfo & {weeks: DayData[]};
|
||||||
|
|
||||||
|
type Contributor = ContributorStats & {
|
||||||
|
email: string,
|
||||||
|
total_commits: number,
|
||||||
|
total_additions: number,
|
||||||
|
total_deletions: number,
|
||||||
|
max_contribution_type: number,
|
||||||
|
};
|
||||||
|
type ContributorsData = Record<string, ContributorInfo & {weeks: DayDataObject}>;
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
locale: {
|
locale: {
|
||||||
@@ -89,10 +101,10 @@ const props = defineProps<{
|
|||||||
|
|
||||||
const isLoading = shallowRef(false);
|
const isLoading = shallowRef(false);
|
||||||
const errorText = shallowRef('');
|
const errorText = shallowRef('');
|
||||||
const totalStats = shallowRef<Record<string, any>>({});
|
const totalStats = shallowRef<DayData[]>([]);
|
||||||
const sortedContributors = shallowRef<Array<Record<string, any>>>([]);
|
const sortedContributors = shallowRef<Contributor[]>([]);
|
||||||
const type = shallowRef<ContributionType>('commits');
|
const type = shallowRef<ContributionType>('commits');
|
||||||
let contributorsStats: Record<string, any> = {};
|
let contributorsStats: Record<string, ContributorStats> = {};
|
||||||
// plain values, so the main chart options do not follow the zoomed range
|
// plain values, so the main chart options do not follow the zoomed range
|
||||||
let xAxisStart: number | null = null;
|
let xAxisStart: number | null = null;
|
||||||
let xAxisEnd: number | null = null;
|
let xAxisEnd: number | null = null;
|
||||||
@@ -113,7 +125,7 @@ onMounted(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function sortContributors() {
|
function sortContributors() {
|
||||||
const criteria = `total_${type.value}`;
|
const criteria = `total_${type.value}` as const;
|
||||||
sortedContributors.value = filterContributorWeeksByDateRange()
|
sortedContributors.value = filterContributorWeeksByDateRange()
|
||||||
.filter((contributor) => contributor[criteria] !== 0)
|
.filter((contributor) => contributor[criteria] !== 0)
|
||||||
.sort((a, b) => b[criteria] - a[criteria])
|
.sort((a, b) => b[criteria] - a[criteria])
|
||||||
@@ -142,23 +154,22 @@ async function fetchGraphData() {
|
|||||||
}
|
}
|
||||||
} while (response.status === 202);
|
} while (response.status === 202);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json() as ContributorsData;
|
const data: ContributorsData = await response.json();
|
||||||
const {total, ...other} = data;
|
const {total, ...other} = data;
|
||||||
// below line might be deleted if we are sure go produces map always sorted by keys
|
// below line might be deleted if we are sure go produces map always sorted by keys
|
||||||
total.weeks = Object.fromEntries(Object.entries(total.weeks).sort());
|
const totalWeeks = Object.fromEntries(Object.entries(total.weeks).sort());
|
||||||
|
|
||||||
const weekValues = Object.values(total.weeks);
|
const weekValues = Object.values(totalWeeks);
|
||||||
xAxisStart = weekValues[0].week;
|
xAxisStart = weekValues[0].week;
|
||||||
xAxisEnd = firstStartDateAfterDate(new Date());
|
xAxisEnd = firstStartDateAfterDate(new Date());
|
||||||
const startDays = startDaysBetween(xAxisStart, xAxisEnd);
|
const startDays = startDaysBetween(xAxisStart, xAxisEnd);
|
||||||
total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks);
|
|
||||||
xAxisMin.value = xAxisStart;
|
xAxisMin.value = xAxisStart;
|
||||||
xAxisMax.value = xAxisEnd;
|
xAxisMax.value = xAxisEnd;
|
||||||
contributorsStats = Object.fromEntries(Object.entries(other).map(([email, user]) => {
|
contributorsStats = Object.fromEntries(Object.entries(other).map(([email, user]) => {
|
||||||
return [email, {...user, weeks: fillEmptyStartDaysWithZeroes(startDays, user.weeks)}];
|
return [email, {...user, weeks: fillEmptyStartDaysWithZeroes(startDays, user.weeks)}];
|
||||||
}));
|
}));
|
||||||
sortContributors();
|
sortContributors();
|
||||||
totalStats.value = total;
|
totalStats.value = fillEmptyStartDaysWithZeroes(startDays, totalWeeks);
|
||||||
errorText.value = '';
|
errorText.value = '';
|
||||||
} else {
|
} else {
|
||||||
errorText.value = response.statusText;
|
errorText.value = response.statusText;
|
||||||
@@ -171,22 +182,22 @@ async function fetchGraphData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filterContributorWeeksByDateRange() {
|
function filterContributorWeeksByDateRange() {
|
||||||
const filteredData: Array<Record<string, any>> = [];
|
const filteredData: Contributor[] = [];
|
||||||
const minTime = xAxisMin.value! - oneWeek;
|
const minTime = xAxisMin.value! - oneWeek;
|
||||||
const maxTime = xAxisMax.value! + oneWeek;
|
const maxTime = xAxisMax.value! + oneWeek;
|
||||||
const contributionType = type.value;
|
const contributionType = type.value;
|
||||||
for (const [key, user] of Object.entries(contributorsStats)) {
|
for (const [key, user] of Object.entries(contributorsStats)) {
|
||||||
user.total_commits = 0;
|
let totalCommits = 0;
|
||||||
user.total_additions = 0;
|
let totalAdditions = 0;
|
||||||
user.total_deletions = 0;
|
let totalDeletions = 0;
|
||||||
user.max_contribution_type = 0;
|
let maxContributionType = 0;
|
||||||
const filteredWeeks = user.weeks.filter((week: Record<string, number>) => {
|
const filteredWeeks = user.weeks.filter((week) => {
|
||||||
if (week.week >= minTime && week.week <= maxTime) {
|
if (week.week >= minTime && week.week <= maxTime) {
|
||||||
user.total_commits += week.commits;
|
totalCommits += week.commits;
|
||||||
user.total_additions += week.additions;
|
totalAdditions += week.additions;
|
||||||
user.total_deletions += week.deletions;
|
totalDeletions += week.deletions;
|
||||||
if (week[contributionType] > user.max_contribution_type) {
|
if (week[contributionType] > maxContributionType) {
|
||||||
user.max_contribution_type = week[contributionType];
|
maxContributionType = week[contributionType];
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -194,24 +205,32 @@ function filterContributorWeeksByDateRange() {
|
|||||||
});
|
});
|
||||||
// this line is required. See https://github.com/sahinakkaya/gitea/pull/3#discussion_r1396495722
|
// this line is required. See https://github.com/sahinakkaya/gitea/pull/3#discussion_r1396495722
|
||||||
// for details.
|
// for details.
|
||||||
user.max_contribution_type += 1;
|
maxContributionType += 1;
|
||||||
|
|
||||||
filteredData.push({...user, weeks: filteredWeeks, email: key});
|
filteredData.push({
|
||||||
|
...user,
|
||||||
|
weeks: filteredWeeks,
|
||||||
|
total_commits: totalCommits,
|
||||||
|
total_additions: totalAdditions,
|
||||||
|
total_deletions: totalDeletions,
|
||||||
|
max_contribution_type: maxContributionType,
|
||||||
|
email: key,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return filteredData;
|
return filteredData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const maxMainGraph = computed(() => {
|
const maxMainGraph = computed(() => {
|
||||||
return roundUpMax(Math.max(...totalStats.value.weeks.map((o: Record<string, any>) => o[type.value])));
|
return roundUpMax(Math.max(...totalStats.value.map((o) => o[type.value])));
|
||||||
});
|
});
|
||||||
|
|
||||||
// one shared maximum, otherwise the contributor graphs cannot be compared
|
// one shared maximum, otherwise the contributor graphs cannot be compared
|
||||||
const maxContributorGraph = computed(() => {
|
const maxContributorGraph = computed(() => {
|
||||||
return roundUpMax(Math.max(...sortedContributors.value.map((c: Record<string, any>) => c.max_contribution_type)));
|
return roundUpMax(Math.max(...sortedContributors.value.map((c) => c.max_contribution_type)));
|
||||||
});
|
});
|
||||||
|
|
||||||
function toGraphData(data: Array<Record<string, any>>): ChartData<'line'> {
|
function toGraphData(data: DayData[]): ChartData<'line'> {
|
||||||
const contributionType = type.value;
|
const contributionType = type.value;
|
||||||
return {
|
return {
|
||||||
datasets: [
|
datasets: [
|
||||||
@@ -319,7 +338,7 @@ function getOptions(chartType: ChartType): LineOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mainChart = computed(() => ({
|
const mainChart = computed(() => ({
|
||||||
graphData: toGraphData(totalStats.value.weeks),
|
graphData: toGraphData(totalStats.value),
|
||||||
chartOptions: getOptions('main'),
|
chartOptions: getOptions('main'),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -390,7 +409,7 @@ const contributorCharts = computed(() => sortedContributors.value.map((contribut
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ChartCanvas
|
<ChartCanvas
|
||||||
v-if="Object.keys(totalStats).length !== 0"
|
v-if="totalStats.length"
|
||||||
type="line" :data="mainChart.graphData" :options="mainChart.chartOptions"
|
type="line" :data="mainChart.graphData" :options="mainChart.chartOptions"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,6 +18,18 @@ import {
|
|||||||
type RoutedEdge,
|
type RoutedEdge,
|
||||||
} from './WorkflowGraph.utils.ts';
|
} from './WorkflowGraph.utils.ts';
|
||||||
|
|
||||||
|
export type WorkflowGraphLocale = {
|
||||||
|
graphJobsCount1: string,
|
||||||
|
graphJobsCountN: string,
|
||||||
|
graphDependenciesCount1: string,
|
||||||
|
graphDependenciesCountN: string,
|
||||||
|
graphSuccessRate: string,
|
||||||
|
graphZoomIn: string,
|
||||||
|
graphZoomMax: string,
|
||||||
|
graphZoomOut: string,
|
||||||
|
graphResetView: string,
|
||||||
|
};
|
||||||
|
|
||||||
interface StoredState {
|
interface StoredState {
|
||||||
scale: number;
|
scale: number;
|
||||||
translateX: number;
|
translateX: number;
|
||||||
@@ -32,7 +44,7 @@ const props = defineProps<{
|
|||||||
workflowId: string;
|
workflowId: string;
|
||||||
workflowLink?: string;
|
workflowLink?: string;
|
||||||
triggerEvent?: string;
|
triggerEvent?: string;
|
||||||
locale: Record<string, string>;
|
locale: WorkflowGraphLocale;
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const settingKeyStates = 'actions-graph-states';
|
const settingKeyStates = 'actions-graph-states';
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ body { background: ${backgroundColor}; }
|
|||||||
|
|
||||||
const iframeId = queryParams.get('gitea-iframe-id');
|
const iframeId = queryParams.get('gitea-iframe-id');
|
||||||
// iframe is in different origin, so we need to use postMessage to communicate
|
// iframe is in different origin, so we need to use postMessage to communicate
|
||||||
const postIframeMsg = (cmd: string, data: Record<string, any> = {}) => {
|
const postIframeMsg = (cmd: 'resize' | 'open-link', data: Record<string, string | number | null> = {}) => {
|
||||||
window.parent.postMessage({giteaIframeCmd: cmd, giteaIframeId: iframeId, ...data}, '*');
|
window.parent.postMessage({giteaIframeCmd: cmd, giteaIframeId: iframeId, ...data}, '*');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function initSystemConfigAutoCheckbox(el: HTMLInputElement) {
|
|||||||
value: String(collectCheckboxBooleanValue(el)),
|
value: String(collectCheckboxBooleanValue(el)),
|
||||||
});
|
});
|
||||||
const resp = await POST(`${appSubUrl}/-/admin/config`, {data});
|
const resp = await POST(`${appSubUrl}/-/admin/config`, {data});
|
||||||
const json: Record<string, any> = await resp.json();
|
const json: {errorMessage?: string} = await resp.json();
|
||||||
if (json.errorMessage) throw new Error(json.errorMessage);
|
if (json.errorMessage) throw new Error(json.errorMessage);
|
||||||
} catch (ex) {
|
} catch (ex) {
|
||||||
showTemporaryTooltip(el, errorMessage(ex));
|
showTemporaryTooltip(el, errorMessage(ex));
|
||||||
@@ -112,7 +112,7 @@ export class ConfigFormValueMapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
collectConfigValueFromElement(el: GeneralFormFieldElement) {
|
collectConfigValueFromElement(el: GeneralFormFieldElement) {
|
||||||
let val: any;
|
let val: boolean | number | string;
|
||||||
const valType = this.presetValueTypes[el.name];
|
const valType = this.presetValueTypes[el.name];
|
||||||
if (el.matches('[type="checkbox"]')) {
|
if (el.matches('[type="checkbox"]')) {
|
||||||
// TODO: if it needs to support array values in the future,
|
// TODO: if it needs to support array values in the future,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export async function initAdminSelfCheck() {
|
|||||||
now: String(Date.now()), // TODO: check time difference between server and client
|
now: String(Date.now()), // TODO: check time difference between server and client
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const json: Record<string, any> = await resp.json();
|
const json: {problems: string[] | null} = await resp.json();
|
||||||
toggleElem(elCheckByFrontend, Boolean(json.problems?.length));
|
toggleElem(elCheckByFrontend, Boolean(json.problems?.length));
|
||||||
for (const problem of json.problems ?? []) {
|
for (const problem of json.problems ?? []) {
|
||||||
const elProblem = document.createElement('div');
|
const elProblem = document.createElement('div');
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ export type ElementWithAssignableProperties = {
|
|||||||
nodeName: string;
|
nodeName: string;
|
||||||
getAttribute: (name: string) => string | null;
|
getAttribute: (name: string) => string | null;
|
||||||
setAttribute: (name: string, value: string) => void;
|
setAttribute: (name: string, value: string) => void;
|
||||||
} & Record<string, any>;
|
};
|
||||||
|
|
||||||
export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) {
|
export function assignElementProperty(el: ElementWithAssignableProperties, kebabName: string, val: string) {
|
||||||
if (el.nodeName === 'FORM') {
|
if (el.nodeName === 'FORM') {
|
||||||
@@ -59,14 +59,15 @@ export function assignElementProperty(el: ElementWithAssignableProperties, kebab
|
|||||||
if (kebabName === 'url') kebabName = 'action';
|
if (kebabName === 'url') kebabName = 'action';
|
||||||
}
|
}
|
||||||
const camelizedName = camelize(kebabName);
|
const camelizedName = camelize(kebabName);
|
||||||
const old = el[camelizedName];
|
const properties: Record<string, unknown> = el;
|
||||||
|
const old = properties[camelizedName];
|
||||||
if (typeof old === 'boolean') {
|
if (typeof old === 'boolean') {
|
||||||
el[camelizedName] = val === 'true';
|
properties[camelizedName] = val === 'true';
|
||||||
} else if (typeof old === 'number') {
|
} else if (typeof old === 'number') {
|
||||||
el[camelizedName] = parseFloat(val);
|
properties[camelizedName] = parseFloat(val);
|
||||||
} else if (typeof old === 'string') {
|
} else if (typeof old === 'string') {
|
||||||
el[camelizedName] = val;
|
properties[camelizedName] = val;
|
||||||
} else if (old?.nodeName) {
|
} else if (old && typeof old === 'object' && 'nodeName' in old) {
|
||||||
// "form" has an edge case: its "<input name=action>" element overwrites the "action" property, we can only set attribute
|
// "form" has an edge case: its "<input name=action>" element overwrites the "action" property, we can only set attribute
|
||||||
el.setAttribute(kebabName, val);
|
el.setAttribute(kebabName, val);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.ts';
|
import {handleGlobalEnterQuickSubmit} from './QuickSubmit.ts';
|
||||||
import {renderPreviewPanelContent} from '../repo-editor.ts';
|
import {renderPreviewPanelContent} from '../repo-editor.ts';
|
||||||
import {toggleTasklistCheckbox} from '../../markup/tasklist.ts';
|
import {toggleTasklistCheckbox} from '../../markup/tasklist.ts';
|
||||||
import {easyMDEToolbarActions} from './EasyMDEToolbarActions.ts';
|
import {easyMDEToolbarActions, type EasyMdeToolbarAction} from './EasyMDEToolbarActions.ts';
|
||||||
import {initTextExpander} from './TextExpander.ts';
|
import {initTextExpander} from './TextExpander.ts';
|
||||||
import {showErrorToast} from '../../modules/toast.ts';
|
import {showErrorToast} from '../../modules/toast.ts';
|
||||||
import {POST} from '../../modules/fetch.ts';
|
import {POST} from '../../modules/fetch.ts';
|
||||||
@@ -25,6 +25,7 @@ import {DropzoneCustomEventReloadFiles, initDropzone} from '../dropzone.ts';
|
|||||||
import {createTippy} from '../../modules/tippy.ts';
|
import {createTippy} from '../../modules/tippy.ts';
|
||||||
import {initTabSwitcher} from '../../modules/fomantic/tab.ts';
|
import {initTabSwitcher} from '../../modules/fomantic/tab.ts';
|
||||||
import type EasyMDE from 'easymde';
|
import type EasyMDE from 'easymde';
|
||||||
|
import type Dropzone from '@deltablot/dropzone';
|
||||||
import {localUserSettings} from '../../modules/user-settings.ts';
|
import {localUserSettings} from '../../modules/user-settings.ts';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,11 +58,11 @@ type Heights = {
|
|||||||
|
|
||||||
type ComboMarkdownEditorOptions = {
|
type ComboMarkdownEditorOptions = {
|
||||||
editorHeights?: Heights,
|
editorHeights?: Heights,
|
||||||
easyMDEOptions?: EasyMDE.Options,
|
easyMDEOptions?: Omit<EasyMDE.Options, 'toolbar'> & {toolbar?: ReadonlyArray<string>},
|
||||||
};
|
};
|
||||||
|
|
||||||
type ComboMarkdownEditorTextarea = HTMLTextAreaElement & {_giteaComboMarkdownEditor: any};
|
type ComboMarkdownEditorTextarea = HTMLTextAreaElement & {_giteaComboMarkdownEditor: ComboMarkdownEditor};
|
||||||
type ComboMarkdownEditorContainer = HTMLElement & {_giteaComboMarkdownEditor?: any};
|
type ComboMarkdownEditorContainer = HTMLElement & {_giteaComboMarkdownEditor?: ComboMarkdownEditor};
|
||||||
|
|
||||||
export class ComboMarkdownEditor {
|
export class ComboMarkdownEditor {
|
||||||
static EventEditorContentChanged = EventEditorContentChanged;
|
static EventEditorContentChanged = EventEditorContentChanged;
|
||||||
@@ -75,18 +76,18 @@ export class ComboMarkdownEditor {
|
|||||||
tabPreviewer?: HTMLElement;
|
tabPreviewer?: HTMLElement;
|
||||||
|
|
||||||
supportEasyMDE!: boolean;
|
supportEasyMDE!: boolean;
|
||||||
easyMDE: any;
|
easyMDE: EasyMDE | null = null;
|
||||||
easyMDEToolbarActions: any;
|
easyMDEToolbarActions?: Record<string, EasyMdeToolbarAction>;
|
||||||
easyMDEToolbarDefault: any;
|
easyMDEToolbarDefault!: string[];
|
||||||
|
|
||||||
textarea!: ComboMarkdownEditorTextarea;
|
textarea!: ComboMarkdownEditorTextarea;
|
||||||
textareaMarkdownToolbar!: HTMLElement;
|
textareaMarkdownToolbar!: HTMLElement;
|
||||||
textareaAutosize: any;
|
textareaAutosize?: ReturnType<typeof autosize>;
|
||||||
|
|
||||||
buttonMonospace!: HTMLButtonElement;
|
buttonMonospace!: HTMLButtonElement;
|
||||||
|
|
||||||
dropzone: HTMLElement | null = null;
|
dropzone: HTMLElement | null = null;
|
||||||
attachedDropzoneInst: any;
|
attachedDropzoneInst?: Dropzone;
|
||||||
|
|
||||||
previewMode!: string;
|
previewMode!: string;
|
||||||
previewUrl!: string;
|
previewUrl!: string;
|
||||||
@@ -188,19 +189,19 @@ export class ComboMarkdownEditor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dropzoneReloadFiles() {
|
dropzoneReloadFiles() {
|
||||||
if (!this.dropzone) return;
|
if (!this.attachedDropzoneInst) return;
|
||||||
this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
|
this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
dropzoneSubmitReload() {
|
dropzoneSubmitReload() {
|
||||||
if (!this.dropzone) return;
|
if (!this.attachedDropzoneInst) return;
|
||||||
this.attachedDropzoneInst.emit('submit');
|
this.attachedDropzoneInst.emit('submit');
|
||||||
this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
|
this.attachedDropzoneInst.emit(DropzoneCustomEventReloadFiles);
|
||||||
}
|
}
|
||||||
|
|
||||||
isUploading() {
|
isUploading() {
|
||||||
if (!this.dropzone) return false;
|
if (!this.attachedDropzoneInst) return false;
|
||||||
return this.attachedDropzoneInst.getQueuedFiles().length || this.attachedDropzoneInst.getUploadingFiles().length;
|
return Boolean(this.attachedDropzoneInst.getQueuedFiles().length || this.attachedDropzoneInst.getUploadingFiles().length);
|
||||||
}
|
}
|
||||||
|
|
||||||
setupTab() {
|
setupTab() {
|
||||||
@@ -300,9 +301,9 @@ export class ComboMarkdownEditor {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions: any) {
|
parseEasyMDEToolbar(easyMde: typeof EasyMDE, actions: ReadonlyArray<string>) {
|
||||||
this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(easyMde, this);
|
this.easyMDEToolbarActions = this.easyMDEToolbarActions || easyMDEToolbarActions(easyMde, this);
|
||||||
const processed = [];
|
const processed: EasyMdeToolbarAction[] = [];
|
||||||
for (const action of actions) {
|
for (const action of actions) {
|
||||||
const actionButton = this.easyMDEToolbarActions[action];
|
const actionButton = this.easyMDEToolbarActions[action];
|
||||||
if (!actionButton) throw new Error(`Unknown EasyMDE toolbar action ${action}`);
|
if (!actionButton) throw new Error(`Unknown EasyMDE toolbar action ${action}`);
|
||||||
@@ -345,27 +346,27 @@ export class ComboMarkdownEditor {
|
|||||||
inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
|
inputStyle: 'contenteditable', // nativeSpellcheck requires contenteditable
|
||||||
nativeSpellcheck: true,
|
nativeSpellcheck: true,
|
||||||
...this.options.easyMDEOptions,
|
...this.options.easyMDEOptions,
|
||||||
|
toolbar: this.parseEasyMDEToolbar(EasyMDE, this.options.easyMDEOptions?.toolbar ?? this.easyMDEToolbarDefault) as EasyMDE.Options['toolbar'],
|
||||||
};
|
};
|
||||||
easyMDEOpt.toolbar = this.parseEasyMDEToolbar(EasyMDE, easyMDEOpt.toolbar ?? this.easyMDEToolbarDefault);
|
|
||||||
|
|
||||||
this.easyMDE = new EasyMDE(easyMDEOpt);
|
this.easyMDE = new EasyMDE(easyMDEOpt);
|
||||||
this.easyMDE.codemirror.on('change', () => triggerEditorContentChanged(this.container));
|
this.easyMDE.codemirror.on('change', () => triggerEditorContentChanged(this.container));
|
||||||
this.easyMDE.codemirror.setOption('extraKeys', {
|
this.easyMDE.codemirror.setOption('extraKeys', {
|
||||||
'Cmd-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
|
'Cmd-Enter': () => { handleGlobalEnterQuickSubmit(this.textarea) },
|
||||||
'Ctrl-Enter': (cm: any) => handleGlobalEnterQuickSubmit(cm.getTextArea()),
|
'Ctrl-Enter': () => { handleGlobalEnterQuickSubmit(this.textarea) },
|
||||||
Enter: (cm: any) => {
|
Enter: (cm) => {
|
||||||
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
|
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
|
||||||
if (!tributeContainer || tributeContainer.style.display === 'none') {
|
if (!tributeContainer || tributeContainer.style.display === 'none') {
|
||||||
cm.execCommand('newlineAndIndent');
|
cm.execCommand('newlineAndIndent');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Up: (cm: any) => {
|
Up: (cm) => {
|
||||||
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
|
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
|
||||||
if (!tributeContainer || tributeContainer.style.display === 'none') {
|
if (!tributeContainer || tributeContainer.style.display === 'none') {
|
||||||
return cm.execCommand('goLineUp');
|
return cm.execCommand('goLineUp');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
Down: (cm: any) => {
|
Down: (cm) => {
|
||||||
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
|
const tributeContainer = document.querySelector<HTMLElement>('.tribute-container');
|
||||||
if (!tributeContainer || tributeContainer.style.display === 'none') {
|
if (!tributeContainer || tributeContainer.style.display === 'none') {
|
||||||
return cm.execCommand('goLineDown');
|
return cm.execCommand('goLineDown');
|
||||||
@@ -380,20 +381,16 @@ export class ComboMarkdownEditor {
|
|||||||
hideElem(this.textareaMarkdownToolbar);
|
hideElem(this.textareaMarkdownToolbar);
|
||||||
}
|
}
|
||||||
|
|
||||||
value(v?: any) {
|
value(v?: string): string {
|
||||||
if (v === undefined) {
|
if (v !== undefined) {
|
||||||
if (this.easyMDE) {
|
if (this.easyMDE) {
|
||||||
return this.easyMDE.value();
|
this.easyMDE.value(v);
|
||||||
|
} else {
|
||||||
|
this.textarea.value = v;
|
||||||
}
|
}
|
||||||
return this.textarea.value;
|
this.textareaAutosize?.resizeToFit();
|
||||||
}
|
}
|
||||||
|
return this.easyMDE ? this.easyMDE.value() : this.textarea.value;
|
||||||
if (this.easyMDE) {
|
|
||||||
this.easyMDE.value(v);
|
|
||||||
} else {
|
|
||||||
this.textarea.value = v;
|
|
||||||
}
|
|
||||||
this.textareaAutosize?.resizeToFit();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
focus() {
|
focus() {
|
||||||
@@ -438,10 +435,9 @@ function applyMonospaceToAllEditors() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getComboMarkdownEditor(el: any): ComboMarkdownEditor | null {
|
export function getComboMarkdownEditor(el: Element | null): ComboMarkdownEditor | null {
|
||||||
if (!el) return null;
|
if (!el) return null;
|
||||||
if (el.length) el = el[0];
|
return (el as ComboMarkdownEditorContainer)._giteaComboMarkdownEditor ?? null;
|
||||||
return el._giteaComboMarkdownEditor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function initComboMarkdownEditor(container: HTMLElement, options:ComboMarkdownEditorOptions = {}) {
|
export async function initComboMarkdownEditor(container: HTMLElement, options:ComboMarkdownEditorOptions = {}) {
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ import {svg} from '../../svg.ts';
|
|||||||
import type EasyMDE from 'easymde';
|
import type EasyMDE from 'easymde';
|
||||||
import type {ComboMarkdownEditor} from './ComboMarkdownEditor.ts';
|
import type {ComboMarkdownEditor} from './ComboMarkdownEditor.ts';
|
||||||
|
|
||||||
export function easyMDEToolbarActions(easyMde: typeof EasyMDE, editor: ComboMarkdownEditor): Record<string, Partial<EasyMDE.ToolbarIcon | string>> {
|
export type EasyMdeToolbarAction = {
|
||||||
const actions: Record<string, Partial<EasyMDE.ToolbarIcon> | string> = {
|
name?: string,
|
||||||
|
action: EasyMDE.ToolbarIcon['action'],
|
||||||
|
icon: string,
|
||||||
|
title: string,
|
||||||
|
} | '|';
|
||||||
|
|
||||||
|
export function easyMDEToolbarActions(easyMde: typeof EasyMDE, editor: ComboMarkdownEditor): Record<string, EasyMdeToolbarAction> {
|
||||||
|
const actions: Record<string, EasyMdeToolbarAction> = {
|
||||||
'|': '|',
|
'|': '|',
|
||||||
'heading-1': {
|
'heading-1': {
|
||||||
action: easyMde.toggleHeading1,
|
action: easyMde.toggleHeading1,
|
||||||
|
|||||||
@@ -12,18 +12,20 @@ import type Dropzone from '@deltablot/dropzone';
|
|||||||
|
|
||||||
let uploadIdCounter = 0;
|
let uploadIdCounter = 0;
|
||||||
|
|
||||||
|
type UploadFile = File & {_giteaUploadId?: number, uuid?: string};
|
||||||
|
|
||||||
export const EventUploadStateChanged = 'ce-upload-state-changed';
|
export const EventUploadStateChanged = 'ce-upload-state-changed';
|
||||||
|
|
||||||
export function triggerUploadStateChanged(target: HTMLElement) {
|
export function triggerUploadStateChanged(target: HTMLElement) {
|
||||||
target.dispatchEvent(new CustomEvent(EventUploadStateChanged, {bubbles: true}));
|
target.dispatchEvent(new CustomEvent(EventUploadStateChanged, {bubbles: true}));
|
||||||
}
|
}
|
||||||
|
|
||||||
function uploadFile(dropzoneEl: HTMLElement, file: File) {
|
function uploadFile(dropzoneEl: HTMLElement, file: UploadFile) {
|
||||||
return new Promise((resolve) => {
|
return new Promise<UploadFile>((resolve) => {
|
||||||
const curUploadId = uploadIdCounter++;
|
const curUploadId = uploadIdCounter++;
|
||||||
(file as any)._giteaUploadId = curUploadId;
|
file._giteaUploadId = curUploadId;
|
||||||
const dropzoneInst = dropzoneEl.dropzone;
|
const dropzoneInst = dropzoneEl.dropzone;
|
||||||
const onUploadDone = ({file}: {file: any}) => {
|
const onUploadDone = ({file}: {file: UploadFile}) => {
|
||||||
if (file._giteaUploadId === curUploadId) {
|
if (file._giteaUploadId === curUploadId) {
|
||||||
dropzoneInst.off(DropzoneCustomEventUploadDone, onUploadDone);
|
dropzoneInst.off(DropzoneCustomEventUploadDone, onUploadDone);
|
||||||
resolve(file);
|
resolve(file);
|
||||||
@@ -131,7 +133,7 @@ function getPastedImages(e: ClipboardEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
|
export function initEasyMDEPaste(easyMDE: EasyMDE, dropzoneEl: HTMLElement) {
|
||||||
const editor = new CodeMirrorEditor(easyMDE.codemirror as any);
|
const editor = new CodeMirrorEditor(easyMDE.codemirror as CodeMirror.EditorFromTextArea);
|
||||||
easyMDE.codemirror.on('paste', (_, e) => {
|
easyMDE.codemirror.on('paste', (_, e) => {
|
||||||
const images = getPastedImages(e);
|
const images = getPastedImages(e);
|
||||||
if (!images.length) return;
|
if (!images.length) return;
|
||||||
|
|||||||
@@ -10,16 +10,12 @@ import type TextExpanderElement from '@github/text-expander-element';
|
|||||||
import type {TextExpanderChangeEvent, TextExpanderResult} from '@github/text-expander-element';
|
import type {TextExpanderChangeEvent, TextExpanderResult} from '@github/text-expander-element';
|
||||||
|
|
||||||
async function fetchIssueSuggestions(key: string, text: string, signal: AbortSignal): Promise<TextExpanderResult> {
|
async function fetchIssueSuggestions(key: string, text: string, signal: AbortSignal): Promise<TextExpanderResult> {
|
||||||
const issuePathInfo = parseIssueHref(window.location.href);
|
const hrefPathInfo = parseIssueHref(window.location.href);
|
||||||
if (!issuePathInfo.ownerName) {
|
// the fallback has no indexString, it is only used to exclude the current issue when "matchIssue"
|
||||||
const repoOwnerPathInfo = parseRepoOwnerPathInfo(window.location.pathname);
|
const pathInfo = hrefPathInfo ?? parseRepoOwnerPathInfo(window.location.pathname);
|
||||||
issuePathInfo.ownerName = repoOwnerPathInfo.ownerName;
|
if (!pathInfo) return {matched: false};
|
||||||
issuePathInfo.repoName = repoOwnerPathInfo.repoName;
|
|
||||||
// then no issuePathInfo.indexString here, it is only used to exclude the current issue when "matchIssue"
|
|
||||||
}
|
|
||||||
if (!issuePathInfo.ownerName) return {matched: false};
|
|
||||||
|
|
||||||
const matches = await matchIssue(issuePathInfo.ownerName, issuePathInfo.repoName, issuePathInfo.indexString, text, signal);
|
const matches = await matchIssue(pathInfo.ownerName, pathInfo.repoName, hrefPathInfo?.indexString, text, signal);
|
||||||
if (!matches.length) return {matched: false};
|
if (!matches.length) return {matched: false};
|
||||||
|
|
||||||
const ul = createElementFromAttrs('ul', {class: 'suggestions'});
|
const ul = createElementFromAttrs('ul', {class: 'suggestions'});
|
||||||
@@ -131,7 +127,8 @@ export function initTextExpander(expander: TextExpanderElement) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
expander.addEventListener('text-expander-value', ({detail}: Record<string, any>) => {
|
expander.addEventListener('text-expander-value', (event) => {
|
||||||
|
const {detail} = event as CustomEvent<{item: HTMLElement, key: string, value: string}>;
|
||||||
if (detail?.item) {
|
if (detail?.item) {
|
||||||
// add a space after @mentions and #issue as it's likely the user wants one
|
// add a space after @mentions and #issue as it's likely the user wants one
|
||||||
const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
|
const suffix = ['@', '#'].includes(detail.key) ? ' ' : '';
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {isImageFile, isVideoFile} from '../utils.ts';
|
|||||||
import type Dropzone from '@deltablot/dropzone';
|
import type Dropzone from '@deltablot/dropzone';
|
||||||
|
|
||||||
type CustomDropzoneFile = Dropzone.DropzoneFile & {uuid: string};
|
type CustomDropzoneFile = Dropzone.DropzoneFile & {uuid: string};
|
||||||
|
type UploadResponse = {uuid: string};
|
||||||
|
|
||||||
// dropzone has its owner event dispatcher (emitter)
|
// dropzone has its owner event dispatcher (emitter)
|
||||||
export const DropzoneCustomEventReloadFiles = 'dropzone-custom-reload-files';
|
export const DropzoneCustomEventReloadFiles = 'dropzone-custom-reload-files';
|
||||||
@@ -69,19 +70,20 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
|
|||||||
|
|
||||||
let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event
|
let disableRemovedfileEvent = false; // when resetting the dropzone (removeAllFiles), disable the "removedfile" event
|
||||||
let fileUuidDict: FileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone
|
let fileUuidDict: FileUuidDict = {}; // to record: if a comment has been saved, then the uploaded files won't be deleted from server when clicking the Remove in the dropzone
|
||||||
const opts: Record<string, any> = {
|
const opts: Dropzone.DropzoneOptions = {
|
||||||
url: dropzoneEl.getAttribute('data-upload-url'),
|
url: dropzoneEl.getAttribute('data-upload-url')!,
|
||||||
acceptedFiles: ['*/*', ''].includes(dropzoneEl.getAttribute('data-accepts')!) ? null : dropzoneEl.getAttribute('data-accepts'),
|
|
||||||
addRemoveLinks: true,
|
addRemoveLinks: true,
|
||||||
dictDefaultMessage: dropzoneEl.getAttribute('data-default-message'),
|
dictDefaultMessage: dropzoneEl.getAttribute('data-default-message')!,
|
||||||
dictInvalidFileType: dropzoneEl.getAttribute('data-invalid-input-type'),
|
dictInvalidFileType: dropzoneEl.getAttribute('data-invalid-input-type')!,
|
||||||
dictFileTooBig: dropzoneEl.getAttribute('data-file-too-big'),
|
dictFileTooBig: dropzoneEl.getAttribute('data-file-too-big')!,
|
||||||
dictRemoveFile: dropzoneEl.getAttribute('data-remove-file'),
|
dictRemoveFile: dropzoneEl.getAttribute('data-remove-file')!,
|
||||||
timeout: 0,
|
timeout: 0,
|
||||||
thumbnailMethod: 'contain',
|
thumbnailMethod: 'contain',
|
||||||
thumbnailWidth: 480,
|
thumbnailWidth: 480,
|
||||||
thumbnailHeight: 480,
|
thumbnailHeight: 480,
|
||||||
};
|
};
|
||||||
|
const accepts = dropzoneEl.getAttribute('data-accepts')!;
|
||||||
|
if (!['*/*', ''].includes(accepts)) opts.acceptedFiles = accepts;
|
||||||
if (dropzoneEl.hasAttribute('data-max-file')) opts.maxFiles = Number(dropzoneEl.getAttribute('data-max-file'));
|
if (dropzoneEl.hasAttribute('data-max-file')) opts.maxFiles = Number(dropzoneEl.getAttribute('data-max-file'));
|
||||||
if (dropzoneEl.hasAttribute('data-max-size')) opts.maxFilesize = Number(dropzoneEl.getAttribute('data-max-size'));
|
if (dropzoneEl.hasAttribute('data-max-size')) opts.maxFilesize = Number(dropzoneEl.getAttribute('data-max-size'));
|
||||||
|
|
||||||
@@ -89,7 +91,7 @@ export async function initDropzone(dropzoneEl: HTMLElement) {
|
|||||||
// "http://localhost:3000/owner/repo/issues/[object%20Event]"
|
// "http://localhost:3000/owner/repo/issues/[object%20Event]"
|
||||||
// the reason is that the preview "callback(dataURL)" is assign to "img.onerror" then "thumbnail" uses the error object as the dataURL and generates '<img src="[object Event]">'
|
// the reason is that the preview "callback(dataURL)" is assign to "img.onerror" then "thumbnail" uses the error object as the dataURL and generates '<img src="[object Event]">'
|
||||||
const dzInst = await createDropzone(dropzoneEl, opts);
|
const dzInst = await createDropzone(dropzoneEl, opts);
|
||||||
dzInst.on('success', (file: CustomDropzoneFile, resp: any) => {
|
dzInst.on('success', (file: CustomDropzoneFile, resp: UploadResponse) => {
|
||||||
file.uuid = resp.uuid;
|
file.uuid = resp.uuid;
|
||||||
fileUuidDict[file.uuid] = {submitted: false};
|
fileUuidDict[file.uuid] = {submitted: false};
|
||||||
const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${resp.uuid}`, value: resp.uuid});
|
const input = createElementFromAttrs('input', {name: 'files', type: 'hidden', id: `dropzone-file-${resp.uuid}`, value: resp.uuid});
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export function initDiffFileViewedForm(el: Element) {
|
|||||||
// Unfortunately, actual forms cause too many problems, hence another approach is needed
|
// Unfortunately, actual forms cause too many problems, hence another approach is needed
|
||||||
const files: Record<string, boolean> = {};
|
const files: Record<string, boolean> = {};
|
||||||
files[fileName] = this.checked;
|
files[fileName] = this.checked;
|
||||||
const data: Record<string, any> = {files};
|
const data: {files: Record<string, boolean>, headCommitSHA?: string} = {files};
|
||||||
const headCommitSHA = el.getAttribute('data-headcommit');
|
const headCommitSHA = el.getAttribute('data-headcommit');
|
||||||
if (headCommitSHA) data.headCommitSHA = headCommitSHA;
|
if (headCommitSHA) data.headCommitSHA = headCommitSHA;
|
||||||
POST(el.getAttribute('data-link')!, {data});
|
POST(el.getAttribute('data-link')!, {data});
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ async function showRefIssuePopup(link: HTMLAnchorElement) {
|
|||||||
export function initRefIssueContextPopup() {
|
export function initRefIssueContextPopup() {
|
||||||
const selector = 'a[href]:not([data-ref-issue-popup]):not(.ref-external-issue)';
|
const selector = 'a[href]:not([data-ref-issue-popup]):not(.ref-external-issue)';
|
||||||
addDelegatedEventListener<HTMLAnchorElement, MouseEvent>(document, 'mouseover', selector, (link) => {
|
addDelegatedEventListener<HTMLAnchorElement, MouseEvent>(document, 'mouseover', selector, (link) => {
|
||||||
if (!parseIssueHref(link.getAttribute('href')!).ownerName) return;
|
if (!parseIssueHref(link.getAttribute('href')!)) return;
|
||||||
if (!link.classList.contains('ref-issue') && !link.closest('[data-ref-issue-container]')) return;
|
if (!link.classList.contains('ref-issue') && !link.closest('[data-ref-issue-container]')) return;
|
||||||
if (getAttachedTippyInstance(link)) return;
|
if (getAttachedTippyInstance(link)) return;
|
||||||
link.setAttribute('data-ref-issue-popup', '');
|
link.setAttribute('data-ref-issue-popup', '');
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
|
import {hideElem, showElem, toggleElem} from '../utils/dom.ts';
|
||||||
import {GET} from '../modules/fetch.ts';
|
import {GET} from '../modules/fetch.ts';
|
||||||
|
|
||||||
|
type GitRef = {name: string, web_link: string};
|
||||||
|
type RefsResponse = {tags: GitRef[], branches: GitRef[], default_branch: string};
|
||||||
|
|
||||||
async function loadBranchesAndTags(area: Element, loadingButton: Element) {
|
async function loadBranchesAndTags(area: Element, loadingButton: Element) {
|
||||||
loadingButton.classList.add('disabled');
|
loadingButton.classList.add('disabled');
|
||||||
try {
|
try {
|
||||||
const res = await GET(loadingButton.getAttribute('data-url')!);
|
const res = await GET(loadingButton.getAttribute('data-url')!);
|
||||||
const data = await res.json();
|
const data: RefsResponse = await res.json();
|
||||||
hideElem(loadingButton);
|
hideElem(loadingButton);
|
||||||
addTags(area, data.tags);
|
addTags(area, data.tags);
|
||||||
addBranches(area, data.branches, data.default_branch);
|
addBranches(area, data.branches, data.default_branch);
|
||||||
@@ -15,7 +18,7 @@ async function loadBranchesAndTags(area: Element, loadingButton: Element) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addTags(area: Element, tags: Array<Record<string, any>>) {
|
function addTags(area: Element, tags: GitRef[]) {
|
||||||
const tagArea = area.querySelector('.tag-area')!;
|
const tagArea = area.querySelector('.tag-area')!;
|
||||||
toggleElem(tagArea.parentElement!, tags.length > 0);
|
toggleElem(tagArea.parentElement!, tags.length > 0);
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
@@ -23,7 +26,7 @@ function addTags(area: Element, tags: Array<Record<string, any>>) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function addBranches(area: Element, branches: Array<Record<string, any>>, defaultBranch: string) {
|
function addBranches(area: Element, branches: GitRef[], defaultBranch: string) {
|
||||||
const defaultBranchTooltip = area.getAttribute('data-text-default-branch-tooltip');
|
const defaultBranchTooltip = area.getAttribute('data-text-default-branch-tooltip');
|
||||||
const branchArea = area.querySelector('.branch-area')!;
|
const branchArea = area.querySelector('.branch-area')!;
|
||||||
toggleElem(branchArea.parentElement!, branches.length > 0);
|
toggleElem(branchArea.parentElement!, branches.length > 0);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export function strSubMatch(full: string, subLower: string) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calcMatchedWeight(matchResult: Array<any>) {
|
export function calcMatchedWeight(matchResult: string[]) {
|
||||||
let weight = 0;
|
let weight = 0;
|
||||||
for (let i = 0; i < matchResult.length; i++) {
|
for (let i = 0; i < matchResult.length; i++) {
|
||||||
if (i % 2 === 1) { // matches are on odd indices, see strSubMatch
|
if (i % 2 === 1) { // matches are on odd indices, see strSubMatch
|
||||||
|
|||||||
@@ -3,9 +3,13 @@ import {hideElem, queryElemChildren, showElem} from '../utils/dom.ts';
|
|||||||
import {POST} from '../modules/fetch.ts';
|
import {POST} from '../modules/fetch.ts';
|
||||||
import {showErrorToast, type Toast} from '../modules/toast.ts';
|
import {showErrorToast, type Toast} from '../modules/toast.ts';
|
||||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||||
|
import type {FomanticApiResponse, JQueryElem} from '../types.ts';
|
||||||
|
|
||||||
const {appSubUrl} = window.config;
|
const {appSubUrl} = window.config;
|
||||||
|
|
||||||
|
type TopicSearchResponse = {topics: Array<{topic_name: string}>};
|
||||||
|
type TopicSearchResult = {description: string, 'data-value': string};
|
||||||
|
|
||||||
export function initRepoTopicBar() {
|
export function initRepoTopicBar() {
|
||||||
const mgrBtn = document.querySelector<HTMLButtonElement>('#manage_topic');
|
const mgrBtn = document.querySelector<HTMLButtonElement>('#manage_topic');
|
||||||
if (!mgrBtn) return;
|
if (!mgrBtn) return;
|
||||||
@@ -87,10 +91,10 @@ export function initRepoTopicBar() {
|
|||||||
apiSettings: {
|
apiSettings: {
|
||||||
url: `${appSubUrl}/explore/topics/search?q={query}`,
|
url: `${appSubUrl}/explore/topics/search?q={query}`,
|
||||||
throttle: 500,
|
throttle: 500,
|
||||||
onResponse(this: any, res: any) {
|
onResponse(this: {urlData: {query: string}}, res: TopicSearchResponse) {
|
||||||
const formattedResponse = {
|
const formattedResponse: FomanticApiResponse<TopicSearchResult> = {
|
||||||
success: false,
|
success: false,
|
||||||
results: [] as Array<Record<string, any>>,
|
results: [],
|
||||||
};
|
};
|
||||||
const query = stripTags(this.urlData.query.trim());
|
const query = stripTags(this.urlData.query.trim());
|
||||||
let found_query = false;
|
let found_query = false;
|
||||||
@@ -134,7 +138,7 @@ export function initRepoTopicBar() {
|
|||||||
this.attr('data-value', value).contents().first().replaceWith(value);
|
this.attr('data-value', value).contents().first().replaceWith(value);
|
||||||
return fomanticQuery(this);
|
return fomanticQuery(this);
|
||||||
},
|
},
|
||||||
onAdd(addedValue: string, _addedText: any, $addedChoice: any) {
|
onAdd(addedValue: string, _addedText: any, $addedChoice: JQueryElem) {
|
||||||
addedValue = addedValue.toLowerCase().trim();
|
addedValue = addedValue.toLowerCase().trim();
|
||||||
$addedChoice[0].setAttribute('data-value', addedValue);
|
$addedChoice[0].setAttribute('data-value', addedValue);
|
||||||
$addedChoice[0].setAttribute('data-text', addedValue);
|
$addedChoice[0].setAttribute('data-text', addedValue);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {parseIssuePageInfo} from '../utils.ts';
|
|||||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||||
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
|
import {hideFomanticModal, showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||||
import {html, htmlRaw} from '../utils/html.ts';
|
import {html, htmlRaw} from '../utils/html.ts';
|
||||||
|
import type {JQueryElem} from '../types.ts';
|
||||||
|
|
||||||
let i18nTextEdited: string;
|
let i18nTextEdited: string;
|
||||||
let i18nTextOptions: string;
|
let i18nTextOptions: string;
|
||||||
@@ -35,7 +36,7 @@ function showContentHistoryDetail(issueBaseUrl: string, commentId: string, histo
|
|||||||
$fomanticDropdownOptions.dropdown({
|
$fomanticDropdownOptions.dropdown({
|
||||||
showOnFocus: false,
|
showOnFocus: false,
|
||||||
allowReselection: true,
|
allowReselection: true,
|
||||||
async onChange(_value: string, _text: string, $item: any) {
|
async onChange(_value: string, _text: string, $item: JQueryElem) {
|
||||||
const optionItem = $item.data('option-item');
|
const optionItem = $item.data('option-item');
|
||||||
if (optionItem === 'delete') {
|
if (optionItem === 'delete') {
|
||||||
if (window.confirm(i18nTextDeleteFromHistoryConfirm)) {
|
if (window.confirm(i18nTextDeleteFromHistoryConfirm)) {
|
||||||
@@ -116,7 +117,7 @@ function showContentHistoryMenu(issueBaseUrl: string, elCommentItem: Element, co
|
|||||||
onHide() {
|
onHide() {
|
||||||
$fomanticDropdown.dropdown('change values', null);
|
$fomanticDropdown.dropdown('change values', null);
|
||||||
},
|
},
|
||||||
onChange(value: string, itemHtml: string, $item: any) {
|
onChange(value: string, itemHtml: string, $item: JQueryElem) {
|
||||||
if (value && !$item.find('[data-history-is-deleted=1]').length) {
|
if (value && !$item.find('[data-history-is-deleted=1]').length) {
|
||||||
showContentHistoryDetail(issueBaseUrl, commentId, value, itemHtml);
|
showContentHistoryDetail(issueBaseUrl, commentId, value, itemHtml);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {fomanticQuery} from '../modules/fomantic/base.ts';
|
|||||||
import {performFetchAction} from '../modules/fetch-action.ts';
|
import {performFetchAction} from '../modules/fetch-action.ts';
|
||||||
import type {SortableEvent} from 'sortablejs';
|
import type {SortableEvent} from 'sortablejs';
|
||||||
|
|
||||||
|
type IssuePoster = {avatar_link: string, full_name: string, username: string};
|
||||||
|
type ProcessedIssuePoster = {type: 'html', html: string};
|
||||||
|
type IssuePosterResponse = {results: IssuePoster[]};
|
||||||
|
|
||||||
function initRepoIssueListCheckboxes() {
|
function initRepoIssueListCheckboxes() {
|
||||||
const issueSelectAll = document.querySelector<HTMLInputElement>('.issue-checkbox-all');
|
const issueSelectAll = document.querySelector<HTMLInputElement>('.issue-checkbox-all');
|
||||||
if (!issueSelectAll) return; // logged out state
|
if (!issueSelectAll) return; // logged out state
|
||||||
@@ -100,7 +104,7 @@ function initDropdownUserRemoteSearch(el: Element) {
|
|||||||
elMenu.querySelector(`.item[data-value="${CSS.escape(username)}"]`)?.classList.add('selected');
|
elMenu.querySelector(`.item[data-value="${CSS.escape(username)}"]`)?.classList.add('selected');
|
||||||
};
|
};
|
||||||
|
|
||||||
const processedResults: Record<string, string>[] = []; // to be used by dropdown to generate menu items
|
const processedResults: ProcessedIssuePoster[] = []; // to be used by dropdown to generate menu items
|
||||||
const syncItemFromInput = () => {
|
const syncItemFromInput = () => {
|
||||||
const inputVal = elSearchInput.value.trim();
|
const inputVal = elSearchInput.value.trim();
|
||||||
elItemFromInput.setAttribute('data-value', inputVal);
|
elItemFromInput.setAttribute('data-value', inputVal);
|
||||||
@@ -121,7 +125,7 @@ function initDropdownUserRemoteSearch(el: Element) {
|
|||||||
onMenuUpdated: () => syncItemFromInput(),
|
onMenuUpdated: () => syncItemFromInput(),
|
||||||
apiSettings: {
|
apiSettings: {
|
||||||
url: `${searchUrl}&q={query}`,
|
url: `${searchUrl}&q={query}`,
|
||||||
onResponse(resp: any) {
|
onResponse(resp: IssuePosterResponse) {
|
||||||
// the content is provided by backend IssuePosters handler
|
// the content is provided by backend IssuePosters handler
|
||||||
processedResults.length = 0;
|
processedResults.length = 0;
|
||||||
for (const item of resp.results) {
|
for (const item of resp.results) {
|
||||||
@@ -132,8 +136,7 @@ function initDropdownUserRemoteSearch(el: Element) {
|
|||||||
const htmlItem = html`<div class="item" data-value="${item.username}">${htmlRaw(htmlItemInner)}</div>`;
|
const htmlItem = html`<div class="item" data-value="${item.username}">${htmlRaw(htmlItemInner)}</div>`;
|
||||||
processedResults.push({type: 'html', html: htmlItem});
|
processedResults.push({type: 'html', html: htmlItem});
|
||||||
}
|
}
|
||||||
resp.results = processedResults;
|
return {results: processedResults};
|
||||||
return resp;
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {GET} from '../modules/fetch.ts';
|
|||||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||||
import {createElementFromHTML, activePageTimerRefresh} from '../utils/dom.ts';
|
import {createElementFromHTML, activePageTimerRefresh} from '../utils/dom.ts';
|
||||||
import {registerGlobalEventFunc} from '../modules/observer.ts';
|
import {registerGlobalEventFunc} from '../modules/observer.ts';
|
||||||
|
import type {JQueryElem} from '../types.ts';
|
||||||
|
|
||||||
export function initRepoPullRequestUpdate(el: HTMLElement) {
|
export function initRepoPullRequestUpdate(el: HTMLElement) {
|
||||||
const elDropdown = el.querySelector(':scope > .ui.dropdown');
|
const elDropdown = el.querySelector(':scope > .ui.dropdown');
|
||||||
@@ -10,10 +11,10 @@ export function initRepoPullRequestUpdate(el: HTMLElement) {
|
|||||||
const elButton = el.querySelector<HTMLButtonElement>(':scope > button')!;
|
const elButton = el.querySelector<HTMLButtonElement>(':scope > button')!;
|
||||||
|
|
||||||
fomanticQuery(elDropdown).dropdown({
|
fomanticQuery(elDropdown).dropdown({
|
||||||
onChange(_text: string, _value: string, $choice: any) {
|
onChange(_text: string, _value: string, $choice: JQueryElem) {
|
||||||
const choiceEl = $choice[0];
|
const choiceEl = $choice[0];
|
||||||
elButton.textContent = choiceEl.textContent;
|
elButton.textContent = choiceEl.textContent;
|
||||||
elButton.setAttribute('data-url', choiceEl.getAttribute('data-update-url'));
|
elButton.setAttribute('data-url', choiceEl.getAttribute('data-update-url')!);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {parseIssuePageInfo} from '../utils.ts';
|
|||||||
import {html} from '../utils/html.ts';
|
import {html} from '../utils/html.ts';
|
||||||
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
import {fomanticQuery} from '../modules/fomantic/base.ts';
|
||||||
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
import {showTemporaryTooltip} from '../modules/tippy.ts';
|
||||||
|
import type {FomanticApiResponse, Issue} from '../types.ts';
|
||||||
|
|
||||||
const {appSubUrl} = window.config;
|
const {appSubUrl} = window.config;
|
||||||
|
|
||||||
@@ -64,8 +65,8 @@ export function initRepoIssueSidebarDependency(elSidebar: HTMLElement) {
|
|||||||
apiSettings: {
|
apiSettings: {
|
||||||
url: issueSearchUrl,
|
url: issueSearchUrl,
|
||||||
rawResponse: true, // backend responds an array, prevent fomantic api from converting it to an object
|
rawResponse: true, // backend responds an array, prevent fomantic api from converting it to an object
|
||||||
onResponse(response: any) {
|
onResponse(response: Issue[]) {
|
||||||
const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
|
const filteredResponse: FomanticApiResponse<{value: number, name: string}> = {success: true, results: []};
|
||||||
const currIssueId = elDropdown.getAttribute('data-issue-id');
|
const currIssueId = elDropdown.getAttribute('data-issue-id');
|
||||||
// Parse the response from the api to work with our dropdown
|
// Parse the response from the api to work with our dropdown
|
||||||
for (const issue of response) {
|
for (const issue of response) {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {fomanticQuery} from '../modules/fomantic/base.ts';
|
|||||||
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
import {showFomanticModal} from '../modules/fomantic/modal.ts';
|
||||||
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
import {ignoreAreYouSure} from '../vendor/jquery.are-you-sure.ts';
|
||||||
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
import {registerGlobalInitFunc} from '../modules/observer.ts';
|
||||||
|
import type {FomanticApiResponse} from '../types.ts';
|
||||||
|
|
||||||
const {appSubUrl} = window.config;
|
const {appSubUrl} = window.config;
|
||||||
|
|
||||||
@@ -305,8 +306,8 @@ export function initRepoIssueReferenceIssue() {
|
|||||||
fullTextSearch: true,
|
fullTextSearch: true,
|
||||||
apiSettings: {
|
apiSettings: {
|
||||||
url: `${appSubUrl}/repo/search?q={query}&limit=20`,
|
url: `${appSubUrl}/repo/search?q={query}&limit=20`,
|
||||||
onResponse(response: any) {
|
onResponse(response: {data: Array<{repository: {full_name: string}}>}) {
|
||||||
const filteredResponse = {success: true, results: [] as Array<Record<string, any>>};
|
const filteredResponse: FomanticApiResponse<{name: string, value: string}> = {success: true, results: []};
|
||||||
for (const repo of response.data) {
|
for (const repo of response.data) {
|
||||||
filteredResponse.results.push({
|
filteredResponse.results.push({
|
||||||
name: htmlEscape(repo.repository.full_name),
|
name: htmlEscape(repo.repository.full_name),
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function initRepoNewTemplateSearch(form: HTMLFormElement) {
|
|||||||
$repoTemplateDropdown.dropdown('setting', {
|
$repoTemplateDropdown.dropdown('setting', {
|
||||||
apiSettings: {
|
apiSettings: {
|
||||||
url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${ownerId}`,
|
url: `${appSubUrl}/repo/search?q={query}&template=true&priority_owner_id=${ownerId}`,
|
||||||
onResponse(response: any) {
|
onResponse(response: {data: Array<{repository: {full_name: string, id: number}}>}) {
|
||||||
const results = [];
|
const results = [];
|
||||||
results.push({name: '', value: ''}); // empty item means not using template
|
results.push({name: '', value: ''}); // empty item means not using template
|
||||||
for (const tmplRepo of response.data) {
|
for (const tmplRepo of response.data) {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ async function initRepoWikiForm(form: HTMLFormElement) {
|
|||||||
'unordered-list', 'ordered-list', '|',
|
'unordered-list', 'ordered-list', '|',
|
||||||
'link', 'image', 'table', 'horizontal-rule', '|',
|
'link', 'image', 'table', 'horizontal-rule', '|',
|
||||||
'preview', 'fullscreen', 'side-by-side', '|', 'gitea-switch-to-textarea',
|
'preview', 'fullscreen', 'side-by-side', '|', 'gitea-switch-to-textarea',
|
||||||
] as any, // to use custom toolbar buttons
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
19
web_src/js/globals.d.ts
vendored
19
web_src/js/globals.d.ts
vendored
@@ -1,5 +1,8 @@
|
|||||||
interface JQuery {
|
interface JQuery {
|
||||||
fomanticExt: any; // fomantic extension
|
fomanticExt: {
|
||||||
|
onDropdownAfterFiltered?: (this: HTMLElement) => void,
|
||||||
|
onModalBeforeHidden?: (this: HTMLElement) => void,
|
||||||
|
}; // fomantic extension
|
||||||
api: any, // fomantic
|
api: any, // fomantic
|
||||||
dimmer: any, // fomantic
|
dimmer: any, // fomantic
|
||||||
dropdown: any; // fomantic
|
dropdown: any; // fomantic
|
||||||
@@ -23,7 +26,7 @@ interface Window {
|
|||||||
sharedWorkerUri: string,
|
sharedWorkerUri: string,
|
||||||
runModeIsProd: boolean,
|
runModeIsProd: boolean,
|
||||||
customEmojis: Record<string, string>,
|
customEmojis: Record<string, string>,
|
||||||
pageData: Record<string, any> & {
|
pageData: {
|
||||||
adminUserListSearchForm?: {
|
adminUserListSearchForm?: {
|
||||||
SortType: string,
|
SortType: string,
|
||||||
StatusFilterMap: Record<string, string>,
|
StatusFilterMap: Record<string, string>,
|
||||||
@@ -37,8 +40,14 @@ interface Window {
|
|||||||
FolderIcon?: string,
|
FolderIcon?: string,
|
||||||
FolderOpenIcon?: string,
|
FolderOpenIcon?: string,
|
||||||
repoLink?: string,
|
repoLink?: string,
|
||||||
repoActivityTopAuthors?: any[],
|
repoActivityTopAuthors?: Array<{
|
||||||
dashboardRepoList?: Record<string, any>,
|
avatar_link: string,
|
||||||
|
commits: number,
|
||||||
|
home_link: string,
|
||||||
|
login: string,
|
||||||
|
name: string,
|
||||||
|
}>,
|
||||||
|
dashboardRepoList?: Record<string, unknown>,
|
||||||
},
|
},
|
||||||
notificationSettings: {
|
notificationSettings: {
|
||||||
MinTimeout: number,
|
MinTimeout: number,
|
||||||
@@ -69,7 +78,7 @@ interface Window {
|
|||||||
giteaExternalRenderHelper?: {
|
giteaExternalRenderHelper?: {
|
||||||
isValidCssColor(s: string | null): boolean,
|
isValidCssColor(s: string | null): boolean,
|
||||||
queryParams: URLSearchParams,
|
queryParams: URLSearchParams,
|
||||||
postIframeMsg(cmd: string, data: Record<string, any> = {}),
|
postIframeMsg(cmd: 'resize' | 'open-link', data: Record<string, string | number | null>): void,
|
||||||
}
|
}
|
||||||
|
|
||||||
// do not add more properties here unless it is a must
|
// do not add more properties here unless it is a must
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ function toggleLoadingIndicator(el: HTMLElement, opt: FetchActionOpts, isLoading
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleFetchActionSuccessJson(el: HTMLElement, respJson: any) {
|
export async function handleFetchActionSuccessJson(el: HTMLElement, respJson: {redirect?: unknown} | null) {
|
||||||
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
|
ignoreAreYouSure(el); // ignore the areYouSure check before reloading
|
||||||
const redirect = respJson?.redirect;
|
const redirect = respJson?.redirect;
|
||||||
if (typeof redirect === 'string' && redirect) {
|
if (typeof redirect === 'string' && redirect) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function initGiteaFomantic() {
|
|||||||
// Do not use "cursor: pointer" for dropdown labels
|
// Do not use "cursor: pointer" for dropdown labels
|
||||||
$.fn.dropdown.settings.className.label += ' tw-cursor-default';
|
$.fn.dropdown.settings.className.label += ' tw-cursor-default';
|
||||||
// Always use Gitea's SVG icons
|
// Always use Gitea's SVG icons
|
||||||
$.fn.dropdown.settings.templates.label = function(_value: any, text: any, preserveHTML: any, className: Record<string, string>) {
|
$.fn.dropdown.settings.templates.label = function(_value: any, text: string, preserveHTML: boolean, className: Record<string, string>) {
|
||||||
const escape = $.fn.dropdown.settings.templates.escape;
|
const escape = $.fn.dropdown.settings.templates.escape;
|
||||||
return escape(text, preserveHTML) + svg('octicon-x', 16, `${className.delete} icon`);
|
return escape(text, preserveHTML) + svg('octicon-x', 16, `${className.delete} icon`);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import type {FomanticInitFunction} from '../../types.ts';
|
import type {FomanticInitFunction, JQueryElem} from '../../types.ts';
|
||||||
import {generateElemId, queryElems} from '../../utils/dom.ts';
|
import {generateElemId, queryElems} from '../../utils/dom.ts';
|
||||||
import {trString} from '../i18n.ts';
|
import {trString} from '../i18n.ts';
|
||||||
|
|
||||||
const ariaPatchKey = '_giteaAriaPatchDropdown';
|
const ariaPatchKey = '_giteaAriaPatchDropdown';
|
||||||
const fomanticDropdownFn = $.fn.dropdown;
|
const fomanticDropdownFn = $.fn.dropdown;
|
||||||
|
|
||||||
|
type AriaDropdownElement = HTMLElement & {
|
||||||
|
[ariaPatchKey]: {
|
||||||
|
focusableRole: 'combobox' | 'menu';
|
||||||
|
listPopupRole: 'listbox' | '';
|
||||||
|
listItemRole: 'option' | 'menuitem';
|
||||||
|
deferredRefreshAriaActiveItem: (delay?: number) => void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
// use our own `$().dropdown` function to patch Fomantic's dropdown module
|
// use our own `$().dropdown` function to patch Fomantic's dropdown module
|
||||||
export function initAriaDropdownPatch() {
|
export function initAriaDropdownPatch() {
|
||||||
if ($.fn.dropdown === ariaDropdownFn) throw new Error('initAriaDropdownPatch could only be called once');
|
if ($.fn.dropdown === ariaDropdownFn) throw new Error('initAriaDropdownPatch could only be called once');
|
||||||
@@ -44,9 +53,9 @@ function ariaDropdownFn(this: any, ...args: Parameters<FomanticInitFunction>) {
|
|||||||
|
|
||||||
// make the item has role=option/menuitem, add an id if there wasn't one yet, make items as non-focusable
|
// make the item has role=option/menuitem, add an id if there wasn't one yet, make items as non-focusable
|
||||||
// the elements inside the dropdown menu item should not be focusable, the focus should always be on the dropdown primary element.
|
// the elements inside the dropdown menu item should not be focusable, the focus should always be on the dropdown primary element.
|
||||||
function updateMenuItem(dropdown: HTMLElement, item: HTMLElement) {
|
function updateMenuItem(dropdown: AriaDropdownElement, item: HTMLElement) {
|
||||||
if (!item.id) item.id = generateElemId('_aria_dropdown_item_');
|
if (!item.id) item.id = generateElemId('_aria_dropdown_item_');
|
||||||
item.setAttribute('role', (dropdown as any)[ariaPatchKey].listItemRole);
|
item.setAttribute('role', dropdown[ariaPatchKey].listItemRole);
|
||||||
item.setAttribute('tabindex', '-1');
|
item.setAttribute('tabindex', '-1');
|
||||||
for (const el of item.querySelectorAll('a, input, button')) el.setAttribute('tabindex', '-1');
|
for (const el of item.querySelectorAll('a, input, button')) el.setAttribute('tabindex', '-1');
|
||||||
}
|
}
|
||||||
@@ -69,15 +78,15 @@ function updateSelectionLabel(label: HTMLElement) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDropdownAfterFiltered(this: any) {
|
function onDropdownAfterFiltered(this: HTMLElement) {
|
||||||
const $dropdown = $(this).closest('.ui.dropdown'); // "this" can be the "ui dropdown" or "<select>"
|
const $dropdown = $(this).closest<AriaDropdownElement>('.ui.dropdown'); // "this" can be the "ui dropdown" or "<select>"
|
||||||
const hideEmptyDividers = $dropdown.dropdown('setting', 'hideDividers') === 'empty';
|
const hideEmptyDividers = $dropdown.dropdown('setting', 'hideDividers') === 'empty';
|
||||||
const itemsMenu = $dropdown[0].querySelector('.scrolling.menu') || $dropdown[0].querySelector('.menu');
|
const itemsMenu = $dropdown[0].querySelector('.scrolling.menu') || $dropdown[0].querySelector('.menu');
|
||||||
if (hideEmptyDividers && itemsMenu) hideScopedEmptyDividers(itemsMenu);
|
if (hideEmptyDividers && itemsMenu) hideScopedEmptyDividers(itemsMenu);
|
||||||
}
|
}
|
||||||
|
|
||||||
// delegate the dropdown's template functions and callback functions to add aria attributes.
|
// delegate the dropdown's template functions and callback functions to add aria attributes.
|
||||||
function delegateDropdownModule($dropdown: any) {
|
function delegateDropdownModule($dropdown: JQueryElem<AriaDropdownElement>) {
|
||||||
const dropdownCall = fomanticDropdownFn.bind($dropdown);
|
const dropdownCall = fomanticDropdownFn.bind($dropdown);
|
||||||
|
|
||||||
// the "template" functions are used for dynamic creation (eg: AJAX)
|
// the "template" functions are used for dynamic creation (eg: AJAX)
|
||||||
@@ -104,9 +113,18 @@ function delegateDropdownModule($dropdown: any) {
|
|||||||
return $label;
|
return $label;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// some close paths fire no DOM event (Escape, programmatic hide, synthetic click) and call sites
|
||||||
|
// replace the "onHide" setting, so wrap the internal hide that every close path goes through
|
||||||
|
const dropdownHideOld = dropdownCall('internal', 'hide');
|
||||||
|
dropdownCall('internal', 'hide', function(this: unknown, ...args: unknown[]) {
|
||||||
|
const ret = dropdownHideOld.apply(this, args);
|
||||||
|
$dropdown[0][ariaPatchKey].deferredRefreshAriaActiveItem();
|
||||||
|
return ret;
|
||||||
|
});
|
||||||
|
|
||||||
const oldSet = dropdownCall('internal', 'set');
|
const oldSet = dropdownCall('internal', 'set');
|
||||||
const oldSetDirection = oldSet.direction;
|
const oldSetDirection = oldSet.direction;
|
||||||
oldSet.direction = function($menu: any) {
|
oldSet.direction = function($menu?: JQueryElem) {
|
||||||
oldSetDirection.call(this, $menu);
|
oldSetDirection.call(this, $menu);
|
||||||
const classNames = dropdownCall('setting', 'className');
|
const classNames = dropdownCall('setting', 'className');
|
||||||
$menu = $menu || $dropdown.find('> .menu');
|
$menu = $menu || $dropdown.find('> .menu');
|
||||||
@@ -122,7 +140,7 @@ function delegateDropdownModule($dropdown: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// for static dropdown elements (generated by server-side template), prepare them with necessary aria attributes
|
// for static dropdown elements (generated by server-side template), prepare them with necessary aria attributes
|
||||||
function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, menu: HTMLElement) {
|
function attachStaticElements(dropdown: AriaDropdownElement, focusable: HTMLElement, menu: HTMLElement) {
|
||||||
// prepare static dropdown menu list popup
|
// prepare static dropdown menu list popup
|
||||||
if (!menu.id) {
|
if (!menu.id) {
|
||||||
menu.id = generateElemId('_aria_dropdown_menu_');
|
menu.id = generateElemId('_aria_dropdown_menu_');
|
||||||
@@ -131,7 +149,7 @@ function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, men
|
|||||||
$(menu).find('> .item').each((_, item) => updateMenuItem(dropdown, item));
|
$(menu).find('> .item').each((_, item) => updateMenuItem(dropdown, item));
|
||||||
|
|
||||||
// this role could only be changed after its content is ready, otherwise some browsers+readers (like Chrome+AppleVoice) crash
|
// this role could only be changed after its content is ready, otherwise some browsers+readers (like Chrome+AppleVoice) crash
|
||||||
menu.setAttribute('role', (dropdown as any)[ariaPatchKey].listPopupRole);
|
menu.setAttribute('role', dropdown[ariaPatchKey].listPopupRole);
|
||||||
|
|
||||||
// prepare selection label items
|
// prepare selection label items
|
||||||
for (const label of dropdown.querySelectorAll<HTMLElement>('.ui.label')) {
|
for (const label of dropdown.querySelectorAll<HTMLElement>('.ui.label')) {
|
||||||
@@ -139,8 +157,8 @@ function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, men
|
|||||||
}
|
}
|
||||||
|
|
||||||
// make the primary element (focusable) aria-friendly
|
// make the primary element (focusable) aria-friendly
|
||||||
focusable.setAttribute('role', focusable.getAttribute('role') ?? (dropdown as any)[ariaPatchKey].focusableRole);
|
focusable.setAttribute('role', focusable.getAttribute('role') ?? dropdown[ariaPatchKey].focusableRole);
|
||||||
focusable.setAttribute('aria-haspopup', (dropdown as any)[ariaPatchKey].listPopupRole);
|
focusable.setAttribute('aria-haspopup', dropdown[ariaPatchKey].listPopupRole);
|
||||||
focusable.setAttribute('aria-controls', menu.id);
|
focusable.setAttribute('aria-controls', menu.id);
|
||||||
focusable.setAttribute('aria-expanded', 'false');
|
focusable.setAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
@@ -151,9 +169,7 @@ function attachStaticElements(dropdown: HTMLElement, focusable: HTMLElement, men
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachInitElements(dropdown: HTMLElement) {
|
function attachInitElements(dropdown: AriaDropdownElement) {
|
||||||
(dropdown as any)[ariaPatchKey] = {};
|
|
||||||
|
|
||||||
// Dropdown has 2 different focusing behaviors
|
// Dropdown has 2 different focusing behaviors
|
||||||
// * with search input: the input is focused, and it works with aria-activedescendant pointing another sibling element.
|
// * with search input: the input is focused, and it works with aria-activedescendant pointing another sibling element.
|
||||||
// * without search input (but the readonly text), the dropdown itself is focused. then the aria-activedescendant points to the element inside dropdown
|
// * without search input (but the readonly text), the dropdown itself is focused. then the aria-activedescendant points to the element inside dropdown
|
||||||
@@ -191,15 +207,17 @@ function attachInitElements(dropdown: HTMLElement) {
|
|||||||
// Since #19861 we have prepared the "combobox" solution, but didn't get enough time to put it into practice and test before.
|
// Since #19861 we have prepared the "combobox" solution, but didn't get enough time to put it into practice and test before.
|
||||||
const isComboBox = dropdown.querySelectorAll('input').length > 0;
|
const isComboBox = dropdown.querySelectorAll('input').length > 0;
|
||||||
|
|
||||||
(dropdown as any)[ariaPatchKey].focusableRole = isComboBox ? 'combobox' : 'menu';
|
dropdown[ariaPatchKey] = {
|
||||||
(dropdown as any)[ariaPatchKey].listPopupRole = isComboBox ? 'listbox' : '';
|
focusableRole: isComboBox ? 'combobox' : 'menu',
|
||||||
(dropdown as any)[ariaPatchKey].listItemRole = isComboBox ? 'option' : 'menuitem';
|
listPopupRole: isComboBox ? 'listbox' : '',
|
||||||
|
listItemRole: isComboBox ? 'option' : 'menuitem',
|
||||||
|
deferredRefreshAriaActiveItem: attachDomEvents(dropdown, focusable, menu),
|
||||||
|
};
|
||||||
|
|
||||||
attachDomEvents(dropdown, focusable, menu);
|
|
||||||
attachStaticElements(dropdown, focusable, menu);
|
attachStaticElements(dropdown, focusable, menu);
|
||||||
}
|
}
|
||||||
|
|
||||||
function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HTMLElement) {
|
function attachDomEvents(dropdown: AriaDropdownElement, focusable: HTMLElement, menu: HTMLElement) {
|
||||||
// when showing, it has class: ".animating.in"
|
// when showing, it has class: ".animating.in"
|
||||||
// when hiding, it has class: ".visible.animating.out"
|
// when hiding, it has class: ".visible.animating.out"
|
||||||
const isMenuVisible = () => (menu.classList.contains('visible') && !menu.classList.contains('out')) || menu.classList.contains('in');
|
const isMenuVisible = () => (menu.classList.contains('visible') && !menu.classList.contains('out')) || menu.classList.contains('in');
|
||||||
@@ -216,7 +234,7 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT
|
|||||||
// if the popup is visible and has an active/selected item, use its id as aria-activedescendant
|
// if the popup is visible and has an active/selected item, use its id as aria-activedescendant
|
||||||
if (menuVisible) {
|
if (menuVisible) {
|
||||||
focusable.setAttribute('aria-activedescendant', active.id);
|
focusable.setAttribute('aria-activedescendant', active.id);
|
||||||
} else if ((dropdown as any)[ariaPatchKey].listPopupRole === 'menu') {
|
} else if (dropdown[ariaPatchKey].focusableRole === 'menu') {
|
||||||
// for menu, when the popup is hidden, no need to keep the aria-activedescendant, and clear the active/selected item
|
// for menu, when the popup is hidden, no need to keep the aria-activedescendant, and clear the active/selected item
|
||||||
focusable.removeAttribute('aria-activedescendant');
|
focusable.removeAttribute('aria-activedescendant');
|
||||||
active.classList.remove('active', 'selected');
|
active.classList.remove('active', 'selected');
|
||||||
@@ -242,7 +260,6 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT
|
|||||||
// when the popup is hiding, it's better to have a small "delay", because there is a Fomantic UI animation
|
// when the popup is hiding, it's better to have a small "delay", because there is a Fomantic UI animation
|
||||||
// without the delay for hiding, the UI will be somewhat laggy and sometimes may get stuck in the animation.
|
// without the delay for hiding, the UI will be somewhat laggy and sometimes may get stuck in the animation.
|
||||||
const deferredRefreshAriaActiveItem = (delay = 0) => { setTimeout(refreshAriaActiveItem, delay) };
|
const deferredRefreshAriaActiveItem = (delay = 0) => { setTimeout(refreshAriaActiveItem, delay) };
|
||||||
(dropdown as any)[ariaPatchKey].deferredRefreshAriaActiveItem = deferredRefreshAriaActiveItem;
|
|
||||||
dropdown.addEventListener('keyup', (e) => { if (e.key.startsWith('Arrow')) deferredRefreshAriaActiveItem(); });
|
dropdown.addEventListener('keyup', (e) => { if (e.key.startsWith('Arrow')) deferredRefreshAriaActiveItem(); });
|
||||||
|
|
||||||
// if the dropdown has been opened by focus, do not trigger the next click event again.
|
// if the dropdown has been opened by focus, do not trigger the next click event again.
|
||||||
@@ -279,6 +296,8 @@ function attachDomEvents(dropdown: HTMLElement, focusable: HTMLElement, menu: HT
|
|||||||
}
|
}
|
||||||
ignoreClickPreEvents = ignoreClickPreVisible = 0;
|
ignoreClickPreEvents = ignoreClickPreVisible = 0;
|
||||||
}, {capture: true});
|
}, {capture: true});
|
||||||
|
|
||||||
|
return deferredRefreshAriaActiveItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Although Fomantic Dropdown supports "hideDividers", it doesn't really work with our "scoped dividers"
|
// Although Fomantic Dropdown supports "hideDividers", it doesn't really work with our "scoped dividers"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type {FomanticInitFunction} from '../../types.ts';
|
import type {FomanticInitFunction, JQueryElem} from '../../types.ts';
|
||||||
import {queryElems} from '../../utils/dom.ts';
|
import {queryElems} from '../../utils/dom.ts';
|
||||||
import {hideToastsFrom} from '../toast.ts';
|
import {hideToastsFrom} from '../toast.ts';
|
||||||
|
|
||||||
@@ -36,7 +36,7 @@ export function initAriaModalPatch() {
|
|||||||
|
|
||||||
// the patched `$.fn.modal` modal function
|
// the patched `$.fn.modal` modal function
|
||||||
// * it does the one-time attaching on the first call
|
// * it does the one-time attaching on the first call
|
||||||
function ariaModalFn(this: any, ...args: Parameters<FomanticInitFunction>) {
|
function ariaModalFn(this: JQueryElem, ...args: Parameters<FomanticInitFunction>) {
|
||||||
const ret = fomanticModalFn.apply(this, args);
|
const ret = fomanticModalFn.apply(this, args);
|
||||||
if (args[0] === 'show' || args[0]?.autoShow) {
|
if (args[0] === 'show' || args[0]?.autoShow) {
|
||||||
for (const el of this) {
|
for (const el of this) {
|
||||||
@@ -52,7 +52,7 @@ function ariaModalFn(this: any, ...args: Parameters<FomanticInitFunction>) {
|
|||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
function onModalBeforeHidden(this: any) {
|
function onModalBeforeHidden(this: HTMLElement) {
|
||||||
const $modal = $(this);
|
const $modal = $(this);
|
||||||
const elModal = $modal[0];
|
const elModal = $modal[0];
|
||||||
hideToastsFrom(elModal.closest('.ui.dimmer') ?? document.body);
|
hideToastsFrom(elModal.closest('.ui.dimmer') ?? document.body);
|
||||||
@@ -63,7 +63,7 @@ function onModalBeforeHidden(this: any) {
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onModalApproveDefault(this: any) {
|
function onModalApproveDefault(this: HTMLElement) {
|
||||||
const $modal = $(this);
|
const $modal = $(this);
|
||||||
const selectors = $modal.modal('setting', 'selector');
|
const selectors = $modal.modal('setting', 'selector');
|
||||||
const elModal = $modal[0];
|
const elModal = $modal[0];
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ export function initFomanticTransition() {
|
|||||||
'set duration', 'save conditions', 'restore conditions',
|
'set duration', 'save conditions', 'restore conditions',
|
||||||
]);
|
]);
|
||||||
// stand-in for removed transition module
|
// stand-in for removed transition module
|
||||||
$.fn.transition = function (arg0: any, arg1: any, arg2: any) {
|
$.fn.transition = function (arg0: any, arg1?: number, arg2?: (this: HTMLElement) => void) {
|
||||||
if (arg0 === 'is supported') return true;
|
if (arg0 === 'is supported') return true;
|
||||||
if (arg0 === 'is animating') return false;
|
if (arg0 === 'is animating') return false;
|
||||||
if (arg0 === 'is inward') return false;
|
if (arg0 === 'is inward') return false;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
export type InplaceRenderPlugin = {
|
export type InplaceRenderPlugin = {
|
||||||
name: string;
|
name: string;
|
||||||
canHandle: (filename: string, mimeType: string) => boolean;
|
canHandle: (filename: string, mimeType: string) => boolean;
|
||||||
render: (container: HTMLElement, fileUrl: string, options?: any) => Promise<void>;
|
render: (container: HTMLElement, fileUrl: string) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type FrontendRenderOptions = {
|
export type FrontendRenderOptions = {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export type Mention = {
|
|||||||
avatar: string,
|
avatar: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RequestData = string | FormData | URLSearchParams | Record<string, any>;
|
export type RequestData = FormData | URLSearchParams | Record<string, unknown> | unknown[];
|
||||||
|
|
||||||
export type RequestOpts = {
|
export type RequestOpts = {
|
||||||
data?: RequestData,
|
data?: RequestData,
|
||||||
@@ -36,6 +36,16 @@ export type IssuePageInfo = {
|
|||||||
issueDependencySearchType: string,
|
issueDependencySearchType: string,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Label = {
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
exclusive: boolean,
|
||||||
|
is_archived: boolean,
|
||||||
|
color: string,
|
||||||
|
description: string,
|
||||||
|
url: string,
|
||||||
|
};
|
||||||
|
|
||||||
export type Issue = {
|
export type Issue = {
|
||||||
id: number,
|
id: number,
|
||||||
number: number,
|
number: number,
|
||||||
@@ -49,12 +59,18 @@ export type Issue = {
|
|||||||
merged: boolean;
|
merged: boolean;
|
||||||
},
|
},
|
||||||
repository: {
|
repository: {
|
||||||
|
id: number,
|
||||||
|
name: string,
|
||||||
|
owner: string,
|
||||||
full_name: string,
|
full_name: string,
|
||||||
html_url: string,
|
|
||||||
},
|
},
|
||||||
labels: Array<string>,
|
labels: Array<Label>,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type JQueryElem<T extends HTMLElement = HTMLElement> = ReturnType<typeof $<T>>;
|
||||||
|
|
||||||
|
export type FomanticApiResponse<T> = {success: boolean, results: T[]};
|
||||||
|
|
||||||
export type FomanticInitFunction = {
|
export type FomanticInitFunction = {
|
||||||
settings?: Record<string, any>,
|
settings?: Record<string, any>,
|
||||||
(...args: any[]): any,
|
(...args: any[]): any,
|
||||||
|
|||||||
@@ -51,13 +51,13 @@ test('parseIssueHref', () => {
|
|||||||
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/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?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('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});
|
expect(parseIssueHref('')).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseRepoOwnerPathInfo', () => {
|
test('parseRepoOwnerPathInfo', () => {
|
||||||
expect(parseRepoOwnerPathInfo('/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo'});
|
expect(parseRepoOwnerPathInfo('/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo'});
|
||||||
expect(parseRepoOwnerPathInfo('/owner/repo/releases')).toEqual({ownerName: 'owner', repoName: 'repo'});
|
expect(parseRepoOwnerPathInfo('/owner/repo/releases')).toEqual({ownerName: 'owner', repoName: 'repo'});
|
||||||
expect(parseRepoOwnerPathInfo('/other')).toEqual({});
|
expect(parseRepoOwnerPathInfo('/other')).toBeNull();
|
||||||
window.config.appSubUrl = '/sub';
|
window.config.appSubUrl = '/sub';
|
||||||
expect(parseRepoOwnerPathInfo('/sub/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo'});
|
expect(parseRepoOwnerPathInfo('/sub/owner/repo/issues/new')).toEqual({ownerName: 'owner', repoName: 'repo'});
|
||||||
expect(parseRepoOwnerPathInfo('/sub/owner/repo/compare/feature/branch-1...fix/branch-2')).toEqual({ownerName: 'owner', repoName: 'repo'});
|
expect(parseRepoOwnerPathInfo('/sub/owner/repo/compare/feature/branch-1...fix/branch-2')).toEqual({ownerName: 'owner', repoName: 'repo'});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function extname(path: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** test whether a variable is an object */
|
/** test whether a variable is an object */
|
||||||
export function isObject<T = Record<string, any>>(obj: any): obj is T {
|
export function isObject(obj: unknown): obj is Record<string, unknown> {
|
||||||
return Object.prototype.toString.call(obj) === '[object Object]';
|
return Object.prototype.toString.call(obj) === '[object Object]';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,17 +49,21 @@ export function stripTags(text: string): string {
|
|||||||
return text;
|
return text;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseIssueHref(href: string): IssuePathInfo {
|
export function parseIssueHref(href: string): IssuePathInfo | null {
|
||||||
// FIXME: it should use pathname and trim the appSubUrl ahead
|
// FIXME: it should use pathname and trim the appSubUrl ahead
|
||||||
const path = (href || '').replace(/[#?].*$/, '');
|
const path = (href || '').replace(/[#?].*$/, '');
|
||||||
const [_, ownerName, repoName, pathType, indexString] = /([^/]+)\/([^/]+)\/(issues|pulls)\/([0-9]+)/.exec(path) || [];
|
const match = /([^/]+)\/([^/]+)\/(issues|pulls)\/([0-9]+)/.exec(path);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, ownerName, repoName, pathType, indexString] = match;
|
||||||
return {ownerName, repoName, pathType, indexString};
|
return {ownerName, repoName, pathType, indexString};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseRepoOwnerPathInfo(pathname: string): RepoOwnerPathInfo {
|
export function parseRepoOwnerPathInfo(pathname: string): RepoOwnerPathInfo | null {
|
||||||
const appSubUrl = window.config.appSubUrl;
|
const appSubUrl = window.config.appSubUrl;
|
||||||
if (appSubUrl && pathname.startsWith(appSubUrl)) pathname = pathname.substring(appSubUrl.length);
|
if (appSubUrl && pathname.startsWith(appSubUrl)) pathname = pathname.substring(appSubUrl.length);
|
||||||
const [_, ownerName, repoName] = /([^/]+)\/([^/]+)/.exec(pathname) || [];
|
const match = /([^/]+)\/([^/]+)/.exec(pathname);
|
||||||
|
if (!match) return null;
|
||||||
|
const [, ownerName, repoName] = match;
|
||||||
return {ownerName, repoName};
|
return {ownerName, repoName};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -235,7 +235,7 @@ export function autosize(textarea: HTMLTextAreaElement, {viewportMarginBottom =
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function onInputDebounce(fn: () => Promisable<any>) {
|
export function onInputDebounce(fn: () => Promisable<void>) {
|
||||||
return debounce(fn, 300);
|
return debounce(fn, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +277,7 @@ export function createElementFromHTML<T extends Element>(htmlString: string): T
|
|||||||
return div.firstChild as T;
|
return div.firstChild as T;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createElementFromAttrs<T extends HTMLElement>(tagName: string, attrs: Record<string, any> | null, ...children: (Node | string)[]): T {
|
export function createElementFromAttrs<T extends HTMLElement>(tagName: string, attrs: Record<string, string | number | boolean | null | undefined> | null, ...children: (Node | string)[]): T {
|
||||||
const el = document.createElement(tagName);
|
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 (value === undefined || value === null) continue;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export type TimedFunction<T extends (...args: Array<any>) => any> = ((...args: P
|
|||||||
function createTimed<T extends (...args: Array<any>) => any>(func: T, wait: number, leading: boolean, trailing: boolean, isThrottle: boolean): TimedFunction<T> {
|
function createTimed<T extends (...args: Array<any>) => any>(func: T, wait: number, leading: boolean, trailing: boolean, isThrottle: boolean): TimedFunction<T> {
|
||||||
let timer: TimeoutId | null = null;
|
let timer: TimeoutId | null = null;
|
||||||
let pendingArgs: Parameters<T> | null = null;
|
let pendingArgs: Parameters<T> | null = null;
|
||||||
let resolvers: Array<{resolve: (value: any) => void, reject: (reason: any) => void}> = [];
|
let resolvers: Array<{resolve: (value: Awaited<ReturnType<T>>) => void, reject: (reason: any) => void}> = [];
|
||||||
|
|
||||||
const invoke = async (args: Parameters<T>): Promise<void> => {
|
const invoke = async (args: Parameters<T>): Promise<void> => {
|
||||||
const settling = resolvers;
|
const settling = resolvers;
|
||||||
|
|||||||
@@ -71,11 +71,11 @@ export async function matchMention(mentionsUrl: string, queryText: string): Prom
|
|||||||
return sortAndReduce(results);
|
return sortAndReduce(results);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function matchIssue(owner: string, repo: string, issueIndexStr: string, query: string, signal: AbortSignal): Promise<Issue[]> {
|
export async function matchIssue(owner: string, repo: string, issueIndexStr: string | undefined, query: string, signal: AbortSignal): Promise<Issue[]> {
|
||||||
const res = await GET(`${window.config.appSubUrl}/${owner}/${repo}/issues/suggestions?q=${encodeURIComponent(query)}`, {signal});
|
const res = await GET(`${window.config.appSubUrl}/${owner}/${repo}/issues/suggestions?q=${encodeURIComponent(query)}`, {signal});
|
||||||
|
|
||||||
const issues: Issue[] = await res.json();
|
const issues: Issue[] = await res.json();
|
||||||
const issueNumber = parseInt(issueIndexStr);
|
const issueNumber = parseInt(issueIndexStr || '');
|
||||||
|
|
||||||
// filter out issue with same id
|
// filter out issue with same id
|
||||||
return issues.filter((i) => i.number !== issueNumber);
|
return issues.filter((i) => i.number !== issueNumber);
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export type DayDataObject = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayDataObject): DayData[] {
|
export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayDataObject): DayData[] {
|
||||||
const result: Record<string, any> = {};
|
const result: DayDataObject = {};
|
||||||
|
|
||||||
for (const startDay of startDays) {
|
for (const startDay of startDays) {
|
||||||
result[startDay] = data[startDay] || {'week': startDay, 'additions': 0, 'deletions': 0, 'commits': 0};
|
result[startDay] = data[startDay] || {'week': startDay, 'additions': 0, 'deletions': 0, 'commits': 0};
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ function getRelativeTimeUnit(duration: Duration, opts?: {relativeTo?: Date | num
|
|||||||
const rounded = roundToSingleUnit(duration, opts);
|
const rounded = roundToSingleUnit(duration, opts);
|
||||||
if (rounded.blank) return [0, 'second'];
|
if (rounded.blank) return [0, 'second'];
|
||||||
for (const unit of unitNames) {
|
for (const unit of unitNames) {
|
||||||
const val = (rounded as any)[`${unit}s`];
|
const val = rounded[`${unit}s`];
|
||||||
if (val) return [val, unit];
|
if (val) return [val, unit];
|
||||||
}
|
}
|
||||||
return [0, 'second'];
|
return [0, 'second'];
|
||||||
@@ -420,10 +420,10 @@ class RelativeTime extends HTMLElement {
|
|||||||
duration = emptyDuration;
|
duration = emptyDuration;
|
||||||
}
|
}
|
||||||
const d = duration.blank ? emptyDuration : duration.abs();
|
const d = duration.blank ? emptyDuration : duration.abs();
|
||||||
if (typeof Intl !== 'undefined' && (Intl as any).DurationFormat) {
|
if (typeof Intl !== 'undefined' && Intl.DurationFormat) {
|
||||||
const opts: Record<string, string> = {style};
|
const opts: Intl.DurationFormatOptions = {style};
|
||||||
if (duration.blank) opts.secondsDisplay = 'always';
|
if (duration.blank) opts.secondsDisplay = 'always';
|
||||||
return new (Intl as any).DurationFormat(locale, opts).format({
|
return new Intl.DurationFormat(locale, opts).format({
|
||||||
years: d.years, months: d.months, weeks: d.weeks, days: d.days,
|
years: d.years, months: d.months, weeks: d.weeks, days: d.days,
|
||||||
hours: d.hours, minutes: d.minutes, seconds: d.seconds,
|
hours: d.hours, minutes: d.minutes, seconds: d.seconds,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user