implement validation framework, validate new project page

This commit is contained in:
MiniDigger | Martin 2022-04-15 17:43:22 +02:00
parent 18ae7783d6
commit 58a2eb77c3
16 changed files with 247 additions and 82 deletions

View File

@ -19,7 +19,7 @@ Stuff that needs to be done before I consider this a successful POC
- [x] date formatting needs to go thru i18n
- [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
- [ ] validation of forms/inputs etc (groundwork is done, just needs to be added everywhere...)
- [ ] check that we have loading states everywhere, on like buttons and whatever
- [x] add header calls to all pages

View File

@ -89,6 +89,5 @@
"vite-plugin-pwa": "^0.11.13",
"vite-plugin-vue-layouts": "^0.6.0",
"vite-plugin-windicss": "^1.8.3"
},
"//": "VITE NEEDS TO BE 2.7 FOR NOW! https://github.com/frandiox/vite-ssr/discussions/143"
}
}

View File

@ -1,16 +1,18 @@
<script lang="ts" setup>
import Popper from "vue3-popper";
import { computed } from "vue";
import { ErrorObject } from "@vuelidate/core";
import { computed, Ref } from "vue";
import { isErrorObject } from "~/composables/useValidationHelpers";
const props = defineProps<{
errorMessages?: string[];
errorMessages?: string[] | ErrorObject[];
}>();
const formattedError = computed<string>(() => {
const formattedError = computed<string | Ref<string>>(() => {
if (!props.errorMessages || props.errorMessages.length === 0) {
return "";
}
return props.errorMessages[0];
return isErrorObject(props.errorMessages[0]) ? props.errorMessages[0].$message : props.errorMessages[0];
});
const hasError = computed<boolean>(() => {

View File

@ -1,24 +1,30 @@
<script setup lang="ts">
import { computed } from "vue";
import { useValidation } from "~/composables/useValidationHelpers";
import { ValidationRule } from "@vuelidate/core";
const emit = defineEmits<{
(e: "update:modelValue", value: boolean | boolean[]): void;
}>();
const internalVal = computed({
get: () => props.modelValue,
set: (v) => emit("update:modelValue", v),
set: (val) => emit("update:modelValue", val),
});
const props = defineProps<{
modelValue: boolean | boolean[];
label?: string;
disabled?: boolean;
errorMessages?: string[];
rules?: ValidationRule<string | undefined>[];
}>();
const { v, errors, hasError } = useValidation(props.label, props.rules, internalVal, props.errorMessages);
</script>
<template>
<label class="group relative pl-30px customCheckboxContainer w-max" :cursor="disabled ? 'auto' : 'pointer'">
<template v-if="props.label">{{ props.label }}</template>
<input v-model="internalVal" type="checkbox" class="hidden" v-bind="$attrs" :disabled="disabled" />
<input v-model="internalVal" type="checkbox" class="hidden" v-bind="$attrs" :disabled="disabled" @blur="v.$touch()" />
<span
class="absolute top-5px left-0 h-15px w-15px rounded bg-gray-300"
after="absolute hidden content-DEFAULT top-1px left-5px border-solid w-6px h-12px border-r-3px border-b-3px border-white"

View File

@ -1,5 +1,7 @@
<script lang="ts" setup>
import { computed } from "vue";
import { useValidation } from "~/composables/useValidationHelpers";
import { ValidationRule } from "@vuelidate/core";
const emit = defineEmits<{
(e: "update:modelValue", date: string): void;
@ -10,10 +12,16 @@ const date = computed({
});
const props = defineProps<{
modelValue: string;
label?: string;
disabled?: boolean;
errorMessages?: string[];
rules?: ValidationRule<string | undefined>[];
}>();
const { v, errors, hasError } = useValidation(props.label, props.rules, date, props.errorMessages);
</script>
<template>
<!-- todo make fancy -->
<input v-model="date" type="date" v-bind="$attrs" />
<input v-model="date" type="date" v-bind="$attrs" :disabled="disabled" />
</template>

View File

@ -1,5 +1,7 @@
<script lang="ts" setup>
import { computed } from "vue";
import { useValidation } from "~/composables/useValidationHelpers";
import { ValidationRule } from "@vuelidate/core";
const emit = defineEmits<{
(e: "update:modelValue", file: string): void;
@ -10,11 +12,17 @@ const file = computed({
});
const props = defineProps<{
modelValue: string;
label?: string;
disabled?: boolean;
showSize?: boolean;
errorMessages?: string[];
rules?: ValidationRule<string | undefined>[];
}>();
const { v, errors, hasError } = useValidation(props.label, props.rules, file, props.errorMessages);
</script>
<template>
<!-- todo make fancy, implement functionality -->
<input type="file" v-bind="$attrs" />
<input type="file" v-bind="$attrs" :disabled="disabled" />
</template>

View File

@ -1,23 +1,29 @@
<script setup lang="ts">
import { computed } from "vue";
import { useValidation } from "~/composables/useValidationHelpers";
import { ValidationRule } from "@vuelidate/core";
const emit = defineEmits<{
(e: "update:modelValue", value?: string): void;
}>();
const internalVal = computed({
get: () => props.modelValue,
set: (v) => emit("update:modelValue", v),
set: (val) => emit("update:modelValue", val),
});
const props = defineProps<{
modelValue?: string;
label?: string;
errorMessages?: string[];
rules?: ValidationRule<string | undefined>[];
}>();
const { v, errors, hasError } = useValidation(props.label, props.rules, internalVal, props.errorMessages);
</script>
<template>
<label class="group relative cursor-pointer pl-30px customCheckboxContainer w-max">
<template v-if="props.label">{{ props.label }}</template>
<input v-model="internalVal" type="radio" class="hidden" v-bind="$attrs" />
<input v-model="internalVal" type="radio" class="hidden" v-bind="$attrs" @blur="v.$touch()" />
<span
class="absolute top-5px left-0 h-15px w-15px rounded-full bg-gray-300"
after="absolute hidden content-DEFAULT top-1px left-5px border-solid w-6px h-12px border-r-3px border-b-3px border-white rounded-full"

View File

@ -2,13 +2,15 @@
import { computed } from "vue";
import { FloatingLabel, inputClasses } from "~/composables/useInputHelper";
import ErrorTooltip from "~/components/design/ErrorTooltip.vue";
import { useValidation } from "~/composables/useValidationHelpers";
import { ValidationRule } from "@vuelidate/core";
const emit = defineEmits<{
(e: "update:modelValue", value: object | string | boolean | number | null | undefined): void;
}>();
const internalVal = computed({
get: () => props.modelValue,
set: (v) => emit("update:modelValue", v),
set: (val) => emit("update:modelValue", val),
});
export interface Option {
@ -25,6 +27,7 @@ const props = withDefaults(
disabled?: boolean;
label?: string;
errorMessages?: string[];
rules?: ValidationRule<string | undefined>[];
}>(),
{
modelValue: "",
@ -32,19 +35,17 @@ const props = withDefaults(
itemText: "text",
label: "",
errorMessages: () => [],
rules: () => [],
}
);
// TODO proper validation
const error = computed<boolean>(() => {
return props.errorMessages ? props.errorMessages.length > 0 : false;
});
const { v, errors, hasError } = useValidation(props.label, props.rules, internalVal, props.errorMessages);
</script>
<template>
<ErrorTooltip :error-messages="errorMessages" class="w-full">
<label class="relative flex" :class="{ filled: internalVal, error: error }">
<select v-model="internalVal" :disabled="disabled" :class="inputClasses">
<ErrorTooltip :error-messages="errors" class="w-full">
<label class="relative flex" :class="{ filled: internalVal, error: hasError }">
<select v-model="internalVal" :disabled="disabled" :class="inputClasses" @blur="v.$touch()">
<option v-for="val in values" :key="val[itemValue] || val" :value="val[itemValue] || val" class="dark:bg-[#191e28]">
{{ val[itemText] || val }}
</option>

View File

@ -2,6 +2,8 @@
import { computed, ref } from "vue";
import { FloatingLabel, inputClasses } from "~/composables/useInputHelper";
import ErrorTooltip from "~/components/design/ErrorTooltip.vue";
import { useValidation } from "~/composables/useValidationHelpers";
import { ValidationRule } from "@vuelidate/core";
const tag = ref<string>("");
const emit = defineEmits<{
@ -14,20 +16,19 @@ const tags = computed({
const props = defineProps<{
modelValue: string[];
label?: string;
errorMessages?: string[];
counter?: boolean;
maxlength?: number;
errorMessages?: string[];
rules?: ValidationRule<string | undefined>[];
}>();
// TODO proper validation
const error = computed<boolean>(() => {
return props.errorMessages ? props.errorMessages.length > 0 : false;
});
const { v, errors, hasError } = useValidation(props.label, props.rules, tags, props.errorMessages);
if (!tags.value) tags.value = [];
function remove(t: string) {
tags.value = tags.value.filter((v) => v != t);
v.value.$touch();
}
function add() {
@ -39,14 +40,14 @@ function add() {
</script>
<template>
<ErrorTooltip :error-messages="errorMessages" class="w-full">
<div class="relative flex items-center pointer-events-none text-left" :class="{ filled: (modelValue && modelValue.length) || tag, error: error }">
<ErrorTooltip :error-messages="errors" class="w-full">
<div class="relative flex items-center pointer-events-none text-left" :class="{ filled: (modelValue && modelValue.length) || tag, error: hasError }">
<div :class="inputClasses" class="flex">
<span v-for="t in tags" :key="t" class="bg-primary-light-400 rounded-4xl px-1 py-1 mx-1 h-30px inline-flex items-center" dark="text-black">
{{ t }}
<button class="text-gray-400 ml-1 inline-flex pointer-events-auto" hover="text-gray-500" @click="remove(t)"><icon-mdi-close-circle /></button>
</span>
<input v-model="tag" type="text" class="pointer-events-auto outline-none bg-transparent flex-grow" @keydown.enter="add" />
<input v-model="tag" type="text" class="pointer-events-auto outline-none bg-transparent flex-grow" @keydown.enter="add" @blur="v.$touch()" />
<floating-label :label="label" />
</div>
<span v-if="counter && maxlength">{{ tags?.length || 0 }}/{{ maxlength }}</span>

View File

@ -2,32 +2,32 @@
import { computed } from "vue";
import { FloatingLabel, inputClasses } from "~/composables/useInputHelper";
import ErrorTooltip from "~/components/design/ErrorTooltip.vue";
import { ValidationRule } from "@vuelidate/core";
import { useValidation } from "~/composables/useValidationHelpers";
const emit = defineEmits<{
(e: "update:modelValue", value?: string): void;
}>();
const value = computed({
get: () => props.modelValue,
set: (v) => emit("update:modelValue", v),
set: (val) => emit("update:modelValue", val),
});
const props = defineProps<{
modelValue?: string;
label?: string;
errorMessages?: string[];
counter?: boolean;
maxlength?: number;
errorMessages?: string[];
rules?: ValidationRule<string | undefined>[];
}>();
// TODO proper validation
const error = computed<boolean>(() => {
return props.errorMessages ? props.errorMessages.length > 0 : false;
});
const { v, errors, hasError } = useValidation(props.label, props.rules, value, props.errorMessages);
</script>
<template>
<ErrorTooltip :error-messages="errorMessages" class="w-full">
<label class="relative flex" :class="{ filled: modelValue, error: error }">
<input v-model="value" type="text" :class="inputClasses" v-bind="$attrs" :maxlength="maxlength" />
<ErrorTooltip :error-messages="errors" class="w-full">
<label class="relative flex" :class="{ filled: modelValue, error: hasError }">
<input v-model="value" type="text" :class="inputClasses" v-bind="$attrs" :maxlength="maxlength" @blur="v.$touch()" />
<span v-if="counter && maxlength" class="inline-flex items-center ml-2">{{ value?.length || 0 }}/{{ maxlength }}</span>
<span v-else-if="counter">{{ value?.length || 0 }}</span>
<floating-label :label="label" /> </label

View File

@ -2,31 +2,32 @@
import { computed } from "vue";
import { FloatingLabel, inputClasses } from "~/composables/useInputHelper";
import ErrorTooltip from "~/components/design/ErrorTooltip.vue";
import { useValidation } from "~/composables/useValidationHelpers";
import { ValidationRule } from "@vuelidate/core";
const emit = defineEmits<{
(e: "update:modelValue", value: string): void;
}>();
const value = computed({
get: () => props.modelValue,
set: (v) => emit("update:modelValue", v),
set: (val) => emit("update:modelValue", val),
});
const props = defineProps<{
modelValue: string;
label?: string;
errorMessages?: string[];
counter?: boolean;
maxlength?: number;
errorMessages?: string[];
rules?: ValidationRule<string | undefined>[];
}>();
// TODO proper validation
const error = computed<boolean>(() => {
return props.errorMessages ? props.errorMessages.length > 0 : false;
});
const { v, errors, hasError } = useValidation(props.label, props.rules, value, props.errorMessages);
</script>
<template>
<ErrorTooltip :error-messages="errorMessages" class="w-full">
<label class="relative flex" :class="{ filled: modelValue, error: error }">
<textarea v-model="value" :class="inputClasses" v-bind="$attrs" :maxlength="maxlength" />
<ErrorTooltip :error-messages="errors" class="w-full">
<label class="relative flex" :class="{ filled: modelValue, error: hasError }">
<textarea v-model="value" :class="inputClasses" v-bind="$attrs" :maxlength="maxlength" @blur="v.$touch()" />
<floating-label :label="label" />
</label>
<div v-if="counter" class="mt-1 mb-2">

View File

@ -22,6 +22,7 @@ export const FloatingLabel = defineComponent({
label: {
type: String,
required: false,
default: "",
},
},
render() {

View File

@ -0,0 +1,100 @@
import { ErrorObject, useVuelidate, ValidationRule } from "@vuelidate/core";
import { computed, Ref } from "vue";
import * as validators from "@vuelidate/validators";
import { createI18nMessage, helpers, ValidatorWrapper } from "@vuelidate/validators";
import { I18n } from "~/i18n";
import { useInternalApi } from "~/composables/useApi";
export function isErrorObject(errorObject: string | ErrorObject): errorObject is ErrorObject {
return (<ErrorObject>errorObject).$message !== undefined;
}
export function constructValidators<T>(rules: ValidationRule<T | undefined>[] | undefined, name: string) {
// no clue why this cast is needed
return rules ? { [name]: rules } : { [name]: {} };
}
export function useValidation<T>(
name: string | undefined,
rules: ValidationRule<T | undefined>[] | undefined,
state: Ref,
errorMessages?: string[] | undefined
) {
const n = name || "val";
const v = useVuelidate(constructValidators(rules, n), { [n]: state });
const errors = computed(() => {
const e = [];
if (errorMessages) {
e.push(...errorMessages);
}
if (v.value.$errors) {
e.push(...v.value.$errors);
}
return e;
});
const hasError = computed(() => (errorMessages && errorMessages.length) || v.value.$error);
return { v, errors, hasError };
}
export const withI18nMessage = <T extends ValidationRule | ValidatorWrapper>(validator: T, overrideMsg?: string): T => {
return createI18nMessage({
t: (_, params) => {
let msg = "validation." + params.type;
const response = params.response;
if (response && response.$message) {
msg = response.$message;
}
if (overrideMsg) {
msg = overrideMsg;
}
console.log("translate", msg, overrideMsg, params);
return I18n.value.global.t(msg, params);
},
})(validator, { withArguments: true });
};
function withOverrideMessage<T extends ValidationRule | ValidatorWrapper>(rule: T) {
return (overrideMsg?: string) => {
return withI18nMessage(rule, overrideMsg);
};
}
// basic
export const required = withOverrideMessage(validators.required);
export const requiredIf = withOverrideMessage(validators.requiredIf);
export const minLength = withOverrideMessage(validators.minLength);
export const maxLength = withOverrideMessage(validators.maxLength);
export const url = withOverrideMessage(validators.url);
// custom
export const validName = withOverrideMessage(
(ownerId: string) =>
helpers.withParams(
{ ownerId, type: "validName" },
helpers.withAsync(async (value: string) => {
if (!helpers.req(value)) {
return { $valid: true };
}
try {
await useInternalApi("projects/validateName", false, "get", {
userId: ownerId,
value: value,
});
return { $valid: true };
} catch (e) {
return !e.response?.data.isHangarApiException ? { $valid: false } : { $valid: false, $message: e.response?.data.message };
}
})
) as ValidationRule<{ ownerId: string }>
);
export const pattern = withOverrideMessage(
(regex: string) =>
helpers.withParams({ regex, type: "pattern" }, (value: string) => {
if (!helpers.req(value)) {
return { $valid: true };
}
return { $valid: new RegExp(regex).test(value) };
}) as ValidationRule<{ regex: string }>
);

View File

@ -2,6 +2,7 @@ import type { App } from "vue";
import { createI18n } from "vue-i18n";
import { DATE_FORMATS } from "./date-formats";
import { DEFAULT_LOCALE, SUPPORTED_LOCALES } from "./locales";
import { ref } from "vue";
export { DEFAULT_LOCALE, SUPPORTED_LOCALES, SUPPORTED_LANGUAGES, extractLocaleFromPath } from "./locales";
@ -26,6 +27,8 @@ export async function loadAsyncLanguage(i18n: any, locale = DEFAULT_LOCALE) {
}
}
export const I18n = ref();
export async function installI18n(app: App, locale = "") {
locale = SUPPORTED_LOCALES.includes(locale) ? locale : DEFAULT_LOCALE;
try {
@ -44,6 +47,7 @@ export async function installI18n(app: App, locale = "") {
});
app.use(i18n);
I18n.value = i18n;
} catch (error) {
console.log("installI18n error", error);
@ -55,5 +59,6 @@ export async function installI18n(app: App, locale = "") {
datetimeFormats: DATE_FORMATS,
});
app.use(i18n);
I18n.value = i18n;
}
}

View File

@ -863,11 +863,12 @@
"hangarAuth": "This only change the locale for your current browser (as a cookie). Click here to change your lang on paper auth for all paper services"
},
"validation": {
"required": "{0} is required",
"maxLength": "Maximum length is {0}",
"required": "{property} is required",
"requiredIf": "{property} is required",
"maxLength": "Maximum length is {max}",
"minLength": "Minimum length is {0}",
"invalidFormat": "{0} is invalid",
"invalidUrl": "Invalid URL format"
"pattern": "{property} is invalid",
"url": "Invalid URL format"
},
"prompts": {
"confirm": "Got it!",

View File

@ -2,7 +2,7 @@
import { ProjectOwner, ProjectSettingsForm } from "hangar-internal";
import { ProjectCategory } from "~/types/enums";
import { handleRequestError } from "~/composables/useErrorHandling";
import { computed, reactive, Ref, ref, watch } from "vue";
import { computed, Ref, ref } from "vue";
import { useInternalApi } from "~/composables/useApi";
import { useContext } from "vite-ssr/vue";
import { useBackendDataStore } from "~/store/backendData";
@ -19,6 +19,8 @@ import Tabs, { Tab } from "~/components/design/Tabs.vue";
import Button from "~/components/design/Button.vue";
import Markdown from "~/components/Markdown.vue";
import InputTextarea from "~/components/ui/InputTextarea.vue";
import { useVuelidate } from "@vuelidate/core";
import { required, maxLength, validName, pattern, url, requiredIf } from "~/composables/useValidationHelpers";
interface NewProjectForm extends ProjectSettingsForm {
ownerId: ProjectOwner["userId"];
@ -35,7 +37,6 @@ const settings = useSettingsStore();
// TODO move to useApi
const projectOwners = await useInternalApi<ProjectOwner[]>("projects/possibleOwners");
const nameErrors: Ref<string[]> = ref([]);
const projectCreationErrors: Ref<string[]> = ref([]);
const projectLoading = ref(true);
const form = ref<NewProjectForm>({
@ -55,6 +56,13 @@ const converter = ref({
loading: false,
});
const rules = {
name: {
required,
},
};
const v = useVuelidate(rules, form);
const isCustomLicense = computed(() => form.value.settings.license.type === "(custom)");
const selectedStep = ref("tos");
@ -109,23 +117,6 @@ function createProject() {
});
}
const projectName = computed(() => form.value.name);
watch(projectName, (newName) => {
nameErrors.value = [];
if (!newName) {
return;
}
useInternalApi("projects/validateName", false, "get", {
userId: form.value.ownerId,
value: newName,
}).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!";
@ -155,14 +146,37 @@ function retry() {
<!-- todo i18n -->
<p class="basis-full mb-4">Please provide the basic settings for this project</p>
<div class="basis-full md:basis-6/12">
<InputSelect v-model="form.ownerId" :values="projectOwners" item-value="id" item-text="name" :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')"
:rules="[required()]"
/>
</div>
<div class="basis-full md:basis-6/12 mt-4 md:mt-0">
<InputText v-model.trim="form.name" :error-messages="nameErrors" :label="i18n.t('project.new.step2.projectName')" />
<InputText
v-model.trim="form.name"
:label="i18n.t('project.new.step2.projectName')"
:rules="[
required(),
maxLength()(backendData.validations.project.name.max),
pattern()(backendData.validations.project.name.regex),
validName()(form.ownerId),
]"
/>
</div>
<div class="basis-full md:basis-8/12 mt-4">
<InputText v-model.trim="form.description" :label="i18n.t('project.new.step2.projectSummary')" :rules="[required()]" />
</div>
<div class="basis-full md:basis-8/12 mt-4"><InputText v-model.trim="form.description" :label="i18n.t('project.new.step2.projectSummary')" /></div>
<div class="basis-full md:basis-4/12 mt-4">
<InputSelect v-model="form.category" :values="backendData.categoryOptions" :label="i18n.t('project.new.step2.projectCategory')" />
<InputSelect
v-model="form.category"
:values="backendData.categoryOptions"
:label="i18n.t('project.new.step2.projectCategory')"
:rules="[required()]"
/>
</div>
</div>
</template>
@ -175,10 +189,10 @@ function retry() {
<hr />
</div>
<div class="flex flex-wrap">
<div class="basis-full mt-4"><InputText v-model.trim="form.settings.homepage" :label="i18n.t('project.new.step3.homepage')" /></div>
<div class="basis-full mt-4"><InputText v-model.trim="form.settings.issues" :label="i18n.t('project.new.step3.issues')" /></div>
<div class="basis-full mt-4"><InputText v-model.trim="form.settings.source" :label="i18n.t('project.new.step3.source')" /></div>
<div class="basis-full mt-4"><InputText v-model.trim="form.settings.support" :label="i18n.t('project.new.step3.support')" /></div>
<div class="basis-full mt-4"><InputText v-model.trim="form.settings.homepage" :label="i18n.t('project.new.step3.homepage')" :rules="[url()]" /></div>
<div class="basis-full mt-4"><InputText v-model.trim="form.settings.issues" :label="i18n.t('project.new.step3.issues')" :rules="[url()]" /></div>
<div class="basis-full mt-4"><InputText v-model.trim="form.settings.source" :label="i18n.t('project.new.step3.source')" :rules="[url()]" /></div>
<div class="basis-full mt-4"><InputText v-model.trim="form.settings.support" :label="i18n.t('project.new.step3.support')" :rules="[url()]" /></div>
</div>
<div class="text-lg mt-6">
<IconMdiLicense />
@ -187,13 +201,18 @@ function retry() {
</div>
<div class="flex flex-wrap">
<div class="basis-full mt-4" :md="isCustomLicense ? 'basis-4/12' : 'basis-6/12'">
<InputSelect v-model="form.settings.license.type" :values="backendData.licenseOptions" :label="i18n.t('project.new.step3.type')" />
<InputSelect
v-model="form.settings.license.type"
:values="backendData.licenseOptions"
:label="i18n.t('project.new.step3.type')"
:rules="[required()]"
/>
</div>
<div v-if="isCustomLicense" class="basis-full md:basis-8/12 mt-4">
<InputText v-model.trim="form.settings.license.name" :label="i18n.t('project.new.step3.customName')" />
<InputText v-model.trim="form.settings.license.name" :label="i18n.t('project.new.step3.customName')" :rules="[requiredIf()(isCustomLicense)]" />
</div>
<div class="basis-full mt-4" :md="isCustomLicense ? 'basis-full' : 'basis-6/12'">
<InputText v-model.trim="form.settings.license.url" :label="i18n.t('project.new.step3.url')" />
<InputText v-model.trim="form.settings.license.url" :label="i18n.t('project.new.step3.url')" :rules="[url()]" />
</div>
</div>
<div class="text-lg mt-6">
@ -202,7 +221,14 @@ function retry() {
<hr />
</div>
<div class="flex">
<div class="mt-4 basis-full"><InputTag v-model="form.settings.keywords" :label="i18n.t('project.new.step3.keywords')" /></div>
<div class="mt-4 basis-full">
<InputTag
v-model="form.settings.keywords"
:label="i18n.t('project.new.step3.keywords')"
:rules="[maxLength()(backendData.validations.project.keywords.max)]"
:maxlength="backendData.validations.project.keywords.max"
/>
</div>
</div>
</template>
<template #import>