diff --git a/addon/components/utils/address-form.hbs b/addon/components/utils/address-form.hbs index dd6163fe..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}} - + \ No newline at end of file diff --git a/addon/components/utils/address-form.ts b/addon/components/utils/address-form.ts index 780f0602..b093ee99 100644 --- a/addon/components/utils/address-form.ts +++ b/addon/components/utils/address-form.ts @@ -2,12 +2,9 @@ import Component from '@glimmer/component'; import { tracked } from '@glimmer/tracking'; import { action, get, set } from '@ember/object'; import { isEmpty } from '@ember/utils'; -import { isTesting } from '@embroider/macros'; -import { getOwner } from '@ember/application'; - -import { Loader } from '@googlemaps/js-api-loader'; import { CountryData, countries } from '@upfluence/oss-components/utils/country-codes'; import { next } from '@ember/runloop'; +import { type AutocompletionAddress } from '@upfluence/ember-upf-utils/modifiers/setup-autocomplete'; type FocusableInput = | 'first-name' @@ -32,9 +29,6 @@ interface UtilsAddressFormArgs { } type ProvinceData = { code: string; name: string }; -type GAddressComponent = google.maps.GeocoderAddressComponent; -type GPlaceResult = google.maps.places.PlaceResult; -type GAutoComplete = google.maps.places.Autocomplete; type AddressKey = 'address' | 'line'; const BASE_VALIDATED_ADDRESS_FIELDS: string[] = ['city', 'countryCode', 'zipcode']; @@ -63,27 +57,6 @@ export default class extends Component { 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 4d33c3a2..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..745568eb 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 { type 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 new file mode 100644 index 00000000..7537ba2c --- /dev/null +++ b/addon/modifiers/setup-autocomplete.ts @@ -0,0 +1,173 @@ +import Modifier, { type ArgsFor, type PositionalArgs, type NamedArgs } from 'ember-modifier'; +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 } 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; + Args: { + Named: { + callback(result: AutocompletionAddress): void; + loader?: Loader; + }; + }; +} + +const AUTOCOMPLETE_CONTAINER_CLASS = 'autocomplete-input-container'; +const AUTOCOMPLETE_OPTIONS: GoogleAutocompleteOptions = { + fields: ['address_components'], + strictBounds: false, + types: ['address'] +}; + +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(); +} + +export default class SetupAutocompleteModifier extends Modifier { + targetElement: HTMLElement | null = null; + targetInput: HTMLInputElement | null = null; + result: AutocompletionAddress | null = null; + createdWrapper: boolean = false; + + private callback: ((result: AutocompletionAddress) => void) | null = null; + + constructor(owner: unknown, args: ArgsFor) { + super(owner, args); + registerDestructor(this, cleanup); + } + + modify( + element: HTMLElement, + _: PositionalArgs, + { callback, loader }: NamedArgs + ): void { + const input: HTMLInputElement | null = this.getInputElement(element); + if (!input) return; + + this.targetInput = input; + + assert( + '[modifier][setup-autocomplete] The callback is mandatory and must be a function', + typeof callback === 'function' + ); + this.callback = callback; + + if (!this.targetElement) { + this.setupTargetElement(element, input); + this.setupAutoComplete(loader); + } + } + + private setupTargetElement(element: HTMLElement, input: HTMLInputElement): void { + if (element === input) { + this.targetElement = this.createWrapperForInput(input); + } else { + this.targetElement = element; + this.targetElement.classList.add(AUTOCOMPLETE_CONTAINER_CLASS); + } + } + + private createWrapperForInput(input: HTMLInputElement): HTMLElement { + const parentNode = input.parentNode; + assert('[modifier][setup-autocomplete] Input element must have a parent node', parentNode !== null); + + if (parentNode instanceof HTMLElement && parentNode.classList.contains(AUTOCOMPLETE_CONTAINER_CLASS)) { + return parentNode; + } + + const wrapper = document.createElement('div'); + wrapper.classList.add(AUTOCOMPLETE_CONTAINER_CLASS); + + parentNode.insertBefore(wrapper, input); + wrapper.appendChild(input); + + this.createdWrapper = true; + + return wrapper; + } + + private getInputElement(element: HTMLElement): HTMLInputElement | null { + if (this.isTextInput(element)) return element as HTMLInputElement; + + const inputElement = element.querySelector('input[type="text"]'); + assert( + '[modifier][setup-autocomplete] No input[type="text"] element found in the provided element or its children', + inputElement !== null + ); + return inputElement as HTMLInputElement; + } + + private isTextInput(element: HTMLElement): element is HTMLInputElement { + return element.tagName === 'INPUT' && (element as HTMLInputElement).type === 'text'; + } + + private setupAutoComplete(loader?: Loader): Promise { + 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' + }); + + // @ts-ignore + return loaderInstance.importLibrary('places').then(({ Autocomplete }: google.maps.PlacesLibrary) => { + this.initializeAutocomplete(Autocomplete); + }); + } + + private initializeAutocomplete( + AutocompleteConstructor: new ( + input: HTMLInputElement, + options?: google.maps.places.AutocompleteOptions + ) => GoogleAutocomplete + ): void { + 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.handlePlaceChanged(place); + }); + } + + private handlePlaceChanged(place: GooglePlaceResult): void { + if (!place.address_components) return; + + const formattedAddress = this.targetInput?.value ?? ''; + this.result = parseAddressComponents(place.address_components, formattedAddress); + this.callback?.(this.result); + } +} diff --git a/addon/utils/address-parser.ts b/addon/utils/address-parser.ts new file mode 100644 index 00000000..049bc775 --- /dev/null +++ b/addon/utils/address-parser.ts @@ -0,0 +1,73 @@ +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', + 'route', + 'subpremise', + 'postal_code', + 'postal_code_suffix', + 'locality', + 'postal_town', + 'administrative_area_level_1', + 'country' +] as const; + +type GoogleAddressComponent = google.maps.GeocoderAddressComponent; +type AddressComponentType = (typeof ADDRESS_COMPONENT_TYPES)[number]; + +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): void => { + result.address1 = `${comp.long_name} ${result.address1}`.trim(); + }, + route: (comp: GoogleAddressComponent): void => { + result.address1 += comp.long_name; + }, + subpremise: (comp: GoogleAddressComponent): void => { + result.address2 = comp.long_name; + }, + 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): void => { + result.city = comp.long_name; + }, + postal_town: (comp: GoogleAddressComponent): void => { + result.city = comp.long_name; + }, + administrative_area_level_1: (comp: GoogleAddressComponent) => { + result.state = comp.long_name; + }, + country: (comp: GoogleAddressComponent): void => { + 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/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/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..7acd6e81 100644 --- a/app/styles/components/utils/address-form.less +++ b/app/styles/components/utils/address-form.less @@ -1,45 +1,47 @@ -.google-autocomplete-input-container { +.autocomplete-input-container { position: relative; - .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-target-input { + width: 100%; + } +} - &:after { - display: none; - } +.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; - .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); - } + &:after { + display: none; + } - .pac-item-query { - color: var(--color-gray-600); - } + .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-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/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/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/package.json b/package.json index 7de1cc00..a3c9b875 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", @@ -84,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 54580fce..2b2dd8e4 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 @@ -148,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) @@ -955,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} @@ -967,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} @@ -988,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==} @@ -1045,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': @@ -1244,6 +1276,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==} @@ -1419,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 @@ -1828,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'} @@ -2097,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==} @@ -2910,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==} @@ -4779,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'} @@ -5802,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==} @@ -5938,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'} @@ -7873,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 @@ -7912,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 @@ -7931,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 @@ -7945,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: @@ -8031,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: @@ -8316,6 +8422,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 @@ -8547,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 @@ -8571,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' @@ -9118,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 @@ -10633,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: {} @@ -13452,6 +13566,8 @@ snapshots: lodash@4.17.21: {} + lodash@4.17.23: {} + log-symbols@2.2.0: dependencies: chalk: 2.4.2 @@ -14541,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 @@ -14688,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.js b/tests/dummy/app/controllers/application.ts similarity index 58% rename from tests/dummy/app/controllers/application.js rename to tests/dummy/app/controllers/application.ts index cdf40f82..81df42ed 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 { type AutocompletionAddress } 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,29 @@ 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; + console.log('Auto-complete shipping address', value); + } + + @action + onAutoComplete(value: AutocompletionAddress) { + this.inputValue = value.formattedAddress; + console.log('Auto-complete oss/input-container address', value); + } + + @action + onOssAutoComplete(value: AutocompletionAddress) { + this.ossInputValue = value.formattedAddress; + console.log('Auto-complete native input address', value); } } diff --git a/tests/dummy/app/templates/application.hbs b/tests/dummy/app/templates/application.hbs index 77dd90bc..7d3f529d 100644 --- a/tests/dummy/app/templates/application.hbs +++ b/tests/dummy/app/templates/application.hbs @@ -25,4 +25,6 @@ + + \ No newline at end of file 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/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..aba351b1 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(); }); }); diff --git a/tests/integration/modifiers/setup-autocomplete-test.ts b/tests/integration/modifiers/setup-autocomplete-test.ts new file mode 100644 index 00000000..852213c0 --- /dev/null +++ b/tests/integration/modifiers/setup-autocomplete-test.ts @@ -0,0 +1,248 @@ +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 { type AutocompletionAddress } from '@upfluence/ember-upf-utils/modifiers/setup-autocomplete'; + +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', () => { + 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', () => { + 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', () => { + 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"]'); + + 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', () => { + 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(); + }); + + 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`
` + ); + }); + }); + }); +}); 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, ''); + }); + }); +});