implement admin version approval page

This commit is contained in:
MiniDigger 2022-03-23 16:30:50 +01:00
parent a72c0b65de
commit 03afe4c4d6
5 changed files with 271 additions and 19 deletions

View File

@ -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]

View 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>

View File

@ -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
);
}

View File

@ -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">

View File

@ -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"];