diff --git a/packages/common/src/location/location.ts b/packages/common/src/location/location.ts index 0a7683c511..f850fdda4e 100644 --- a/packages/common/src/location/location.ts +++ b/packages/common/src/location/location.ts @@ -57,7 +57,8 @@ export class Location { _platformStrategy: LocationStrategy; /** @internal */ _platformLocation: PlatformLocation; - private urlChangeListeners: any[] = []; + /** @internal */ + _urlChangeListeners: ((url: string, state: unknown) => void)[] = []; constructor(platformStrategy: LocationStrategy, platformLocation: PlatformLocation) { this._platformStrategy = platformStrategy; @@ -147,7 +148,7 @@ export class Location { */ go(path: string, query: string = '', state: any = null): void { this._platformStrategy.pushState(state, '', path, query); - this.notifyUrlChangeListeners( + this._notifyUrlChangeListeners( this.prepareExternalUrl(path + Location.normalizeQueryParams(query)), state); } @@ -161,7 +162,7 @@ export class Location { */ replaceState(path: string, query: string = '', state: any = null): void { this._platformStrategy.replaceState(state, '', path, query); - this.notifyUrlChangeListeners( + this._notifyUrlChangeListeners( this.prepareExternalUrl(path + Location.normalizeQueryParams(query)), state); } @@ -180,13 +181,13 @@ export class Location { * framework. These are not detectible through "popstate" or "hashchange" events. */ onUrlChange(fn: (url: string, state: unknown) => void) { - this.urlChangeListeners.push(fn); - this.subscribe(v => { this.notifyUrlChangeListeners(v.url, v.state); }); + this._urlChangeListeners.push(fn); + this.subscribe(v => { this._notifyUrlChangeListeners(v.url, v.state); }); } - - private notifyUrlChangeListeners(url: string = '', state: unknown) { - this.urlChangeListeners.forEach(fn => fn(url, state)); + /** @internal */ + _notifyUrlChangeListeners(url: string = '', state: unknown) { + this._urlChangeListeners.forEach(fn => fn(url, state)); } /** diff --git a/packages/common/test/location/location_spec.ts b/packages/common/test/location/location_spec.ts index 97709e4b7c..92b876f5f6 100644 --- a/packages/common/test/location/location_spec.ts +++ b/packages/common/test/location/location_spec.ts @@ -6,8 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {CommonModule, Location, LocationStrategy, PlatformLocation} from '@angular/common'; -import {PathLocationStrategy} from '@angular/common/src/common'; +import {CommonModule, Location, LocationStrategy, PathLocationStrategy, PlatformLocation} from '@angular/common'; import {MockPlatformLocation} from '@angular/common/testing'; import {TestBed, inject} from '@angular/core/testing'; @@ -91,23 +90,22 @@ describe('Location Class', () => { }); it('should have onUrlChange method', inject([Location], (location: Location) => { - expect(typeof location.onUrlChange).toBe('function'); - })); + expect(typeof location.onUrlChange).toBe('function'); + })); - it('should add registered functions to urlChangeListeners', inject([Location], (location: Location) => { + it('should add registered functions to urlChangeListeners', + inject([Location], (location: Location) => { - function changeListener(url: string, state: unknown) { - return undefined; - } + function changeListener(url: string, state: unknown) { return undefined; } - expect((location as any).urlChangeListeners.length).toBe(0); + expect((location as any)._urlChangeListeners.length).toBe(0); - location.onUrlChange(changeListener); + location.onUrlChange(changeListener); - expect((location as any).urlChangeListeners.length).toBe(1); - expect((location as any).urlChangeListeners[0]).toEqual(changeListener); - - })); + expect((location as any)._urlChangeListeners.length).toBe(1); + expect((location as any)._urlChangeListeners[0]).toEqual(changeListener); + + })); }); }); \ No newline at end of file diff --git a/packages/common/testing/src/location_mock.ts b/packages/common/testing/src/location_mock.ts index d72d38ea31..a5efc1ffb3 100644 --- a/packages/common/testing/src/location_mock.ts +++ b/packages/common/testing/src/location_mock.ts @@ -10,19 +10,13 @@ import {Location, LocationStrategy, PlatformLocation} from '@angular/common'; import {EventEmitter, Injectable} from '@angular/core'; import {SubscriptionLike} from 'rxjs'; - -const urlChangeListeners: ((url: string, state: unknown) => void)[] = []; -function notifyUrlChangeListeners(url: string = '', state: unknown) { - urlChangeListeners.forEach(fn => fn(url, state)); -} - /** * A spy for {@link Location} that allows tests to fire simulated location events. * * @publicApi */ @Injectable() -export class SpyLocation extends Location { +export class SpyLocation implements Location { urlChanges: string[] = []; private _history: LocationState[] = [new LocationState('', '', null)]; private _historyIndex: number = 0; @@ -34,6 +28,8 @@ export class SpyLocation extends Location { _platformStrategy: LocationStrategy = null !; /** @internal */ _platformLocation: PlatformLocation = null !; + /** @internal */ + _urlChangeListeners: ((url: string, state: unknown) => void)[] = []; setInitialPath(url: string) { this._history[this._historyIndex].path = url; } @@ -118,8 +114,13 @@ export class SpyLocation extends Location { } } onUrlChange(fn: (url: string, state: unknown) => void) { - urlChangeListeners.push(fn); - this.subscribe(v => { notifyUrlChangeListeners(v.url, v.state); }); + this._urlChangeListeners.push(fn); + this.subscribe(v => { this._notifyUrlChangeListeners(v.url, v.state); }); + } + + /** @internal */ + _notifyUrlChangeListeners(url: string = '', state: unknown) { + this._urlChangeListeners.forEach(fn => fn(url, state)); } subscribe( diff --git a/packages/common/testing/src/mock_platform_location.ts b/packages/common/testing/src/mock_platform_location.ts index 51dcf3cc1c..53142b6a5f 100644 --- a/packages/common/testing/src/mock_platform_location.ts +++ b/packages/common/testing/src/mock_platform_location.ts @@ -12,7 +12,7 @@ import {Subject} from 'rxjs'; function parseUrl(urlStr: string, baseHref: string) { const verifyProtocol = /^((http[s]?|ftp):\/\/)/; - let serverBase; + let serverBase: string|undefined; // URL class requires full URL. If the URL string doesn't start with protocol, we need to add // an arbitrary base URL which can be removed afterward. @@ -42,6 +42,8 @@ export const MOCK_PLATFORM_LOCATION_CONFIG = new InjectionToken('MOCK_PLATFORM_L /** * Mock implementation of URL state. + * + * @publicApi */ @Injectable() export class MockPlatformLocation implements PlatformLocation { @@ -87,24 +89,12 @@ export class MockPlatformLocation implements PlatformLocation { get href(): string { let url = `${this.protocol}//${this.hostname}${this.port ? ':' + this.port : ''}`; - url += `${this.pathname === '/' ? '' : this.pathname}${this.search}${this.hash}` + url += `${this.pathname === '/' ? '' : this.pathname}${this.search}${this.hash}`; return url; } get url(): string { return `${this.pathname}${this.search}${this.hash}`; } - private setHash(value: string, oldUrl: string) { - if (this.hash === value) { - // Don't fire events if the hash has not changed. - return; - } - (this as{hash: string}).hash = value; - const newUrl = this.url; - scheduleMicroTask(() => this.hashUpdate.next({ - type: 'hashchange', state: null, oldUrl, newUrl - } as LocationChangeEvent)); - } - private parseChanges(state: unknown, url: string, baseHref: string = '') { // When the `history.state` value is stored, it is always copied. state = JSON.parse(JSON.stringify(state)); @@ -112,24 +102,31 @@ export class MockPlatformLocation implements PlatformLocation { } replaceState(state: any, title: string, newUrl: string): void { - const oldUrl = this.url; - const {pathname, search, state: parsedState, hash} = this.parseChanges(state, newUrl); - this.urlChanges[0] = {...this.urlChanges[0], pathname, search, state: parsedState}; - this.setHash(hash, oldUrl); + this.urlChanges[0] = {...this.urlChanges[0], pathname, search, hash, state: parsedState}; } pushState(state: any, title: string, newUrl: string): void { const {pathname, search, state: parsedState, hash} = this.parseChanges(state, newUrl); - this.urlChanges.unshift({...this.urlChanges[0], pathname, search, state: parsedState}); + this.urlChanges.unshift({...this.urlChanges[0], pathname, search, hash, state: parsedState}); } forward(): void { throw new Error('Not implemented'); } - back(): void { this.urlChanges.shift(); } + back(): void { + const oldUrl = this.url; + const oldHash = this.hash; + this.urlChanges.shift(); + const newHash = this.hash; + + if (oldHash !== newHash) { + scheduleMicroTask(() => this.hashUpdate.next({ + type: 'hashchange', state: null, oldUrl, newUrl: this.url + } as LocationChangeEvent)); + } + } - // History API isn't available on server, therefore return undefined getState(): unknown { return this.state; } } diff --git a/packages/common/upgrade/rollup.config.js b/packages/common/upgrade/rollup.config.js deleted file mode 100644 index 1b5064f046..0000000000 --- a/packages/common/upgrade/rollup.config.js +++ /dev/null @@ -1,30 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -const resolve = require('rollup-plugin-node-resolve'); -const sourcemaps = require('rollup-plugin-sourcemaps'); - -const globals = { - '@angular/core': 'ng.core', - '@angular/common': 'ng.common', - '@angular/common/upgrade': 'ng.common.upgrade', - '@angular/upgrade/static': 'ng.upgrade.static' -}; - - -module.exports = { - entry: '../../../dist/packages-dist/common/fesm5/upgrade.js', - dest: '../../../dist/packages-dist/common/bundles/common-upgrade.umd.js', - format: 'umd', - exports: 'named', - amd: {id: '@angular/common/upgrade'}, - moduleName: 'ng.common.upgrade', - plugins: [resolve(), sourcemaps()], - external: Object.keys(globals), - globals: globals -}; diff --git a/packages/common/upgrade/src/$location.ts b/packages/common/upgrade/src/$location.ts new file mode 100644 index 0000000000..a21b0c5977 --- /dev/null +++ b/packages/common/upgrade/src/$location.ts @@ -0,0 +1,694 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {Location, LocationStrategy, PlatformLocation} from '@angular/common'; +import {UpgradeModule} from '@angular/upgrade/static'; + +import {UrlCodec} from './params'; +import {deepEqual, isAnchor, isPromise} from './utils'; + +const PATH_MATCH = /^([^?#]*)(\?([^#]*))?(#(.*))?$/; +const DOUBLE_SLASH_REGEX = /^\s*[\\/]{2,}/; +const IGNORE_URI_REGEXP = /^\s*(javascript|mailto):/i; +const DEFAULT_PORTS: {[key: string]: number} = { + 'http:': 80, + 'https:': 443, + 'ftp:': 21 +}; + +/** + * Docs TBD. + * + * @publicApi + */ +export class LocationUpgradeService { + private initalizing = true; + private updateBrowser = false; + private $$absUrl: string = ''; + private $$url: string = ''; + private $$protocol: string; + private $$host: string = ''; + private $$port: number|null; + private $$replace: boolean = false; + private $$path: string = ''; + private $$search: any = ''; + private $$hash: string = ''; + private $$state: unknown; + + private cachedState: unknown = null; + + constructor( + $injector: any, private location: Location, private platformLocation: PlatformLocation, + private urlCodec: UrlCodec, private locationStrategy: LocationStrategy) { + const initialUrl = this.browserUrl(); + + let parsedUrl = this.urlCodec.parse(initialUrl); + + if (typeof parsedUrl === 'string') { + throw 'Invalid URL'; + } + + this.$$protocol = parsedUrl.protocol; + this.$$host = parsedUrl.hostname; + this.$$port = parseInt(parsedUrl.port) || DEFAULT_PORTS[parsedUrl.protocol] || null; + + this.$$parseLinkUrl(initialUrl, initialUrl); + this.cacheState(); + this.$$state = this.browserState(); + + if (isPromise($injector)) { + $injector.then($i => this.initialize($i)); + } else { + this.initialize($injector); + } + } + + private initialize($injector: any) { + const $rootScope = $injector.get('$rootScope'); + const $rootElement = $injector.get('$rootElement'); + + $rootElement.on('click', (event: any) => { + if (event.ctrlKey || event.metaKey || event.shiftKey || event.which === 2 || + event.button === 2) { + return; + } + + let elm: (Node & ParentNode)|null = event.target; + + // traverse the DOM up to find first A tag + while (elm && elm.nodeName.toLowerCase() !== 'a') { + // ignore rewriting if no A tag (reached root element, or no parent - removed from document) + if (elm === $rootElement[0] || !(elm = elm.parentNode)) { + return; + } + } + + if (!isAnchor(elm)) { + return; + } + + const absHref = elm.href; + const relHref = elm.getAttribute('href'); + + // Ignore when url is started with javascript: or mailto: + if (IGNORE_URI_REGEXP.test(absHref)) { + return; + } + + if (absHref && !elm.getAttribute('target') && !event.isDefaultPrevented()) { + if (this.$$parseLinkUrl(absHref, relHref)) { + // We do a preventDefault for all urls that are part of the AngularJS application, + // in html5mode and also without, so that we are able to abort navigation without + // getting double entries in the location history. + event.preventDefault(); + // update location manually + if (this.absUrl() !== this.browserUrl()) { + $rootScope.$apply(); + } + } + } + }); + + this.location.onUrlChange((newUrl, newState) => { + let oldUrl = this.absUrl(); + let oldState = this.$$state; + this.$$parse(newUrl); + newUrl = this.absUrl(); + this.$$state = newState; + const defaultPrevented = + $rootScope.$broadcast('$locationChangeStart', newUrl, oldUrl, newState, oldState) + .defaultPrevented; + + // if the location was changed by a `$locationChangeStart` handler then stop + // processing this location change + if (this.absUrl() !== newUrl) return; + + // If default was prevented, set back to old state. This is the state that was locally + // cached in the $location service. + if (defaultPrevented) { + this.$$parse(oldUrl); + this.state(oldState); + this.setBrowserUrlWithFallback(oldUrl, false, oldState); + } else { + this.initalizing = false; + $rootScope.$broadcast('$locationChangeSuccess', newUrl, oldUrl, newState, oldState); + this.resetBrowserUpdate(); + } + if (!$rootScope.$$phase) { + $rootScope.$digest(); + } + }); + + // update browser + $rootScope.$watch(() => { + if (this.initalizing || this.updateBrowser) { + this.updateBrowser = false; + + const oldUrl = this.browserUrl(); + const newUrl = this.absUrl(); + const oldState = this.browserState(); + let currentReplace = this.$$replace; + + const urlOrStateChanged = + !this.urlCodec.areEqual(oldUrl, newUrl) || oldState !== this.$$state; + + // Fire location changes one time to on initialization. This must be done on the + // next tick (thus inside $evalAsync()) in order for listeners to be registered + // before the event fires. Mimicing behavior from $locationWatch: + // https://github.com/angular/angular.js/blob/master/src/ng/location.js#L983 + if (this.initalizing || urlOrStateChanged) { + this.initalizing = false; + + $rootScope.$evalAsync(() => { + // Get the new URL again since it could have changed due to async update + const newUrl = this.absUrl(); + const defaultPrevented = + $rootScope + .$broadcast('$locationChangeStart', newUrl, oldUrl, this.$$state, oldState) + .defaultPrevented; + + // if the location was changed by a `$locationChangeStart` handler then stop + // processing this location change + if (this.absUrl() !== newUrl) return; + + if (defaultPrevented) { + this.$$parse(oldUrl); + this.$$state = oldState; + } else { + // This block doesn't run when initalizing because it's going to perform the update to + // the URL which shouldn't be needed when initalizing. + if (urlOrStateChanged) { + this.setBrowserUrlWithFallback( + newUrl, currentReplace, oldState === this.$$state ? null : this.$$state); + this.$$replace = false; + } + $rootScope.$broadcast( + '$locationChangeSuccess', newUrl, oldUrl, this.$$state, oldState); + } + }); + } + } + this.$$replace = false; + }); + } + + private resetBrowserUpdate() { + this.$$replace = false; + this.$$state = this.browserState(); + this.updateBrowser = false; + this.lastBrowserUrl = this.browserUrl(); + } + + private lastHistoryState: unknown; + private lastBrowserUrl: string = ''; + private browserUrl(): string; + private browserUrl(url: string, replace?: boolean, state?: unknown): this; + private browserUrl(url?: string, replace?: boolean, state?: unknown) { + // In modern browsers `history.state` is `null` by default; treating it separately + // from `undefined` would cause `$browser.url('/foo')` to change `history.state` + // to undefined via `pushState`. Instead, let's change `undefined` to `null` here. + if (typeof state === 'undefined') { + state = null; + } + + // setter + if (url) { + let sameState = this.lastHistoryState === state; + + // Normalize the inputted URL + url = this.urlCodec.parse(url).href; + + // Don't change anything if previous and current URLs and states match. + if (this.lastBrowserUrl === url && sameState) { + return this; + } + this.lastBrowserUrl = url; + this.lastHistoryState = state; + + // Remove server base from URL as the Angular APIs for updating URL require + // it to be the path+. + url = this.stripBaseUrl(this.getServerBase(), url) || url; + + // Set the URL + if (replace) { + this.locationStrategy.replaceState(state, '', url, ''); + } else { + this.locationStrategy.pushState(state, '', url, ''); + } + + this.cacheState(); + + return this; + // getter + } else { + return this.platformLocation.href; + } + } + + // This variable should be used *only* inside the cacheState function. + private lastCachedState: unknown = null; + private cacheState() { + // This should be the only place in $browser where `history.state` is read. + this.cachedState = this.platformLocation.getState(); + if (typeof this.cachedState === 'undefined') { + this.cachedState = null; + } + + // Prevent callbacks fo fire twice if both hashchange & popstate were fired. + if (deepEqual(this.cachedState, this.lastCachedState)) { + this.cachedState = this.lastCachedState; + } + + this.lastCachedState = this.cachedState; + this.lastHistoryState = this.cachedState; + } + + /** + * This function emulates the $browser.state() function from AngularJS. It will cause + * history.state to be cached unless changed with deep equality check. + */ + private browserState(): unknown { return this.cachedState; } + + private stripBaseUrl(base: string, url: string) { + if (url.startsWith(base)) { + return url.substr(base.length); + } + return undefined; + } + + private getServerBase() { + const {protocol, hostname, port} = this.platformLocation; + const baseHref = this.locationStrategy.getBaseHref(); + let url = `${protocol}//${hostname}${port ? ':' + port : ''}${baseHref || '/'}`; + return url.endsWith('/') ? url : url + '/'; + } + + private parseAppUrl(url: string) { + if (DOUBLE_SLASH_REGEX.test(url)) { + throw new Error(`Bad Path - URL cannot start with double slashes: ${url}`); + } + + let prefixed = (url.charAt(0) !== '/'); + if (prefixed) { + url = '/' + url; + } + let match = this.urlCodec.parse(url, this.getServerBase()); + if (typeof match === 'string') { + throw new Error(`Bad URL - Cannot parse URL: ${url}`); + } + let path = + prefixed && match.pathname.charAt(0) === '/' ? match.pathname.substring(1) : match.pathname; + this.$$path = this.urlCodec.decodePath(path); + this.$$search = this.urlCodec.decodeSearch(match.search); + this.$$hash = this.urlCodec.decodeHash(match.hash); + + // make sure path starts with '/'; + if (this.$$path && this.$$path.charAt(0) !== '/') { + this.$$path = '/' + this.$$path; + } + } + + $$parse(url: string) { + let pathUrl: string|undefined; + if (url.startsWith('/')) { + pathUrl = url; + } else { + // Remove protocol & hostname if URL starts with it + pathUrl = this.stripBaseUrl(this.getServerBase(), url); + } + if (typeof pathUrl === 'undefined') { + throw new Error(`Invalid url "${url}", missing path prefix "${this.getServerBase()}".`); + } + + this.parseAppUrl(pathUrl); + + if (!this.$$path) { + this.$$path = '/'; + } + this.composeUrls(); + } + + $$parseLinkUrl(url: string, relHref?: string|null): boolean { + // When relHref is passed, it should be a hash and is handled separately + if (relHref && relHref[0] === '#') { + this.hash(relHref.slice(1)); + return true; + } + let rewrittenUrl; + let appUrl = this.stripBaseUrl(this.getServerBase(), url); + if (typeof appUrl !== 'undefined') { + rewrittenUrl = this.getServerBase() + appUrl; + } else if (this.getServerBase() === url + '/') { + rewrittenUrl = this.getServerBase(); + } + // Set the URL + if (rewrittenUrl) { + this.$$parse(rewrittenUrl); + } + return !!rewrittenUrl; + } + + private setBrowserUrlWithFallback(url: string, replace: boolean, state: unknown) { + const oldUrl = this.url(); + const oldState = this.$$state; + try { + this.browserUrl(url, replace, state); + + // Make sure $location.state() returns referentially identical (not just deeply equal) + // state object; this makes possible quick checking if the state changed in the digest + // loop. Checking deep equality would be too expensive. + this.$$state = this.browserState(); + } catch (e) { + // Restore old values if pushState fails + this.url(oldUrl); + this.$$state = oldState; + + throw e; + } + } + + private composeUrls() { + this.$$url = this.urlCodec.normalize(this.$$path, this.$$search, this.$$hash); + this.$$absUrl = this.getServerBase() + this.$$url.substr(1); // remove '/' from front of URL + this.updateBrowser = true; + } + + /** + * This method is getter only. + * + * Return full URL representation with all segments encoded according to rules specified in + * [RFC 3986](http://www.ietf.org/rfc/rfc3986.txt). + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo + * let absUrl = $location.absUrl(); + * // => "http://example.com/#/some/path?foo=bar&baz=xoxo" + * ``` + */ + absUrl(): string { return this.$$absUrl; } + + /** + * This method is getter / setter. + * + * Return URL (e.g. `/path?a=b#hash`) when called without any parameter. + * + * Change path, search and hash, when called with parameter and return `$location`. + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo + * let url = $location.url(); + * // => "/some/path?foo=bar&baz=xoxo" + * ``` + */ + url(): string; + url(url: string): this; + url(url?: string): string|this { + if (typeof url === 'string') { + if (!url.length) { + url = '/'; + } + + const match = PATH_MATCH.exec(url); + if (!match) return this; + if (match[1] || url === '') this.path(this.urlCodec.decodePath(match[1])); + if (match[2] || match[1] || url === '') this.search(match[3] || ''); + this.hash(match[5] || ''); + + // Chainable method + return this; + } + + return this.$$url; + } + + /** + * This method is getter only. + * + * Return protocol of current URL. + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo + * let protocol = $location.protocol(); + * // => "http" + * ``` + */ + protocol(): string { return this.$$protocol; } + + /** + * This method is getter only. + * + * Return host of current URL. + * + * Note: compared to the non-AngularJS version `location.host` which returns `hostname:port`, this + * returns the `hostname` portion only. + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo + * let host = $location.host(); + * // => "example.com" + * + * // given URL http://user:password@example.com:8080/#/some/path?foo=bar&baz=xoxo + * host = $location.host(); + * // => "example.com" + * host = location.host; + * // => "example.com:8080" + * ``` + */ + host(): string { return this.$$host; } + + /** + * This method is getter only. + * + * Return port of current URL. + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo + * let port = $location.port(); + * // => 80 + * ``` + */ + port(): number|null { return this.$$port; } + + /** + * This method is getter / setter. + * + * Return path of current URL when called without any parameter. + * + * Change path when called with parameter and return `$location`. + * + * Note: Path should always begin with forward slash (/), this method will add the forward slash + * if it is missing. + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo + * let path = $location.path(); + * // => "/some/path" + * ``` + */ + path(): string; + path(path: string|number|null): this; + path(path?: string|number|null): string|this { + if (typeof path === 'undefined') { + return this.$$path; + } + + // null path converts to empty string. Prepend with "/" if needed. + path = path !== null ? path.toString() : ''; + path = path.charAt(0) === '/' ? path : '/' + path; + + this.$$path = path; + + this.composeUrls(); + return this; + } + + /** + * This method is getter / setter. + * + * Return search part (as object) of current URL when called without any parameter. + * + * Change search part when called with parameter and return `$location`. + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo + * let searchObject = $location.search(); + * // => {foo: 'bar', baz: 'xoxo'} + * + * // set foo to 'yipee' + * $location.search('foo', 'yipee'); + * // $location.search() => {foo: 'yipee', baz: 'xoxo'} + * ``` + * + * @param {string|Object.|Object.>} search New search params - string or + * hash object. + * + * When called with a single argument the method acts as a setter, setting the `search` component + * of `$location` to the specified value. + * + * If the argument is a hash object containing an array of values, these values will be encoded + * as duplicate search parameters in the URL. + * + * @param {(string|Number|Array|boolean)=} paramValue If `search` is a string or number, then `paramValue` + * will override only a single search property. + * + * If `paramValue` is an array, it will override the property of the `search` component of + * `$location` specified via the first argument. + * + * If `paramValue` is `null`, the property specified via the first argument will be deleted. + * + * If `paramValue` is `true`, the property specified via the first argument will be added with no + * value nor trailing equal sign. + * + * @return {Object} If called with no arguments returns the parsed `search` object. If called with + * one or more arguments returns `$location` object itself. + */ + search(): {[key: string]: unknown}; + search(search: string|number|{[key: string]: unknown}): this; + search( + search: string|number|{[key: string]: unknown}, + paramValue: null|undefined|string|number|boolean|string[]): this; + search( + search?: string|number|{[key: string]: unknown}, + paramValue?: null|undefined|string|number|boolean|string[]): {[key: string]: unknown}|this { + switch (arguments.length) { + case 0: + return this.$$search; + case 1: + if (typeof search === 'string' || typeof search === 'number') { + this.$$search = this.urlCodec.decodeSearch(search.toString()); + } else if (typeof search === 'object' && search !== null) { + // Copy the object so it's never mutated + search = {...search}; + // remove object undefined or null properties + for (const key in search) { + if (search[key] == null) delete search[key]; + } + + this.$$search = search; + } else { + throw new Error( + 'LocationProvider.search(): First argument must be a string or an object.'); + } + break; + default: + if (typeof search === 'string') { + const currentSearch = this.search(); + if (typeof paramValue === 'undefined' || paramValue === null) { + delete currentSearch[search]; + return this.search(currentSearch); + } else { + currentSearch[search] = paramValue; + return this.search(currentSearch); + } + } + } + this.composeUrls(); + return this; + } + + /** + * This method is getter / setter. + * + * Returns the hash fragment when called without any parameters. + * + * Changes the hash fragment when called with a parameter and returns `$location`. + * + * + * ```js + * // given URL http://example.com/#/some/path?foo=bar&baz=xoxo#hashValue + * let hash = $location.hash(); + * // => "hashValue" + * ``` + */ + hash(): string; + hash(hash: string|number|null): this; + hash(hash?: string|number|null): string|this { + if (typeof hash === 'undefined') { + return this.$$hash; + } + + this.$$hash = hash !== null ? hash.toString() : ''; + + this.composeUrls(); + return this; + } + + /** + * If called, all changes to $location during the current `$digest` will replace the current + * history record, instead of adding a new one. + */ + replace(): this { + this.$$replace = true; + return this; + } + + /** + * This method is getter / setter. + * + * Return the history state object when called without any parameter. + * + * Change the history state object when called with one parameter and return `$location`. + * The state object is later passed to `pushState` or `replaceState`. + * + * NOTE: This method is supported only in HTML5 mode and only in browsers supporting + * the HTML5 History API (i.e. methods `pushState` and `replaceState`). If you need to support + * older browsers (like IE9 or Android < 4.0), don't use this method. + * + */ + state(): unknown; + state(state: unknown): this; + state(state?: unknown): unknown|this { + if (typeof state === 'undefined') { + return this.$$state; + } + + this.$$state = state; + return this; + } +} + +/** + * Docs TBD. + * + * @publicApi + */ +export class LocationUpgradeProvider { + constructor( + private ngUpgrade: UpgradeModule, private location: Location, + private platformLocation: PlatformLocation, private urlCodec: UrlCodec, + private locationStrategy: LocationStrategy) {} + + $get() { + return new LocationUpgradeService( + this.ngUpgrade.$injector, this.location, this.platformLocation, this.urlCodec, + this.locationStrategy); + } + + /** + * Stub method used to keep API compatible with AngularJS. This setting is configured through + * the LocationUpgradeModule's `config` method in your Angular app. + */ + hashPrefix(prefix?: string) { + throw new Error('Configure LocationUpgrade through LocationUpgradeModule.config method.'); + } + + /** + * Stub method used to keep API compatible with AngularJS. This setting is configured through + * the LocationUpgradeModule's `config` method in your Angular app. + */ + html5Mode(mode?: any) { + throw new Error('Configure LocationUpgrade through LocationUpgradeModule.config method.'); + } +} diff --git a/packages/common/upgrade/src/angular_js_module.ts b/packages/common/upgrade/src/angular_js_module.ts new file mode 100644 index 0000000000..e5ef85bd14 --- /dev/null +++ b/packages/common/upgrade/src/angular_js_module.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {downgradeInjectable} from '@angular/upgrade/static'; +import {LocationUpgradeProvider} from './$location'; + +/** + * Name of AngularJS module under which $location upgrade services are exported. + * + * @publicApi + */ +export const LOCATION_UPGRADE_MODULE = 'LOCATION_UPGRADE_MODULE'; + +/** + * Downgraded $location provider. API should match AngularJS $location and should be a drop-in + * replacement. + * + * @publicApi + */ +export const $locationProvider = downgradeInjectable(LocationUpgradeProvider); diff --git a/packages/common/upgrade/src/index.ts b/packages/common/upgrade/src/index.ts index 2c7f54209f..b82f517449 100644 --- a/packages/common/upgrade/src/index.ts +++ b/packages/common/upgrade/src/index.ts @@ -6,5 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -export * from './location'; -export * from './location_module'; \ No newline at end of file +export * from './location_upgrade_module'; +export * from './angular_js_module'; +export * from './$location'; +export * from './params'; \ No newline at end of file diff --git a/packages/common/upgrade/src/location.ts b/packages/common/upgrade/src/location.ts deleted file mode 100644 index a4b5984cce..0000000000 --- a/packages/common/upgrade/src/location.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import {Injectable} from '@angular/core'; - -/** - * A Location service that provides properties and methods to match AngularJS's `$location` - * service. It is recommended that this LocationUpgradeService be used in place of - * `$location` in any hybrid Angular/AngularJS applications. - */ -@Injectable() -export class LocationUpgradeService { -} diff --git a/packages/common/upgrade/src/location_module.ts b/packages/common/upgrade/src/location_module.ts deleted file mode 100644 index 8f131b94ee..0000000000 --- a/packages/common/upgrade/src/location_module.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * @license - * Copyright Google Inc. All Rights Reserved. - * - * Use of this source code is governed by an MIT-style license that can be - * found in the LICENSE file at https://angular.io/license - */ - -import {NgModule} from '@angular/core'; -import {LocationUpgradeService} from './location'; - -/** - * Module used for configuring Angular's LocationUpgradeService. - */ -@NgModule({providers: [LocationUpgradeService]}) -export class LocationUpgradeModule { -} diff --git a/packages/common/upgrade/src/location_upgrade_module.ts b/packages/common/upgrade/src/location_upgrade_module.ts new file mode 100644 index 0000000000..4a6b877743 --- /dev/null +++ b/packages/common/upgrade/src/location_upgrade_module.ts @@ -0,0 +1,108 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {APP_BASE_HREF, CommonModule, HashLocationStrategy, Location, LocationStrategy, PathLocationStrategy, PlatformLocation} from '@angular/common'; +import {Inject, InjectionToken, ModuleWithProviders, NgModule, Optional} from '@angular/core'; +import {UpgradeModule} from '@angular/upgrade/static'; + +import {LocationUpgradeProvider, LocationUpgradeService} from './$location'; +import {AngularJSUrlCodec, UrlCodec} from './params'; + +/** + * Configuration options for LocationUpgrade. + * + * @publicApi + */ +export interface LocationUpgradeConfig { + useHash?: boolean; + hashPrefix?: string; + urlCodec?: typeof UrlCodec; + serverBaseHref?: string; + appBaseHref?: string; +} + +/** + * Is used in DI to configure the location upgrade package. + * + * @publicApi + */ +export const LOCATION_UPGRADE_CONFIGURATION = + new InjectionToken('LOCATION_UPGRADE_CONFIGURATION'); + +const APP_BASE_HREF_RESOLVED = new InjectionToken('APP_BASE_HREF_RESOLVED'); + +/** + * Module used for configuring Angular's LocationUpgradeService. + * + * @publicApi + */ +@NgModule({imports: [CommonModule]}) +export class LocationUpgradeModule { + static config(config?: LocationUpgradeConfig): ModuleWithProviders { + return { + ngModule: LocationUpgradeModule, + providers: [ + Location, + { + provide: LocationUpgradeService, + useFactory: provide$location, + deps: [UpgradeModule, Location, PlatformLocation, UrlCodec, LocationStrategy] + }, + {provide: LOCATION_UPGRADE_CONFIGURATION, useValue: config ? config : {}}, + {provide: UrlCodec, useFactory: provideUrlCodec, deps: [LOCATION_UPGRADE_CONFIGURATION]}, + { + provide: APP_BASE_HREF_RESOLVED, + useFactory: provideAppBaseHref, + deps: [LOCATION_UPGRADE_CONFIGURATION, [new Inject(APP_BASE_HREF), new Optional()]] + }, + { + provide: LocationStrategy, + useFactory: provideLocationStrategy, + deps: [ + PlatformLocation, + APP_BASE_HREF_RESOLVED, + LOCATION_UPGRADE_CONFIGURATION, + ] + }, + ], + }; + } +} + +/** @internal */ +export function provideAppBaseHref(config: LocationUpgradeConfig, appBaseHref?: string) { + if (config && config.appBaseHref != null) { + return config.appBaseHref; + } else if (appBaseHref != null) { + return appBaseHref; + } + return ''; +} + +/** @internal */ +export function provideUrlCodec(config: LocationUpgradeConfig) { + const codec = config && config.urlCodec || AngularJSUrlCodec; + return new (codec as any)(); +} + +/** @internal */ +export function provideLocationStrategy( + platformLocation: PlatformLocation, baseHref: string, options: LocationUpgradeConfig = {}) { + return options.useHash ? new HashLocationStrategy(platformLocation, baseHref) : + new PathLocationStrategy(platformLocation, baseHref); +} + +/** @internal */ +export function provide$location( + ngUpgrade: UpgradeModule, location: Location, platformLocation: PlatformLocation, + urlCodec: UrlCodec, locationStrategy: LocationStrategy) { + const $locationProvider = new LocationUpgradeProvider( + ngUpgrade, location, platformLocation, urlCodec, locationStrategy); + + return $locationProvider.$get(); +} \ No newline at end of file diff --git a/packages/common/upgrade/src/params.ts b/packages/common/upgrade/src/params.ts index 2fa1049505..a357c17a51 100644 --- a/packages/common/upgrade/src/params.ts +++ b/packages/common/upgrade/src/params.ts @@ -46,6 +46,7 @@ export abstract class UrlCodec { * @publicApi */ export class AngularJSUrlCodec implements UrlCodec { + // https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L15 encodePath(path: string): string { const segments = path.split('/'); let i = segments.length; @@ -59,6 +60,7 @@ export class AngularJSUrlCodec implements UrlCodec { return _stripIndexHtml((path && path[0] !== '/' && '/' || '') + path); } + // https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L42 encodeSearch(search: string|{[k: string]: unknown}): string { if (typeof search === 'string') { search = parseKeyValue(search); @@ -68,11 +70,13 @@ export class AngularJSUrlCodec implements UrlCodec { return search ? '?' + search : ''; } + // https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L44 encodeHash(hash: string) { hash = encodeUriSegment(hash); return hash ? '#' + hash : ''; } + // https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L27 decodePath(path: string, html5Mode = true): string { const segments = path.split('/'); let i = segments.length; @@ -88,13 +92,17 @@ export class AngularJSUrlCodec implements UrlCodec { return segments.join('/'); } + // https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L72 decodeSearch(search: string) { return parseKeyValue(search); } + // https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L73 decodeHash(hash: string) { hash = decodeURIComponent(hash); return hash[0] === '#' ? hash.substring(1) : hash; } + // https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L149 + // https://github.com/angular/angular.js/blob/864c7f0/src/ng/location.js#L42 normalize(href: string): string; normalize(path: string, search: {[k: string]: unknown}, hash: string, baseUrl?: string): string; normalize(pathOrHref: string, search?: {[k: string]: unknown}, hash?: string, baseUrl?: string): @@ -128,6 +136,7 @@ export class AngularJSUrlCodec implements UrlCodec { areEqual(a: string, b: string) { return this.normalize(a) === this.normalize(b); } + // https://github.com/angular/angular.js/blob/864c7f0/src/ng/urlUtils.js#L60 parse(url: string, base?: string) { try { const parsed = new URL(url, base); @@ -170,7 +179,8 @@ function tryDecodeURIComponent(value: string) { /** - * Parses an escaped url query string into key-value pairs. + * Parses an escaped url query string into key-value pairs. Logic taken from + * https://github.com/angular/angular.js/blob/864c7f0/src/Angular.js#L1382 * @returns {Object.} */ function parseKeyValue(keyValue: string): {[k: string]: unknown} { @@ -200,6 +210,10 @@ function parseKeyValue(keyValue: string): {[k: string]: unknown} { return obj; } +/** + * Serializes into key-value pairs. Logic taken from + * https://github.com/angular/angular.js/blob/864c7f0/src/Angular.js#L1409 + */ function toKeyValue(obj: {[k: string]: unknown}) { const parts: unknown[] = []; for (const key in obj) { @@ -230,6 +244,8 @@ function toKeyValue(obj: {[k: string]: unknown}) { * unreserved = ALPHA / DIGIT / "-" / "." / "_" / "~" * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" * / "*" / "+" / "," / ";" / "=" + * + * Logic from https://github.com/angular/angular.js/blob/864c7f0/src/Angular.js#L1437 */ function encodeUriSegment(val: string) { return encodeUriQuery(val, true) @@ -249,6 +265,8 @@ function encodeUriSegment(val: string) { * pct-encoded = "%" HEXDIG HEXDIG * sub-delims = "!" / "$" / "&" / "'" / "(" / ")" * / "*" / "+" / "," / ";" / "=" + * + * Logic from https://github.com/angular/angular.js/blob/864c7f0/src/Angular.js#L1456 */ function encodeUriQuery(val: string, pctEncodeSpaces: boolean = false) { return encodeURIComponent(val) diff --git a/packages/common/upgrade/src/utils.ts b/packages/common/upgrade/src/utils.ts new file mode 100644 index 0000000000..97fcc594c1 --- /dev/null +++ b/packages/common/upgrade/src/utils.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +export function stripPrefix(val: string, prefix: string): string { + return val.startsWith(prefix) ? val.substring(prefix.length) : val; +} + +export function deepEqual(a: any, b: any): boolean { + if (a === b) { + return true; + } else if (!a || !b) { + return false; + } else { + try { + if ((a.prototype !== b.prototype) || (Array.isArray(a) && Array.isArray(b))) { + return false; + } + return JSON.stringify(a) === JSON.stringify(b); + } catch (e) { + return false; + } + } +} + +export function isAnchor(el: (Node & ParentNode) | Element | null): el is HTMLAnchorElement { + return (el).href !== undefined; +} + +export function isPromise(obj: any): obj is Promise { + // allow any Promise/A+ compliant thenable. + // It's up to the caller to ensure that obj.then conforms to the spec + return !!obj && typeof obj.then === 'function'; +} diff --git a/packages/common/upgrade/test/BUILD.bazel b/packages/common/upgrade/test/BUILD.bazel index 1cd1cb834a..af86ae9745 100644 --- a/packages/common/upgrade/test/BUILD.bazel +++ b/packages/common/upgrade/test/BUILD.bazel @@ -6,8 +6,9 @@ ts_library( srcs = glob(["**/*.ts"]), deps = [ "//packages/common", - "//packages/common/upgrade", "//packages/common/testing", + "//packages/common/upgrade", + "//packages/core", "//packages/core/testing", "//packages/upgrade/static", ], diff --git a/packages/common/upgrade/test/upgrade.spec.ts b/packages/common/upgrade/test/upgrade.spec.ts index a360da774e..0df73c9519 100644 --- a/packages/common/upgrade/test/upgrade.spec.ts +++ b/packages/common/upgrade/test/upgrade.spec.ts @@ -6,28 +6,630 @@ * found in the LICENSE file at https://angular.io/license */ -import {LocationUpgradeModule, LocationUpgradeService} from '@angular/common/upgrade'; +import {CommonModule, PathLocationStrategy} from '@angular/common'; import {TestBed, inject} from '@angular/core/testing'; import {UpgradeModule} from '@angular/upgrade/static'; -describe('LocationUpgradeService', () => { +import {LocationUpgradeService} from '../src/$location'; + +import {LocationUpgradeTestModule} from './upgrade_location_test_module'; + +export class MockUpgradeModule { + $injector = { + get(key: string) { + if (key === '$rootScope') { + return new $rootScopeMock(); + } else { + throw new Error(`Unsupported mock service requested: ${key}`); + } + } + }; +} + +export function injectorFactory() { + const rootScopeMock = new $rootScopeMock(); + const rootElementMock = {on: () => undefined}; + return function $injectorGet(provider: string) { + if (provider === '$rootScope') { + return rootScopeMock; + } else if (provider === '$rootElement') { + return rootElementMock; + } else { + throw new Error(`Unsupported injectable mock: ${provider}`); + } + }; +} + +export class $rootScopeMock { + private watchers: any[] = []; + private events: {[k: string]: any[]} = {}; + runWatchers() { this.watchers.forEach(fn => fn()); } + + $watch(fn: any) { this.watchers.push(fn); } + + $broadcast(evt: string, ...args: any[]) { + if (this.events[evt]) { + this.events[evt].forEach(fn => { fn.apply(fn, args); }); + } + return {defaultPrevented: false, preventDefault() { this.defaultPrevented = true; }}; + } + + $on(evt: string, fn: any) { + if (!this.events[evt]) { + this.events[evt] = []; + } + this.events[evt].push(fn); + } + + $evalAsync(fn: any) { fn(); } +} + +describe('LocationProvider', () => { let upgradeModule: UpgradeModule; beforeEach(() => { TestBed.configureTestingModule({ - imports: [LocationUpgradeModule], + imports: [ + LocationUpgradeTestModule.config(), + ], providers: [UpgradeModule], }); upgradeModule = TestBed.get(UpgradeModule); - upgradeModule.$injector = { - get: jasmine.createSpy('$injector.get').and.returnValue({'$on': () => undefined}) - }; + upgradeModule.$injector = {get: injectorFactory()}; }); - it('should instantiate LocationUpgradeService', - inject([LocationUpgradeService], (location: LocationUpgradeService) => { - expect(location).toBeDefined(); - expect(location instanceof LocationUpgradeService).toBe(true); + it('should instantiate LocationProvider', + inject([LocationUpgradeService], ($location: LocationUpgradeService) => { + expect($location).toBeDefined(); + expect($location instanceof LocationUpgradeService).toBe(true); })); -}); \ No newline at end of file + +}); + + +describe('LocationHtml5Url', function() { + let $location: LocationUpgradeService; + let upgradeModule: UpgradeModule; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + LocationUpgradeTestModule.config( + {useHash: false, appBaseHref: '/pre', startUrl: 'http://server'}), + ], + providers: [UpgradeModule], + + }); + upgradeModule = TestBed.get(UpgradeModule); + upgradeModule.$injector = {get: injectorFactory()}; + }); + + beforeEach( + inject([LocationUpgradeService], (loc: LocationUpgradeService) => { $location = loc; })); + + + it('should set the URL', () => { + $location.url(''); + expect($location.absUrl()).toBe('http://server/pre/'); + $location.url('/test'); + expect($location.absUrl()).toBe('http://server/pre/test'); + $location.url('test'); + expect($location.absUrl()).toBe('http://server/pre/test'); + $location.url('/somewhere?something=1#hash_here'); + expect($location.absUrl()).toBe('http://server/pre/somewhere?something=1#hash_here'); + }); + + it('should rewrite regular URL', () => { + expect(parseLinkAndReturn($location, 'http://other')).toEqual(undefined); + expect(parseLinkAndReturn($location, 'http://server/pre')).toEqual('http://server/pre/'); + expect(parseLinkAndReturn($location, 'http://server/pre/')).toEqual('http://server/pre/'); + expect(parseLinkAndReturn($location, 'http://server/pre/otherPath')) + .toEqual('http://server/pre/otherPath'); + // Note: relies on the previous state! + expect(parseLinkAndReturn($location, 'someIgnoredAbsoluteHref', '#test')) + .toEqual('http://server/pre/otherPath#test'); + }); + + it('should rewrite index URL', () => { + // Reset hostname url and hostname + $location.$$parseLinkUrl('http://server/pre/index.html'); + expect($location.absUrl()).toEqual('http://server/pre/'); + + expect(parseLinkAndReturn($location, 'http://server/pre')).toEqual('http://server/pre/'); + expect(parseLinkAndReturn($location, 'http://server/pre/')).toEqual('http://server/pre/'); + expect(parseLinkAndReturn($location, 'http://server/pre/otherPath')) + .toEqual('http://server/pre/otherPath'); + // Note: relies on the previous state! + expect(parseLinkAndReturn($location, 'someIgnoredAbsoluteHref', '#test')) + .toEqual('http://server/pre/otherPath#test'); + }); + + it('should complain if the path starts with double slashes', function() { + expect(function() { + parseLinkAndReturn($location, 'http://server/pre///other/path'); + }).toThrow(); + + expect(function() { + parseLinkAndReturn($location, 'http://server/pre/\\\\other/path'); + }).toThrow(); + + expect(function() { + parseLinkAndReturn($location, 'http://server/pre//\\//other/path'); + }).toThrow(); + }); + + it('should support state', + function() { expect($location.state({a: 2}).state()).toEqual({a: 2}); }); +}); + + +describe('NewUrl', function() { + let $location: LocationUpgradeService; + let upgradeModule: UpgradeModule; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + LocationUpgradeTestModule.config({useHash: false, startUrl: 'http://www.domain.com:9877'}), + ], + providers: [UpgradeModule], + }); + + upgradeModule = TestBed.get(UpgradeModule); + upgradeModule.$injector = {get: injectorFactory()}; + }); + + beforeEach( + inject([LocationUpgradeService], (loc: LocationUpgradeService) => { $location = loc; })); + + // Sets the default most of these tests rely on + function setupUrl(url = '/path/b?search=a&b=c&d#hash') { $location.url(url); } + + it('should provide common getters', function() { + setupUrl(); + expect($location.absUrl()).toBe('http://www.domain.com:9877/path/b?search=a&b=c&d#hash'); + expect($location.protocol()).toBe('http'); + expect($location.host()).toBe('www.domain.com'); + expect($location.port()).toBe(9877); + expect($location.path()).toBe('/path/b'); + expect($location.search()).toEqual({search: 'a', b: 'c', d: true}); + expect($location.hash()).toBe('hash'); + expect($location.url()).toBe('/path/b?search=a&b=c&d#hash'); + }); + + + it('path() should change path', function() { + setupUrl(); + $location.path('/new/path'); + expect($location.path()).toBe('/new/path'); + expect($location.absUrl()).toBe('http://www.domain.com:9877/new/path?search=a&b=c&d#hash'); + }); + + it('path() should not break on numeric values', function() { + setupUrl(); + $location.path(1); + expect($location.path()).toBe('/1'); + expect($location.absUrl()).toBe('http://www.domain.com:9877/1?search=a&b=c&d#hash'); + }); + + it('path() should allow using 0 as path', function() { + setupUrl(); + $location.path(0); + expect($location.path()).toBe('/0'); + expect($location.absUrl()).toBe('http://www.domain.com:9877/0?search=a&b=c&d#hash'); + }); + + it('path() should set to empty path on null value', function() { + setupUrl(); + $location.path('/foo'); + expect($location.path()).toBe('/foo'); + $location.path(null); + expect($location.path()).toBe('/'); + }); + + it('search() should accept string', function() { + setupUrl(); + $location.search('x=y&c'); + expect($location.search()).toEqual({x: 'y', c: true}); + expect($location.absUrl()).toBe('http://www.domain.com:9877/path/b?x=y&c#hash'); + }); + + + it('search() should accept object', function() { + setupUrl(); + $location.search({one: 1, two: true}); + expect($location.search()).toEqual({one: 1, two: true}); + expect($location.absUrl()).toBe('http://www.domain.com:9877/path/b?one=1&two#hash'); + }); + + + it('search() should copy object', function() { + setupUrl(); + let obj = {one: 1, two: true, three: null}; + $location.search(obj); + expect(obj).toEqual({one: 1, two: true, three: null}); + obj.one = 100; // changed value + expect($location.search()).toEqual({one: 1, two: true}); + expect($location.absUrl()).toBe('http://www.domain.com:9877/path/b?one=1&two#hash'); + }); + + + it('search() should change single parameter', function() { + setupUrl(); + $location.search({id: 'old', preserved: true}); + $location.search('id', 'new'); + + expect($location.search()).toEqual({id: 'new', preserved: true}); + }); + + + it('search() should remove single parameter', function() { + setupUrl(); + $location.search({id: 'old', preserved: true}); + $location.search('id', null); + + expect($location.search()).toEqual({preserved: true}); + }); + + + it('search() should remove multiple parameters', function() { + setupUrl(); + $location.search({one: 1, two: true}); + expect($location.search()).toEqual({one: 1, two: true}); + $location.search({one: null, two: null}); + expect($location.search()).toEqual({}); + expect($location.absUrl()).toBe('http://www.domain.com:9877/path/b#hash'); + }); + + + it('search() should accept numeric keys', function() { + setupUrl(); + $location.search({1: 'one', 2: 'two'}); + expect($location.search()).toEqual({'1': 'one', '2': 'two'}); + expect($location.absUrl()).toBe('http://www.domain.com:9877/path/b?1=one&2=two#hash'); + }); + + + it('search() should handle multiple value', function() { + setupUrl(); + $location.search('a&b'); + expect($location.search()).toEqual({a: true, b: true}); + + $location.search('a', null); + + expect($location.search()).toEqual({b: true}); + + $location.search('b', undefined); + expect($location.search()).toEqual({}); + }); + + + it('search() should handle single value', function() { + setupUrl(); + $location.search('ignore'); + expect($location.search()).toEqual({ignore: true}); + $location.search(1); + expect($location.search()).toEqual({1: true}); + }); + + it('search() should throw error an incorrect argument', function() { + expect(() => { + $location.search((null as any)); + }).toThrowError('LocationProvider.search(): First argument must be a string or an object.'); + expect(function() { + $location.search((undefined as any)); + }).toThrowError('LocationProvider.search(): First argument must be a string or an object.'); + }); + + it('hash() should change hash fragment', function() { + setupUrl(); + $location.hash('new-hash'); + expect($location.hash()).toBe('new-hash'); + expect($location.absUrl()).toBe('http://www.domain.com:9877/path/b?search=a&b=c&d#new-hash'); + }); + + + it('hash() should accept numeric parameter', function() { + setupUrl(); + $location.hash(5); + expect($location.hash()).toBe('5'); + expect($location.absUrl()).toBe('http://www.domain.com:9877/path/b?search=a&b=c&d#5'); + }); + + it('hash() should allow using 0', function() { + setupUrl(); + $location.hash(0); + expect($location.hash()).toBe('0'); + expect($location.absUrl()).toBe('http://www.domain.com:9877/path/b?search=a&b=c&d#0'); + }); + + it('hash() should accept null parameter', function() { + setupUrl(); + $location.hash(null); + expect($location.hash()).toBe(''); + expect($location.absUrl()).toBe('http://www.domain.com:9877/path/b?search=a&b=c&d'); + }); + + + it('url() should change the path, search and hash', function() { + setupUrl(); + $location.url('/some/path?a=b&c=d#hhh'); + expect($location.url()).toBe('/some/path?a=b&c=d#hhh'); + expect($location.absUrl()).toBe('http://www.domain.com:9877/some/path?a=b&c=d#hhh'); + expect($location.path()).toBe('/some/path'); + expect($location.search()).toEqual({a: 'b', c: 'd'}); + expect($location.hash()).toBe('hhh'); + }); + + + it('url() should change only hash when no search and path specified', function() { + setupUrl(); + $location.url('#some-hash'); + + expect($location.hash()).toBe('some-hash'); + expect($location.url()).toBe('/path/b?search=a&b=c&d#some-hash'); + expect($location.absUrl()).toBe('http://www.domain.com:9877/path/b?search=a&b=c&d#some-hash'); + }); + + + it('url() should change only search and hash when no path specified', function() { + setupUrl(); + $location.url('?a=b'); + + expect($location.search()).toEqual({a: 'b'}); + expect($location.hash()).toBe(''); + expect($location.path()).toBe('/path/b'); + }); + + + it('url() should reset search and hash when only path specified', function() { + setupUrl(); + $location.url('/new/path'); + + expect($location.path()).toBe('/new/path'); + expect($location.search()).toEqual({}); + expect($location.hash()).toBe(''); + }); + + it('url() should change path when empty string specified', function() { + setupUrl(); + $location.url(''); + + expect($location.path()).toBe('/'); + expect($location.search()).toEqual({}); + expect($location.hash()).toBe(''); + }); + + it('replace should set $$replace flag and return itself', function() { + expect(($location as any).$$replace).toBe(false); + + $location.replace(); + expect(($location as any).$$replace).toBe(true); + expect($location.replace()).toBe($location); + }); + + describe('encoding', function() { + + it('should encode special characters', function() { + $location.path('/a <>#'); + $location.search({'i j': '<>#'}); + $location.hash('<>#'); + + expect($location.path()).toBe('/a <>#'); + expect($location.search()).toEqual({'i j': '<>#'}); + expect($location.hash()).toBe('<>#'); + expect($location.absUrl()) + .toBe('http://www.domain.com:9877/a%20%3C%3E%23?i%20j=%3C%3E%23#%3C%3E%23'); + }); + + it('should not encode !$:@', function() { + $location.path('/!$:@'); + $location.search(''); + $location.hash('!$:@'); + + expect($location.absUrl()).toBe('http://www.domain.com:9877/!$:@#!$:@'); + }); + + it('should decode special characters', function() { + $location.$$parse('http://www.domain.com:9877/a%20%3C%3E%23?i%20j=%3C%3E%23#x%20%3C%3E%23'); + expect($location.path()).toBe('/a <>#'); + expect($location.search()).toEqual({'i j': '<>#'}); + expect($location.hash()).toBe('x <>#'); + }); + + it('should not decode encoded forward slashes in the path', function() { + $location.$$parse('http://www.domain.com:9877/a/ng2;path=%2Fsome%2Fpath'); + expect($location.path()).toBe('/a/ng2;path=%2Fsome%2Fpath'); + expect($location.search()).toEqual({}); + expect($location.hash()).toBe(''); + expect($location.url()).toBe('/a/ng2;path=%2Fsome%2Fpath'); + expect($location.absUrl()).toBe('http://www.domain.com:9877/a/ng2;path=%2Fsome%2Fpath'); + }); + + it('should decode pluses as spaces in urls', function() { + $location.$$parse('http://www.domain.com:9877/?a+b=c+d'); + expect($location.search()).toEqual({'a b': 'c d'}); + }); + + it('should retain pluses when setting search queries', function() { + $location.search({'a+b': 'c+d'}); + expect($location.search()).toEqual({'a+b': 'c+d'}); + }); + + }); + + it('should not preserve old properties when parsing new url', function() { + $location.$$parse('http://www.domain.com:9877/a'); + + expect($location.path()).toBe('/a'); + expect($location.search()).toEqual({}); + expect($location.hash()).toBe(''); + expect($location.absUrl()).toBe('http://www.domain.com:9877/a'); + }); +}); + +describe('New URL Parsing', () => { + let $location: LocationUpgradeService; + let upgradeModule: UpgradeModule; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + LocationUpgradeTestModule.config( + {useHash: false, appBaseHref: '/base', startUrl: 'http://server'}), + ], + providers: [UpgradeModule], + }); + + upgradeModule = TestBed.get(UpgradeModule); + upgradeModule.$injector = {get: injectorFactory()}; + }); + + beforeEach( + inject([LocationUpgradeService], (loc: LocationUpgradeService) => { $location = loc; })); + + it('should prepend path with basePath', function() { + $location.$$parse('http://server/base/abc?a'); + expect($location.path()).toBe('/abc'); + expect($location.search()).toEqual({a: true}); + + $location.path('/new/path'); + expect($location.absUrl()).toBe('http://server/base/new/path?a'); + }); + +}); + +describe('New URL Parsing', () => { + let $location: LocationUpgradeService; + let upgradeModule: UpgradeModule; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + CommonModule, + LocationUpgradeTestModule.config({useHash: false, startUrl: 'http://host.com/'}), + ], + providers: [UpgradeModule], + }); + + upgradeModule = TestBed.get(UpgradeModule); + upgradeModule.$injector = {get: injectorFactory()}; + }); + + beforeEach( + inject([LocationUpgradeService], (loc: LocationUpgradeService) => { $location = loc; })); + + it('should parse new url', function() { + $location.$$parse('http://host.com/base'); + expect($location.path()).toBe('/base'); + }); + + it('should parse new url with #', function() { + $location.$$parse('http://host.com/base#'); + expect($location.path()).toBe('/base'); + }); + + it('should prefix path with forward-slash', function() { + $location.path('b'); + + expect($location.path()).toBe('/b'); + expect($location.absUrl()).toBe('http://host.com/b'); + }); + + it('should set path to forward-slash when empty', function() { + $location.$$parse('http://host.com/'); + expect($location.path()).toBe('/'); + expect($location.absUrl()).toBe('http://host.com/'); + }); + + it('setters should return Url object to allow chaining', function() { + expect($location.path('/any')).toBe($location); + expect($location.search('')).toBe($location); + expect($location.hash('aaa')).toBe($location); + expect($location.url('/some')).toBe($location); + }); + + it('should throw error when invalid server url given', function() { + + expect(function() { $location.$$parse('http://other.server.org/path#/path'); }) + .toThrowError( + 'Invalid url "http://other.server.org/path#/path", missing path prefix "http://host.com/".'); + }); + + + describe('state', function() { + let mock$rootScope: $rootScopeMock; + + beforeEach(inject([UpgradeModule], (ngUpgrade: UpgradeModule) => { + mock$rootScope = ngUpgrade.$injector.get('$rootScope'); + })); + + it('should set $$state and return itself', function() { + expect(($location as any).$$state).toEqual(null); + + let returned = $location.state({a: 2}); + expect(($location as any).$$state).toEqual({a: 2}); + expect(returned).toBe($location); + }); + + it('should set state', function() { + $location.state({a: 2}); + expect($location.state()).toEqual({a: 2}); + }); + + it('should allow to set both URL and state', function() { + $location.url('/foo').state({a: 2}); + expect($location.url()).toEqual('/foo'); + expect($location.state()).toEqual({a: 2}); + }); + + it('should allow to mix state and various URL functions', function() { + $location.path('/foo').hash('abcd').state({a: 2}).search('bar', 'baz'); + expect($location.path()).toEqual('/foo'); + expect($location.state()).toEqual({a: 2}); + expect($location.search() && $location.search().bar).toBe('baz'); + expect($location.hash()).toEqual('abcd'); + }); + + it('should always have the same value by reference until the value is changed', function() { + expect(($location as any).$$state).toEqual(null); + expect($location.state()).toEqual(null); + + const stateValue = {foo: 'bar'}; + + $location.state(stateValue); + expect($location.state()).toBe(stateValue); + mock$rootScope.runWatchers(); + + const testState = $location.state(); + + // $location.state() should equal by reference + expect($location.state()).toEqual(stateValue); + expect($location.state()).toBe(testState); + + mock$rootScope.runWatchers(); + expect($location.state()).toBe(testState); + mock$rootScope.runWatchers(); + expect($location.state()).toBe(testState); + + // Confirm updating other values doesn't change the value of `state` + $location.path('/new'); + + expect($location.state()).toBe(testState); + mock$rootScope.runWatchers(); + + // After watchers have been run, location should be updated and `state` should change + expect($location.state()).toBe(null); + }); + + }); +}); + +function parseLinkAndReturn(location: LocationUpgradeService, toUrl: string, relHref?: string) { + const resetUrl = location.$$parseLinkUrl(toUrl, relHref); + return resetUrl && location.absUrl() || undefined; +} diff --git a/packages/common/upgrade/test/upgrade_location_test_module.ts b/packages/common/upgrade/test/upgrade_location_test_module.ts new file mode 100644 index 0000000000..14c52c1116 --- /dev/null +++ b/packages/common/upgrade/test/upgrade_location_test_module.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright Google Inc. All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {APP_BASE_HREF, CommonModule, Location, LocationStrategy, PlatformLocation} from '@angular/common'; +import {MockPlatformLocation} from '@angular/common/testing'; +import {Inject, InjectionToken, ModuleWithProviders, NgModule, Optional} from '@angular/core'; +import {UpgradeModule} from '@angular/upgrade/static'; + +import {LocationUpgradeProvider, LocationUpgradeService} from '../src/$location'; +import {LocationUpgradeModule} from '../src/location_upgrade_module'; +import {UrlCodec} from '../src/params'; + +export interface LocationUpgradeTestingConfig { + useHash?: boolean; + hashPrefix?: string; + urlCodec?: typeof UrlCodec; + startUrl?: string; + appBaseHref?: string; +} + +/** + * @description + * + * Is used in DI to configure the router. + * + * @publicApi + */ +export const LOC_UPGRADE_TEST_CONFIG = + new InjectionToken('LOC_UPGRADE_TEST_CONFIG'); + + +export const APP_BASE_HREF_RESOLVED = new InjectionToken('APP_BASE_HREF_RESOLVED'); + +/** + * Module used for configuring Angular's LocationUpgradeService. + */ +@NgModule({imports: [CommonModule]}) +export class LocationUpgradeTestModule { + static config(config?: LocationUpgradeTestingConfig): + ModuleWithProviders { + return { + ngModule: LocationUpgradeTestModule, + providers: [ + {provide: LOC_UPGRADE_TEST_CONFIG, useValue: config || {}}, { + provide: PlatformLocation, + useFactory: (appBaseHref?: string) => { + if (config && config.appBaseHref != null) { + appBaseHref = config.appBaseHref; + } else if (appBaseHref == null) { + appBaseHref = ''; + } + return new MockPlatformLocation( + {startUrl: config && config.startUrl, appBaseHref: appBaseHref}); + }, + deps: [[new Inject(APP_BASE_HREF), new Optional()]] + }, + { + provide: LocationUpgradeService, + useFactory: provide$location, + deps: [ + UpgradeModule, Location, PlatformLocation, UrlCodec, LocationStrategy, + LOC_UPGRADE_TEST_CONFIG + ] + }, + LocationUpgradeModule + .config({ + appBaseHref: config && config.appBaseHref, + useHash: config && config.useHash || false + }) + .providers ! + ], + }; + } +} + +export function provide$location( + ngUpgrade: UpgradeModule, location: Location, platformLocation: PlatformLocation, + urlCodec: UrlCodec, locationStrategy: LocationStrategy, config?: LocationUpgradeTestingConfig) { + const $locationProvider = new LocationUpgradeProvider( + ngUpgrade, location, platformLocation, urlCodec, locationStrategy); + + $locationProvider.hashPrefix(config && config.hashPrefix); + $locationProvider.html5Mode(config && !config.useHash); + + return $locationProvider.$get(); +} \ No newline at end of file diff --git a/tools/public_api_guard/common/common.d.ts b/tools/public_api_guard/common/common.d.ts index e21a375cff..50eee2b32f 100644 --- a/tools/public_api_guard/common/common.d.ts +++ b/tools/public_api_guard/common/common.d.ts @@ -116,7 +116,6 @@ export declare class HashLocationStrategy extends LocationStrategy { back(): void; forward(): void; getBaseHref(): string; - getState(): unknown; onPopState(fn: LocationChangeListener): void; path(includeHash?: boolean): string; prepareExternalUrl(internal: string): string; @@ -167,13 +166,14 @@ export declare class KeyValuePipe implements PipeTransform { } export declare class Location { - constructor(platformStrategy: LocationStrategy); + constructor(platformStrategy: LocationStrategy, platformLocation: PlatformLocation); back(): void; forward(): void; getState(): unknown; go(path: string, query?: string, state?: any): void; isCurrentPathEqualTo(path: string, query?: string): boolean; normalize(url: string): string; + onUrlChange(fn: (url: string, state: unknown) => void): void; path(includeHash?: boolean): string; prepareExternalUrl(url: string): string; replaceState(path: string, query?: string, state?: any): void; @@ -198,7 +198,6 @@ export declare abstract class LocationStrategy { abstract back(): void; abstract forward(): void; abstract getBaseHref(): string; - abstract getState(): unknown; abstract onPopState(fn: LocationChangeListener): void; abstract path(includeHash?: boolean): string; abstract prepareExternalUrl(internal: string): string; @@ -362,7 +361,6 @@ export declare class PathLocationStrategy extends LocationStrategy { back(): void; forward(): void; getBaseHref(): string; - getState(): unknown; onPopState(fn: LocationChangeListener): void; path(includeHash?: boolean): string; prepareExternalUrl(internal: string): string; @@ -378,6 +376,7 @@ export declare class PercentPipe implements PipeTransform { export declare abstract class PlatformLocation { abstract readonly hash: string; abstract readonly hostname: string; + abstract readonly href: string; abstract readonly pathname: string; abstract readonly port: string; abstract readonly protocol: string; diff --git a/tools/public_api_guard/common/testing.d.ts b/tools/public_api_guard/common/testing.d.ts index 02752aa1ed..3ae4bb9f51 100644 --- a/tools/public_api_guard/common/testing.d.ts +++ b/tools/public_api_guard/common/testing.d.ts @@ -16,7 +16,28 @@ export declare class MockLocationStrategy extends LocationStrategy { simulatePopState(url: string): void; } -export declare class SpyLocation implements Location { +export declare class MockPlatformLocation implements PlatformLocation { + readonly hash: string; + readonly hostname: string; + readonly href: string; + readonly pathname: string; + readonly port: string; + readonly protocol: string; + readonly search: string; + readonly state: unknown; + readonly url: string; + constructor(config?: MockPlatformLocationConfig); + back(): void; + forward(): void; + getBaseHrefFromDOM(): string; + getState(): unknown; + onHashChange(fn: LocationChangeListener): void; + onPopState(fn: LocationChangeListener): void; + pushState(state: any, title: string, newUrl: string): void; + replaceState(state: any, title: string, newUrl: string): void; +} + +export declare class SpyLocation extends Location { urlChanges: string[]; back(): void; forward(): void; @@ -24,6 +45,7 @@ export declare class SpyLocation implements Location { go(path: string, query?: string, state?: any): void; isCurrentPathEqualTo(path: string, query?: string): boolean; normalize(url: string): string; + onUrlChange(fn: (url: string, state: unknown) => void): void; path(): string; prepareExternalUrl(url: string): string; replaceState(path: string, query?: string, state?: any): void; diff --git a/tools/public_api_guard/common/upgrade.d.ts b/tools/public_api_guard/common/upgrade.d.ts new file mode 100644 index 0000000000..72b47abf3d --- /dev/null +++ b/tools/public_api_guard/common/upgrade.d.ts @@ -0,0 +1,108 @@ +export declare const $locationProvider: Function; + +export declare class AngularJSUrlCodec implements UrlCodec { + areEqual(a: string, b: string): boolean; + decodeHash(hash: string): string; + decodePath(path: string, html5Mode?: boolean): string; + decodeSearch(search: string): { + [k: string]: unknown; + }; + encodeHash(hash: string): string; + encodePath(path: string): string; + encodeSearch(search: string | { + [k: string]: unknown; + }): string; + normalize(href: string): string; + normalize(path: string, search: { + [k: string]: unknown; + }, hash: string, baseUrl?: string): string; + parse(url: string, base?: string): { + href: string; + protocol: string; + host: string; + search: string; + hash: string; + hostname: string; + port: string; + pathname: string; + }; +} + +export declare const LOCATION_UPGRADE_CONFIGURATION: InjectionToken; + +export declare const LOCATION_UPGRADE_MODULE = "LOCATION_UPGRADE_MODULE"; + +export interface LocationUpgradeConfig { + appBaseHref?: string; + hashPrefix?: string; + serverBaseHref?: string; + urlCodec?: typeof UrlCodec; + useHash?: boolean; +} + +export declare class LocationUpgradeModule { + static config(config?: LocationUpgradeConfig): ModuleWithProviders; +} + +export declare class LocationUpgradeProvider { + constructor(ngUpgrade: UpgradeModule, location: Location, platformLocation: PlatformLocation, urlCodec: UrlCodec, locationStrategy: LocationStrategy); + $get(): LocationUpgradeService; + hashPrefix(prefix?: string): void; + html5Mode(mode?: any): void; +} + +export declare class LocationUpgradeService { + constructor($injector: any, location: Location, platformLocation: PlatformLocation, urlCodec: UrlCodec, locationStrategy: LocationStrategy); + $$parse(url: string): void; + $$parseLinkUrl(url: string, relHref?: string | null): boolean; + absUrl(): string; + hash(hash: string | number | null): this; + hash(): string; + host(): string; + path(): string; + path(path: string | number | null): this; + port(): number | null; + protocol(): string; + replace(): this; + search(): { + [key: string]: unknown; + }; + search(search: string | number | { + [key: string]: unknown; + }): this; + search(search: string | number | { + [key: string]: unknown; + }, paramValue: null | undefined | string | number | boolean | string[]): this; + state(state: unknown): this; + state(): unknown; + url(): string; + url(url: string): this; +} + +export declare abstract class UrlCodec { + abstract areEqual(a: string, b: string): boolean; + abstract decodeHash(hash: string): string; + abstract decodePath(path: string): string; + abstract decodeSearch(search: string): { + [k: string]: unknown; + }; + abstract encodeHash(hash: string): string; + abstract encodePath(path: string): string; + abstract encodeSearch(search: string | { + [k: string]: unknown; + }): string; + abstract normalize(href: string): string; + abstract normalize(path: string, search: { + [k: string]: unknown; + }, hash: string, baseUrl?: string): string; + abstract parse(url: string, base?: string): { + href: string; + protocol: string; + host: string; + search: string; + hash: string; + hostname: string; + port: string; + pathname: string; + }; +}