Migrate vue components to setup (#32329)

Migrated a handful Vue components to the `setup` syntax using
composition api as it has better Typescript support and is becoming the
new default in the Vue ecosystem.

- [x] ActionRunStatus.vue    
- [x] ActivityHeatmap.vue
- [x] ContextPopup.vue       
- [x] DiffFileList.vue
- [x] DiffFileTree.vue       
- [x] DiffFileTreeItem.vue    
- [x] PullRequestMergeForm.vue
- [x] RepoActivityTopAuthors.vue  
- [x] RepoCodeFrequency.vue
- [x] RepoRecentCommits.vue
- [x] ScopedAccessTokenSelector.vue

Left some larger components untouched for now to not go to crazy in this
single PR:
- [ ] DiffCommitSelector.vue  
- [ ] RepoActionView.vue
- [ ] RepoContributors.vue
- [ ] DashboardRepoList.vue  
- [ ] RepoBranchTagSelector.vue
This commit is contained in:
Anbraten 2024-10-28 21:15:05 +01:00 committed by GitHub
parent a920fcfd91
commit 348d1d0f32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 702 additions and 708 deletions

View File

@ -2,31 +2,21 @@
Please also update the template file above if this vue is modified. Please also update the template file above if this vue is modified.
action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, unknown action status accepted: success, skipped, waiting, blocked, running, failure, cancelled, unknown
--> -->
<script lang="ts"> <script lang="ts" setup>
import {SvgIcon} from '../svg.ts'; import {SvgIcon} from '../svg.ts';
export default { withDefaults(defineProps<{
components: {SvgIcon}, status: '',
props: { size?: number,
status: { className?: string,
type: String, localeStatus?: string,
required: true, }>(), {
}, size: 16,
size: { className: undefined,
type: Number, localeStatus: undefined,
default: 16, });
},
className: {
type: String,
default: '',
},
localeStatus: {
type: String,
default: '',
},
},
};
</script> </script>
<template> <template>
<span class="tw-flex tw-items-center" :data-tooltip-content="localeStatus" v-if="status"> <span class="tw-flex tw-items-center" :data-tooltip-content="localeStatus" v-if="status">
<SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class-name="className" v-if="status === 'success'"/> <SvgIcon name="octicon-check-circle-fill" class="text green" :size="size" :class-name="className" v-if="status === 'success'"/>

View File

@ -1,58 +1,56 @@
<script lang="ts"> <script lang="ts" setup>
// TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged // TODO: Switch to upstream after https://github.com/razorness/vue3-calendar-heatmap/pull/34 is merged
import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap'; import {CalendarHeatmap} from '@silverwind/vue3-calendar-heatmap';
import {onMounted, ref} from 'vue';
import type {Value as HeatmapValue, Locale as HeatmapLocale} from '@silverwind/vue3-calendar-heatmap';
export default { defineProps<{
components: {CalendarHeatmap}, values?: HeatmapValue[];
props: { locale: {
values: { textTotalContributions: string;
type: Array, heatMapLocale: Partial<HeatmapLocale>;
default: () => [], noDataText: string;
}, tooltipUnit: string;
locale: { };
type: Object, }>();
default: () => {},
},
},
data: () => ({
colorRange: [
'var(--color-secondary-alpha-60)',
'var(--color-secondary-alpha-60)',
'var(--color-primary-light-4)',
'var(--color-primary-light-2)',
'var(--color-primary)',
'var(--color-primary-dark-2)',
'var(--color-primary-dark-4)',
],
endDate: new Date(),
}),
mounted() {
// work around issue with first legend color being rendered twice and legend cut off
const legend = document.querySelector('.vch__external-legend-wrapper');
legend.setAttribute('viewBox', '12 0 80 10');
legend.style.marginRight = '-12px';
},
methods: {
handleDayClick(e) {
// Reset filter if same date is clicked
const params = new URLSearchParams(document.location.search);
const queryDate = params.get('date');
// Timezone has to be stripped because toISOString() converts to UTC
const clickedDate = new Date(e.date - (e.date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
if (queryDate && queryDate === clickedDate) { const colorRange = [
params.delete('date'); 'var(--color-secondary-alpha-60)',
} else { 'var(--color-secondary-alpha-60)',
params.set('date', clickedDate); 'var(--color-primary-light-4)',
} 'var(--color-primary-light-2)',
'var(--color-primary)',
'var(--color-primary-dark-2)',
'var(--color-primary-dark-4)',
];
params.delete('page'); const endDate = ref(new Date());
const newSearch = params.toString(); onMounted(() => {
window.location.search = newSearch.length ? `?${newSearch}` : ''; // work around issue with first legend color being rendered twice and legend cut off
}, const legend = document.querySelector<HTMLElement>('.vch__external-legend-wrapper');
}, legend.setAttribute('viewBox', '12 0 80 10');
}; legend.style.marginRight = '-12px';
});
function handleDayClick(e: Event & {date: Date}) {
// Reset filter if same date is clicked
const params = new URLSearchParams(document.location.search);
const queryDate = params.get('date');
// Timezone has to be stripped because toISOString() converts to UTC
const clickedDate = new Date(e.date.getTime() - (e.date.getTimezoneOffset() * 60000)).toISOString().substring(0, 10);
if (queryDate && queryDate === clickedDate) {
params.delete('date');
} else {
params.set('date', clickedDate);
}
params.delete('page');
const newSearch = params.toString();
window.location.search = newSearch.length ? `?${newSearch}` : '';
}
</script> </script>
<template> <template>
<div class="total-contributions"> <div class="total-contributions">

View File

@ -1,100 +1,96 @@
<script lang="ts"> <script lang="ts" setup>
import {SvgIcon} from '../svg.ts'; import {SvgIcon} from '../svg.ts';
import {GET} from '../modules/fetch.ts'; import {GET} from '../modules/fetch.ts';
import {computed, onMounted, ref} from 'vue';
import type {Issue} from '../types';
const {appSubUrl, i18n} = window.config; const {appSubUrl, i18n} = window.config;
export default { const loading = ref(false);
components: {SvgIcon}, const issue = ref(null);
data: () => ({ const renderedLabels = ref('');
loading: false, const i18nErrorOccurred = i18n.error_occurred;
issue: null, const i18nErrorMessage = ref(null);
renderedLabels: '',
i18nErrorOccurred: i18n.error_occurred,
i18nErrorMessage: null,
}),
computed: {
createdAt() {
return new Date(this.issue.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'});
},
body() { const createdAt = computed(() => new Date(issue.value.created_at).toLocaleDateString(undefined, {year: 'numeric', month: 'short', day: 'numeric'}));
const body = this.issue.body.replace(/\n+/g, ' '); const body = computed(() => {
if (body.length > 85) { const body = issue.value.body.replace(/\n+/g, ' ');
return `${body.substring(0, 85)}`; if (body.length > 85) {
} return `${body.substring(0, 85)}`;
return body; }
}, return body;
});
icon() { function getIssueIcon(issue: Issue) {
if (this.issue.pull_request !== null) { if (issue.pull_request) {
if (this.issue.state === 'open') { if (issue.state === 'open') {
if (this.issue.pull_request.draft === true) { if (issue.pull_request.draft === true) {
return 'octicon-git-pull-request-draft'; // WIP PR return 'octicon-git-pull-request-draft'; // WIP PR
}
return 'octicon-git-pull-request'; // Open PR
} else if (this.issue.pull_request.merged === true) {
return 'octicon-git-merge'; // Merged PR
}
return 'octicon-git-pull-request'; // Closed PR
} else if (this.issue.state === 'open') {
return 'octicon-issue-opened'; // Open Issue
} }
return 'octicon-issue-closed'; // Closed Issue return 'octicon-git-pull-request'; // Open PR
}, } else if (issue.pull_request.merged === true) {
return 'octicon-git-merge'; // Merged PR
}
return 'octicon-git-pull-request'; // Closed PR
} else if (issue.state === 'open') {
return 'octicon-issue-opened'; // Open Issue
}
return 'octicon-issue-closed'; // Closed Issue
}
color() { function getIssueColor(issue: Issue) {
if (this.issue.pull_request !== null) { if (issue.pull_request) {
if (this.issue.pull_request.draft === true) { if (issue.pull_request.draft === true) {
return 'grey'; // WIP PR return 'grey'; // WIP PR
} else if (this.issue.pull_request.merged === true) { } else if (issue.pull_request.merged === true) {
return 'purple'; // Merged PR return 'purple'; // Merged PR
} }
} }
if (this.issue.state === 'open') { if (issue.state === 'open') {
return 'green'; // Open Issue return 'green'; // Open Issue
} }
return 'red'; // Closed Issue return 'red'; // Closed Issue
}, }
},
mounted() {
this.$refs.root.addEventListener('ce-load-context-popup', (e) => {
const data = e.detail;
if (!this.loading && this.issue === null) {
this.load(data);
}
});
},
methods: {
async load(data) {
this.loading = true;
this.i18nErrorMessage = null;
try { const root = ref<HTMLElement | null>(null);
const response = await GET(`${appSubUrl}/${data.owner}/${data.repo}/issues/${data.index}/info`); // backend: GetIssueInfo
const respJson = await response.json(); onMounted(() => {
if (!response.ok) { root.value.addEventListener('ce-load-context-popup', (e: CustomEvent) => {
this.i18nErrorMessage = respJson.message ?? i18n.network_error; const data = e.detail;
return; if (!loading.value && issue.value === null) {
} load(data);
this.issue = respJson.convertedIssue; }
this.renderedLabels = respJson.renderedLabels; });
} catch { });
this.i18nErrorMessage = i18n.network_error;
} finally { async function load(data) {
this.loading = false; loading.value = true;
} i18nErrorMessage.value = null;
},
}, try {
}; const response = await GET(`${appSubUrl}/${data.owner}/${data.repo}/issues/${data.index}/info`); // backend: GetIssueInfo
const respJson = await response.json();
if (!response.ok) {
i18nErrorMessage.value = respJson.message ?? i18n.network_error;
return;
}
issue.value = respJson.convertedIssue;
renderedLabels.value = respJson.renderedLabels;
} catch {
i18nErrorMessage.value = i18n.network_error;
} finally {
loading.value = false;
}
}
</script> </script>
<template> <template>
<div ref="root"> <div ref="root">
<div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/> <div v-if="loading" class="tw-h-12 tw-w-12 is-loading"/>
<div v-if="!loading && issue !== null" class="tw-flex tw-flex-col tw-gap-2"> <div v-if="!loading && issue !== null" class="tw-flex tw-flex-col tw-gap-2">
<div class="tw-text-12">{{ issue.repository.full_name }} on {{ createdAt }}</div> <div class="tw-text-12">{{ issue.repository.full_name }} on {{ createdAt }}</div>
<div class="flex-text-block"> <div class="flex-text-block">
<svg-icon :name="icon" :class="['text', color]"/> <svg-icon :name="getIssueIcon(issue)" :class="['text', getIssueColor(issue)]"/>
<span class="issue-title tw-font-semibold tw-break-anywhere"> <span class="issue-title tw-font-semibold tw-break-anywhere">
{{ issue.title }} {{ issue.title }}
<span class="index">#{{ issue.number }}</span> <span class="index">#{{ issue.number }}</span>

View File

@ -1,40 +1,42 @@
<script lang="ts"> <script lang="ts" setup>
import {onMounted, onUnmounted} from 'vue';
import {loadMoreFiles} from '../features/repo-diff.ts'; import {loadMoreFiles} from '../features/repo-diff.ts';
import {diffTreeStore} from '../modules/stores.ts'; import {diffTreeStore} from '../modules/stores.ts';
export default { const store = diffTreeStore();
data: () => {
return {store: diffTreeStore()}; onMounted(() => {
}, document.querySelector('#show-file-list-btn').addEventListener('click', toggleFileList);
mounted() { });
document.querySelector('#show-file-list-btn').addEventListener('click', this.toggleFileList);
}, onUnmounted(() => {
unmounted() { document.querySelector('#show-file-list-btn').removeEventListener('click', toggleFileList);
document.querySelector('#show-file-list-btn').removeEventListener('click', this.toggleFileList); });
},
methods: { function toggleFileList() {
toggleFileList() { store.fileListIsVisible = !store.fileListIsVisible;
this.store.fileListIsVisible = !this.store.fileListIsVisible; }
},
diffTypeToString(pType) { function diffTypeToString(pType) {
const diffTypes = { const diffTypes = {
1: 'add', 1: 'add',
2: 'modify', 2: 'modify',
3: 'del', 3: 'del',
4: 'rename', 4: 'rename',
5: 'copy', 5: 'copy',
}; };
return diffTypes[pType]; return diffTypes[pType];
}, }
diffStatsWidth(adds, dels) {
return `${adds / (adds + dels) * 100}%`; function diffStatsWidth(adds, dels) {
}, return `${adds / (adds + dels) * 100}%`;
loadMoreData() { }
loadMoreFiles(this.store.linkLoadMore);
}, function loadMoreData() {
}, loadMoreFiles(store.linkLoadMore);
}; }
</script> </script>
<template> <template>
<ol class="diff-stats tw-m-0" ref="root" v-if="store.fileListIsVisible"> <ol class="diff-stats tw-m-0" ref="root" v-if="store.fileListIsVisible">
<li v-for="file in store.files" :key="file.NameHash"> <li v-for="file in store.files" :key="file.NameHash">

View File

@ -1,130 +1,137 @@
<script lang="ts"> <script lang="ts" setup>
import DiffFileTreeItem from './DiffFileTreeItem.vue'; import DiffFileTreeItem from './DiffFileTreeItem.vue';
import {loadMoreFiles} from '../features/repo-diff.ts'; import {loadMoreFiles} from '../features/repo-diff.ts';
import {toggleElem} from '../utils/dom.ts'; import {toggleElem} from '../utils/dom.ts';
import {diffTreeStore} from '../modules/stores.ts'; import {diffTreeStore} from '../modules/stores.ts';
import {setFileFolding} from '../features/file-fold.ts'; import {setFileFolding} from '../features/file-fold.ts';
import {computed, onMounted, onUnmounted} from 'vue';
const LOCAL_STORAGE_KEY = 'diff_file_tree_visible'; const LOCAL_STORAGE_KEY = 'diff_file_tree_visible';
export default { const store = diffTreeStore();
components: {DiffFileTreeItem},
data: () => {
return {store: diffTreeStore()};
},
computed: {
fileTree() {
const result = [];
for (const file of this.store.files) {
// Split file into directories
const splits = file.Name.split('/');
let index = 0;
let parent = null;
let isFile = false;
for (const split of splits) {
index += 1;
// reached the end
if (index === splits.length) {
isFile = true;
}
let newParent = {
name: split,
children: [],
isFile,
};
if (isFile === true) { const fileTree = computed(() => {
newParent.file = file; const result = [];
} for (const file of store.files) {
// Split file into directories
if (parent) { const splits = file.Name.split('/');
// check if the folder already exists let index = 0;
const existingFolder = parent.children.find( let parent = null;
(x) => x.name === split, let isFile = false;
); for (const split of splits) {
if (existingFolder) { index += 1;
newParent = existingFolder; // reached the end
} else { if (index === splits.length) {
parent.children.push(newParent); isFile = true;
}
} else {
const existingFolder = result.find((x) => x.name === split);
if (existingFolder) {
newParent = existingFolder;
} else {
result.push(newParent);
}
}
parent = newParent;
}
} }
const mergeChildIfOnlyOneDir = (entries) => { let newParent = {
for (const entry of entries) { name: split,
if (entry.children) { children: [],
mergeChildIfOnlyOneDir(entry.children); isFile,
} } as {
if (entry.children.length === 1 && entry.children[0].isFile === false) { name: string,
// Merge it to the parent children: any[],
entry.name = `${entry.name}/${entry.children[0].name}`; isFile: boolean,
entry.children = entry.children[0].children; file?: any,
}
}
}; };
// Merge folders with just a folder as children in order to
// reduce the depth of our tree.
mergeChildIfOnlyOneDir(result);
return result;
},
},
mounted() {
// Default to true if unset
this.store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', this.toggleVisibility);
this.hashChangeListener = () => { if (isFile === true) {
this.store.selectedItem = window.location.hash; newParent.file = file;
this.expandSelectedFile();
};
this.hashChangeListener();
window.addEventListener('hashchange', this.hashChangeListener);
},
unmounted() {
document.querySelector('.diff-toggle-file-tree-button').removeEventListener('click', this.toggleVisibility);
window.removeEventListener('hashchange', this.hashChangeListener);
},
methods: {
expandSelectedFile() {
// expand file if the selected file is folded
if (this.store.selectedItem) {
const box = document.querySelector(this.store.selectedItem);
const folded = box?.getAttribute('data-folded') === 'true';
if (folded) setFileFolding(box, box.querySelector('.fold-file'), false);
} }
},
toggleVisibility() { if (parent) {
this.updateVisibility(!this.store.fileTreeIsVisible); // check if the folder already exists
}, const existingFolder = parent.children.find(
updateVisibility(visible) { (x) => x.name === split,
this.store.fileTreeIsVisible = visible; );
localStorage.setItem(LOCAL_STORAGE_KEY, this.store.fileTreeIsVisible); if (existingFolder) {
this.updateState(this.store.fileTreeIsVisible); newParent = existingFolder;
}, } else {
updateState(visible) { parent.children.push(newParent);
const btn = document.querySelector('.diff-toggle-file-tree-button'); }
const [toShow, toHide] = btn.querySelectorAll('.icon'); } else {
const tree = document.querySelector('#diff-file-tree'); const existingFolder = result.find((x) => x.name === split);
const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text'); if (existingFolder) {
btn.setAttribute('data-tooltip-content', newTooltip); newParent = existingFolder;
toggleElem(tree, visible); } else {
toggleElem(toShow, !visible); result.push(newParent);
toggleElem(toHide, visible); }
}, }
loadMoreData() { parent = newParent;
loadMoreFiles(this.store.linkLoadMore); }
}, }
}, const mergeChildIfOnlyOneDir = (entries) => {
}; for (const entry of entries) {
if (entry.children) {
mergeChildIfOnlyOneDir(entry.children);
}
if (entry.children.length === 1 && entry.children[0].isFile === false) {
// Merge it to the parent
entry.name = `${entry.name}/${entry.children[0].name}`;
entry.children = entry.children[0].children;
}
}
};
// Merge folders with just a folder as children in order to
// reduce the depth of our tree.
mergeChildIfOnlyOneDir(result);
return result;
});
onMounted(() => {
// Default to true if unset
store.fileTreeIsVisible = localStorage.getItem(LOCAL_STORAGE_KEY) !== 'false';
document.querySelector('.diff-toggle-file-tree-button').addEventListener('click', toggleVisibility);
hashChangeListener();
window.addEventListener('hashchange', hashChangeListener);
});
onUnmounted(() => {
document.querySelector('.diff-toggle-file-tree-button').removeEventListener('click', toggleVisibility);
window.removeEventListener('hashchange', hashChangeListener);
});
function hashChangeListener() {
store.selectedItem = window.location.hash;
expandSelectedFile();
}
function expandSelectedFile() {
// expand file if the selected file is folded
if (store.selectedItem) {
const box = document.querySelector(store.selectedItem);
const folded = box?.getAttribute('data-folded') === 'true';
if (folded) setFileFolding(box, box.querySelector('.fold-file'), false);
}
}
function toggleVisibility() {
updateVisibility(!store.fileTreeIsVisible);
}
function updateVisibility(visible) {
store.fileTreeIsVisible = visible;
localStorage.setItem(LOCAL_STORAGE_KEY, store.fileTreeIsVisible);
updateState(store.fileTreeIsVisible);
}
function updateState(visible) {
const btn = document.querySelector('.diff-toggle-file-tree-button');
const [toShow, toHide] = btn.querySelectorAll('.icon');
const tree = document.querySelector('#diff-file-tree');
const newTooltip = btn.getAttribute(visible ? 'data-hide-text' : 'data-show-text');
btn.setAttribute('data-tooltip-content', newTooltip);
toggleElem(tree, visible);
toggleElem(toShow, !visible);
toggleElem(toHide, visible);
}
function loadMoreData() {
loadMoreFiles(store.linkLoadMore);
}
</script> </script>
<template> <template>
<div v-if="store.fileTreeIsVisible" class="diff-file-tree-items"> <div v-if="store.fileTreeIsVisible" class="diff-file-tree-items">
<!-- only render the tree if we're visible. in many cases this is something that doesn't change very often --> <!-- only render the tree if we're visible. in many cases this is something that doesn't change very often -->
@ -134,6 +141,7 @@ export default {
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.diff-file-tree-items { .diff-file-tree-items {
display: flex; display: flex;

View File

@ -1,33 +1,41 @@
<script lang="ts"> <script lang="ts" setup>
import {SvgIcon} from '../svg.ts'; import {SvgIcon} from '../svg.ts';
import {diffTreeStore} from '../modules/stores.ts'; import {diffTreeStore} from '../modules/stores.ts';
import {ref} from 'vue';
export default { type File = {
components: {SvgIcon}, Name: string;
props: { NameHash: string;
item: { Type: number;
type: Object, IsViewed: boolean;
required: true, }
},
}, type Item = {
data: () => ({ name: string;
store: diffTreeStore(), isFile: boolean;
collapsed: false, file?: File;
}), children?: Item[];
methods: {
getIconForDiffType(pType) {
const diffTypes = {
1: {name: 'octicon-diff-added', classes: ['text', 'green']},
2: {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
3: {name: 'octicon-diff-removed', classes: ['text', 'red']},
4: {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
5: {name: 'octicon-diff-renamed', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
};
return diffTypes[pType];
},
},
}; };
defineProps<{
item: Item,
}>();
const store = diffTreeStore();
const collapsed = ref(false);
function getIconForDiffType(pType) {
const diffTypes = {
1: {name: 'octicon-diff-added', classes: ['text', 'green']},
2: {name: 'octicon-diff-modified', classes: ['text', 'yellow']},
3: {name: 'octicon-diff-removed', classes: ['text', 'red']},
4: {name: 'octicon-diff-renamed', classes: ['text', 'teal']},
5: {name: 'octicon-diff-renamed', classes: ['text', 'green']}, // there is no octicon for copied, so renamed should be ok
};
return diffTypes[pType];
}
</script> </script>
<template> <template>
<!--title instead of tooltip above as the tooltip needs too much work with the current methods, i.e. not being loaded or staying open for "too long"--> <!--title instead of tooltip above as the tooltip needs too much work with the current methods, i.e. not being loaded or staying open for "too long"-->
<a <a

View File

@ -1,84 +1,83 @@
<script lang="ts"> <script lang="ts" setup>
import {computed, onMounted, onUnmounted, ref, watch} from 'vue';
import {SvgIcon} from '../svg.ts'; import {SvgIcon} from '../svg.ts';
import {toggleElem} from '../utils/dom.ts'; import {toggleElem} from '../utils/dom.ts';
const {csrfToken, pageData} = window.config; const {csrfToken, pageData} = window.config;
export default { const mergeForm = ref(pageData.pullRequestMergeForm);
components: {SvgIcon},
data: () => ({
csrfToken,
mergeForm: pageData.pullRequestMergeForm,
mergeTitleFieldValue: '', const mergeTitleFieldValue = ref('');
mergeMessageFieldValue: '', const mergeMessageFieldValue = ref('');
deleteBranchAfterMerge: false, const deleteBranchAfterMerge = ref(false);
autoMergeWhenSucceed: false, const autoMergeWhenSucceed = ref(false);
mergeStyle: '', const mergeStyle = ref('');
mergeStyleDetail: { // dummy only, these values will come from one of the mergeForm.mergeStyles const mergeStyleDetail = ref({
hideMergeMessageTexts: false, hideMergeMessageTexts: false,
textDoMerge: '', textDoMerge: '',
mergeTitleFieldText: '', mergeTitleFieldText: '',
mergeMessageFieldText: '', mergeMessageFieldText: '',
hideAutoMerge: false, hideAutoMerge: false,
}, });
mergeStyleAllowedCount: 0,
showMergeStyleMenu: false, const mergeStyleAllowedCount = ref(0);
showActionForm: false,
}),
computed: {
mergeButtonStyleClass() {
if (this.mergeForm.allOverridableChecksOk) return 'primary';
return this.autoMergeWhenSucceed ? 'primary' : 'red';
},
forceMerge() {
return this.mergeForm.canMergeNow && !this.mergeForm.allOverridableChecksOk;
},
},
watch: {
mergeStyle(val) {
this.mergeStyleDetail = this.mergeForm.mergeStyles.find((e) => e.name === val);
for (const elem of document.querySelectorAll('[data-pull-merge-style]')) {
toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val);
}
},
},
created() {
this.mergeStyleAllowedCount = this.mergeForm.mergeStyles.reduce((v, msd) => v + (msd.allowed ? 1 : 0), 0);
let mergeStyle = this.mergeForm.mergeStyles.find((e) => e.allowed && e.name === this.mergeForm.defaultMergeStyle)?.name; const showMergeStyleMenu = ref(false);
if (!mergeStyle) mergeStyle = this.mergeForm.mergeStyles.find((e) => e.allowed)?.name; const showActionForm = ref(false);
this.switchMergeStyle(mergeStyle, !this.mergeForm.canMergeNow);
}, const mergeButtonStyleClass = computed(() => {
mounted() { if (mergeForm.value.allOverridableChecksOk) return 'primary';
document.addEventListener('mouseup', this.hideMergeStyleMenu); return autoMergeWhenSucceed.value ? 'primary' : 'red';
}, });
unmounted() {
document.removeEventListener('mouseup', this.hideMergeStyleMenu); const forceMerge = computed(() => {
}, return mergeForm.value.canMergeNow && !mergeForm.value.allOverridableChecksOk;
methods: { });
hideMergeStyleMenu() {
this.showMergeStyleMenu = false; watch(mergeStyle, (val) => {
}, mergeStyleDetail.value = mergeForm.value.mergeStyles.find((e) => e.name === val);
toggleActionForm(show) { for (const elem of document.querySelectorAll('[data-pull-merge-style]')) {
this.showActionForm = show; toggleElem(elem, elem.getAttribute('data-pull-merge-style') === val);
if (!show) return; }
this.deleteBranchAfterMerge = this.mergeForm.defaultDeleteBranchAfterMerge; });
this.mergeTitleFieldValue = this.mergeStyleDetail.mergeTitleFieldText;
this.mergeMessageFieldValue = this.mergeStyleDetail.mergeMessageFieldText; onMounted(() => {
}, mergeStyleAllowedCount.value = mergeForm.value.mergeStyles.reduce((v, msd) => v + (msd.allowed ? 1 : 0), 0);
switchMergeStyle(name, autoMerge = false) {
this.mergeStyle = name; let mergeStyle = mergeForm.value.mergeStyles.find((e) => e.allowed && e.name === mergeForm.value.defaultMergeStyle)?.name;
this.autoMergeWhenSucceed = autoMerge; if (!mergeStyle) mergeStyle = mergeForm.value.mergeStyles.find((e) => e.allowed)?.name;
}, switchMergeStyle(mergeStyle, !mergeForm.value.canMergeNow);
clearMergeMessage() {
this.mergeMessageFieldValue = this.mergeForm.defaultMergeMessage; document.addEventListener('mouseup', hideMergeStyleMenu);
}, });
},
}; onUnmounted(() => {
document.removeEventListener('mouseup', hideMergeStyleMenu);
});
function hideMergeStyleMenu() {
showMergeStyleMenu.value = false;
}
function toggleActionForm(show: boolean) {
showActionForm.value = show;
if (!show) return;
deleteBranchAfterMerge.value = mergeForm.value.defaultDeleteBranchAfterMerge;
mergeTitleFieldValue.value = mergeStyleDetail.value.mergeTitleFieldText;
mergeMessageFieldValue.value = mergeStyleDetail.value.mergeMessageFieldText;
}
function switchMergeStyle(name, autoMerge = false) {
mergeStyle.value = name;
autoMergeWhenSucceed.value = autoMerge;
}
function clearMergeMessage() {
mergeMessageFieldValue.value = mergeForm.value.defaultMergeMessage;
}
</script> </script>
<template> <template>
<!-- <!--
if this component is shown, either the user is an admin (can do a merge without checks), or they are a writer who has the permission to do a merge if this component is shown, either the user is an admin (can do a merge without checks), or they are a writer who has the permission to do a merge
@ -186,6 +185,7 @@ export default {
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
/* to keep UI the same, at the moment we are still using some Fomantic UI styles, but we do not use their scripts, so we need to fine tune some styles */ /* to keep UI the same, at the moment we are still using some Fomantic UI styles, but we do not use their scripts, so we need to fine tune some styles */
.ui.dropdown .menu.show { .ui.dropdown .menu.show {

View File

@ -1,68 +1,62 @@
<script lang="ts"> <script lang="ts" setup>
import {VueBarGraph} from 'vue-bar-graph'; import {VueBarGraph} from 'vue-bar-graph';
import {createApp} from 'vue'; import {computed, onMounted, ref} from 'vue';
const sfc = { const colors = ref({
components: {VueBarGraph}, barColor: 'green',
data: () => ({ textColor: 'black',
colors: { textAltColor: 'white',
barColor: 'green', });
textColor: 'black',
textAltColor: 'white',
},
// possible keys: // possible keys:
// * avatar_link: (...) // * avatar_link: (...)
// * commits: (...) // * commits: (...)
// * home_link: (...) // * home_link: (...)
// * login: (...) // * login: (...)
// * name: (...) // * name: (...)
activityTopAuthors: window.config.pageData.repoActivityTopAuthors || [], const activityTopAuthors = window.config.pageData.repoActivityTopAuthors || [];
}),
computed: {
graphPoints() {
return this.activityTopAuthors.map((item) => {
return {
value: item.commits,
label: item.name,
};
});
},
graphAuthors() {
return this.activityTopAuthors.map((item, idx) => {
return {
position: idx + 1,
...item,
};
});
},
graphWidth() {
return this.activityTopAuthors.length * 40;
},
},
mounted() {
const refStyle = window.getComputedStyle(this.$refs.style);
const refAltStyle = window.getComputedStyle(this.$refs.altStyle);
this.colors.barColor = refStyle.backgroundColor; const graphPoints = computed(() => {
this.colors.textColor = refStyle.color; return activityTopAuthors.value.map((item) => {
this.colors.textAltColor = refAltStyle.color; return {
}, value: item.commits,
}; label: item.name,
};
});
});
export function initRepoActivityTopAuthorsChart() { const graphAuthors = computed(() => {
const el = document.querySelector('#repo-activity-top-authors-chart'); return activityTopAuthors.value.map((item, idx) => {
if (el) { return {
createApp(sfc).mount(el); position: idx + 1,
} ...item,
} };
});
});
export default sfc; // activate the IDE's Vue plugin const graphWidth = computed(() => {
return activityTopAuthors.value.length * 40;
});
const styleElement = ref<HTMLElement | null>(null);
const altStyleElement = ref<HTMLElement | null>(null);
onMounted(() => {
const refStyle = window.getComputedStyle(styleElement.value);
const refAltStyle = window.getComputedStyle(altStyleElement.value);
colors.value = {
barColor: refStyle.backgroundColor,
textColor: refStyle.color,
textAltColor: refAltStyle.color,
};
});
</script> </script>
<template> <template>
<div> <div>
<div class="activity-bar-graph" ref="style" style="width: 0; height: 0;"/> <div class="activity-bar-graph" ref="styleElement" style="width: 0; height: 0;"/>
<div class="activity-bar-graph-alt" ref="altStyle" style="width: 0; height: 0;"/> <div class="activity-bar-graph-alt" ref="altStyleElement" style="width: 0; height: 0;"/>
<vue-bar-graph <vue-bar-graph
:points="graphPoints" :points="graphPoints"
:show-x-axis="true" :show-x-axis="true"

View File

@ -1,4 +1,4 @@
<script lang="ts"> <script lang="ts" setup>
import {SvgIcon} from '../svg.ts'; import {SvgIcon} from '../svg.ts';
import { import {
Chart, Chart,
@ -15,10 +15,12 @@ import {
startDaysBetween, startDaysBetween,
firstStartDateAfterDate, firstStartDateAfterDate,
fillEmptyStartDaysWithZeroes, fillEmptyStartDaysWithZeroes,
type DayData,
} from '../utils/time.ts'; } from '../utils/time.ts';
import {chartJsColors} from '../utils/color.ts'; import {chartJsColors} from '../utils/color.ts';
import {sleep} from '../utils.ts'; import {sleep} from '../utils.ts';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import {onMounted, ref} from 'vue';
const {pageData} = window.config; const {pageData} = window.config;
@ -34,114 +36,110 @@ Chart.register(
Filler, Filler,
); );
export default { defineProps<{
components: {ChartLine, SvgIcon}, locale: {
props: { loadingTitle: string;
locale: { loadingTitleFailed: string;
type: Object, loadingInfo: string;
required: true, };
}, }>();
},
data: () => ({ const isLoading = ref(false);
isLoading: false, const errorText = ref('');
errorText: '', const repoLink = ref(pageData.repoLink || []);
repoLink: pageData.repoLink || [], const data = ref<DayData[]>([]);
data: [],
}), onMounted(() => {
mounted() { fetchGraphData();
this.fetchGraphData(); });
},
methods: { async function fetchGraphData() {
async fetchGraphData() { isLoading.value = true;
this.isLoading = true; try {
try { let response: Response;
let response; do {
do { response = await GET(`${repoLink.value}/activity/code-frequency/data`);
response = await GET(`${this.repoLink}/activity/code-frequency/data`); if (response.status === 202) {
if (response.status === 202) { await sleep(1000); // wait for 1 second before retrying
await sleep(1000); // wait for 1 second before retrying
}
} while (response.status === 202);
if (response.ok) {
this.data = await response.json();
const weekValues = Object.values(this.data);
const start = weekValues[0].week;
const end = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(start, end);
this.data = fillEmptyStartDaysWithZeroes(startDays, this.data);
this.errorText = '';
} else {
this.errorText = response.statusText;
}
} catch (err) {
this.errorText = err.message;
} finally {
this.isLoading = false;
} }
}, } while (response.status === 202);
if (response.ok) {
data.value = await response.json();
const weekValues = Object.values(data.value);
const start = weekValues[0].week;
const end = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(start, end);
data.value = fillEmptyStartDaysWithZeroes(startDays, data.value);
errorText.value = '';
} else {
errorText.value = response.statusText;
}
} catch (err) {
errorText.value = err.message;
} finally {
isLoading.value = false;
}
}
toGraphData(data) { function toGraphData(data) {
return { return {
datasets: [ datasets: [
{ {
data: data.map((i) => ({x: i.week, y: i.additions})), data: data.map((i) => ({x: i.week, y: i.additions})),
pointRadius: 0, pointRadius: 0,
pointHitRadius: 0, pointHitRadius: 0,
fill: true, fill: true,
label: 'Additions', label: 'Additions',
backgroundColor: chartJsColors['additions'], backgroundColor: chartJsColors['additions'],
borderWidth: 0, borderWidth: 0,
tension: 0.3, tension: 0.3,
}, },
{ {
data: data.map((i) => ({x: i.week, y: -i.deletions})), data: data.map((i) => ({x: i.week, y: -i.deletions})),
pointRadius: 0, pointRadius: 0,
pointHitRadius: 0, pointHitRadius: 0,
fill: true, fill: true,
label: 'Deletions', label: 'Deletions',
backgroundColor: chartJsColors['deletions'], backgroundColor: chartJsColors['deletions'],
borderWidth: 0, borderWidth: 0,
tension: 0.3, tension: 0.3,
}, },
], ],
}; };
}, }
getOptions() { const options = {
return { responsive: true,
responsive: true, maintainAspectRatio: false,
maintainAspectRatio: false, animation: true,
animation: true, plugins: {
plugins: { legend: {
legend: { display: true,
display: true, },
}, },
}, scales: {
scales: { x: {
x: { type: 'time',
type: 'time', grid: {
grid: { display: false,
display: false, },
}, time: {
time: { minUnit: 'month',
minUnit: 'month', },
}, ticks: {
ticks: { maxRotation: 0,
maxRotation: 0, maxTicksLimit: 12,
maxTicksLimit: 12, },
}, },
}, y: {
y: { ticks: {
ticks: { maxTicksLimit: 6,
maxTicksLimit: 6, },
},
},
},
};
}, },
}, },
}; };
</script> </script>
<template> <template>
<div> <div>
<div class="ui header tw-flex tw-items-center tw-justify-between"> <div class="ui header tw-flex tw-items-center tw-justify-between">
@ -160,11 +158,12 @@ export default {
</div> </div>
<ChartLine <ChartLine
v-memo="data" v-if="data.length !== 0" v-memo="data" v-if="data.length !== 0"
:data="toGraphData(data)" :options="getOptions()" :data="toGraphData(data)" :options="options"
/> />
</div> </div>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.main-graph { .main-graph {
height: 440px; height: 440px;

View File

@ -1,4 +1,4 @@
<script lang="ts"> <script lang="ts" setup>
import {SvgIcon} from '../svg.ts'; import {SvgIcon} from '../svg.ts';
import { import {
Chart, Chart,
@ -6,6 +6,7 @@ import {
BarElement, BarElement,
LinearScale, LinearScale,
TimeScale, TimeScale,
type ChartOptions,
} from 'chart.js'; } from 'chart.js';
import {GET} from '../modules/fetch.ts'; import {GET} from '../modules/fetch.ts';
import {Bar} from 'vue-chartjs'; import {Bar} from 'vue-chartjs';
@ -13,10 +14,12 @@ import {
startDaysBetween, startDaysBetween,
firstStartDateAfterDate, firstStartDateAfterDate,
fillEmptyStartDaysWithZeroes, fillEmptyStartDaysWithZeroes,
type DayData,
} from '../utils/time.ts'; } from '../utils/time.ts';
import {chartJsColors} from '../utils/color.ts'; import {chartJsColors} from '../utils/color.ts';
import {sleep} from '../utils.ts'; import {sleep} from '../utils.ts';
import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm'; import 'chartjs-adapter-dayjs-4/dist/chartjs-adapter-dayjs-4.esm';
import {onMounted, ref} from 'vue';
const {pageData} = window.config; const {pageData} = window.config;
@ -30,95 +33,91 @@ Chart.register(
Tooltip, Tooltip,
); );
export default { defineProps<{
components: {Bar, SvgIcon}, locale: {
props: { loadingTitle: string;
locale: { loadingTitleFailed: string;
type: Object, loadingInfo: string;
required: true, };
}, }>();
},
data: () => ({ const isLoading = ref(false);
isLoading: false, const errorText = ref('');
errorText: '', const repoLink = ref(pageData.repoLink || []);
repoLink: pageData.repoLink || [], const data = ref<DayData[]>([]);
data: [],
}), onMounted(() => {
mounted() { fetchGraphData();
this.fetchGraphData(); });
},
methods: { async function fetchGraphData() {
async fetchGraphData() { isLoading.value = true;
this.isLoading = true; try {
try { let response: Response;
let response; do {
do { response = await GET(`${repoLink.value}/activity/recent-commits/data`);
response = await GET(`${this.repoLink}/activity/recent-commits/data`); if (response.status === 202) {
if (response.status === 202) { await sleep(1000); // wait for 1 second before retrying
await sleep(1000); // wait for 1 second before retrying
}
} while (response.status === 202);
if (response.ok) {
const data = await response.json();
const start = Object.values(data)[0].week;
const end = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(start, end);
this.data = fillEmptyStartDaysWithZeroes(startDays, data).slice(-52);
this.errorText = '';
} else {
this.errorText = response.statusText;
}
} catch (err) {
this.errorText = err.message;
} finally {
this.isLoading = false;
} }
}, } while (response.status === 202);
if (response.ok) {
const data = await response.json();
const start = Object.values(data)[0].week;
const end = firstStartDateAfterDate(new Date());
const startDays = startDaysBetween(start, end);
data.value = fillEmptyStartDaysWithZeroes(startDays, data).slice(-52);
errorText.value = '';
} else {
errorText.value = response.statusText;
}
} catch (err) {
errorText.value = err.message;
} finally {
isLoading.value = false;
}
}
toGraphData(data) { function toGraphData(data) {
return { return {
datasets: [ datasets: [
{ {
data: data.map((i) => ({x: i.week, y: i.commits})), data: data.map((i) => ({x: i.week, y: i.commits})),
label: 'Commits', label: 'Commits',
backgroundColor: chartJsColors['commits'], backgroundColor: chartJsColors['commits'],
borderWidth: 0, borderWidth: 0,
tension: 0.3, tension: 0.3,
}, },
], ],
}; };
}, }
getOptions() { const options = {
return { responsive: true,
responsive: true, maintainAspectRatio: false,
maintainAspectRatio: false, animation: true,
animation: true, scales: {
scales: { x: {
x: { type: 'time',
type: 'time', grid: {
grid: { display: false,
display: false, },
}, time: {
time: { minUnit: 'week',
minUnit: 'week', },
}, ticks: {
ticks: { maxRotation: 0,
maxRotation: 0, maxTicksLimit: 52,
maxTicksLimit: 52, },
}, },
}, y: {
y: { ticks: {
ticks: { maxTicksLimit: 6,
maxTicksLimit: 6, },
},
},
},
};
}, },
}, },
}; } satisfies ChartOptions;
</script> </script>
<template> <template>
<div> <div>
<div class="ui header tw-flex tw-items-center tw-justify-between"> <div class="ui header tw-flex tw-items-center tw-justify-between">
@ -137,7 +136,7 @@ export default {
</div> </div>
<Bar <Bar
v-memo="data" v-if="data.length !== 0" v-memo="data" v-if="data.length !== 0"
:data="toGraphData(data)" :options="getOptions()" :data="toGraphData(data)" :options="options"
/> />
</div> </div>
</div> </div>

View File

@ -1,78 +1,60 @@
<script lang="ts"> <script lang="ts" setup>
import {computed, onMounted, onUnmounted} from 'vue';
import {hideElem, showElem} from '../utils/dom.ts'; import {hideElem, showElem} from '../utils/dom.ts';
const sfc = { const props = defineProps<{
props: { isAdmin: boolean;
isAdmin: { noAccessLabel: string;
type: Boolean, readLabel: string;
required: true, writeLabel: string;
}, }>();
noAccessLabel: {
type: String,
required: true,
},
readLabel: {
type: String,
required: true,
},
writeLabel: {
type: String,
required: true,
},
},
computed: { const categories = computed(() => {
categories() { const categories = [
const categories = [ 'activitypub',
'activitypub', ];
]; if (props.isAdmin) {
if (this.isAdmin) { categories.push('admin');
categories.push('admin'); }
} categories.push(
categories.push( 'issue',
'issue', 'misc',
'misc', 'notification',
'notification', 'organization',
'organization', 'package',
'package', 'repository',
'repository', 'user');
'user'); return categories;
return categories; });
},
},
mounted() { onMounted(() => {
document.querySelector('#scoped-access-submit').addEventListener('click', this.onClickSubmit); document.querySelector('#scoped-access-submit').addEventListener('click', onClickSubmit);
}, });
unmounted() { onUnmounted(() => {
document.querySelector('#scoped-access-submit').removeEventListener('click', this.onClickSubmit); document.querySelector('#scoped-access-submit').removeEventListener('click', onClickSubmit);
}, });
methods: { function onClickSubmit(e) {
onClickSubmit(e) { e.preventDefault();
e.preventDefault();
const warningEl = document.querySelector('#scoped-access-warning'); const warningEl = document.querySelector('#scoped-access-warning');
// check that at least one scope has been selected // check that at least one scope has been selected
for (const el of document.querySelectorAll('.access-token-select')) { for (const el of document.querySelectorAll<HTMLInputElement>('.access-token-select')) {
if (el.value) { if (el.value) {
// Hide the error if it was visible from previous attempt. // Hide the error if it was visible from previous attempt.
hideElem(warningEl); hideElem(warningEl);
// Submit the form. // Submit the form.
document.querySelector('#scoped-access-form').submit(); document.querySelector<HTMLFormElement>('#scoped-access-form').submit();
// Don't show the warning. // Don't show the warning.
return; return;
} }
} }
// no scopes selected, show validation error // no scopes selected, show validation error
showElem(warningEl); showElem(warningEl);
}, }
},
};
export default sfc;
</script> </script>
<template> <template>
<div v-for="category in categories" :key="category" class="field tw-pl-1 tw-pb-1 access-token-category"> <div v-for="category in categories" :key="category" class="field tw-pl-1 tw-pb-1 access-token-category">
<label class="category-label" :for="'access-token-scope-' + category"> <label class="category-label" :for="'access-token-scope-' + category">

View File

@ -3,6 +3,8 @@ import {hideElem, queryElems, showElem} from '../utils/dom.ts';
import {POST} from '../modules/fetch.ts'; import {POST} from '../modules/fetch.ts';
import {showErrorToast} from '../modules/toast.ts'; import {showErrorToast} from '../modules/toast.ts';
import {sleep} from '../utils.ts'; import {sleep} from '../utils.ts';
import RepoActivityTopAuthors from '../components/RepoActivityTopAuthors.vue';
import {createApp} from 'vue';
async function onDownloadArchive(e) { async function onDownloadArchive(e) {
e.preventDefault(); e.preventDefault();
@ -32,6 +34,13 @@ export function initRepoArchiveLinks() {
queryElems('a.archive-link[href]', (el) => el.addEventListener('click', onDownloadArchive)); queryElems('a.archive-link[href]', (el) => el.addEventListener('click', onDownloadArchive));
} }
export function initRepoActivityTopAuthorsChart() {
const el = document.querySelector('#repo-activity-top-authors-chart');
if (el) {
createApp(RepoActivityTopAuthors).mount(el);
}
}
export function initRepoCloneLink() { export function initRepoCloneLink() {
const $repoCloneSsh = $('#repo-clone-ssh'); const $repoCloneSsh = $('#repo-clone-ssh');
const $repoCloneHttps = $('#repo-clone-https'); const $repoCloneHttps = $('#repo-clone-https');

View File

@ -2,7 +2,6 @@
import './bootstrap.ts'; import './bootstrap.ts';
import './htmx.ts'; import './htmx.ts';
import {initRepoActivityTopAuthorsChart} from './components/RepoActivityTopAuthors.vue';
import {initDashboardRepoList} from './components/DashboardRepoList.vue'; import {initDashboardRepoList} from './components/DashboardRepoList.vue';
import {initGlobalCopyToClipboardListener} from './features/clipboard.ts'; import {initGlobalCopyToClipboardListener} from './features/clipboard.ts';
@ -42,7 +41,7 @@ import {initRepoTemplateSearch} from './features/repo-template.ts';
import {initRepoCodeView} from './features/repo-code.ts'; import {initRepoCodeView} from './features/repo-code.ts';
import {initSshKeyFormParser} from './features/sshkey-helper.ts'; import {initSshKeyFormParser} from './features/sshkey-helper.ts';
import {initUserSettings} from './features/user-settings.ts'; import {initUserSettings} from './features/user-settings.ts';
import {initRepoArchiveLinks} from './features/repo-common.ts'; import {initRepoActivityTopAuthorsChart, initRepoArchiveLinks} from './features/repo-common.ts';
import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts'; import {initRepoMigrationStatusChecker} from './features/repo-migrate.ts';
import { import {
initRepoSettingGitHook, initRepoSettingGitHook,

View File

@ -36,3 +36,13 @@ export type IssueData = {
type: string, type: string,
index: string, index: string,
} }
export type Issue = {
id: number;
title: string;
state: 'open' | 'closed';
pull_request?: {
draft: boolean;
merged: boolean;
};
};

View File

@ -42,14 +42,14 @@ export function firstStartDateAfterDate(inputDate: Date): number {
return resultDate.valueOf(); return resultDate.valueOf();
} }
type DayData = { export type DayData = {
week: number, week: number,
additions: number, additions: number,
deletions: number, deletions: number,
commits: number, commits: number,
} }
export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayData): DayData[] { export function fillEmptyStartDaysWithZeroes(startDays: number[], data: DayData[]): DayData[] {
const result = {}; const result = {};
for (const startDay of startDays) { for (const startDay of startDays) {