new project page is functional now!

This commit is contained in:
MiniDigger | Martin 2022-04-05 22:53:24 +02:00
parent 9768c730e5
commit 74d0394d46
7 changed files with 125 additions and 79 deletions

View File

@ -68,7 +68,7 @@ once QA has passed, the checkboxes can be removed and the page can be ~~striked
- new
- [x] fetch
- [x] layout
- [ ] functionality
- [x] functionality
- [ ] design
- [ ] qa
- notifications

View File

@ -1,5 +1,5 @@
<script lang="ts" setup>
import { computed } from "vue";
import { computed, ref } from "vue";
import Link from "~/components/design/Link.vue";
import Card from "~/components/design/Card.vue";
import { useSettingsStore } from "~/store/settings";
@ -17,23 +17,48 @@ const internalValue = computed({
set: (value) => emit("update:modelValue", value),
});
const activeStep = computed(() => props.steps.find((s) => s.value === internalValue.value));
const activeStepIndex = computed(() => props.steps.indexOf(activeStep.value as Step) + 1);
export interface Step {
value: string;
header: string;
}
const props = defineProps<{
modelValue: string;
steps: Step[];
buttonLangKey: string;
}>();
const activeStep = computed(() => props.steps.find((s) => s.value === internalValue.value));
const activeStepIndex = computed(() => props.steps.indexOf(activeStep.value as Step) + 1);
const canBack = ref(true);
const canNext = ref(true);
const showBack = ref(true);
const showNext = ref(true);
function back() {
if (canBack.value) {
internalValue.value = props.steps[activeStepIndex.value - 2].value;
}
}
function next() {
if (canNext.value) {
internalValue.value = props.steps[activeStepIndex.value].value;
}
}
function goto(step: Step) {
const idx = props.steps.indexOf(step);
if (idx >= activeStepIndex.value && canNext.value) {
next();
} else if (idx < activeStepIndex.value && canBack.value) {
back();
}
}
export interface Step {
value: string;
header: string;
}
</script>
<template>
<!-- todo stepper logic to prevent stepping thru out of order -->
<!-- todo style active, next and past step headers differently -->
<div>
<div class="w-full">
@ -47,7 +72,7 @@ const props = defineProps<{
:class="internalValue === step.value ? 'underline' : '!font-semibold'"
:href="'#' + step.value"
display="hidden md:inline"
@click.prevent="internalValue = step.value"
@click.prevent="goto(step)"
>
{{ step.header }}
</Link>
@ -64,9 +89,10 @@ const props = defineProps<{
<div v-for="step in steps" :key="step.value">
<slot v-if="internalValue === step.value" :name="step.value" />
</div>
<!-- todo next/back button -->
<Button size="medium" class="mt-2 mr-2">{{ i18n.t(buttonLangKey + activeStepIndex + ".continue") }}</Button>
<Button size="medium" class="mt-2">{{ i18n.t(buttonLangKey + activeStepIndex + ".back") }}</Button>
<Button v-if="showBack" :disable="canBack" size="medium" class="mt-2 mr-2" @click="back">{{
i18n.t(buttonLangKey + activeStepIndex + ".back")
}}</Button>
<Button v-if="showNext" :disable="canNext" size="medium" class="mt-2" @click="next">{{ i18n.t(buttonLangKey + activeStepIndex + ".continue") }}</Button>
</Card>
</div>
</div>

View File

@ -15,13 +15,24 @@ export interface Option {
text: string;
}
const props = defineProps<{
modelValue: object | string | boolean | number | null | undefined;
values: Option[];
disabled?: boolean;
label?: string;
errorMessages?: string[];
}>();
const props = withDefaults(
defineProps<{
modelValue: object | string | boolean | number | null;
values: Option[] | Record<string, any> | string[];
itemValue?: string;
itemText?: string;
disabled?: boolean;
label?: string;
errorMessages?: string[];
}>(),
{
modelValue: "",
itemValue: "value",
itemText: "text",
label: "",
errorMessages: () => [],
}
);
// TODO proper validation
const error = computed<boolean>(() => {
@ -29,12 +40,11 @@ const error = computed<boolean>(() => {
});
</script>
<!-- todo make fancy -->
<template>
<label class="relative flex" :class="{ filled: modelValue, error: error }">
<label class="relative flex" :class="{ filled: internalVal, error: error }">
<select v-model="internalVal" :disabled="disabled" :class="inputClasses">
<option v-for="val in values" :key="val.value" :value="val.value" class="dark:bg-[#191e28]">
{{ val.text }}
<option v-for="val in values" :key="val[itemValue] || val" :value="val[itemValue] || val" class="dark:bg-[#191e28]">
{{ val[itemText] || val }}
</option>
</select>
<floating-label :label="label" />

View File

@ -16,7 +16,6 @@ const props = defineProps<{
errorMessages?: string[];
counter?: boolean;
maxlength?: number;
id: string;
}>();
// TODO proper validation

View File

@ -60,16 +60,6 @@ const loading = reactive({
});
const isCustomLicense = computed(() => form.settings.license.type === "(custom)");
const licenses = computed<Option[]>(() =>
backendData.licenses.map<Option>((l) => {
return { value: l, text: l };
})
);
const categories = computed<Option[]>(() =>
backendData.visibleCategories.map<Option>((c) => {
return { value: c.apiName, text: i18n.t(c.title) };
})
);
watch(route, (val) => (selectedTab.value = val.hash.replace("#", "")), { deep: true });
watch(selectedTab, (val) => history.replaceState({}, "", route.path + "#" + val));
@ -219,7 +209,7 @@ useHead(
<Tabs v-model="selectedTab" :tabs="tabs">
<template #general>
<ProjectSettingsSection title="project.settings.category" description="project.settings.categorySub">
<InputSelect v-model="form.category" :values="categories" />
<InputSelect v-model="form.category" :values="backendData.categoryOptions" />
</ProjectSettingsSection>
<ProjectSettingsSection title="project.settings.description" description="project.settings.descriptionSub">
<InputText v-model="form.description" counter :maxlength="backendData.validations?.project?.desc?.max" />
@ -280,7 +270,7 @@ useHead(
<ProjectSettingsSection title="project.settings.license" description="project.settings.licenseSub">
<div class="flex">
<div class="basis-full" :md="isCustomLicense ? 'basis-4/12' : 'basis-6/12'">
<InputSelect v-model="form.settings.license.type" :values="licenses" :label="i18n.t('project.settings.licenseType')" />
<InputSelect v-model="form.settings.license.type" :values="backendData.licenseOptions" :label="i18n.t('project.settings.licenseType')" />
</div>
<div v-if="isCustomLicense" class="basis-full md:basis-8/12">
<InputText v-model.trim="form.settings.license.name" :label="i18n.t('project.settings.licenseCustom')" />

View File

@ -2,17 +2,15 @@
import { ProjectOwner, ProjectSettingsForm } from "hangar-internal";
import { ProjectCategory } from "~/types/enums";
import { handleRequestError } from "~/composables/useErrorHandling";
import { computed, Ref, ref, watch } from "vue";
import { computed, reactive, Ref, ref, watch } from "vue";
import { useInternalApi } from "~/composables/useApi";
import { useContext } from "vite-ssr/vue";
import PageTitle from "~/components/design/PageTitle.vue";
import { useBackendDataStore } from "~/store/backendData";
import { useI18n } from "vue-i18n";
import { useRoute, useRouter } from "vue-router";
import { useSeo } from "~/composables/useSeo";
import { useHead } from "@vueuse/head";
import Steps, { Step } from "~/components/design/Steps.vue";
import Card from "~/components/design/Card.vue";
import { useSettingsStore } from "~/store/settings";
import InputSelect from "~/components/ui/InputSelect.vue";
import InputText from "~/components/ui/InputText.vue";
@ -30,36 +28,34 @@ interface NewProjectForm extends ProjectSettingsForm {
const ctx = useContext();
const i18n = useI18n();
const store = useBackendDataStore();
const backendData = useBackendDataStore();
const router = useRouter();
const route = useRoute();
const settings = useSettingsStore();
const visibleCategories = store.visibleCategories;
const licenses = store.licenses;
// TODO move to useApi
const projectOwners = await useInternalApi<ProjectOwner[]>("projects/possibleOwners");
const error = ref("");
const nameErrors: Ref<string[]> = ref([]);
const projectCreationErrors: Ref<string[]> = ref([]);
const form: NewProjectForm = {
const projectLoading = ref(true);
const form = ref<NewProjectForm>({
category: ProjectCategory.ADMIN_TOOLS,
settings: {
license: {} as ProjectSettingsForm["settings"]["license"],
donation: {} as ProjectSettingsForm["settings"]["donation"],
keywords: [],
} as unknown as ProjectSettingsForm["settings"],
} as NewProjectForm;
const projectName = ref(form.name);
} as NewProjectForm);
form.ownerId = projectOwners[0].userId;
form.value.ownerId = projectOwners[0].userId;
const converter = {
const converter = ref({
bbCode: "",
markdown: "",
loading: false,
};
});
const isCustomLicense = computed(() => form.settings.license.type === "(custom)");
const isCustomLicense = computed(() => form.value.settings.license.type === "(custom)");
const selectedStep = ref("tos");
const steps: Step[] = [
@ -82,16 +78,16 @@ const bbCodeTabs: Tab[] = [
useHead(useSeo("New Project", null, route, null));
function convertBBCode() {
converter.loading = true;
converter.value.loading = true;
useInternalApi<string>("pages/convert-bbcode", false, "post", {
content: converter.bbCode,
content: converter.value.bbCode,
})
.then((markdown) => {
converter.markdown = markdown;
converter.value.markdown = markdown;
})
.catch((e) => handleRequestError(e, ctx, i18n))
.finally(() => {
converter.loading = false;
converter.value.loading = false;
});
}
@ -113,26 +109,38 @@ function createProject() {
});
}
const projectName = computed(() => form.value.name);
watch(projectName, (newName) => {
nameErrors.value = [];
if (!newName) {
error.value = "";
return;
}
useInternalApi("projects/validateName", false, "get", {
userId: form.ownerId,
userId: form.value.ownerId,
value: newName,
})
.then(() => {
error.value = "";
form.name = newName;
})
.catch((e) => {
if (!e.response?.data.isHangarApiException) {
return handleRequestError(e, ctx, i18n);
}
error.value = i18n.t(e.response?.data.message);
});
}).catch((e) => {
if (!e.response?.data.isHangarApiException) {
return handleRequestError(e, ctx, i18n);
}
nameErrors.value.push(i18n.t(e.response?.data.message));
});
});
function retry() {
if (!form.value.pageContent) {
form.value.pageContent = "# " + form.value.name + " \nWelcome to your new project!";
}
useInternalApi<string>("projects/create", true, "post", form.value)
.then((url) => {
router.push(url);
})
.catch((err) => {
projectCreationErrors.value.push(err.response.data);
})
.finally(() => {
projectLoading.value = false;
});
}
</script>
<!-- todo: rules, icon -->
@ -145,14 +153,14 @@ watch(projectName, (newName) => {
<template #basic>
<div class="flex flex-wrap">
<div class="basis-full md:basis-6/12">
<InputSelect v-model="form.ownerId" :values="projectOwners" :label="i18n.t('project.new.step2.userSelect')" />
<InputSelect v-model="form.ownerId" :values="projectOwners" item-value="id" item-text="name" :label="i18n.t('project.new.step2.userSelect')" />
</div>
<div class="basis-full md:basis-6/12">
<InputText v-model.trim="form.name" :error-messages="nameErrors" :label="i18n.t('project.new.step2.projectName')" />
</div>
<div class="basis-full md:basis-8/12"><InputText v-model.trim="form.description" :label="i18n.t('project.new.step2.projectSummary')" /></div>
<div class="basis-full md:basis-4/12">
<InputSelect v-model="form.category" :values="visibleCategories" :label="i18n.t('project.new.step2.projectCategory')" />
<InputSelect v-model="form.category" :values="backendData.categoryOptions" :label="i18n.t('project.new.step2.projectCategory')" />
</div>
</div>
</template>
@ -175,7 +183,7 @@ watch(projectName, (newName) => {
</div>
<div class="flex flex-wrap">
<div class="basis-full" :md="isCustomLicense ? 'basis-4/12' : 'basis-6/12'">
<InputSelect v-model="form.settings.license.type" :values="licenses" :label="i18n.t('project.new.step3.type')" />
<InputSelect v-model="form.settings.license.type" :values="backendData.licenseOptions" :label="i18n.t('project.new.step3.type')" />
</div>
<div v-if="isCustomLicense" class="basis-full md:basis-8/12">
<InputText v-model.trim="form.settings.license.name" :label="i18n.t('project.new.step3.customName')" />
@ -207,7 +215,7 @@ watch(projectName, (newName) => {
<InputTextarea v-model="converter.markdown" :rows="6" :label="i18n.t('project.new.step4.convertLabels.output')" />
</template>
<template #preview>
<Button block color="primary" class="my-2" :disabled="form.pageContent === converter.markdown" @click="saveAsHomePage">
<Button block color="primary" class="my-2" :disabled="form.pageContent === converter.markdown" @click="form.pageContent = converter.markdown">
<IconMdiContentSave />
{{ i18n.t("project.new.step4.saveAsHomePage") }}
</Button>
@ -226,16 +234,20 @@ watch(projectName, (newName) => {
<template #finishing>
<!-- todo loader -->
<!--<v-progress-circular v-if="projectLoading" indeterminate color="red" size="50" />-->
<span v-if="projectLoading">Loading....</span>
<div v-if="!projectError" class="text-h5 mt-2">
{{ i18n.t("project.new.step5.text") }}
</div>
<template v-else>
<span v-if="projectLoading">
Loading....
<Button @click="retry">button go brrrr</Button>
</span>
<template v-else-if="projectCreationErrors && projectCreationErrors.length > 0">
<div class="text-lg mt-2">
{{ i18n.t("project.new.error.create") }}
{{ projectCreationErrors }}
</div>
<Button @click="retry"> Retry </Button>
</template>
<div v-else class="text-h5 mt-2">
{{ i18n.t("project.new.step5.text") }}
</div>
</template>
</Steps>
</template>

View File

@ -6,6 +6,8 @@ import { NamedPermission, Platform, ProjectCategory, Prompt } from "~/types/enum
import { Announcement as AnnouncementObject, Announcement, IPermission, Role } from "hangar-api";
import { fetchIfNeeded, useInternalApi } from "~/composables/useApi";
import { Option } from "~/components/ui/InputSelect.vue";
import { useI18n } from "vue-i18n";
interface Validation {
regex?: string;
@ -97,6 +99,11 @@ export const useBackendDataStore = defineStore("backendData", () => {
const visibleCategories = computed(() => [...(projectCategories.value?.values() || [])].filter((value) => value.visible));
const visiblePlatforms = computed(() => (platforms.value ? [...platforms.value.values()].filter((value) => value.visible) : []));
const licenseOptions = computed<Option[]>(() => licenses.value.map<Option>((l) => ({ value: l, text: l })));
const categoryOptions = computed<Option[]>(() =>
visibleCategories.value.map<Option>((c) => ({ value: c.apiName, text: useI18n({ useScope: "global" }).t(c.title) }))
);
return {
projectCategories,
permissions,
@ -112,6 +119,8 @@ export const useBackendDataStore = defineStore("backendData", () => {
initBackendData,
visibleCategories,
visiblePlatforms,
licenseOptions,
categoryOptions,
};
});