mirror of
https://github.com/go-gitea/gitea.git
synced 2026-04-04 10:05:18 +02:00
create vue components to construct reorderable group hierarchy in dashboard repo list
This commit is contained in:
parent
3244cfc34c
commit
8f653da71f
230
web_src/js/components/DashboardRepoGroup.vue
Normal file
230
web_src/js/components/DashboardRepoGroup.vue
Normal file
@ -0,0 +1,230 @@
|
||||
<script setup lang="ts">
|
||||
import type {SortableOptions} from 'sortablejs';
|
||||
import DashboardRepoGroupItem from './DashboardRepoGroupItem.vue';
|
||||
import {Sortable} from 'sortablejs-vue3';
|
||||
import hash from 'object-hash';
|
||||
import {computed, inject, nextTick, type ComputedRef, type WritableComputedRef} from 'vue';
|
||||
import {GET, POST} from '../modules/fetch.ts';
|
||||
import type {GroupMapType} from './DashboardRepoList.vue';
|
||||
const {curGroup, depth} = defineProps<{ curGroup: number; depth: number; }>();
|
||||
const emitter = defineEmits<{
|
||||
loadChanged: [ boolean ],
|
||||
itemAdded: [ item: any, index: number ],
|
||||
itemRemoved: [ item: any, index: number ]
|
||||
}>();
|
||||
const groupData = inject<WritableComputedRef<Map<number, GroupMapType>>>('groups');
|
||||
const searchUrl = inject<string>('searchURL');
|
||||
const orgName = inject<string>('orgName');
|
||||
|
||||
const combined = computed(() => {
|
||||
let groups = groupData.value.get(curGroup)?.subgroups ?? [];
|
||||
groups = Array.from(new Set(groups));
|
||||
|
||||
const repos = (groupData.value.get(curGroup)?.repos ?? []).filter((a, pos, arr) => arr.findIndex((b) => b.id === a.id) === pos);
|
||||
const c = [
|
||||
...groups, // ,
|
||||
...repos,
|
||||
];
|
||||
return c;
|
||||
});
|
||||
function repoMapper(webSearchRepo: any) {
|
||||
return {
|
||||
...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_link: webSearchRepo.latest_commit_status?.TargetURL,
|
||||
locale_latest_commit_status_state: webSearchRepo.locale_latest_commit_status,
|
||||
};
|
||||
}
|
||||
function mapper(item: any) {
|
||||
groupData.value.set(item.group.id, {
|
||||
repos: item.repos.map((a: any) => repoMapper(a)),
|
||||
subgroups: item.subgroups.map((a: {group: any}) => a.group.id),
|
||||
...item.group,
|
||||
latest_commit_status_state: item.latest_commit_status?.State, // if latest_commit_status is null, it means there is no commit status
|
||||
latest_commit_status_state_link: item.latest_commit_status?.TargetURL,
|
||||
locale_latest_commit_status_state: item.locale_latest_commit_status,
|
||||
});
|
||||
// return {
|
||||
// ...item.group,
|
||||
// subgroups: item.subgroups.map((a) => mapper(a)),
|
||||
// repos: item.repos.map((a) => repoMapper(a)),
|
||||
// };
|
||||
}
|
||||
async function searchGroup(gid: number) {
|
||||
emitter('loadChanged', true);
|
||||
const searchedURL = `${searchUrl}&group_id=${gid}`;
|
||||
let response, json;
|
||||
try {
|
||||
response = await GET(searchedURL);
|
||||
json = await response.json();
|
||||
} catch {
|
||||
emitter('loadChanged', false);
|
||||
return;
|
||||
}
|
||||
mapper(json.data);
|
||||
for (const g of json.data.subgroups) {
|
||||
mapper(g);
|
||||
}
|
||||
emitter('loadChanged', false);
|
||||
const tmp = groupData.value;
|
||||
groupData.value = tmp;
|
||||
}
|
||||
const orepos = inject<ComputedRef<any[]>>('repos');
|
||||
|
||||
const dynKey = computed(() => hash(combined.value));
|
||||
function getId(it: any) {
|
||||
if (typeof it === 'number') {
|
||||
return `group-${it}`;
|
||||
}
|
||||
return `repo-${it.id}`;
|
||||
}
|
||||
|
||||
const options: SortableOptions = {
|
||||
group: {
|
||||
name: 'repo-group',
|
||||
put(to, _from, _drag, _ev) {
|
||||
const closestLi = to.el?.closest('li');
|
||||
const base = to.el.getAttribute('data-is-group').toLowerCase() === 'true';
|
||||
if (closestLi) {
|
||||
const input = Array.from(closestLi?.querySelector('label')?.children).find((a) => a instanceof HTMLInputElement && a.checked);
|
||||
return base && Boolean(input);
|
||||
}
|
||||
return base;
|
||||
},
|
||||
pull: true,
|
||||
},
|
||||
delay: 500,
|
||||
emptyInsertThreshold: 50,
|
||||
delayOnTouchOnly: true,
|
||||
dataIdAttr: 'data-sort-id',
|
||||
draggable: '.expandable-menu-item',
|
||||
dragClass: 'active',
|
||||
store: {
|
||||
get() {
|
||||
return combined.value.map((a) => getId(a)).filter((a, i, arr) => arr.indexOf(a) === i);
|
||||
},
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
async set(sortable) {
|
||||
const arr = sortable.toArray();
|
||||
const groups = Array.from(new Set(arr.filter((a) => a.startsWith('group')).map((a) => parseInt(a.split('-')[1]))));
|
||||
const repos = arr
|
||||
.filter((a) => a.startsWith('repo'))
|
||||
.map((a) => orepos.value.filter(Boolean).find((b) => b.id === parseInt(a.split('-')[1])))
|
||||
.map((a, i) => ({...a, group_sort_order: i + 1}))
|
||||
.filter((a, pos, arr) => arr.findIndex((b) => b.id === a.id) === pos);
|
||||
|
||||
for (let i = 0; i < groups.length; i++) {
|
||||
const cur = groupData.value.get(groups[i]);
|
||||
groupData.value.set(groups[i], {
|
||||
...cur,
|
||||
sort_order: i + 1,
|
||||
});
|
||||
}
|
||||
const cur = groupData.value.get(curGroup);
|
||||
const ndata: GroupMapType = {
|
||||
...cur,
|
||||
subgroups: groups.toSorted((a, b) => groupData.value.get(a).sort_order - groupData.value.get(b).sort_order),
|
||||
repos: repos.toSorted((a, b) => a.group_sort_order - b.group_sort_order),
|
||||
};
|
||||
groupData.value.set(curGroup, ndata);
|
||||
// const tmp = groupData.value;
|
||||
// groupData.value = tmp;
|
||||
for (let i = 0; i < ndata.subgroups.length; i++) {
|
||||
const sg = ndata.subgroups[i];
|
||||
const data = {
|
||||
newParent: curGroup,
|
||||
id: sg,
|
||||
newPos: i + 1,
|
||||
isGroup: true,
|
||||
};
|
||||
try {
|
||||
await POST(`/${orgName}/groups/items/move`, {
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
for (const r of ndata.repos) {
|
||||
const data = {
|
||||
newParent: curGroup,
|
||||
id: r.id,
|
||||
newPos: r.group_sort_order,
|
||||
isGroup: false,
|
||||
};
|
||||
try {
|
||||
await POST(`/${orgName}/groups/items/move`, {
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
nextTick(() => {
|
||||
const finalSorted = [
|
||||
...ndata.subgroups,
|
||||
...ndata.repos,
|
||||
].map(getId);
|
||||
try {
|
||||
sortable.sort(finalSorted, true);
|
||||
} catch {}
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
</script>
|
||||
<template>
|
||||
<Sortable
|
||||
:options="options" tag="ul"
|
||||
:class="{ 'expandable-menu': curGroup === 0, 'repo-owner-name-list': curGroup === 0, 'expandable-ul': true }"
|
||||
v-model:list="combined"
|
||||
:data-is-group="true"
|
||||
:item-key="(it) => getId(it)"
|
||||
:key="dynKey"
|
||||
>
|
||||
<template #item="{ element, index }">
|
||||
<dashboard-repo-group-item
|
||||
:index="index + 1"
|
||||
:item="element"
|
||||
:depth="depth + 1"
|
||||
:key="getId(element)"
|
||||
@load-requested="searchGroup"
|
||||
/>
|
||||
</template>
|
||||
</Sortable>
|
||||
</template>
|
||||
<style scoped>
|
||||
ul.expandable-ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
ul.expandable-ul li {
|
||||
padding: 0 10px;
|
||||
}
|
||||
.repos-search {
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.repos-filter {
|
||||
margin-top: 0 !important;
|
||||
border-bottom-width: 0 !important;
|
||||
}
|
||||
|
||||
.repos-filter .item {
|
||||
padding-left: 6px !important;
|
||||
padding-right: 6px !important;
|
||||
}
|
||||
|
||||
.repo-owner-name-list li.active {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
ul.expandable-ul > li:not(:last-child) {
|
||||
border-bottom: 1px solid var(--color-secondary);
|
||||
}
|
||||
ul.expandable-ul > li:first-child {
|
||||
border-top: 1px solid var(--color-secondary);
|
||||
}
|
||||
</style>
|
||||
124
web_src/js/components/DashboardRepoGroupItem.vue
Normal file
124
web_src/js/components/DashboardRepoGroupItem.vue
Normal file
@ -0,0 +1,124 @@
|
||||
<script lang="ts" setup>
|
||||
import {computed, inject, type WritableComputedRef} from 'vue';
|
||||
import {commitStatus, type CommitStatus, type GroupMapType} from './DashboardRepoList.vue';
|
||||
import DashboardRepoGroup from './DashboardRepoGroup.vue';
|
||||
import {SvgIcon, type SvgName} from '../svg.ts';
|
||||
const {depth} = defineProps<{index: number; depth: number;}>();
|
||||
const groupData = inject<WritableComputedRef<Map<number, GroupMapType>>>('groups');
|
||||
const loadedMap = inject<WritableComputedRef<Map<number, boolean>>>('loadedMap');
|
||||
const expandedGroups = inject<WritableComputedRef<number[]>>('expandedGroups');
|
||||
const itemProp = defineModel<any>('item');
|
||||
const isGroup = computed<boolean>(() => typeof itemProp.value === 'number');
|
||||
const item = computed(() => isGroup.value ? groupData.value.get(itemProp.value as number) : itemProp.value);
|
||||
const id = computed(() => typeof itemProp.value === 'number' ? itemProp.value : itemProp.value.id);
|
||||
const idKey = computed<string>(() => {
|
||||
const prefix = isGroup.value ? 'group' : 'repo';
|
||||
return `${prefix}-${id.value}`;
|
||||
});
|
||||
|
||||
const indentCss = computed<string>(() => `padding-inline-start: ${depth * 0.5}rem`);
|
||||
|
||||
function icon(item: any) {
|
||||
if (item.repos) {
|
||||
return 'octicon-list-unordered';
|
||||
}
|
||||
if (item.fork) {
|
||||
return 'octicon-repo-forked';
|
||||
} else if (item.mirror) {
|
||||
return 'octicon-mirror';
|
||||
} else if (item.template) {
|
||||
return `octicon-repo-template`;
|
||||
} else if (item.private) {
|
||||
return 'octicon-lock';
|
||||
} else if (item.internal) {
|
||||
return 'octicon-repo';
|
||||
}
|
||||
return 'octicon-repo';
|
||||
}
|
||||
|
||||
function statusIcon(status: CommitStatus): SvgName {
|
||||
return commitStatus[status].name as SvgName;
|
||||
}
|
||||
|
||||
function statusColor(status: CommitStatus) {
|
||||
return commitStatus[status].color;
|
||||
}
|
||||
const emitter = defineEmits<{
|
||||
loadRequested: [ number ]
|
||||
}>();
|
||||
function onCheck(nv: boolean) {
|
||||
if (isGroup.value && expandedGroups) {
|
||||
if (nv) {
|
||||
expandedGroups.value = [...expandedGroups.value, item.value.id];
|
||||
if (!loadedMap.value.has(item.value.id)) {
|
||||
emitter('loadRequested', item.value.id as number);
|
||||
loadedMap.value.set(item.value.id, true);
|
||||
}
|
||||
} else {
|
||||
const idx = expandedGroups.value.indexOf(item.value.id as number);
|
||||
if (idx > -1) {
|
||||
expandedGroups.value = expandedGroups.value.toSpliced(idx, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const active = computed(() => isGroup.value && expandedGroups.value.includes(id.value));
|
||||
</script>
|
||||
<template>
|
||||
<li class="tw-flex tw-flex-col tw-px-0 tw-pr-0 expandable-menu-item tw-mt-0" :data-sort-id="idKey" :data-is-group="isGroup" :data-id="id">
|
||||
<label
|
||||
class="tw-flex tw-items-center tw-py-2"
|
||||
:style="indentCss"
|
||||
:class="{
|
||||
'has-children': !!item.repos?.length || !!item.subgroups?.length || isGroup,
|
||||
}"
|
||||
>
|
||||
<input v-if="isGroup" :checked="active" type="checkbox" class="toggle tw-h-0 tw-w-0 tw-overflow-hidden tw-opacity-0 tw-absolute" @change="(e) => onCheck((e.target as HTMLInputElement).checked)">
|
||||
<svg-icon :name="icon(item)" :size="16" class-name="repo-list-icon"/>
|
||||
<svg-icon v-if="isGroup" name="octicon-chevron-right" :size="16" class="collapse-icon"/>
|
||||
<a :href="item.link" class="repo-list-link muted tw-flex-shrink">
|
||||
<div class="text truncate">{{ item.full_name || item.name }}</div>
|
||||
<div v-if="item.archived">
|
||||
<svg-icon name="octicon-archive" :size="16"/>
|
||||
</div>
|
||||
</a>
|
||||
<a class="tw-flex tw-items-center" v-if="item.latest_commit_status_state" :href="item.latest_commit_status_state_link" :data-tooltip-content="item.locale_latest_commit_status_state">
|
||||
<!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl -->
|
||||
<svg-icon :name="statusIcon(item.latest_commit_status_state)" :class-name="'commit-status icon text ' + statusColor(item.latest_commit_status_state)" :size="16"/>
|
||||
</a>
|
||||
</label>
|
||||
<div class="menu-expandable-content">
|
||||
<div class="menu-expandable-content-inner">
|
||||
<dashboard-repo-group :cur-group="id" v-if="isGroup" :depth="depth + 1"/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<style scoped>
|
||||
.repo-list-link {
|
||||
min-width: 0;
|
||||
/* for text truncation */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.repo-list-link .svg {
|
||||
color: var(--color-text-light-2);
|
||||
}
|
||||
|
||||
.repo-list-icon, .collapse-icon {
|
||||
min-width: 16px;
|
||||
margin-right: 2px;
|
||||
top: 0px;
|
||||
}
|
||||
|
||||
/* octicon-mirror has no padding inside the SVG */
|
||||
.repo-list-icon.octicon-mirror {
|
||||
width: 14px;
|
||||
min-width: 14px;
|
||||
margin-left: 1px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
</style>
|
||||
Loading…
x
Reference in New Issue
Block a user