feat(common): provide replacement for AngularJS $location service (#30055)

This commit provides a replacement for `$location`. The new service is written in Angular, and can be consumed into existing applications by using the downgraded version
of the provider.

Prior to this addition, applications upgrading from AngularJS to Angular could get into a situation where AngularJS wanted to control the URL, and would often parse or se
rialize the URL in a different way than Angular. Additionally, AngularJS was alerted to URL changes only through the `$digest` cycle. This provided a buggy feedback loop
from Angular to AngularJS.

With this new `LocationUpgradeProvider`, the `$location` methods and events are provided in Angular, and use Angular APIs to make updates to the URL. Additionally, change
s to the URL made by other parts of the Angular framework (such as the Router) will be listened for and will cause events to fire in AngularJS, but will no longer attempt
 to update the URL (since it was already updated by the Angular framework).

This centralizes URL reads and writes to Angular and should help provide an easier path to upgrading AngularJS applications to Angular.

PR Close #30055
This commit is contained in:
Jason Aden 2019-04-23 07:16:08 -07:00 committed by Ben Lesh
parent f185ff3792
commit 4277600d5e
19 changed files with 1777 additions and 137 deletions

View File

@ -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));
}
/**

View File

@ -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);
}));
});
});

View File

@ -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(

View File

@ -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; }
}

View File

@ -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
};

View File

@ -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.<string>|Object.<Array.<string>>} 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<string>|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.');
}
}

View File

@ -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);

View File

@ -6,5 +6,7 @@
* found in the LICENSE file at https://angular.io/license
*/
export * from './location';
export * from './location_module';
export * from './location_upgrade_module';
export * from './angular_js_module';
export * from './$location';
export * from './params';

View File

@ -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 {
}

View File

@ -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 {
}

View File

@ -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<LocationUpgradeConfig>('LOCATION_UPGRADE_CONFIGURATION');
const APP_BASE_HREF_RESOLVED = new InjectionToken<string>('APP_BASE_HREF_RESOLVED');
/**
* Module used for configuring Angular's LocationUpgradeService.
*
* @publicApi
*/
@NgModule({imports: [CommonModule]})
export class LocationUpgradeModule {
static config(config?: LocationUpgradeConfig): ModuleWithProviders<LocationUpgradeModule> {
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();
}

View File

@ -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.<string,boolean|Array>}
*/
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)

View File

@ -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 (<HTMLAnchorElement>el).href !== undefined;
}
export function isPromise(obj: any): obj is Promise<any> {
// 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';
}

View File

@ -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",
],

View File

@ -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);
}));
});
});
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;
}

View File

@ -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<LocationUpgradeTestingConfig>('LOC_UPGRADE_TEST_CONFIG');
export const APP_BASE_HREF_RESOLVED = new InjectionToken<string>('APP_BASE_HREF_RESOLVED');
/**
* Module used for configuring Angular's LocationUpgradeService.
*/
@NgModule({imports: [CommonModule]})
export class LocationUpgradeTestModule {
static config(config?: LocationUpgradeTestingConfig):
ModuleWithProviders<LocationUpgradeTestModule> {
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();
}

View File

@ -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;

View File

@ -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;

View File

@ -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<LocationUpgradeConfig>;
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<LocationUpgradeModule>;
}
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;
};
}