1
0
Fork 0
forked from forgejo/forgejo

Merge pull request '[gitea] cherry-pick' (#2375) from earl-warren/forgejo:wip-gitea-cherry-pick into forgejo

Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/2375
Reviewed-by: Otto <otto@codeberg.org>
This commit is contained in:
Earl Warren 2024-02-19 14:42:41 +00:00
commit 6c9c0aca76
67 changed files with 3542 additions and 880 deletions

View file

@ -413,6 +413,13 @@ ol.ui.list li,
color: var(--color-text-light-2);
}
/* extend fomantic style '.ui.dropdown > .text > img' to include svg.img */
.ui.dropdown > .text > .img {
margin-left: 0;
float: none;
margin-right: 0.78571429rem;
}
.ui.dropdown > .text > .description,
.ui.dropdown .menu > .item > .description {
color: var(--color-text-light-2);

View file

@ -7,6 +7,10 @@ extends:
- plugin:vue/vue3-recommended
- plugin:vue-scoped-css/vue3-recommended
parserOptions:
sourceType: module
ecmaVersion: latest
env:
browser: true

View file

@ -0,0 +1,443 @@
<script>
import {SvgIcon} from '../svg.js';
import {
Chart,
Title,
Tooltip,
Legend,
BarElement,
CategoryScale,
LinearScale,
TimeScale,
PointElement,
LineElement,
Filler,
} from 'chart.js';
import {GET} from '../modules/fetch.js';
import zoomPlugin from 'chartjs-plugin-zoom';
import {Line as ChartLine} from 'vue-chartjs';
import {
startDaysBetween,
firstStartDateAfterDate,
fillEmptyStartDaysWithZeroes,
} from '../utils/time.js';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import $ from 'jquery';
const {pageData} = window.config;
const colors = {
text: '--color-text',
border: '--color-secondary-alpha-60',
commits: '--color-primary-alpha-60',
additions: '--color-green',
deletions: '--color-red',
title: '--color-secondary-dark-4',
};
const styles = window.getComputedStyle(document.documentElement);
const getColor = (name) => styles.getPropertyValue(name).trim();
for (const [key, value] of Object.entries(colors)) {
colors[key] = getColor(value);
}
const customEventListener = {
id: 'customEventListener',
afterEvent: (chart, args, opts) => {
// event will be replayed from chart.update when reset zoom,
// so we need to check whether args.replay is true to avoid call loops
if (args.event.type === 'dblclick' && opts.chartType === 'main' && !args.replay) {
chart.resetZoom();
opts.instance.updateOtherCharts(args.event, true);
}
}
};
Chart.defaults.color = colors.text;
Chart.defaults.borderColor = colors.border;
Chart.register(
TimeScale,
CategoryScale,
LinearScale,
BarElement,
Title,
Tooltip,
Legend,
PointElement,
LineElement,
Filler,
zoomPlugin,
customEventListener,
);
export default {
components: {ChartLine, SvgIcon},
props: {
locale: {
type: Object,
required: true,
},
},
data: () => ({
isLoading: false,
errorText: '',
totalStats: {},
sortedContributors: {},
repoLink: pageData.repoLink || [],
type: pageData.contributionType,
contributorsStats: [],
xAxisStart: null,
xAxisEnd: null,
xAxisMin: null,
xAxisMax: null,
}),
mounted() {
this.fetchGraphData();
$('#repo-contributors').dropdown({
onChange: (val) => {
this.xAxisMin = this.xAxisStart;
this.xAxisMax = this.xAxisEnd;
this.type = val;
this.sortContributors();
}
});
},
methods: {
sortContributors() {
const contributors = this.filterContributorWeeksByDateRange();
const criteria = `total_${this.type}`;
this.sortedContributors = Object.values(contributors)
.filter((contributor) => contributor[criteria] !== 0)
.sort((a, b) => a[criteria] > b[criteria] ? -1 : a[criteria] === b[criteria] ? 0 : 1)
.slice(0, 100);
},
async fetchGraphData() {
this.isLoading = true;
try {
let response;
do {
response = await GET(`${this.repoLink}/activity/contributors/data`);
if (response.status === 202) {
await new Promise((resolve) => setTimeout(resolve, 1000)); // wait for 1 second before retrying
}
} while (response.status === 202);
if (response.ok) {
const data = await response.json();
const {total, ...rest} = data;
// 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 weekValues = Object.values(total.weeks);
this.xAxisStart = weekValues[0].week;
this.xAxisEnd = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(new Date(this.xAxisStart), new Date(this.xAxisEnd));
total.weeks = fillEmptyStartDaysWithZeroes(startDays, total.weeks);
this.xAxisMin = this.xAxisStart;
this.xAxisMax = this.xAxisEnd;
this.contributorsStats = {};
for (const [email, user] of Object.entries(rest)) {
user.weeks = fillEmptyStartDaysWithZeroes(startDays, user.weeks);
this.contributorsStats[email] = user;
}
this.sortContributors();
this.totalStats = total;
this.errorText = '';
} else {
this.errorText = response.statusText;
}
} catch (err) {
this.errorText = err.message;
} finally {
this.isLoading = false;
}
},
filterContributorWeeksByDateRange() {
const filteredData = {};
const data = this.contributorsStats;
for (const key of Object.keys(data)) {
const user = data[key];
user.total_commits = 0;
user.total_additions = 0;
user.total_deletions = 0;
user.max_contribution_type = 0;
const filteredWeeks = user.weeks.filter((week) => {
const oneWeek = 7 * 24 * 60 * 60 * 1000;
if (week.week >= this.xAxisMin - oneWeek && week.week <= this.xAxisMax + oneWeek) {
user.total_commits += week.commits;
user.total_additions += week.additions;
user.total_deletions += week.deletions;
if (week[this.type] > user.max_contribution_type) {
user.max_contribution_type = week[this.type];
}
return true;
}
return false;
});
// this line is required. See https://github.com/sahinakkaya/gitea/pull/3#discussion_r1396495722
// for details.
user.max_contribution_type += 1;
filteredData[key] = {...user, weeks: filteredWeeks};
}
return filteredData;
},
maxMainGraph() {
// This method calculates maximum value for Y value of the main graph. If the number
// of maximum contributions for selected contribution type is 15.955 it is probably
// better to round it up to 20.000.This method is responsible for doing that.
// Normally, chartjs handles this automatically, but it will resize the graph when you
// zoom, pan etc. I think resizing the graph makes it harder to compare things visually.
const maxValue = Math.max(
...this.totalStats.weeks.map((o) => o[this.type])
);
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
if (coefficient % 1 === 0) return maxValue;
return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
},
maxContributorGraph() {
// Similar to maxMainGraph method this method calculates maximum value for Y value
// for contributors' graph. If I let chartjs do this for me, it will choose different
// maxY value for each contributors' graph which again makes it harder to compare.
const maxValue = Math.max(
...this.sortedContributors.map((c) => c.max_contribution_type)
);
const [coefficient, exp] = maxValue.toExponential().split('e').map(Number);
if (coefficient % 1 === 0) return maxValue;
return (1 - (coefficient % 1)) * 10 ** exp + maxValue;
},
toGraphData(data) {
return {
datasets: [
{
data: data.map((i) => ({x: i.week, y: i[this.type]})),
pointRadius: 0,
pointHitRadius: 0,
fill: 'start',
backgroundColor: colors[this.type],
borderWidth: 0,
tension: 0.3,
},
],
};
},
updateOtherCharts(event, reset) {
const minVal = event.chart.options.scales.x.min;
const maxVal = event.chart.options.scales.x.max;
if (reset) {
this.xAxisMin = this.xAxisStart;
this.xAxisMax = this.xAxisEnd;
this.sortContributors();
} else if (minVal) {
this.xAxisMin = minVal;
this.xAxisMax = maxVal;
this.sortContributors();
}
},
getOptions(type) {
return {
responsive: true,
maintainAspectRatio: false,
animation: false,
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove', 'dblclick'],
plugins: {
title: {
display: type === 'main',
text: 'drag: zoom, shift+drag: pan, double click: reset zoom',
color: colors.title,
position: 'top',
align: 'center',
},
customEventListener: {
chartType: type,
instance: this,
},
legend: {
display: false,
},
zoom: {
pan: {
enabled: true,
modifierKey: 'shift',
mode: 'x',
threshold: 20,
onPanComplete: this.updateOtherCharts,
},
limits: {
x: {
// Check https://www.chartjs.org/chartjs-plugin-zoom/latest/guide/options.html#scale-limits
// to know what each option means
min: 'original',
max: 'original',
// number of milliseconds in 2 weeks. Minimum x range will be 2 weeks when you zoom on the graph
minRange: 2 * 7 * 24 * 60 * 60 * 1000,
},
},
zoom: {
drag: {
enabled: type === 'main',
},
pinch: {
enabled: type === 'main',
},
mode: 'x',
onZoomComplete: this.updateOtherCharts,
},
},
},
scales: {
x: {
min: this.xAxisMin,
max: this.xAxisMax,
type: 'time',
grid: {
display: false,
},
time: {
minUnit: 'month',
},
ticks: {
maxRotation: 0,
maxTicksLimit: type === 'main' ? 12 : 6,
},
},
y: {
min: 0,
max: type === 'main' ? this.maxMainGraph() : this.maxContributorGraph(),
ticks: {
maxTicksLimit: type === 'main' ? 6 : 4,
},
},
},
};
},
},
};
</script>
<template>
<div>
<h2 class="ui header gt-df gt-ac gt-sb">
<div>
<relative-time
v-if="xAxisMin > 0"
format="datetime"
year="numeric"
month="short"
day="numeric"
weekday=""
:datetime="new Date(xAxisMin)"
>
{{ new Date(xAxisMin) }}
</relative-time>
{{ isLoading ? locale.loadingTitle : errorText ? locale.loadingTitleFailed: "-" }}
<relative-time
v-if="xAxisMax > 0"
format="datetime"
year="numeric"
month="short"
day="numeric"
weekday=""
:datetime="new Date(xAxisMax)"
>
{{ new Date(xAxisMax) }}
</relative-time>
</div>
<div>
<!-- Contribution type -->
<div class="ui dropdown jump" id="repo-contributors">
<div class="ui basic compact button">
<span class="text">
{{ locale.filterLabel }} <strong>{{ locale.contributionType[type] }}</strong>
<svg-icon name="octicon-triangle-down" :size="14"/>
</span>
</div>
<div class="menu">
<div :class="['item', {'active': type === 'commits'}]">
{{ locale.contributionType.commits }}
</div>
<div :class="['item', {'active': type === 'additions'}]">
{{ locale.contributionType.additions }}
</div>
<div :class="['item', {'active': type === 'deletions'}]">
{{ locale.contributionType.deletions }}
</div>
</div>
</div>
</div>
</h2>
<div class="gt-df ui segment main-graph">
<div v-if="isLoading || errorText !== ''" class="gt-tc gt-m-auto">
<div v-if="isLoading">
<SvgIcon name="octicon-sync" class="gt-mr-3 job-status-rotate"/>
{{ locale.loadingInfo }}
</div>
<div v-else class="text red">
<SvgIcon name="octicon-x-circle-fill"/>
{{ errorText }}
</div>
</div>
<ChartLine
v-memo="[totalStats.weeks, type]" v-if="Object.keys(totalStats).length !== 0"
:data="toGraphData(totalStats.weeks)" :options="getOptions('main')"
/>
</div>
<div class="contributor-grid">
<div
v-for="(contributor, index) in sortedContributors" :key="index" class="stats-table"
v-memo="[sortedContributors, type]"
>
<div class="ui top attached header gt-df gt-f1">
<b class="ui right">#{{ index + 1 }}</b>
<a :href="contributor.home_link">
<img class="ui avatar gt-vm" height="40" width="40" :src="contributor.avatar_link">
</a>
<div class="gt-ml-3">
<a v-if="contributor.home_link !== ''" :href="contributor.home_link"><h4>{{ contributor.name }}</h4></a>
<h4 v-else class="contributor-name">
{{ contributor.name }}
</h4>
<p class="gt-font-12 gt-df gt-gap-2">
<strong v-if="contributor.total_commits">{{ contributor.total_commits.toLocaleString() }} {{ locale.contributionType.commits }}</strong>
<strong v-if="contributor.total_additions" class="text green">{{ contributor.total_additions.toLocaleString() }}++ </strong>
<strong v-if="contributor.total_deletions" class="text red">
{{ contributor.total_deletions.toLocaleString() }}--</strong>
</p>
</div>
</div>
<div class="ui attached segment">
<div>
<ChartLine
:data="toGraphData(contributor.weeks)"
:options="getOptions('contributor')"
/>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.main-graph {
height: 260px;
}
.contributor-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.contributor-name {
margin-bottom: 0;
}
</style>

View file

@ -1,14 +1,13 @@
import $ from 'jquery';
import {initCompLabelEdit} from './comp/LabelEdit.js';
import {toggleElem} from '../utils/dom.js';
export function initCommonOrganization() {
if ($('.organization').length === 0) {
if (!document.querySelectorAll('.organization').length) {
return;
}
$('.organization.settings.options #org_name').on('input', function () {
const nameChanged = $(this).val().toLowerCase() !== $(this).attr('data-org-name').toLowerCase();
document.querySelector('.organization.settings.options #org_name')?.addEventListener('input', function () {
const nameChanged = this.value.toLowerCase() !== this.getAttribute('data-org-name').toLowerCase();
toggleElem('#org-name-change-prompt', nameChanged);
});

View file

@ -1,5 +1,3 @@
import $ from 'jquery';
export function handleGlobalEnterQuickSubmit(target) {
const form = target.closest('form');
if (form) {
@ -8,14 +6,9 @@ export function handleGlobalEnterQuickSubmit(target) {
return;
}
if (form.classList.contains('form-fetch-action')) {
form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true}));
return;
}
// here use the event to trigger the submit event (instead of calling `submit()` method directly)
// otherwise the `areYouSure` handler won't be executed, then there will be an annoying "confirm to leave" dialog
$(form).trigger('submit');
form.dispatchEvent(new SubmitEvent('submit', {bubbles: true, cancelable: true}));
} else {
// if no form, then the editor is for an AJAX request, dispatch an event to the target, let the target's event handler to do the AJAX request.
// the 'ce-' prefix means this is a CustomEvent

View file

@ -1,43 +1,41 @@
import $ from 'jquery';
import {POST} from '../../modules/fetch.js';
import {hideElem, showElem, toggleElem} from '../../utils/dom.js';
const {csrfToken} = window.config;
export function initCompWebHookEditor() {
if ($('.new.webhook').length === 0) {
if (!document.querySelectorAll('.new.webhook').length) {
return;
}
$('.events.checkbox input').on('change', function () {
if ($(this).is(':checked')) {
showElem($('.events.fields'));
}
});
$('.non-events.checkbox input').on('change', function () {
if ($(this).is(':checked')) {
hideElem($('.events.fields'));
}
});
for (const input of document.querySelectorAll('.events.checkbox input')) {
input.addEventListener('change', function () {
if (this.checked) {
showElem('.events.fields');
}
});
}
for (const input of document.querySelectorAll('.non-events.checkbox input')) {
input.addEventListener('change', function () {
if (this.checked) {
hideElem('.events.fields');
}
});
}
const updateContentType = function () {
const visible = $('#http_method').val() === 'POST';
toggleElem($('#content_type').parent().parent(), visible);
const visible = document.getElementById('http_method').value === 'POST';
toggleElem(document.getElementById('content_type').parentNode.parentNode, visible);
};
updateContentType();
$('#http_method').on('change', () => {
updateContentType();
});
document.getElementById('http_method').addEventListener('change', updateContentType);
// Test delivery
$('#test-delivery').on('click', function () {
const $this = $(this);
$this.addClass('loading disabled');
$.post($this.data('link'), {
_csrf: csrfToken
}).done(
setTimeout(() => {
window.location.href = $this.data('redirect');
}, 5000)
);
document.getElementById('test-delivery')?.addEventListener('click', async function () {
this.classList.add('loading', 'disabled');
await POST(this.getAttribute('data-link'));
setTimeout(() => {
window.location.href = this.getAttribute('data-redirect');
}, 5000);
});
}

View file

@ -1,11 +1,10 @@
import $ from 'jquery';
import {createApp} from 'vue';
import ContextPopup from '../components/ContextPopup.vue';
import {parseIssueHref} from '../utils.js';
import {createTippy} from '../modules/tippy.js';
export function initContextPopups() {
const refIssues = $('.ref-issue');
const refIssues = document.querySelectorAll('.ref-issue');
attachRefIssueContextPopup(refIssues);
}

View file

@ -0,0 +1,28 @@
import {createApp} from 'vue';
export async function initRepoContributors() {
const el = document.getElementById('repo-contributors-chart');
if (!el) return;
const {default: RepoContributors} = await import(/* webpackChunkName: "contributors-graph" */'../components/RepoContributors.vue');
try {
const View = createApp(RepoContributors, {
locale: {
filterLabel: el.getAttribute('data-locale-filter-label'),
contributionType: {
commits: el.getAttribute('data-locale-contribution-type-commits'),
additions: el.getAttribute('data-locale-contribution-type-additions'),
deletions: el.getAttribute('data-locale-contribution-type-deletions'),
},
loadingTitle: el.getAttribute('data-locale-loading-title'),
loadingTitleFailed: el.getAttribute('data-locale-loading-title-failed'),
loadingInfo: el.getAttribute('data-locale-loading-info'),
}
});
View.mount(el);
} catch (err) {
console.error('RepoContributors failed to load', err);
el.textContent = el.getAttribute('data-locale-component-failed-to-load');
}
}

View file

@ -194,7 +194,7 @@ export function initRepoCodeView() {
const blob = await $.get(`${url}?${query}&anchor=${anchor}`);
currentTarget.closest('tr').outerHTML = blob;
});
$(document).on('click', '.copy-line-permalink', async (e) => {
await clippie(toAbsoluteUrl(e.currentTarget.getAttribute('data-url')));
$(document).on('click', '.copy-line-permalink', async ({currentTarget}) => {
await clippie(toAbsoluteUrl(currentTarget.getAttribute('data-url')));
});
}

View file

@ -69,16 +69,12 @@ function initRepoIssueListCheckboxes() {
}
}
updateIssuesMeta(
url,
action,
issueIDs,
elementId,
).then(() => {
try {
await updateIssuesMeta(url, action, issueIDs, elementId);
window.location.reload();
}).catch((reason) => {
showErrorToast(reason.responseJSON.error);
});
} catch (err) {
showErrorToast(err.responseJSON?.error ?? err.message);
}
});
}

View file

@ -344,19 +344,15 @@ export async function updateIssuesMeta(url, action, issueIds, elementId) {
export function initRepoIssueComments() {
if ($('.repository.view.issue .timeline').length === 0) return;
$('.re-request-review').on('click', function (e) {
$('.re-request-review').on('click', async function (e) {
e.preventDefault();
const url = $(this).data('update-url');
const issueId = $(this).data('issue-id');
const id = $(this).data('id');
const isChecked = $(this).hasClass('checked');
updateIssuesMeta(
url,
isChecked ? 'detach' : 'attach',
issueId,
id,
).then(() => window.location.reload());
await updateIssuesMeta(url, isChecked ? 'detach' : 'attach', issueId, id);
window.location.reload();
});
$(document).on('click', (event) => {

View file

@ -205,12 +205,15 @@ export function initRepoCommentForm() {
$listMenu.find('.no-select.item').on('click', function (e) {
e.preventDefault();
if (hasUpdateAction) {
updateIssuesMeta(
$listMenu.data('update-url'),
'clear',
$listMenu.data('issue-id'),
'',
).then(reloadConfirmDraftComment);
(async () => {
await updateIssuesMeta(
$listMenu.data('update-url'),
'clear',
$listMenu.data('issue-id'),
'',
);
reloadConfirmDraftComment();
})();
}
$(this).parent().find('.item').each(function () {
@ -248,12 +251,15 @@ export function initRepoCommentForm() {
$(this).addClass('selected active');
if (hasUpdateAction) {
updateIssuesMeta(
$menu.data('update-url'),
'',
$menu.data('issue-id'),
$(this).data('id'),
).then(reloadConfirmDraftComment);
(async () => {
await updateIssuesMeta(
$menu.data('update-url'),
'',
$menu.data('issue-id'),
$(this).data('id'),
);
reloadConfirmDraftComment();
})();
}
let icon = '';
@ -281,12 +287,15 @@ export function initRepoCommentForm() {
});
if (hasUpdateAction) {
updateIssuesMeta(
$menu.data('update-url'),
'',
$menu.data('issue-id'),
$(this).data('id'),
).then(reloadConfirmDraftComment);
(async () => {
await updateIssuesMeta(
$menu.data('update-url'),
'',
$menu.data('issue-id'),
$(this).data('id'),
);
reloadConfirmDraftComment();
})();
}
$list.find('.selected').html('');

View file

@ -1,12 +1,10 @@
import $ from 'jquery';
export function initSshKeyFormParser() {
// Parse SSH Key
$('#ssh-key-content').on('change paste keyup', function () {
const arrays = $(this).val().split(' ');
const $title = $('#ssh-key-title');
if ($title.val() === '' && arrays.length === 3 && arrays[2] !== '') {
$title.val(arrays[2]);
// Parse SSH Key
document.getElementById('ssh-key-content')?.addEventListener('input', function () {
const arrays = this.value.split(' ');
const title = document.getElementById('ssh-key-title');
if (!title.value && arrays.length === 3 && arrays[2] !== '') {
title.value = arrays[2];
}
});
}

View file

@ -1,18 +1,19 @@
import $ from 'jquery';
import {hideElem, showElem} from '../utils/dom.js';
export function initUserSettings() {
if ($('.user.settings.profile').length > 0) {
$('#username').on('keyup', function () {
const $prompt = $('#name-change-prompt');
const $prompt_redirect = $('#name-change-redirect-prompt');
if ($(this).val().toString().toLowerCase() !== $(this).data('name').toString().toLowerCase()) {
showElem($prompt);
showElem($prompt_redirect);
} else {
hideElem($prompt);
hideElem($prompt_redirect);
}
});
}
if (document.querySelectorAll('.user.settings.profile').length === 0) return;
const usernameInput = document.getElementById('username');
if (!usernameInput) return;
usernameInput.addEventListener('input', function () {
const prompt = document.getElementById('name-change-prompt');
const promptRedirect = document.getElementById('name-change-redirect-prompt');
if (this.value.toLowerCase() !== this.getAttribute('data-name').toLowerCase()) {
showElem(prompt);
showElem(promptRedirect);
} else {
hideElem(prompt);
hideElem(promptRedirect);
}
});
}

View file

@ -1,6 +1,9 @@
import * as htmx from 'htmx.org';
import {showErrorToast} from './modules/toast.js';
// https://github.com/bigskysoftware/idiomorph#htmx
import 'idiomorph/dist/idiomorph-ext.js';
// https://htmx.org/reference/#config
htmx.config.requestClass = 'is-loading';
htmx.config.scrollIntoViewOnBoost = false;

View file

@ -83,6 +83,7 @@ import {initGiteaFomantic} from './modules/fomantic.js';
import {onDomReady} from './utils/dom.js';
import {initRepoIssueList} from './features/repo-issue-list.js';
import {initCommonIssueListQuickGoto} from './features/common-issue-list.js';
import {initRepoContributors} from './features/contributors.js';
import {initRepoDiffCommitBranchesAndTags} from './features/repo-diff-commit.js';
import {initDirAuto} from './modules/dirauto.js';
@ -172,6 +173,7 @@ onDomReady(() => {
initRepoWikiForm();
initRepository();
initRepositoryActionView();
initRepoContributors();
initCommitStatuses();
initCaptcha();

View file

@ -1,4 +1,4 @@
import $ from 'jquery';
import {POST} from '../modules/fetch.js';
const preventListener = (e) => e.preventDefault();
@ -55,12 +55,11 @@ export function initMarkupTasklist() {
const updateUrl = editContentZone.getAttribute('data-update-url');
const context = editContentZone.getAttribute('data-context');
await $.post(updateUrl, {
ignore_attachments: true,
_csrf: window.config.csrfToken,
content: newContent,
context
});
const requestBody = new FormData();
requestBody.append('ignore_attachments', 'true');
requestBody.append('content', newContent);
requestBody.append('context', context);
await POST(updateUrl, {data: requestBody});
rawContent.textContent = newContent;
} catch (err) {

View file

@ -8,19 +8,17 @@ const safeMethods = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
// fetch wrapper, use below method name functions and the `data` option to pass in data
// which will automatically set an appropriate headers. For json content, only object
// and array types are currently supported.
export function request(url, {method = 'GET', headers = {}, data, body, ...other} = {}) {
let contentType;
if (!body) {
if (data instanceof FormData || data instanceof URLSearchParams) {
body = data;
} else if (isObject(data) || Array.isArray(data)) {
contentType = 'application/json';
body = JSON.stringify(data);
}
export function request(url, {method = 'GET', data, headers = {}, ...other} = {}) {
let body, contentType;
if (data instanceof FormData || data instanceof URLSearchParams) {
body = data;
} else if (isObject(data) || Array.isArray(data)) {
contentType = 'application/json';
body = JSON.stringify(data);
}
const headersMerged = new Headers({
...(!safeMethods.has(method.toUpperCase()) && {'x-csrf-token': csrfToken}),
...(!safeMethods.has(method) && {'x-csrf-token': csrfToken}),
...(contentType && {'content-type': contentType}),
});
@ -31,8 +29,8 @@ export function request(url, {method = 'GET', headers = {}, data, body, ...other
return fetch(url, {
method,
headers: headersMerged,
...(body && {body}),
...other,
...(body && {body}),
});
}

46
web_src/js/utils/time.js Normal file
View file

@ -0,0 +1,46 @@
import dayjs from 'dayjs';
// Returns an array of millisecond-timestamps of start-of-week days (Sundays)
export function startDaysBetween(startDate, endDate) {
// Ensure the start date is a Sunday
while (startDate.getDay() !== 0) {
startDate.setDate(startDate.getDate() + 1);
}
const start = dayjs(startDate);
const end = dayjs(endDate);
const startDays = [];
let current = start;
while (current.isBefore(end)) {
startDays.push(current.valueOf());
// we are adding 7 * 24 hours instead of 1 week because we don't want
// date library to use local time zone to calculate 1 week from now.
// local time zone is problematic because of daylight saving time (dst)
// used on some countries
current = current.add(7 * 24, 'hour');
}
return startDays;
}
export function firstStartDateAfterDate(inputDate) {
if (!(inputDate instanceof Date)) {
throw new Error('Invalid date');
}
const dayOfWeek = inputDate.getDay();
const daysUntilSunday = 7 - dayOfWeek;
const resultDate = new Date(inputDate.getTime());
resultDate.setDate(resultDate.getDate() + daysUntilSunday);
return resultDate.valueOf();
}
export function fillEmptyStartDaysWithZeroes(startDays, data) {
const result = {};
for (const startDay of startDays) {
result[startDay] = data[startDay] || {'week': startDay, 'additions': 0, 'deletions': 0, 'commits': 0};
}
return Object.values(result);
}

View file

@ -0,0 +1,15 @@
import {startDaysBetween} from './time.js';
test('startDaysBetween', () => {
expect(startDaysBetween(new Date('2024-02-15'), new Date('2024-04-18'))).toEqual([
1708214400000,
1708819200000,
1709424000000,
1710028800000,
1710633600000,
1711238400000,
1711843200000,
1712448000000,
1713052800000,
]);
});