fix remaining type errors, fix org loading, fix project settings, watch for key changes in data fetching

This commit is contained in:
MiniDigger | Martin 2024-08-10 15:43:02 +02:00
parent 089a3e396e
commit b90bd46e74
14 changed files with 182 additions and 89 deletions

View File

@ -119,7 +119,7 @@ export default defineNuxtConfig({
typedPages: true,
},
typescript: {
// typeCheck: "build", // TODO enable typechecking on build
typeCheck: "build",
tsConfig: {
include: ["./types/typed-router.d.ts"],
compilerOptions: {

View File

@ -20,7 +20,7 @@ const sorters = [
{ id: "-newest", label: i18n.t("project.sorting.newest") },
];
const toArray = (input: unknown) => (Array.isArray(input) ? input : input ? [input] : []);
const toArray = (input: (string | null)[] | string | null): string[] => (Array.isArray(input) ? (input as string[]) : input ? [input!] : []);
const filters = ref({
versions: toArray(route.query.version),
categories: toArray(route.query.category),
@ -38,12 +38,12 @@ const query = ref<string>((route.query.query as string) || "");
const requestParams = computed(() => {
const limit = 10;
const params: Record<string, any> = {
const params: ReturnType<Parameters<typeof useProjects>[0]> = {
limit,
offset: page.value * limit,
version: filters.value.versions,
category: filters.value.categories,
platform: filters.value.platform !== null ? [filters.value.platform] : [],
platform: filters.value.platform ? [filters.value.platform] : [],
tag: filters.value.tags,
};
if (query.value) {
@ -55,7 +55,7 @@ const requestParams = computed(() => {
return params;
});
const { projects } = useProjects(() => requestParams.value, router);
const { projects, refreshProjects } = useProjects(() => requestParams.value, router);
// if somebody set page too high, lets reset it back
watch(projects, () => {
@ -63,6 +63,12 @@ watch(projects, () => {
page.value = 0;
}
});
// for some reason the watch in useProjects doesn't work for filters 🤷
watch(
() => filters.value.versions,
() => refreshProjects(),
{ deep: true }
);
function versions(platform: Platform): PlatformVersion[] {
const platformData = useBackendData.platforms?.get(platform);
@ -76,9 +82,9 @@ function versions(platform: Platform): PlatformVersion[] {
function updatePlatform(platform: any) {
filters.value.platform = platform;
const allowedVersion: PlatformVersion[] = versions(platform);
const allowedVersion = versions(platform);
filters.value.versions = filters.value.versions.filter((existingVersion) => {
return allowedVersion.find((allowedNewVersion) => allowedNewVersion === existingVersion);
return allowedVersion.find((allowedNewVersion) => allowedNewVersion.version === existingVersion);
});
}

View File

@ -11,6 +11,10 @@ function remove(index: number) {
model.value.splice(index, 1);
}
function add() {
if (!model.value) {
model.value = [{ id: 0, name: "", url: "" }];
return;
}
let nextId = Math.max(...model.value.map((l) => l.id)) + 1;
if (nextId === -Infinity) {
nextId = 0;

View File

@ -50,10 +50,28 @@ export function usePossibleAlts(user: () => string) {
return { possibleAlts, possibleAltsStatus };
}
export function useProjects(params: () => { member?: string; limit?: number; offset?: number; q?: string; owner?: string }, router?: Router) {
const { data: projects, status: projectsStatus } = useData(
export function useProjects(
params: () => {
member?: string;
limit?: number;
offset?: number;
query?: string;
owner?: string;
version?: string[];
category?: string[];
platform?: Platform[];
tag?: string[];
sort?: string;
},
router?: Router
) {
const {
data: projects,
status: projectsStatus,
refresh: refreshProjects,
} = useData(
params,
(p) => "projects:" + p,
(p) => "projects:" + (p.member || p.owner || "main") + ":" + p.offset,
(p) => useApi<PaginatedResultProject>("projects", "get", { ...p }),
true,
() => false,
@ -63,7 +81,7 @@ export function useProjects(params: () => { member?: string; limit?: number; off
}
}
);
return { projects, projectsStatus };
return { projects, projectsStatus, refreshProjects };
}
export function useStarred(user: () => string) {
@ -214,8 +232,8 @@ export function usePossiblePerms(user: () => string) {
export function useAdminStats(params: () => { from: string; to: string }) {
const { data: adminStats, status: adminStatsStatus } = useData(
params,
() => "adminStats:" + params().from + params().to,
(params) => useInternalApi<DayStats[]>("admin/stats", "get", params)
(p) => "adminStats:" + p.from + ":" + p.to,
(p) => useInternalApi<DayStats[]>("admin/stats", "get", p)
);
return { adminStats, adminStatsStatus };
}
@ -272,7 +290,7 @@ export function useUser(userName: () => string) {
export function useUsers(params: () => { query?: string; limit?: number; offset?: number; sort?: string[] }) {
const { data: users, status: usersStatus } = useData(
params,
() => "users",
(p) => "users:" + p.query + ":" + p.offset + ":" + p.sort,
(p) => useApi<PaginatedResultUser>("users", "get", p)
);
return { users, usersStatus };
@ -284,7 +302,7 @@ export function useActionLogs(
) {
const { data: actionLogs, status: actionLogsStatus } = useData(
params,
() => "actionLogs",
(p) => "actionLogs:" + p.offset + ":" + p.sort + ":" + p.user + ":" + p.logAction + ":" + p.authorName + ":" + p.projectSlug,
(p) => useInternalApi<PaginatedResultHangarLoggedAction>("admin/log", "get", p),
true,
() => false,
@ -300,7 +318,7 @@ export function useActionLogs(
export function useStaff(params: () => { offset?: number; limit?: number; sort?: string[]; query?: string }) {
const { data: staff, status: staffStatus } = useData(
params,
() => "staff",
(p) => "staff:" + p.offset + ":" + p.sort + ":" + p.query,
(p) => useApi<PaginatedResultUser>("staff", "GET", p)
);
return { staff, staffStatus };
@ -309,7 +327,7 @@ export function useStaff(params: () => { offset?: number; limit?: number; sort?:
export function useAuthors(params: () => { offset?: number; limit?: number; sort?: string[]; query?: string }) {
const { data: authors, status: authorStatus } = useData(
params,
() => "authors",
(p) => "authors:" + p.offset + ":" + p.sort + ":" + p.query,
(p) => useApi<PaginatedResultUser>("authors", "GET", p)
);
return { authors, authorStatus };
@ -375,7 +393,7 @@ export function useProjectVersions(
) {
const { data: versions, status: versionsStatus } = useData(
params,
(p) => "versions:" + p.project,
(p) => "versions:" + p.project + ":" + p.data.offset + ":" + p.data.channel + ":" + p.data.platform + ":" + p.data.includeHiddenChannels,
(p) => useApi<PaginatedResultVersion>(`projects/${p.project}/versions`, "GET", p.data),
true,
() => false,

View File

@ -1,43 +1,49 @@
import type { RouteLocationNormalized } from "vue-router";
import type { ExtendedProjectPage, HangarOrganization, HangarProject, HangarVersion, User } from "~/types/backend";
type dataLoaders = "user" | "project" | "version" | "organization" | "page";
type routeParams = "user" | "project" | "version" | "organization" | "page";
type routeParams = "user" | "project" | "version" | "page";
type DataLoaderTypes = {
user: User;
project: HangarProject;
version: HangarVersion;
organization: HangarOrganization;
page: ExtendedProjectPage;
};
// TODO check every handling of the reject stuff (for both composables)
export function useDataLoader<T>(key: dataLoaders) {
const data = useState<T | undefined>(key);
export function useDataLoader<K extends keyof DataLoaderTypes>(key: K) {
const data = useState<DataLoaderTypes[K] | undefined>(key);
function loader(
param: routeParams,
to: RouteLocationNormalized,
from: RouteLocationNormalized,
loader: (param: string) => Promise<T>,
loader: (param: string) => Promise<DataLoaderTypes[K]>,
promises: Promise<any>[]
) {
const meta = to.meta["dataLoader_" + key];
if (meta) {
const oldParam = key in from.params ? (from.params[param as never] as string) : undefined;
const newParam = key in to.params ? (to.params[param as never] as string) : undefined;
const oldParam = param in from.params ? (from.params[param as never] as string) : undefined;
const newParam = param in to.params ? (to.params[param as never] as string) : undefined;
if (data.value && oldParam === newParam) {
console.log("skip loading", key); // TODO test this
return newParam;
} else if (newParam) {
promises.push(
new Promise<void>(async (resolve, reject) => {
console.log("load loading", param);
console.log("load loading", key);
const result = await loader(newParam).catch(reject);
// await new Promise((resolve) => setTimeout(resolve, 5000));
if (result) {
data.value = result;
console.log("load loaded", param);
console.log("load loaded", key);
resolve();
}
})
);
return newParam;
}
console.log("dataLoader " + key + " is miss configured for " + to.path + "!");
console.warn("dataLoader " + key + " is miss configured for " + to.path + "! (no param " + param + ")");
return undefined;
} else {
data.value = undefined;
@ -57,8 +63,13 @@ export function useData<T, P extends Record<string, unknown> | string>(
callback: (params: P) => void = () => {},
defaultValue: T | undefined = undefined
) {
// TODO make this reactive somehow
const data = useState<T | undefined>(key(params()));
// state tracking is twofold.
// `state` is used store data in the nuxt payload, so it will be shared between server and client side and on client side navigation
const state = useState<Record<string, T | undefined>>("useData", () => ({}));
// `data` is used to store a reference into the state, using the current key. it points to the data we want to return
// we are not using a computed here, since consumers might manually want to update the data. this kinda corrupts the cache, but we can't do much about it
const data = ref<T | undefined>();
const status = ref<"idle" | "loading" | "success" | "error">("idle");
let promise: Promise<void> | undefined;
@ -66,17 +77,18 @@ export function useData<T, P extends Record<string, unknown> | string>(
return load(params());
}
function setState(newState?: T) {
state.value[key(params())] = newState;
data.value = newState;
}
if (import.meta.server && !server) {
return { data, status, refresh };
}
function load(params: P) {
status.value = "loading";
if (defaultValue) {
data.value = defaultValue;
} else {
data.value = undefined;
}
setState(defaultValue || undefined);
if (skip(params)) {
console.log("skip", key(params));
@ -89,7 +101,7 @@ export function useData<T, P extends Record<string, unknown> | string>(
//await new Promise((resolve) => setTimeout(resolve, 5000));
console.log("loaded", key(params));
if (result) {
data.value = result;
setState(result);
status.value = "success";
callback(params);
resolve();
@ -99,9 +111,13 @@ export function useData<T, P extends Record<string, unknown> | string>(
});
}
// load initial state
data.value = state.value[key(params())];
// if we have no state, queue a load
if (!data.value) {
promise = load(params());
// if on server (and we dont wanna skip server fetching, we need await the promise onServerPrefetch)
if (import.meta.server && server && promise) {
onServerPrefetch(async () => {
console.log("server prefetch", key(params()));
@ -110,6 +126,22 @@ export function useData<T, P extends Record<string, unknown> | string>(
}
}
// when the key changes, we move the data from the old key to the new key
watch(
() => key(params()),
(newKey, oldKey) => {
if (newKey === oldKey) {
return;
}
const oldState = state.value[oldKey];
state.value[newKey] = oldState;
state.value[oldKey] = undefined;
data.value = oldState;
console.log("watchKey", newKey, oldKey);
}
);
// when the params change, we load the new data
watchDebounced(
params,
(newParams, oldParams) => {

View File

@ -73,11 +73,11 @@ function _handleRequestError(err: AxiosError | H3Error | unknown, i18n?: Compose
});
} else if ("response" in err && typeof err.response?.data === "object" && err.response.data) {
_handleErrorResponse(err.response.data, i18n);
} else if (err.cause?.response?.data) {
_handleErrorResponse(err.cause.response.data, i18n);
} else if (err.cause?.statusCode) {
} else if ((err.cause as any)?.response?.data) {
_handleErrorResponse((err.cause as any).response.data, i18n);
} else if ((err.cause as any)?.statusCode) {
// this error was rethrown, lets inform nuxt
showError(err.cause);
showError(err.cause as any);
} else {
throw createError({
statusCode: 500,

View File

@ -91,9 +91,12 @@ export const maxFileSize = withOverrideMessage((maxSize: number) =>
})
);
export const noDuplicated = withOverrideMessage((elements: any[] | (() => any[])) =>
export const noDuplicated = withOverrideMessage((elements: any[] | (() => any[] | undefined) | undefined) =>
helpers.withParams({ elements, type: "noDuplicated" }, () => {
const els = typeof elements === "function" ? elements() : unref(elements);
if (!els) {
return { $valid: true };
}
return { $valid: new Set(els).size === els.length };
})
);

View File

@ -7,6 +7,10 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
console.log("hit vite path???????????????????????", to.fullPath);
return;
}
// don't call on router.replace when we just update the query
if (import.meta.client && to.path === from?.path) {
return;
}
await useAuth.updateUser();
await loadRoutePerms(to);

View File

@ -2,12 +2,15 @@ import type { ExtendedProjectPage, HangarOrganization, HangarProject, HangarVers
import { useDataLoader } from "~/composables/useDataLoader";
import { isAxiosError } from "axios";
// this middleware takes care of fetching the "important" data for pages, like user/project/org/version/page, based on route params
// it also handles 404s and redirects to the proper casing
export default defineNuxtRouteMiddleware(async (to, from) => {
// don't call on router.replace when we just update the query
if (import.meta.client && to.path === from?.path) {
return;
}
// if we are in an error state, dont waste time loading this data
const error = useError();
if (error.value) {
return;
@ -22,13 +25,7 @@ export default defineNuxtRouteMiddleware(async (to, from) => {
const projectName = projectLoader("project", to, from, async (projectName) => useInternalApi<HangarProject>("projects/project/" + projectName), promises);
const { loader: organizationLoader } = useDataLoader<HangarOrganization>("organization");
organizationLoader(
"organization",
to,
from,
async (organizationName) => useInternalApi<HangarOrganization>("organizations/org/" + organizationName),
promises
);
organizationLoader("user", to, from, async (organizationName) => useInternalApi<HangarOrganization>("organizations/org/" + organizationName), promises);
const { loader: versionLoader, data: version } = useDataLoader<HangarVersion>("version");
const versionName = versionLoader(

View File

@ -1,11 +1,12 @@
<script lang="ts" setup>
import { useDataLoader } from "~/composables/useDataLoader";
import type { User } from "~/types/backend";
const { data: user } = useDataLoader<User>("user");
const { data: user } = useDataLoader("user");
const { data: org } = useDataLoader("organization");
definePageMeta({
dataLoader_user: true,
dataLoader_organization: true,
});
</script>
@ -14,8 +15,7 @@ definePageMeta({
<router-view v-slot="{ Component }">
<Suspense>
<div>
<!-- todo fix org -->
<component :is="Component" :user="user" :organization="undefined" />
<component :is="Component" :user="user" :organization="org" />
</div>
</Suspense>
</router-view>

View File

@ -1,12 +1,12 @@
<script lang="ts" setup>
import type { HangarProject, HangarProjectPage, User } from "~/types/backend";
import type { HangarProjectPage, User } from "~/types/backend";
import { useDataLoader } from "~/composables/useDataLoader";
defineProps<{
user?: User;
}>();
const { data: project } = useDataLoader<HangarProject>("project");
const { data: project } = useDataLoader("project");
definePageMeta({
dataLoader_project: true,

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { type ExtendedProjectPage, type HangarProject, NamedPermission, type User } from "~/types/backend";
import { type HangarProject, NamedPermission, type User } from "~/types/backend";
import { useDataLoader } from "~/composables/useDataLoader";
const props = defineProps<{
@ -9,7 +9,7 @@ const props = defineProps<{
const route = useRoute("user-project-pages-page");
const { data: page } = useDataLoader<ExtendedProjectPage>("page");
const { data: page } = useDataLoader("page");
definePageMeta({
dataLoader_page: true,

View File

@ -4,7 +4,16 @@ import { useVuelidate } from "@vuelidate/core";
import { Cropper, type CropperResult } from "vue-advanced-cropper";
import type { Tab } from "~/types/components/design/Tabs";
import InputText from "~/components/ui/InputText.vue";
import { type HangarProject, type HangarUser, NamedPermission, type PaginatedResultUser, Tag, Visibility } from "~/types/backend";
import {
Category,
type HangarProject,
type HangarUser,
NamedPermission,
type PaginatedResultUser,
type ProjectSettings,
Tag,
Visibility,
} from "~/types/backend";
import "vue-advanced-cropper/dist/style.css";
@ -34,16 +43,27 @@ if (hasPerms(NamedPermission.IsSubjectOwner) || hasPerms(NamedPermission.DeleteP
}
const form = reactive({
settings: cloneDeep(props.project.settings),
description: props.project.description,
category: props.project.category,
});
if (!form.settings.license.type) {
form.settings.license.type = "Unspecified";
}
if (!form.settings.links) {
form.settings.links = [];
}
settings: undefined,
description: undefined,
category: undefined,
} as { settings?: ProjectSettings; description?: string; category?: Category });
watch(
() => props.project,
(val) => {
form.settings = cloneDeep(val?.settings);
form.description = val?.description;
form.category = val?.category;
if (form.settings && !form.settings?.license?.type) {
form.settings.license.type = "Unspecified";
}
if (form.settings && !form.settings?.links) {
form.settings.links = [];
}
},
{ immediate: true }
);
const hasCustomIcon = computed(() => props.project?.avatarUrl?.includes("project"));
const projectIcon = ref<File | null>(null);
@ -84,8 +104,8 @@ const loading = reactive({
transfer: false,
});
const isCustomLicense = computed(() => form.settings.license.type === "Other");
const isUnspecifiedLicense = computed(() => form.settings.license.type === "Unspecified");
const isCustomLicense = computed(() => form.settings?.license?.type === "Other");
const isUnspecifiedLicense = computed(() => form.settings?.license?.type === "Unspecified");
watch(route, (val) => (selectedTab.value = val.params.slug?.[0] || "general"), { deep: true });
watch(selectedTab, (val) => router.replace("/" + route.params.user + "/" + route.params.project + "/settings/" + val));
@ -106,10 +126,10 @@ async function save() {
if (!(await v.value.$validate())) return;
loading.save = true;
try {
if (!isCustomLicense.value) {
if (form.settings && !isCustomLicense.value) {
form.settings.license.name = undefined as unknown as string;
}
if (isUnspecifiedLicense.value) {
if (form.settings && isUnspecifiedLicense.value) {
form.settings.license.url = undefined;
}
@ -252,35 +272,44 @@ useHead(useSeo(i18n.t("project.settings.title") + " | " + props.project?.name, p
</ProjectSettingsSection>
<ProjectSettingsSection title="project.settings.keywords" description="project.settings.keywordsSub">
<InputTag
v-if="form.settings"
v-model="form.settings.keywords"
counter
:maxlength="useBackendData.validations?.project?.keywords?.max || 5"
:tag-maxlength="useBackendData.validations?.project?.keywordName?.max || 16"
:label="i18n.t('project.new.step3.keywords')"
:rules="[maxLength()(useBackendData.validations?.project?.keywords?.max || 5), noDuplicated()(() => form.settings.keywords)]"
:rules="[maxLength()(useBackendData.validations?.project?.keywords?.max || 5), noDuplicated()(() => form.settings?.keywords)]"
/>
</ProjectSettingsSection>
<ProjectSettingsSection title="project.settings.tags.title" description="project.settings.tagsSub">
<InputCheckbox v-for="tag in Object.values(Tag)" :key="tag" v-model="form.settings.tags" :value="tag">
<template #label>
<IconMdiPuzzleOutline v-if="tag === Tag.ADDON" />
<IconMdiBookshelf v-else-if="tag === Tag.LIBRARY" />
<IconMdiLeaf v-else-if="tag === Tag.SUPPORTS_FOLIA" />
<span class="ml-1">{{ i18n.t("project.settings.tags." + tag + ".title") }}</span>
<Tooltip>
<template #content> {{ i18n.t("project.settings.tags." + tag + ".description") }} </template>
<IconMdiHelpCircleOutline class="ml-1 text-gray-500 dark:text-gray-400 text-sm" />
</Tooltip>
</template>
</InputCheckbox>
<template v-if="form.settings">
<InputCheckbox v-for="tag in Object.values(Tag)" :key="tag" v-model="form.settings.tags" :value="tag">
<template #label>
<IconMdiPuzzleOutline v-if="tag === Tag.ADDON" />
<IconMdiBookshelf v-else-if="tag === Tag.LIBRARY" />
<IconMdiLeaf v-else-if="tag === Tag.SUPPORTS_FOLIA" />
<span class="ml-1">{{ i18n.t("project.settings.tags." + tag + ".title") }}</span>
<Tooltip>
<template #content> {{ i18n.t("project.settings.tags." + tag + ".description") }} </template>
<IconMdiHelpCircleOutline class="ml-1 text-gray-500 dark:text-gray-400 text-sm" />
</Tooltip>
</template>
</InputCheckbox>
</template>
</ProjectSettingsSection>
<ProjectSettingsSection title="project.settings.license" description="project.settings.licenseSub">
<div class="flex md:gap-2 lt-md:flex-wrap">
<div class="basis-full" :md="isCustomLicense ? 'basis-4/12' : 'basis-6/12'">
<InputSelect v-model="form.settings.license.type" :values="useLicenseOptions" :label="i18n.t('project.settings.licenseType')" />
<InputSelect
v-if="form.settings"
v-model="form.settings.license.type"
:values="useLicenseOptions"
:label="i18n.t('project.settings.licenseType')"
/>
</div>
<div v-if="isCustomLicense" class="basis-full md:basis-8/12">
<InputText
v-if="form.settings"
v-model.trim="form.settings.license.name"
:label="i18n.t('project.settings.licenseCustom')"
:rules="[
@ -291,7 +320,7 @@ useHead(useSeo(i18n.t("project.settings.title") + " | " + props.project?.name, p
/>
</div>
<div v-if="!isUnspecifiedLicense" class="basis-full" :md="isCustomLicense ? 'basis-full' : 'basis-6/12'">
<InputText v-model.trim="form.settings.license.url" :label="i18n.t('project.settings.licenseUrl')" :rules="[validUrl()]" />
<InputText v-if="form.settings" v-model.trim="form.settings.license.url" :label="i18n.t('project.settings.licenseUrl')" :rules="[validUrl()]" />
</div>
</div>
</ProjectSettingsSection>
@ -342,7 +371,7 @@ useHead(useSeo(i18n.t("project.settings.title") + " | " + props.project?.name, p
</template>
<template #links>
<ProjectSettingsSection title="project.settings.links.title" description="project.settings.links.sub">
<ProjectLinksForm v-model="form.settings.links" />
<ProjectLinksForm v-if="form.settings" v-model="form.settings.links" />
</ProjectSettingsSection>
</template>
<template #management>

View File

@ -6,7 +6,7 @@ defineProps<{
project?: HangarProject;
}>();
const { data: version } = useDataLoader<HangarVersion>("version");
const { data: version } = useDataLoader("version");
definePageMeta({
dataLoader_version: true,