Skip to content

Commit 0dc36bc

Browse files
create vue components to construct reorderable group hierarchy in dashboard repo list
1 parent 177539a commit 0dc36bc

File tree

2 files changed

+354
-0
lines changed

2 files changed

+354
-0
lines changed
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
<script setup lang="ts">
2+
import type {SortableOptions} from 'sortablejs';
3+
import DashboardRepoGroupItem from './DashboardRepoGroupItem.vue';
4+
import {Sortable} from 'sortablejs-vue3';
5+
import hash from 'object-hash';
6+
import {computed, inject, nextTick, type ComputedRef, type WritableComputedRef} from 'vue';
7+
import {GET, POST} from '../modules/fetch.ts';
8+
import type {GroupMapType} from './DashboardRepoList.vue';
9+
const {curGroup, depth} = defineProps<{ curGroup: number; depth: number; }>();
10+
const emitter = defineEmits<{
11+
loadChanged: [ boolean ],
12+
itemAdded: [ item: any, index: number ],
13+
itemRemoved: [ item: any, index: number ]
14+
}>();
15+
const groupData = inject<WritableComputedRef<Map<number, GroupMapType>>>('groups');
16+
const searchUrl = inject<string>('searchURL');
17+
const orgName = inject<string>('orgName');
18+
19+
const combined = computed(() => {
20+
let groups = groupData.value.get(curGroup)?.subgroups ?? [];
21+
groups = Array.from(new Set(groups));
22+
23+
const repos = (groupData.value.get(curGroup)?.repos ?? []).filter((a, pos, arr) => arr.findIndex((b) => b.id === a.id) === pos);
24+
const c = [
25+
...groups, // ,
26+
...repos,
27+
];
28+
return c;
29+
});
30+
function repoMapper(webSearchRepo: any) {
31+
return {
32+
...webSearchRepo.repository,
33+
latest_commit_status_state: webSearchRepo.latest_commit_status?.State, // if latest_commit_status is null, it means there is no commit status
34+
latest_commit_status_state_link: webSearchRepo.latest_commit_status?.TargetURL,
35+
locale_latest_commit_status_state: webSearchRepo.locale_latest_commit_status,
36+
};
37+
}
38+
function mapper(item: any) {
39+
groupData.value.set(item.group.id, {
40+
repos: item.repos.map((a: any) => repoMapper(a)),
41+
subgroups: item.subgroups.map((a: {group: any}) => a.group.id),
42+
...item.group,
43+
latest_commit_status_state: item.latest_commit_status?.State, // if latest_commit_status is null, it means there is no commit status
44+
latest_commit_status_state_link: item.latest_commit_status?.TargetURL,
45+
locale_latest_commit_status_state: item.locale_latest_commit_status,
46+
});
47+
// return {
48+
// ...item.group,
49+
// subgroups: item.subgroups.map((a) => mapper(a)),
50+
// repos: item.repos.map((a) => repoMapper(a)),
51+
// };
52+
}
53+
async function searchGroup(gid: number) {
54+
emitter('loadChanged', true);
55+
const searchedURL = `${searchUrl}&group_id=${gid}`;
56+
let response, json;
57+
try {
58+
response = await GET(searchedURL);
59+
json = await response.json();
60+
} catch {
61+
emitter('loadChanged', false);
62+
return;
63+
}
64+
mapper(json.data);
65+
for (const g of json.data.subgroups) {
66+
mapper(g);
67+
}
68+
emitter('loadChanged', false);
69+
const tmp = groupData.value;
70+
groupData.value = tmp;
71+
}
72+
const orepos = inject<ComputedRef<any[]>>('repos');
73+
74+
const dynKey = computed(() => hash(combined.value));
75+
function getId(it: any) {
76+
if (typeof it === 'number') {
77+
return `group-${it}`;
78+
}
79+
return `repo-${it.id}`;
80+
}
81+
82+
const options: SortableOptions = {
83+
group: {
84+
name: 'repo-group',
85+
put(to, _from, _drag, _ev) {
86+
const closestLi = to.el?.closest('li');
87+
const base = to.el.getAttribute('data-is-group').toLowerCase() === 'true';
88+
if (closestLi) {
89+
const input = Array.from(closestLi?.querySelector('label')?.children).find((a) => a instanceof HTMLInputElement && a.checked);
90+
return base && Boolean(input);
91+
}
92+
return base;
93+
},
94+
pull: true,
95+
},
96+
delay: 500,
97+
emptyInsertThreshold: 50,
98+
delayOnTouchOnly: true,
99+
dataIdAttr: 'data-sort-id',
100+
draggable: '.expandable-menu-item',
101+
dragClass: 'active',
102+
store: {
103+
get() {
104+
return combined.value.map((a) => getId(a)).filter((a, i, arr) => arr.indexOf(a) === i);
105+
},
106+
// eslint-disable-next-line @typescript-eslint/no-misused-promises
107+
async set(sortable) {
108+
const arr = sortable.toArray();
109+
const groups = Array.from(new Set(arr.filter((a) => a.startsWith('group')).map((a) => parseInt(a.split('-')[1]))));
110+
const repos = arr
111+
.filter((a) => a.startsWith('repo'))
112+
.map((a) => orepos.value.filter(Boolean).find((b) => b.id === parseInt(a.split('-')[1])))
113+
.map((a, i) => ({...a, group_sort_order: i + 1}))
114+
.filter((a, pos, arr) => arr.findIndex((b) => b.id === a.id) === pos);
115+
116+
for (let i = 0; i < groups.length; i++) {
117+
const cur = groupData.value.get(groups[i]);
118+
groupData.value.set(groups[i], {
119+
...cur,
120+
sort_order: i + 1,
121+
});
122+
}
123+
const cur = groupData.value.get(curGroup);
124+
const ndata: GroupMapType = {
125+
...cur,
126+
subgroups: groups.toSorted((a, b) => groupData.value.get(a).sort_order - groupData.value.get(b).sort_order),
127+
repos: repos.toSorted((a, b) => a.group_sort_order - b.group_sort_order),
128+
};
129+
groupData.value.set(curGroup, ndata);
130+
// const tmp = groupData.value;
131+
// groupData.value = tmp;
132+
for (let i = 0; i < ndata.subgroups.length; i++) {
133+
const sg = ndata.subgroups[i];
134+
const data = {
135+
newParent: curGroup,
136+
id: sg,
137+
newPos: i + 1,
138+
isGroup: true,
139+
};
140+
try {
141+
await POST(`/${orgName}/groups/items/move`, {
142+
data,
143+
});
144+
} catch (error) {
145+
console.error(error);
146+
}
147+
}
148+
for (const r of ndata.repos) {
149+
const data = {
150+
newParent: curGroup,
151+
id: r.id,
152+
newPos: r.group_sort_order,
153+
isGroup: false,
154+
};
155+
try {
156+
await POST(`/${orgName}/groups/items/move`, {
157+
data,
158+
});
159+
} catch (error) {
160+
console.error(error);
161+
}
162+
}
163+
nextTick(() => {
164+
const finalSorted = [
165+
...ndata.subgroups,
166+
...ndata.repos,
167+
].map(getId);
168+
try {
169+
sortable.sort(finalSorted, true);
170+
} catch {}
171+
});
172+
},
173+
},
174+
};
175+
176+
</script>
177+
<template>
178+
<Sortable
179+
:options="options" tag="ul"
180+
:class="{ 'expandable-menu': curGroup === 0, 'repo-owner-name-list': curGroup === 0, 'expandable-ul': true }"
181+
v-model:list="combined"
182+
:data-is-group="true"
183+
:item-key="(it) => getId(it)"
184+
:key="dynKey"
185+
>
186+
<template #item="{ element, index }">
187+
<dashboard-repo-group-item
188+
:index="index + 1"
189+
:item="element"
190+
:depth="depth + 1"
191+
:key="getId(element)"
192+
@load-requested="searchGroup"
193+
/>
194+
</template>
195+
</Sortable>
196+
</template>
197+
<style scoped>
198+
ul.expandable-ul {
199+
list-style: none;
200+
margin: 0;
201+
padding-left: 0;
202+
}
203+
204+
ul.expandable-ul li {
205+
padding: 0 10px;
206+
}
207+
.repos-search {
208+
padding-bottom: 0 !important;
209+
}
210+
211+
.repos-filter {
212+
margin-top: 0 !important;
213+
border-bottom-width: 0 !important;
214+
}
215+
216+
.repos-filter .item {
217+
padding-left: 6px !important;
218+
padding-right: 6px !important;
219+
}
220+
221+
.repo-owner-name-list li.active {
222+
background: var(--color-hover);
223+
}
224+
ul.expandable-ul > li:not(:last-child) {
225+
border-bottom: 1px solid var(--color-secondary);
226+
}
227+
ul.expandable-ul > li:first-child {
228+
border-top: 1px solid var(--color-secondary);
229+
}
230+
</style>
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<script lang="ts" setup>
2+
import {computed, inject, type WritableComputedRef} from 'vue';
3+
import {commitStatus, type CommitStatus, type GroupMapType} from './DashboardRepoList.vue';
4+
import DashboardRepoGroup from './DashboardRepoGroup.vue';
5+
import {SvgIcon, type SvgName} from '../svg.ts';
6+
const {depth} = defineProps<{index: number; depth: number;}>();
7+
const groupData = inject<WritableComputedRef<Map<number, GroupMapType>>>('groups');
8+
const loadedMap = inject<WritableComputedRef<Map<number, boolean>>>('loadedMap');
9+
const expandedGroups = inject<WritableComputedRef<number[]>>('expandedGroups');
10+
const itemProp = defineModel<any>('item');
11+
const isGroup = computed<boolean>(() => typeof itemProp.value === 'number');
12+
const item = computed(() => isGroup.value ? groupData.value.get(itemProp.value as number) : itemProp.value);
13+
const id = computed(() => typeof itemProp.value === 'number' ? itemProp.value : itemProp.value.id);
14+
const idKey = computed<string>(() => {
15+
const prefix = isGroup.value ? 'group' : 'repo';
16+
return `${prefix}-${id.value}`;
17+
});
18+
19+
const indentCss = computed<string>(() => `padding-inline-start: ${depth * 0.5}rem`);
20+
21+
function icon(item: any) {
22+
if (item.repos) {
23+
return 'octicon-list-unordered';
24+
}
25+
if (item.fork) {
26+
return 'octicon-repo-forked';
27+
} else if (item.mirror) {
28+
return 'octicon-mirror';
29+
} else if (item.template) {
30+
return `octicon-repo-template`;
31+
} else if (item.private) {
32+
return 'octicon-lock';
33+
} else if (item.internal) {
34+
return 'octicon-repo';
35+
}
36+
return 'octicon-repo';
37+
}
38+
39+
function statusIcon(status: CommitStatus): SvgName {
40+
return commitStatus[status].name as SvgName;
41+
}
42+
43+
function statusColor(status: CommitStatus) {
44+
return commitStatus[status].color;
45+
}
46+
const emitter = defineEmits<{
47+
loadRequested: [ number ]
48+
}>();
49+
function onCheck(nv: boolean) {
50+
if (isGroup.value && expandedGroups) {
51+
if (nv) {
52+
expandedGroups.value = [...expandedGroups.value, item.value.id];
53+
if (!loadedMap.value.has(item.value.id)) {
54+
emitter('loadRequested', item.value.id as number);
55+
loadedMap.value.set(item.value.id, true);
56+
}
57+
} else {
58+
const idx = expandedGroups.value.indexOf(item.value.id as number);
59+
if (idx > -1) {
60+
expandedGroups.value = expandedGroups.value.toSpliced(idx, 1);
61+
}
62+
}
63+
}
64+
}
65+
const active = computed(() => isGroup.value && expandedGroups.value.includes(id.value));
66+
</script>
67+
<template>
68+
<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">
69+
<label
70+
class="tw-flex tw-items-center tw-py-2"
71+
:style="indentCss"
72+
:class="{
73+
'has-children': !!item.repos?.length || !!item.subgroups?.length || isGroup,
74+
}"
75+
>
76+
<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)">
77+
<svg-icon :name="icon(item)" :size="16" class-name="repo-list-icon"/>
78+
<svg-icon v-if="isGroup" name="octicon-chevron-right" :size="16" class="collapse-icon"/>
79+
<a :href="item.link" class="repo-list-link muted tw-flex-shrink">
80+
<div class="text truncate">{{ item.full_name || item.name }}</div>
81+
<div v-if="item.archived">
82+
<svg-icon name="octicon-archive" :size="16"/>
83+
</div>
84+
</a>
85+
<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">
86+
<!-- the commit status icon logic is taken from templates/repo/commit_status.tmpl -->
87+
<svg-icon :name="statusIcon(item.latest_commit_status_state)" :class-name="'commit-status icon text ' + statusColor(item.latest_commit_status_state)" :size="16"/>
88+
</a>
89+
</label>
90+
<div class="menu-expandable-content">
91+
<div class="menu-expandable-content-inner">
92+
<dashboard-repo-group :cur-group="id" v-if="isGroup" :depth="depth + 1"/>
93+
</div>
94+
</div>
95+
</li>
96+
</template>
97+
<style scoped>
98+
.repo-list-link {
99+
min-width: 0;
100+
/* for text truncation */
101+
display: flex;
102+
align-items: center;
103+
flex: 1;
104+
gap: 0.5rem;
105+
}
106+
107+
.repo-list-link .svg {
108+
color: var(--color-text-light-2);
109+
}
110+
111+
.repo-list-icon, .collapse-icon {
112+
min-width: 16px;
113+
margin-right: 2px;
114+
top: 0px;
115+
}
116+
117+
/* octicon-mirror has no padding inside the SVG */
118+
.repo-list-icon.octicon-mirror {
119+
width: 14px;
120+
min-width: 14px;
121+
margin-left: 1px;
122+
margin-right: 3px;
123+
}
124+
</style>

0 commit comments

Comments
 (0)