refactor: use useForm in some views

This commit is contained in:
axolotle 2024-07-08 16:16:02 +02:00
parent a22ee09344
commit 026ccb68ed
8 changed files with 465 additions and 569 deletions

View file

@ -1,12 +1,13 @@
<script setup lang="ts">
import { useVuelidate } from '@vuelidate/core'
import { reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter, type LocationQueryValue } from 'vue-router'
import { useStore } from 'vuex'
import { useForm } from '@/composables/form'
import { alphalownumdot_, minLength, required } from '@/helpers/validators'
import { useStoreGetters } from '@/store/utils'
import type { FieldProps, FormFieldDict } from '@/types/form'
const props = withDefaults(
defineProps<{
@ -22,41 +23,40 @@ const store = useStore()
const router = useRouter()
const { installed } = useStoreGetters()
const serverError = ref('')
const form = reactive({
type Form = typeof form.value
const form = ref({
username: '',
password: '',
})
const v$ = useVuelidate(
{
username: { required, alphalownumdot_ },
password: { required, passwordLenght: minLength(4) },
},
form,
)
console.log(v$.value)
const fields = {
const fields = reactive({
username: {
component: 'InputItem',
label: t('user_username'),
rules: { required, alphalownumdot_ },
props: {
id: 'username',
autocomplete: 'username',
},
},
} satisfies FieldProps<'InputItem', Form['username']>,
password: {
component: 'InputItem',
label: t('password'),
rules: { required, passwordLenght: minLength(4) },
props: {
id: 'password',
type: 'password',
autocomplete: 'current-password',
},
},
}
} satisfies FieldProps<'InputItem', Form['password']>,
} satisfies FormFieldDict<Form>)
function login() {
const credentials = [form.username, form.password].join(':')
const { v, onSubmit } = useForm(form, fields)
const onLogin = onSubmit((onError) => {
const { username, password } = form.value
const credentials = [username, password].join(':')
store
.dispatch('LOGIN', credentials)
.then(() => {
@ -70,41 +70,27 @@ function login() {
)
}
})
.catch((err) => {
if (err.name !== 'APIUnauthorizedError') throw err
serverError.value = t('wrong_password_or_username')
})
}
.catch((err) => onError(err, t('wrong_password_or_username')))
})
</script>
<template>
<CardForm
:title="t('login')"
id="login-form"
v-model="form"
:fields="fields"
icon="lock"
:validation="v$"
:server-error="serverError"
@submit.prevent="login"
:title="t('login')"
:validations="v"
@submit="onLogin"
>
<!-- ADMIN USERNAME -->
<FormField
v-bind="fields.username"
v-model="form.username"
:validation="v$.username"
/>
<!-- ADMIN PASSWORD -->
<FormField
v-bind="fields.password"
v-model="form.password"
:validation="v$.password"
/>
<template #buttons>
<!-- FIXME should we remove the disabled state? -->
<BButton
type="submit"
variant="success"
:disabled="!installed"
form="ynh-form"
form="login-form"
>
{{ t('login') }}
</BButton>

View file

@ -1,10 +1,12 @@
<script setup lang="ts">
import { useVuelidate } from '@vuelidate/core'
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import api from '@/api'
import { APIBadRequestError } from '@/api/errors'
import { useForm } from '@/composables/form'
import { useAutoModal } from '@/composables/useAutoModal'
import { asUnreffed } from '@/helpers/commons'
import {
alphalownumdot_,
minLength,
@ -13,92 +15,99 @@ import {
sameAs,
} from '@/helpers/validators'
import { formatFormData } from '@/helpers/yunohostArguments'
import type { FieldProps, FormFieldDict } from '@/types/form'
import LoginView from '@/views/LoginView.vue'
import { DomainForm } from '@/views/_partials'
const { t } = useI18n()
const modalConfirm = useAutoModal()
const step = ref('start')
type Steps = 'start' | 'domain' | 'user' | 'rootfsspace-error' | 'login'
const step = ref<Steps>('start')
const serverError = ref('')
const domain = ref(undefined)
const domain = ref('')
const dyndns_recovery_password = ref('')
const form = reactive({
type Form = typeof form.value
const form = ref({
username: '',
fullname: '',
password: '',
confirmation: '',
})
const rules = computed(() => ({
username: { required, alphalownumdot_ },
fullname: { required, name },
password: { required, passwordLenght: minLength(8) },
confirmation: { required, passwordMatch: sameAs(form.password) },
}))
const v$ = useVuelidate(rules, form)
const fields = reactive({
// FIXME satisfies FormFieldDict but not for CardForm?
alert: {
component: 'ReadOnlyAlertItem',
props: { label: t('postinstall.user.first_user_help'), type: 'info' },
} satisfies FieldProps<'ReadOnlyAlertItem'>,
const fields = {
username: {
component: 'InputItem',
label: t('user_username'),
props: {
id: 'username',
placeholder: t('placeholder.username'),
},
},
rules: { required, alphalownumdot_ },
props: { id: 'username', placeholder: t('placeholder.username') },
} satisfies FieldProps<'InputItem', Form['username']>,
fullname: {
component: 'InputItem',
label: t('user_fullname'),
props: {
id: 'fullname',
placeholder: t('placeholder.fullname'),
},
},
rules: { required, name },
props: { id: 'fullname', placeholder: t('placeholder.fullname') },
} satisfies FieldProps<'InputItem', Form['fullname']>,
password: {
component: 'InputItem',
label: t('password'),
description: t('good_practices_about_admin_password'),
descriptionVariant: 'warning',
rules: { required, passwordLenght: minLength(8) },
props: { id: 'password', placeholder: '••••••••', type: 'password' },
},
} satisfies FieldProps<'InputItem', Form['password']>,
confirmation: {
component: 'InputItem',
label: t('password_confirmation'),
props: {
id: 'confirmation',
placeholder: '••••••••',
type: 'password',
},
},
}
rules: asUnreffed(
computed(() => ({
required,
passwordMatch: sameAs(form.value.password),
})),
),
props: { id: 'confirmation', placeholder: '••••••••', type: 'password' },
} satisfies FieldProps<'InputItem', Form['confirmation']>,
} satisfies FormFieldDict<Form>)
function goToStep(step_) {
const { v, onSubmit, serverErrors } = useForm(form, fields)
function goToStep(step_: Steps) {
serverError.value = ''
step.value = step_
}
function setDomain(data) {
function setDomain(data: { domain: string; dyndns_recovery_password: string }) {
domain.value = data.domain
dyndns_recovery_password.value = data.dyndns_recovery_password
goToStep('user')
}
async function setUser() {
const setUser = onSubmit(async () => {
const confirmed = await modalConfirm(
t('confirm_postinstall', { domain: domain.value }),
)
if (!confirmed) return
performPostInstall()
}
})
async function performPostInstall(force = false) {
// FIXME update formatFormData to unwrap ref auto
const { username, fullname, password } = form.value
const data = await formatFormData({
domain: domain.value,
dyndns_recovery_password: dyndns_recovery_password.value,
username: form.username,
fullname: form.fullname,
password: form.password,
username,
fullname,
password,
})
// FIXME does the api will throw an error for bad passwords ?
@ -111,19 +120,21 @@ async function performPostInstall(force = false) {
goToStep('login')
})
.catch((err) => {
const hasWordsInError = (words) =>
const hasWordsInError = (words: string[]) =>
words.some((word) => (err.key || err.message).includes(word))
if (err.name !== 'APIBadRequestError') throw err
if (!(err instanceof APIBadRequestError)) throw err
if (err.key === 'postinstall_low_rootfsspace') {
step.value = 'rootfsspace-error'
serverError.value = err.message
} else if (hasWordsInError(['domain', 'dyndns'])) {
step.value = 'domain'
serverError.value = err.message
} else if (hasWordsInError(['password', 'user'])) {
step.value = 'user'
serverErrors.global = [err.message]
} else {
throw err
}
serverError.value = err.message
})
}
</script>
@ -156,11 +167,11 @@ async function performPostInstall(force = false) {
@submit="setDomain"
>
<template #disclaimer>
<p class="alert alert-info" v-t="'postinstall_domain'" />
<p v-t="'postinstall_domain'" class="alert alert-info" />
</template>
</DomainForm>
<BButton variant="primary" @click="goToStep('start')" class="mt-3">
<BButton variant="primary" class="mt-3" @click="goToStep('start')">
<YIcon iname="chevron-left" /> {{ $t('previous') }}
</BButton>
</template>
@ -168,28 +179,16 @@ async function performPostInstall(force = false) {
<!-- FIRST USER SETUP STEP -->
<template v-else-if="step === 'user'">
<CardForm
:title="$t('postinstall.user.title')"
v-model="form"
:fields="fields"
icon="user-plus"
:validation="v$"
:server-error="serverError"
:submit-text="$t('next')"
:title="$t('postinstall.user.title')"
:validations="v"
@submit.prevent="setUser"
>
<ReadOnlyAlertItem
:label="$t('postinstall.user.first_user_help')"
type="info"
/>
<FormField
v-for="(field, key) in fields"
:key="key"
v-bind="field"
v-model="form[key]"
:validation="v$.form[key]"
/>
</CardForm>
<BButton variant="primary" @click="goToStep('domain')" class="mt-3">
<BButton variant="primary" class="mt-3" @click="goToStep('domain')">
<YIcon iname="chevron-left" /> {{ $t('previous') }}
</BButton>
</template>

View file

@ -1,8 +1,9 @@
<script setup lang="ts">
import { useVuelidate } from '@vuelidate/core'
import { computed, reactive, ref } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useForm } from '@/composables/form'
import { asUnreffed } from '@/helpers/commons'
import {
domain,
dynDomain,
@ -12,23 +13,26 @@ import {
} from '@/helpers/validators'
import { formatFormData } from '@/helpers/yunohostArguments'
import { useStoreGetters } from '@/store/utils'
import type { AdressModelValue, FieldProps, FormFieldDict } from '@/types/form'
defineOptions({
inheritAttrs: false,
})
withDefaults(
const props = withDefaults(
defineProps<{
title: string
submitText?: string | null
submitText?: string
serverError?: string
}>(),
{
submitText: null,
serverError: '',
submitText: undefined,
serverError: undefined,
},
)
const emit = defineEmits(['submit'])
const emit = defineEmits<{
submit: [data: { domain: string; dyndns_recovery_password: string }]
}>()
const { t } = useI18n()
@ -42,82 +46,112 @@ const dynDnsForbiden = computed(() => {
})
})
const selected = ref(dynDnsForbiden.value ? 'domain' : '')
const form = reactive({
type Selected = 'domain' | 'dynDomain' | 'localDomain'
const selected = ref<'' | Selected>(dynDnsForbiden.value ? 'domain' : '')
type Form = {
domain: string
dynDomain: AdressModelValue
dynDomainPassword: string
dynDomainPasswordConfirmation: string
localDomain: AdressModelValue
}
const form = ref<Form>({
domain: '',
dynDomain: { localPart: '', separator: '.', domain: 'nohost.me' },
dynDomainPassword: '',
dynDomainPasswordConfirmation: '',
localDomain: { localPart: '', separator: '.', domain: 'local' },
})
const rules = computed(() => ({
selected: { required },
form: ['domain', 'localDomain'].includes(selected.value)
? {
[selected.value]:
selected.value === 'domain'
? { required, domain }
: { localPart: { required, dynDomain } },
}
: {
dynDomain: { localPart: { required, dynDomain } },
dynDomainPassword: { passwordLenght: minLength(8) },
dynDomainPasswordConfirmation: {
passwordMatch: sameAs(form.dynDomainPassword),
},
},
}))
const v$ = useVuelidate(rules, { selected, form })
const fields = {
const fields = reactive({
domain: {
component: 'InputItem',
label: t('domain_name'),
rules: asUnreffed(
computed(() =>
selected.value === 'domain' ? { required, domain } : undefined,
),
),
props: {
id: 'domain',
placeholder: t('placeholder.domain'),
},
},
} satisfies FieldProps<'InputItem', Form['domain']>,
dynDomain: {
component: 'AdressItem',
label: t('domain_name'),
rules: { localPart: { required, dynDomain } },
props: {
id: 'dyn-domain',
placeholder: t('placeholder.domain').split('.')[0],
type: 'domain',
choices: dynDomains,
},
},
} satisfies FieldProps<'AdressItem', Form['dynDomain']>,
dynDomainPassword: {
component: 'InputItem',
label: t('domain.add.dyn_dns_password'),
description: t('domain.add.dyn_dns_password_desc'),
rules: { passwordLenght: minLength(8) },
props: {
id: 'dyn-dns-password',
placeholder: '••••••••',
type: 'password',
},
},
} satisfies FieldProps<'InputItem', Form['dynDomainPassword']>,
dynDomainPasswordConfirmation: {
component: 'InputItem',
label: t('password_confirmation'),
rules: asUnreffed(
computed(() => ({
passwordMatch: sameAs(form.value.dynDomainPassword),
})),
),
props: {
id: 'dyn-dns-password-confirmation',
placeholder: '••••••••',
type: 'password',
},
},
} satisfies FieldProps<'InputItem', Form['dynDomainPasswordConfirmation']>,
localDomain: {
component: 'AdressItem',
label: t('domain_name'),
rules: asUnreffed(
computed(() =>
selected.value === 'localDomain'
? { localPart: { required, dynDomain } }
: undefined,
),
),
props: {
id: 'dyn-domain',
placeholder: t('placeholder.domain').split('.')[0],
type: 'domain',
choices: ['local', 'test'],
},
} satisfies FieldProps<'AdressItem', Form['localDomain']>,
} satisfies FormFieldDict<Form>)
const { v, onSubmit, serverErrors } = useForm(form, fields)
watch(
() => props.serverError,
() => {
if (props.serverError) {
serverErrors.global = [props.serverError]
}
},
}
)
const dynKeys = [
'dynDomain',
'dynDomainPassword',
'dynDomainPasswordConfirmation',
] as (keyof Form)[]
const domainIsVisible = computed(() => {
return selected.value === 'domain'
@ -131,15 +165,17 @@ const localDomainIsVisible = computed(() => {
return selected.value === 'localDomain'
})
async function onSubmit() {
const onDomainAdd = onSubmit(async () => {
const domainType = selected.value
if (!domainType) return
const data = await formatFormData({
domain: form[domainType],
domain: form.value[domainType],
dyndns_recovery_password:
domainType === 'dynDomain' ? form.dynDomainPassword : '',
domainType === 'dynDomain' ? form.value.dynDomainPassword : '',
})
emit('submit', data)
}
})
</script>
<template>
@ -147,9 +183,8 @@ async function onSubmit() {
:title="title"
icon="globe"
:submit-text="submitText"
:validation="v$"
:server-error="serverError"
@submit.prevent="onSubmit"
:validations="v"
@submit.prevent="onDomainAdd"
>
<template #disclaimer>
<slot name="disclaimer" />
@ -167,8 +202,8 @@ async function onSubmit() {
{{ $t('domain.add.from_registrar') }}
</BFormRadio>
<BCollapse id="collapse-domain" v-model:visible="domainIsVisible">
<p class="mt-2 alert alert-info">
<BCollapse id="collapse-domain" v-model="domainIsVisible">
<p class="mt-2 mb-3 alert alert-info">
<YIcon iname="info-circle" />
<span class="ps-1" v-html="$t('domain.add.from_registrar_desc')" />
</p>
@ -176,8 +211,7 @@ async function onSubmit() {
<FormField
v-bind="fields.domain"
v-model="form.domain"
:validation="v$.domain"
class="mt-3"
:validation="v.domain"
/>
</BCollapse>
@ -194,34 +228,21 @@ async function onSubmit() {
{{ $t('domain.add.from_yunohost') }}
</BFormRadio>
<BCollapse id="collapse-dynDomain" v-model:visible="dynDomainIsVisible">
<p class="mt-2 alert alert-info">
<BCollapse id="collapse-dynDomain" v-model="dynDomainIsVisible">
<p class="mt-2 mb-3 alert alert-info">
<YIcon iname="info-circle" />
<span class="ps-1" v-html="$t('domain.add.from_yunohost_desc')" />
</p>
<FormField
v-bind="fields.dynDomain"
:validation="v$.dynDomain"
class="mt-3"
>
<template #default="{ self }">
<AdressItem v-bind="self" v-model="form.dynDomain" />
</template>
</FormField>
<FormField
v-bind="fields.dynDomainPassword"
:validation="v$.dynDomainPassword"
v-model="form.dynDomainPassword"
/>
<FormField
v-bind="fields.dynDomainPasswordConfirmation"
:validation="v$.dynDomainPasswordConfirmation"
v-model="form.dynDomainPasswordConfirmation"
v-for="key in dynKeys"
:key="key"
v-bind="fields[key]"
v-model="form[key]"
:validation="v[key]"
/>
</BCollapse>
<div
v-if="dynDnsForbiden"
class="alert alert-warning mt-2"
@ -239,21 +260,17 @@ async function onSubmit() {
{{ $t('domain.add.from_local') }}
</BFormRadio>
<BCollapse id="collapse-localDomain" v-model:visible="localDomainIsVisible">
<p class="mt-2 alert alert-info">
<BCollapse id="collapse-localDomain" v-model="localDomainIsVisible">
<p class="mt-2 mb-3 alert alert-info">
<YIcon iname="info-circle" />
<span class="ps-1" v-html="$t('domain.add.from_local_desc')" />
</p>
<FormField
v-bind="fields.localDomain"
:validation="v$.localDomain"
class="mt-3"
>
<template #default="{ self }">
<AdressItem v-bind="self" v-model="form.localDomain" />
</template>
</FormField>
v-model="form.localDomain"
:validation="v.localDomain"
/>
</BCollapse>
</CardForm>
</template>

View file

@ -1,14 +1,15 @@
<script setup lang="ts">
import { useVuelidate } from '@vuelidate/core'
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRoute, useRouter } from 'vue-router'
import CardDeckFeed from '@/components/CardDeckFeed.vue'
import { useForm } from '@/composables/form'
import { useAutoModal } from '@/composables/useAutoModal'
import { useInitialQueries } from '@/composables/useInitialQueries'
import { randint } from '@/helpers/commons'
import { appRepoUrl, required } from '@/helpers/validators'
import { useInitialQueries } from '@/composables/useInitialQueries'
import type { FieldProps, FormFieldDict } from '@/types/form'
const props = withDefaults(
defineProps<{
@ -37,8 +38,20 @@ const { loading } = useInitialQueries(
const apps = ref()
const selectedApp = ref()
const antifeatures = ref()
const url = ref()
const v$ = useVuelidate({ url: { required, appRepoUrl } }, { url })
const form = ref({ url: '' })
const fields = {
url: {
component: 'InputItem',
label: t('url'),
rules: { required, appRepoUrl },
props: {
id: 'custom-install',
placeholder: 'https://some.git.forge.tld/USER/REPOSITORY',
},
} satisfies FieldProps<'InputItem', string>,
} satisfies FormFieldDict<typeof form.value>
const { v, onSubmit } = useForm(form, fields)
const qualityOptions = [
{ value: 'high_quality', text: t('only_highquality_apps') },
@ -169,16 +182,16 @@ async function onInstallClick(appId: string) {
}
// INSTALL CUSTOM APP
async function onCustomInstallClick() {
const onCustomInstallClick = onSubmit(async () => {
const confirmed = await modalConfirm(t('confirm_install_custom_app'))
if (!confirmed) return
const url_ = url.value
const url = form.value.url
router.push({
name: 'app-install-custom',
params: { id: url_.endsWith('/') ? url_ : url_ + '/' },
params: { id: url.endsWith('/') ? url : url + '/' },
})
}
})
</script>
<template>
@ -354,12 +367,14 @@ async function onCustomInstallClick() {
<template #bot>
<!-- INSTALL CUSTOM APP -->
<CardForm
:title="$t('custom_app_install')"
v-model="form"
icon="download"
@submit.prevent="onCustomInstallClick"
:fields="fields"
:submit-text="$t('install')"
:validation="v$"
:title="$t('custom_app_install')"
:validations="v"
class="mt-5"
@submit.prevent="onCustomInstallClick"
>
<template #disclaimer>
<div class="alert alert-warning">
@ -367,13 +382,6 @@ async function onCustomInstallClick() {
{{ $t('confirm_install_custom_app') }}
</div>
</template>
<!-- URL -->
<FormField
v-bind="customInstall.field"
v-model="customInstall.url"
:validation="v$.customInstall.url"
/>
</CardForm>
</template>

View file

@ -1,56 +1,52 @@
<script setup lang="ts">
import { useVuelidate } from '@vuelidate/core'
import { reactive, ref } from 'vue'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import api from '@/api'
import { APIBadRequestError, APIError } from '@/api/errors'
import { useForm } from '@/composables/form'
import { alphalownumdot_, required } from '@/helpers/validators'
import type { FieldProps, FormFieldDict } from '@/types/form'
const { t } = useI18n()
const router = useRouter()
const form = reactive({ groupname: '' })
const v$ = useVuelidate({ groupname: { required, alphalownumdot_ } }, form)
const serverError = ref('')
const groupnameField = {
const form = ref({ groupname: '' })
const fields = {
groupname: {
component: 'InputItem',
label: t('group_name'),
description: t('group_format_name_help'),
rules: { required, alphalownumdot_ },
props: {
id: 'groupname',
placeholder: t('placeholder.groupname'),
},
}
function onSubmit() {
} satisfies FieldProps<'InputItem', string>,
} satisfies FormFieldDict<typeof form.value>
const { v, onSubmit } = useForm(form, fields)
const onAddGroup = onSubmit((onError) => {
api
.post({ uri: 'users/groups', storeKey: 'groups' }, form, {
key: 'groups.create',
name: form.groupname,
name: form.value.groupname,
})
.then(() => {
router.push({ name: 'group-list' })
})
.catch((err: APIError) => {
if (!(err instanceof APIBadRequestError)) throw err
serverError.value = err.message
})
}
.catch(onError)
})
</script>
<template>
<CardForm
:title="$t('group_new')"
v-model="form"
icon="users"
:validation="v$"
:server-error="serverError"
@submit.prevent="onSubmit"
>
<!-- GROUP NAME -->
<FormField
v-bind="groupnameField"
v-model="form.groupname"
:validation="v$.form.groupname"
:fields="fields"
:title="$t('group_new')"
:validations="v"
@submit.prevent="onAddGroup"
/>
</CardForm>
</template>

View file

@ -1,12 +1,12 @@
<script setup lang="ts">
import { useVuelidate } from '@vuelidate/core'
import { computed, reactive, ref } from 'vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import api from '@/api'
import { APIBadRequestError, type APIError } from '@/api/errors'
import { useForm } from '@/composables/form'
import { useInitialQueries } from '@/composables/useInitialQueries'
import { asUnreffed } from '@/helpers/commons'
import {
alphalownumdot_,
minLength,
@ -17,6 +17,7 @@ import {
} from '@/helpers/validators'
import { formatFormData } from '@/helpers/yunohostArguments'
import { useStoreGetters } from '@/store/utils'
import type { FieldProps, FormFieldDict } from '@/types/form'
const { t } = useI18n()
const router = useRouter()
@ -30,149 +31,122 @@ const { loading } = useInitialQueries(
const { userNames, domainsAsChoices, mainDomain } = useStoreGetters()
const fields = {
username: {
label: t('user_username'),
props: {
id: 'username',
placeholder: t('placeholder.username'),
},
},
fullname: {
label: t('user_fullname'),
props: {
id: 'fullname',
placeholder: t('placeholder.fullname'),
},
},
domain: {
id: 'mail',
label: t('user_email'),
description: t('tip_about_user_email'),
descriptionVariant: 'info',
props: { choices: domainsAsChoices },
},
password: {
label: t('password'),
description: t('good_practices_about_user_password'),
descriptionVariant: 'warning',
props: {
id: 'password',
placeholder: '••••••••',
type: 'password',
},
},
confirmation: {
label: t('password_confirmation'),
props: {
id: 'confirmation',
placeholder: '••••••••',
type: 'password',
},
},
}
const form = reactive({
type Form = typeof form.value
const form = ref({
username: '',
fullname: '',
domain: '',
password: '',
confirmation: '',
})
const rules = computed(() => ({
const fields = {
username: {
component: 'InputItem',
label: t('user_username'),
rules: asUnreffed(
computed(() => ({
required,
alphalownumdot_,
notInUsers: unique(userNames.value),
notInUsers: unique(userNames),
})),
),
props: {
id: 'username',
placeholder: t('placeholder.username'),
},
fullname: { required, name },
domain: { required },
password: { required, passwordLenght: minLength(8) },
confirmation: { required, passwordMatch: sameAs(form.password) },
}))
const v$ = useVuelidate(rules, form)
const serverError = ref('')
} satisfies FieldProps<'InputItem', Form['username']>,
fullname: {
component: 'InputItem',
hr: true,
label: t('user_fullname'),
rules: { required, name },
props: {
id: 'fullname',
placeholder: t('placeholder.fullname'),
},
} satisfies FieldProps<'InputItem', Form['fullname']>,
domain: {
component: 'SelectItem',
hr: true,
id: 'mail',
label: t('user_email'),
description: t('tip_about_user_email'),
descriptionVariant: 'info',
rules: { required },
props: { choices: asUnreffed(domainsAsChoices) },
} satisfies FieldProps<'SelectItem', Form['domain']>,
password: {
component: 'InputItem',
label: t('password'),
description: t('good_practices_about_user_password'),
descriptionVariant: 'warning',
rules: { required, passwordLenght: minLength(8) },
props: {
id: 'password',
placeholder: '••••••••',
type: 'password',
},
} satisfies FieldProps<'InputItem', Form['password']>,
confirmation: {
component: 'InputItem',
label: t('password_confirmation'),
rules: asUnreffed(
computed(() => ({
required,
passwordMatch: sameAs(form.value.password),
})),
),
props: {
id: 'confirmation',
placeholder: '••••••••',
type: 'password',
},
} satisfies FieldProps<'InputItem', Form['confirmation']>,
} satisfies FormFieldDict<Form>
const { v, onSubmit } = useForm(form, fields)
function onQueriesResponse() {
form.domain = mainDomain.value
form.value.domain = mainDomain.value
}
async function onSubmit() {
const data = await formatFormData(form, { flatten: true })
const onUserCreate = onSubmit(async (onError) => {
const data = await formatFormData(form.value, { flatten: true })
api
.post({ uri: 'users' }, data, {
key: 'users.create',
name: form.username,
name: form.value.username,
})
.then(() => {
router.push({ name: 'user-list' })
})
.catch((err: APIError) => {
if (!(err instanceof APIBadRequestError)) throw err
serverError.value = err.message
})
}
.catch(onError)
})
</script>
<template>
<ViewBase :loading="loading" skeleton="CardFormSkeleton">
<CardForm
:title="$t('users_new')"
v-model="form"
icon="user-plus"
:validation="v$"
:server-error="serverError"
@submit.prevent="onSubmit"
:fields="fields"
:title="$t('users_new')"
:validations="v"
@submit.prevent="onUserCreate"
>
<!-- USER NAME -->
<FormField
v-bind="fields.username"
v-model="form.username"
:validation="v$.form.username"
/>
<!-- USER FULLNAME -->
<FormField
v-bind="fields.fullname"
:validation="v$.form.fullname"
v-model="form.fullname"
/>
<hr />
<!-- USER MAIL DOMAIN -->
<FormField v-bind="fields.domain" :validation="v$.form.domain">
<template #default="{ self }">
<template #component:domain="componentProps">
<BInputGroup>
<BInputGroupText id="local-part" tag="label" class="border-right-0">
{{ form.username }}@
</BInputGroupText>
<SelectItem
aria-labelledby="local-part"
aria-describedby="mail__BV_description_"
v-model="form.domain"
v-bind="self"
/>
<SelectItem v-bind="componentProps" v-model="form.domain" />
</BInputGroup>
</template>
</FormField>
<hr />
<!-- USER PASSWORD -->
<FormField
v-bind="fields.password"
v-model="form.password"
:validation="v$.form.password"
/>
<!-- USER PASSWORD CONFIRMATION -->
<FormField
v-bind="fields.confirmation"
v-model="form.confirmation"
:validation="v$.form.confirmation"
/>
</CardForm>
</ViewBase>
</template>

View file

@ -1,17 +1,16 @@
<script setup lang="ts">
import { useVuelidate } from '@vuelidate/core'
import { computed, nextTick, reactive, ref } from 'vue'
import { computed, reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import api from '@/api'
import type ViewBase from '@/components/globals/ViewBase.vue'
import { useArrayRule, useForm } from '@/composables/form'
import { useInitialQueries } from '@/composables/useInitialQueries'
import { arrayDiff } from '@/helpers/commons'
import { arrayDiff, asUnreffed } from '@/helpers/commons'
import {
emailForward,
emailLocalPart,
helpers,
integer,
minLength,
minValue,
@ -25,6 +24,7 @@ import {
sizeToM,
} from '@/helpers/yunohostArguments'
import { useStoreGetters } from '@/store/utils'
import type { AdressModelValue, FieldProps, FormFieldDict } from '@/types/form'
const props = defineProps<{
name: string
@ -44,124 +44,138 @@ const viewElem = ref<InstanceType<typeof ViewBase> | null>(null)
const { user, domainsAsChoices, mainDomain } = useStoreGetters()
const fields = {
type Form = typeof form.value
const form = ref({
username: props.name,
fullname: '',
mail: { localPart: '', separator: '@', domain: '' } as AdressModelValue,
mailbox_quota: '' as string | number,
mail_aliases: [] as AdressModelValue[],
mail_forward: [] as string[],
change_password: '',
confirmation: '',
})
const fields = reactive({
username: {
component: 'InputItem',
label: t('user_username'),
modelValue: props.name,
props: { id: 'username', disabled: true },
props: {
id: 'username',
disabled: true,
},
} satisfies FieldProps<'InputItem', Form['username']>,
fullname: {
component: 'InputItem',
label: t('user_fullname'),
rules: { required, nameValidator },
props: {
id: 'fullname',
placeholder: t('placeholder.fullname'),
},
},
} satisfies FieldProps<'InputItem', Form['fullname']>,
mail: {
component: 'AdressItem',
label: t('user_email'),
props: { id: 'mail', choices: domainsAsChoices },
rules: {
localPart: { required, email: emailLocalPart },
},
props: { id: 'mail', choices: asUnreffed(domainsAsChoices) },
} satisfies FieldProps<'AdressItem', Form['mail']>,
mailbox_quota: {
append: 'M',
component: 'InputItem',
label: t('user_mailbox_quota'),
description: t('mailbox_quota_description'),
example: t('mailbox_quota_example'),
// example: t('mailbox_quota_example'),
rules: { integer, minValue: minValue(0) },
props: {
id: 'mailbox-quota',
placeholder: t('mailbox_quota_placeholder'),
},
},
} satisfies FieldProps<'InputItem', Form['mailbox_quota']>,
mail_aliases: {
component: 'AdressItem',
rules: asUnreffed(
useArrayRule(() => form.value.mail_aliases, {
localPart: { required, email: emailLocalPart },
}),
),
props: {
placeholder: t('placeholder.username'),
choices: domainsAsChoices,
},
choices: asUnreffed(domainsAsChoices),
},
} satisfies FieldProps<'AdressItem', Form['mail_aliases']>,
mail_forward: {
component: 'InputItem',
rules: asUnreffed(
useArrayRule(() => form.value.mail_forward, { required, emailForward }),
),
props: {
placeholder: t('user_new_forward'),
type: 'email',
},
},
} satisfies FieldProps<'InputItem', Form['mail_forward']>,
change_password: {
component: 'InputItem',
label: t('password'),
description: t('good_practices_about_user_password'),
descriptionVariant: 'warning',
rules: { passwordLenght: minLength(8) },
props: {
id: 'change_password',
type: 'password',
placeholder: '••••••••',
autocomplete: 'new-password',
},
},
} satisfies FieldProps<'InputItem', Form['change_password']>,
confirmation: {
component: 'InputItem',
label: t('password_confirmation'),
rules: asUnreffed(
computed(() => ({ passwordMatch: sameAs(form.value.change_password) })),
),
props: {
id: 'confirmation',
type: 'password',
placeholder: '••••••••',
autocomplete: 'new-password',
},
},
}
const form = reactive({
fullname: '',
mail: { localPart: '', separator: '@', domain: '' },
mailbox_quota: '',
mail_aliases: [],
mail_forward: [],
change_password: '',
confirmation: '',
})
const rules = computed(() => ({
fullname: { required, nameValidator },
mail: {
localPart: { required, email: emailLocalPart },
},
mailbox_quota: { integer, minValue: minValue(0) },
mail_aliases: {
$each: helpers.forEach({
localPart: { required, email: emailLocalPart },
}),
},
mail_forward: {
$each: helpers.forEach({
mail: { required, emailForward },
}),
},
change_password: { passwordLenght: minLength(8) },
confirmation: { passwordMatch: sameAs(form.change_password) },
}))
const v$ = useVuelidate(rules, form)
const serverError = ref('')
} satisfies FieldProps<'InputItem', Form['confirmation']>,
} satisfies FormFieldDict<Form>)
const { v, onSubmit } = useForm(form, fields)
function onQueriesResponse(user_: any) {
form.fullname = user_.fullname
form.mail = adressToFormValue(user_.mail)
form.value.fullname = user_.fullname
form.value.mail = adressToFormValue(user_.mail)
if (user_['mail-aliases']) {
form.mail_aliases = user_['mail-aliases'].map((mail) =>
form.value.mail_aliases = user_['mail-aliases'].map((mail) =>
adressToFormValue(mail),
)
}
if (user_['mail-forward']) {
form.mail_forward = user_['mail-forward'].map((mail) => ({ mail })) // Copy value
form.value.mail_forward = user_['mail-forward'].map((mail) => ({ mail })) // Copy value
}
// mailbox-quota could be 'No quota' or 'Pas de quota'...
if (parseInt(user_['mailbox-quota'].limit) > 0) {
form.mailbox_quota = sizeToM(user_['mailbox-quota'].limit)
form.value.mailbox_quota = sizeToM(user_['mailbox-quota'].limit)
} else {
form.mailbox_quota = ''
form.value.mailbox_quota = ''
}
}
async function onSubmit() {
const formData = await formatFormData(form, { flatten: true })
const onUserEdit = onSubmit(async (onError, serverErrors) => {
const { data: formData } = await formatFormData(form.value, {
flatten: true,
extract: ['username'],
})
// FIXME not sure computed can be executed?
const user_ = user.value(props.name)
const data = {}
@ -169,7 +183,7 @@ async function onSubmit() {
formData.mailbox_quota = ''
}
formData.mail_forward = formData.mail_forward?.map((v) => v.mail)
// formData.mail_forward = formData.mail_forward?.map((v) => v.mail)
for (const key of ['mail_aliases', 'mail_forward']) {
const dashedKey = key.replace('_', '-')
@ -193,7 +207,7 @@ async function onSubmit() {
}
if (Object.keys(data).length === 0) {
serverError.value = t('error_modify_something')
serverErrors.global = [t('error_modify_something')]
return
}
@ -205,132 +219,45 @@ async function onSubmit() {
.then(() => {
router.push({ name: 'user-info', param: { name: props.name } })
})
.catch((err) => {
if (err.name !== 'APIBadRequestError') throw err
serverError.value = err.message
})
}
function addEmailField(type: 'aliases' | 'forward') {
form['mail_' + type].push(
type === 'aliases'
? { localPart: '', separator: '@', domain: mainDomain.value }
: { mail: '' },
)
// Focus last input after rendering update
nextTick(() => {
const inputs = viewElem.value!.$el.querySelectorAll(`#mail-${type} input`)
inputs[inputs.length - 1].focus()
})
}
function removeEmailField(type: 'aliases' | 'forward', index: number) {
form['mail_' + type].splice(index, 1)
}
.catch(onError)
})
</script>
<template>
<ViewBase ref="viewElem" :loading="loading" skeleton="CardFormSkeleton">
<CardForm
:title="$t('user_username_edit', { name })"
v-model="form"
icon="user"
:validation="v$"
:server-error="serverError"
@submit.prevent="onSubmit"
:fields="fields"
:title="$t('user_username_edit', { name })"
:validations="v"
@submit.prevent="onUserEdit"
>
<!-- USERNAME (disabled) -->
<FormField v-bind="fields.username" />
<!-- USER FULLNAME -->
<FormField
v-bind="fields.fullname"
v-model="form.fullname"
:validation="v$.form.fullname"
<template #field:mail_aliases="fieldProps">
<FormFieldMultiple
v-bind="fieldProps"
v-model="form.mail_aliases"
:add-btn-text="t('user_emailaliases_add')"
:default-value="
() => ({
localPart: '',
separator: '@',
domain: mainDomain,
})
"
:validation="v.form.mail_aliases"
/>
<hr />
<!-- USER EMAIL -->
<FormField v-bind="fields.mail" :validation="v$.form.mail">
<template #default="{ self }">
<AdressItem v-bind="self" v-model="form.mail" />
</template>
</FormField>
<!-- MAILBOX QUOTA -->
<FormField
v-bind="fields.mailbox_quota"
:validation="v$.form.mailbox_quota"
>
<template #default="{ self }">
<BInputGroup append="M">
<InputItem v-bind="self" v-model="form.mailbox_quota" />
</BInputGroup>
<template #field:mail_forward="fieldProps">
<FormFieldMultiple
v-bind="fieldProps"
v-model="form.mail_forward"
:add-btn-text="t('user_emailforward_add')"
:default-value="() => ''"
:validation="v.form.mail_forward"
/>
</template>
</FormField>
<hr />
<!-- MAIL ALIASES -->
<FormField :label="$t('user_emailaliases')" id="mail-aliases">
<div v-for="(mail, i) in form.mail_aliases" :key="i" class="mail-list">
<FormField
v-bind="fields.mail_aliases"
:id="'mail_aliases' + i"
:validation="v$.form.mail_aliases"
:validation-index="i"
>
<template #default="{ self }">
<AdressItem v-bind="self" v-model="form.mail_aliases[i]" />
</template>
</FormField>
<BButton variant="danger" @click="removeEmailField('aliases', i)">
<YIcon :title="$t('delete')" iname="trash-o" />
<span class="visually-hidden">{{ $t('delete') }}</span>
</BButton>
</div>
<BButton variant="success" @click="addEmailField('aliases')">
<YIcon iname="plus" /> {{ $t('user_emailaliases_add') }}
</BButton>
</FormField>
<!-- MAIL FORWARD -->
<FormField :label="$t('user_emailforward')" id="mail-forward">
<div v-for="(mail, i) in form.mail_forward" :key="i" class="mail-list">
<FormField
v-bind="fields.mail_forward"
v-model="form.mail_forward[i].mail"
:id="'mail-forward' + i"
:validation="v$.form.mail_forward"
:validation-index="i"
/>
<BButton variant="danger" @click="removeEmailField('forward', i)">
<YIcon :title="$t('delete')" iname="trash-o" />
<span class="visually-hidden">{{ $t('delete') }}</span>
</BButton>
</div>
<BButton variant="success" @click="addEmailField('forward')">
<YIcon iname="plus" /> {{ $t('user_emailforward_add') }}
</BButton>
</FormField>
<hr />
<!-- USER PASSWORD -->
<FormField
v-bind="fields.change_password"
v-model="form.change_password"
:validation="v$.form.change_password"
/>
<!-- USER PASSWORD CONFIRMATION -->
<FormField
v-bind="fields.confirmation"
v-model="form.confirmation"
:validation="v$.form.confirmation"
/>
</CardForm>
</ViewBase>
</template>

View file

@ -1,30 +1,39 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import { reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import api from '@/api'
import { useForm } from '@/composables/form'
import { useAutoModal } from '@/composables/useAutoModal'
import { required } from '@/helpers/validators'
import { formatFormData } from '@/helpers/yunohostArguments'
import { useVuelidate } from '@vuelidate/core'
import type { FieldProps, FileModelValue, FormFieldDict } from '@/types/form'
const { t } = useI18n()
const router = useRouter()
const store = useStore()
const modalConfirm = useAutoModal()
const fields = {
type Form = typeof form.value
const form = ref({
csvfile: { file: null } as FileModelValue,
update: false,
delete: false,
})
const fields = reactive({
csvfile: {
component: 'FileItem',
label: t('users_import_csv_file'),
description: t('users_import_csv_file_desc'),
component: 'FileItem',
rules: { file: required },
props: {
id: 'csvfile',
accept: 'text/csv',
placeholder: t('placeholder.file'),
},
},
} satisfies FieldProps<'FileItem', Form['csvfile']>,
update: {
label: t('users_import_update'),
@ -33,28 +42,22 @@ const fields = {
props: {
id: 'update',
},
},
} satisfies FieldProps<'CheckboxItem', Form['update']>,
delete: {
component: 'CheckboxItem',
label: t('users_import_delete'),
description: t('users_import_delete_desc'),
component: 'CheckboxItem',
props: {
id: 'delete',
},
},
}
const form = reactive({
csvfile: { file: null },
update: false,
delete: false,
})
const rules = computed(() => ({}))
const v$ = useVuelidate(rules, form)
const serverError = ref('')
} satisfies FieldProps<'CheckboxItem', Form['delete']>,
} satisfies FormFieldDict<Form>)
async function onSubmit() {
if (form.delete) {
const { v, onSubmit } = useForm(form, fields)
const onUserImport = onSubmit(async (onError) => {
if (form.value.delete) {
const confirmed = await modalConfirm(
t('users_import_confirm_destructive'),
{ okTitle: t('users_import_delete_others') },
@ -62,7 +65,7 @@ async function onSubmit() {
if (!confirmed) return
}
const requestArgs = { ...form } as Partial<typeof form>
const requestArgs = { ...form.value } as Partial<Form>
if (!requestArgs.delete) delete requestArgs.delete
if (!requestArgs.update) delete requestArgs.update
const data = await formatFormData(requestArgs)
@ -78,31 +81,17 @@ async function onSubmit() {
])
router.push({ name: 'user-list' })
})
.catch((error) => {
serverError.value = error.message
})
}
.catch(onError)
})
</script>
<template>
<CardForm
:title="$t('users_import')"
v-model="form"
icon="user-plus"
:validation="v$"
:server-error="serverError"
@submit.prevent="onSubmit"
>
<!-- CSV FILE -->
<FormField
v-bind="fields.csvfile"
v-model="form.csvfile"
:validation="v$.form.csvfile"
:fields="fields"
:title="$t('users_import')"
:validations="v"
@submit.prevent="onUserImport"
/>
<!-- UPDATE -->
<FormField v-bind="fields.update" v-model="form.update" />
<!-- DELETE -->
<FormField v-bind="fields.delete" v-model="form.delete" />
</CardForm>
</template>