From 8261aac7be0c7f028ea93e4a34e40febbebe55a8 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Mon, 18 Mar 2019 17:23:12 -0700 Subject: [PATCH 1/2] [FEATURE ember-glimmer-angle-bracket-built-ins] Implement ``. --- .../glimmer/lib/components/link-to.ts | 2457 ++++++++++++----- .../glimmer/lib/templates/link-to.hbs | 6 +- .../link-to/query-params-angle-test.js | 746 +++++ ...ams-test.js => query-params-curly-test.js} | 13 - .../link-to/rendering-angle-test.js | 111 + ...dering-test.js => rendering-curly-test.js} | 28 +- .../components/link-to/routing-angle-test.js | 1853 +++++++++++++ ...{routing-test.js => routing-curly-test.js} | 50 +- .../transitioning-classes-angle-test.js | 347 +++ ...js => transitioning-classes-curly-test.js} | 0 10 files changed, 4820 insertions(+), 791 deletions(-) create mode 100644 packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-angle-test.js rename packages/@ember/-internals/glimmer/tests/integration/components/link-to/{query-params-test.js => query-params-curly-test.js} (98%) create mode 100644 packages/@ember/-internals/glimmer/tests/integration/components/link-to/rendering-angle-test.js rename packages/@ember/-internals/glimmer/tests/integration/components/link-to/{rendering-test.js => rendering-curly-test.js} (84%) create mode 100644 packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-angle-test.js rename packages/@ember/-internals/glimmer/tests/integration/components/link-to/{routing-test.js => routing-curly-test.js} (96%) create mode 100644 packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-angle-test.js rename packages/@ember/-internals/glimmer/tests/integration/components/link-to/{transitioning-classes-test.js => transitioning-classes-curly-test.js} (100%) diff --git a/packages/@ember/-internals/glimmer/lib/components/link-to.ts b/packages/@ember/-internals/glimmer/lib/components/link-to.ts index efbb694fc51..dc3c67d95e6 100644 --- a/packages/@ember/-internals/glimmer/lib/components/link-to.ts +++ b/packages/@ember/-internals/glimmer/lib/components/link-to.ts @@ -2,885 +2,1834 @@ @module ember */ -/** - The `{{link-to}}` component renders a link to the supplied - `routeName` passing an optionally supplied model to the - route as its `model` context of the route. The block - for `{{link-to}}` becomes the innerHTML of the rendered - element: - - ```handlebars - {{#link-to 'photoGallery'}} - Great Hamster Photos - {{/link-to}} - ``` - - You can also use an inline form of `{{link-to}}` component by - passing the link text as the first argument - to the component: - - ```handlebars - {{link-to 'Great Hamster Photos' 'photoGallery'}} - ``` - - Both will result in: - - ```html - - Great Hamster Photos - - ``` - - ### Supplying a tagName - By default `{{link-to}}` renders an `` element. This can - be overridden for a single use of `{{link-to}}` by supplying - a `tagName` option: - - ```handlebars - {{#link-to 'photoGallery' tagName="li"}} - Great Hamster Photos - {{/link-to}} - ``` - - ```html -
  • - Great Hamster Photos -
  • - ``` - - To override this option for your entire application, see - "Overriding Application-wide Defaults". - - ### Disabling the `link-to` component - By default `{{link-to}}` is enabled. - any passed value to the `disabled` component property will disable - the `link-to` component. - - static use: the `disabled` option: - - ```handlebars - {{#link-to 'photoGallery' disabled=true}} - Great Hamster Photos - {{/link-to}} - ``` - - dynamic use: the `disabledWhen` option: - - ```handlebars - {{#link-to 'photoGallery' disabledWhen=controller.someProperty}} - Great Hamster Photos - {{/link-to}} - ``` - - any truthy value passed to `disabled` will disable it except `undefined`. - - See "Overriding Application-wide Defaults" for more. - - ### Handling `href` - `{{link-to}}` will use your application's Router to - fill the element's `href` property with a url that - matches the path to the supplied `routeName` for your - router's configured `Location` scheme, which defaults - to HashLocation. - - ### Handling current route - `{{link-to}}` will apply a CSS class name of 'active' - when the application's current route matches - the supplied routeName. For example, if the application's - current route is 'photoGallery.recent' the following - use of `{{link-to}}`: - - ```handlebars - {{#link-to 'photoGallery.recent'}} - Great Hamster Photos - {{/link-to}} - ``` - - will result in - - ```html -
    - Great Hamster Photos - - ``` - - The CSS class name used for active classes can be customized - for a single use of `{{link-to}}` by passing an `activeClass` - option: - - ```handlebars - {{#link-to 'photoGallery.recent' activeClass="current-url"}} - Great Hamster Photos - {{/link-to}} - ``` - - ```html - - Great Hamster Photos - - ``` - - To override this option for your entire application, see - "Overriding Application-wide Defaults". - - ### Keeping a link active for other routes - - If you need a link to be 'active' even when it doesn't match - the current route, you can use the `current-when` argument. - - ```handlebars - {{#link-to 'photoGallery' current-when='photos'}} - Photo Gallery - {{/link-to}} - ``` - - This may be helpful for keeping links active for: - - * non-nested routes that are logically related - * some secondary menu approaches - * 'top navigation' with 'sub navigation' scenarios - - A link will be active if `current-when` is `true` or the current - route is the route this link would transition to. - - To match multiple routes 'space-separate' the routes: - - ```handlebars - {{#link-to 'gallery' current-when='photos drawings paintings'}} - Art Gallery - {{/link-to}} - ``` - - ### Supplying a model - An optional model argument can be used for routes whose - paths contain dynamic segments. This argument will become - the model context of the linked route: - - ```javascript - Router.map(function() { - this.route("photoGallery", {path: "hamster-photos/:photo_id"}); - }); - ``` - - ```handlebars - {{#link-to 'photoGallery' aPhoto}} - {{aPhoto.title}} - {{/link-to}} - ``` - - ```html - - Tomster - - ``` - - ### Supplying multiple models - For deep-linking to route paths that contain multiple - dynamic segments, multiple model arguments can be used. - As the router transitions through the route path, each - supplied model argument will become the context for the - route with the dynamic segments: - - ```javascript - Router.map(function() { - this.route("photoGallery", { path: "hamster-photos/:photo_id" }, function() { - this.route("comment", {path: "comments/:comment_id"}); - }); - }); - ``` - This argument will become the model context of the linked route: - - ```handlebars - {{#link-to 'photoGallery.comment' aPhoto comment}} - {{comment.body}} - {{/link-to}} - ``` - - ```html - - A+++ would snuggle again. - - ``` - - ### Supplying an explicit dynamic segment value - If you don't have a model object available to pass to `{{link-to}}`, - an optional string or integer argument can be passed for routes whose - paths contain dynamic segments. This argument will become the value - of the dynamic segment: - - ```javascript - Router.map(function() { - this.route("photoGallery", { path: "hamster-photos/:photo_id" }); - }); - ``` +import { alias, computed, get } from '@ember/-internals/metal'; +import { isSimpleClick } from '@ember/-internals/views'; +import { EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS } from '@ember/canary-features'; +import { assert, warn } from '@ember/debug'; +import { flaggedInstrument } from '@ember/instrumentation'; +import { assign } from '@ember/polyfills'; +import { inject as injectService } from '@ember/service'; +import { DEBUG } from '@glimmer/env'; +import EmberComponent, { HAS_BLOCK } from '../component'; +import layout from '../templates/link-to'; - ```handlebars - {{#link-to 'photoGallery' aPhotoId}} - {{aPhoto.title}} - {{/link-to}} - ``` +let LinkComponent: any; - ```html - - Tomster - - ``` +if (EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS) { + /** + The `{{link-to}}` component renders a link to the supplied + `routeName` passing an optionally supplied model to the + route as its `model` context of the route. The block + for `{{link-to}}` becomes the innerHTML of the rendered + element: + + ```handlebars + {{#link-to 'photoGallery'}} + Great Hamster Photos + {{/link-to}} + ``` - When transitioning into the linked route, the `model` hook will - be triggered with parameters including this passed identifier. + You can also use an inline form of `{{link-to}}` component by + passing the link text as the first argument + to the component: - ### Allowing Default Action + ```handlebars + {{link-to 'Great Hamster Photos' 'photoGallery'}} + ``` - By default the `{{link-to}}` component prevents the default browser action - by calling `preventDefault()` as this sort of action bubbling is normally - handled internally and we do not want to take the browser to a new URL (for - example). + Both will result in: - If you need to override this behavior specify `preventDefault=false` in - your template: + ```html + + Great Hamster Photos + + ``` - ```handlebars - {{#link-to 'photoGallery' aPhotoId preventDefault=false}} - {{aPhotoId.title}} - {{/link-to}} - ``` + ### Supplying a tagName + By default `{{link-to}}` renders an `` element. This can + be overridden for a single use of `{{link-to}}` by supplying + a `tagName` option: - ### Overriding attributes - You can override any given property of the `LinkComponent` - that is generated by the `{{link-to}}` component by passing - key/value pairs, like so: + ```handlebars + {{#link-to 'photoGallery' tagName="li"}} + Great Hamster Photos + {{/link-to}} + ``` - ```handlebars - {{#link-to aPhoto tagName='li' title='Following this link will change your life' classNames='pic sweet'}} - Uh-mazing! - {{/link-to}} - ``` + ```html +
  • + Great Hamster Photos +
  • + ``` - See [LinkComponent](/api/ember/release/classes/LinkComponent) for a - complete list of overrideable properties. Be sure to also - check out inherited properties of `LinkComponent`. + To override this option for your entire application, see + "Overriding Application-wide Defaults". - ### Overriding Application-wide Defaults + ### Disabling the `link-to` component + By default `{{link-to}}` is enabled. + any passed value to the `disabled` component property will disable + the `link-to` component. - ``{{link-to}}`` creates an instance of `LinkComponent` for rendering. To - override options for your entire application, export your customized - `LinkComponent` from `app/components/link-to.js` with the desired overrides: + static use: the `disabled` option: - ```javascript - // app/components/link-to.js - import LinkComponent from '@ember/routing/link-component'; + ```handlebars + {{#link-to 'photoGallery' disabled=true}} + Great Hamster Photos + {{/link-to}} + ``` - export default LinkComponent.extend({ - activeClass: "is-active", - tagName: 'li' - }) - ``` + dynamic use: the `disabledWhen` option: - It is also possible to override the default event in this manner: + ```handlebars + {{#link-to 'photoGallery' disabledWhen=controller.someProperty}} + Great Hamster Photos + {{/link-to}} + ``` - ```javascript - import LinkComponent from '@ember/routing/link-component'; + any truthy value passed to `disabled` will disable it except `undefined`. - export default LinkComponent.extend({ - eventName: 'customEventName' - }); - ``` - - @method link-to - @for Ember.Templates.helpers - @param {String} routeName - @param {Object} [context]* - @param [options] {Object} Handlebars key/value pairs of options, you can override any property of Ember.LinkComponent - @return {String} HTML string - @see {LinkComponent} - @public -*/ + See "Overriding Application-wide Defaults" for more. -import { computed, get } from '@ember/-internals/metal'; -import { isSimpleClick } from '@ember/-internals/views'; -import { assert, warn } from '@ember/debug'; -import { flaggedInstrument } from '@ember/instrumentation'; -import { assign } from '@ember/polyfills'; -import { inject as injectService } from '@ember/service'; -import { DEBUG } from '@glimmer/env'; -import EmberComponent, { HAS_BLOCK } from '../component'; -import layout from '../templates/link-to'; + ### Handling `href` + `{{link-to}}` will use your application's Router to + fill the element's `href` property with a url that + matches the path to the supplied `routeName` for your + router's configured `Location` scheme, which defaults + to HashLocation. -/** - @module @ember/routing -*/ + ### Handling current route + `{{link-to}}` will apply a CSS class name of 'active' + when the application's current route matches + the supplied routeName. For example, if the application's + current route is 'photoGallery.recent' the following + use of `{{link-to}}`: -/** - `LinkComponent` renders an element whose `click` event triggers a - transition of the application's instance of `Router` to - a supplied route by name. + ```handlebars + {{#link-to 'photoGallery.recent'}} + Great Hamster Photos + {{/link-to}} + ``` - `LinkComponent` components are invoked with {{#link-to}}. Properties - of this class can be overridden with `reopen` to customize application-wide - behavior. + will result in - @class LinkComponent - @extends Component - @see {Ember.Templates.helpers.link-to} - @public -**/ + ```html +
    + Great Hamster Photos + + ``` -const EMPTY_QUERY_PARAMS = Object.freeze({ values: Object.freeze({}) }); + The CSS class name used for active classes can be customized + for a single use of `{{link-to}}` by passing an `activeClass` + option: -const LinkComponent = EmberComponent.extend({ - layout, + ```handlebars + {{#link-to 'photoGallery.recent' activeClass="current-url"}} + Great Hamster Photos + {{/link-to}} + ``` - tagName: 'a', + ```html + + Great Hamster Photos + + ``` - /** - Used to determine when this `LinkComponent` is active. + To override this option for your entire application, see + "Overriding Application-wide Defaults". - @property current-when - @public - */ - 'current-when': null, + ### Keeping a link active for other routes - /** - Sets the `title` attribute of the `LinkComponent`'s HTML element. + If you need a link to be 'active' even when it doesn't match + the current route, you can use the `current-when` argument. - @property title - @default null - @public - **/ - title: null, + ```handlebars + {{#link-to 'photoGallery' current-when='photos'}} + Photo Gallery + {{/link-to}} + ``` - /** - Sets the `rel` attribute of the `LinkComponent`'s HTML element. + This may be helpful for keeping links active for: - @property rel - @default null - @public - **/ - rel: null, + * non-nested routes that are logically related + * some secondary menu approaches + * 'top navigation' with 'sub navigation' scenarios - /** - Sets the `tabindex` attribute of the `LinkComponent`'s HTML element. + A link will be active if `current-when` is `true` or the current + route is the route this link would transition to. - @property tabindex - @default null - @public - **/ - tabindex: null, + To match multiple routes 'space-separate' the routes: - /** - Sets the `target` attribute of the `LinkComponent`'s HTML element. + ```handlebars + {{#link-to 'gallery' current-when='photos drawings paintings'}} + Art Gallery + {{/link-to}} + ``` - @since 1.8.0 - @property target - @default null - @public - **/ - target: null, + ### Supplying a model + An optional model argument can be used for routes whose + paths contain dynamic segments. This argument will become + the model context of the linked route: - /** - The CSS class to apply to `LinkComponent`'s element when its `active` - property is `true`. + ```javascript + Router.map(function() { + this.route("photoGallery", {path: "hamster-photos/:photo_id"}); + }); + ``` - @property activeClass - @type String - @default active - @public - **/ - activeClass: 'active', + ```handlebars + {{#link-to 'photoGallery' aPhoto}} + {{aPhoto.title}} + {{/link-to}} + ``` - /** - The CSS class to apply to `LinkComponent`'s element when its `loading` - property is `true`. + ```html + + Tomster + + ``` - @property loadingClass - @type String - @default loading - @private - **/ - loadingClass: 'loading', + ### Supplying multiple models + For deep-linking to route paths that contain multiple + dynamic segments, multiple model arguments can be used. + As the router transitions through the route path, each + supplied model argument will become the context for the + route with the dynamic segments: + + ```javascript + Router.map(function() { + this.route("photoGallery", { path: "hamster-photos/:photo_id" }, function() { + this.route("comment", {path: "comments/:comment_id"}); + }); + }); + ``` + This argument will become the model context of the linked route: - /** - The CSS class to apply to a `LinkComponent`'s element when its `disabled` - property is `true`. + ```handlebars + {{#link-to 'photoGallery.comment' aPhoto comment}} + {{comment.body}} + {{/link-to}} + ``` - @property disabledClass - @type String - @default disabled - @private - **/ - disabledClass: 'disabled', + ```html + + A+++ would snuggle again. + + ``` - /** - Determines whether the `LinkComponent` will trigger routing via - the `replaceWith` routing strategy. + ### Supplying an explicit dynamic segment value + If you don't have a model object available to pass to `{{link-to}}`, + an optional string or integer argument can be passed for routes whose + paths contain dynamic segments. This argument will become the value + of the dynamic segment: - @property replace - @type Boolean - @default false - @public - **/ - replace: false, + ```javascript + Router.map(function() { + this.route("photoGallery", { path: "hamster-photos/:photo_id" }); + }); + ``` - /** - By default the `{{link-to}}` component will bind to the `href` and - `title` attributes. It's discouraged that you override these defaults, - however you can push onto the array if needed. + ```handlebars + {{#link-to 'photoGallery' aPhotoId}} + {{aPhoto.title}} + {{/link-to}} + ``` - @property attributeBindings - @type Array | String - @default ['title', 'rel', 'tabindex', 'target'] - @public - */ - attributeBindings: ['href', 'title', 'rel', 'tabindex', 'target'], + ```html + + Tomster + + ``` - /** - By default the `{{link-to}}` component will bind to the `active`, `loading`, - and `disabled` classes. It is discouraged to override these directly. + When transitioning into the linked route, the `model` hook will + be triggered with parameters including this passed identifier. - @property classNameBindings - @type Array - @default ['active', 'loading', 'disabled', 'ember-transitioning-in', 'ember-transitioning-out'] - @public - */ - classNameBindings: ['active', 'loading', 'disabled', 'transitioningIn', 'transitioningOut'], + ### Allowing Default Action - /** - By default the `{{link-to}}` component responds to the `click` event. You - can override this globally by setting this property to your custom - event name. + By default the `{{link-to}}` component prevents the default browser action + by calling `preventDefault()` as this sort of action bubbling is normally + handled internally and we do not want to take the browser to a new URL (for + example). - This is particularly useful on mobile when one wants to avoid the 300ms - click delay using some sort of custom `tap` event. + If you need to override this behavior specify `preventDefault=false` in + your template: - @property eventName - @type String - @default click - @private - */ - eventName: 'click', + ```handlebars + {{#link-to 'photoGallery' aPhotoId preventDefault=false}} + {{aPhotoId.title}} + {{/link-to}} + ``` - // this is doc'ed here so it shows up in the events - // section of the API documentation, which is where - // people will likely go looking for it. - /** - Triggers the `LinkComponent`'s routing behavior. If - `eventName` is changed to a value other than `click` - the routing behavior will trigger on that custom event - instead. + ### Overriding attributes + You can override any given property of the `LinkComponent` + that is generated by the `{{link-to}}` component by passing + key/value pairs, like so: - @event click - @private - */ + ```handlebars + {{#link-to aPhoto tagName='li' title='Following this link will change your life' classNames='pic sweet'}} + Uh-mazing! + {{/link-to}} + ``` - /** - An overridable method called when `LinkComponent` objects are instantiated. + See [LinkComponent](/api/ember/release/classes/LinkComponent) for a + complete list of overrideable properties. Be sure to also + check out inherited properties of `LinkComponent`. + + ### Overriding Application-wide Defaults - Example: + ``{{link-to}}`` creates an instance of `LinkComponent` for rendering. To + override options for your entire application, export your customized + `LinkComponent` from `app/components/link-to.js` with the desired overrides: - ```app/components/my-link.js + ```javascript + // app/components/link-to.js import LinkComponent from '@ember/routing/link-component'; export default LinkComponent.extend({ - init() { - this._super(...arguments); - console.log('Event is ' + this.get('eventName')); - } + activeClass: "is-active", + tagName: 'li' + }) + ``` + + It is also possible to override the default event in this manner: + + ```javascript + import LinkComponent from '@ember/routing/link-component'; + + export default LinkComponent.extend({ + eventName: 'customEventName' }); ``` - NOTE: If you do override `init` for a framework class like `Component`, - be sure to call `this._super(...arguments)` in your - `init` declaration! If you don't, Ember may not have an opportunity to - do important setup work, and you'll see strange behavior in your - application. + @method link-to + @for Ember.Templates.helpers + @param {String} routeName + @param {Object} [context]* + @param [options] {Object} Handlebars key/value pairs of options, you can override any property of Ember.LinkComponent + @return {String} HTML string + @see {LinkComponent} + @public + */ - @method init - @private + /** + @module @ember/routing */ - init() { - this._super(...arguments); - // Map desired event name to invoke function - let eventName = get(this, 'eventName'); - this.on(eventName, this, this._invoke); - }, + /** + `LinkComponent` renders an element whose `click` event triggers a + transition of the application's instance of `Router` to + a supplied route by name. - _routing: injectService('-routing'), + `LinkComponent` components are invoked with {{#link-to}}. Properties + of this class can be overridden with `reopen` to customize application-wide + behavior. - /** - Accessed as a classname binding to apply the `LinkComponent`'s `disabledClass` - CSS `class` to the element when the link is disabled. + @class LinkComponent + @extends Component + @see {Ember.Templates.helpers.link-to} + @public + **/ - When `true` interactions with the element will not trigger route changes. - @property disabled - @private - */ - disabled: computed({ - get(_key: string): boolean { - // always returns false for `get` because (due to the `set` just below) - // the cached return value from the set will prevent this getter from _ever_ - // being called after a set has occured - return false; + const UNDEFINED = Object.freeze({ + toString() { + return 'UNDEFINED'; }, + }); - set(_key: string, value: any): boolean { - (this as any)._isDisabled = value; - - return value ? get(this, 'disabledClass') : false; + const EMPTY_QUERY_PARAMS = Object.freeze({}); + + LinkComponent = EmberComponent.extend({ + layout, + + tagName: 'a', + + /** + @property route + @public + */ + route: UNDEFINED, + + /** + @property model + @public + */ + model: UNDEFINED, + + /** + @property models + @public + */ + models: UNDEFINED, + + /** + @property query + @public + */ + query: UNDEFINED, + + /** + Used to determine when this `LinkComponent` is active. + + @property current-when + @public + */ + 'current-when': null, + + /** + Sets the `title` attribute of the `LinkComponent`'s HTML element. + + @property title + @default null + @public + **/ + title: null, + + /** + Sets the `rel` attribute of the `LinkComponent`'s HTML element. + + @property rel + @default null + @public + **/ + rel: null, + + /** + Sets the `tabindex` attribute of the `LinkComponent`'s HTML element. + + @property tabindex + @default null + @public + **/ + tabindex: null, + + /** + Sets the `target` attribute of the `LinkComponent`'s HTML element. + + @since 1.8.0 + @property target + @default null + @public + **/ + target: null, + + /** + The CSS class to apply to `LinkComponent`'s element when its `active` + property is `true`. + + @property activeClass + @type String + @default active + @public + **/ + activeClass: 'active', + + /** + The CSS class to apply to `LinkComponent`'s element when its `loading` + property is `true`. + + @property loadingClass + @type String + @default loading + @private + **/ + loadingClass: 'loading', + + /** + The CSS class to apply to a `LinkComponent`'s element when its `disabled` + property is `true`. + + @property disabledClass + @type String + @default disabled + @private + **/ + disabledClass: 'disabled', + + /** + Determines whether the `LinkComponent` will trigger routing via + the `replaceWith` routing strategy. + + @property replace + @type Boolean + @default false + @public + **/ + replace: false, + + /** + By default the `{{link-to}}` component will bind to the `href` and + `title` attributes. It's discouraged that you override these defaults, + however you can push onto the array if needed. + + @property attributeBindings + @type Array | String + @default ['title', 'rel', 'tabindex', 'target'] + @public + */ + attributeBindings: ['href', 'title', 'rel', 'tabindex', 'target'], + + /** + By default the `{{link-to}}` component will bind to the `active`, `loading`, + and `disabled` classes. It is discouraged to override these directly. + + @property classNameBindings + @type Array + @default ['active', 'loading', 'disabled', 'ember-transitioning-in', 'ember-transitioning-out'] + @public + */ + classNameBindings: ['active', 'loading', 'disabled', 'transitioningIn', 'transitioningOut'], + + /** + By default the `{{link-to}}` component responds to the `click` event. You + can override this globally by setting this property to your custom + event name. + + This is particularly useful on mobile when one wants to avoid the 300ms + click delay using some sort of custom `tap` event. + + @property eventName + @type String + @default click + @private + */ + eventName: 'click', + + // this is doc'ed here so it shows up in the events + // section of the API documentation, which is where + // people will likely go looking for it. + /** + Triggers the `LinkComponent`'s routing behavior. If + `eventName` is changed to a value other than `click` + the routing behavior will trigger on that custom event + instead. + + @event click + @private + */ + + /** + An overridable method called when `LinkComponent` objects are instantiated. + + Example: + + ```app/components/my-link.js + import LinkComponent from '@ember/routing/link-component'; + + export default LinkComponent.extend({ + init() { + this._super(...arguments); + console.log('Event is ' + this.get('eventName')); + } + }); + ``` + + NOTE: If you do override `init` for a framework class like `Component`, + be sure to call `this._super(...arguments)` in your + `init` declaration! If you don't, Ember may not have an opportunity to + do important setup work, and you'll see strange behavior in your + application. + + @method init + @private + */ + init() { + this._super(...arguments); + + // Map desired event name to invoke function + let { eventName } = this; + this.on(eventName, this, this._invoke); }, - }), - _isActive(routerState: any) { - if (get(this, 'loading')) { - return false; - } + _routing: injectService('-routing'), + _currentRoute: alias('_routing.currentRouteName'), + _currentRouterState: alias('_routing.currentState'), + _targetRouterState: alias('_routing.targetState'), + + _route: computed('route', '_currentRoute', function computeLinkToComponentRoute(this: any) { + let { route } = this; + return route === UNDEFINED ? this._currentRoute : route; + }), - let currentWhen = get(this, 'current-when'); + _models: computed('model', 'models', function computeLinkToComponentModels(this: any) { + let { model, models } = this; - if (typeof currentWhen === 'boolean') { - return currentWhen; - } + assert( + 'You cannot provide both the `@model` and `@models` arguments to the component', + model === UNDEFINED || models === UNDEFINED + ); - let isCurrentWhenSpecified = Boolean(currentWhen); - currentWhen = currentWhen || get(this, 'qualifiedRouteName'); - currentWhen = currentWhen.split(' '); + if (model !== UNDEFINED) { + return [model]; + } else if (models !== UNDEFINED) { + return models; + } else { + return []; + } + }), - let routing = this._routing; - let models = get(this, 'models'); - let resolvedQueryParams = get(this, 'resolvedQueryParams'); + _query: computed('query', function computeLinkToComponentQuery(this: any) { + let { query } = this; - for (let i = 0; i < currentWhen.length; i++) { - if ( - routing.isActiveForRoute( - models, - resolvedQueryParams, - currentWhen[i], - routerState, - isCurrentWhenSpecified - ) - ) { + if (query === UNDEFINED) { + return EMPTY_QUERY_PARAMS; + } else { + return Object.assign({}, query); + } + }), + + /** + Accessed as a classname binding to apply the `LinkComponent`'s `disabledClass` + CSS `class` to the element when the link is disabled. + + When `true` interactions with the element will not trigger route changes. + @property disabled + @private + */ + disabled: computed({ + get(_key: string): boolean { + // always returns false for `get` because (due to the `set` just below) + // the cached return value from the set will prevent this getter from _ever_ + // being called after a set has occured + return false; + }, + + set(this: any, _key: string, value: any): boolean { + this._isDisabled = value; + + return value ? this.disabledClass : false; + }, + }), + + /** + Accessed as a classname binding to apply the `LinkComponent`'s `activeClass` + CSS `class` to the element when the link is active. + + A `LinkComponent` is considered active when its `currentWhen` property is `true` + or the application's current route is the route the `LinkComponent` would trigger + transitions into. + + The `currentWhen` property can match against multiple routes by separating + route names using the ` ` (space) character. + + @property active + @private + */ + active: computed('activeClass', '_active', function computeLinkToComponentActiveClass( + this: any + ) { + return this._active ? this.activeClass : false; + }), + + _active: computed( + '_currentRouterState', + '_route', + '_models', + '_query', + 'loading', + 'current-when', + function computeLinkToComponentActive(this: any) { + let { _currentRouterState: state } = this; + + if (state) { + return this._isActive(state); + } else { + return false; + } + } + ), + + willBeActive: computed( + '_currentRouterState', + '_targetRouterState', + '_route', + '_models', + '_query', + 'loading', + 'current-when', + function computeLinkToComponentWillBeActive(this: any) { + let { _currentRouterState: current, _targetRouterState: target } = this; + + if (current === target) { + return; + } + + return this._isActive(target); + } + ), + + _isActive(routerState: any) { + if (this.loading) { + return false; + } + + let currentWhen = this['current-when']; + + if (typeof currentWhen === 'boolean') { + return currentWhen; + } + + let isCurrentWhenSpecified = Boolean(currentWhen); + + if (isCurrentWhenSpecified) { + currentWhen = currentWhen.split(' '); + } else { + currentWhen = [this._route]; + } + + let { _models: models, _query: query, _routing: routing } = this; + + for (let i = 0; i < currentWhen.length; i++) { + if ( + routing.isActiveForRoute( + models, + query, + currentWhen[i], + routerState, + isCurrentWhenSpecified + ) + ) { + return true; + } + } + + return false; + }, + + transitioningIn: computed( + '_active', + 'willBeActive', + function computeLinkToComponentTransitioningIn(this: any) { + if (this.willBeActive === true && !this._active) { + return 'ember-transitioning-in'; + } else { + return false; + } + } + ), + + transitioningOut: computed( + '_active', + 'willBeActive', + function computeLinkToComponentTransitioningOut(this: any) { + if (this.willBeActive === false && this._active) { + return 'ember-transitioning-out'; + } else { + return false; + } + } + ), + + /** + Event handler that invokes the link, activating the associated route. + + @method _invoke + @param {Event} event + @private + */ + _invoke(this: any, event: Event): boolean { + if (!isSimpleClick(event)) { return true; } - } - return false; - }, + let { bubbles, preventDefault } = this; + let target = this.element.target; + let isSelf = !target || target === '_self'; - /** - Accessed as a classname binding to apply the `LinkComponent`'s `activeClass` - CSS `class` to the element when the link is active. + if (preventDefault !== false && isSelf) { + event.preventDefault(); + } - A `LinkComponent` is considered active when its `currentWhen` property is `true` - or the application's current route is the route the `LinkComponent` would trigger - transitions into. + if (bubbles === false) { + event.stopPropagation(); + } - The `currentWhen` property can match against multiple routes by separating - route names using the ` ` (space) character. + if (this._isDisabled) { + return false; + } - @property active - @private - */ - active: computed('activeClass', '_active', function computeLinkToComponentActiveClass(this: any) { - return this.get('_active') ? get(this, 'activeClass') : false; - }), - - _active: computed('_routing.currentState', 'attrs.params', function computeLinkToComponentActive( - this: any - ) { - let currentState = get(this, '_routing.currentState'); - if (!currentState) { + if (this.loading) { + // tslint:disable-next-line:max-line-length + warn( + 'This link is in an inactive loading state because at least one of its models ' + + 'currently has a null/undefined value, or the provided route name is invalid.', + false, + { + id: 'ember-glimmer.link-to.inactive-loading-state', + } + ); + return false; + } + + if (!isSelf) { + return false; + } + + let { + _route: routeName, + _models: models, + _query: queryParams, + replace: shouldReplace, + } = this; + + let payload = { + queryParams, + routeName, + }; + + flaggedInstrument( + 'interaction.link-to', + payload, + this._generateTransition(payload, routeName, models, queryParams, shouldReplace) + ); return false; - } - return this._isActive(currentState); - }), - - willBeActive: computed('_routing.targetState', function computeLinkToComponentWillBeActive( - this: any - ) { - let routing = this._routing; - let targetState = get(routing, 'targetState'); - if (get(routing, 'currentState') === targetState) { - return; - } - - return this._isActive(targetState); - }), - - transitioningIn: computed( - 'active', - 'willBeActive', - function computeLinkToComponentTransitioningIn(this: any) { - if (get(this, 'willBeActive') === true && !get(this, '_active')) { - return 'ember-transitioning-in'; + }, + + _generateTransition( + payload: any, + qualifiedRouteName: string, + models: any[], + queryParams: any[], + shouldReplace: boolean + ) { + let { _routing: routing } = this; + + return () => { + payload.transition = routing.transitionTo( + qualifiedRouteName, + models, + queryParams, + shouldReplace + ); + }; + }, + + /** + Sets the element's `href` attribute to the url for + the `LinkComponent`'s targeted route. + + If the `LinkComponent`'s `tagName` is changed to a value other + than `a`, this property will be ignored. + + @property href + @private + */ + href: computed( + '_route', + '_models', + '_query', + 'tagName', + 'loading', + 'loadingHref', + function computeLinkToComponentHref(this: any) { + if (this.tagName !== 'a') { + return; + } + + if (this.loading) { + return this.loadingHref; + } + + let { _route: route, _models: models, _query: query, _routing: routing } = this; + + if (DEBUG) { + /* + * Unfortunately, to get decent error messages, we need to do this. + * In some future state we should be able to use a "feature flag" + * which allows us to strip this without needing to call it twice. + * + * if (isDebugBuild()) { + * // Do the useful debug thing, probably including try/catch. + * } else { + * // Do the performant thing. + * } + */ + try { + return routing.generateURL(route, models, query); + } catch (e) { + // tslint:disable-next-line:max-line-length + assert( + `You attempted to generate a link for the "${this.route}" route, but did not ` + + `pass the models required for generating its dynamic segments. ` + + e.message + ); + } + } else { + return routing.generateURL(route, models, query); + } + } + ), + + loading: computed( + '_route', + '_modelsAreLoaded', + 'loadingClass', + function computeLinkToComponentLoading(this: any) { + let { _route: route, _modelsAreLoaded: loaded } = this; + + if (!loaded || route === null || route === undefined) { + return this.loadingClass; + } + } + ), + + _modelsAreLoaded: computed('_models', function computeLinkToComponentModelsAreLoaded( + this: any + ) { + let { _models: models } = this; + + for (let i = 0; i < models.length; i++) { + let model = models[i]; + if (model === null || model === undefined) { + return false; + } + } + + return true; + }), + + /** + The default href value to use while a link-to is loading. + Only applies when tagName is 'a' + + @property loadingHref + @type String + @default # + @private + */ + loadingHref: '#', + + didReceiveAttrs() { + let { disabledWhen } = this; + + if (disabledWhen !== undefined) { + this.set('disabled', disabledWhen); + } + + let { params } = this; + + if (!params || params.length === 0) { + assert( + 'You must provide at least one of the `@route`, `@model`, `@models` or `@query` argument to ``.', + !( + this.route === UNDEFINED && + this.model === UNDEFINED && + this.models === UNDEFINED && + this.query === UNDEFINED + ) + ); + + return; + } + + params = params.slice(); + + // Process the positional arguments, in order. + // 1. Inline link title comes first, if present. + if (!this[HAS_BLOCK]) { + this.set('linkTitle', params.shift()); + } + + // 2. The last argument is possibly the `query` object. + let lastParam = params[params.length - 1]; + + if (lastParam && lastParam.isQueryParams) { + this.set('query', params.pop().values); } else { - return false; + this.set('query', UNDEFINED); } - } - ), - transitioningOut: computed( - 'active', - 'willBeActive', - function computeLinkToComponentTransitioningOut(this: any) { - if (get(this, 'willBeActive') === false && get(this, '_active')) { - return 'ember-transitioning-out'; + // 3. If there is a `route`, it is now at index 0. + if (params.length === 0) { + this.set('route', UNDEFINED); } else { - return false; + this.set('route', params.shift()); } - } - ), + // 4. Any remaining indices (if any) are `models`. + this.set('model', UNDEFINED); + this.set('models', params); + }, + }); + + LinkComponent.toString = () => '@ember/routing/link-component'; + + LinkComponent.reopenClass({ + positionalParams: 'params', + }); +} else { /** - Event handler that invokes the link, activating the associated route. + The `{{link-to}}` component renders a link to the supplied + `routeName` passing an optionally supplied model to the + route as its `model` context of the route. The block + for `{{link-to}}` becomes the innerHTML of the rendered + element: + + ```handlebars + {{#link-to 'photoGallery'}} + Great Hamster Photos + {{/link-to}} + ``` + + You can also use an inline form of `{{link-to}}` component by + passing the link text as the first argument + to the component: + + ```handlebars + {{link-to 'Great Hamster Photos' 'photoGallery'}} + ``` + + Both will result in: + + ```html + + Great Hamster Photos + + ``` + + ### Supplying a tagName + By default `{{link-to}}` renders an `` element. This can + be overridden for a single use of `{{link-to}}` by supplying + a `tagName` option: + + ```handlebars + {{#link-to 'photoGallery' tagName="li"}} + Great Hamster Photos + {{/link-to}} + ``` + + ```html +
  • + Great Hamster Photos +
  • + ``` + + To override this option for your entire application, see + "Overriding Application-wide Defaults". + + ### Disabling the `link-to` component + By default `{{link-to}}` is enabled. + any passed value to the `disabled` component property will disable + the `link-to` component. + + static use: the `disabled` option: + + ```handlebars + {{#link-to 'photoGallery' disabled=true}} + Great Hamster Photos + {{/link-to}} + ``` + + dynamic use: the `disabledWhen` option: + + ```handlebars + {{#link-to 'photoGallery' disabledWhen=controller.someProperty}} + Great Hamster Photos + {{/link-to}} + ``` + + any truthy value passed to `disabled` will disable it except `undefined`. + + See "Overriding Application-wide Defaults" for more. + + ### Handling `href` + `{{link-to}}` will use your application's Router to + fill the element's `href` property with a url that + matches the path to the supplied `routeName` for your + router's configured `Location` scheme, which defaults + to HashLocation. + + ### Handling current route + `{{link-to}}` will apply a CSS class name of 'active' + when the application's current route matches + the supplied routeName. For example, if the application's + current route is 'photoGallery.recent' the following + use of `{{link-to}}`: + + ```handlebars + {{#link-to 'photoGallery.recent'}} + Great Hamster Photos + {{/link-to}} + ``` + + will result in + + ```html +
    + Great Hamster Photos + + ``` + + The CSS class name used for active classes can be customized + for a single use of `{{link-to}}` by passing an `activeClass` + option: + + ```handlebars + {{#link-to 'photoGallery.recent' activeClass="current-url"}} + Great Hamster Photos + {{/link-to}} + ``` + + ```html + + Great Hamster Photos + + ``` + + To override this option for your entire application, see + "Overriding Application-wide Defaults". + + ### Keeping a link active for other routes + + If you need a link to be 'active' even when it doesn't match + the current route, you can use the `current-when` argument. + + ```handlebars + {{#link-to 'photoGallery' current-when='photos'}} + Photo Gallery + {{/link-to}} + ``` + + This may be helpful for keeping links active for: + + * non-nested routes that are logically related + * some secondary menu approaches + * 'top navigation' with 'sub navigation' scenarios + + A link will be active if `current-when` is `true` or the current + route is the route this link would transition to. + + To match multiple routes 'space-separate' the routes: + + ```handlebars + {{#link-to 'gallery' current-when='photos drawings paintings'}} + Art Gallery + {{/link-to}} + ``` - @method _invoke - @param {Event} event - @private + ### Supplying a model + An optional model argument can be used for routes whose + paths contain dynamic segments. This argument will become + the model context of the linked route: + + ```javascript + Router.map(function() { + this.route("photoGallery", {path: "hamster-photos/:photo_id"}); + }); + ``` + + ```handlebars + {{#link-to 'photoGallery' aPhoto}} + {{aPhoto.title}} + {{/link-to}} + ``` + + ```html + + Tomster + + ``` + + ### Supplying multiple models + For deep-linking to route paths that contain multiple + dynamic segments, multiple model arguments can be used. + As the router transitions through the route path, each + supplied model argument will become the context for the + route with the dynamic segments: + + ```javascript + Router.map(function() { + this.route("photoGallery", { path: "hamster-photos/:photo_id" }, function() { + this.route("comment", {path: "comments/:comment_id"}); + }); + }); + ``` + This argument will become the model context of the linked route: + + ```handlebars + {{#link-to 'photoGallery.comment' aPhoto comment}} + {{comment.body}} + {{/link-to}} + ``` + + ```html + + A+++ would snuggle again. + + ``` + + ### Supplying an explicit dynamic segment value + If you don't have a model object available to pass to `{{link-to}}`, + an optional string or integer argument can be passed for routes whose + paths contain dynamic segments. This argument will become the value + of the dynamic segment: + + ```javascript + Router.map(function() { + this.route("photoGallery", { path: "hamster-photos/:photo_id" }); + }); + ``` + + ```handlebars + {{#link-to 'photoGallery' aPhotoId}} + {{aPhoto.title}} + {{/link-to}} + ``` + + ```html + + Tomster + + ``` + + When transitioning into the linked route, the `model` hook will + be triggered with parameters including this passed identifier. + + ### Allowing Default Action + + By default the `{{link-to}}` component prevents the default browser action + by calling `preventDefault()` as this sort of action bubbling is normally + handled internally and we do not want to take the browser to a new URL (for + example). + + If you need to override this behavior specify `preventDefault=false` in + your template: + + ```handlebars + {{#link-to 'photoGallery' aPhotoId preventDefault=false}} + {{aPhotoId.title}} + {{/link-to}} + ``` + + ### Overriding attributes + You can override any given property of the `LinkComponent` + that is generated by the `{{link-to}}` component by passing + key/value pairs, like so: + + ```handlebars + {{#link-to aPhoto tagName='li' title='Following this link will change your life' classNames='pic sweet'}} + Uh-mazing! + {{/link-to}} + ``` + + See [LinkComponent](/api/ember/release/classes/LinkComponent) for a + complete list of overrideable properties. Be sure to also + check out inherited properties of `LinkComponent`. + + ### Overriding Application-wide Defaults + + ``{{link-to}}`` creates an instance of `LinkComponent` for rendering. To + override options for your entire application, export your customized + `LinkComponent` from `app/components/link-to.js` with the desired overrides: + + ```javascript + // app/components/link-to.js + import LinkComponent from '@ember/routing/link-component'; + + export default LinkComponent.extend({ + activeClass: "is-active", + tagName: 'li' + }) + ``` + + It is also possible to override the default event in this manner: + + ```javascript + import LinkComponent from '@ember/routing/link-component'; + + export default LinkComponent.extend({ + eventName: 'customEventName' + }); + ``` + + @method link-to + @for Ember.Templates.helpers + @param {String} routeName + @param {Object} [context]* + @param [options] {Object} Handlebars key/value pairs of options, you can override any property of Ember.LinkComponent + @return {String} HTML string + @see {LinkComponent} + @public */ - _invoke(this: any, event: Event): boolean { - if (!isSimpleClick(event)) { - return true; - } - let preventDefault = get(this, 'preventDefault'); - let targetAttribute = get(this, 'target'); + /** + @module @ember/routing + */ - if (preventDefault !== false && (!targetAttribute || targetAttribute === '_self')) { - event.preventDefault(); - } + /** + `LinkComponent` renders an element whose `click` event triggers a + transition of the application's instance of `Router` to + a supplied route by name. - if (get(this, 'bubbles') === false) { - event.stopPropagation(); - } + `LinkComponent` components are invoked with {{#link-to}}. Properties + of this class can be overridden with `reopen` to customize application-wide + behavior. - if (this._isDisabled) { - return false; - } + @class LinkComponent + @extends Component + @see {Ember.Templates.helpers.link-to} + @public + **/ - if (get(this, 'loading')) { - // tslint:disable-next-line:max-line-length - warn( - 'This link-to is in an inactive loading state because at least one of its parameters presently has a null/undefined value, or the provided route name is invalid.', - false, - { - id: 'ember-glimmer.link-to.inactive-loading-state', + const EMPTY_QUERY_PARAMS = Object.freeze({ values: Object.freeze({}) }); + + LinkComponent = EmberComponent.extend({ + layout, + + tagName: 'a', + + /** + Used to determine when this `LinkComponent` is active. + + @property current-when + @public + */ + 'current-when': null, + + /** + Sets the `title` attribute of the `LinkComponent`'s HTML element. + + @property title + @default null + @public + **/ + title: null, + + /** + Sets the `rel` attribute of the `LinkComponent`'s HTML element. + + @property rel + @default null + @public + **/ + rel: null, + + /** + Sets the `tabindex` attribute of the `LinkComponent`'s HTML element. + + @property tabindex + @default null + @public + **/ + tabindex: null, + + /** + Sets the `target` attribute of the `LinkComponent`'s HTML element. + + @since 1.8.0 + @property target + @default null + @public + **/ + target: null, + + /** + The CSS class to apply to `LinkComponent`'s element when its `active` + property is `true`. + + @property activeClass + @type String + @default active + @public + **/ + activeClass: 'active', + + /** + The CSS class to apply to `LinkComponent`'s element when its `loading` + property is `true`. + + @property loadingClass + @type String + @default loading + @private + **/ + loadingClass: 'loading', + + /** + The CSS class to apply to a `LinkComponent`'s element when its `disabled` + property is `true`. + + @property disabledClass + @type String + @default disabled + @private + **/ + disabledClass: 'disabled', + + /** + Determines whether the `LinkComponent` will trigger routing via + the `replaceWith` routing strategy. + + @property replace + @type Boolean + @default false + @public + **/ + replace: false, + + /** + By default the `{{link-to}}` component will bind to the `href` and + `title` attributes. It's discouraged that you override these defaults, + however you can push onto the array if needed. + + @property attributeBindings + @type Array | String + @default ['title', 'rel', 'tabindex', 'target'] + @public + */ + attributeBindings: ['href', 'title', 'rel', 'tabindex', 'target'], + + /** + By default the `{{link-to}}` component will bind to the `active`, `loading`, + and `disabled` classes. It is discouraged to override these directly. + + @property classNameBindings + @type Array + @default ['active', 'loading', 'disabled', 'ember-transitioning-in', 'ember-transitioning-out'] + @public + */ + classNameBindings: ['active', 'loading', 'disabled', 'transitioningIn', 'transitioningOut'], + + /** + By default the `{{link-to}}` component responds to the `click` event. You + can override this globally by setting this property to your custom + event name. + + This is particularly useful on mobile when one wants to avoid the 300ms + click delay using some sort of custom `tap` event. + + @property eventName + @type String + @default click + @private + */ + eventName: 'click', + + // this is doc'ed here so it shows up in the events + // section of the API documentation, which is where + // people will likely go looking for it. + /** + Triggers the `LinkComponent`'s routing behavior. If + `eventName` is changed to a value other than `click` + the routing behavior will trigger on that custom event + instead. + + @event click + @private + */ + + /** + An overridable method called when `LinkComponent` objects are instantiated. + + Example: + + ```app/components/my-link.js + import LinkComponent from '@ember/routing/link-component'; + + export default LinkComponent.extend({ + init() { + this._super(...arguments); + console.log('Event is ' + this.get('eventName')); } - ); - return false; - } + }); + ``` + + NOTE: If you do override `init` for a framework class like `Component`, + be sure to call `this._super(...arguments)` in your + `init` declaration! If you don't, Ember may not have an opportunity to + do important setup work, and you'll see strange behavior in your + application. + + @method init + @private + */ + init() { + this._super(...arguments); + + // Map desired event name to invoke function + let eventName = get(this, 'eventName'); + this.on(eventName, this, this._invoke); + }, + + _routing: injectService('-routing'), + + /** + Accessed as a classname binding to apply the `LinkComponent`'s `disabledClass` + CSS `class` to the element when the link is disabled. + + When `true` interactions with the element will not trigger route changes. + @property disabled + @private + */ + disabled: computed({ + get(_key: string): boolean { + // always returns false for `get` because (due to the `set` just below) + // the cached return value from the set will prevent this getter from _ever_ + // being called after a set has occured + return false; + }, + + set(_key: string, value: any): boolean { + (this as any)._isDisabled = value; + + return value ? get(this, 'disabledClass') : false; + }, + }), + + _isActive(routerState: any) { + if (get(this, 'loading')) { + return false; + } + + let currentWhen = get(this, 'current-when'); + + if (typeof currentWhen === 'boolean') { + return currentWhen; + } + + let isCurrentWhenSpecified = Boolean(currentWhen); + currentWhen = currentWhen || get(this, 'qualifiedRouteName'); + currentWhen = currentWhen.split(' '); + + let routing = this._routing; + let models = get(this, 'models'); + let resolvedQueryParams = get(this, 'resolvedQueryParams'); + + for (let i = 0; i < currentWhen.length; i++) { + if ( + routing.isActiveForRoute( + models, + resolvedQueryParams, + currentWhen[i], + routerState, + isCurrentWhenSpecified + ) + ) { + return true; + } + } - if (targetAttribute && targetAttribute !== '_self') { return false; - } - - let qualifiedRouteName = get(this, 'qualifiedRouteName'); - let models = get(this, 'models'); - let queryParams = get(this, 'queryParams.values'); - let shouldReplace = get(this, 'replace'); - - let payload = { - queryParams, - routeName: qualifiedRouteName, - }; - - // tslint:disable-next-line:max-line-length - flaggedInstrument( - 'interaction.link-to', - payload, - this._generateTransition(payload, qualifiedRouteName, models, queryParams, shouldReplace) - ); - return false; - }, - - _generateTransition( - payload: any, - qualifiedRouteName: string, - models: any[], - queryParams: any[], - shouldReplace: boolean - ) { - let routing = this._routing; - return () => { - payload.transition = routing.transitionTo( - qualifiedRouteName, - models, - queryParams, - shouldReplace - ); - }; - }, + }, - queryParams: EMPTY_QUERY_PARAMS, + /** + Accessed as a classname binding to apply the `LinkComponent`'s `activeClass` + CSS `class` to the element when the link is active. + + A `LinkComponent` is considered active when its `currentWhen` property is `true` + or the application's current route is the route the `LinkComponent` would trigger + transitions into. + + The `currentWhen` property can match against multiple routes by separating + route names using the ` ` (space) character. + + @property active + @private + */ + active: computed('activeClass', '_active', function computeLinkToComponentActiveClass( + this: any + ) { + return this.get('_active') ? get(this, 'activeClass') : false; + }), + + _active: computed( + '_routing.currentState', + 'attrs.params', + function computeLinkToComponentActive(this: any) { + let currentState = get(this, '_routing.currentState'); + if (!currentState) { + return false; + } + return this._isActive(currentState); + } + ), + + willBeActive: computed('_routing.targetState', function computeLinkToComponentWillBeActive( + this: any + ) { + let routing = this._routing; + let targetState = get(routing, 'targetState'); + if (get(routing, 'currentState') === targetState) { + return; + } - qualifiedRouteName: computed( - 'targetRouteName', - '_routing.currentState', - function computeLinkToComponentQualifiedRouteName(this: any) { - let params = get(this, 'params'); - let paramsLength = params.length; - let lastParam = params[paramsLength - 1]; - if (lastParam && lastParam.isQueryParams) { - paramsLength--; + return this._isActive(targetState); + }), + + transitioningIn: computed( + 'active', + 'willBeActive', + function computeLinkToComponentTransitioningIn(this: any) { + if (get(this, 'willBeActive') === true && !get(this, '_active')) { + return 'ember-transitioning-in'; + } else { + return false; + } } - let onlyQueryParamsSupplied = this[HAS_BLOCK] ? paramsLength === 0 : paramsLength === 1; - if (onlyQueryParamsSupplied) { - return get(this, '_routing.currentRouteName'); + ), + + transitioningOut: computed( + 'active', + 'willBeActive', + function computeLinkToComponentTransitioningOut(this: any) { + if (get(this, 'willBeActive') === false && get(this, '_active')) { + return 'ember-transitioning-out'; + } else { + return false; + } } - return get(this, 'targetRouteName'); - } - ), + ), - resolvedQueryParams: computed('queryParams', function computeLinkToComponentResolvedQueryParams( - this: any - ) { - let resolvedQueryParams = {}; - let queryParams = get(this, 'queryParams'); + /** + Event handler that invokes the link, activating the associated route. - if (queryParams !== EMPTY_QUERY_PARAMS) { - let { values } = queryParams; - assign(resolvedQueryParams, values); - } + @method _invoke + @param {Event} event + @private + */ + _invoke(this: any, event: Event): boolean { + if (!isSimpleClick(event)) { + return true; + } - return resolvedQueryParams; - }), + let preventDefault = get(this, 'preventDefault'); + let targetAttribute = get(this, 'target'); - /** - Sets the element's `href` attribute to the url for - the `LinkComponent`'s targeted route. + if (preventDefault !== false && (!targetAttribute || targetAttribute === '_self')) { + event.preventDefault(); + } - If the `LinkComponent`'s `tagName` is changed to a value other - than `a`, this property will be ignored. + if (get(this, 'bubbles') === false) { + event.stopPropagation(); + } - @property href - @private - */ - href: computed('models', 'qualifiedRouteName', function computeLinkToComponentHref(this: any) { - if (get(this, 'tagName') !== 'a') { - return; - } - - let qualifiedRouteName = get(this, 'qualifiedRouteName'); - let models = get(this, 'models'); - - if (get(this, 'loading')) { - return get(this, 'loadingHref'); - } - - let routing = this._routing; - let queryParams = get(this, 'queryParams.values'); - - if (DEBUG) { - /* - * Unfortunately, to get decent error messages, we need to do this. - * In some future state we should be able to use a "feature flag" - * which allows us to strip this without needing to call it twice. - * - * if (isDebugBuild()) { - * // Do the useful debug thing, probably including try/catch. - * } else { - * // Do the performant thing. - * } - */ - try { - routing.generateURL(qualifiedRouteName, models, queryParams); - } catch (e) { + if (this._isDisabled) { + return false; + } + + if (get(this, 'loading')) { // tslint:disable-next-line:max-line-length - assert( - 'You attempted to define a `{{link-to "' + - qualifiedRouteName + - '"}}` but did not pass the parameters required for generating its dynamic segments. ' + - e.message + warn( + 'This link-to is in an inactive loading state because at least one of its parameters presently has a null/undefined value, or the provided route name is invalid.', + false, + { + id: 'ember-glimmer.link-to.inactive-loading-state', + } + ); + return false; + } + + if (targetAttribute && targetAttribute !== '_self') { + return false; + } + + let qualifiedRouteName = get(this, 'qualifiedRouteName'); + let models = get(this, 'models'); + let queryParams = get(this, 'queryParams.values'); + let shouldReplace = get(this, 'replace'); + + let payload = { + queryParams, + routeName: qualifiedRouteName, + }; + + // tslint:disable-next-line:max-line-length + flaggedInstrument( + 'interaction.link-to', + payload, + this._generateTransition(payload, qualifiedRouteName, models, queryParams, shouldReplace) + ); + return false; + }, + + _generateTransition( + payload: any, + qualifiedRouteName: string, + models: any[], + queryParams: any[], + shouldReplace: boolean + ) { + let routing = this._routing; + return () => { + payload.transition = routing.transitionTo( + qualifiedRouteName, + models, + queryParams, + shouldReplace ); + }; + }, + + queryParams: EMPTY_QUERY_PARAMS, + + qualifiedRouteName: computed( + 'targetRouteName', + '_routing.currentState', + function computeLinkToComponentQualifiedRouteName(this: any) { + let params = get(this, 'params'); + let paramsLength = params.length; + let lastParam = params[paramsLength - 1]; + if (lastParam && lastParam.isQueryParams) { + paramsLength--; + } + let onlyQueryParamsSupplied = this[HAS_BLOCK] ? paramsLength === 0 : paramsLength === 1; + if (onlyQueryParamsSupplied) { + return get(this, '_routing.currentRouteName'); + } + return get(this, 'targetRouteName'); + } + ), + + resolvedQueryParams: computed('queryParams', function computeLinkToComponentResolvedQueryParams( + this: any + ) { + let resolvedQueryParams = {}; + let queryParams = get(this, 'queryParams'); + + if (queryParams !== EMPTY_QUERY_PARAMS) { + let { values } = queryParams; + assign(resolvedQueryParams, values); } - } - return routing.generateURL(qualifiedRouteName, models, queryParams); - }), + return resolvedQueryParams; + }), + + /** + Sets the element's `href` attribute to the url for + the `LinkComponent`'s targeted route. + + If the `LinkComponent`'s `tagName` is changed to a value other + than `a`, this property will be ignored. + + @property href + @private + */ + href: computed('models', 'qualifiedRouteName', function computeLinkToComponentHref(this: any) { + if (get(this, 'tagName') !== 'a') { + return; + } - loading: computed( - '_modelsAreLoaded', - 'qualifiedRouteName', - function computeLinkToComponentLoading(this: any) { let qualifiedRouteName = get(this, 'qualifiedRouteName'); - let modelsAreLoaded = get(this, '_modelsAreLoaded'); + let models = get(this, 'models'); - if (!modelsAreLoaded || qualifiedRouteName === null || qualifiedRouteName === undefined) { - return get(this, 'loadingClass'); + if (get(this, 'loading')) { + return get(this, 'loadingHref'); } - } - ), - _modelsAreLoaded: computed('models', function computeLinkToComponentModelsAreLoaded(this: any) { - let models = get(this, 'models'); - for (let i = 0; i < models.length; i++) { - let model = models[i]; - if (model === null || model === undefined) { - return false; + let routing = this._routing; + let queryParams = get(this, 'queryParams.values'); + + if (DEBUG) { + /* + * Unfortunately, to get decent error messages, we need to do this. + * In some future state we should be able to use a "feature flag" + * which allows us to strip this without needing to call it twice. + * + * if (isDebugBuild()) { + * // Do the useful debug thing, probably including try/catch. + * } else { + * // Do the performant thing. + * } + */ + try { + routing.generateURL(qualifiedRouteName, models, queryParams); + } catch (e) { + // tslint:disable-next-line:max-line-length + assert( + 'You attempted to define a `{{link-to "' + + qualifiedRouteName + + '"}}` but did not pass the parameters required for generating its dynamic segments. ' + + e.message + ); + } } - } - return true; - }), + return routing.generateURL(qualifiedRouteName, models, queryParams); + }), - /** - The default href value to use while a link-to is loading. - Only applies when tagName is 'a' + loading: computed( + '_modelsAreLoaded', + 'qualifiedRouteName', + function computeLinkToComponentLoading(this: any) { + let qualifiedRouteName = get(this, 'qualifiedRouteName'); + let modelsAreLoaded = get(this, '_modelsAreLoaded'); - @property loadingHref - @type String - @default # - @private - */ - loadingHref: '#', + if (!modelsAreLoaded || qualifiedRouteName === null || qualifiedRouteName === undefined) { + return get(this, 'loadingClass'); + } + } + ), + + _modelsAreLoaded: computed('models', function computeLinkToComponentModelsAreLoaded(this: any) { + let models = get(this, 'models'); + for (let i = 0; i < models.length; i++) { + let model = models[i]; + if (model === null || model === undefined) { + return false; + } + } - didReceiveAttrs() { - let queryParams; - let params = get(this, 'params'); + return true; + }), - if (params) { - // Do not mutate params in place - params = params.slice(); - } - - assert( - 'You must provide one or more parameters to the link-to component.', - params && params.length - ); - - let disabledWhen = get(this, 'disabledWhen'); - if (disabledWhen !== undefined) { - this.set('disabled', disabledWhen); - } - - // Process the positional arguments, in order. - // 1. Inline link title comes first, if present. - if (!this[HAS_BLOCK]) { - this.set('linkTitle', params.shift()); - } - - // 2. `targetRouteName` is now always at index 0. - this.set('targetRouteName', params[0]); - - // 3. The last argument (if still remaining) is the `queryParams` object. - let lastParam = params[params.length - 1]; - - if (lastParam && lastParam.isQueryParams) { - queryParams = params.pop(); - } else { - queryParams = EMPTY_QUERY_PARAMS; - } - this.set('queryParams', queryParams); - - // 4. Any remaining indices (excepting `targetRouteName` at 0) are `models`. - params.shift(); - this.set('models', params); - }, -}); - -LinkComponent.toString = () => '@ember/routing/link-component'; - -LinkComponent.reopenClass({ - positionalParams: 'params', -}); + /** + The default href value to use while a link-to is loading. + Only applies when tagName is 'a' + + @property loadingHref + @type String + @default # + @private + */ + loadingHref: '#', + + didReceiveAttrs() { + let queryParams; + let params = get(this, 'params'); + + if (params) { + // Do not mutate params in place + params = params.slice(); + } + + assert( + 'You must provide one or more parameters to the link-to component.', + params && params.length + ); + + let disabledWhen = get(this, 'disabledWhen'); + if (disabledWhen !== undefined) { + this.set('disabled', disabledWhen); + } + + // Process the positional arguments, in order. + // 1. Inline link title comes first, if present. + if (!this[HAS_BLOCK]) { + this.set('linkTitle', params.shift()); + } + + // 2. `targetRouteName` is now always at index 0. + this.set('targetRouteName', params[0]); + + // 3. The last argument (if still remaining) is the `queryParams` object. + let lastParam = params[params.length - 1]; + + if (lastParam && lastParam.isQueryParams) { + queryParams = params.pop(); + } else { + queryParams = EMPTY_QUERY_PARAMS; + } + this.set('queryParams', queryParams); + + // 4. Any remaining indices (excepting `targetRouteName` at 0) are `models`. + params.shift(); + this.set('models', params); + }, + }); + + LinkComponent.toString = () => '@ember/routing/link-component'; + + LinkComponent.reopenClass({ + positionalParams: 'params', + }); +} export default LinkComponent; diff --git a/packages/@ember/-internals/glimmer/lib/templates/link-to.hbs b/packages/@ember/-internals/glimmer/lib/templates/link-to.hbs index 5f543159a3f..e67d811c27e 100644 --- a/packages/@ember/-internals/glimmer/lib/templates/link-to.hbs +++ b/packages/@ember/-internals/glimmer/lib/templates/link-to.hbs @@ -1 +1,5 @@ -{{#if linkTitle}}{{linkTitle}}{{else}}{{yield}}{{/if}} \ No newline at end of file +{{~#if (has-block)~}} + {{yield}} +{{~else~}} + {{this.linkTitle}} +{{~/if~}} diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-angle-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-angle-test.js new file mode 100644 index 00000000000..c89d83b6f58 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-angle-test.js @@ -0,0 +1,746 @@ +import { EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS } from '@ember/canary-features'; +import Controller from '@ember/controller'; +import { RSVP } from '@ember/-internals/runtime'; +import { Route } from '@ember/-internals/routing'; +import { + ApplicationTestCase, + classes as classMatcher, + moduleFor, + runTask, +} from 'internal-test-helpers'; + +if (EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS) { + moduleFor( + ' component with query-params (rendering)', + class extends ApplicationTestCase { + constructor() { + super(...arguments); + + this.add( + 'controller:index', + Controller.extend({ + queryParams: ['foo'], + foo: '123', + bar: 'yes', + }) + ); + } + + ['@test populates href with fully supplied query param values']() { + this.addTemplate( + 'index', + `Index` + ); + + return this.visit('/').then(() => { + this.assertComponentElement(this.firstChild, { + tagName: 'a', + attrs: { href: '/?bar=NAW&foo=456' }, + content: 'Index', + }); + }); + } + + ['@test populates href with partially supplied query param values, but omits if value is default value']() { + this.addTemplate( + 'index', + `Index` + ); + + return this.visit('/').then(() => { + this.assertComponentElement(this.firstChild, { + tagName: 'a', + attrs: { href: '/', class: classMatcher('ember-view active') }, + content: 'Index', + }); + }); + } + } + ); + + moduleFor( + ' component with query params (routing)', + class extends ApplicationTestCase { + constructor() { + super(); + let indexProperties = { + foo: '123', + bar: 'abc', + }; + this.add( + 'controller:index', + Controller.extend({ + queryParams: ['foo', 'bar', 'abool'], + foo: indexProperties.foo, + bar: indexProperties.bar, + boundThing: 'OMG', + abool: true, + }) + ); + this.add( + 'controller:about', + Controller.extend({ + queryParams: ['baz', 'bat'], + baz: 'alex', + bat: 'borf', + }) + ); + this.indexProperties = indexProperties; + } + + shouldNotBeActive(assert, selector) { + this.checkActive(assert, selector, false); + } + + shouldBeActive(assert, selector) { + this.checkActive(assert, selector, true); + } + + getController(name) { + return this.applicationInstance.lookup(`controller:${name}`); + } + + checkActive(assert, selector, active) { + let classList = this.$(selector)[0].className; + assert.equal( + classList.indexOf('active') > -1, + active, + selector + ' active should be ' + active.toString() + ); + } + + [`@test doesn't update controller QP properties on current route when invoked`](assert) { + this.addTemplate('index', `Index`); + + return this.visit('/').then(() => { + this.click('#the-link'); + let indexController = this.getController('index'); + + assert.deepEqual( + indexController.getProperties('foo', 'bar'), + this.indexProperties, + 'controller QP properties do not update' + ); + }); + } + + [`@test doesn't update controller QP properties on current route when invoked (empty query-params obj)`]( + assert + ) { + this.addTemplate( + 'index', + `Index` + ); + + return this.visit('/').then(() => { + this.click('#the-link'); + let indexController = this.getController('index'); + + assert.deepEqual( + indexController.getProperties('foo', 'bar'), + this.indexProperties, + 'controller QP properties do not update' + ); + }); + } + + [`@test doesn't update controller QP properties on current route when invoked (empty query-params obj, inferred route)`]( + assert + ) { + this.addTemplate('index', `Index`); + + return this.visit('/').then(() => { + this.click('#the-link'); + let indexController = this.getController('index'); + + assert.deepEqual( + indexController.getProperties('foo', 'bar'), + this.indexProperties, + 'controller QP properties do not update' + ); + }); + } + + ['@test updates controller QP properties on current route when invoked'](assert) { + this.addTemplate( + 'index', + ` + + Index + + ` + ); + + return this.visit('/').then(() => { + this.click('#the-link'); + let indexController = this.getController('index'); + + assert.deepEqual( + indexController.getProperties('foo', 'bar'), + { foo: '456', bar: 'abc' }, + 'controller QP properties updated' + ); + }); + } + + ['@test updates controller QP properties on current route when invoked (inferred route)']( + assert + ) { + this.addTemplate( + 'index', + ` + + Index + + ` + ); + + return this.visit('/').then(() => { + this.click('#the-link'); + let indexController = this.getController('index'); + + assert.deepEqual( + indexController.getProperties('foo', 'bar'), + { foo: '456', bar: 'abc' }, + 'controller QP properties updated' + ); + }); + } + + ['@test updates controller QP properties on other route after transitioning to that route']( + assert + ) { + this.router.map(function() { + this.route('about'); + }); + + this.addTemplate( + 'index', + ` + + About + + ` + ); + + return this.visit('/').then(() => { + let theLink = this.$('#the-link'); + assert.equal(theLink.attr('href'), '/about?baz=lol'); + + runTask(() => this.click('#the-link')); + + let aboutController = this.getController('about'); + + assert.deepEqual( + aboutController.getProperties('baz', 'bat'), + { baz: 'lol', bat: 'borf' }, + 'about controller QP properties updated' + ); + }); + } + + ['@test supplied QP properties can be bound'](assert) { + this.addTemplate( + 'index', + ` + + Index + + ` + ); + + return this.visit('/').then(() => { + let indexController = this.getController('index'); + let theLink = this.$('#the-link'); + + assert.equal(theLink.attr('href'), '/?foo=OMG'); + + runTask(() => indexController.set('boundThing', 'ASL')); + + assert.equal(theLink.attr('href'), '/?foo=ASL'); + }); + } + + ['@test supplied QP properties can be bound (booleans)'](assert) { + this.addTemplate( + 'index', + ` + + Index + + ` + ); + + return this.visit('/').then(() => { + let indexController = this.getController('index'); + let theLink = this.$('#the-link'); + + assert.equal(theLink.attr('href'), '/?abool=OMG'); + + runTask(() => indexController.set('boundThing', false)); + + assert.equal(theLink.attr('href'), '/?abool=false'); + + this.click('#the-link'); + + assert.deepEqual( + indexController.getProperties('foo', 'bar', 'abool'), + { foo: '123', bar: 'abc', abool: false }, + 'bound bool QP properties update' + ); + }); + } + ['@test href updates when unsupplied controller QP props change'](assert) { + this.addTemplate( + 'index', + ` + + Index + + ` + ); + + return this.visit('/').then(() => { + let indexController = this.getController('index'); + let theLink = this.$('#the-link'); + + assert.equal(theLink.attr('href'), '/?foo=lol'); + + runTask(() => indexController.set('bar', 'BORF')); + + assert.equal(theLink.attr('href'), '/?bar=BORF&foo=lol'); + + runTask(() => indexController.set('foo', 'YEAH')); + + assert.equal(theLink.attr('href'), '/?bar=BORF&foo=lol'); + }); + } + + ['@test The component with only query params always transitions to the current route with the query params applied']( + assert + ) { + // Test harness for bug #12033 + this.addTemplate( + 'cars', + ` + Create new car + Page 2 + {{outlet}} + ` + ); + + this.addTemplate( + 'cars.create', + `Close create form` + ); + + this.router.map(function() { + this.route('cars', function() { + this.route('create'); + }); + }); + + this.add( + 'controller:cars', + Controller.extend({ + queryParams: ['page'], + page: 1, + }) + ); + + return this.visit('/cars/create').then(() => { + let router = this.appRouter; + let carsController = this.getController('cars'); + + assert.equal(router.currentRouteName, 'cars.create'); + + runTask(() => this.click('#close-link')); + + assert.equal(router.currentRouteName, 'cars.index'); + assert.equal(router.get('url'), '/cars'); + assert.equal(carsController.get('page'), 1, 'The page query-param is 1'); + + runTask(() => this.click('#page2-link')); + + assert.equal(router.currentRouteName, 'cars.index', 'The active route is still cars'); + assert.equal(router.get('url'), '/cars?page=2', 'The url has been updated'); + assert.equal(carsController.get('page'), 2, 'The query params have been updated'); + }); + } + + ['@test the component applies activeClass when query params are not changed']( + assert + ) { + this.addTemplate( + 'index', + ` + Index + Index + Index + ` + ); + + this.addTemplate( + 'search', + ` + Index + Index + Index + Index + Index + Index + Index + {{outlet}} + ` + ); + + this.addTemplate( + 'search.results', + ` + Index + Index + Index + Index + Index + Index + Index + ` + ); + + this.router.map(function() { + this.route('search', function() { + this.route('results'); + }); + }); + + this.add( + 'controller:search', + Controller.extend({ + queryParams: ['search', 'archive'], + search: '', + archive: false, + }) + ); + + this.add( + 'controller:search.results', + Controller.extend({ + queryParams: ['sort', 'showDetails'], + sort: 'title', + showDetails: true, + }) + ); + + return this.visit('/') + .then(() => { + this.shouldNotBeActive(assert, '#cat-link'); + this.shouldNotBeActive(assert, '#dog-link'); + + return this.visit('/?foo=cat'); + }) + .then(() => { + this.shouldBeActive(assert, '#cat-link'); + this.shouldNotBeActive(assert, '#dog-link'); + + return this.visit('/?foo=dog'); + }) + .then(() => { + this.shouldBeActive(assert, '#dog-link'); + this.shouldNotBeActive(assert, '#cat-link'); + this.shouldBeActive(assert, '#change-nothing'); + + return this.visit('/search?search=same'); + }) + .then(() => { + this.shouldBeActive(assert, '#same-search'); + this.shouldNotBeActive(assert, '#change-search'); + this.shouldNotBeActive(assert, '#same-search-add-archive'); + this.shouldNotBeActive(assert, '#only-add-archive'); + this.shouldNotBeActive(assert, '#remove-one'); + + return this.visit('/search?search=same&archive=true'); + }) + .then(() => { + this.shouldBeActive(assert, '#both-same'); + this.shouldNotBeActive(assert, '#change-one'); + + return this.visit('/search/results?search=same&sort=title&showDetails=true'); + }) + .then(() => { + this.shouldBeActive(assert, '#same-sort-child-only'); + this.shouldBeActive(assert, '#same-search-parent-only'); + this.shouldNotBeActive(assert, '#change-search-parent-only'); + this.shouldBeActive(assert, '#same-search-same-sort-child-and-parent'); + this.shouldNotBeActive(assert, '#same-search-different-sort-child-and-parent'); + this.shouldNotBeActive(assert, '#change-search-same-sort-child-and-parent'); + }); + } + + ['@test the component applies active class when query-param is a number'](assert) { + this.addTemplate( + 'index', + ` + + Index + + ` + ); + + this.add( + 'controller:index', + Controller.extend({ + queryParams: ['page'], + page: 1, + pageNumber: 5, + }) + ); + + return this.visit('/') + .then(() => { + this.shouldNotBeActive(assert, '#page-link'); + return this.visit('/?page=5'); + }) + .then(() => { + this.shouldBeActive(assert, '#page-link'); + }); + } + + ['@test the component applies active class when query-param is an array'](assert) { + this.addTemplate( + 'index', + ` + Index + Index + Index + ` + ); + + this.add( + 'controller:index', + Controller.extend({ + queryParams: ['pages'], + pages: [], + pagesArray: [1, 2], + biggerArray: [1, 2, 3], + emptyArray: [], + }) + ); + + return this.visit('/') + .then(() => { + this.shouldNotBeActive(assert, '#array-link'); + + return this.visit('/?pages=%5B1%2C2%5D'); + }) + .then(() => { + this.shouldBeActive(assert, '#array-link'); + this.shouldNotBeActive(assert, '#bigger-link'); + this.shouldNotBeActive(assert, '#empty-link'); + + return this.visit('/?pages=%5B2%2C1%5D'); + }) + .then(() => { + this.shouldNotBeActive(assert, '#array-link'); + this.shouldNotBeActive(assert, '#bigger-link'); + this.shouldNotBeActive(assert, '#empty-link'); + + return this.visit('/?pages=%5B1%2C2%2C3%5D'); + }) + .then(() => { + this.shouldBeActive(assert, '#bigger-link'); + this.shouldNotBeActive(assert, '#array-link'); + this.shouldNotBeActive(assert, '#empty-link'); + }); + } + ['@test the component applies active class to the parent route'](assert) { + this.router.map(function() { + this.route('parent', function() { + this.route('child'); + }); + }); + + this.addTemplate( + 'application', + ` + Parent + Child + Parent + {{outlet}} + ` + ); + + this.add( + 'controller:parent.child', + Controller.extend({ + queryParams: ['foo'], + foo: 'bar', + }) + ); + + return this.visit('/') + .then(() => { + this.shouldNotBeActive(assert, '#parent-link'); + this.shouldNotBeActive(assert, '#parent-child-link'); + this.shouldNotBeActive(assert, '#parent-link-qp'); + return this.visit('/parent/child?foo=dog'); + }) + .then(() => { + this.shouldBeActive(assert, '#parent-link'); + this.shouldNotBeActive(assert, '#parent-link-qp'); + }); + } + + ['@test The component disregards query-params in activeness computation when current-when is specified']( + assert + ) { + let appLink; + + this.router.map(function() { + this.route('parent'); + }); + + this.addTemplate( + 'application', + ` + + Parent + + {{outlet}} + ` + ); + + this.addTemplate( + 'parent', + ` + + Parent + + {{outlet}} + ` + ); + + this.add( + 'controller:parent', + Controller.extend({ + queryParams: ['page'], + page: 1, + }) + ); + + return this.visit('/') + .then(() => { + appLink = this.$('#app-link'); + + assert.equal(appLink.attr('href'), '/parent'); + this.shouldNotBeActive(assert, '#app-link'); + + return this.visit('/parent?page=2'); + }) + .then(() => { + appLink = this.$('#app-link'); + let router = this.appRouter; + + assert.equal(appLink.attr('href'), '/parent'); + this.shouldBeActive(assert, '#app-link'); + assert.equal(this.$('#parent-link').attr('href'), '/parent'); + this.shouldBeActive(assert, '#parent-link'); + + let parentController = this.getController('parent'); + + assert.equal(parentController.get('page'), 2); + + runTask(() => parentController.set('page', 3)); + + assert.equal(router.get('location.path'), '/parent?page=3'); + this.shouldBeActive(assert, '#app-link'); + this.shouldBeActive(assert, '#parent-link'); + + runTask(() => this.click('#app-link')); + + assert.equal(router.get('location.path'), '/parent'); + }); + } + + ['@test the component default query params while in active transition regression test']( + assert + ) { + this.router.map(function() { + this.route('foos'); + this.route('bars'); + }); + + let foos = RSVP.defer(); + let bars = RSVP.defer(); + + this.addTemplate( + 'application', + ` + Foos + Baz Foos + Quux Bars + ` + ); + + this.add( + 'controller:foos', + Controller.extend({ + queryParams: ['status'], + baz: false, + }) + ); + + this.add( + 'route:foos', + Route.extend({ + model() { + return foos.promise; + }, + }) + ); + + this.add( + 'controller:bars', + Controller.extend({ + queryParams: ['status'], + quux: false, + }) + ); + + this.add( + 'route:bars', + Route.extend({ + model() { + return bars.promise; + }, + }) + ); + + return this.visit('/').then(() => { + let router = this.appRouter; + let foosLink = this.$('#foos-link'); + let barsLink = this.$('#bars-link'); + let bazLink = this.$('#baz-foos-link'); + + assert.equal(foosLink.attr('href'), '/foos'); + assert.equal(bazLink.attr('href'), '/foos?baz=true'); + assert.equal(barsLink.attr('href'), '/bars?quux=true'); + assert.equal(router.get('location.path'), '/'); + this.shouldNotBeActive(assert, '#foos-link'); + this.shouldNotBeActive(assert, '#baz-foos-link'); + this.shouldNotBeActive(assert, '#bars-link'); + + runTask(() => barsLink.click()); + this.shouldNotBeActive(assert, '#bars-link'); + + runTask(() => foosLink.click()); + this.shouldNotBeActive(assert, '#foos-link'); + + runTask(() => foos.resolve()); + + assert.equal(router.get('location.path'), '/foos'); + this.shouldBeActive(assert, '#foos-link'); + }); + } + } + ); +} diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-curly-test.js similarity index 98% rename from packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-test.js rename to packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-curly-test.js index b1f5f6e4314..c7842f5927a 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/query-params-curly-test.js @@ -5,7 +5,6 @@ import { ApplicationTestCase, classes as classMatcher, moduleFor, - runLoopSettled, runTask, } from 'internal-test-helpers'; @@ -725,17 +724,5 @@ moduleFor( this.shouldBeActive(assert, '#foos-link'); }); } - - [`@test the {{link-to}} component throws a useful error if you invoke it wrong`](assert) { - assert.expect(1); - - this.addTemplate('application', `{{#link-to id='the-link'}}Index{{/link-to}}`); - - expectAssertion(() => { - this.visit('/'); - }, /You must provide one or more parameters to the link-to component/); - - return runLoopSettled(); - } } ); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/rendering-angle-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/rendering-angle-test.js new file mode 100644 index 00000000000..48b9ab05227 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/rendering-angle-test.js @@ -0,0 +1,111 @@ +import { moduleFor, ApplicationTestCase, RenderingTestCase, runTask } from 'internal-test-helpers'; + +import { EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS } from '@ember/canary-features'; +import Controller from '@ember/controller'; +import { set } from '@ember/-internals/metal'; +import { LinkComponent } from '@ember/-internals/glimmer'; + +if (EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS) { + moduleFor( + ' component (rendering tests)', + class extends ApplicationTestCase { + [`@test throws a useful error if you invoke it wrong`](assert) { + assert.expect(1); + + this.addTemplate('application', `Index`); + + expectAssertion(() => { + this.visit('/'); + }, /You must provide at least one of the `@route`, `@model`, `@models` or `@query` argument to ``/); + } + + ['@test should be able to be inserted in DOM when the router is not present']() { + this.addTemplate('application', `Go to Index`); + + return this.visit('/').then(() => { + this.assertText('Go to Index'); + }); + } + + ['@test re-renders when title changes']() { + let controller; + + this.addTemplate('application', `{{title}}`); + + this.add( + 'controller:application', + Controller.extend({ + init() { + this._super(...arguments); + controller = this; + }, + title: 'foo', + }) + ); + + return this.visit('/').then(() => { + this.assertText('foo'); + runTask(() => set(controller, 'title', 'bar')); + this.assertText('bar'); + }); + } + + ['@test re-computes active class when params change'](assert) { + let controller; + + this.addTemplate('application', 'foo'); + + this.add( + 'controller:application', + Controller.extend({ + init() { + this._super(...arguments); + controller = this; + }, + routeName: 'index', + }) + ); + + this.router.map(function() { + this.route('bar', { path: '/bar' }); + }); + + return this.visit('/bar').then(() => { + assert.equal(this.firstChild.classList.contains('active'), false); + runTask(() => set(controller, 'routeName', 'bar')); + assert.equal(this.firstChild.classList.contains('active'), true); + }); + } + + ['@test able to safely extend the built-in component and use the normal path']() { + this.addComponent('custom-link-to', { + ComponentClass: LinkComponent.extend(), + }); + + this.addTemplate('application', `{{title}}`); + + this.add( + 'controller:application', + Controller.extend({ + title: 'Hello', + }) + ); + + return this.visit('/').then(() => { + this.assertText('Hello'); + }); + } + } + ); + + moduleFor( + ' component (rendering tests, without router)', + class extends RenderingTestCase { + ['@test should be able to be inserted in DOM when the router is not present - block']() { + this.render(`Go to Index`); + + this.assertText('Go to Index'); + } + } + ); +} diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/rendering-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/rendering-curly-test.js similarity index 84% rename from packages/@ember/-internals/glimmer/tests/integration/components/link-to/rendering-test.js rename to packages/@ember/-internals/glimmer/tests/integration/components/link-to/rendering-curly-test.js index 3be9729f188..6cdf926401e 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/rendering-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/rendering-curly-test.js @@ -7,6 +7,30 @@ import { LinkComponent } from '@ember/-internals/glimmer'; moduleFor( '{{link-to}} component (rendering tests)', class extends ApplicationTestCase { + [`@feature(ember-glimmer-angle-bracket-built-ins) throws a useful error if you invoke it wrong`]( + assert + ) { + assert.expect(1); + + this.addTemplate('application', `{{#link-to id='the-link'}}Index{{/link-to}}`); + + expectAssertion(() => { + this.visit('/'); + }, /You must provide at least one of the `@route`, `@model`, `@models` or `@query` argument to ``/); + } + + [`@feature(!ember-glimmer-angle-bracket-built-ins) throws a useful error if you invoke it wrong`]( + assert + ) { + assert.expect(1); + + this.addTemplate('application', `{{#link-to id='the-link'}}Index{{/link-to}}`); + + expectAssertion(() => { + this.visit('/'); + }, /You must provide one or more parameters to the link-to component/); + } + ['@test should be able to be inserted in DOM when the router is not present']() { this.addTemplate('application', `{{#link-to 'index'}}Go to Index{{/link-to}}`); @@ -18,7 +42,8 @@ moduleFor( ['@test re-renders when title changes']() { let controller; - this.addTemplate('application', '{{link-to title routeName}}'); + this.addTemplate('application', `{{link-to title 'index'}}`); + this.add( 'controller:application', Controller.extend({ @@ -27,7 +52,6 @@ moduleFor( controller = this; }, title: 'foo', - routeName: 'index', }) ); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-angle-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-angle-test.js new file mode 100644 index 00000000000..2b1fe78393b --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-angle-test.js @@ -0,0 +1,1853 @@ +/* eslint-disable no-inner-declarations */ +// ^^^ remove after unflagging EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS +import { moduleFor, ApplicationTestCase, runLoopSettled, runTask } from 'internal-test-helpers'; +import { EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS } from '@ember/canary-features'; +import Controller, { inject as injectController } from '@ember/controller'; +import { A as emberA, RSVP } from '@ember/-internals/runtime'; +import { alias } from '@ember/-internals/metal'; +import { subscribe, reset } from '@ember/instrumentation'; +import { Route, NoneLocation } from '@ember/-internals/routing'; +import { EMBER_IMPROVED_INSTRUMENTATION } from '@ember/canary-features'; + +if (EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS) { + // IE includes the host name + function normalizeUrl(url) { + return url.replace(/https?:\/\/[^\/]+/, ''); + } + + function shouldNotBeActive(assert, element) { + checkActive(assert, element, false); + } + + function shouldBeActive(assert, element) { + checkActive(assert, element, true); + } + + function checkActive(assert, element, active) { + let classList = element.attr('class'); + assert.equal(classList.indexOf('active') > -1, active, `${element} active should be ${active}`); + } + + moduleFor( + ' component (routing tests)', + class extends ApplicationTestCase { + constructor() { + super(); + + this.router.map(function() { + this.route('about'); + }); + + this.addTemplate( + 'index', + ` +

    Home

    + About + Self + ` + ); + this.addTemplate( + 'about', + ` +

    About

    + Home + Self + ` + ); + } + + ['@test The component navigates into the named route'](assert) { + return this.visit('/') + .then(() => { + assert.equal(this.$('h3.home').length, 1, 'The home template was rendered'); + assert.equal( + this.$('#self-link.active').length, + 1, + 'The self-link was rendered with active class' + ); + assert.equal( + this.$('#about-link:not(.active)').length, + 1, + 'The other link was rendered without active class' + ); + + return this.click('#about-link'); + }) + .then(() => { + assert.equal(this.$('h3.about').length, 1, 'The about template was rendered'); + assert.equal( + this.$('#self-link.active').length, + 1, + 'The self-link was rendered with active class' + ); + assert.equal( + this.$('#home-link:not(.active)').length, + 1, + 'The other link was rendered without active class' + ); + }); + } + + [`@test the component doesn't add an href when the tagName isn't 'a'`](assert) { + this.addTemplate( + 'index', + `About` + ); + + return this.visit('/').then(() => { + assert.equal(this.$('#about-link').attr('href'), undefined, 'there is no href attribute'); + }); + } + + [`@test the component applies a 'disabled' class when disabled`](assert) { + this.addTemplate( + 'index', + ` + About + About + ` + ); + + this.add( + 'controller:index', + Controller.extend({ + shouldDisable: true, + dynamicDisabledWhen: 'shouldDisable', + }) + ); + + return this.visit('/').then(() => { + assert.equal( + this.$('#about-link-static.disabled').length, + 1, + 'The static link is disabled when its disabledWhen is true' + ); + assert.equal( + this.$('#about-link-dynamic.disabled').length, + 1, + 'The dynamic link is disabled when its disabledWhen is true' + ); + + let controller = this.applicationInstance.lookup('controller:index'); + runTask(() => controller.set('dynamicDisabledWhen', false)); + + assert.equal( + this.$('#about-link-dynamic.disabled').length, + 0, + 'The dynamic link is re-enabled when its disabledWhen becomes false' + ); + }); + } + + [`@test the component doesn't apply a 'disabled' class if disabledWhen is not provided`]( + assert + ) { + this.addTemplate('index', `About`); + + return this.visit('/').then(() => { + assert.ok( + !this.$('#about-link').hasClass('disabled'), + 'The link is not disabled if disabledWhen not provided' + ); + }); + } + + [`@test the component supports a custom disabledClass`](assert) { + this.addTemplate( + 'index', + `About` + ); + + return this.visit('/').then(() => { + assert.equal( + this.$('#about-link.do-not-want').length, + 1, + 'The link can apply a custom disabled class' + ); + }); + } + + [`@test the component supports a custom disabledClass set via bound param`]( + assert + ) { + this.addTemplate( + 'index', + `About` + ); + + this.add( + 'controller:index', + Controller.extend({ + disabledClass: 'do-not-want', + }) + ); + + return this.visit('/').then(() => { + assert.equal( + this.$('#about-link.do-not-want').length, + 1, + 'The link can apply a custom disabled class via bound param' + ); + }); + } + + [`@test the component does not respond to clicks when disabledWhen`](assert) { + this.addTemplate( + 'index', + `About` + ); + + return this.visit('/') + .then(() => { + return this.click('#about-link'); + }) + .then(() => { + assert.equal(this.$('h3.about').length, 0, 'Transitioning did not occur'); + }); + } + + [`@test the component does not respond to clicks when disabled`](assert) { + this.addTemplate( + 'index', + `About` + ); + + return this.visit('/') + .then(() => { + return this.click('#about-link'); + }) + .then(() => { + assert.equal(this.$('h3.about').length, 0, 'Transitioning did not occur'); + }); + } + + [`@test the component responds to clicks according to its disabledWhen bound param`]( + assert + ) { + this.addTemplate( + 'index', + `About` + ); + + this.add( + 'controller:index', + Controller.extend({ + disabledWhen: true, + }) + ); + + return this.visit('/') + .then(() => { + return this.click('#about-link'); + }) + .then(() => { + assert.equal(this.$('h3.about').length, 0, 'Transitioning did not occur'); + + let controller = this.applicationInstance.lookup('controller:index'); + controller.set('disabledWhen', false); + + return runLoopSettled(); + }) + .then(() => { + return this.click('#about-link'); + }) + .then(() => { + assert.equal( + this.$('h3.about').length, + 1, + 'Transitioning did occur when disabledWhen became false' + ); + }); + } + + [`@test The component supports a custom activeClass`](assert) { + this.addTemplate( + 'index', + ` +

    Home

    + About + Self + ` + ); + + return this.visit('/').then(() => { + assert.equal(this.$('h3.home').length, 1, 'The home template was rendered'); + assert.equal( + this.$('#self-link.zomg-active').length, + 1, + 'The self-link was rendered with active class' + ); + assert.equal( + this.$('#about-link:not(.active)').length, + 1, + 'The other link was rendered without active class' + ); + }); + } + + [`@test The component supports a custom activeClass from a bound param`](assert) { + this.addTemplate( + 'index', + ` +

    Home

    + About + Self + ` + ); + + this.add( + 'controller:index', + Controller.extend({ + activeClass: 'zomg-active', + }) + ); + + return this.visit('/').then(() => { + assert.equal(this.$('h3.home').length, 1, 'The home template was rendered'); + assert.equal( + this.$('#self-link.zomg-active').length, + 1, + 'The self-link was rendered with active class' + ); + assert.equal( + this.$('#about-link:not(.active)').length, + 1, + 'The other link was rendered without active class' + ); + }); + } + + // See https://github.com/emberjs/ember.js/issues/17771 + [`@skip The component supports 'classNameBindings' with custom values [GH #11699]`]( + assert + ) { + this.addTemplate( + 'index', + ` +

    Home

    + About + ` + ); + + this.add( + 'controller:index', + Controller.extend({ + foo: false, + }) + ); + + return this.visit('/').then(() => { + assert.equal( + this.$('#about-link.foo-is-false').length, + 1, + 'The about-link was rendered with the falsy class' + ); + + let controller = this.applicationInstance.lookup('controller:index'); + runTask(() => controller.set('foo', true)); + + assert.equal( + this.$('#about-link.foo-is-true').length, + 1, + 'The about-link was rendered with the truthy class after toggling the property' + ); + }); + } + } + ); + + moduleFor( + ' component (routing tests - location hooks)', + class extends ApplicationTestCase { + constructor() { + super(); + + this.updateCount = 0; + this.replaceCount = 0; + + let testContext = this; + this.add( + 'location:none', + NoneLocation.extend({ + setURL() { + testContext.updateCount++; + return this._super(...arguments); + }, + replaceURL() { + testContext.replaceCount++; + return this._super(...arguments); + }, + }) + ); + + this.router.map(function() { + this.route('about'); + }); + + this.addTemplate( + 'index', + ` +

    Home

    + About + Self + ` + ); + this.addTemplate( + 'about', + ` +

    About

    + Home + Self + ` + ); + } + + visit() { + return super.visit(...arguments).then(() => { + this.updateCountAfterVisit = this.updateCount; + this.replaceCountAfterVisit = this.replaceCount; + }); + } + + ['@test The component supports URL replacement'](assert) { + this.addTemplate( + 'index', + ` +

    Home

    + About + ` + ); + + return this.visit('/') + .then(() => { + return this.click('#about-link'); + }) + .then(() => { + assert.equal( + this.updateCount, + this.updateCountAfterVisit, + 'setURL should not be called' + ); + assert.equal( + this.replaceCount, + this.replaceCountAfterVisit + 1, + 'replaceURL should be called once' + ); + }); + } + + ['@test The component supports URL replacement via replace=boundTruthyThing']( + assert + ) { + this.addTemplate( + 'index', + ` +

    Home

    + About + ` + ); + + this.add( + 'controller:index', + Controller.extend({ + boundTruthyThing: true, + }) + ); + + return this.visit('/') + .then(() => { + return this.click('#about-link'); + }) + .then(() => { + assert.equal( + this.updateCount, + this.updateCountAfterVisit, + 'setURL should not be called' + ); + assert.equal( + this.replaceCount, + this.replaceCountAfterVisit + 1, + 'replaceURL should be called once' + ); + }); + } + + ['@test The component supports setting replace=boundFalseyThing'](assert) { + this.addTemplate( + 'index', + ` +

    Home

    + About + ` + ); + + this.add( + 'controller:index', + Controller.extend({ + boundFalseyThing: false, + }) + ); + + return this.visit('/') + .then(() => { + return this.click('#about-link'); + }) + .then(() => { + assert.equal( + this.updateCount, + this.updateCountAfterVisit + 1, + 'setURL should be called' + ); + assert.equal( + this.replaceCount, + this.replaceCountAfterVisit, + 'replaceURL should not be called' + ); + }); + } + } + ); + + if (EMBER_IMPROVED_INSTRUMENTATION) { + moduleFor( + 'The component with EMBER_IMPROVED_INSTRUMENTATION', + class extends ApplicationTestCase { + constructor() { + super(); + + this.router.map(function() { + this.route('about'); + }); + + this.addTemplate( + 'index', + ` +

    Home

    + About + Self + ` + ); + this.addTemplate( + 'about', + ` +

    About

    + Home + Self + ` + ); + } + + beforeEach() { + return this.visit('/'); + } + + afterEach() { + reset(); + + return super.afterEach(); + } + + ['@test The component fires an interaction event'](assert) { + assert.expect(2); + + subscribe('interaction.link-to', { + before() { + assert.ok(true, 'instrumentation subscriber was called'); + }, + after() { + assert.ok(true, 'instrumentation subscriber was called'); + }, + }); + + return this.click('#about-link'); + } + + ['@test The component interaction event includes the route name'](assert) { + assert.expect(2); + + subscribe('interaction.link-to', { + before(name, timestamp, { routeName }) { + assert.equal(routeName, 'about', 'instrumentation subscriber was passed route name'); + }, + after(name, timestamp, { routeName }) { + assert.equal(routeName, 'about', 'instrumentation subscriber was passed route name'); + }, + }); + + return this.click('#about-link'); + } + + ['@test The component interaction event includes the transition in the after hook']( + assert + ) { + assert.expect(1); + + subscribe('interaction.link-to', { + before() {}, + after(name, timestamp, { transition }) { + assert.equal( + transition.targetName, + 'about', + 'instrumentation subscriber was passed route name' + ); + }, + }); + + return this.click('#about-link'); + } + } + ); + } + + moduleFor( + 'The component - nested routes and link-to arguments', + class extends ApplicationTestCase { + ['@test The component supports leaving off .index for nested routes'](assert) { + this.router.map(function() { + this.route('about', function() { + this.route('item'); + }); + }); + + this.addTemplate('about', `

    About

    {{outlet}}`); + this.addTemplate('about.index', `
    Index
    `); + this.addTemplate( + 'about.item', + `
    About
    ` + ); + + return this.visit('/about/item').then(() => { + assert.equal(normalizeUrl(this.$('#item a').attr('href')), '/about'); + }); + } + + [`@test The component supports custom, nested, current-when`](assert) { + this.router.map(function() { + this.route('index', { path: '/' }, function() { + this.route('about'); + }); + + this.route('item'); + }); + + this.addTemplate('index', `

    Home

    {{outlet}}`); + this.addTemplate( + 'index.about', + `ITEM` + ); + + return this.visit('/about').then(() => { + assert.equal( + this.$('#other-link.active').length, + 1, + 'The link is active since current-when is a parent route' + ); + }); + } + + [`@test The component does not disregard current-when when it is given explicitly for a route`]( + assert + ) { + this.router.map(function() { + this.route('index', { path: '/' }, function() { + this.route('about'); + }); + + this.route('items', function() { + this.route('item'); + }); + }); + + this.addTemplate('index', `

    Home

    {{outlet}}`); + this.addTemplate( + 'index.about', + `ITEM` + ); + + return this.visit('/about').then(() => { + assert.equal( + this.$('#other-link.active').length, + 1, + 'The link is active when current-when is given for explicitly for a route' + ); + }); + } + + ['@test The component does not disregard current-when when it is set via a bound param']( + assert + ) { + this.router.map(function() { + this.route('index', { path: '/' }, function() { + this.route('about'); + }); + + this.route('items', function() { + this.route('item'); + }); + }); + + this.add( + 'controller:index.about', + Controller.extend({ + currentWhen: 'index', + }) + ); + + this.addTemplate('index', `

    Home

    {{outlet}}`); + this.addTemplate( + 'index.about', + `ITEM` + ); + + return this.visit('/about').then(() => { + assert.equal( + this.$('#other-link.active').length, + 1, + 'The link is active when current-when is given for explicitly for a route' + ); + }); + } + + ['@test The component supports multiple current-when routes'](assert) { + this.router.map(function() { + this.route('index', { path: '/' }, function() { + this.route('about'); + }); + this.route('item'); + this.route('foo'); + }); + + this.addTemplate('index', `

    Home

    {{outlet}}`); + this.addTemplate( + 'index.about', + `ITEM` + ); + this.addTemplate( + 'item', + `ITEM` + ); + this.addTemplate( + 'foo', + `ITEM` + ); + + return this.visit('/about') + .then(() => { + assert.equal( + this.$('#link1.active').length, + 1, + 'The link is active since current-when contains the parent route' + ); + + return this.visit('/item'); + }) + .then(() => { + assert.equal( + this.$('#link2.active').length, + 1, + 'The link is active since you are on the active route' + ); + + return this.visit('/foo'); + }) + .then(() => { + assert.equal( + this.$('#link3.active').length, + 0, + 'The link is not active since current-when does not contain the active route' + ); + }); + } + + ['@test The component supports boolean values for current-when'](assert) { + this.router.map(function() { + this.route('index', { path: '/' }, function() { + this.route('about'); + }); + this.route('item'); + }); + + this.addTemplate( + 'index.about', + ` + ITEM + ITEM + ` + ); + + this.add('controller:index.about', Controller.extend({ isCurrent: false })); + + return this.visit('/about').then(() => { + assert.ok( + this.$('#about-link').hasClass('active'), + 'The link is active since current-when is true' + ); + assert.notOk( + this.$('#index-link').hasClass('active'), + 'The link is not active since current-when is false' + ); + + let controller = this.applicationInstance.lookup('controller:index.about'); + runTask(() => controller.set('isCurrent', true)); + + assert.ok( + this.$('#index-link').hasClass('active'), + 'The link is active since current-when is true' + ); + }); + } + + ['@test The component defaults to bubbling'](assert) { + this.addTemplate( + 'about', + ` +
    + About +
    + {{outlet}} + ` + ); + + this.addTemplate('about.contact', `

    Contact

    `); + + this.router.map(function() { + this.route('about', function() { + this.route('contact'); + }); + }); + + let hidden = 0; + + this.add( + 'route:about', + Route.extend({ + actions: { + hide() { + hidden++; + }, + }, + }) + ); + + return this.visit('/about') + .then(() => { + return this.click('#about-contact'); + }) + .then(() => { + assert.equal(this.$('#contact').text(), 'Contact', 'precond - the link worked'); + + assert.equal(hidden, 1, 'The link bubbles'); + }); + } + + [`@test The component supports bubbles=false`](assert) { + this.addTemplate( + 'about', + ` +
    + + About + +
    + {{outlet}} + ` + ); + + this.addTemplate('about.contact', `

    Contact

    `); + + this.router.map(function() { + this.route('about', function() { + this.route('contact'); + }); + }); + + let hidden = 0; + + this.add( + 'route:about', + Route.extend({ + actions: { + hide() { + hidden++; + }, + }, + }) + ); + + return this.visit('/about') + .then(() => { + return this.click('#about-contact'); + }) + .then(() => { + assert.equal(this.$('#contact').text(), 'Contact', 'precond - the link worked'); + + assert.equal(hidden, 0, "The link didn't bubble"); + }); + } + + [`@test The component supports bubbles=boundFalseyThing`](assert) { + this.addTemplate( + 'about', + ` +
    + + About + +
    + {{outlet}} + ` + ); + + this.addTemplate('about.contact', `

    Contact

    `); + + this.add( + 'controller:about', + Controller.extend({ + boundFalseyThing: false, + }) + ); + + this.router.map(function() { + this.route('about', function() { + this.route('contact'); + }); + }); + + let hidden = 0; + + this.add( + 'route:about', + Route.extend({ + actions: { + hide() { + hidden++; + }, + }, + }) + ); + + return this.visit('/about') + .then(() => { + return this.click('#about-contact'); + }) + .then(() => { + assert.equal(this.$('#contact').text(), 'Contact', 'precond - the link worked'); + assert.equal(hidden, 0, "The link didn't bubble"); + }); + } + + [`@test The component moves into the named route with context`](assert) { + this.router.map(function() { + this.route('about'); + this.route('item', { path: '/item/:id' }); + }); + + this.addTemplate( + 'about', + ` +

    List

    +
      + {{#each model as |person|}} +
    • + + {{person.name}} + +
    • + {{/each}} +
    + Home + ` + ); + + this.addTemplate( + 'item', + ` +

    Item

    +

    {{model.name}}

    + Home + ` + ); + + this.addTemplate( + 'index', + ` +

    Home

    + About + ` + ); + + this.add( + 'route:about', + Route.extend({ + model() { + return [ + { id: 'yehuda', name: 'Yehuda Katz' }, + { id: 'tom', name: 'Tom Dale' }, + { id: 'erik', name: 'Erik Brynroflsson' }, + ]; + }, + }) + ); + + return this.visit('/about') + .then(() => { + assert.equal(this.$('h3.list').length, 1, 'The home template was rendered'); + assert.equal( + normalizeUrl(this.$('#home-link').attr('href')), + '/', + 'The home link points back at /' + ); + + return this.click('#yehuda'); + }) + .then(() => { + assert.equal(this.$('h3.item').length, 1, 'The item template was rendered'); + assert.equal(this.$('p').text(), 'Yehuda Katz', 'The name is correct'); + + return this.click('#home-link'); + }) + .then(() => { + return this.click('#about-link'); + }) + .then(() => { + assert.equal(normalizeUrl(this.$('li a#yehuda').attr('href')), '/item/yehuda'); + assert.equal(normalizeUrl(this.$('li a#tom').attr('href')), '/item/tom'); + assert.equal(normalizeUrl(this.$('li a#erik').attr('href')), '/item/erik'); + + return this.click('#erik'); + }) + .then(() => { + assert.equal(this.$('h3.item').length, 1, 'The item template was rendered'); + assert.equal(this.$('p').text(), 'Erik Brynroflsson', 'The name is correct'); + }); + } + + [`@test The component binds some anchor html tag common attributes`](assert) { + this.addTemplate( + 'index', + ` +

    Home

    + + Self + + ` + ); + + return this.visit('/').then(() => { + let link = this.$('#self-link'); + assert.equal(link.attr('title'), 'title-attr', 'The self-link contains title attribute'); + assert.equal(link.attr('rel'), 'rel-attr', 'The self-link contains rel attribute'); + assert.equal(link.attr('tabindex'), '-1', 'The self-link contains tabindex attribute'); + }); + } + + [`@test The component supports 'target' attribute`](assert) { + this.addTemplate( + 'index', + ` +

    Home

    + Self + ` + ); + + return this.visit('/').then(() => { + let link = this.$('#self-link'); + assert.equal(link.attr('target'), '_blank', 'The self-link contains `target` attribute'); + }); + } + + [`@test The component supports 'target' attribute specified as a bound param`]( + assert + ) { + this.addTemplate( + 'index', + ` +

    Home

    + Self + ` + ); + + this.add( + 'controller:index', + Controller.extend({ + boundLinkTarget: '_blank', + }) + ); + + return this.visit('/').then(() => { + let link = this.$('#self-link'); + assert.equal(link.attr('target'), '_blank', 'The self-link contains `target` attribute'); + }); + } + + [`@test the component calls preventDefault`](assert) { + this.router.map(function() { + this.route('about'); + }); + + this.addTemplate('index', `About`); + + return this.visit('/').then(() => { + assertNav({ prevented: true }, () => this.$('#about-link').click(), assert); + }); + } + + [`@test the component does not call preventDefault if '@preventDefault={{false}}' is passed as an option`]( + assert + ) { + this.router.map(function() { + this.route('about'); + }); + + this.addTemplate( + 'index', + `About` + ); + + return this.visit('/').then(() => { + assertNav({ prevented: false }, () => this.$('#about-link').trigger('click'), assert); + }); + } + + [`@test the component does not call preventDefault if '@preventDefault={{boundFalseyThing}}' is passed as an option`]( + assert + ) { + this.router.map(function() { + this.route('about'); + }); + + this.addTemplate( + 'index', + `About` + ); + + this.add( + 'controller:index', + Controller.extend({ + boundFalseyThing: false, + }) + ); + + return this.visit('/').then(() => { + assertNav({ prevented: false }, () => this.$('#about-link').trigger('click'), assert); + }); + } + + [`@test The component does not call preventDefault if 'target' attribute is provided`]( + assert + ) { + this.addTemplate( + 'index', + ` +

    Home

    + Self + ` + ); + + return this.visit('/').then(() => { + assertNav({ prevented: false }, () => this.$('#self-link').click(), assert); + }); + } + + [`@test The component should preventDefault when 'target = _self'`](assert) { + this.addTemplate( + 'index', + ` +

    Home

    + Self + ` + ); + + return this.visit('/').then(() => { + assertNav({ prevented: true }, () => this.$('#self-link').click(), assert); + }); + } + + [`@test The component should not transition if target is not equal to _self or empty`]( + assert + ) { + this.addTemplate( + 'index', + ` + + About + + ` + ); + + this.router.map(function() { + this.route('about'); + }); + + return this.visit('/') + .then(() => this.click('#about-link')) + .then(() => { + expectDeprecation(() => { + let currentRouteName = this.applicationInstance + .lookup('controller:application') + .get('currentRouteName'); + assert.notEqual( + currentRouteName, + 'about', + 'link-to should not transition if target is not equal to _self or empty' + ); + }, 'Accessing `currentRouteName` on `controller:application` is deprecated, use the `currentRouteName` property on `service:router` instead.'); + }); + } + + [`@test The component accepts string/numeric arguments`](assert) { + this.router.map(function() { + this.route('filter', { path: '/filters/:filter' }); + this.route('post', { path: '/post/:post_id' }); + this.route('repo', { path: '/repo/:owner/:name' }); + }); + + this.add( + 'controller:filter', + Controller.extend({ + filter: 'unpopular', + repo: { owner: 'ember', name: 'ember.js' }, + post_id: 123, + }) + ); + + this.addTemplate( + 'filter', + ` +

    {{filter}}

    + Unpopular + Unpopular + Post + Post + Repo + ` + ); + + return this.visit('/filters/popular').then(() => { + assert.equal(normalizeUrl(this.$('#link').attr('href')), '/filters/unpopular'); + assert.equal(normalizeUrl(this.$('#path-link').attr('href')), '/filters/unpopular'); + assert.equal(normalizeUrl(this.$('#post-path-link').attr('href')), '/post/123'); + assert.equal(normalizeUrl(this.$('#post-number-link').attr('href')), '/post/123'); + assert.equal( + normalizeUrl(this.$('#repo-object-link').attr('href')), + '/repo/ember/ember.js' + ); + }); + } + + [`@test Issue 4201 - Shorthand for route.index shouldn't throw errors about context arguments`]( + assert + ) { + assert.expect(2); + this.router.map(function() { + this.route('lobby', function() { + this.route('index', { path: ':lobby_id' }); + this.route('list'); + }); + }); + + this.add( + 'route:lobby.index', + Route.extend({ + model(params) { + assert.equal(params.lobby_id, 'foobar'); + return params.lobby_id; + }, + }) + ); + + this.addTemplate( + 'lobby.index', + `Lobby` + ); + + this.addTemplate( + 'lobby.list', + `Lobby` + ); + + return this.visit('/lobby/list') + .then(() => this.click('#lobby-link')) + .then(() => shouldBeActive(assert, this.$('#lobby-link'))); + } + + [`@test Quoteless route param performs property lookup`](assert) { + this.router.map(function() { + this.route('about'); + }); + + this.addTemplate( + 'index', + ` + string + path + ` + ); + + this.add( + 'controller:index', + Controller.extend({ + foo: 'index', + }) + ); + + let assertEquality = href => { + assert.equal(normalizeUrl(this.$('#string-link').attr('href')), '/'); + assert.equal(normalizeUrl(this.$('#path-link').attr('href')), href); + }; + + return this.visit('/').then(() => { + assertEquality('/'); + + let controller = this.applicationInstance.lookup('controller:index'); + runTask(() => controller.set('foo', 'about')); + + assertEquality('/about'); + }); + } + + [`@test The component refreshes href element when one of params changes`](assert) { + this.router.map(function() { + this.route('post', { path: '/posts/:post_id' }); + }); + + let post = { id: '1' }; + let secondPost = { id: '2' }; + + this.addTemplate('index', `post`); + + this.add('controller:index', Controller.extend()); + + return this.visit('/').then(() => { + let indexController = this.applicationInstance.lookup('controller:index'); + runTask(() => indexController.set('post', post)); + + assert.equal( + normalizeUrl(this.$('#post').attr('href')), + '/posts/1', + 'precond - Link has rendered href attr properly' + ); + + runTask(() => indexController.set('post', secondPost)); + + assert.equal( + this.$('#post').attr('href'), + '/posts/2', + 'href attr was updated after one of the params had been changed' + ); + + runTask(() => indexController.set('post', null)); + + assert.equal( + this.$('#post').attr('href'), + '#', + 'href attr becomes # when one of the arguments in nullified' + ); + }); + } + + [`@test The component is active when a route is active`](assert) { + this.router.map(function() { + this.route('about', function() { + this.route('item'); + }); + }); + + this.addTemplate( + 'about', + ` +
    + About + Item + {{outlet}} +
    + ` + ); + + return this.visit('/about') + .then(() => { + assert.equal(this.$('#about-link.active').length, 1, 'The about route link is active'); + assert.equal(this.$('#item-link.active').length, 0, 'The item route link is inactive'); + + return this.visit('/about/item'); + }) + .then(() => { + assert.equal(this.$('#about-link.active').length, 1, 'The about route link is active'); + assert.equal(this.$('#item-link.active').length, 1, 'The item route link is active'); + }); + } + + [`@test The component works in an #each'd array of string route names`](assert) { + this.router.map(function() { + this.route('foo'); + this.route('bar'); + this.route('rar'); + }); + + this.add( + 'controller:index', + Controller.extend({ + routeNames: emberA(['foo', 'bar', 'rar']), + route1: 'bar', + route2: 'foo', + }) + ); + + this.addTemplate( + 'index', + ` + {{#each routeNames as |routeName|}} + {{routeName}} + {{/each}} + {{#each routeNames as |r|}} + {{r}} + {{/each}} + a + b + ` + ); + + let linksEqual = (links, expected) => { + assert.equal(links.length, expected.length, 'Has correct number of links'); + + let idx; + for (idx = 0; idx < links.length; idx++) { + let href = this.$(links[idx]).attr('href'); + // Old IE includes the whole hostname as well + assert.equal( + href.slice(-expected[idx].length), + expected[idx], + `Expected link to be '${expected[idx]}', but was '${href}'` + ); + } + }; + + return this.visit('/').then(() => { + linksEqual(this.$('a'), ['/foo', '/bar', '/rar', '/foo', '/bar', '/rar', '/bar', '/foo']); + + let indexController = this.applicationInstance.lookup('controller:index'); + runTask(() => indexController.set('route1', 'rar')); + + linksEqual(this.$('a'), ['/foo', '/bar', '/rar', '/foo', '/bar', '/rar', '/rar', '/foo']); + + runTask(() => indexController.routeNames.shiftObject()); + + linksEqual(this.$('a'), ['/bar', '/rar', '/bar', '/rar', '/rar', '/foo']); + }); + } + + [`@test the component throws a useful error if you invoke it wrong`](assert) { + assert.expect(1); + + this.router.map(function() { + this.route('post', { path: 'post/:post_id' }); + }); + + this.addTemplate('application', `Post`); + + assert.throws(() => { + this.visit('/'); + }, /(You attempted to generate a link for the "post" route, but did not pass the models required for generating its dynamic segments.|You must provide param `post_id` to `generate`)/); + + return runLoopSettled(); + } + + [`@test the component does not throw an error if its route has exited`](assert) { + assert.expect(0); + + this.router.map(function() { + this.route('post', { path: 'post/:post_id' }); + }); + + this.addTemplate( + 'application', + ` + Home + Default Post + {{#if currentPost}} + Current Post + {{/if}} + ` + ); + + this.add( + 'controller:application', + Controller.extend({ + defaultPost: { id: 1 }, + postController: injectController('post'), + currentPost: alias('postController.model'), + }) + ); + + this.add('controller:post', Controller.extend()); + + this.add( + 'route:post', + Route.extend({ + model() { + return { id: 2 }; + }, + serialize(model) { + return { post_id: model.id }; + }, + }) + ); + + return this.visit('/') + .then(() => this.click('#default-post-link')) + .then(() => this.click('#home-link')) + .then(() => this.click('#current-post-link')) + .then(() => this.click('#home-link')); + } + + [`@test the component's active property respects changing parent route context`]( + assert + ) { + this.router.map(function() { + this.route('things', { path: '/things/:name' }, function() { + this.route('other'); + }); + }); + + this.addTemplate( + 'application', + ` + OMG + LOL + ` + ); + + return this.visit('/things/omg') + .then(() => { + shouldBeActive(assert, this.$('#omg-link')); + shouldNotBeActive(assert, this.$('#lol-link')); + + return this.visit('/things/omg/other'); + }) + .then(() => { + shouldBeActive(assert, this.$('#omg-link')); + shouldNotBeActive(assert, this.$('#lol-link')); + }); + } + + [`@test the component populates href with default query param values even without query-params object`]( + assert + ) { + this.add( + 'controller:index', + Controller.extend({ + queryParams: ['foo'], + foo: '123', + }) + ); + + this.addTemplate('index', `Index`); + + return this.visit('/').then(() => { + assert.equal(this.$('#the-link').attr('href'), '/', 'link has right href'); + }); + } + + [`@test the component populates href with default query param values with empty query-params object`]( + assert + ) { + this.add( + 'controller:index', + Controller.extend({ + queryParams: ['foo'], + foo: '123', + }) + ); + + this.addTemplate( + 'index', + `Index` + ); + + return this.visit('/').then(() => { + assert.equal(this.$('#the-link').attr('href'), '/', 'link has right href'); + }); + } + + [`@test the component with only query-params and a block updates when route changes`]( + assert + ) { + this.router.map(function() { + this.route('about'); + }); + + this.add( + 'controller:application', + Controller.extend({ + queryParams: ['foo', 'bar'], + foo: '123', + bar: 'yes', + }) + ); + + this.addTemplate( + 'application', + `Index` + ); + + return this.visit('/') + .then(() => { + assert.equal( + this.$('#the-link').attr('href'), + '/?bar=NAW&foo=456', + 'link has right href' + ); + + return this.visit('/about'); + }) + .then(() => { + assert.equal( + this.$('#the-link').attr('href'), + '/about?bar=NAW&foo=456', + 'link has right href' + ); + }); + } + + ['@test [GH#17018] passing model to with `hash` helper works']() { + this.router.map(function() { + this.route('post', { path: '/posts/:post_id' }); + }); + + this.add( + 'route:index', + Route.extend({ + model() { + return RSVP.hash({ + user: { name: 'Papa Smurf' }, + }); + }, + }) + ); + + this.addTemplate( + 'index', + `Post` + ); + + this.addTemplate('post', 'Post: {{this.model.user.name}}'); + + return this.visit('/') + .then(() => { + this.assertComponentElement(this.firstChild, { + tagName: 'a', + attrs: { href: '/posts/someId' }, + content: 'Post', + }); + + return this.click('a'); + }) + .then(() => { + this.assertText('Post: Papa Smurf'); + }); + } + + [`@test The component can use dynamic params`](assert) { + this.router.map(function() { + this.route('foo', { path: 'foo/:some/:thing' }); + this.route('bar', { path: 'bar/:some/:thing/:else' }); + }); + + this.add( + 'controller:index', + Controller.extend({ + init() { + this._super(...arguments); + this.dynamicLinkParams = ['foo', 'one', 'two']; + }, + }) + ); + + this.addTemplate( + 'index', + ` +

    Home

    + Dynamic + ` + ); + + return this.visit('/').then(() => { + let link = this.$('#dynamic-link'); + + assert.equal(link.attr('href'), '/foo/one/two'); + + let controller = this.applicationInstance.lookup('controller:index'); + runTask(() => { + controller.set('dynamicLinkParams', ['bar', 'one', 'two', 'three']); + }); + + assert.equal(link.attr('href'), '/bar/one/two/three'); + }); + } + + [`@test GJ: to a parent root model hook which performs a 'transitionTo' has correct active class #13256`]( + assert + ) { + assert.expect(1); + + this.router.map(function() { + this.route('parent', function() { + this.route('child'); + }); + }); + + this.add( + 'route:parent', + Route.extend({ + afterModel() { + this.transitionTo('parent.child'); + }, + }) + ); + + this.addTemplate( + 'application', + `Parent` + ); + + return this.visit('/') + .then(() => { + return this.click('#parent-link'); + }) + .then(() => { + shouldBeActive(assert, this.$('#parent-link')); + }); + } + } + ); + + moduleFor( + 'The component - loading states and warnings', + class extends ApplicationTestCase { + [`@test with null/undefined dynamic parameters are put in a loading state`]( + assert + ) { + assert.expect(19); + let warningMessage = + 'This link is in an inactive loading state because at least one of its models currently has a null/undefined value, or the provided route name is invalid.'; + + this.router.map(function() { + this.route('thing', { path: '/thing/:thing_id' }); + this.route('about'); + }); + + this.addTemplate( + 'index', + ` + + string + + + string + + ` + ); + + this.add( + 'controller:index', + Controller.extend({ + destinationRoute: null, + routeContext: null, + loadingClass: 'i-am-loading', + }) + ); + + this.add( + 'route:about', + Route.extend({ + activate() { + assert.ok(true, 'About was entered'); + }, + }) + ); + + function assertLinkStatus(link, url) { + if (url) { + assert.equal(normalizeUrl(link.attr('href')), url, 'loaded link-to has expected href'); + assert.ok(!link.hasClass('i-am-loading'), 'loaded linkComponent has no loadingClass'); + } else { + assert.equal(normalizeUrl(link.attr('href')), '#', "unloaded link-to has href='#'"); + assert.ok(link.hasClass('i-am-loading'), 'loading linkComponent has loadingClass'); + } + } + + let contextLink, staticLink, controller; + + return this.visit('/') + .then(() => { + contextLink = this.$('#context-link'); + staticLink = this.$('#static-link'); + controller = this.applicationInstance.lookup('controller:index'); + + assertLinkStatus(contextLink); + assertLinkStatus(staticLink); + + return expectWarning(() => { + return this.click(contextLink[0]); + }, warningMessage); + }) + .then(() => { + // Set the destinationRoute (context is still null). + runTask(() => controller.set('destinationRoute', 'thing')); + assertLinkStatus(contextLink); + + // Set the routeContext to an id + runTask(() => controller.set('routeContext', '456')); + assertLinkStatus(contextLink, '/thing/456'); + + // Test that 0 isn't interpreted as falsy. + runTask(() => controller.set('routeContext', 0)); + assertLinkStatus(contextLink, '/thing/0'); + + // Set the routeContext to an object + runTask(() => { + controller.set('routeContext', { id: 123 }); + }); + assertLinkStatus(contextLink, '/thing/123'); + + // Set the destinationRoute back to null. + runTask(() => controller.set('destinationRoute', null)); + assertLinkStatus(contextLink); + + return expectWarning(() => { + return this.click(staticLink[0]); + }, warningMessage); + }) + .then(() => { + runTask(() => controller.set('secondRoute', 'about')); + assertLinkStatus(staticLink, '/about'); + + // Click the now-active link + return this.click(staticLink[0]); + }); + } + } + ); + + function assertNav(options, callback, assert) { + let nav = false; + + function check(event) { + assert.equal( + event.defaultPrevented, + options.prevented, + `expected defaultPrevented=${options.prevented}` + ); + nav = true; + event.preventDefault(); + } + + try { + document.addEventListener('click', check); + callback(); + } finally { + document.removeEventListener('click', check); + assert.ok(nav, 'Expected a link to be clicked'); + } + } +} diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js similarity index 96% rename from packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-test.js rename to packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js index f6ae8f5e538..218d27aee29 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js @@ -1,4 +1,5 @@ import { moduleFor, ApplicationTestCase, runLoopSettled, runTask } from 'internal-test-helpers'; +import { EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS } from '@ember/canary-features'; import Controller, { inject as injectController } from '@ember/controller'; import { A as emberA, RSVP } from '@ember/-internals/runtime'; import { alias } from '@ember/-internals/metal'; @@ -560,7 +561,7 @@ if (EMBER_IMPROVED_INSTRUMENTATION) { return this.click('#about-link'); } - ['@test The {{link-to}} helper interaction event includes the transition in the after hook']( + ['@test The {{link-to}} component interaction event includes the transition in the after hook']( assert ) { assert.expect(1); @@ -585,7 +586,7 @@ if (EMBER_IMPROVED_INSTRUMENTATION) { moduleFor( 'The {{link-to}} component - nested routes and link-to arguments', class extends ApplicationTestCase { - ['@test The {{link-to}} helper supports leaving off .index for nested routes'](assert) { + ['@test The {{link-to}} component supports leaving off .index for nested routes'](assert) { this.router.map(function() { this.route('about', function() { this.route('item'); @@ -1001,7 +1002,7 @@ moduleFor( }); } - [`@test The {{link-to}} helper binds some anchor html tag common attributes`](assert) { + [`@test The {{link-to}} component binds some anchor html tag common attributes`](assert) { this.addTemplate( 'index', ` @@ -1020,7 +1021,7 @@ moduleFor( }); } - [`@test The {{link-to}} helper supports 'target' attribute`](assert) { + [`@test The {{link-to}} component supports 'target' attribute`](assert) { this.addTemplate( 'index', ` @@ -1035,7 +1036,7 @@ moduleFor( }); } - [`@test The {{link-to}} helper supports 'target' attribute specified as a bound param`]( + [`@test The {{link-to}} component supports 'target' attribute specified as a bound param`]( assert ) { this.addTemplate( @@ -1056,7 +1057,7 @@ moduleFor( }); } - [`@test the {{link-to}} helper calls preventDefault`](assert) { + [`@test the {{link-to}} component calls preventDefault`](assert) { this.router.map(function() { this.route('about'); }); @@ -1068,7 +1069,7 @@ moduleFor( }); } - [`@test the {{link-to}} helper does not call preventDefault if 'preventDefault=false' is passed as an option`]( + [`@test the {{link-to}} component does not call preventDefault if 'preventDefault=false' is passed as an option`]( assert ) { this.router.map(function() { @@ -1085,7 +1086,7 @@ moduleFor( }); } - [`@test the {{link-to}} helper does not call preventDefault if 'preventDefault=boundFalseyThing' is passed as an option`]( + [`@test the {{link-to}} component does not call preventDefault if 'preventDefault=boundFalseyThing' is passed as an option`]( assert ) { this.router.map(function() { @@ -1109,7 +1110,7 @@ moduleFor( }); } - [`@test The {{link-to}} helper does not call preventDefault if 'target' attribute is provided`]( + [`@test The {{link-to}} component does not call preventDefault if 'target' attribute is provided`]( assert ) { this.addTemplate( @@ -1125,7 +1126,7 @@ moduleFor( }); } - [`@test The {{link-to}} helper should preventDefault when 'target = _self'`](assert) { + [`@test The {{link-to}} component should preventDefault when 'target = _self'`](assert) { this.addTemplate( 'index', ` @@ -1139,7 +1140,7 @@ moduleFor( }); } - [`@test The {{link-to}} helper should not transition if target is not equal to _self or empty`]( + [`@test The {{link-to}} component should not transition if target is not equal to _self or empty`]( assert ) { this.addTemplate( @@ -1171,7 +1172,7 @@ moduleFor( }); } - [`@test The {{link-to}} helper accepts string/numeric arguments`](assert) { + [`@test The {{link-to}} component accepts string/numeric arguments`](assert) { this.router.map(function() { this.route('filter', { path: '/filters/:filter' }); this.route('post', { path: '/post/:post_id' }); @@ -1282,7 +1283,7 @@ moduleFor( }); } - [`@test The {{link-to}} helper refreshes href element when one of params changes`](assert) { + [`@test The {{link-to}} component refreshes href element when one of params changes`](assert) { this.router.map(function() { this.route('post', { path: '/posts/:post_id' }); }); @@ -1322,7 +1323,7 @@ moduleFor( }); } - [`@test The {{link-to}} helper is active when a route is active`](assert) { + [`@test The {{link-to}} component is active when a route is active`](assert) { this.router.map(function() { this.route('about', function() { this.route('item'); @@ -1353,7 +1354,7 @@ moduleFor( }); } - [`@test The {{link-to}} helper works in an #each'd array of string route names`](assert) { + [`@test The {{link-to}} component works in an #each'd array of string route names`](assert) { this.router.map(function() { this.route('foo'); this.route('bar'); @@ -1412,7 +1413,7 @@ moduleFor( }); } - [`@test The non-block form {{link-to}} helper moves into the named route`](assert) { + [`@test The non-block form {{link-to}} component moves into the named route`](assert) { assert.expect(3); this.router.map(function() { this.route('contact'); @@ -1454,7 +1455,7 @@ moduleFor( }); } - [`@test The non-block form {{link-to}} helper updates the link text when it is a binding`]( + [`@test The non-block form {{link-to}} component updates the link text when it is a binding`]( assert ) { assert.expect(8); @@ -1538,7 +1539,7 @@ moduleFor( }); } - [`@test The non-block form {{link-to}} helper moves into the named route with context`]( + [`@test The non-block form {{link-to}} component moves into the named route with context`]( assert ) { assert.expect(5); @@ -1971,14 +1972,21 @@ moduleFor( ); moduleFor( - 'The {{link-to}} helper - loading states and warnings', + 'The {{link-to}} component - loading states and warnings', class extends ApplicationTestCase { [`@test {{link-to}} with null/undefined dynamic parameters are put in a loading state`]( assert ) { assert.expect(19); - let warningMessage = - 'This link-to is in an inactive loading state because at least one of its parameters presently has a null/undefined value, or the provided route name is invalid.'; + let warningMessage; + + if (EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS) { + warningMessage = + 'This link is in an inactive loading state because at least one of its models currently has a null/undefined value, or the provided route name is invalid.'; + } else { + warningMessage = + 'This link-to is in an inactive loading state because at least one of its parameters presently has a null/undefined value, or the provided route name is invalid.'; + } this.router.map(function() { this.route('thing', { path: '/thing/:thing_id' }); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-angle-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-angle-test.js new file mode 100644 index 00000000000..dc1306c5f67 --- /dev/null +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-angle-test.js @@ -0,0 +1,347 @@ +/* eslint-disable no-inner-declarations */ +// ^^^ remove after unflagging EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS +import { RSVP } from '@ember/-internals/runtime'; +import { Route } from '@ember/-internals/routing'; +import { EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS } from '@ember/canary-features'; +import { moduleFor, ApplicationTestCase, runTask } from 'internal-test-helpers'; + +if (EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS) { + function assertHasClass(assert, selector, label) { + let testLabel = `${selector.attr('id')} should have class ${label}`; + + assert.equal(selector.hasClass(label), true, testLabel); + } + + function assertHasNoClass(assert, selector, label) { + let testLabel = `${selector.attr('id')} should not have class ${label}`; + + assert.equal(selector.hasClass(label), false, testLabel); + } + + moduleFor( + ' component: .transitioning-in .transitioning-out CSS classes', + class extends ApplicationTestCase { + constructor() { + super(); + + this.aboutDefer = RSVP.defer(); + this.otherDefer = RSVP.defer(); + this.newsDefer = RSVP.defer(); + let _this = this; + + this.router.map(function() { + this.route('about'); + this.route('other'); + this.route('news'); + }); + + this.add( + 'route:about', + Route.extend({ + model() { + return _this.aboutDefer.promise; + }, + }) + ); + + this.add( + 'route:other', + Route.extend({ + model() { + return _this.otherDefer.promise; + }, + }) + ); + + this.add( + 'route:news', + Route.extend({ + model() { + return _this.newsDefer.promise; + }, + }) + ); + + this.addTemplate( + 'application', + ` + {{outlet}} + Index + About + Other + News + ` + ); + } + + beforeEach() { + return this.visit('/'); + } + + afterEach() { + super.afterEach(); + this.aboutDefer = null; + this.otherDefer = null; + this.newsDefer = null; + } + + ['@test while a transition is underway'](assert) { + let $index = this.$('#index-link'); + let $about = this.$('#about-link'); + let $other = this.$('#other-link'); + + $about.click(); + + assertHasClass(assert, $index, 'active'); + assertHasNoClass(assert, $about, 'active'); + assertHasNoClass(assert, $other, 'active'); + + assertHasNoClass(assert, $index, 'ember-transitioning-in'); + assertHasClass(assert, $about, 'ember-transitioning-in'); + assertHasNoClass(assert, $other, 'ember-transitioning-in'); + + assertHasClass(assert, $index, 'ember-transitioning-out'); + assertHasNoClass(assert, $about, 'ember-transitioning-out'); + assertHasNoClass(assert, $other, 'ember-transitioning-out'); + + runTask(() => this.aboutDefer.resolve()); + + assertHasNoClass(assert, $index, 'active'); + assertHasClass(assert, $about, 'active'); + assertHasNoClass(assert, $other, 'active'); + + assertHasNoClass(assert, $index, 'ember-transitioning-in'); + assertHasNoClass(assert, $about, 'ember-transitioning-in'); + assertHasNoClass(assert, $other, 'ember-transitioning-in'); + + assertHasNoClass(assert, $index, 'ember-transitioning-out'); + assertHasNoClass(assert, $about, 'ember-transitioning-out'); + assertHasNoClass(assert, $other, 'ember-transitioning-out'); + } + + ['@test while a transition is underway with activeClass is false'](assert) { + let $index = this.$('#index-link'); + let $news = this.$('#news-link'); + let $other = this.$('#other-link'); + + $news.click(); + + assertHasClass(assert, $index, 'active'); + assertHasNoClass(assert, $news, 'active'); + assertHasNoClass(assert, $other, 'active'); + + assertHasNoClass(assert, $index, 'ember-transitioning-in'); + assertHasClass(assert, $news, 'ember-transitioning-in'); + assertHasNoClass(assert, $other, 'ember-transitioning-in'); + + assertHasClass(assert, $index, 'ember-transitioning-out'); + assertHasNoClass(assert, $news, 'ember-transitioning-out'); + assertHasNoClass(assert, $other, 'ember-transitioning-out'); + + runTask(() => this.newsDefer.resolve()); + + assertHasNoClass(assert, $index, 'active'); + assertHasNoClass(assert, $news, 'active'); + assertHasNoClass(assert, $other, 'active'); + + assertHasNoClass(assert, $index, 'ember-transitioning-in'); + assertHasNoClass(assert, $news, 'ember-transitioning-in'); + assertHasNoClass(assert, $other, 'ember-transitioning-in'); + + assertHasNoClass(assert, $index, 'ember-transitioning-out'); + assertHasNoClass(assert, $news, 'ember-transitioning-out'); + assertHasNoClass(assert, $other, 'ember-transitioning-out'); + } + } + ); + + moduleFor( + ` component: .transitioning-in .transitioning-out CSS classes - nested link-to's`, + class extends ApplicationTestCase { + constructor() { + super(); + this.aboutDefer = RSVP.defer(); + this.otherDefer = RSVP.defer(); + let _this = this; + + this.router.map(function() { + this.route('parent-route', function() { + this.route('about'); + this.route('other'); + }); + }); + this.add( + 'route:parent-route.about', + Route.extend({ + model() { + return _this.aboutDefer.promise; + }, + }) + ); + + this.add( + 'route:parent-route.other', + Route.extend({ + model() { + return _this.otherDefer.promise; + }, + }) + ); + + this.addTemplate( + 'application', + ` + {{outlet}} + + Index + + + About + + + Other + + ` + ); + } + + beforeEach() { + return this.visit('/'); + } + + resolveAbout() { + return runTask(() => { + this.aboutDefer.resolve(); + this.aboutDefer = RSVP.defer(); + }); + } + + resolveOther() { + return runTask(() => { + this.otherDefer.resolve(); + this.otherDefer = RSVP.defer(); + }); + } + + teardown() { + super.teardown(); + this.aboutDefer = null; + this.otherDefer = null; + } + + [`@test while a transition is underway with nested link-to's`](assert) { + // TODO undo changes to this test but currently this test navigates away if navigation + // outlet is not stable and the second $about.click() is triggered. + let $about = this.$('#about-link'); + + $about.click(); + + let $index = this.$('#index-link'); + $about = this.$('#about-link'); + let $other = this.$('#other-link'); + + assertHasClass(assert, $index, 'active'); + assertHasNoClass(assert, $about, 'active'); + assertHasNoClass(assert, $about, 'active'); + + assertHasNoClass(assert, $index, 'ember-transitioning-in'); + assertHasClass(assert, $about, 'ember-transitioning-in'); + assertHasNoClass(assert, $other, 'ember-transitioning-in'); + + assertHasClass(assert, $index, 'ember-transitioning-out'); + assertHasNoClass(assert, $about, 'ember-transitioning-out'); + assertHasNoClass(assert, $other, 'ember-transitioning-out'); + + this.resolveAbout(); + + $index = this.$('#index-link'); + $about = this.$('#about-link'); + $other = this.$('#other-link'); + + assertHasNoClass(assert, $index, 'active'); + assertHasClass(assert, $about, 'active'); + assertHasNoClass(assert, $other, 'active'); + + assertHasNoClass(assert, $index, 'ember-transitioning-in'); + assertHasNoClass(assert, $about, 'ember-transitioning-in'); + assertHasNoClass(assert, $other, 'ember-transitioning-in'); + + assertHasNoClass(assert, $index, 'ember-transitioning-out'); + assertHasNoClass(assert, $about, 'ember-transitioning-out'); + assertHasNoClass(assert, $other, 'ember-transitioning-out'); + + $other.click(); + + $index = this.$('#index-link'); + $about = this.$('#about-link'); + $other = this.$('#other-link'); + + assertHasNoClass(assert, $index, 'active'); + assertHasClass(assert, $about, 'active'); + assertHasNoClass(assert, $other, 'active'); + + assertHasNoClass(assert, $index, 'ember-transitioning-in'); + assertHasNoClass(assert, $about, 'ember-transitioning-in'); + assertHasClass(assert, $other, 'ember-transitioning-in'); + + assertHasNoClass(assert, $index, 'ember-transitioning-out'); + assertHasClass(assert, $about, 'ember-transitioning-out'); + assertHasNoClass(assert, $other, 'ember-transitioning-out'); + + this.resolveOther(); + + $index = this.$('#index-link'); + $about = this.$('#about-link'); + $other = this.$('#other-link'); + + assertHasNoClass(assert, $index, 'active'); + assertHasNoClass(assert, $about, 'active'); + assertHasClass(assert, $other, 'active'); + + assertHasNoClass(assert, $index, 'ember-transitioning-in'); + assertHasNoClass(assert, $about, 'ember-transitioning-in'); + assertHasNoClass(assert, $other, 'ember-transitioning-in'); + + assertHasNoClass(assert, $index, 'ember-transitioning-out'); + assertHasNoClass(assert, $about, 'ember-transitioning-out'); + assertHasNoClass(assert, $other, 'ember-transitioning-out'); + + $about.click(); + + $index = this.$('#index-link'); + $about = this.$('#about-link'); + $other = this.$('#other-link'); + + assertHasNoClass(assert, $index, 'active'); + assertHasNoClass(assert, $about, 'active'); + assertHasClass(assert, $other, 'active'); + + assertHasNoClass(assert, $index, 'ember-transitioning-in'); + assertHasClass(assert, $about, 'ember-transitioning-in'); + assertHasNoClass(assert, $other, 'ember-transitioning-in'); + + assertHasNoClass(assert, $index, 'ember-transitioning-out'); + assertHasNoClass(assert, $about, 'ember-transitioning-out'); + assertHasClass(assert, $other, 'ember-transitioning-out'); + + this.resolveAbout(); + + $index = this.$('#index-link'); + $about = this.$('#about-link'); + $other = this.$('#other-link'); + + assertHasNoClass(assert, $index, 'active'); + assertHasClass(assert, $about, 'active'); + assertHasNoClass(assert, $other, 'active'); + + assertHasNoClass(assert, $index, 'ember-transitioning-in'); + assertHasNoClass(assert, $about, 'ember-transitioning-in'); + assertHasNoClass(assert, $other, 'ember-transitioning-in'); + + assertHasNoClass(assert, $index, 'ember-transitioning-out'); + assertHasNoClass(assert, $about, 'ember-transitioning-out'); + assertHasNoClass(assert, $other, 'ember-transitioning-out'); + } + } + ); +} diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-curly-test.js similarity index 100% rename from packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-test.js rename to packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-curly-test.js From c51e381dc77fc636285a800f445c3e4af3730de5 Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Mon, 18 Mar 2019 19:11:49 -0700 Subject: [PATCH 2/2] Fix docs issue --- packages/@ember/-internals/glimmer/lib/components/link-to.ts | 4 ++++ tests/docs/expected.js | 3 +++ 2 files changed, 7 insertions(+) diff --git a/packages/@ember/-internals/glimmer/lib/components/link-to.ts b/packages/@ember/-internals/glimmer/lib/components/link-to.ts index dc3c67d95e6..4a13b2d266a 100644 --- a/packages/@ember/-internals/glimmer/lib/components/link-to.ts +++ b/packages/@ember/-internals/glimmer/lib/components/link-to.ts @@ -344,24 +344,28 @@ if (EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS) { /** @property route + @category EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS @public */ route: UNDEFINED, /** @property model + @category EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS @public */ model: UNDEFINED, /** @property models + @category EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS @public */ models: UNDEFINED, /** @property query + @category EMBER_GLIMMER_ANGLE_BRACKET_BUILT_INS @public */ query: UNDEFINED, diff --git a/tests/docs/expected.js b/tests/docs/expected.js index ffe1aa8bdc0..0910470de8a 100644 --- a/tests/docs/expected.js +++ b/tests/docs/expected.js @@ -354,6 +354,7 @@ module.exports = { 'mixin', 'model', 'modelFor', + 'models', 'mount', 'mut', 'name', @@ -406,6 +407,7 @@ module.exports = { 'pushObjects', 'pushState', 'query-params', + 'query', 'queryParams', 'queryParamsDidChange', 'queues', @@ -477,6 +479,7 @@ module.exports = { 'reverseObjects', 'rootElement', 'rootURL', + 'route', 'routeDidChange', 'routeName', 'routeWillChange',