"Better" version selector

Closes #1088
This commit is contained in:
Nassim Jahnke 2023-02-14 00:49:06 +01:00
parent 780d806d8d
commit 653feb0a4f
No known key found for this signature in database
GPG Key ID: 6BE3B555EBC5982B
9 changed files with 174 additions and 35 deletions

View File

@ -0,0 +1,4 @@
package io.papermc.hangar.model.common;
public record PlatformVersion(String version, String[] subVersions) {
}

View File

@ -4,10 +4,17 @@ import io.papermc.hangar.HangarComponent;
import io.papermc.hangar.config.CacheConfig;
import io.papermc.hangar.db.dao.internal.table.PlatformVersionDAO;
import io.papermc.hangar.model.common.Platform;
import io.papermc.hangar.model.common.PlatformVersion;
import io.papermc.hangar.model.db.PlatformVersionTable;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
@ -17,6 +24,8 @@ import org.springframework.transaction.annotation.Transactional;
@Service
public class PlatformService extends HangarComponent {
private static final Logger LOGGER = LoggerFactory.getLogger(PlatformService.class);
private static final String[] ARR = new String[0];
private final PlatformVersionDAO platformVersionDAO;
@Autowired
@ -25,10 +34,45 @@ public class PlatformService extends HangarComponent {
}
@Cacheable(CacheConfig.PLATFORMS)
public List<String> getVersionsForPlatform(final Platform platform) {
public List<String> getFullVersionsForPlatform(final Platform platform) {
return this.platformVersionDAO.getVersionsForPlatform(platform);
}
@Cacheable(CacheConfig.PLATFORMS)
public List<PlatformVersion> getVersionsForPlatform(final Platform platform) {
final List<String> versions = this.platformVersionDAO.getVersionsForPlatform(platform);
final Map<String, List<String>> platformVersions = new LinkedHashMap<>();
for (final String version : versions) {
if (version.split("\\.").length <= 2) {
platformVersions.put(version, new ArrayList<>());
continue;
}
final String parent = version.substring(0, version.lastIndexOf('.'));
final List<String> subVersions = platformVersions.get(parent);
if (subVersions == null) {
LOGGER.warn("Version {} does not have a parent version", version);
platformVersions.put(version, new ArrayList<>());
continue;
}
subVersions.add(version);
}
// Reverse everything and add self
for (final Map.Entry<String, List<String>> entry : platformVersions.entrySet()) {
final List<String> subVersions = entry.getValue();
if (!subVersions.isEmpty()) {
Collections.reverse(subVersions);
subVersions.add(entry.getKey());
}
}
final List<PlatformVersion> list = platformVersions.entrySet().stream().map(entry -> new PlatformVersion(entry.getKey(), entry.getValue().toArray(ARR))).collect(Collectors.toList());
Collections.reverse(list);
return list;
}
@Transactional
@CacheEvict(value = CacheConfig.PLATFORMS, allEntries = true)
public void updatePlatformVersions(final Map<Platform, List<String>> platformVersions) {

View File

@ -424,7 +424,7 @@ public class VersionFactory extends HangarComponent {
}
}
if (pendingVersion.getPlatformDependencies().entrySet().stream().anyMatch(en -> !new HashSet<>(this.platformService.getVersionsForPlatform(en.getKey())).containsAll(en.getValue()))) {
if (pendingVersion.getPlatformDependencies().entrySet().stream().anyMatch(en -> !new HashSet<>(this.platformService.getFullVersionsForPlatform(en.getKey())).containsAll(en.getValue()))) {
throw new HangarApiException(HttpStatus.BAD_REQUEST, "version.new.error.invalidPlatformVersion");
}
}

View File

@ -0,0 +1,79 @@
<script lang="ts" setup>
import { computed, watch } from "vue";
import { PlatformVersion } from "hangar-internal";
import InputCheckbox from "~/lib/components/ui/InputCheckbox.vue";
import ArrowSpoiler from "~/lib/components/design/ArrowSpoiler.vue";
const props = defineProps<{
versions: PlatformVersion[];
modelValue: string[];
open: boolean;
}>();
const emit = defineEmits<{
(e: "update:modelValue", selected: string[]): void;
}>();
const selected = computed({
get: () => props.modelValue,
set: (value) => emit("update:modelValue", value),
});
// TODO All of this is horrible
watch(selected, (oldValue, newValue) => {
const removedVersions = [...newValue.filter((x) => !oldValue.includes(x))];
const addedVersions = [...oldValue.filter((x) => !newValue.includes(x))];
for (const version of removedVersions) {
if (!version.endsWith(".x")) {
continue;
}
const parentVersion = version.substring(0, version.length - 2);
const platformVersion = props.versions.find((v) => v.version === parentVersion);
if (!platformVersion) {
continue;
}
for (const subVersion of platformVersion.subVersions) {
selected.value.splice(selected.value.indexOf(subVersion), 1);
}
}
for (const version of addedVersions) {
if (!version.endsWith(".x")) {
continue;
}
const parentVersion = version.substring(0, version.length - 2);
const platformVersion = props.versions.find((v) => v.version === parentVersion);
if (!platformVersion) {
continue;
}
for (const subVersion of platformVersion.subVersions) {
if (!selected.value.includes(subVersion)) {
selected.value.push(subVersion);
}
}
}
});
</script>
<template>
<div v-for="version in versions" :key="version.version">
<div v-if="version.subVersions.length !== 0">
<ArrowSpoiler :open="open">
<template #title>
<div class="mr-8">
<InputCheckbox v-model="selected" :value="version.version + '.x'" :label="version.version" />
</div>
</template>
<template #content>
<div class="ml-5">
<InputCheckbox v-for="subversion in version.subVersions" :key="subversion" v-model="selected" :value="subversion" :label="subversion" />
</div>
</template>
</ArrowSpoiler>
</div>
<InputCheckbox v-else v-model="selected" :value="version.version" :label="version.version" />
</div>
</template>

View File

@ -6,10 +6,9 @@ import { useRoute, useRouter } from "vue-router";
import Button from "~/lib/components/design/Button.vue";
import Modal from "~/lib/components/modals/Modal.vue";
import { Platform } from "~/types/enums";
import { useBackendData } from "~/store/backendData";
import { handleRequestError } from "~/composables/useErrorHandling";
import { useInternalApi } from "~/composables/useApi";
import InputTag from "~/lib/components/ui/InputTag.vue";
import VersionSelector from "~/components/VersionSelector.vue";
const props = defineProps<{
project: HangarProject;
@ -46,7 +45,9 @@ function save() {
<template>
<Modal :title="i18n.t('version.edit.platformVersions', [platform.name])" window-classes="w-200">
<InputTag v-model="selectedVersions" :options="platform.possibleVersions" />
<div class="flex flex-row flex-wrap gap-5">
<VersionSelector v-model="selectedVersions" :versions="platform.possibleVersions" open />
</div>
<Button class="mt-3" :disabled="loading" @click="save">{{ i18n.t("general.save") }}</Button>
<template #activator="{ on }">

@ -1 +1 @@
Subproject commit f97c995535949a1ef9b1c44d98400f018cc6b27a
Subproject commit 6cac2d63bb9d266665b4931cf99bfb6b5742c23e

View File

@ -22,7 +22,7 @@ import { formatSize } from "~/lib/composables/useFile";
import ChannelModal from "~/components/modals/ChannelModal.vue";
import { useBackendData } from "~/store/backendData";
import DependencyTable from "~/components/projects/DependencyTable.vue";
import InputTag from "~/lib/components/ui/InputTag.vue";
import VersionSelector from "~/components/VersionSelector.vue";
import Tabs from "~/lib/components/design/Tabs.vue";
import PlatformLogo from "~/components/logos/platforms/PlatformLogo.vue";
import { useProjectChannels } from "~/composables/useApiHelper";
@ -369,12 +369,13 @@ useHead(useSeo(i18n.t("version.new.title") + " | " + props.project.name, props.p
<div class="flex flex-wrap space-y-5 mb-8">
<div v-for="platform in selectedPlatformsData" :key="platform.enumName" class="basis-full">
<span class="text-lg inline-flex items-center"><PlatformLogo :platform="platform.enumName" :size="25" class="mr-1" /> {{ platform.name }}</span>
<div class="mt-2">
<InputTag
<div class="ml-1 flex flex-row flex-wrap gap-5">
<VersionSelector
v-if="pendingVersion"
v-model="pendingVersion.platformDependencies[platform.enumName]"
:options="platform.possibleVersions"
:versions="platform.possibleVersions"
:rules="platformVersionRules"
open
/>
</div>
</div>

View File

@ -1,9 +1,10 @@
<script lang="ts" setup>
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { computed, ref } from "vue";
import { computed, Ref, ref } from "vue";
import { cloneDeep, isEqual } from "lodash-es";
import { useHead } from "@vueuse/head";
import { PlatformVersion } from "hangar-internal";
import { handleRequestError } from "~/composables/useErrorHandling";
import { useBackendData } from "~/store/backendData";
import { useInternalApi } from "~/composables/useApi";
@ -15,6 +16,7 @@ import Table from "~/lib/components/design/Table.vue";
import { useSeo } from "~/composables/useSeo";
import { useNotificationStore } from "~/lib/store/notification";
import { definePageMeta } from "#imports";
import { Platform } from "~/types/enums";
definePageMeta({
globalPermsRequired: ["MANUAL_VALUE_CHANGES"],
@ -26,17 +28,31 @@ const router = useRouter();
const notification = useNotificationStore();
const platformMap = useBackendData.platforms;
const originalPlatforms = platformMap ? [...platformMap.values()] : [];
const platforms = ref(cloneDeep(originalPlatforms));
const platforms = platformMap ? [...platformMap.values()] : [];
const loading = ref<boolean>(false);
useHead(useSeo(i18n.t("platformVersions.title"), null, route, null));
const fullVersions: Ref<Record<Platform, string[]>> = ref({
PAPER: [],
WATERFALL: [],
VELOCITY: [],
});
reset();
function versions(versions: PlatformVersion[]): string[] {
const fullVersions = [];
for (const version of versions) {
fullVersions.push(version.version, ...version.subVersions);
}
return fullVersions;
}
async function save() {
loading.value = true;
const data: { [key: string]: string[] } = {};
for (const pl of platforms.value || []) {
data[pl.enumName] = pl.possibleVersions;
for (const pl of platforms || []) {
data[pl.enumName] = fullVersions.value[pl.enumName];
}
try {
await useInternalApi("admin/platformVersions", "post", data);
@ -49,10 +65,10 @@ async function save() {
}
function reset() {
platforms.value = cloneDeep(originalPlatforms);
for (const platform of useBackendData.platforms.values()) {
fullVersions.value[platform.enumName] = versions(platform.possibleVersions);
}
}
const hasChanged = computed(() => !isEqual(platforms.value, originalPlatforms));
</script>
<template>
@ -70,7 +86,7 @@ const hasChanged = computed(() => !isEqual(platforms.value, originalPlatforms));
<tr v-for="platform in platforms" :key="platform.name">
<td>{{ platform.name }}</td>
<td>
<InputTag v-model="platform.possibleVersions"></InputTag>
<InputTag v-model="fullVersions[platform.enumName]"></InputTag>
</td>
</tr>
</tbody>
@ -79,8 +95,8 @@ const hasChanged = computed(() => !isEqual(platforms.value, originalPlatforms));
<template #footer>
<span class="flex justify-end items-center gap-2">
Updates may take a while to take effect!
<Button :disabled="!hasChanged" @click="reset">{{ i18n.t("general.reset") }}</Button>
<Button :disabled="loading || !hasChanged" @click="save"> {{ i18n.t("platformVersions.saveChanges") }}</Button>
<Button @click="reset">{{ i18n.t("general.reset") }}</Button>
<Button :disabled="loading" @click="save"> {{ i18n.t("platformVersions.saveChanges") }}</Button>
</span>
</template>
</Card>

View File

@ -5,6 +5,7 @@ import { computed, isRef, Ref, ref, watch } from "vue";
import { useHead } from "@vueuse/head";
import { useRoute, useRouter } from "vue-router";
import { PaginatedResult, Project } from "hangar-api";
import { PlatformVersion } from "hangar-internal";
import InputCheckbox from "~/lib/components/ui/InputCheckbox.vue";
import { useBackendData, useVisibleCategories, useVisiblePlatforms } from "~/store/backendData";
import ProjectList from "~/components/projects/ProjectList.vue";
@ -20,6 +21,7 @@ import PlatformLogo from "~/components/logos/platforms/PlatformLogo.vue";
import CategoryLogo from "~/components/logos/categories/CategoryLogo.vue";
import LicenseLogo from "~/components/logos/licenses/LicenseLogo.vue";
import { useConfig } from "~/lib/composables/useConfig";
import VersionSelector from "~/components/VersionSelector.vue";
const i18n = useI18n();
const route = useRoute();
@ -102,23 +104,21 @@ async function checkOffsetLargerCount() {
}
}
function versions(platform: Platform) {
function versions(platform: Platform): PlatformVersion[] {
const platformData = useBackendData.platforms?.get(platform);
if (!platformData) {
return [];
}
return [...platformData.possibleVersions].reverse().map((k) => {
return { version: k };
});
return platformData.possibleVersions;
}
function updatePlatform(platform: any) {
filters.value.platform = platform;
const allowedVersion = versions(platform);
const allowedVersion: PlatformVersion[] = versions(platform);
filters.value.versions = filters.value.versions.filter((existingVersion) => {
return allowedVersion.find((allowedNewVersion) => allowedNewVersion.version === existingVersion);
return allowedVersion.find((allowedNewVersion) => allowedNewVersion === existingVersion);
});
}
@ -233,14 +233,8 @@ useHead(meta);
</div>
<div v-if="filters.platform" class="versions">
<h4 class="font-bold mb-1">{{ i18n.t("hangar.projectSearch.versions") }}</h4>
<div class="flex flex-col gap-1 max-h-30 overflow-auto">
<InputCheckbox
v-for="version in versions(filters.platform)"
:key="version.version"
v-model="filters.versions"
:value="version.version"
:label="version.version"
/>
<div class="flex flex-col gap-1 max-h-40 overflow-auto">
<VersionSelector v-model="filters.versions" :versions="versions(filters.platform)" :open="false" />
</div>
</div>
<div class="categories">