Skip to content

Commit

Permalink
feat(members): divide profile, devices and capabilities into multiple…
Browse files Browse the repository at this point in the history
… panels
mtthp committed Dec 2, 2024
1 parent d3a623f commit 9051a78
Showing 10 changed files with 453 additions and 199 deletions.
17 changes: 17 additions & 0 deletions src/helpers/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ErrorObject } from '@vuelidate/core';
import { AxiosError } from 'axios';
import { isNil } from 'lodash';

@@ -75,3 +76,19 @@ export const scrollToFirstError = () => {
});
}
};

/**
* Retrieve the count of error fields in Vuelidate
* @param errors
* @returns number
*/
export const getVuelidateErrorFieldsCount = (errors: ErrorObject[]): number =>
Object.keys(
errors.reduce(
(groups, error) => ({
...groups,
[error.$property]: [...((groups as never)[error.$property] || []), error],
}),
{},
),
).length;
14 changes: 12 additions & 2 deletions src/i18n/locales/en-GB/members.json
Original file line number Diff line number Diff line change
@@ -97,7 +97,8 @@
"birthdate": {
"label": "Birthdate"
},
"capacities": {
"capabilities": {
"apply": "Change capabilities",
"keysAccess": {
"description": "Retrieve the code for key boxes in the mobile app to access workspaces independently.",
"label": "Key Box Access"
@@ -107,7 +108,11 @@
"label": "Administrator"
},
"onFetch": {
"fail": "Unable to fetch member's capacities"
"fail": "Unable to fetch member's capabilities"
},
"onUpdate": {
"fail": "Unable to update {name} capabilities",
"success": "{name} capabilities updated"
},
"parkingAccess": {
"description": "Open the parking barrier.",
@@ -140,12 +145,17 @@
},
"macAddresses": {
"add": "Specify a device | Add another device | Add other devices",
"apply": "Change devices",
"check": "Check",
"description": {
"link": "MAC address",
"text": "Allows knowing the presence through a {link}"
},
"label": "Device | Device | Devices",
"onUpdate": {
"fail": "Unable to update devices of {name}",
"success": "Devices of {name} updated"
},
"remove": "Remove"
},
"onUpdate": {
1 change: 1 addition & 0 deletions src/i18n/locales/en-GB/validations.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"decimal": "This doesn't look like a decimal number",
"email": "This doesn't look like an email",
"invalidFields": "All good 👍 | One field is incorrect | {count} fields are incorrect",
"macAddress": "This doesn't look like a MAC address",
"minValue": "This should be greater than or equal to {min}",
"numeric": "This doesn't look like an integer",
12 changes: 11 additions & 1 deletion src/i18n/locales/fr-FR/members.json
Original file line number Diff line number Diff line change
@@ -97,7 +97,8 @@
"birthdate": {
"label": "Date de naissance"
},
"capacities": {
"capabilities": {
"apply": "Modifier les capacités",
"keysAccess": {
"description": "Récupère le code des boîtes à clés dans l'application mobile pour accéder aux espaces de travail en toute autonomie.",
"label": "Accès à la boîte à clés"
@@ -109,6 +110,10 @@
"onFetch": {
"fail": "Impossible de récupérer les capacités"
},
"onUpdate": {
"fail": "Impossible de mettre à jour les capacités de {name}",
"success": "Capacités de {name} mis à jour"
},
"parkingAccess": {
"description": "Ouvrir la barrière du parking.",
"label": "Accès au parking"
@@ -140,12 +145,17 @@
},
"macAddresses": {
"add": "Spécifier un appareil | Ajouter un autre appareil | Ajouter un autre appareil",
"apply": "Modifier les appareils",
"check": "Vérifier",
"description": {
"link": "adresse MAC",
"text": "Permet de connaître la présence au travers d'une {link}"
},
"label": "Appareil | Appareil | Appareils",
"onUpdate": {
"fail": "Impossible de mettre à jour les appareils de {name}",
"success": "Appareils de {name} mis à jour"
},
"remove": "Retirer"
},
"onUpdate": {
1 change: 1 addition & 0 deletions src/i18n/locales/fr-FR/validations.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
{
"decimal": "Ça ne ressemble pas à un nombre décimal",
"email": "Ça ne ressemble pas à un email",
"invalidFields": "Tout est bon 👍 | Une information est incorrecte | {count} informations sont incorrectes",
"macAddress": "Ça ne ressemble pas à une addresse MAC",
"minValue": "Au minimum {min}",
"numeric": "Ça ne ressemble pas à un nombre entier",
181 changes: 181 additions & 0 deletions src/views/Private/Members/Detail/MemberCapabilitesPanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<template>
<form class="shadow sm:overflow-hidden sm:rounded-md" @submit.prevent="onSubmit">
<ul class="flex flex-col gap-4 bg-white px-4 py-5 sm:p-6">
<AppSwitchField
as="li"
:description="$t('members.detail.profile.capabilities.manager.description')"
disabled
:label="$t('members.detail.profile.capabilities.manager.label')"
:model-value="member.isAdmin" />
<AppSwitchField
v-model="state.canUnlockGate"
as="li"
:description="$t('members.detail.profile.capabilities.unlockGate.description')"
:label="$t('members.detail.profile.capabilities.unlockGate.label')"
:loading="isFetchingCapabilites" />
<AppSwitchField
v-model="state.hasParkingAccess"
as="li"
:description="$t('members.detail.profile.capabilities.parkingAccess.description')"
:label="$t('members.detail.profile.capabilities.parkingAccess.label')"
:loading="isFetchingCapabilites" />
<AppSwitchField
v-model="state.canUnlockDeckDoor"
as="li"
:description="$t('members.detail.profile.capabilities.unlockDeckDoor.description')"
:label="$t('members.detail.profile.capabilities.unlockDeckDoor.label')"
:loading="isFetchingCapabilites" />
<AppSwitchField
v-model="state.hasKeysAccess"
as="li"
:description="$t('members.detail.profile.capabilities.keysAccess.description')"
:label="$t('members.detail.profile.capabilities.keysAccess.label')"
:loading="isFetchingCapabilites" />
<AppAlert
v-if="capabilitiesError"
:description="capabilitiesError.message"
:title="$t('members.detail.profile.capabilities.onFetch.fail')"
type="error">
<template #action>
<AppButton
class="self-start border border-gray-300 bg-white text-base text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-gray-400 sm:w-auto sm:text-sm"
:loading="isFetchingCapabilites"
@click="refetchCapabilities">
{{ $t('action.retry') }}
</AppButton>
</template>
</AppAlert>
</ul>

<div
class="flex flex-row flex-wrap gap-3 border-t border-gray-200 bg-gray-50 px-4 py-3 sm:px-6">
<AppButton
class="border border-transparent bg-indigo-600 text-white shadow-sm hover:bg-indigo-700 focus:ring-indigo-500"
:icon="mdiCheckAll"
:loading="state.isSubmitting"
type="submit">
{{ $t('members.detail.profile.capabilities.apply') }}
</AppButton>
<AppAlert
v-if="state.hasFailValidationOnce"
class="truncate"
:title="
$t('validations.invalidFields', {
count: getVuelidateErrorFieldsCount(vuelidate.$errors),
})
"
:type="vuelidate.$errors.length > 0 ? 'error' : 'success'" />
</div>
</form>
</template>

<script setup lang="ts">
import AppAlert from '@/components/form/AppAlert.vue';
import AppButton from '@/components/form/AppButton.vue';
import AppSwitchField from '@/components/form/AppSwitchField.vue';
import {
getVuelidateErrorFieldsCount,
handleSilentError,
scrollToFirstError,
} from '@/helpers/errors';
import { UserCapabilities } from '@/services/api/auth';
import { Member, getMemberCapabilities, updateMemberCapabilities } from '@/services/api/members';
import { useNotificationsStore } from '@/store/notifications';
import { mdiCheckAll } from '@mdi/js';
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { useVuelidate } from '@vuelidate/core';
import { PropType, computed, nextTick, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const emit = defineEmits(['update:member']);
const props = defineProps({
member: {
type: Object as PropType<Member>,
required: true,
},
});
const queryClient = useQueryClient();
const notificationsStore = useNotificationsStore();
const i18n = useI18n();
const state = reactive({
canUnlockGate: false as boolean,
hasParkingAccess: false as boolean,
canUnlockDeckDoor: false as boolean,
hasKeysAccess: false as boolean,
isSubmitting: false as boolean,
hasFailValidationOnce: false as boolean,
});
const {
isFetching: isFetchingCapabilites,
data: capabilities,
error: capabilitiesError,
refetch: refetchCapabilities,
} = useQuery({
queryKey: ['members', computed(() => props.member._id), 'capabilities'],
queryFn: ({ queryKey: [_, memberId] }) => getMemberCapabilities(memberId),
retry: false,
refetchOnWindowFocus: false,
});
const rules = computed(() => ({}));
const vuelidate = useVuelidate(rules, state);
const onSubmit = async () => {
const isValid = await vuelidate.value.$validate();
if (!isValid) {
state.hasFailValidationOnce = true;
nextTick(scrollToFirstError);
return;
}
state.isSubmitting = true;
(async () => {
await updateMemberCapabilities(props.member._id, {
[UserCapabilities.UNLOCK_GATE]: state.canUnlockGate,
[UserCapabilities.PARKING_ACCESS]: state.hasParkingAccess,
[UserCapabilities.UNLOCK_DECK_DOOR]: state.canUnlockDeckDoor,
[UserCapabilities.KEYS_ACCESS]: state.hasKeysAccess,
});
})()
.then(() => {
notificationsStore.addNotification({
message: i18n.t('members.detail.profile.capabilities.onUpdate.success', {
name: [props.member.firstName, props.member.lastName].filter(Boolean).join(' '),
}),
type: 'success',
timeout: 3_000,
});
queryClient.invalidateQueries({
queryKey: ['members', computed(() => props.member._id), 'history'],
});
})
.catch(handleSilentError)
.catch((error) => {
notificationsStore.addErrorNotification(
error,
i18n.t('members.detail.profile.capabilities.onUpdate.fail', {
name: [props.member.firstName, props.member.lastName].filter(Boolean).join(' '),
}),
);
return Promise.reject(error);
})
.finally(() => {
state.isSubmitting = false;
});
};
watch(
capabilities,
(memberCapabilities) => {
state.canUnlockGate = memberCapabilities?.[UserCapabilities.UNLOCK_GATE] ?? false;
state.hasParkingAccess = memberCapabilities?.[UserCapabilities.PARKING_ACCESS] ?? false;
state.canUnlockDeckDoor = memberCapabilities?.[UserCapabilities.UNLOCK_DECK_DOOR] ?? false;
state.hasKeysAccess = memberCapabilities?.[UserCapabilities.KEYS_ACCESS] ?? false;
},
{ immediate: true },
);
</script>
Original file line number Diff line number Diff line change
@@ -1,58 +1,6 @@
<template>
<form class="shadow sm:overflow-hidden sm:rounded-md" @submit.prevent="onSubmit">
<div class="flex flex-col items-stretch bg-white px-4 py-5 sm:p-6">
<div class="flex flex-row flex-wrap gap-x-6">
<AppTextField
id="first-name"
v-model="state.firstname"
autocomplete="given-name"
class="min-w-48 shrink grow basis-0"
disabled
:errors="vuelidate.firstname.$errors.map(({ $message }) => $message as string)"
:label="$t('members.detail.profile.firstname.label')"
name="first-name"
required
type="text"
@blur="vuelidate.firstname.$touch()" />
<AppTextField
id="last-name"
v-model="state.lastname"
autocomplete="family-name"
class="min-w-48 shrink grow basis-0"
disabled
:errors="vuelidate.lastname.$errors.map(({ $message }) => $message as string)"
:label="$t('members.detail.profile.lastname.label')"
name="last-name"
required
type="text"
@blur="vuelidate.lastname.$touch()" />
</div>

<div class="flex flex-row flex-wrap gap-x-6">
<AppTextField
id="email"
v-model="state.email"
autocomplete="email"
class="min-w-48 shrink grow basis-0"
disabled
:errors="vuelidate.email.$errors.map(({ $message }) => $message as string)"
:label="$t('members.detail.profile.email.label')"
name="email"
required
type="email"
@blur="vuelidate.email.$touch()" />
<AppTextField
id="birthdate"
v-model="state.birthdate"
autocomplete="bday"
class="min-w-48 shrink grow basis-0"
disabled
:label="$t('members.detail.profile.birthdate.label')"
name="birthdate"
:prepend-icon="mdiCakeVariantOutline"
type="date" />
</div>

<fieldset class="flex flex-col">
<legend class="block font-medium text-gray-900 sm:text-sm">
{{ $t('members.detail.profile.macAddresses.label', { count: state.devices.length }) }}
@@ -90,7 +38,6 @@
placeholder="A0:B1:C2:D3:E4:F5"
:prepend-icon="mdiLaptop"
required
@blur="vuelidate.email.$touch()"
@update:model-value="(value: string) => onMacAddressInput(index, value)">
<template #append>
<a
@@ -135,99 +82,48 @@
</li>
</ul>
</fieldset>

<hr class="mt-6 border-gray-200" />

<ul class="mb-2 mt-6 flex flex-col gap-4">
<AppSwitchField
as="li"
:description="$t('members.detail.profile.capacities.manager.description')"
disabled
:label="$t('members.detail.profile.capacities.manager.label')"
:model-value="member.isAdmin" />
<AppSwitchField
v-model="state.canUnlockGate"
as="li"
:description="$t('members.detail.profile.capacities.unlockGate.description')"
:label="$t('members.detail.profile.capacities.unlockGate.label')"
:loading="isFetchingCapabilites" />
<AppSwitchField
v-model="state.hasParkingAccess"
as="li"
:description="$t('members.detail.profile.capacities.parkingAccess.description')"
:label="$t('members.detail.profile.capacities.parkingAccess.label')"
:loading="isFetchingCapabilites" />
<AppSwitchField
v-model="state.canUnlockDeckDoor"
as="li"
:description="$t('members.detail.profile.capacities.unlockDeckDoor.description')"
:label="$t('members.detail.profile.capacities.unlockDeckDoor.label')"
:loading="isFetchingCapabilites" />
<AppSwitchField
v-model="state.hasKeysAccess"
as="li"
:description="$t('members.detail.profile.capacities.keysAccess.description')"
:label="$t('members.detail.profile.capacities.keysAccess.label')"
:loading="isFetchingCapabilites" />
<AppAlert
v-if="capabilitiesError"
:description="capabilitiesError.message"
:title="$t('members.detail.profile.capacities.onFetch.fail')"
type="error">
<template #action>
<AppButton
class="self-start border border-gray-300 bg-white text-base text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-gray-400 sm:w-auto sm:text-sm"
:loading="isFetchingCapabilites"
@click="refetchCapabilities">
{{ $t('action.retry') }}
</AppButton>
</template>
</AppAlert>
</ul>
</div>
<div class="flex flex-row border-t border-gray-200 bg-gray-50 px-4 py-3 sm:px-6">

<div
class="flex flex-row flex-wrap gap-3 border-t border-gray-200 bg-gray-50 px-4 py-3 sm:px-6">
<AppButton
class="border border-transparent bg-indigo-600 text-white shadow-sm hover:bg-indigo-700 focus:ring-indigo-500"
:icon="mdiCheckAll"
:loading="state.isSubmitting"
type="submit">
{{ $t('action.apply') }}
{{ $t('members.detail.profile.macAddresses.apply') }}
</AppButton>
<AppAlert
v-if="state.hasFailValidationOnce"
class="truncate"
:title="
$t('validations.invalidFields', {
count: getVuelidateErrorFieldsCount(vuelidate.$errors),
})
"
:type="vuelidate.$errors.length > 0 ? 'error' : 'success'" />
</div>
</form>
</template>

<script setup lang="ts">
import AppAlert from '@/components/form/AppAlert.vue';
import AppButton from '@/components/form/AppButton.vue';
import AppSwitchField from '@/components/form/AppSwitchField.vue';
import AppTextField from '@/components/form/AppTextField.vue';
import { handleSilentError, scrollToFirstError } from '@/helpers/errors';
import { withAppI18nMessage } from '@/i18n';
import { UserCapabilities } from '@/services/api/auth';
import {
Device,
Member,
getMemberCapabilities,
updateMemberCapabilities,
updateMemberMacAddresses,
} from '@/services/api/members';
getVuelidateErrorFieldsCount,
handleSilentError,
scrollToFirstError,
} from '@/helpers/errors';
import { Device, Member, updateMemberMacAddresses } from '@/services/api/members';
import { useNotificationsStore } from '@/store/notifications';
import {
mdiCakeVariantOutline,
mdiCheckAll,
mdiClose,
mdiLaptop,
mdiOpenInNew,
mdiPlus,
} from '@mdi/js';
import { useQuery, useQueryClient } from '@tanstack/vue-query';
import { mdiCheckAll, mdiClose, mdiLaptop, mdiOpenInNew, mdiPlus } from '@mdi/js';
import { useQueryClient } from '@tanstack/vue-query';
import { useVuelidate } from '@vuelidate/core';
import { email, helpers, macAddress, required } from '@vuelidate/validators';
import { helpers, macAddress, required } from '@vuelidate/validators';
import { PropType, computed, nextTick, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const emit = defineEmits(['update:member']);
const props = defineProps({
member: {
type: Object as PropType<Member>,
@@ -239,41 +135,13 @@ const queryClient = useQueryClient();
const notificationsStore = useNotificationsStore();
const i18n = useI18n();
const state = reactive({
firstname: null as string | null,
lastname: null as string | null,
email: null as string | null,
birthdate: null as string | null,
devices: [] as Device[],
canUnlockGate: false as boolean,
hasParkingAccess: false as boolean,
canUnlockDeckDoor: false as boolean,
hasKeysAccess: false as boolean,
isSubmitting: false as boolean,
});
const {
isFetching: isFetchingCapabilites,
data: capabilities,
error: capabilitiesError,
refetch: refetchCapabilities,
} = useQuery({
queryKey: ['members', computed(() => props.member._id), 'capabilities'],
queryFn: ({ queryKey: [_, memberId] }) => getMemberCapabilities(memberId),
retry: false,
refetchOnWindowFocus: false,
isSubmitting: false as boolean,
hasFailValidationOnce: false as boolean,
});
const rules = computed(() => ({
firstname: {
// required: withAppI18nMessage(required)
},
lastname: {
// required: withAppI18nMessage(required)
},
email: {
required: withAppI18nMessage(required),
email: withAppI18nMessage(email),
},
devices: {
$each: helpers.forEach({
macAddress: {
@@ -309,35 +177,21 @@ const onMacAddressInput = (deviceIndex: number, userInput: string) => {
const onSubmit = async () => {
const isValid = await vuelidate.value.$validate();
if (!isValid) {
state.hasFailValidationOnce = true;
nextTick(scrollToFirstError);
return;
}
state.isSubmitting = true;
(async () => {
// await updateMember(props.member.id, {
// firstName: state.firstname,
// lastName: state.lastname,
// email: state.email,
// birthdate: state.birthdate,
// macAddresses: state.devices.map(({ macAddress }) => macAddress),
// } as Member)
await updateMemberCapabilities(props.member._id, {
[UserCapabilities.UNLOCK_GATE]: state.canUnlockGate,
[UserCapabilities.PARKING_ACCESS]: state.hasParkingAccess,
[UserCapabilities.UNLOCK_DECK_DOOR]: state.canUnlockDeckDoor,
[UserCapabilities.KEYS_ACCESS]: state.hasKeysAccess,
});
await updateMemberMacAddresses(
props.member._id,
state.devices.map(({ macAddress }) => macAddress),
);
})()
.then(() => {
// emit('update:member', updatedMember);
notificationsStore.addNotification({
message: i18n.t('members.detail.profile.onUpdate.success', {
message: i18n.t('members.detail.profile.macAddresses.onUpdate.success', {
name: [props.member.firstName, props.member.lastName].filter(Boolean).join(' '),
}),
type: 'success',
@@ -351,7 +205,7 @@ const onSubmit = async () => {
.catch((error) => {
notificationsStore.addErrorNotification(
error,
i18n.t('members.detail.profile.onUpdate.fail', {
i18n.t('members.detail.profile.macAddresses.onUpdate.fail', {
name: [props.member.firstName, props.member.lastName].filter(Boolean).join(' '),
}),
);
@@ -366,27 +220,12 @@ watch(
() => props.member,
(member) => {
if (member) {
state.firstname = member.firstName || null;
state.lastname = member.lastName || null;
state.email = member.email || null;
state.birthdate = member.birthDate || null;
state.devices =
member.macAddresses.map((macAddress) => ({ id: macAddress, macAddress })) || [];
}
},
{ immediate: true },
);
watch(
capabilities,
(memberCapabilities) => {
state.canUnlockGate = memberCapabilities?.[UserCapabilities.UNLOCK_GATE] ?? false;
state.hasParkingAccess = memberCapabilities?.[UserCapabilities.PARKING_ACCESS] ?? false;
state.canUnlockDeckDoor = memberCapabilities?.[UserCapabilities.UNLOCK_DECK_DOOR] ?? false;
state.hasKeysAccess = memberCapabilities?.[UserCapabilities.KEYS_ACCESS] ?? false;
},
{ immediate: true },
);
</script>

<style scoped>
189 changes: 189 additions & 0 deletions src/views/Private/Members/Detail/MemberProfilePanel.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
<template>
<form class="shadow sm:overflow-hidden sm:rounded-md" @submit.prevent="onSubmit">
<div class="flex flex-col items-stretch bg-white px-4 pt-5 sm:px-6 sm:pt-6">
<div class="flex flex-row flex-wrap gap-x-6">
<AppTextField
id="first-name"
v-model="state.firstname"
autocomplete="given-name"
class="min-w-48 shrink grow basis-0"
disabled
:errors="vuelidate.firstname.$errors.map(({ $message }) => $message as string)"
:label="$t('members.detail.profile.firstname.label')"
name="first-name"
required
type="text"
@blur="vuelidate.firstname.$touch()" />
<AppTextField
id="last-name"
v-model="state.lastname"
autocomplete="family-name"
class="min-w-48 shrink grow basis-0"
disabled
:errors="vuelidate.lastname.$errors.map(({ $message }) => $message as string)"
:label="$t('members.detail.profile.lastname.label')"
name="last-name"
required
type="text"
@blur="vuelidate.lastname.$touch()" />
</div>

<div class="flex flex-row flex-wrap gap-x-6">
<AppTextField
id="email"
v-model="state.email"
autocomplete="email"
class="min-w-48 shrink grow basis-0"
disabled
:errors="vuelidate.email.$errors.map(({ $message }) => $message as string)"
:label="$t('members.detail.profile.email.label')"
name="email"
required
type="email"
@blur="vuelidate.email.$touch()" />
<AppTextField
id="birthdate"
v-model="state.birthdate"
autocomplete="bday"
class="min-w-48 shrink grow basis-0"
disabled
:label="$t('members.detail.profile.birthdate.label')"
name="birthdate"
:prepend-icon="mdiCakeVariantOutline"
type="date" />
</div>
</div>

<div
class="flex flex-row flex-wrap gap-3 border-t border-gray-200 bg-gray-50 px-4 py-3 sm:px-6">
<AppButton
class="border border-transparent bg-indigo-600 text-white shadow-sm hover:bg-indigo-700 focus:ring-indigo-500"
:icon="mdiCheckAll"
:loading="state.isSubmitting"
type="submit">
{{ $t('action.apply') }}
</AppButton>
<AppAlert
v-if="state.hasFailValidationOnce"
class="truncate"
:title="
$t('validations.invalidFields', {
count: getVuelidateErrorFieldsCount(vuelidate.$errors),
})
"
:type="vuelidate.$errors.length > 0 ? 'error' : 'success'" />
</div>
</form>
</template>

<script setup lang="ts">
import AppAlert from '@/components/form/AppAlert.vue';
import AppButton from '@/components/form/AppButton.vue';
import AppTextField from '@/components/form/AppTextField.vue';
import {
handleSilentError,
scrollToFirstError,
getVuelidateErrorFieldsCount,
} from '@/helpers/errors';
import { withAppI18nMessage } from '@/i18n';
import { Member, updateMember } from '@/services/api/members';
import { useNotificationsStore } from '@/store/notifications';
import { mdiCakeVariantOutline, mdiCheckAll } from '@mdi/js';
import { useQueryClient } from '@tanstack/vue-query';
import { useVuelidate } from '@vuelidate/core';
import { email, required } from '@vuelidate/validators';
import { PropType, computed, nextTick, reactive, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const props = defineProps({
member: {
type: Object as PropType<Member>,
required: true,
},
});
const queryClient = useQueryClient();
const notificationsStore = useNotificationsStore();
const i18n = useI18n();
const state = reactive({
firstname: null as string | null,
lastname: null as string | null,
email: null as string | null,
birthdate: null as string | null,
isSubmitting: false as boolean,
hasFailValidationOnce: false as boolean,
});
const rules = computed(() => ({
firstname: {
// required: withAppI18nMessage(required)
},
lastname: {
// required: withAppI18nMessage(required)
},
email: {
required: withAppI18nMessage(required),
email: withAppI18nMessage(email),
},
}));
const vuelidate = useVuelidate(rules, state);
const onSubmit = async () => {
const isValid = await vuelidate.value.$validate();
if (!isValid) {
state.hasFailValidationOnce = true;
nextTick(scrollToFirstError);
return;
}
state.isSubmitting = true;
(async () => {
await updateMember(props.member._id, {
firstName: state.firstname,
lastName: state.lastname,
email: state.email,
birthdate: state.birthdate,
} as never);
})()
.then(() => {
notificationsStore.addNotification({
message: i18n.t('members.detail.profile.onUpdate.success', {
name: [props.member.firstName, props.member.lastName].filter(Boolean).join(' '),
}),
type: 'success',
timeout: 3_000,
});
queryClient.invalidateQueries({
queryKey: ['members', computed(() => props.member._id), 'history'],
});
})
.catch(handleSilentError)
.catch((error) => {
notificationsStore.addErrorNotification(
error,
i18n.t('members.detail.profile.onUpdate.fail', {
name: [props.member.firstName, props.member.lastName].filter(Boolean).join(' '),
}),
);
return Promise.reject(error);
})
.finally(() => {
state.isSubmitting = false;
});
};
watch(
() => props.member,
(member) => {
if (member) {
state.firstname = member.firstName || null;
state.lastname = member.lastName || null;
state.email = member.email || null;
state.birthdate = member.birthDate || null;
}
},
{ immediate: true },
);
</script>
Original file line number Diff line number Diff line change
@@ -34,18 +34,19 @@ import AppButton from '@/components/form/AppButton.vue';
import { Member, buildMemberWordpressProfileUrl, syncMember } from '@/services/api/members';
import { useNotificationsStore } from '@/store/notifications';
import { mdiOpenInNew } from '@mdi/js';
import { useQueryClient } from '@tanstack/vue-query';
import { isNil } from 'lodash';
import { PropType, reactive } from 'vue';
import { PropType, computed, reactive } from 'vue';
import { useI18n } from 'vue-i18n';
const emit = defineEmits(['update:member']);
const props = defineProps({
member: {
type: Object as PropType<Member>,
required: true,
},
});
const queryClient = useQueryClient();
const i18n = useI18n();
const notificationsStore = useNotificationsStore();
const state = reactive({
@@ -55,15 +56,17 @@ const state = reactive({
const onSync = () => {
state.isSyncing = true;
syncMember(props.member._id)
.then((member) => {
emit('update:member', member);
.then(() => {
notificationsStore.addNotification({
message: i18n.t('members.detail.wordpress.onSync.success', {
name: [props.member.firstName, props.member.lastName].filter(Boolean).join(' '),
}),
type: 'success',
timeout: 3_000,
});
queryClient.invalidateQueries({
queryKey: ['members', computed(() => props.member._id), 'history'],
});
})
.catch((error) => {
notificationsStore.addErrorNotification(
13 changes: 8 additions & 5 deletions src/views/Private/Members/MembersDetail.vue
Original file line number Diff line number Diff line change
@@ -252,8 +252,10 @@
class="mt-16 px-3 sm:px-0"
:description="$t('members.detail.profile.description')"
:title="$t('members.detail.profile.title')">
<ProfilePanel :member="member" @update:member="refetchMember" />
<WordpressPanel class="mt-3" :member="member" @update:member="refetchMember" />
<MemberProfilePanel :member="member" />
<MemberDevicesPanel class="mt-3" :member="member" />
<MemberCapabilitesPanel class="mt-3" :member="member" />
<MemberWordpressPanel class="mt-3" :member="member" />

<template #append>
<dl class="sticky top-3 flex flex-row flex-wrap gap-3">
@@ -430,12 +432,14 @@

<script setup lang="ts">
import ActivityGraph from './Detail/Activity/ActivityGraph.vue';
import MemberCapabilitesPanel from './Detail/MemberCapabilitesPanel.vue';
import MemberDevicesPanel from './Detail/MemberDevicesPanel.vue';
import MemberHistoryPanel from './Detail/MemberHistoryPanel.vue';
import ProfilePanel from './Detail/ProfilePanel.vue';
import MemberProfilePanel from './Detail/MemberProfilePanel.vue';
import MemberWordpressPanel from './Detail/MemberWordpressPanel.vue';
import SectionRow from './Detail/SectionRow.vue';
import SubscriptionsListPanel from './Detail/Subscriptions/SubscriptionsListPanel.vue';
import TicketsListPanel from './Detail/Tickets/TicketsListPanel.vue';
import WordpressPanel from './Detail/WordpressPanel.vue';
import MembersThumbnail from './MembersThumbnail.vue';
import ErrorState from '@/components/ErrorState.vue';
import LoadingSpinner from '@/components/LoadingSpinner.vue';
@@ -498,7 +502,6 @@ const {
isFetching: isFetchingMember,
data: member,
error: memberError,
refetch: refetchMember,
} = useQuery({
queryKey: ['members', computed(() => props.id)],
queryFn: ({ queryKey: [_, memberId] }) => getMember(memberId),

0 comments on commit 9051a78

Please sign in to comment.