mirror of
https://github.com/HangarMC/Hangar.git
synced 2025-01-06 13:56:14 +08:00
implement admin version approval page
This commit is contained in:
parent
a72c0b65de
commit
03afe4c4d6
@ -11,7 +11,7 @@ Stuff that needs to be done before I consider this a successful POC
|
||||
- [x] seo
|
||||
- [x] route perms
|
||||
- [x] error pages (needs design but what doesnt?)
|
||||
- [ ] snackbar
|
||||
- [ ] snackbar/success notifications/whatever
|
||||
- [ ] maybe deployment alongside the existing frontend? (server is working now)
|
||||
- [ ] figure out why vite isn't serving the manifest
|
||||
- [x] cors?
|
||||
@ -20,6 +20,7 @@ Stuff that needs to be done before I consider this a successful POC
|
||||
- [x] investigate why eslint/prettier don't auto fix
|
||||
- [x] actually implement page transitions (as opposed to popping up below the page)
|
||||
- [ ] validation of forms/inputs etc
|
||||
- [ ] add header calls to all pages
|
||||
|
||||
## Big list of pages!
|
||||
|
||||
@ -259,10 +260,10 @@ once QA has passed, the checkboxes can be removed and the page can be ~~striked
|
||||
- [x] design
|
||||
- [ ] qa
|
||||
- versions
|
||||
- [ ] fetch
|
||||
- [ ] layout
|
||||
- [ ] functionality
|
||||
- [ ] design
|
||||
- [x] fetch
|
||||
- [x] layout
|
||||
- [ ] functionality (expansion missing)
|
||||
- [x] design
|
||||
- [ ] qa
|
||||
- user (empty)
|
||||
- [user]
|
||||
|
71
frontend-new/src/components/Tag.vue
Normal file
71
frontend-new/src/components/Tag.vue
Normal file
@ -0,0 +1,71 @@
|
||||
<script lang="ts" setup>
|
||||
import { Tag } from "hangar-api";
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
name: string;
|
||||
data: string;
|
||||
color: { foreground?: string; background: string };
|
||||
tag: Tag;
|
||||
shortForm?: boolean;
|
||||
}>();
|
||||
|
||||
const cName = computed(() => (props.tag ? props.tag.name : props.name));
|
||||
const cData = computed(() => (props.tag ? props.tag.data : props.data));
|
||||
const cColor = computed(() => (props.tag ? props.tag.color : props.color));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tags" :class="{ 'has-addons': cData && !shortForm }">
|
||||
<span
|
||||
:style="{
|
||||
color: cColor.foreground,
|
||||
background: cColor.background,
|
||||
'border-color': cColor.background,
|
||||
}"
|
||||
class="tag"
|
||||
>
|
||||
{{ shortForm && cData ? cData : cName }}
|
||||
</span>
|
||||
<span v-if="cData && !shortForm" class="tag">{{ cData }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
// todo reimplement using windi, but I g2g now
|
||||
.tags {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
|
||||
&.has-addons {
|
||||
.tag:first-child {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.tag:nth-child(2) {
|
||||
border-bottom-left-radius: 0;
|
||||
border-top-left-radius: 0;
|
||||
border-left: none;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tag {
|
||||
border: 1px solid #dcdcdc;
|
||||
display: flex;
|
||||
background-color: #f5f5f5;
|
||||
color: #495057;
|
||||
border-radius: 3px;
|
||||
font-size: 0.75em;
|
||||
height: 2em;
|
||||
padding-left: 0.75em;
|
||||
padding-right: 0.75em;
|
||||
white-space: nowrap;
|
||||
margin: 5px;
|
||||
}
|
||||
}
|
||||
</style>
|
@ -1,52 +1,60 @@
|
||||
import { useApi, useInternalApi } from "~/composables/useApi";
|
||||
import { PaginatedResult, Project, User } from "hangar-api";
|
||||
import { useInitialState } from "~/composables/useInitialState";
|
||||
import { Flag, HangarNotification, HealthReport, Invites, LoggedAction } from "hangar-internal";
|
||||
import { Flag, HangarNotification, HealthReport, Invites, LoggedAction, ReviewQueueEntry } from "hangar-internal";
|
||||
|
||||
export async function useProjects(pagination = { limit: 25, offset: 0 }, blocking = true) {
|
||||
return await useInitialState("useProjects", () => useApi<PaginatedResult<Project>>("projects", false, "get", pagination), blocking);
|
||||
return useInitialState("useProjects", () => useApi<PaginatedResult<Project>>("projects", false, "get", pagination), blocking);
|
||||
}
|
||||
|
||||
export async function useUser(user: string, blocking = true) {
|
||||
return await useInitialState("useUser", () => useApi<User>("users/" + user, false), blocking);
|
||||
return useInitialState("useUser", () => useApi<User>("users/" + user, false), blocking);
|
||||
}
|
||||
|
||||
export async function useProject(user: string, project: string, blocking = true) {
|
||||
return await useInitialState("useProject", () => useApi<Project>("projects/" + user + "/" + project, false), blocking);
|
||||
return useInitialState("useProject", () => useApi<Project>("projects/" + user + "/" + project, false), blocking);
|
||||
}
|
||||
|
||||
export async function useStargazers(user: string, project: string, blocking = true) {
|
||||
return await useInitialState("useStargazers", () => useApi<PaginatedResult<User>>(`projects/${user}/${project}/stargazers`, false), blocking);
|
||||
return useInitialState("useStargazers", () => useApi<PaginatedResult<User>>(`projects/${user}/${project}/stargazers`, false), blocking);
|
||||
}
|
||||
|
||||
export async function useWatchers(user: string, project: string, blocking = true) {
|
||||
return await useInitialState("useWatchers", () => useApi<PaginatedResult<User>>(`projects/${user}/${project}/watchers`, false), blocking);
|
||||
return useInitialState("useWatchers", () => useApi<PaginatedResult<User>>(`projects/${user}/${project}/watchers`, false), blocking);
|
||||
}
|
||||
|
||||
export async function useStaff(blocking = true) {
|
||||
return await useInitialState("useStaff", () => useApi<PaginatedResult<User>>("staff", false), blocking);
|
||||
return useInitialState("useStaff", () => useApi<PaginatedResult<User>>("staff", false), blocking);
|
||||
}
|
||||
|
||||
export async function useAuthors(blocking = true) {
|
||||
return await useInitialState("useAuthors", () => useApi<PaginatedResult<User>>("authors", false), blocking);
|
||||
return useInitialState("useAuthors", () => useApi<PaginatedResult<User>>("authors", false), blocking);
|
||||
}
|
||||
|
||||
export async function useInvites(blocking = true) {
|
||||
return await useInitialState("useInvites", () => useInternalApi<Invites>("invites", false), blocking);
|
||||
return useInitialState("useInvites", () => useInternalApi<Invites>("invites", false), blocking);
|
||||
}
|
||||
|
||||
export async function useNotifications(blocking = true) {
|
||||
return await useInitialState("useNotifications", () => useInternalApi<HangarNotification[]>("notifications", false), blocking);
|
||||
return useInitialState("useNotifications", () => useInternalApi<HangarNotification[]>("notifications", false), blocking);
|
||||
}
|
||||
|
||||
export async function useFlags(blocking = true) {
|
||||
return await useInitialState("useFlags", () => useInternalApi<Flag[]>("flags/", false), blocking);
|
||||
return useInitialState("useFlags", () => useInternalApi<Flag[]>("flags/", false), blocking);
|
||||
}
|
||||
|
||||
export async function useHealthReport(blocking = true) {
|
||||
return await useInitialState("useHealthReport", () => useInternalApi<HealthReport>("admin/health", false), blocking);
|
||||
return useInitialState("useHealthReport", () => useInternalApi<HealthReport>("admin/health", false), blocking);
|
||||
}
|
||||
|
||||
export async function useActionLogs(blocking = true) {
|
||||
return await useInitialState("useActionLogs", () => useInternalApi<PaginatedResult<LoggedAction>>("admin/log/", false), blocking);
|
||||
return useInitialState("useActionLogs", () => useInternalApi<PaginatedResult<LoggedAction>>("admin/log/", false), blocking);
|
||||
}
|
||||
|
||||
export async function useVersionApprovals(blocking = true) {
|
||||
return useInitialState(
|
||||
"useVersionApprovals",
|
||||
() => useInternalApi<{ underReview: ReviewQueueEntry[]; notStarted: ReviewQueueEntry[] }>("admin/approval/versions", false),
|
||||
blocking
|
||||
);
|
||||
}
|
||||
|
@ -1,5 +1,176 @@
|
||||
<script lang="ts" setup>
|
||||
import { useI18n } from "vue-i18n";
|
||||
import SortableTable, { Header } from "~/components/SortableTable.vue";
|
||||
import { Review, ReviewQueueEntry } from "hangar-internal";
|
||||
import { ReviewAction } from "~/types/enums";
|
||||
import { useContext } from "vite-ssr/vue";
|
||||
import { useVersionApprovals } from "~/composables/useApiHelper";
|
||||
import { handleRequestError } from "~/composables/useErrorHandling";
|
||||
import { ref } from "vue";
|
||||
import Card from "~/components/design/Card.vue";
|
||||
import Button from "~/components/design/Button.vue";
|
||||
import Link from "~/components/design/Link.vue";
|
||||
import Tag from "~/components/Tag.vue";
|
||||
|
||||
const i18n = useI18n();
|
||||
const ctx = useContext();
|
||||
const data = await useVersionApprovals().catch((e) => handleRequestError(e, ctx, i18n));
|
||||
const underReviewExpanded = ref([]);
|
||||
|
||||
const actions = {
|
||||
ongoing: [ReviewAction.START, ReviewAction.MESSAGE, ReviewAction.UNDO_APPROVAL, ReviewAction.REOPEN],
|
||||
stopped: [ReviewAction.STOP],
|
||||
approved: [ReviewAction.APPROVE, ReviewAction.PARTIALLY_APPROVE],
|
||||
};
|
||||
|
||||
const underReviewHeaders: Header[] = [
|
||||
{ title: i18n.t("versionApproval.project") as string, name: "project", sortable: false },
|
||||
{ title: i18n.t("versionApproval.version") as string, name: "version", sortable: false },
|
||||
{ title: i18n.t("versionApproval.queuedBy") as string, name: "queuedBy", sortable: true },
|
||||
{ title: i18n.t("versionApproval.status") as string, name: "status", sortable: true },
|
||||
{ title: "", name: "reviewLogs", sortable: false },
|
||||
];
|
||||
|
||||
const notStartedHeaders: Header[] = [
|
||||
{ title: i18n.t("versionApproval.project") as string, name: "project", sortable: false },
|
||||
{ title: i18n.t("versionApproval.date") as string, name: "date", sortable: true },
|
||||
{ title: i18n.t("versionApproval.version") as string, name: "version", sortable: false },
|
||||
{ title: i18n.t("versionApproval.queuedBy") as string, name: "queuedBy", sortable: true },
|
||||
{ title: "", name: "startBtn", sortable: false },
|
||||
];
|
||||
|
||||
function getRouteParams(entry: ReviewQueueEntry) {
|
||||
return {
|
||||
user: entry.namespace.owner,
|
||||
project: entry.namespace.slug,
|
||||
version: entry.versionString,
|
||||
platform: entry.platforms[0].toLowerCase(),
|
||||
};
|
||||
}
|
||||
|
||||
function isOngoing(review: Review) {
|
||||
return actions.ongoing.includes(review.lastAction);
|
||||
}
|
||||
|
||||
function isStopped(review: Review) {
|
||||
return actions.stopped.includes(review.lastAction);
|
||||
}
|
||||
|
||||
function isApproved(review: Review) {
|
||||
return actions.approved.includes(review.lastAction);
|
||||
}
|
||||
|
||||
function getOngoingCount(entry: ReviewQueueEntry) {
|
||||
return getCount(entry, ...actions.ongoing);
|
||||
}
|
||||
|
||||
function getStoppedCount(entry: ReviewQueueEntry) {
|
||||
return getCount(entry, ...actions.stopped);
|
||||
}
|
||||
|
||||
function getApprovedCount(entry: ReviewQueueEntry) {
|
||||
return getCount(entry, ...actions.approved);
|
||||
}
|
||||
|
||||
function getCount(entry: ReviewQueueEntry, ..._actions: ReviewAction[]) {
|
||||
let count = 0;
|
||||
for (const review of entry.reviews) {
|
||||
if (_actions.includes(review.lastAction)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<h1>versions approvals</h1>
|
||||
<Card>
|
||||
<template #header>{{ i18n.t("versionApproval.approvalQueue") }}</template>
|
||||
|
||||
<SortableTable :headers="notStartedHeaders" :items="data?.notStarted">
|
||||
<template #item_project="{ item }">
|
||||
<Link :to="`/${item.namespace.owner}/${item.namespace.slug}`">
|
||||
{{ `${item.namespace.owner}/${item.namespace.slug}` }}
|
||||
</Link>
|
||||
</template>
|
||||
<template #item_date="{ item }">
|
||||
<span class="start-date">{{ i18n.d(item.versionCreatedAt, "time") }}</span>
|
||||
</template>
|
||||
<template #item_version="{ item }">
|
||||
<Link :to="{ name: 'user-project-versions-version-platform', params: getRouteParams(item) }">
|
||||
<Tag :color="{ background: item.channelColor }" :name="item.channelName" :data="item.versionString" />
|
||||
</Link>
|
||||
</template>
|
||||
<template #item_queuedBy="{ item }">
|
||||
<Link :to="`/${item.versionAuthor}`">
|
||||
{{ item.versionAuthor }}
|
||||
</Link>
|
||||
</template>
|
||||
<template #item_startBtn="{ item }">
|
||||
<Link :to="{ name: 'user-project-versions-version-platform-reviews', params: getRouteParams(item) }" nuxt>
|
||||
<IconMdiPlay></IconMdiPlay>
|
||||
{{ i18n.t("version.page.reviewStart") }}
|
||||
</Link>
|
||||
</template>
|
||||
</SortableTable>
|
||||
</Card>
|
||||
|
||||
<Card class="mt-4">
|
||||
<template #header>{{ i18n.t("versionApproval.inReview") }}</template>
|
||||
|
||||
<SortableTable :headers="underReviewHeaders" :items="data?.underReview">
|
||||
<template #item_project="{ item }">
|
||||
<Link :to="`/${item.namespace.owner}/${item.namespace.slug}`">
|
||||
{{ `${item.namespace.owner}/${item.namespace.slug}` }}
|
||||
</Link>
|
||||
</template>
|
||||
<template #item_version="{ item }">
|
||||
<Link :to="{ name: 'user-project-versions-version-platform', params: getRouteParams(item) }">
|
||||
<Tag :color="{ background: item.channelColor }" :name="item.channelName" :data="item.versionString" />
|
||||
</Link>
|
||||
</template>
|
||||
<template #item_queuedBy="{ item }">
|
||||
<Link :to="`/${item.versionAuthor}`">
|
||||
{{ item.versionAuthor }}
|
||||
</Link>
|
||||
<br />
|
||||
<small>{{ i18n.d(item.versionCreatedAt, "time") }}</small>
|
||||
</template>
|
||||
<template #item_status="{ item }">
|
||||
<span class="text-yellow-400">
|
||||
{{ i18n.t("versionApproval.statuses.ongoing", [getOngoingCount(item)]) }}
|
||||
</span>
|
||||
<br />
|
||||
<span class="text-red-400">
|
||||
{{ i18n.t("versionApproval.statuses.stopped", [getStoppedCount(item)]) }}
|
||||
</span>
|
||||
<br />
|
||||
<span class="text-green-400"> {{ i18n.t("versionApproval.statuses.approved", [getApprovedCount(item)]) }}</span>
|
||||
</template>
|
||||
<template #item_reviewLogs="{ item }">
|
||||
<Link :to="{ name: 'user-project-versions-version-platform-reviews', params: getRouteParams(item) }">
|
||||
<IconMdiListStatus />
|
||||
{{ i18n.t("version.page.reviewLogs") }}
|
||||
</Link>
|
||||
</template>
|
||||
<!-- todo item expansion -->
|
||||
<template #expanded-item="{ item, headers }">
|
||||
<td :colspan="headers.length">
|
||||
<ul>
|
||||
<li v-for="entry in item.reviews" :key="entry.reviewerName" class="review-list-entry">
|
||||
<span class="reviewer-name status-colored" :class="{ ongoing: isOngoing(entry), stopped: isStopped(entry), approved: isApproved(entry) }">{{
|
||||
entry.reviewerName
|
||||
}}</span>
|
||||
<span class="review-started">{{ i18n.t("versionApproval.started", [i18n.d(entry.reviewStarted, "time")]) }}</span>
|
||||
<span v-if="entry.reviewEnded" class="review-ended status-colored" :class="{ stopped: isStopped(entry), approved: isApproved(entry) }">{{
|
||||
i18n.t("versionApproval.ended", [i18n.d(entry.reviewEnded, "time")])
|
||||
}}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</td>
|
||||
</template>
|
||||
</SortableTable>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<route lang="yaml">
|
||||
|
1
frontend-new/src/types/generated/icons.d.ts
vendored
1
frontend-new/src/types/generated/icons.d.ts
vendored
@ -11,6 +11,7 @@ declare module "vue" {
|
||||
IconMdiListStatus: typeof import("~icons/mdi/list-status")["default"];
|
||||
IconMdiMenu: typeof import("~icons/mdi/menu")["default"];
|
||||
IconMdiOpenInNew: typeof import("~icons/mdi/open-in-new")["default"];
|
||||
IconMdiPlay: typeof import("~icons/mdi/play")["default"];
|
||||
IconMdiSortVariant: typeof import("~icons/mdi/sort-variant")["default"];
|
||||
IconMdiWeatherNight: typeof import("~icons/mdi/weather-night")["default"];
|
||||
IconMdiWhiteBalanceSunny: typeof import("~icons/mdi/white-balance-sunny")["default"];
|
||||
|
Loading…
Reference in New Issue
Block a user