feat(front) : onglet adresse prestataire (ERP-143) (#105)
Auto Tag Develop / tag (push) Successful in 7s
Auto Tag Develop / tag (push) Successful in 7s
Empilée sur ERP-142 (#104). ## Périmètre ERP-143 Onglet **Adresse** de l'écran `/providers/new` — saisie multi-adresses (blocs ajoutables) via la sous-ressource addresses. - **`ProviderAddressBlock.vue`** (miroir `SupplierAddressBlock` **simplifié**) : Sélecteur de sites (≥1, RG-3.05) / Catégories (PRESTATAIRE, RG-3.09) / Contact(s) rattaché(s) (depuis l'onglet Contact) / Pays (défaut France) / Code postal / Ville / Adresse (autocomplete BAN) / Complément. **Pas** de type d'adresse, **pas** de bennes, **pas** de triage (différence M2). - **RG-3.06** : `useAddressAutocomplete()` **réutilisé tel quel** — CP → liste des villes (BAN) ; cas dégradé (API down) → ville/adresse en saisie libre + toast unique. - **`useProviderForm`** étendu : `addresses`, `canAddAddress` (RG-3.05/3.09), `add/removeAddress`, `submitAddresses` (POST `/providers/{id}/addresses` + PATCH `/provider_addresses/{id}`, groupe `provider:write:addresses`), erreurs 422 **par ligne**. - **`useProviderReferentials`** : ajout des pays (`/countries`) pour le select Pays. - Helpers purs `utils/forms/providerAddress.ts` (`isProviderAddressValid`, `buildProviderAddressPayload` — relations en IRI, requis vides omis au POST). - « + Nouvelle adresse » / Supprimer (modal) / « Valider ». i18n `technique.providers.form.address` + `confirmDelete.address`. ## Conformité - `useApi()` only ; `Malio*` only ; aucun texte FR en dur ; `useAddressAutocomplete` non réécrit ; pas d'import inter-module (helpers ré-implémentés côté Technique, règle ABSOLUE n°1). ## Vérifications - Vitest : 436/436 (18 nouveaux : helpers adresse, bloc — BAN dégradé/allow-create/mapping erreurs, workflow adresses POST/PATCH/422 par ligne). - ESLint : OK. - `nuxi typecheck` : 0 erreur sur les fichiers source du ticket. - Golden path navigateur : page compile, onglet Contact OK. NB : l'onglet Adresse est gaté derrière la validation principal+contact (multiselect `Malio` non pilotable en a11y) — couvert par tests unitaires (montage + BAN + mapping) + typecheck. Reviewed-on: #105 Co-authored-by: tristan <tristan@yuno.malio.fr> Co-committed-by: tristan <tristan@yuno.malio.fr>
This commit was merged in pull request #105.
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
<template>
|
||||
<div class="relative grid grid-cols-4 gap-x-[44px] gap-y-4 bg-white py-4 pl-[28px] pr-[60px] shadow-[0_4px_4px_0_rgba(0,0,0,0.25)]">
|
||||
<!-- Suppression : modal de confirmation cote parent. -->
|
||||
<MalioButtonIcon
|
||||
v-if="removable && !readonly"
|
||||
icon="mdi:delete-outline"
|
||||
variant="ghost"
|
||||
button-class="absolute top-3 right-3"
|
||||
v-bind="{ ariaLabel: t('technique.providers.form.address.remove') }"
|
||||
@click="$emit('remove')"
|
||||
/>
|
||||
|
||||
<!-- Sites Starseed : multiselect a tags (>= 1 obligatoire, RG-3.05). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.siteIris"
|
||||
:options="siteOptions"
|
||||
:label="t('technique.providers.form.address.sites')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.sites"
|
||||
@update:model-value="(v: (string | number)[]) => update('siteIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Categories de type PRESTATAIRE (>= 1 obligatoire, RG-3.09). -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.categoryIris"
|
||||
:options="categoryOptions"
|
||||
:label="t('technique.providers.form.address.categories')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.categories"
|
||||
@update:model-value="(v: (string | number)[]) => update('categoryIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<!-- Contacts rattaches (M2M, facultatif) : alimente par l'onglet Contact. -->
|
||||
<MalioSelectCheckbox
|
||||
:model-value="model.contactIris"
|
||||
:options="contactOptions"
|
||||
:label="t('technique.providers.form.address.contacts')"
|
||||
:display-tag="true"
|
||||
:readonly="readonly"
|
||||
@update:model-value="(v: (string | number)[]) => update('contactIris', v.map(String))"
|
||||
/>
|
||||
|
||||
<MalioSelect
|
||||
:model-value="model.country"
|
||||
:options="countryOptions"
|
||||
:label="t('technique.providers.form.address.country')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
@update:model-value="(v: string | number | null) => update('country', String(v ?? 'France'))"
|
||||
/>
|
||||
|
||||
<MalioInputText
|
||||
:model-value="model.postalCode"
|
||||
:label="t('technique.providers.form.address.postalCode')"
|
||||
:mask="POSTAL_CODE_MASK"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.postalCode"
|
||||
@update:model-value="onPostalCodeChange"
|
||||
/>
|
||||
|
||||
<!-- Ville : MalioSelect alimente par le code postal (BAN). Saisie libre si BAN indispo. -->
|
||||
<MalioSelect
|
||||
v-if="!degraded"
|
||||
:model-value="model.city"
|
||||
:options="cityOptions"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
empty-option-label=""
|
||||
:required="true"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string | number | null) => update('city', v === null ? null : String(v))"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.city"
|
||||
:label="t('technique.providers.form.address.city')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.city"
|
||||
@update:model-value="(v: string) => update('city', v)"
|
||||
/>
|
||||
|
||||
<!-- Adresse (BAN) sur 2 colonnes + Adresse complementaire. allow-create : le
|
||||
texte saisi est conserve si la BAN ne propose rien (saisie manuelle). -->
|
||||
<div class="col-span-2">
|
||||
<MalioInputAutocomplete
|
||||
v-if="!readonly"
|
||||
:model-value="model.street"
|
||||
:options="addressOptions"
|
||||
:loading="addressLoading"
|
||||
:min-search-length="3"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.street"
|
||||
:allow-create="true"
|
||||
:no-results-text="t('technique.providers.form.address.streetNotFound')"
|
||||
@update:model-value="(v: string | number | null) => update('street', v === null ? null : String(v))"
|
||||
@search="onAddressSearch"
|
||||
@select="onAddressSelect"
|
||||
/>
|
||||
<MalioInputText
|
||||
v-else
|
||||
:model-value="model.street"
|
||||
:label="t('technique.providers.form.address.street')"
|
||||
:readonly="readonly"
|
||||
:required="true"
|
||||
:error="errors?.street"
|
||||
@update:model-value="(v: string) => update('street', v)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="col-span-1">
|
||||
<MalioInputText
|
||||
:model-value="model.streetComplement"
|
||||
:label="t('technique.providers.form.address.streetComplement')"
|
||||
:readonly="readonly"
|
||||
:error="errors?.streetComplement"
|
||||
@update:model-value="(v: string) => update('streetComplement', v)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useAddressAutocomplete, type AddressSuggestion } from '~/shared/composables/useAddressAutocomplete'
|
||||
import type { RefOption } from '~/modules/technique/composables/useProviderReferentials'
|
||||
import type { ProviderAddressFormDraft } from '~/modules/technique/types/providerForm'
|
||||
|
||||
// Masque code postal FR : 5 chiffres.
|
||||
const POSTAL_CODE_MASK = '#####'
|
||||
|
||||
const props = defineProps<{
|
||||
/** Brouillon de l'adresse (v-model). */
|
||||
modelValue: ProviderAddressFormDraft
|
||||
/** Categories autorisees sur une adresse (type PRESTATAIRE). */
|
||||
categoryOptions: RefOption[]
|
||||
/** Sites Starseed disponibles. */
|
||||
siteOptions: RefOption[]
|
||||
/** Contacts deja saisis, rattachables a l'adresse. */
|
||||
contactOptions: RefOption[]
|
||||
/** Pays disponibles (France par defaut). */
|
||||
countryOptions: RefOption[]
|
||||
removable?: boolean
|
||||
readonly?: boolean
|
||||
/** Erreurs serveur 422 de cette ligne, indexees par champ (ERP-101). */
|
||||
errors?: Record<string, string>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: ProviderAddressFormDraft]
|
||||
'remove': []
|
||||
/** Emis une fois quand le service d'autocompletion bascule en indisponible. */
|
||||
'degraded': []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const autocomplete = useAddressAutocomplete()
|
||||
|
||||
const model = computed(() => props.modelValue)
|
||||
|
||||
// Repli saisie libre de la VILLE quand la BAN est indisponible (recuperable).
|
||||
const degraded = ref(false)
|
||||
let unavailableNotified = false
|
||||
const banCityOptions = ref<RefOption[]>([])
|
||||
const banAddressOptions = ref<RefOption[]>([])
|
||||
|
||||
// Options ville effectives : on garantit que la ville courante figure toujours
|
||||
// dans la liste, sinon MalioSelect afficherait un champ vide en lecture seule.
|
||||
const cityOptions = computed<RefOption[]>(() => {
|
||||
const current = props.modelValue.city
|
||||
if (current && !banCityOptions.value.some(o => o.value === current)) {
|
||||
return [{ value: current, label: current }, ...banCityOptions.value]
|
||||
}
|
||||
return banCityOptions.value
|
||||
})
|
||||
|
||||
// Meme garantie pour le champ Adresse : la rue courante doit toujours figurer
|
||||
// dans les options, sinon MalioInputAutocomplete laisse le champ vide.
|
||||
const addressOptions = computed<RefOption[]>(() => {
|
||||
const current = props.modelValue.street
|
||||
if (current && !banAddressOptions.value.some(o => o.value === current)) {
|
||||
return [{ value: current, label: current }, ...banAddressOptions.value]
|
||||
}
|
||||
return banAddressOptions.value
|
||||
})
|
||||
const addressLoading = ref(false)
|
||||
// Conserve les suggestions d'adresse pour retrouver ville/CP au moment du select.
|
||||
let lastAddressSuggestions: AddressSuggestion[] = []
|
||||
|
||||
/** Emet un nouveau brouillon avec le champ modifie (immutabilite). */
|
||||
function update<K extends keyof ProviderAddressFormDraft>(field: K, value: ProviderAddressFormDraft[K]): void {
|
||||
emit('update:modelValue', { ...props.modelValue, [field]: value })
|
||||
}
|
||||
|
||||
/** Previent le parent (toast unique) que l'autocompletion est indisponible. */
|
||||
function notifyUnavailable(): void {
|
||||
if (!unavailableNotified) {
|
||||
unavailableNotified = true
|
||||
emit('degraded')
|
||||
}
|
||||
}
|
||||
|
||||
/** Saisie du code postal → met a jour le champ + interroge la BAN pour la ville (RG-3.06). */
|
||||
async function onPostalCodeChange(value: string): Promise<void> {
|
||||
update('postalCode', value)
|
||||
|
||||
const digits = (value ?? '').replace(/\D/g, '')
|
||||
if (digits.length < 5) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const suggestions = await autocomplete.searchCity(digits)
|
||||
banCityOptions.value = suggestions.map(s => ({ value: s.city, label: s.city }))
|
||||
degraded.value = false
|
||||
}
|
||||
catch {
|
||||
degraded.value = true
|
||||
notifyUnavailable()
|
||||
}
|
||||
}
|
||||
|
||||
/** Recherche d'adresse assistee (event de MalioInputAutocomplete). */
|
||||
async function onAddressSearch(query: string): Promise<void> {
|
||||
// La BAN exige au moins 3 caracteres : on n'envoie rien en deca (evite un 400).
|
||||
if (query.trim().length < 3) {
|
||||
banAddressOptions.value = []
|
||||
return
|
||||
}
|
||||
addressLoading.value = true
|
||||
try {
|
||||
const postalCode = (model.value.postalCode ?? '').replace(/\D/g, '') || undefined
|
||||
const suggestions = await autocomplete.searchAddress(query, postalCode)
|
||||
lastAddressSuggestions = suggestions
|
||||
banAddressOptions.value = suggestions.map(s => ({ value: s.street, label: s.label }))
|
||||
}
|
||||
catch {
|
||||
// Erreur transitoire : on vide les suggestions, la prochaine frappe reessaie.
|
||||
banAddressOptions.value = []
|
||||
notifyUnavailable()
|
||||
}
|
||||
finally {
|
||||
addressLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Selection d'une suggestion d'adresse → remplit rue + ville + CP. */
|
||||
function onAddressSelect(option: { label: string, value: string | number } | null): void {
|
||||
if (option === null) {
|
||||
return
|
||||
}
|
||||
const suggestion = lastAddressSuggestions.find(s => s.street === option.value)
|
||||
if (!suggestion) {
|
||||
update('street', String(option.value))
|
||||
return
|
||||
}
|
||||
emit('update:modelValue', {
|
||||
...props.modelValue,
|
||||
street: suggestion.street,
|
||||
city: suggestion.city,
|
||||
postalCode: suggestion.postalCode,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,157 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { defineComponent, h, ref, computed } from 'vue'
|
||||
import { emptyProviderAddress } from '~/modules/technique/types/providerForm'
|
||||
import ProviderAddressBlock from '../ProviderAddressBlock.vue'
|
||||
|
||||
// Mocks controlables du composable BAN (hoisted), reutilise tel quel du M1/M2.
|
||||
const { searchCityMock, searchAddressMock } = vi.hoisted(() => ({
|
||||
searchCityMock: vi.fn(),
|
||||
searchAddressMock: vi.fn(),
|
||||
}))
|
||||
vi.mock('~/shared/composables/useAddressAutocomplete', () => ({
|
||||
useAddressAutocomplete: () => ({
|
||||
searchCity: searchCityMock,
|
||||
searchAddress: searchAddressMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
// Auto-imports Nuxt/Vue utilises sans import explicite par le composant.
|
||||
vi.stubGlobal('useI18n', () => ({ t: (key: string) => key }))
|
||||
vi.stubGlobal('ref', ref)
|
||||
vi.stubGlobal('computed', computed)
|
||||
|
||||
// Stub de MalioInputAutocomplete : expose les `value` des options + allowCreate.
|
||||
const MalioInputAutocompleteStub = defineComponent({
|
||||
name: 'MalioInputAutocomplete',
|
||||
props: {
|
||||
modelValue: { type: [String, Number, null], default: undefined },
|
||||
options: { type: Array as () => { value: string | number, label: string }[], default: () => [] },
|
||||
loading: { type: Boolean, default: false },
|
||||
minSearchLength: { type: Number, default: 0 },
|
||||
label: { type: String, default: '' },
|
||||
readonly: { type: Boolean, default: false },
|
||||
allowCreate: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['update:modelValue', 'search', 'select'],
|
||||
setup(props) {
|
||||
return () => h('div', {
|
||||
'data-testid': 'addr-autocomplete',
|
||||
'data-options': JSON.stringify(props.options.map(o => o.value)),
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
function mountBlock(overrides: Record<string, unknown> = {}, errors?: Record<string, string>) {
|
||||
return mount(ProviderAddressBlock, {
|
||||
props: {
|
||||
modelValue: { ...emptyProviderAddress(), ...overrides },
|
||||
categoryOptions: [],
|
||||
siteOptions: [],
|
||||
contactOptions: [],
|
||||
countryOptions: [],
|
||||
...(errors ? { errors } : {}),
|
||||
},
|
||||
global: {
|
||||
stubs: {
|
||||
MalioButtonIcon: true,
|
||||
MalioSelect: true,
|
||||
MalioSelectCheckbox: true,
|
||||
MalioInputText: true,
|
||||
MalioInputAutocomplete: MalioInputAutocompleteStub,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
describe('ProviderAddressBlock — version simplifiee M3 (pas de type/bennes/triage)', () => {
|
||||
it('ne rend NI type d\'adresse, NI bennes, NI prestation de triage (difference M2)', () => {
|
||||
const wrapper = mountBlock()
|
||||
// Pas de stepper (bennes) ni de case a cocher (triage) dans le bloc M3.
|
||||
expect(wrapper.find('malio-input-number-stub').exists()).toBe(false)
|
||||
expect(wrapper.find('malio-checkbox-stub').exists()).toBe(false)
|
||||
// Aucun select ne porte le label « type d'adresse ».
|
||||
const hasAddressType = wrapper.findAll('malio-select-stub').some(
|
||||
el => el.attributes('label') === 'technique.providers.form.address.addressType',
|
||||
)
|
||||
expect(hasAddressType).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProviderAddressBlock — mapping erreur par champ (ERP-101)', () => {
|
||||
it('affiche les erreurs serveur sur sites et categories (RG-3.05 / RG-3.09)', () => {
|
||||
const wrapper = mountBlock({}, {
|
||||
sites: 'Au moins un site est obligatoire.',
|
||||
categories: 'Au moins une catégorie est obligatoire.',
|
||||
})
|
||||
const checkboxes = wrapper.findAll('malio-select-checkbox-stub')
|
||||
const sitesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.sites')
|
||||
const categoriesField = checkboxes.find(el => el.attributes('label') === 'technique.providers.form.address.categories')
|
||||
|
||||
expect(sitesField?.attributes('error')).toBe('Au moins un site est obligatoire.')
|
||||
expect(categoriesField?.attributes('error')).toBe('Au moins une catégorie est obligatoire.')
|
||||
})
|
||||
|
||||
it('affiche l\'erreur serveur sur le code postal', () => {
|
||||
const wrapper = mountBlock({}, { postalCode: 'Code postal invalide.' })
|
||||
const field = wrapper.findAll('malio-input-text-stub').find(
|
||||
el => el.attributes('label') === 'technique.providers.form.address.postalCode',
|
||||
)
|
||||
expect(field?.attributes('error')).toBe('Code postal invalide.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ProviderAddressBlock — autocompletion BAN (RG-3.06)', () => {
|
||||
beforeEach(() => {
|
||||
searchCityMock.mockReset()
|
||||
searchAddressMock.mockReset()
|
||||
})
|
||||
|
||||
it('n\'appelle pas la BAN en deca de 3 caracteres', async () => {
|
||||
const wrapper = mountBlock()
|
||||
wrapper.findComponent(MalioInputAutocompleteStub).vm.$emit('search', 'ab')
|
||||
await flushPromises()
|
||||
expect(searchAddressMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('relance la recherche apres une erreur (pas de bascule definitive)', async () => {
|
||||
searchAddressMock
|
||||
.mockRejectedValueOnce(new Error('BAN indisponible'))
|
||||
.mockResolvedValueOnce([
|
||||
{ label: '1 rue du Test, Châtellerault', street: '1 rue du Test', postalCode: '86100', city: 'Châtellerault' },
|
||||
])
|
||||
const wrapper = mountBlock()
|
||||
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||
|
||||
auto.vm.$emit('search', 'rue du test')
|
||||
await flushPromises()
|
||||
auto.vm.$emit('search', 'rue du teste')
|
||||
await flushPromises()
|
||||
|
||||
expect(searchAddressMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('cas degrade : la BAN echoue -> emet « degraded » une seule fois (RG-3.06)', async () => {
|
||||
searchAddressMock.mockRejectedValue(new Error('BAN indisponible'))
|
||||
const wrapper = mountBlock()
|
||||
const auto = wrapper.findComponent(MalioInputAutocompleteStub)
|
||||
|
||||
auto.vm.$emit('search', 'rue du test')
|
||||
await flushPromises()
|
||||
auto.vm.$emit('search', 'rue du teste')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('degraded')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('active allow-create sur le champ Adresse (saisie manuelle libre)', () => {
|
||||
const wrapper = mountBlock()
|
||||
expect(wrapper.findComponent(MalioInputAutocompleteStub).props('allowCreate')).toBe(true)
|
||||
})
|
||||
|
||||
it('inclut la rue courante dans les options meme sans recherche BAN', () => {
|
||||
const wrapper = mountBlock({ street: '1 rue du Test' })
|
||||
const values = JSON.parse(wrapper.find('[data-testid="addr-autocomplete"]').attributes('data-options') ?? '[]')
|
||||
expect(values).toContain('1 rue du Test')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user