From 96f482ecf7ebaccdadf95f737cb6467e394afe65 Mon Sep 17 00:00:00 2001 From: Julien Vannier Date: Thu, 22 Jan 2026 17:00:09 +0100 Subject: [PATCH 01/10] Setup good base for the auto-complete modifier --- addon/components/utils/address-form.hbs | 4 +- addon/components/utils/address-inline.hbs | 2 +- addon/modifiers/setup-autocomplete.ts | 194 ++++++++++++++++++ app/modifiers/setup-autocomplete.js | 1 + app/styles/components/utils/address-form.less | 6 +- package.json | 2 + pnpm-lock.yaml | 11 + .../{application.js => application.ts} | 27 ++- .../components/utils/address-form-test.ts | 2 +- .../components/utils/address-inline-test.ts | 4 +- 10 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 addon/modifiers/setup-autocomplete.ts create mode 100644 app/modifiers/setup-autocomplete.js rename tests/dummy/app/controllers/{application.js => application.ts} (63%) diff --git a/addon/components/utils/address-form.hbs b/addon/components/utils/address-form.hbs index dd6163fe..a02684a2 100644 --- a/addon/components/utils/address-form.hbs +++ b/addon/components/utils/address-form.hbs @@ -59,7 +59,7 @@ {{t "upf_utils.address_form.line_1"}} {{#if this.useGoogleAutocomplete}} -
+
- + \ No newline at end of file diff --git a/addon/components/utils/address-inline.hbs b/addon/components/utils/address-inline.hbs index 4d33c3a2..f842ca88 100644 --- a/addon/components/utils/address-inline.hbs +++ b/addon/components/utils/address-inline.hbs @@ -1,6 +1,6 @@
{{#if this.useGoogleAutocomplete}} -
+
{}; +} + +export default class RegisterFormField extends Modifier { + declare targetElement: HTMLElement | null; + declare targetInput: HTMLInputElement | null; + declare callback: (result: AutocompletionResult) => void; + + constructor(owner: unknown, args: ArgsFor) { + super(owner, args); + registerDestructor(this, cleanup); + } + + modify( + element: HTMLElement, + _: PositionalArgs, + { callback }: NamedArgs + ): void { + const input = this.getInputElement(element); + if (!input) return; + + this.targetInput = input; + this.callback = callback; + + if (element === input) { + const wrapper = document.createElement('div'); + wrapper.classList.add('autocomplete-input-container'); + input.parentNode?.insertBefore(wrapper, input); + wrapper.appendChild(input); + this.targetElement = wrapper; + } else { + this.targetElement = element; + this.targetElement.classList.add('autocomplete-input-container'); + } + + this.setupAutoComplete(); + } + + private getInputElement(element: HTMLElement): HTMLInputElement { + if (element.tagName === 'INPUT' && (element as HTMLInputElement).type === 'text') { + return element as HTMLInputElement; + } else { + const inputElement = element.querySelector('input[type="text"]') as HTMLInputElement; + if (inputElement) { + return inputElement; + } else { + assert( + '[modifier][setup-autocomplete] No input[type="text"] element found in the provided element or its children' + ); + } + } + } + + private setupAutoComplete(): void { + if (isTesting()) return; + this.appendPacContainerLocally(); + + const loader = new Loader({ + apiKey: getOwner(this).resolveRegistration('config:environment').google_map_api_key, + version: 'weekly' + }); + + loader.importLibrary('places').then(({ Autocomplete }) => { + this.setupAutocompleteListeners(Autocomplete); + }); + } + + private setupAutocompleteListeners( + Autocomplete: new (input: HTMLInputElement, options?: any) => google.maps.places.Autocomplete + ): void { + const options = { + fields: ['address_components'], + strictBounds: false, + types: ['address'] + }; + const autocomplete = new Autocomplete(this.targetInput!, options); + + autocomplete.addListener('place_changed', () => { + const place = autocomplete.getPlace(); + this.fillInAddress(place); + }); + } + + private fillInAddress(place: GPlaceResult): void { + const result: AutocompletionResult = { + address1: '', + address2: '', + city: '', + state: '', + zipcode: '', + country: '' + }; + + const mapper: { [key: string]: (comp: GAddressComponent) => void } = { + street_number: (comp) => { + result.address1 = `${comp.long_name} ${result.address1}`; + }, + route: (comp) => { + result.address1 += comp.long_name; + }, + subpremise: (comp) => { + result.address2 = comp.long_name; + }, + postal_code: (comp) => { + result.zipcode = `${comp.long_name}${result.zipcode}`; + }, + postal_code_suffix: (comp) => { + result.zipcode = `${result.zipcode}-${comp.long_name}`; + }, + locality: (comp) => { + result.city = comp.long_name; + }, + postal_town: (comp) => { + result.city = comp.long_name; + }, + administrative_area_level_1: (comp) => { + result.state = comp.long_name; + }, + country: (comp) => { + const selectedCountry: CountryData | undefined = countries.find( + (country) => country.alpha2 === comp.short_name + ); + result.country = selectedCountry?.alpha2 ?? ''; + } + }; + + (place.address_components ?? []).reverse().forEach((component) => { + const componentType: string = component.types[0]; + + mapper[componentType]?.(component); + }); + + this.callback(result); + } + + private appendPacContainerLocally(): void { + const onInput = () => { + this.setupPacContainerObserver(); + this.targetInput!.removeEventListener('input', onInput); + }; + this.targetInput!.addEventListener('input', onInput); + } + + private setupPacContainerObserver(): void { + const observer = new MutationObserver((mutationList: any) => { + for (const mutation of mutationList) { + if (mutation.type === 'childList') { + for (const node of mutation.addedNodes) { + if (node?.classList?.contains('pac-container')) { + this.targetElement!.append(node); + observer?.disconnect(); + return; + } + } + } + } + }); + observer.observe(document.body, { childList: true }); + } +} diff --git a/app/modifiers/setup-autocomplete.js b/app/modifiers/setup-autocomplete.js new file mode 100644 index 00000000..d36f0c9b --- /dev/null +++ b/app/modifiers/setup-autocomplete.js @@ -0,0 +1 @@ +export { default } from '@upfluence/ember-upf-utils/modifiers/setup-autocomplete'; diff --git a/app/styles/components/utils/address-form.less b/app/styles/components/utils/address-form.less index 687e2a28..c569f31a 100644 --- a/app/styles/components/utils/address-form.less +++ b/app/styles/components/utils/address-form.less @@ -1,6 +1,10 @@ -.google-autocomplete-input-container { +.autocomplete-input-container { position: relative; + .pac-target-input { + width: 100%; + } + .pac-container { background-color: var(--color-white); border: 1px solid var(--color-border-default); diff --git a/package.json b/package.json index 7de1cc00..19a2c950 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "ember-cli-htmlbars": "^5.7.2", "ember-cli-less": "^2.0.6", "ember-cli-typescript": "^4.2.1", + "ember-modifier": "^3.2.7", "ember-named-blocks-polyfill": "^0.2.4", "moment": "^2.29.4", "tinycolor2": "^1.4.1" @@ -68,6 +69,7 @@ "@types/ember__component": "^3.16.6", "@types/ember__controller": "^3.16.6", "@types/ember__debug": "^3.16.5", + "@types/ember__destroyable": "^4.0.5", "@types/ember__engine": "^3.16.3", "@types/ember__error": "^3.16.1", "@types/ember__object": "^3.12.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 54580fce..9cd40def 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,9 @@ importers: ember-cli-typescript: specifier: ^4.2.1 version: 4.2.1 + ember-modifier: + specifier: ^3.2.7 + version: 3.2.7(@babel/core@7.26.0) ember-named-blocks-polyfill: specifier: ^0.2.4 version: 0.2.5 @@ -99,6 +102,9 @@ importers: '@types/ember__debug': specifier: ^3.16.5 version: 3.16.12 + '@types/ember__destroyable': + specifier: ^4.0.5 + version: 4.0.5 '@types/ember__engine': specifier: ^3.16.3 version: 3.16.9 @@ -1244,6 +1250,9 @@ packages: '@types/ember__debug@3.16.12': resolution: {integrity: sha512-+k+9qNmTaLw6xQCvcZm1DrQ6D2n9uob/8RVAK6jxFkxyPNbdt66z3fn7V/NHIURcBVhGVgf1qr5x62bHW0PIdg==} + '@types/ember__destroyable@4.0.5': + resolution: {integrity: sha512-spJyZxpvecssbXkaOQYcbnlWgb+TasFaKrgAYVbykZY6saMwUdMOGDDoW6uP/y/+A8Jj/fUIatPWJLepeSfgww==} + '@types/ember__engine@3.16.9': resolution: {integrity: sha512-Dab779R+nuGoprrOV1qzomUSEGM9eqXxFB1q5ArK00IDf+B6qkws2rJg6pB7PBSdiBhnhAq8tAJ/WNy4eC/jwQ==} @@ -8316,6 +8325,8 @@ snapshots: '@types/ember__engine': 3.16.9 '@types/ember__object': 3.12.13 + '@types/ember__destroyable@4.0.5': {} + '@types/ember__engine@3.16.9': dependencies: '@types/ember__object': 3.12.13 diff --git a/tests/dummy/app/controllers/application.js b/tests/dummy/app/controllers/application.ts similarity index 63% rename from tests/dummy/app/controllers/application.js rename to tests/dummy/app/controllers/application.ts index cdf40f82..24394dc0 100644 --- a/tests/dummy/app/controllers/application.js +++ b/tests/dummy/app/controllers/application.ts @@ -1,6 +1,7 @@ import Controller from '@ember/controller'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; +import { AutocompletionResult } from '@upfluence/ember-upf-utils/modifiers/setup-autocomplete'; export default class ApplicationController extends Controller { @tracked selectedItems = ['toto']; @@ -20,6 +21,8 @@ export default class ApplicationController extends Controller { zipcode: '10016' }; @tracked shippingAddress = { address: '69 Avenue Victor Hugo, Paris, France', resolved_address: null }; + @tracked inputValue: string = ''; + @tracked ossInputValue: string = ''; constructor() { super(...arguments); @@ -27,9 +30,7 @@ export default class ApplicationController extends Controller { } @action - onSocialMediaHandlerChanged(socialNetwork, handle, formattedUrl) { - console.log(socialNetwork, handle, formattedUrl); - } + onSocialMediaHandlerChanged() {} @action onBlobSwitch() { @@ -37,18 +38,28 @@ export default class ApplicationController extends Controller { } @action - onChange(address, isValid) { - console.log(address, isValid); - } + onChange() {} @action - onLogoChange(icon, color) { + onLogoChange(icon: string, color: string) { this.selectedColor = color; this.selectedIcon = icon; } @action - onChangeAddress(value) { + onChangeAddress(value: any) { this.shippingAddress = value; } + + @action + onAutoComplete(value: AutocompletionResult) { + this.inputValue = value.address1; + console.log('onAutoComplete', value); + } + + @action + onOssAutoComplete(value: AutocompletionResult) { + this.ossInputValue = value.address1; + console.log('onAutoComplete', value); + } } diff --git a/tests/integration/components/utils/address-form-test.ts b/tests/integration/components/utils/address-form-test.ts index 9e32e585..e8f4dbd2 100644 --- a/tests/integration/components/utils/address-form-test.ts +++ b/tests/integration/components/utils/address-form-test.ts @@ -315,7 +315,7 @@ module('Integration | Component | utils/address-form', function (hooks) { @onChange={{this.onChange}} />` ); - assert.dom('.google-autocomplete-input-container[data-control-name="address-form-address1"]').exists(); + assert.dom('.autocomplete-input-container[data-control-name="address-form-address1"]').exists(); }); module('When @hideNameAttrs is true', () => { diff --git a/tests/integration/components/utils/address-inline-test.ts b/tests/integration/components/utils/address-inline-test.ts index 7ec990b6..680bdd66 100644 --- a/tests/integration/components/utils/address-inline-test.ts +++ b/tests/integration/components/utils/address-inline-test.ts @@ -46,11 +46,11 @@ module('Integration | Component | utils/address-inline', function (hooks) { }); }); - test('when @useGoogleAutocomplete is true, it renders the google autocomplete input', async function (assert) { + test('when @useGoogleAutocomplete is true, it renders the autocomplete input', async function (assert) { await render( hbs`` ); - assert.dom('.google-autocomplete-input-container [data-control-name="address-inline"]').exists(); + assert.dom('.autocomplete-input-container [data-control-name="address-inline"]').exists(); }); }); From 3c858d5ac20576b316482ef015fcccc52f1827ae Mon Sep 17 00:00:00 2001 From: Julien Vannier Date: Thu, 22 Jan 2026 17:35:16 +0100 Subject: [PATCH 02/10] Improve code typing & use smaller function & error management --- addon/modifiers/setup-autocomplete.ts | 218 +++++++++++++++++--------- 1 file changed, 140 insertions(+), 78 deletions(-) diff --git a/addon/modifiers/setup-autocomplete.ts b/addon/modifiers/setup-autocomplete.ts index 63885671..1289ae76 100644 --- a/addon/modifiers/setup-autocomplete.ts +++ b/addon/modifiers/setup-autocomplete.ts @@ -6,10 +6,22 @@ import { getOwner } from '@ember/application'; import { isTesting } from '@embroider/macros'; import { Loader } from '@googlemaps/js-api-loader'; -import { CountryData, countries } from '@upfluence/oss-components/utils/country-codes'; +import { countries } from '@upfluence/oss-components/utils/country-codes'; -type GAddressComponent = google.maps.GeocoderAddressComponent; -type GPlaceResult = google.maps.places.PlaceResult; +type GoogleAddressComponent = google.maps.GeocoderAddressComponent; +type GooglePlaceResult = google.maps.places.PlaceResult; +type GoogleAutocomplete = google.maps.places.Autocomplete; +type GoogleAutocompleteOptions = google.maps.places.AutocompleteOptions; +type AddressComponentType = + | 'street_number' + | 'route' + | 'subpremise' + | 'postal_code' + | 'postal_code_suffix' + | 'locality' + | 'postal_town' + | 'administrative_area_level_1' + | 'country'; export type AutocompletionResult = { address1: string; @@ -24,21 +36,29 @@ interface SetupAutocompleteSignature { Element: HTMLElement; Args: { Named: { - callback(): AutocompletionResult; + callback(result: AutocompletionResult): void; }; }; } -function cleanup(instance: RegisterFormField) { +const AUTOCOMPLETE_CONTAINER_CLASS = 'autocomplete-input-container'; +const PAC_CONTAINER_CLASS = 'pac-container'; +const AUTOCOMPLETE_OPTIONS: GoogleAutocompleteOptions = { + fields: ['address_components'], + strictBounds: false, + types: ['address'] +}; + +function cleanup(instance: SetupAutocompleteModifier): void { instance.targetElement = null; instance.targetInput = null; - instance.callback = () => {}; } -export default class RegisterFormField extends Modifier { - declare targetElement: HTMLElement | null; - declare targetInput: HTMLInputElement | null; - declare callback: (result: AutocompletionResult) => void; +export default class SetupAutocompleteModifier extends Modifier { + targetElement: HTMLElement | null = null; + targetInput: HTMLInputElement | null = null; + + private callback: ((result: AutocompletionResult) => void) | null = null; constructor(owner: unknown, args: ArgsFor) { super(owner, args); @@ -55,67 +75,98 @@ export default class RegisterFormField extends Modifier { if (isTesting()) return; + this.appendPacContainerLocally(); - const loader = new Loader({ - apiKey: getOwner(this).resolveRegistration('config:environment').google_map_api_key, - version: 'weekly' - }); + try { + const apiKey = this.getGoogleMapsApiKey(); + const loader = new Loader({ + apiKey, + version: 'weekly' + }); - loader.importLibrary('places').then(({ Autocomplete }) => { - this.setupAutocompleteListeners(Autocomplete); - }); + const { Autocomplete } = await loader.importLibrary('places'); + this.initializeAutocomplete(Autocomplete); + } catch (error) { + console.error('[modifier][setup-autocomplete] Failed to load Google Maps API:', error); + } } - private setupAutocompleteListeners( - Autocomplete: new (input: HTMLInputElement, options?: any) => google.maps.places.Autocomplete + private getGoogleMapsApiKey(): string { + const config = getOwner(this).resolveRegistration('config:environment'); + const apiKey = config?.google_map_api_key; + + assert('[modifier][setup-autocomplete] Google Maps API key is not configured', apiKey); + return apiKey; + } + + private initializeAutocomplete( + AutocompleteConstructor: new ( + input: HTMLInputElement, + options?: google.maps.places.AutocompleteOptions + ) => GoogleAutocomplete ): void { - const options = { - fields: ['address_components'], - strictBounds: false, - types: ['address'] - }; - const autocomplete = new Autocomplete(this.targetInput!, options); + assert('[modifier][setup-autocomplete] Target input is not initialized', this.targetInput !== null); + + const autocomplete = new AutocompleteConstructor(this.targetInput, AUTOCOMPLETE_OPTIONS); autocomplete.addListener('place_changed', () => { const place = autocomplete.getPlace(); - this.fillInAddress(place); + this.handlePlaceChanged(place); }); } - private fillInAddress(place: GPlaceResult): void { + private handlePlaceChanged(place: GooglePlaceResult): void { + if (!place.address_components) return; + + const result = this.parseAddressComponents(place.address_components); + this.callback?.(result); + } + + private parseAddressComponents(components: GoogleAddressComponent[]): AutocompletionResult { const result: AutocompletionResult = { address1: '', address2: '', @@ -125,70 +176,81 @@ export default class RegisterFormField extends Modifier void } = { - street_number: (comp) => { - result.address1 = `${comp.long_name} ${result.address1}`; + const mapper: Record void> = { + street_number: (comp: GoogleAddressComponent) => { + result.address1 = `${comp.long_name} ${result.address1}`.trim(); }, - route: (comp) => { + route: (comp: GoogleAddressComponent) => { result.address1 += comp.long_name; }, - subpremise: (comp) => { + subpremise: (comp: GoogleAddressComponent) => { result.address2 = comp.long_name; }, - postal_code: (comp) => { + postal_code: (comp: GoogleAddressComponent) => { result.zipcode = `${comp.long_name}${result.zipcode}`; }, - postal_code_suffix: (comp) => { + postal_code_suffix: (comp: GoogleAddressComponent) => { result.zipcode = `${result.zipcode}-${comp.long_name}`; }, - locality: (comp) => { + locality: (comp: GoogleAddressComponent) => { result.city = comp.long_name; }, - postal_town: (comp) => { + postal_town: (comp: GoogleAddressComponent) => { result.city = comp.long_name; }, - administrative_area_level_1: (comp) => { + administrative_area_level_1: (comp: GoogleAddressComponent) => { result.state = comp.long_name; }, - country: (comp) => { - const selectedCountry: CountryData | undefined = countries.find( - (country) => country.alpha2 === comp.short_name - ); + country: (comp: GoogleAddressComponent) => { + const selectedCountry = countries.find((country) => country.alpha2 === comp.short_name); result.country = selectedCountry?.alpha2 ?? ''; } }; - (place.address_components ?? []).reverse().forEach((component) => { - const componentType: string = component.types[0]; - + (components ?? []).reverse().forEach((component) => { + const componentType: AddressComponentType = component.types[0] as AddressComponentType; mapper[componentType]?.(component); }); - - this.callback(result); + return result; } private appendPacContainerLocally(): void { - const onInput = () => { + assert('[modifier][setup-autocomplete] Target input is not initialized', this.targetInput !== null); + + const handleInput = (): void => { this.setupPacContainerObserver(); - this.targetInput!.removeEventListener('input', onInput); + this.targetInput?.removeEventListener('input', handleInput); }; - this.targetInput!.addEventListener('input', onInput); + + this.targetInput.addEventListener('input', handleInput, { once: true }); } private setupPacContainerObserver(): void { - const observer = new MutationObserver((mutationList: any) => { + const observer = new MutationObserver((mutationList: MutationRecord[]) => { for (const mutation of mutationList) { - if (mutation.type === 'childList') { - for (const node of mutation.addedNodes) { - if (node?.classList?.contains('pac-container')) { - this.targetElement!.append(node); - observer?.disconnect(); - return; - } + if (mutation.type !== 'childList') { + continue; + } + + for (const node of mutation.addedNodes) { + if (this.isPacContainer(node)) { + this.relocatePacContainer(node as HTMLElement); + observer.disconnect(); + return; } } } }); + observer.observe(document.body, { childList: true }); } + + private isPacContainer(node: Node): node is HTMLElement { + return node instanceof HTMLElement && node.classList.contains(PAC_CONTAINER_CLASS); + } + + private relocatePacContainer(container: HTMLElement): void { + assert('[modifier][setup-autocomplete] Target element is not initialized', this.targetElement !== null); + this.targetElement.appendChild(container); + } } From c0cdaae169211f4c2d1aa65d009463bb53e02303 Mon Sep 17 00:00:00 2001 From: Julien Vannier Date: Fri, 23 Jan 2026 12:05:35 +0100 Subject: [PATCH 03/10] Fix visual glitch effect in input after autocomplete selection --- addon/modifiers/setup-autocomplete.ts | 48 +++++++++++---------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/addon/modifiers/setup-autocomplete.ts b/addon/modifiers/setup-autocomplete.ts index 1289ae76..1b58992e 100644 --- a/addon/modifiers/setup-autocomplete.ts +++ b/addon/modifiers/setup-autocomplete.ts @@ -52,11 +52,13 @@ const AUTOCOMPLETE_OPTIONS: GoogleAutocompleteOptions = { function cleanup(instance: SetupAutocompleteModifier): void { instance.targetElement = null; instance.targetInput = null; + instance.result = null; } export default class SetupAutocompleteModifier extends Modifier { targetElement: HTMLElement | null = null; targetInput: HTMLInputElement | null = null; + result: AutocompletionResult | null = null; private callback: ((result: AutocompletionResult) => void) | null = null; @@ -118,29 +120,16 @@ export default class SetupAutocompleteModifier extends Modifier { if (isTesting()) return; - this.appendPacContainerLocally(); - try { - const apiKey = this.getGoogleMapsApiKey(); - const loader = new Loader({ - apiKey, - version: 'weekly' - }); + const loader = new Loader({ + apiKey: getOwner(this).resolveRegistration('config:environment').google_map_api_key, + version: 'weekly' + }); - const { Autocomplete } = await loader.importLibrary('places'); + loader.importLibrary('places').then(({ Autocomplete }) => { this.initializeAutocomplete(Autocomplete); - } catch (error) { - console.error('[modifier][setup-autocomplete] Failed to load Google Maps API:', error); - } - } - - private getGoogleMapsApiKey(): string { - const config = getOwner(this).resolveRegistration('config:environment'); - const apiKey = config?.google_map_api_key; - - assert('[modifier][setup-autocomplete] Google Maps API key is not configured', apiKey); - return apiKey; + }); } private initializeAutocomplete( @@ -152,18 +141,22 @@ export default class SetupAutocompleteModifier extends Modifier { const place = autocomplete.getPlace(); this.handlePlaceChanged(place); }); + + this.targetInput.addEventListener('focusout', (event) => { + if ((event.target).value === this.result?.address1) return; + (event.target).value = this.result?.address1 ?? ''; + }); } private handlePlaceChanged(place: GooglePlaceResult): void { if (!place.address_components) return; - const result = this.parseAddressComponents(place.address_components); - this.callback?.(result); + this.result = this.parseAddressComponents(place.address_components); + this.callback?.(this.result); } private parseAddressComponents(components: GoogleAddressComponent[]): AutocompletionResult { @@ -211,18 +204,15 @@ export default class SetupAutocompleteModifier extends Modifier { - this.setupPacContainerObserver(); - this.targetInput?.removeEventListener('input', handleInput); - }; - - this.targetInput.addEventListener('input', handleInput, { once: true }); + this.targetInput.addEventListener('input', this.setupPacContainerObserver, { once: true }); } private setupPacContainerObserver(): void { From d58f261fcfdd8fdc6c0d6c773f10f47681fe3679 Mon Sep 17 00:00:00 2001 From: Julien Vannier Date: Fri, 23 Jan 2026 17:00:26 +0100 Subject: [PATCH 04/10] Use new auto-complete modifier for address-form & address-inline --- addon/components/utils/address-form.hbs | 18 ++- addon/components/utils/address-form.ts | 122 +++--------------- addon/components/utils/address-inline.hbs | 18 +-- addon/components/utils/address-inline.ts | 111 ++-------------- addon/modifiers/setup-autocomplete.ts | 37 ++++-- tests/dummy/app/controllers/application.ts | 11 +- tests/dummy/app/templates/application.hbs | 2 + .../components/utils/address-inline-test.ts | 2 +- 8 files changed, 75 insertions(+), 246 deletions(-) diff --git a/addon/components/utils/address-form.hbs b/addon/components/utils/address-form.hbs index a02684a2..82b6ab8d 100644 --- a/addon/components/utils/address-form.hbs +++ b/addon/components/utils/address-form.hbs @@ -59,16 +59,14 @@ {{t "upf_utils.address_form.line_1"}} {{#if this.useGoogleAutocomplete}} -
- -
+ {{else}} { return this.args.useGoogleAutocomplete ?? true; } - @action - initAutoCompletion(): void { - if (isTesting()) return; - this.appendContainerLocally(); - const loader = new Loader({ - apiKey: getOwner(this).resolveRegistration('config:environment').google_map_api_key, - version: 'weekly' - }); - - loader.importLibrary('places').then(({ Autocomplete }) => { - const input = document.querySelector('[data-control-name="address-form-address1"] input') as HTMLInputElement; - const options = { - fields: ['address_components'], - strictBounds: false, - types: ['address'] - }; - const autocomplete = new Autocomplete(input, options); - this.initInputListeners(autocomplete, input); - }); - } - @action selectCountryCode(code: { id: string }): void { set(this.args.address, 'countryCode', code.id); @@ -123,6 +96,20 @@ export default class extends Component { this.args.onChange?.(this.args.address, this.checkAddressValidity()); } + @action + onAddressSelected(address: AutocompletionAddress): void { + set(this.args.address, `${this.addressKey}1`, address.address1); + if (address.address2) { + set(this.args.address, `${this.addressKey}2`, address.address2); + } + this.applyCountry(address.country); + set(this.args.address, 'city', address.city); + set(this.args.address, 'state', address.state); + set(this.args.address, 'zipcode', address.zipcode); + + this.onFieldUpdate(); + } + private checkAddressValidity(): boolean { if (!isEmpty(this.provincesForCountry) && isEmpty(get(this.args.address, 'state'))) return false; @@ -146,70 +133,6 @@ export default class extends Component { }); } - private initInputListeners(autocomplete: GAutoComplete, input: HTMLElement): void { - autocomplete.addListener('place_changed', () => { - const place = autocomplete.getPlace(); - this.fillInAddress(place); - }); - input.addEventListener('focusout', (event) => { - if ((event.target).value !== get(this.args.address, `${this.addressKey}1`)) { - (event.target).value = get(this.args.address, `${this.addressKey}1`) ?? ''; - } - }); - } - - private fillInAddress(place: GPlaceResult): void { - let address1: string = ''; - let address2: string = ''; - let zipcode: string = ''; - let city: string = ''; - - const mapper: { [key: string]: (comp: GAddressComponent) => void } = { - street_number: (comp) => { - address1 = `${comp.long_name} ${address1}`; - }, - route: (comp) => { - address1 += comp.long_name; - }, - subpremise: (comp) => { - address2 = comp.long_name; - }, - postal_code: (comp) => { - zipcode = `${comp.long_name}${zipcode}`; - }, - postal_code_suffix: (comp) => { - zipcode = `${zipcode}-${comp.long_name}`; - }, - locality: (comp) => { - city = comp.long_name; - }, - postal_town: (comp) => { - city = comp.long_name; - }, - administrative_area_level_1: (comp) => { - set(this.args.address, 'state', comp.long_name || ''); - }, - country: (comp) => { - const selectedCountry = this.countries.find((country) => country.alpha2 === comp.short_name); - - if (!selectedCountry) return; - this.applyCountry(selectedCountry); - } - }; - - (place.address_components ?? []).reverse().forEach((component) => { - const componentType: string = component.types[0]; - - mapper[componentType]?.(component); - }); - - set(this.args.address, `${this.addressKey}1`, address1); - set(this.args.address, `${this.addressKey}2`, address2); - set(this.args.address, 'zipcode', zipcode); - set(this.args.address, 'city', city); - this.onFieldUpdate(); - } - private validatedAddressFieldsHandler(): void { this.validatedAddressFields.push(`${this.addressKey}1`); @@ -222,21 +145,6 @@ export default class extends Component { } } - private appendContainerLocally(): void { - const observer = new MutationObserver((mutationList: any) => { - for (const mutation of mutationList) { - if (mutation.type === 'childList') { - const pacContainer = mutation.addedNodes[0]; - if (!pacContainer?.classList.contains('pac-container')) return; - - document.querySelector('[data-control-name="address-form-address1"]')?.append(pacContainer); - observer.disconnect(); - } - } - }); - observer.observe(document.body, { childList: true }); - } - private openCountrySelect(): void { next(() => { (document.querySelector('[data-control-name="address-form-country"] .upf-input') as HTMLInputElement)?.click(); diff --git a/addon/components/utils/address-inline.hbs b/addon/components/utils/address-inline.hbs index f842ca88..1f0df464 100644 --- a/addon/components/utils/address-inline.hbs +++ b/addon/components/utils/address-inline.hbs @@ -1,16 +1,12 @@
{{#if this.useGoogleAutocomplete}} -
- -
+ {{else}} -
- -
+ {{/if}}
\ No newline at end of file diff --git a/addon/components/utils/address-inline.ts b/addon/components/utils/address-inline.ts index f1feb81c..f2faca66 100644 --- a/addon/components/utils/address-inline.ts +++ b/addon/components/utils/address-inline.ts @@ -1,9 +1,6 @@ import Component from '@glimmer/component'; import { action } from '@ember/object'; -import { isTesting } from '@embroider/macros'; -import { getOwner } from '@ember/application'; - -import { Loader } from '@googlemaps/js-api-loader'; +import { AutocompletionAddress } from '@upfluence/ember-upf-utils/modifiers/setup-autocomplete'; interface UtilsAddressInlineArgs { value: ShippingAddress; @@ -22,34 +19,22 @@ export type ShippingAddress = { } | null; }; -type GAddressComponent = google.maps.GeocoderAddressComponent; -type GAutoComplete = google.maps.places.Autocomplete; -type GPlaceResult = google.maps.places.PlaceResult; - export default class extends Component { get useGoogleAutocomplete(): boolean { return this.args.useGoogleAutocomplete ?? true; } @action - initAutoCompletion(): void { - if (isTesting()) return; - this.appendContainerLocally(); - const loader = new Loader({ - // @ts-ignore - apiKey: getOwner(this).resolveRegistration('config:environment').google_map_api_key, - version: 'weekly' - }); - - loader.importLibrary('places').then(({ Autocomplete }) => { - const input = document.querySelector('[data-control-name="address-inline"] input') as HTMLInputElement; - const options = { - fields: ['address_components'], - strictBounds: false, - types: ['address'] - }; - const autocomplete = new Autocomplete(input, options); - this.initInputListeners(autocomplete, input); + onAddressSelected(address: AutocompletionAddress): void { + this.args.onChange({ + address: address.formattedAddress, + resolved_address: { + line_1: address.address1, + zipcode: address.zipcode, + city: address.city, + state: address.state, + country_code: address.country.alpha2 + } }); } @@ -57,78 +42,4 @@ export default class extends Component { onChange(value: string): void { this.args.onChange({ address: value, resolved_address: null }); } - - private appendContainerLocally(): void { - const observer = new MutationObserver((mutationList: any) => { - for (const mutation of mutationList) { - if (mutation.type === 'childList') { - const pacContainer = mutation.addedNodes[0]; - if (!pacContainer?.classList.contains('pac-container')) return; - - document.querySelector('[data-control-name="address-inline"]')?.append(pacContainer); - observer.disconnect(); - } - } - }); - observer.observe(document.body, { childList: true }); - } - - private initInputListeners(autocomplete: GAutoComplete, input: HTMLInputElement): void { - autocomplete.addListener('place_changed', () => { - const place = autocomplete.getPlace(); - this.updateAddress(place, input); - }); - } - - private updateAddress(place: GPlaceResult, input: HTMLInputElement): void { - let address1: string = ''; - let zipcode: string = ''; - let city: string = ''; - let state: string = ''; - let country: string = ''; - - const mapper: { [key: string]: (comp: GAddressComponent) => void } = { - street_number: (comp) => { - address1 = `${comp.long_name} ${address1}`; - }, - route: (comp) => { - address1 += comp.long_name; - }, - postal_code: (comp) => { - zipcode = `${comp.long_name}${zipcode}`; - }, - postal_code_suffix: (comp) => { - zipcode = `${zipcode}-${comp.long_name}`; - }, - locality: (comp) => { - city = comp.long_name; - }, - postal_town: (comp) => { - city = comp.long_name; - }, - administrative_area_level_1: (comp) => { - state = comp.long_name ?? ''; - }, - country: (comp) => { - country = comp.short_name; - } - }; - - (place.address_components ?? []).reverse().forEach((component) => { - const componentType: string = component.types[0]; - - mapper[componentType]?.(component); - }); - - this.args.onChange({ - address: input.value, - resolved_address: { - line_1: address1, - zipcode, - city, - state, - country_code: country - } - }); - } } diff --git a/addon/modifiers/setup-autocomplete.ts b/addon/modifiers/setup-autocomplete.ts index 1b58992e..b0ea877e 100644 --- a/addon/modifiers/setup-autocomplete.ts +++ b/addon/modifiers/setup-autocomplete.ts @@ -6,7 +6,7 @@ import { getOwner } from '@ember/application'; import { isTesting } from '@embroider/macros'; import { Loader } from '@googlemaps/js-api-loader'; -import { countries } from '@upfluence/oss-components/utils/country-codes'; +import { countries, CountryData } from '@upfluence/oss-components/utils/country-codes'; type GoogleAddressComponent = google.maps.GeocoderAddressComponent; type GooglePlaceResult = google.maps.places.PlaceResult; @@ -23,20 +23,21 @@ type AddressComponentType = | 'administrative_area_level_1' | 'country'; -export type AutocompletionResult = { +export type AutocompletionAddress = { address1: string; address2?: string; city: string; state: string; zipcode: string; - country: string; + country: CountryData; + formattedAddress: string; }; interface SetupAutocompleteSignature { Element: HTMLElement; Args: { Named: { - callback(result: AutocompletionResult): void; + callback(result: AutocompletionAddress): void; }; }; } @@ -58,9 +59,9 @@ function cleanup(instance: SetupAutocompleteModifier): void { export default class SetupAutocompleteModifier extends Modifier { targetElement: HTMLElement | null = null; targetInput: HTMLInputElement | null = null; - result: AutocompletionResult | null = null; + result: AutocompletionAddress | null = null; - private callback: ((result: AutocompletionResult) => void) | null = null; + private callback: ((result: AutocompletionAddress) => void) | null = null; constructor(owner: unknown, args: ArgsFor) { super(owner, args); @@ -159,14 +160,16 @@ export default class SetupAutocompleteModifier extends Modifier country.alpha2 === 'US')!; + const result: AutocompletionAddress = { address1: '', address2: '', city: '', state: '', zipcode: '', - country: '' + country: defaultCountry, + formattedAddress: '' }; const mapper: Record void> = { @@ -195,8 +198,7 @@ export default class SetupAutocompleteModifier extends Modifier { - const selectedCountry = countries.find((country) => country.alpha2 === comp.short_name); - result.country = selectedCountry?.alpha2 ?? ''; + result.country = countries.find((country) => country.alpha2 === comp.short_name) ?? defaultCountry; } }; @@ -205,7 +207,18 @@ export default class SetupAutocompleteModifier extends Modifier
+ +
\ No newline at end of file diff --git a/tests/integration/components/utils/address-inline-test.ts b/tests/integration/components/utils/address-inline-test.ts index 680bdd66..aba351b1 100644 --- a/tests/integration/components/utils/address-inline-test.ts +++ b/tests/integration/components/utils/address-inline-test.ts @@ -51,6 +51,6 @@ module('Integration | Component | utils/address-inline', function (hooks) { hbs`` ); - assert.dom('.autocomplete-input-container [data-control-name="address-inline"]').exists(); + assert.dom('.autocomplete-input-container[data-control-name="address-inline"]').exists(); }); }); From bc8f253c442898d83637d80be5900894b8b186c4 Mon Sep 17 00:00:00 2001 From: Julien Vannier Date: Mon, 26 Jan 2026 12:05:00 +0100 Subject: [PATCH 05/10] Fix rendering position & cleanup used pac-container --- addon/modifiers/setup-autocomplete.ts | 55 +----- app/styles/components/utils/address-form.less | 66 ++++--- package.json | 2 +- pnpm-lock.yaml | 165 +++++++++++++++--- tests/dummy/app/controllers/application.ts | 4 +- 5 files changed, 178 insertions(+), 114 deletions(-) diff --git a/addon/modifiers/setup-autocomplete.ts b/addon/modifiers/setup-autocomplete.ts index b0ea877e..8975edfb 100644 --- a/addon/modifiers/setup-autocomplete.ts +++ b/addon/modifiers/setup-autocomplete.ts @@ -43,7 +43,6 @@ interface SetupAutocompleteSignature { } const AUTOCOMPLETE_CONTAINER_CLASS = 'autocomplete-input-container'; -const PAC_CONTAINER_CLASS = 'pac-container'; const AUTOCOMPLETE_OPTIONS: GoogleAutocompleteOptions = { fields: ['address_components'], strictBounds: false, @@ -54,6 +53,7 @@ function cleanup(instance: SetupAutocompleteModifier): void { instance.targetElement = null; instance.targetInput = null; instance.result = null; + document.querySelector('.pac-container')?.remove(); } export default class SetupAutocompleteModifier extends Modifier { @@ -73,7 +73,7 @@ export default class SetupAutocompleteModifier extends Modifier, { callback }: NamedArgs ): void { - const input = this.getInputElement(element); + const input: HTMLInputElement | null = this.getInputElement(element); if (!input) return; this.targetInput = input; @@ -121,7 +121,6 @@ export default class SetupAutocompleteModifier extends Modifier { if (isTesting()) return; - this.appendPacContainerLocally(); const loader = new Loader({ apiKey: getOwner(this).resolveRegistration('config:environment').google_map_api_key, @@ -146,11 +145,6 @@ export default class SetupAutocompleteModifier extends Modifier { - if ((event.target).value === this.result?.address1) return; - (event.target).value = this.result?.address1 ?? ''; - }); } private handlePlaceChanged(place: GooglePlaceResult): void { @@ -209,51 +203,8 @@ export default class SetupAutocompleteModifier extends Modifier { - for (const mutation of mutationList) { - if (mutation.type !== 'childList') { - continue; - } - - for (const node of mutation.addedNodes) { - if (this.isPacContainer(node)) { - this.relocatePacContainer(node as HTMLElement); - observer.disconnect(); - return; - } - } - } - }); - - observer.observe(document.body, { childList: true }); - } - - private isPacContainer(node: Node): node is HTMLElement { - return node instanceof HTMLElement && node.classList.contains(PAC_CONTAINER_CLASS); - } - - private relocatePacContainer(container: HTMLElement): void { - assert('[modifier][setup-autocomplete] Target element is not initialized', this.targetElement !== null); - this.targetElement.appendChild(container); - } } diff --git a/app/styles/components/utils/address-form.less b/app/styles/components/utils/address-form.less index c569f31a..7acd6e81 100644 --- a/app/styles/components/utils/address-form.less +++ b/app/styles/components/utils/address-form.less @@ -4,46 +4,44 @@ .pac-target-input { width: 100%; } +} - .pac-container { - background-color: var(--color-white); - border: 1px solid var(--color-border-default); - border-radius: var(--border-radius-sm); - box-shadow: var(--box-shadow-md); - margin-top: var(--spacing-px-6); - z-index: 1300; - top: 36px !important; - left: 0 !important; +.pac-container { + background-color: var(--color-white); + border: 1px solid var(--color-border-default); + border-radius: var(--border-radius-sm); + box-shadow: var(--box-shadow-md); + margin-top: var(--spacing-px-6); + z-index: 1300; - &:after { - display: none; - } + &:after { + display: none; + } - .pac-item { - display: flex; - align-items: center; - font-size: var(--font-size-sm); - color: var(--color-gray-400); - padding: var(--spacing-px-3) var(--spacing-px-12); - border: none; - gap: var(--spacing-px-6); - } + .pac-item { + display: flex; + align-items: center; + font-size: var(--font-size-sm); + color: var(--color-gray-400); + padding: var(--spacing-px-3) var(--spacing-px-12); + border: none; + gap: var(--spacing-px-6); + } - .pac-item-query { - color: var(--color-gray-600); - } + .pac-item-query { + color: var(--color-gray-600); + } - .pac-icon-marker { - font-family: 'Font Awesome 5 Pro'; - font-weight: 400; - margin: 0; - width: auto; - height: auto; - background-image: none; + .pac-icon-marker { + font-family: 'Font Awesome 7 Pro'; + font-weight: 400; + margin: 0; + width: auto; + height: auto; + background-image: none; - &::before { - content: '\f3c5'; - } + &::before { + content: '\f3c5'; } } } diff --git a/package.json b/package.json index 19a2c950..a3c9b875 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,7 @@ "@types/rsvp": "^4.0.4", "@types/sinon": "^10.0.6", "@typescript-eslint/parser": "^5.0.0", - "@upfluence/oss-components": "^3.81.3", + "@upfluence/oss-components": "^3.88.15", "ember-cli": "~3.28.6", "ember-cli-code-coverage": "^3.0.0", "ember-cli-dependency-checker": "^3.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9cd40def..2b2dd8e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,8 +154,8 @@ importers: specifier: ^5.0.0 version: 5.62.0(eslint@7.32.0)(typescript@4.9.5) '@upfluence/oss-components': - specifier: ^3.81.3 - version: 3.81.3(@babel/core@7.26.0)(@ember/test-helpers@2.9.4(@babel/core@7.26.0)(ember-source@3.28.12(@babel/core@7.26.0)))(ember-source@3.28.12(@babel/core@7.26.0))(qunit@2.23.1)(typescript@4.9.5)(webpack@5.97.1) + specifier: ^3.88.15 + version: 3.88.15(@babel/core@7.26.0)(@ember/test-helpers@2.9.4(@babel/core@7.26.0)(ember-source@3.28.12(@babel/core@7.26.0)))(ember-source@3.28.12(@babel/core@7.26.0))(qunit@2.23.1)(typescript@4.9.5)(webpack@5.97.1) ember-cli: specifier: ~3.28.6 version: 3.28.6(babel-core@6.26.3)(handlebars@4.7.8)(underscore@1.13.7) @@ -961,6 +961,15 @@ packages: '@glint/template': optional: true + '@embroider/macros@1.19.6': + resolution: {integrity: sha512-yPf8lD/gRZmcxms66CCKuZuvWMJ0g/hdCE6P8FZsyewR3So6pxgdgOFp0zk2w5d34jS1ejBtxLNREZbBTELpzw==} + engines: {node: 12.* || 14.* || >= 16} + peerDependencies: + '@glint/template': ^1.0.0 + peerDependenciesMeta: + '@glint/template': + optional: true + '@embroider/shared-internals@1.8.3': resolution: {integrity: sha512-N5Gho6Qk8z5u+mxLCcMYAoQMbN4MmH+z2jXwQHVs859bxuZTxwF6kKtsybDAASCtd2YGxEmzcc1Ja/wM28824w==} engines: {node: 12.* || 14.* || >= 16} @@ -973,6 +982,10 @@ packages: resolution: {integrity: sha512-zi0CENFD1e0DH7c9M/rNKJnFnt2c3+736J3lguBddZdmaIV6Cb8l3HQSkskSW5O4ady+SavemLKO3hCjQQJBIw==} engines: {node: 12.* || 14.* || >= 16} + '@embroider/shared-internals@3.0.2': + resolution: {integrity: sha512-/SusdG+zgosc3t+9sPFVKSFOYyiSgLfXOT6lYNWoG1YtnhWDxlK4S8leZ0jhcVjemdaHln5rTyxCnq8oFLxqpQ==} + engines: {node: 12.* || 14.* || >= 16} + '@embroider/test-setup@0.48.1': resolution: {integrity: sha512-MmYTgQMDVDrZPvxeT27LTUD/BOum21ip1tEYv5H/StSeTZyZQ861Q+8HXQUFTVF/HFjGAB1c/BAgnw+8hO1ueA==} engines: {node: 12.* || 14.* || >= 16} @@ -994,18 +1007,31 @@ packages: '@glint/template': optional: true + '@embroider/util@1.13.5': + resolution: {integrity: sha512-rHhGUzAQ5iOr5Swvk7yaarVe5SJtcjK2t/C8ts9agWfhTq4DVfy8+axF0KOf1jALRiJao3l9ALRGd6letKw2ZQ==} + engines: {node: 12.* || 14.* || >= 16} + peerDependencies: + '@glint/environment-ember-loose': ^1.0.0 + '@glint/template': ^1.0.0 + ember-source: '*' + peerDependenciesMeta: + '@glint/environment-ember-loose': + optional: true + '@glint/template': + optional: true + '@eslint/eslintrc@0.4.3': resolution: {integrity: sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==} engines: {node: ^10.12.0 || >=12.0.0} - '@floating-ui/core@1.6.9': - resolution: {integrity: sha512-uMXCuQ3BItDUbAMhIXw7UPXRfAlOAvZzdK9BWpE60MCn+Svt3aLn9jsPTi/WNGlRUu2uI0v5S7JiIUsbsvh3fw==} + '@floating-ui/core@1.7.3': + resolution: {integrity: sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==} - '@floating-ui/dom@1.6.13': - resolution: {integrity: sha512-umqzocjDgNRGTuO7Q8CU32dkHkECqI8ZdMZ5Swb6QAM0t5rnlrN3lGo1hdpscRd3WS8T6DKYK4ephgIH9iRh3w==} + '@floating-ui/dom@1.7.4': + resolution: {integrity: sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==} - '@floating-ui/utils@0.2.9': - resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + '@floating-ui/utils@0.2.10': + resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} '@formatjs/ecma402-abstract@2.2.4': resolution: {integrity: sha512-lFyiQDVvSbQOpU+WFd//ILolGj4UgA/qXrKeZxdV14uKiAUiPAtX6XAn7WBCRi7Mx6I7EybM9E5yYn4BIpZWYg==} @@ -1051,9 +1077,9 @@ packages: typescript: optional: true - '@fortawesome/fontawesome-pro@git+https://git@github.com:upfluence/fontawesome-pro.git#2ae6c0044b34d06543101723202e117125257264': - resolution: {commit: 2ae6c0044b34d06543101723202e117125257264, repo: git@github.com:upfluence/fontawesome-pro.git, type: git} - version: 6.7.2 + '@fortawesome/fontawesome-pro@git+https://git@github.com:upfluence/fontawesome-pro.git#c639fe415de762c946eaebf4cc4e815ce3ede420': + resolution: {commit: c639fe415de762c946eaebf4cc4e815ce3ede420, repo: git@github.com:upfluence/fontawesome-pro.git, type: git} + version: 7.0.1 engines: {node: '>=6'} '@glimmer/component@1.1.2': @@ -1428,8 +1454,8 @@ packages: resolution: {integrity: sha512-KvSlbSLXM/UTLSxuWRi3rK7qrXVwolFtXiCqo2rQnXV0hS2mAKtK3+bnATX2BRdjNdNyc8da7I2RNmKshb7ZSQ==, tarball: https://npm.pkg.github.com/download/@upfluence/hyperevents/0.3.9/6bc9a5f792a8ee995aa638a54d57a1f94da7b20d} engines: {node: 10.* || >= 12} - '@upfluence/oss-components@3.81.3': - resolution: {integrity: sha512-gewaOtG8c5KrWTYf/Wgk3T0WdXWrkew13P6mDchvfeKHlNEs/W5GE/aPjq7tk8t3ZEMVJhWpUOK15svW30N2fA==, tarball: https://npm.pkg.github.com/download/@upfluence/oss-components/3.81.3/a514087f6a9dc558ffaed2eea21ee27beb0389ef} + '@upfluence/oss-components@3.88.15': + resolution: {integrity: sha512-Ut4mhwl+272//V7VWpwM9oHhaQYBsgRU06LXcs5UcR/ZHiLIo/KJ3RSfxwzl1LehmHigdXrg6ohucTuHAbuTIw==, tarball: https://npm.pkg.github.com/download/@upfluence/oss-components/3.88.15/4c2b1c0d8e430c007ab11fb6111ea3a0b59ff6a3} engines: {node: 12.* || 14.* || >= 16} peerDependencies: qunit: ^2.x @@ -1837,6 +1863,10 @@ packages: resolution: {integrity: sha512-4YNPkuVsxAW5lnSTa6cn4Wk49RX6GAB6vX+M6LqEtN0YePqoFczv1/x0EyLK/o+4E1j9jEuYj5Su7IEPab5JHQ==} engines: {node: '>= 12.*'} + babel-import-util@3.0.1: + resolution: {integrity: sha512-2copPaWQFUrzooJVIVZA/Oppx/S/KOoZ4Uhr+XWEQDMZ8Rvq/0SNQpbdIyMBJ8IELWt10dewuJw+tX4XjOo7Rg==} + engines: {node: '>= 12.*'} + babel-loader@8.4.1: resolution: {integrity: sha512-nXzRChX+Z1GoE6yWavBQg6jDslyFF3SDjl2paADuoQtQW10JqShJt62R6eJQ5m/pjJFDT8xgKIWSP85OY8eXeA==} engines: {node: '>= 8.9'} @@ -2106,6 +2136,7 @@ packages: bootstrap@3.4.1: resolution: {integrity: sha512-yN5oZVmRCwe5aKwzRj6736nSmKDX7pLYwsXiCj/EYmo16hODaBiT4En5btW/jhBF/seV+XMx3aYwukYC3A49DA==} engines: {node: '>=6'} + deprecated: This version of Bootstrap is no longer supported. Please upgrade to the latest version. bower-config@1.4.3: resolution: {integrity: sha512-MVyyUk3d1S7d2cl6YISViwJBc2VXCkxF5AUFykvN0PQj5FsUiMNSgAYTso18oRFfyZ6XEtjrgg9MAaufHbOwNw==} @@ -2919,6 +2950,15 @@ packages: supports-color: optional: true + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + decimal.js@10.4.3: resolution: {integrity: sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==} @@ -4788,6 +4828,9 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + lodash@4.17.23: + resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} + log-symbols@2.2.0: resolution: {integrity: sha512-VeIAFslyIerEJLXHziedo2basKbMKtTw3vfn5IzG0XTjhAVEJyNHnL2p7vc+wBDSdQuUpNw3M2u6xb9QsAY5Eg==} engines: {node: '>=4'} @@ -5811,11 +5854,20 @@ packages: resolution: {integrity: sha512-ZuF55hVUQaaczgOIwqWzkEcEidmlD/xl44x1UZnhOXcYuFN2S6+rcxpG+C1N3So0wvNI3DmJICUFfu2SxhBmvg==} deprecated: https://github.com/lydell/resolve-url#deprecated + resolve.exports@2.0.3: + resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} + engines: {node: '>=10'} + resolve@1.22.10: resolution: {integrity: sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==} engines: {node: '>= 0.4'} hasBin: true + resolve@1.22.11: + resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} + engines: {node: '>= 0.4'} + hasBin: true + responselike@1.0.2: resolution: {integrity: sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ==} @@ -5947,6 +5999,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.7.3: + resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} + engines: {node: '>=10'} + hasBin: true + send@0.19.0: resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} engines: {node: '>= 0.8.0'} @@ -7882,6 +7939,19 @@ snapshots: transitivePeerDependencies: - supports-color + '@embroider/macros@1.19.6': + dependencies: + '@embroider/shared-internals': 3.0.2 + assert-never: 1.4.0 + babel-import-util: 3.0.1 + ember-cli-babel: 7.26.11 + find-up: 5.0.0 + lodash: 4.17.23 + resolve: 1.22.11 + semver: 7.7.3 + transitivePeerDependencies: + - supports-color + '@embroider/shared-internals@1.8.3': dependencies: babel-import-util: 1.4.1 @@ -7921,6 +7991,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@embroider/shared-internals@3.0.2': + dependencies: + babel-import-util: 3.0.1 + debug: 4.4.3 + ember-rfc176-data: 0.3.18 + fs-extra: 9.1.0 + is-subdir: 1.2.0 + js-string-escape: 1.0.1 + lodash: 4.17.23 + minimatch: 3.1.2 + pkg-entry-points: 1.1.1 + resolve-package-path: 4.0.3 + resolve.exports: 2.0.3 + semver: 7.7.3 + typescript-memoize: 1.1.1 + transitivePeerDependencies: + - supports-color + '@embroider/test-setup@0.48.1': dependencies: lodash: 4.17.21 @@ -7940,6 +8028,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@embroider/util@1.13.5(ember-source@3.28.12(@babel/core@7.26.0))': + dependencies: + '@embroider/macros': 1.19.6 + broccoli-funnel: 3.0.8 + ember-cli-babel: 7.26.11 + ember-source: 3.28.12(@babel/core@7.26.0) + transitivePeerDependencies: + - supports-color + '@eslint/eslintrc@0.4.3': dependencies: ajv: 6.12.6 @@ -7954,16 +8051,16 @@ snapshots: transitivePeerDependencies: - supports-color - '@floating-ui/core@1.6.9': + '@floating-ui/core@1.7.3': dependencies: - '@floating-ui/utils': 0.2.9 + '@floating-ui/utils': 0.2.10 - '@floating-ui/dom@1.6.13': + '@floating-ui/dom@1.7.4': dependencies: - '@floating-ui/core': 1.6.9 - '@floating-ui/utils': 0.2.9 + '@floating-ui/core': 1.7.3 + '@floating-ui/utils': 0.2.10 - '@floating-ui/utils@0.2.9': {} + '@floating-ui/utils@0.2.10': {} '@formatjs/ecma402-abstract@2.2.4': dependencies: @@ -8040,7 +8137,7 @@ snapshots: optionalDependencies: typescript: 4.9.5 - '@fortawesome/fontawesome-pro@git+https://git@github.com:upfluence/fontawesome-pro.git#2ae6c0044b34d06543101723202e117125257264': {} + '@fortawesome/fontawesome-pro@git+https://git@github.com:upfluence/fontawesome-pro.git#c639fe415de762c946eaebf4cc4e815ce3ede420': {} '@glimmer/component@1.1.2(@babel/core@7.26.0)': dependencies: @@ -8558,13 +8655,13 @@ snapshots: - supports-color - webpack - '@upfluence/oss-components@3.81.3(@babel/core@7.26.0)(@ember/test-helpers@2.9.4(@babel/core@7.26.0)(ember-source@3.28.12(@babel/core@7.26.0)))(ember-source@3.28.12(@babel/core@7.26.0))(qunit@2.23.1)(typescript@4.9.5)(webpack@5.97.1)': + '@upfluence/oss-components@3.88.15(@babel/core@7.26.0)(@ember/test-helpers@2.9.4(@babel/core@7.26.0)(ember-source@3.28.12(@babel/core@7.26.0)))(ember-source@3.28.12(@babel/core@7.26.0))(qunit@2.23.1)(typescript@4.9.5)(webpack@5.97.1)': dependencies: '@ember/render-modifiers': 2.1.0(@babel/core@7.26.0)(ember-source@3.28.12(@babel/core@7.26.0)) '@embroider/macros': 1.16.10 - '@embroider/util': 1.13.2(ember-source@3.28.12(@babel/core@7.26.0)) - '@floating-ui/dom': 1.6.13 - '@fortawesome/fontawesome-pro': git+https://git@github.com:upfluence/fontawesome-pro.git#2ae6c0044b34d06543101723202e117125257264 + '@embroider/util': 1.13.5(ember-source@3.28.12(@babel/core@7.26.0)) + '@floating-ui/dom': 1.7.4 + '@fortawesome/fontawesome-pro': git+https://git@github.com:upfluence/fontawesome-pro.git#c639fe415de762c946eaebf4cc4e815ce3ede420 babel-plugin-debug-macros: 0.3.4(@babel/core@7.26.0) bootstrap: 3.4.1 broccoli-funnel: 3.0.8 @@ -8582,7 +8679,7 @@ snapshots: ember-truth-helpers: 3.1.1 money-formatter: 0.1.4 qunit: 2.23.1 - resolve: 1.22.10 + resolve: 1.22.11 transitivePeerDependencies: - '@babel/core' - '@ember/test-helpers' @@ -9129,6 +9226,8 @@ snapshots: babel-import-util@3.0.0: {} + babel-import-util@3.0.1: {} + babel-loader@8.4.1(@babel/core@7.26.0)(webpack@4.47.0): dependencies: '@babel/core': 7.26.0 @@ -10644,6 +10743,10 @@ snapshots: dependencies: ms: 2.1.3 + debug@4.4.3: + dependencies: + ms: 2.1.3 + decimal.js@10.4.3: {} decode-uri-component@0.2.2: {} @@ -13463,6 +13566,8 @@ snapshots: lodash@4.17.21: {} + lodash@4.17.23: {} + log-symbols@2.2.0: dependencies: chalk: 2.4.2 @@ -14552,12 +14657,20 @@ snapshots: resolve-url@0.2.1: {} + resolve.exports@2.0.3: {} + resolve@1.22.10: dependencies: is-core-module: 2.16.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + resolve@1.22.11: + dependencies: + is-core-module: 2.16.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + responselike@1.0.2: dependencies: lowercase-keys: 1.0.1 @@ -14699,6 +14812,8 @@ snapshots: semver@7.6.3: {} + semver@7.7.3: {} + send@0.19.0: dependencies: debug: 2.6.9 diff --git a/tests/dummy/app/controllers/application.ts b/tests/dummy/app/controllers/application.ts index bfdf91ec..6ea5f275 100644 --- a/tests/dummy/app/controllers/application.ts +++ b/tests/dummy/app/controllers/application.ts @@ -54,13 +54,13 @@ export default class ApplicationController extends Controller { @action onAutoComplete(value: AutocompletionAddress) { - this.inputValue = value.address1; + this.inputValue = value.formattedAddress; console.log('Auto-complete oss/input-container address', value); } @action onOssAutoComplete(value: AutocompletionAddress) { - this.ossInputValue = value.address1; + this.ossInputValue = value.formattedAddress; console.log('Auto-complete native input address', value); } } From ed2771c52eef07cc6c6a2c815c9bcd6b5edfc8d6 Mon Sep 17 00:00:00 2001 From: Julien Vannier Date: Mon, 26 Jan 2026 15:30:18 +0100 Subject: [PATCH 06/10] Move the address parsing logic in dedicated file --- addon/components/utils/address-form.ts | 2 +- addon/components/utils/address-inline.ts | 2 +- addon/modifiers/setup-autocomplete.ts | 80 +--------------------- addon/utils/address-parser.ts | 78 +++++++++++++++++++++ app/utils/address-parser.js | 1 + tests/dummy/app/controllers/application.ts | 2 +- 6 files changed, 85 insertions(+), 80 deletions(-) create mode 100644 addon/utils/address-parser.ts create mode 100644 app/utils/address-parser.js diff --git a/addon/components/utils/address-form.ts b/addon/components/utils/address-form.ts index 5f92e9cf..ab34d87a 100644 --- a/addon/components/utils/address-form.ts +++ b/addon/components/utils/address-form.ts @@ -4,7 +4,7 @@ import { action, get, set } from '@ember/object'; import { isEmpty } from '@ember/utils'; import { CountryData, countries } from '@upfluence/oss-components/utils/country-codes'; import { next } from '@ember/runloop'; -import { AutocompletionAddress } from '@upfluence/ember-upf-utils/modifiers/setup-autocomplete'; +import { AutocompletionAddress } from '@upfluence/ember-upf-utils/utils/address-parser'; type FocusableInput = | 'first-name' diff --git a/addon/components/utils/address-inline.ts b/addon/components/utils/address-inline.ts index f2faca66..fe4d0094 100644 --- a/addon/components/utils/address-inline.ts +++ b/addon/components/utils/address-inline.ts @@ -1,6 +1,6 @@ import Component from '@glimmer/component'; import { action } from '@ember/object'; -import { AutocompletionAddress } from '@upfluence/ember-upf-utils/modifiers/setup-autocomplete'; +import { AutocompletionAddress } from '@upfluence/ember-upf-utils/utils/address-parser'; interface UtilsAddressInlineArgs { value: ShippingAddress; diff --git a/addon/modifiers/setup-autocomplete.ts b/addon/modifiers/setup-autocomplete.ts index 8975edfb..9a5e82a1 100644 --- a/addon/modifiers/setup-autocomplete.ts +++ b/addon/modifiers/setup-autocomplete.ts @@ -6,32 +6,11 @@ import { getOwner } from '@ember/application'; import { isTesting } from '@embroider/macros'; import { Loader } from '@googlemaps/js-api-loader'; -import { countries, CountryData } from '@upfluence/oss-components/utils/country-codes'; +import { parseAddressComponents, type AutocompletionAddress } from '../utils/address-parser'; -type GoogleAddressComponent = google.maps.GeocoderAddressComponent; type GooglePlaceResult = google.maps.places.PlaceResult; type GoogleAutocomplete = google.maps.places.Autocomplete; type GoogleAutocompleteOptions = google.maps.places.AutocompleteOptions; -type AddressComponentType = - | 'street_number' - | 'route' - | 'subpremise' - | 'postal_code' - | 'postal_code_suffix' - | 'locality' - | 'postal_town' - | 'administrative_area_level_1' - | 'country'; - -export type AutocompletionAddress = { - address1: string; - address2?: string; - city: string; - state: string; - zipcode: string; - country: CountryData; - formattedAddress: string; -}; interface SetupAutocompleteSignature { Element: HTMLElement; @@ -150,61 +129,8 @@ export default class SetupAutocompleteModifier extends Modifier country.alpha2 === 'US')!; - const result: AutocompletionAddress = { - address1: '', - address2: '', - city: '', - state: '', - zipcode: '', - country: defaultCountry, - formattedAddress: '' - }; - - const mapper: Record void> = { - street_number: (comp: GoogleAddressComponent) => { - result.address1 = `${comp.long_name} ${result.address1}`.trim(); - }, - route: (comp: GoogleAddressComponent) => { - result.address1 += comp.long_name; - }, - subpremise: (comp: GoogleAddressComponent) => { - result.address2 = comp.long_name; - }, - postal_code: (comp: GoogleAddressComponent) => { - result.zipcode = `${comp.long_name}${result.zipcode}`; - }, - postal_code_suffix: (comp: GoogleAddressComponent) => { - result.zipcode = `${result.zipcode}-${comp.long_name}`; - }, - locality: (comp: GoogleAddressComponent) => { - result.city = comp.long_name; - }, - postal_town: (comp: GoogleAddressComponent) => { - result.city = comp.long_name; - }, - administrative_area_level_1: (comp: GoogleAddressComponent) => { - result.state = comp.long_name; - }, - country: (comp: GoogleAddressComponent) => { - result.country = countries.find((country) => country.alpha2 === comp.short_name) ?? defaultCountry; - } - }; - - (components ?? []).reverse().forEach((component) => { - const componentType: AddressComponentType = component.types[0] as AddressComponentType; - mapper[componentType]?.(component); - }); - - if (result.address2 === '') delete result['address2']; - - result.formattedAddress = this.targetInput!.value; - - return result; - } } diff --git a/addon/utils/address-parser.ts b/addon/utils/address-parser.ts new file mode 100644 index 00000000..4fb11462 --- /dev/null +++ b/addon/utils/address-parser.ts @@ -0,0 +1,78 @@ +import { countries, CountryData } from '@upfluence/oss-components/utils/country-codes'; + +type GoogleAddressComponent = google.maps.GeocoderAddressComponent; +type AddressComponentType = + | 'street_number' + | 'route' + | 'subpremise' + | 'postal_code' + | 'postal_code_suffix' + | 'locality' + | 'postal_town' + | 'administrative_area_level_1' + | 'country'; + +export type AutocompletionAddress = { + address1: string; + address2?: string; + city: string; + state: string; + zipcode: string; + country: CountryData; + formattedAddress: string; +}; + +export function parseAddressComponents( + components: GoogleAddressComponent[], + formattedAddress: string = '' +): AutocompletionAddress { + const defaultCountry = countries.find((country) => country.alpha2 === 'US')!; + const result: AutocompletionAddress = { + address1: '', + address2: '', + city: '', + state: '', + zipcode: '', + country: defaultCountry, + formattedAddress + }; + + const mapper: Record void> = { + street_number: (comp: GoogleAddressComponent) => { + result.address1 = `${comp.long_name} ${result.address1}`.trim(); + }, + route: (comp: GoogleAddressComponent) => { + result.address1 += comp.long_name; + }, + subpremise: (comp: GoogleAddressComponent) => { + result.address2 = comp.long_name; + }, + postal_code: (comp: GoogleAddressComponent) => { + result.zipcode = `${comp.long_name}${result.zipcode}`; + }, + postal_code_suffix: (comp: GoogleAddressComponent) => { + result.zipcode = `${result.zipcode}-${comp.long_name}`; + }, + locality: (comp: GoogleAddressComponent) => { + result.city = comp.long_name; + }, + postal_town: (comp: GoogleAddressComponent) => { + result.city = comp.long_name; + }, + administrative_area_level_1: (comp: GoogleAddressComponent) => { + result.state = comp.long_name; + }, + country: (comp: GoogleAddressComponent) => { + result.country = countries.find((country) => country.alpha2 === comp.short_name) ?? defaultCountry; + } + }; + + (components ?? []).reverse().forEach((component) => { + const componentType: AddressComponentType = component.types[0] as AddressComponentType; + mapper[componentType]?.(component); + }); + + if (result.address2 === '') delete result['address2']; + + return result; +} diff --git a/app/utils/address-parser.js b/app/utils/address-parser.js new file mode 100644 index 00000000..979d20b6 --- /dev/null +++ b/app/utils/address-parser.js @@ -0,0 +1 @@ +export { parseAddressComponents } from './address-parser'; diff --git a/tests/dummy/app/controllers/application.ts b/tests/dummy/app/controllers/application.ts index 6ea5f275..e8602c05 100644 --- a/tests/dummy/app/controllers/application.ts +++ b/tests/dummy/app/controllers/application.ts @@ -1,7 +1,7 @@ import Controller from '@ember/controller'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; -import { AutocompletionAddress } from '@upfluence/ember-upf-utils/modifiers/setup-autocomplete'; +import { AutocompletionAddress } from '@upfluence/ember-upf-utils/utils/address-parser'; export default class ApplicationController extends Controller { @tracked selectedItems = ['toto']; From f26ed6ef6c2e78e458c61944f58b50de6d134dc1 Mon Sep 17 00:00:00 2001 From: Julien Vannier Date: Mon, 26 Jan 2026 17:06:24 +0100 Subject: [PATCH 07/10] Add tests the setup-autocomplete modifier --- addon/modifiers/setup-autocomplete.ts | 53 +++-- addon/utils/google-maps-mock.ts | 160 +++++++++++++ .../modifiers/setup-autocomplete-test.ts | 225 ++++++++++++++++++ tests/unit/utils/address-parser-test.ts | 181 ++++++++++++++ 4 files changed, 603 insertions(+), 16 deletions(-) create mode 100644 addon/utils/google-maps-mock.ts create mode 100644 tests/integration/modifiers/setup-autocomplete-test.ts create mode 100644 tests/unit/utils/address-parser-test.ts diff --git a/addon/modifiers/setup-autocomplete.ts b/addon/modifiers/setup-autocomplete.ts index 9a5e82a1..936c28d0 100644 --- a/addon/modifiers/setup-autocomplete.ts +++ b/addon/modifiers/setup-autocomplete.ts @@ -2,11 +2,12 @@ import Modifier, { type ArgsFor, type PositionalArgs, type NamedArgs } from 'emb import { registerDestructor } from '@ember/destroyable'; import { assert } from '@ember/debug'; import { getOwner } from '@ember/application'; - import { isTesting } from '@embroider/macros'; import { Loader } from '@googlemaps/js-api-loader'; -import { parseAddressComponents, type AutocompletionAddress } from '../utils/address-parser'; + +import { parseAddressComponents, type AutocompletionAddress } from '@upfluence/ember-upf-utils/utils/address-parser'; +import { MockLoader } from '@upfluence/ember-upf-utils/utils/google-maps-mock'; type GooglePlaceResult = google.maps.places.PlaceResult; type GoogleAutocomplete = google.maps.places.Autocomplete; @@ -17,6 +18,7 @@ interface SetupAutocompleteSignature { Args: { Named: { callback(result: AutocompletionAddress): void; + loader?: Loader; }; }; } @@ -29,9 +31,18 @@ const AUTOCOMPLETE_OPTIONS: GoogleAutocompleteOptions = { }; function cleanup(instance: SetupAutocompleteModifier): void { + if (instance.createdWrapper && instance.targetInput && instance.targetElement) { + const parent = instance.targetElement.parentNode; + if (parent && instance.targetElement.contains(instance.targetInput)) { + parent.insertBefore(instance.targetInput, instance.targetElement); + instance.targetElement?.remove(); + } + } + instance.targetElement = null; instance.targetInput = null; instance.result = null; + instance.createdWrapper = false; document.querySelector('.pac-container')?.remove(); } @@ -39,6 +50,7 @@ export default class SetupAutocompleteModifier extends Modifier void) | null = null; @@ -50,15 +62,18 @@ export default class SetupAutocompleteModifier extends Modifier, - { callback }: NamedArgs + { callback, loader }: NamedArgs ): void { const input: HTMLInputElement | null = this.getInputElement(element); if (!input) return; this.targetInput = input; this.callback = callback; - this.setupTargetElement(element, input); - this.setupAutoComplete(); + + if (!this.targetElement) { + this.setupTargetElement(element, input); + this.setupAutoComplete(loader); + } } private setupTargetElement(element: HTMLElement, input: HTMLInputElement): void { @@ -71,15 +86,21 @@ export default class SetupAutocompleteModifier extends Modifier { - if (isTesting()) return; - - const loader = new Loader({ - apiKey: getOwner(this).resolveRegistration('config:environment').google_map_api_key, - version: 'weekly' - }); + private async setupAutoComplete(loader?: Loader): Promise { + const loaderInstance = isTesting() + ? loader ?? new MockLoader({ apiKey: 'test-key' }) + : new Loader({ + apiKey: getOwner(this).resolveRegistration('config:environment').google_map_api_key, + version: 'weekly' + }); - loader.importLibrary('places').then(({ Autocomplete }) => { + loaderInstance.importLibrary('places').then(({ Autocomplete }) => { this.initializeAutocomplete(Autocomplete); }); } diff --git a/addon/utils/google-maps-mock.ts b/addon/utils/google-maps-mock.ts new file mode 100644 index 00000000..b654971a --- /dev/null +++ b/addon/utils/google-maps-mock.ts @@ -0,0 +1,160 @@ +export type MockGoogleAddressComponent = { + types: string[]; + long_name: string; + short_name: string; +}; + +export type MockPlaceResult = { + address_components?: MockGoogleAddressComponent[]; + formatted_address?: string; +}; + +export class MockAutocomplete { + private listeners: Map = new Map(); + private mockPlace: MockPlaceResult = {}; + + constructor(public input: HTMLInputElement, public options?: Record) {} + + addListener(eventName: string, handler: Function): void { + if (!this.listeners.has(eventName)) { + this.listeners.set(eventName, []); + } + this.listeners.get(eventName)!.push(handler); + } + + getPlace(): MockPlaceResult { + return this.mockPlace; + } + + setPlace(place: MockPlaceResult): void { + this.mockPlace = place; + } + + triggerEvent(eventName: string): void { + const handlers = this.listeners.get(eventName) || []; + handlers.forEach((handler) => handler()); + } + + simulatePlaceSelection(place: MockPlaceResult): void { + this.setPlace(place); + this.triggerEvent('place_changed'); + } +} + +export class MockLoader { + private mockAutocompleteInstance: MockAutocomplete | null = null; + + constructor(public config?: Record) {} + + async importLibrary(libraryName: string): Promise { + if (libraryName === 'places') { + const self = this; + return { + Autocomplete: class { + constructor(input: HTMLInputElement, options?: Record) { + self.mockAutocompleteInstance = new MockAutocomplete(input, options); + return self.mockAutocompleteInstance; + } + } + }; + } + return {}; + } + + getMockAutocompleteInstance(): MockAutocomplete | null { + return this.mockAutocompleteInstance; + } +} + +export function createSampleAddressComponents( + overrides: Partial<{ + streetNumber: string; + route: string; + subpremise: string; + city: string; + state: string; + zipcode: string; + country: string; + countryCode: string; + }> +): MockGoogleAddressComponent[] { + const defaults = { + streetNumber: '1600', + route: 'Amphitheatre Parkway', + subpremise: '', + city: 'Mountain View', + state: 'California', + zipcode: '94043', + country: 'United States', + countryCode: 'US' + }; + + const values = { ...defaults, ...overrides }; + const components: MockGoogleAddressComponent[] = []; + + if (values.streetNumber) { + components.push({ + types: ['street_number'], + long_name: values.streetNumber, + short_name: values.streetNumber + }); + } + + if (values.route) { + components.push({ + types: ['route'], + long_name: values.route, + short_name: values.route + }); + } + + if (values.subpremise) { + components.push({ + types: ['subpremise'], + long_name: values.subpremise, + short_name: values.subpremise + }); + } + + if (values.city) { + components.push({ + types: ['locality'], + long_name: values.city, + short_name: values.city + }); + } + + if (values.state) { + components.push({ + types: ['administrative_area_level_1'], + long_name: values.state, + short_name: values.state + }); + } + + if (values.zipcode) { + components.push({ + types: ['postal_code'], + long_name: values.zipcode, + short_name: values.zipcode + }); + } + + components.push({ + types: ['country'], + long_name: values.country, + short_name: values.countryCode + }); + + return components; +} + +export function createMockPlaceResult( + addressComponents: MockGoogleAddressComponent[], + formattedAddress?: string +): MockPlaceResult { + return { + address_components: addressComponents, + formatted_address: formattedAddress + }; +} diff --git a/tests/integration/modifiers/setup-autocomplete-test.ts b/tests/integration/modifiers/setup-autocomplete-test.ts new file mode 100644 index 00000000..dbf3a123 --- /dev/null +++ b/tests/integration/modifiers/setup-autocomplete-test.ts @@ -0,0 +1,225 @@ +import { module, test } from 'qunit'; +import { setupRenderingTest } from 'ember-qunit'; +import { render, find, settled } from '@ember/test-helpers'; +import { hbs } from 'ember-cli-htmlbars'; +import { AutocompletionAddress } from '@upfluence/ember-upf-utils/utils/address-parser'; + +import { + createMockPlaceResult, + createSampleAddressComponents, + MockLoader +} from '@upfluence/ember-upf-utils/utils/google-maps-mock'; + +module('Integration | Modifier | setup-autocomplete', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.mockLoader = new MockLoader({ apiKey: 'test-key' }); + }); + + module('Element setup', function () { + test('it works with a text input element directly', async function (assert) { + await render( + hbs`
` + ); + + assert.dom('input[type="text"]').exists(); + const input = find('input[type="text"]'); + assert.dom(input?.parentElement).hasClass('autocomplete-input-container'); + }); + + test('it works with input inside a container element', async function (assert) { + await render(hbs` +
+ +
+ `); + + assert.dom('div').hasClass('autocomplete-input-container'); + assert.dom('input[type="text"]').exists(); + }); + }); + + module('Callback functionality', function () { + test('callback is called with parsed address data', async function (assert) { + assert.expect(1); + + this.handleAddress = (result: AutocompletionAddress) => { + assert.ok(result, 'callback receives address result'); + }; + + await render(hbs` +
+ `); + + const mockAutocomplete = this.mockLoader.getMockAutocompleteInstance(); + const mockPlace = createMockPlaceResult(createSampleAddressComponents({})); + mockAutocomplete?.simulatePlaceSelection(mockPlace); + await settled(); + }); + + test('callback receives all expected address fields', async function (assert) { + assert.expect(6); + + this.handleAddress = (result: AutocompletionAddress) => { + assert.ok('address1' in result, 'result has address1'); + assert.ok('city' in result, 'result has city'); + assert.ok('state' in result, 'result has state'); + assert.ok('zipcode' in result, 'result has zipcode'); + assert.ok('country' in result, 'result has country'); + assert.ok('formattedAddress' in result, 'result has formattedAddress'); + }; + + await render(hbs` + + `); + + const mockAutocomplete = this.mockLoader.getMockAutocompleteInstance(); + const mockPlace = createMockPlaceResult(createSampleAddressComponents({})); + mockAutocomplete?.simulatePlaceSelection(mockPlace); + await settled(); + }); + }); + + module('Cleanup', function () { + test('pac-container is removed on teardown', async function (assert) { + await render(hbs` + + `); + + const pacContainer = document.createElement('div'); + pacContainer.classList.add('pac-container'); + document.body.appendChild(pacContainer); + + assert.ok(document.querySelector('.pac-container')); + + await render(hbs`
`); + + assert.notOk(document.querySelector('.pac-container')); + }); + + test('wrapper is properly unwrapped during cleanup', async function (assert) { + await render( + hbs`
` + ); + + assert.dom('#test-input').exists(); + const wrapper = find('#test-input')!.parentElement; + assert.dom(wrapper).hasClass('autocomplete-input-container'); + + const wrapperParent = wrapper?.parentElement; + assert.dom(wrapperParent).exists(); + + await render(hbs`
`); + + assert.dom('.autocomplete-input-container').doesNotExist(); + assert.dom('#new-content').exists(); + }); + + test('cleanup handles already removed elements gracefully', async function (assert) { + await render( + hbs`
` + ); + + assert.dom('input[type="text"]').exists(); + const input = find('input[type="text"]'); + + // Manually remove the wrapper to simulate edge case + const wrapper = input?.parentElement; + if (wrapper?.classList.contains('autocomplete-input-container')) { + wrapper.remove(); + } + + await render(hbs`
`); + assert.ok(true, 'cleanup handled gracefully without errors'); + }); + + test('wrapper is not created when modifier is on container element', async function (assert) { + await render(hbs` +
+ +
+ `); + + const input = find('#container-input') as HTMLInputElement; + const container = input.parentElement; + + assert.dom(container).hasClass('autocomplete-input-container'); + assert.strictEqual(container?.tagName, 'DIV', 'parent is the original div container'); + + await render(hbs`
`); + assert.ok(true, 'cleanup completed without trying to unwrap'); + }); + }); + + module('Edge cases', function () { + test('handles missing callback gracefully', async function (assert) { + await render(hbs`
`); + + assert.dom('input[type="text"]').exists(); + + await settled(); + + const mockAutocomplete = this.mockLoader.getMockAutocompleteInstance(); + const mockPlace = createMockPlaceResult(createSampleAddressComponents({})); + + mockAutocomplete?.simulatePlaceSelection(mockPlace); + await settled(); + + assert.ok(true, 'no error thrown when callback is missing'); + }); + + test('works with pre-filled input value', async function (assert) { + this.value = '123 Main Street'; + + await render(hbs` + + `); + + assert.dom('input[type="text"]').hasValue('123 Main Street'); + }); + + test('preserves input attributes', async function (assert) { + await render(hbs` + + `); + + assert.dom('input[type="text"]').hasAttribute('id', 'address-input'); + assert.dom('input[type="text"]').hasClass('custom-input'); + assert.dom('input[type="text"]').hasAttribute('placeholder', 'Enter address'); + }); + + test('handles international addresses correctly', async function (assert) { + assert.expect(4); + + this.handleAddress = (result: AutocompletionAddress) => { + assert.strictEqual(result.address1, '10 Downing Street'); + assert.strictEqual(result.city, 'London'); + assert.strictEqual(result.zipcode, 'SW1A 2AA'); + assert.strictEqual(result.country.alpha2, 'GB'); + }; + + await render(hbs` + + `); + + const mockAutocomplete = this.mockLoader.getMockAutocompleteInstance(); + const mockPlace = createMockPlaceResult([ + { types: ['street_number'], long_name: '10', short_name: '10' }, + { types: ['route'], long_name: 'Downing Street', short_name: 'Downing St' }, + { types: ['postal_town'], long_name: 'London', short_name: 'London' }, + { types: ['postal_code'], long_name: 'SW1A 2AA', short_name: 'SW1A 2AA' }, + { types: ['country'], long_name: 'United Kingdom', short_name: 'GB' } + ]); + + mockAutocomplete?.simulatePlaceSelection(mockPlace); + await settled(); + }); + }); +}); diff --git a/tests/unit/utils/address-parser-test.ts b/tests/unit/utils/address-parser-test.ts new file mode 100644 index 00000000..da43a375 --- /dev/null +++ b/tests/unit/utils/address-parser-test.ts @@ -0,0 +1,181 @@ +import { module, test } from 'qunit'; +import { setupTest } from 'ember-qunit'; +import { parseAddressComponents } from '@upfluence/ember-upf-utils/utils/address-parser'; +import { createSampleAddressComponents } from '@upfluence/ember-upf-utils/utils/google-maps-mock'; + +module('Unit | Utility | address-parser', function (hooks) { + setupTest(hooks); + + module('parseAddressComponents', function () { + test('parses a complete US address correctly', function (assert) { + const components = createSampleAddressComponents({ + streetNumber: '1600', + route: 'Amphitheatre Parkway', + city: 'Mountain View', + state: 'California', + zipcode: '94043', + country: 'United States', + countryCode: 'US' + }); + + const result = parseAddressComponents(components, '1600 Amphitheatre Parkway, Mountain View, CA 94043'); + + assert.strictEqual(result.address1, '1600 Amphitheatre Parkway', 'address1 is correct'); + assert.strictEqual(result.city, 'Mountain View', 'city is correct'); + assert.strictEqual(result.state, 'California', 'state is correct'); + assert.strictEqual(result.zipcode, '94043', 'zipcode is correct'); + assert.strictEqual(result.country.alpha2, 'US', 'country code is correct'); + assert.strictEqual(result.formattedAddress, '1600 Amphitheatre Parkway, Mountain View, CA 94043'); + }); + + test('parses address with apartment/suite number (subpremise)', function (assert) { + const components = createSampleAddressComponents({ + streetNumber: '123', + route: 'Main Street', + subpremise: 'Apt 4B', + city: 'New York', + state: 'New York', + zipcode: '10001', + countryCode: 'US' + }); + + const result = parseAddressComponents(components, ''); + + assert.strictEqual(result.address1, '123 Main Street'); + assert.strictEqual(result.address2, 'Apt 4B'); + assert.strictEqual(result.city, 'New York'); + }); + + test('handles address without street number', function (assert) { + const components = [ + { types: ['route'], long_name: 'Broadway', short_name: 'Broadway' }, + { types: ['locality'], long_name: 'New York', short_name: 'New York' }, + { types: ['administrative_area_level_1'], long_name: 'New York', short_name: 'NY' }, + { types: ['postal_code'], long_name: '10001', short_name: '10001' }, + { types: ['country'], long_name: 'United States', short_name: 'US' } + ]; + + const result = parseAddressComponents(components, ''); + + assert.strictEqual(result.address1, 'Broadway'); + assert.strictEqual(result.city, 'New York'); + }); + + test('handles postal_code_suffix for extended zip codes', function (assert) { + const components = [ + { types: ['street_number'], long_name: '123', short_name: '123' }, + { types: ['route'], long_name: 'Main St', short_name: 'Main St' }, + { types: ['locality'], long_name: 'Portland', short_name: 'Portland' }, + { types: ['administrative_area_level_1'], long_name: 'Oregon', short_name: 'OR' }, + { types: ['postal_code'], long_name: '97201', short_name: '97201' }, + { types: ['postal_code_suffix'], long_name: '1234', short_name: '1234' }, + { types: ['country'], long_name: 'United States', short_name: 'US' } + ]; + + const result = parseAddressComponents(components, ''); + + assert.strictEqual(result.zipcode, '97201-1234'); + }); + + test('uses postal_town as city when locality is not available', function (assert) { + const components = [ + { types: ['street_number'], long_name: '10', short_name: '10' }, + { types: ['route'], long_name: 'Downing Street', short_name: 'Downing St' }, + { types: ['postal_town'], long_name: 'London', short_name: 'London' }, + { types: ['postal_code'], long_name: 'SW1A 2AA', short_name: 'SW1A 2AA' }, + { types: ['country'], long_name: 'United Kingdom', short_name: 'GB' } + ]; + + const result = parseAddressComponents(components, ''); + + assert.strictEqual(result.city, 'London'); + assert.strictEqual(result.country.alpha2, 'GB'); + }); + + test('postal_town takes precedence over locality when both present', function (assert) { + const components = [ + { types: ['route'], long_name: 'Test Street', short_name: 'Test St' }, + { types: ['postal_town'], long_name: 'Postal Town', short_name: 'Postal Town' }, + { types: ['locality'], long_name: 'Actual City', short_name: 'Actual City' }, + { types: ['postal_code'], long_name: '12345', short_name: '12345' }, + { types: ['country'], long_name: 'United States', short_name: 'US' } + ]; + + const result = parseAddressComponents(components, ''); + + assert.strictEqual(result.city, 'Postal Town'); + }); + + test('handles international address (France)', function (assert) { + const components = createSampleAddressComponents({ + streetNumber: '5', + route: 'Avenue Anatole France', + city: 'Paris', + state: 'Île-de-France', + zipcode: '75007', + country: 'France', + countryCode: 'FR' + }); + + const result = parseAddressComponents(components, ''); + + assert.strictEqual(result.address1, '5 Avenue Anatole France'); + assert.strictEqual(result.city, 'Paris'); + assert.strictEqual(result.country.alpha2, 'FR'); + }); + + test('defaults to US when country code is not recognized', function (assert) { + const components = [ + { types: ['route'], long_name: 'Unknown Street', short_name: 'Unknown St' }, + { types: ['locality'], long_name: 'Unknown City', short_name: 'Unknown City' }, + { types: ['country'], long_name: 'Unknown Country', short_name: 'XX' } + ]; + + const result = parseAddressComponents(components, ''); + + assert.strictEqual(result.country.alpha2, 'US'); + }); + + test('handles empty address components array', function (assert) { + const result = parseAddressComponents([], '123 Test St'); + + assert.strictEqual(result.address1, ''); + assert.strictEqual(result.city, ''); + assert.strictEqual(result.state, ''); + assert.strictEqual(result.zipcode, ''); + assert.strictEqual(result.country.alpha2, 'US'); + assert.strictEqual(result.formattedAddress, '123 Test St'); + }); + + test('omits address2 when not present', function (assert) { + const components = createSampleAddressComponents({ + streetNumber: '123', + route: 'Main St', + city: 'Test City', + zipcode: '12345', + countryCode: 'US' + }); + + const result = parseAddressComponents(components, ''); + + assert.notOk('address2' in result); + }); + + test('preserves formatted address parameter', function (assert) { + const components = createSampleAddressComponents({}); + const formattedAddress = '1600 Amphitheatre Parkway, Mountain View, CA 94043, USA'; + + const result = parseAddressComponents(components, formattedAddress); + + assert.strictEqual(result.formattedAddress, formattedAddress); + }); + + test('handles missing formatted address parameter', function (assert) { + const components = createSampleAddressComponents({}); + + const result = parseAddressComponents(components); + + assert.strictEqual(result.formattedAddress, ''); + }); + }); +}); From a0a77c3b22e1aa1e72415a3ae43dff39248edddb Mon Sep 17 00:00:00 2001 From: Julien Vannier Date: Mon, 26 Jan 2026 18:09:46 +0100 Subject: [PATCH 08/10] Fix failing uedit tests --- .../u-edit/shared-triggers/modals/image-upload-test.ts | 2 +- .../u-edit/shared-triggers/modals/pdf-upload-test.ts | 2 +- tests/integration/modifiers/setup-autocomplete-test.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/integration/components/u-edit/shared-triggers/modals/image-upload-test.ts b/tests/integration/components/u-edit/shared-triggers/modals/image-upload-test.ts index fdf0a957..ac27bbd9 100644 --- a/tests/integration/components/u-edit/shared-triggers/modals/image-upload-test.ts +++ b/tests/integration/components/u-edit/shared-triggers/modals/image-upload-test.ts @@ -43,7 +43,7 @@ module('Integration | Component | u-edit/shared-triggers/modals/image-upload', f hbs`` ); await click('[data-control-name="close-modal-button"]'); - assert.true(this.onClose.calledOnceWithExactly()); + assert.true(this.onClose.calledOnce); }); test("the alert info isn't rendered", async function (assert) { diff --git a/tests/integration/components/u-edit/shared-triggers/modals/pdf-upload-test.ts b/tests/integration/components/u-edit/shared-triggers/modals/pdf-upload-test.ts index cb4cc1dc..35dd7250 100644 --- a/tests/integration/components/u-edit/shared-triggers/modals/pdf-upload-test.ts +++ b/tests/integration/components/u-edit/shared-triggers/modals/pdf-upload-test.ts @@ -43,7 +43,7 @@ module('Integration | Component | u-edit/shared-triggers/modals/pdf-upload', fun hbs`` ); await click('[data-control-name="close-modal-button"]'); - assert.true(this.onClose.calledOnceWithExactly()); + assert.true(this.onClose.calledOnce); }); test('the alert info is rendered', async function (assert) { diff --git a/tests/integration/modifiers/setup-autocomplete-test.ts b/tests/integration/modifiers/setup-autocomplete-test.ts index dbf3a123..4f30b197 100644 --- a/tests/integration/modifiers/setup-autocomplete-test.ts +++ b/tests/integration/modifiers/setup-autocomplete-test.ts @@ -17,7 +17,7 @@ module('Integration | Modifier | setup-autocomplete', function (hooks) { this.mockLoader = new MockLoader({ apiKey: 'test-key' }); }); - module('Element setup', function () { + module('Element setup', () => { test('it works with a text input element directly', async function (assert) { await render( hbs`
` @@ -40,7 +40,7 @@ module('Integration | Modifier | setup-autocomplete', function (hooks) { }); }); - module('Callback functionality', function () { + module('Callback functionality', () => { test('callback is called with parsed address data', async function (assert) { assert.expect(1); @@ -81,7 +81,7 @@ module('Integration | Modifier | setup-autocomplete', function (hooks) { }); }); - module('Cleanup', function () { + module('Cleanup', () => { test('pac-container is removed on teardown', async function (assert) { await render(hbs` @@ -152,7 +152,7 @@ module('Integration | Modifier | setup-autocomplete', function (hooks) { }); }); - module('Edge cases', function () { + module('Edge cases', () => { test('handles missing callback gracefully', async function (assert) { await render(hbs`
`); From fbf56190829ab1aeaea70350939d03a4363ce00e Mon Sep 17 00:00:00 2001 From: Julien Vannier Date: Tue, 27 Jan 2026 15:05:36 +0100 Subject: [PATCH 09/10] PR feedback --- addon/modifiers/setup-autocomplete.ts | 9 ++- addon/utils/address-parser.ts | 37 ++++++------ app/utils/serialize-params.js | 1 - .../modifiers/setup-autocomplete-test.ts | 59 +++++++++++++------ 4 files changed, 68 insertions(+), 38 deletions(-) delete mode 100644 app/utils/serialize-params.js diff --git a/addon/modifiers/setup-autocomplete.ts b/addon/modifiers/setup-autocomplete.ts index 936c28d0..f1833415 100644 --- a/addon/modifiers/setup-autocomplete.ts +++ b/addon/modifiers/setup-autocomplete.ts @@ -68,6 +68,11 @@ export default class SetupAutocompleteModifier extends Modifier { + private setupAutoComplete(loader?: Loader): Promise { const loaderInstance = isTesting() ? loader ?? new MockLoader({ apiKey: 'test-key' }) : new Loader({ @@ -127,7 +132,7 @@ export default class SetupAutocompleteModifier extends Modifier { + return loaderInstance.importLibrary('places').then(({ Autocomplete }) => { this.initializeAutocomplete(Autocomplete); }); } diff --git a/addon/utils/address-parser.ts b/addon/utils/address-parser.ts index 4fb11462..c3514489 100644 --- a/addon/utils/address-parser.ts +++ b/addon/utils/address-parser.ts @@ -1,16 +1,19 @@ import { countries, CountryData } from '@upfluence/oss-components/utils/country-codes'; +const ADDRESS_COMPONENT_TYPES = [ + 'street_number', + 'route', + 'subpremise', + 'postal_code', + 'postal_code_suffix', + 'locality', + 'postal_town', + 'administrative_area_level_1', + 'country' +] as const; + type GoogleAddressComponent = google.maps.GeocoderAddressComponent; -type AddressComponentType = - | 'street_number' - | 'route' - | 'subpremise' - | 'postal_code' - | 'postal_code_suffix' - | 'locality' - | 'postal_town' - | 'administrative_area_level_1' - | 'country'; +type AddressComponentType = (typeof ADDRESS_COMPONENT_TYPES)[number]; export type AutocompletionAddress = { address1: string; @@ -38,31 +41,31 @@ export function parseAddressComponents( }; const mapper: Record void> = { - street_number: (comp: GoogleAddressComponent) => { + street_number: (comp: GoogleAddressComponent): void => { result.address1 = `${comp.long_name} ${result.address1}`.trim(); }, - route: (comp: GoogleAddressComponent) => { + route: (comp: GoogleAddressComponent): void => { result.address1 += comp.long_name; }, - subpremise: (comp: GoogleAddressComponent) => { + subpremise: (comp: GoogleAddressComponent): void => { result.address2 = comp.long_name; }, - postal_code: (comp: GoogleAddressComponent) => { + postal_code: (comp: GoogleAddressComponent): void => { result.zipcode = `${comp.long_name}${result.zipcode}`; }, postal_code_suffix: (comp: GoogleAddressComponent) => { result.zipcode = `${result.zipcode}-${comp.long_name}`; }, - locality: (comp: GoogleAddressComponent) => { + locality: (comp: GoogleAddressComponent): void => { result.city = comp.long_name; }, - postal_town: (comp: GoogleAddressComponent) => { + postal_town: (comp: GoogleAddressComponent): void => { result.city = comp.long_name; }, administrative_area_level_1: (comp: GoogleAddressComponent) => { result.state = comp.long_name; }, - country: (comp: GoogleAddressComponent) => { + country: (comp: GoogleAddressComponent): void => { result.country = countries.find((country) => country.alpha2 === comp.short_name) ?? defaultCountry; } }; diff --git a/app/utils/serialize-params.js b/app/utils/serialize-params.js deleted file mode 100644 index 980afb15..00000000 --- a/app/utils/serialize-params.js +++ /dev/null @@ -1 +0,0 @@ -export { default } from '@upfluence/ember-upf-utils/utils/serialize-params'; diff --git a/tests/integration/modifiers/setup-autocomplete-test.ts b/tests/integration/modifiers/setup-autocomplete-test.ts index 4f30b197..64984b3f 100644 --- a/tests/integration/modifiers/setup-autocomplete-test.ts +++ b/tests/integration/modifiers/setup-autocomplete-test.ts @@ -1,6 +1,6 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { render, find, settled } from '@ember/test-helpers'; +import { render, find, settled, setupOnerror } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; import { AutocompletionAddress } from '@upfluence/ember-upf-utils/utils/address-parser'; @@ -124,7 +124,6 @@ module('Integration | Modifier | setup-autocomplete', function (hooks) { assert.dom('input[type="text"]').exists(); const input = find('input[type="text"]'); - // Manually remove the wrapper to simulate edge case const wrapper = input?.parentElement; if (wrapper?.classList.contains('autocomplete-input-container')) { wrapper.remove(); @@ -153,22 +152,6 @@ module('Integration | Modifier | setup-autocomplete', function (hooks) { }); module('Edge cases', () => { - test('handles missing callback gracefully', async function (assert) { - await render(hbs`
`); - - assert.dom('input[type="text"]').exists(); - - await settled(); - - const mockAutocomplete = this.mockLoader.getMockAutocompleteInstance(); - const mockPlace = createMockPlaceResult(createSampleAddressComponents({})); - - mockAutocomplete?.simulatePlaceSelection(mockPlace); - await settled(); - - assert.ok(true, 'no error thrown when callback is missing'); - }); - test('works with pre-filled input value', async function (assert) { this.value = '123 Main Street'; @@ -221,5 +204,45 @@ module('Integration | Modifier | setup-autocomplete', function (hooks) { mockAutocomplete?.simulatePlaceSelection(mockPlace); await settled(); }); + + module('for error management', () => { + test('handles missing callback gracefully', async function (assert) { + assert.expect(1); + setupOnerror((error: Error) => { + assert.equal( + error.message, + 'Assertion Failed: [modifier][setup-autocomplete] The callback is mandatory and must be a function' + ); + }); + + await render(hbs`
`); + }); + + test('handles missing input element gracefully', async function (assert) { + assert.expect(1); + setupOnerror((error: Error) => { + assert.equal( + error.message, + 'Assertion Failed: [modifier][setup-autocomplete] No input[type="text"] element found in the provided element or its children' + ); + }); + + await render(hbs`
`); + }); + + test('handles missing input element in its children gracefully', async function (assert) { + assert.expect(1); + setupOnerror((error: Error) => { + assert.equal( + error.message, + 'Assertion Failed: [modifier][setup-autocomplete] No input[type="text"] element found in the provided element or its children' + ); + }); + + await render( + hbs`
` + ); + }); + }); }); }); From b4bf65fc67054bb0fb5f96bd91bd617f8618f68d Mon Sep 17 00:00:00 2001 From: Julien Vannier Date: Tue, 27 Jan 2026 18:03:59 +0100 Subject: [PATCH 10/10] Move AutocompletionAddress type in the modifier --- addon/components/utils/address-form.ts | 2 +- addon/components/utils/address-inline.ts | 2 +- addon/modifiers/setup-autocomplete.ts | 17 ++++++++++++++--- addon/utils/address-parser.ts | 14 +++----------- tests/dummy/app/controllers/application.ts | 2 +- .../modifiers/setup-autocomplete-test.ts | 2 +- 6 files changed, 21 insertions(+), 18 deletions(-) diff --git a/addon/components/utils/address-form.ts b/addon/components/utils/address-form.ts index ab34d87a..b093ee99 100644 --- a/addon/components/utils/address-form.ts +++ b/addon/components/utils/address-form.ts @@ -4,7 +4,7 @@ import { action, get, set } from '@ember/object'; import { isEmpty } from '@ember/utils'; import { CountryData, countries } from '@upfluence/oss-components/utils/country-codes'; import { next } from '@ember/runloop'; -import { AutocompletionAddress } from '@upfluence/ember-upf-utils/utils/address-parser'; +import { type AutocompletionAddress } from '@upfluence/ember-upf-utils/modifiers/setup-autocomplete'; type FocusableInput = | 'first-name' diff --git a/addon/components/utils/address-inline.ts b/addon/components/utils/address-inline.ts index fe4d0094..745568eb 100644 --- a/addon/components/utils/address-inline.ts +++ b/addon/components/utils/address-inline.ts @@ -1,6 +1,6 @@ import Component from '@glimmer/component'; import { action } from '@ember/object'; -import { AutocompletionAddress } from '@upfluence/ember-upf-utils/utils/address-parser'; +import { type AutocompletionAddress } from '@upfluence/ember-upf-utils/modifiers/setup-autocomplete'; interface UtilsAddressInlineArgs { value: ShippingAddress; diff --git a/addon/modifiers/setup-autocomplete.ts b/addon/modifiers/setup-autocomplete.ts index f1833415..7537ba2c 100644 --- a/addon/modifiers/setup-autocomplete.ts +++ b/addon/modifiers/setup-autocomplete.ts @@ -6,12 +6,22 @@ import { isTesting } from '@embroider/macros'; import { Loader } from '@googlemaps/js-api-loader'; -import { parseAddressComponents, type AutocompletionAddress } from '@upfluence/ember-upf-utils/utils/address-parser'; +import { parseAddressComponents } from '@upfluence/ember-upf-utils/utils/address-parser'; import { MockLoader } from '@upfluence/ember-upf-utils/utils/google-maps-mock'; +import { CountryData } from '@upfluence/oss-components/utils/country-codes'; type GooglePlaceResult = google.maps.places.PlaceResult; type GoogleAutocomplete = google.maps.places.Autocomplete; type GoogleAutocompleteOptions = google.maps.places.AutocompleteOptions; +export type AutocompletionAddress = { + address1: string; + address2?: string; + city: string; + state: string; + zipcode: string; + country: CountryData; + formattedAddress: string; +}; interface SetupAutocompleteSignature { Element: HTMLElement; @@ -125,14 +135,15 @@ export default class SetupAutocompleteModifier extends Modifier { - const loaderInstance = isTesting() + const loaderInstance: Loader | MockLoader = isTesting() ? loader ?? new MockLoader({ apiKey: 'test-key' }) : new Loader({ apiKey: getOwner(this).resolveRegistration('config:environment').google_map_api_key, version: 'weekly' }); - return loaderInstance.importLibrary('places').then(({ Autocomplete }) => { + // @ts-ignore + return loaderInstance.importLibrary('places').then(({ Autocomplete }: google.maps.PlacesLibrary) => { this.initializeAutocomplete(Autocomplete); }); } diff --git a/addon/utils/address-parser.ts b/addon/utils/address-parser.ts index c3514489..049bc775 100644 --- a/addon/utils/address-parser.ts +++ b/addon/utils/address-parser.ts @@ -1,4 +1,6 @@ -import { countries, CountryData } from '@upfluence/oss-components/utils/country-codes'; +import { countries } from '@upfluence/oss-components/utils/country-codes'; + +import { type AutocompletionAddress } from '@upfluence/ember-upf-utils/modifiers/setup-autocomplete'; const ADDRESS_COMPONENT_TYPES = [ 'street_number', @@ -15,16 +17,6 @@ const ADDRESS_COMPONENT_TYPES = [ type GoogleAddressComponent = google.maps.GeocoderAddressComponent; type AddressComponentType = (typeof ADDRESS_COMPONENT_TYPES)[number]; -export type AutocompletionAddress = { - address1: string; - address2?: string; - city: string; - state: string; - zipcode: string; - country: CountryData; - formattedAddress: string; -}; - export function parseAddressComponents( components: GoogleAddressComponent[], formattedAddress: string = '' diff --git a/tests/dummy/app/controllers/application.ts b/tests/dummy/app/controllers/application.ts index e8602c05..81df42ed 100644 --- a/tests/dummy/app/controllers/application.ts +++ b/tests/dummy/app/controllers/application.ts @@ -1,7 +1,7 @@ import Controller from '@ember/controller'; import { action } from '@ember/object'; import { tracked } from '@glimmer/tracking'; -import { AutocompletionAddress } from '@upfluence/ember-upf-utils/utils/address-parser'; +import { type AutocompletionAddress } from '@upfluence/ember-upf-utils/modifiers/setup-autocomplete'; export default class ApplicationController extends Controller { @tracked selectedItems = ['toto']; diff --git a/tests/integration/modifiers/setup-autocomplete-test.ts b/tests/integration/modifiers/setup-autocomplete-test.ts index 64984b3f..852213c0 100644 --- a/tests/integration/modifiers/setup-autocomplete-test.ts +++ b/tests/integration/modifiers/setup-autocomplete-test.ts @@ -2,7 +2,7 @@ import { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; import { render, find, settled, setupOnerror } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; -import { AutocompletionAddress } from '@upfluence/ember-upf-utils/utils/address-parser'; +import { type AutocompletionAddress } from '@upfluence/ember-upf-utils/modifiers/setup-autocomplete'; import { createMockPlaceResult,