From 6238a51580254f12d09daf5feb16587e7f46303c Mon Sep 17 00:00:00 2001 From: Godfrey Chan Date: Wed, 17 Feb 2021 20:52:33 -0800 Subject: [PATCH] [FEATURE modernized-built-in-components] Implement Part of #19270 --- packages/@ember/-internals/glimmer/index.ts | 2 +- .../glimmer/lib/component-managers/curly.ts | 16 +- .../glimmer/lib/components/-link-to.ts | 1057 ++++++++++++ .../glimmer/lib/components/abstract-input.ts | 13 +- .../glimmer/lib/components/input.ts | 18 +- .../glimmer/lib/components/internal.ts | 89 +- .../glimmer/lib/components/link-to.ts | 1516 +++++++---------- .../-internals/glimmer/lib/setup-registry.ts | 5 +- .../glimmer/lib/templates/-link-to.d.ts | 3 + .../glimmer/lib/templates/-link-to.hbs | 5 + .../glimmer/lib/templates/link-to.hbs | 25 +- .../application/debug-render-tree-test.ts | 37 +- .../components/link-to/routing-angle-test.js | 240 ++- .../components/link-to/routing-curly-test.js | 266 ++- .../transitioning-classes-angle-test.js | 21 +- .../transitioning-classes-curly-test.js | 21 +- packages/@ember/-internals/routing/index.ts | 3 +- .../ember/tests/routing/query_params_test.js | 17 +- .../query_param_async_get_handler_test.js | 46 +- .../lib/ember-dev/deprecation.ts | 2 +- 20 files changed, 2284 insertions(+), 1118 deletions(-) create mode 100644 packages/@ember/-internals/glimmer/lib/components/-link-to.ts create mode 100644 packages/@ember/-internals/glimmer/lib/templates/-link-to.d.ts create mode 100644 packages/@ember/-internals/glimmer/lib/templates/-link-to.hbs diff --git a/packages/@ember/-internals/glimmer/index.ts b/packages/@ember/-internals/glimmer/index.ts index c8a801895ed..f29f23a4a77 100644 --- a/packages/@ember/-internals/glimmer/index.ts +++ b/packages/@ember/-internals/glimmer/index.ts @@ -367,7 +367,7 @@ export { default as RootTemplate } from './lib/templates/root'; export { default as Checkbox } from './lib/components/checkbox'; export { default as TextField } from './lib/components/text-field'; export { default as TextArea } from './lib/components/-textarea'; -export { default as LinkComponent } from './lib/components/link-to'; +export { default as LinkComponent } from './lib/components/-link-to'; export { default as Input } from './lib/components/input'; export { default as Textarea } from './lib/components/textarea'; export { default as Component } from './lib/component'; diff --git a/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts b/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts index 88ec920ca62..d2cb69a1c26 100644 --- a/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts +++ b/packages/@ember/-internals/glimmer/lib/component-managers/curly.ts @@ -8,6 +8,7 @@ import { assign } from '@ember/polyfills'; import { DEBUG } from '@glimmer/env'; import { Bounds, + CapturedArguments, CompilableProgram, Destroyable, ElementOperations, @@ -178,14 +179,19 @@ export default class CurlyComponentManager prepareArgs(ComponentClass: ComponentFactory, args: VMArguments): Option { if (args.named.has('__ARGS__')) { + assert( + '[BUG] cannot pass both __ARGS__ and positional arguments', + args.positional.length === 0 + ); + let { __ARGS__, ...rest } = args.named.capture(); + // does this need to be untracked? + let __args__ = valueForRef(__ARGS__) as CapturedArguments; + let prepared = { - positional: EMPTY_POSITIONAL_ARGS, - named: { - ...rest, - ...(valueForRef(__ARGS__) as { [key: string]: Reference }), - }, + positional: __args__.positional, + named: { ...rest, ...__args__.named }, }; return prepared; diff --git a/packages/@ember/-internals/glimmer/lib/components/-link-to.ts b/packages/@ember/-internals/glimmer/lib/components/-link-to.ts new file mode 100644 index 00000000000..b95c605f16d --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/components/-link-to.ts @@ -0,0 +1,1057 @@ +/** +@module ember +*/ + +import { alias, computed } from '@ember/-internals/metal'; +import { getOwner } from '@ember/-internals/owner'; +import RouterState from '@ember/-internals/routing/lib/system/router_state'; +import { CoreObject } from '@ember/-internals/runtime'; +import { isSimpleClick } from '@ember/-internals/views'; +import { EMBER_MODERNIZED_BUILT_IN_COMPONENTS } from '@ember/canary-features'; +import { assert, deprecate, runInDebug, warn } from '@ember/debug'; +import { EngineInstance, getEngineParent } from '@ember/engine'; +import { flaggedInstrument } from '@ember/instrumentation'; +import { inject as injectService } from '@ember/service'; +import { DEBUG } from '@glimmer/env'; +import EmberComponent from '../component'; +import { HAS_BLOCK } from '../component-managers/curly'; +import layout from '../templates/-link-to'; + +/** + The `LinkTo` 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 `LinkTo` + becomes the contents of the rendered element: + + ```handlebars + + Great Hamster Photos + + ``` + + This will result in: + + ```html + + Great Hamster Photos + + ``` + + ### Disabling the `LinkTo` component + + The `LinkTo` component can be disabled by using the `disabled` argument. A disabled link + doesn't result in a transition when activated, and adds the `disabled` class to the `` + element. + + (The class name to apply to the element can be overridden by using the `disabledClass` + argument) + + ```handlebars + + Great Hamster Photos + + ``` + + ### Handling `href` + + `` will use your application's Router to fill the element's `href` property with a URL + that matches the path to the supplied `routeName`. + + ### Handling current route + + The `LinkTo` component 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', then the following invocation of `LinkTo`: + + ```handlebars + + Great Hamster Photos + + ``` + + will result in + + ```html + + Great Hamster Photos + + ``` + + The CSS class used for active classes can be customized by passing an `activeClass` argument: + + ```handlebars + + Great Hamster Photos + + ``` + + ```html + + Great Hamster Photos + + ``` + + ### 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 + + Photo Gallery + + ``` + + 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 + + Art Gallery + + ``` + + ### 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 + + {{aPhoto.title}} + + ``` + + ```html + + Tomster + + ``` + + ### Supplying multiple models + + For deep-linking to route paths that contain multiple + dynamic segments, the `models` argument 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 + + {{comment.body}} + + ``` + + ```html + + A+++ would snuggle again. + + ``` + + ### Supplying an explicit dynamic segment value + + If you don't have a model object available to pass to `LinkTo`, + 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 + + {{this.aPhoto.title}} + + ``` + + ```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 `` component prevents the default browser action by calling + `preventDefault()` to avoid reloading the browser page. + + If you need to trigger a full browser reload pass `@preventDefault={{false}}`: + + ```handlebars + + {{this.aPhotoId.title}} + + ``` + + ### Supplying a `tagName` + + By default `` renders an `` element. This can be overridden for a single use of + `` by supplying a `tagName` argument: + + ```handlebars + + Great Hamster Photos + + ``` + + This produces: + + ```html +
  • + Great Hamster Photos +
  • + ``` + + In general, this is not recommended. + + ### Supplying query parameters + + If you need to add optional key-value pairs that appear to the right of the ? in a URL, + you can use the `query` argument. + + ```handlebars + + Great Hamster Photos + + ``` + + This will result in: + + ```html +
    + Great Hamster Photos + + ``` + + @for Ember.Templates.components + @method LinkTo + @see {LinkComponent} + @public +*/ + +/** + @module @ember/routing +*/ + +/** + See [Ember.Templates.components.LinkTo](/ember/release/classes/Ember.Templates.components/methods/input?anchor=LinkTo). + + @for Ember.Templates.helpers + @method link-to + @see {Ember.Templates.components.LinkTo} + @public +**/ + +/** + `LinkComponent` is the internal component invoked with `` or `{{link-to}}`. + + @class LinkComponent + @extends Component + @see {Ember.Templates.components.LinkTo} + @public +**/ + +const UNDEFINED = Object.freeze({ + toString() { + return 'UNDEFINED'; + }, +}); + +const EMPTY_QUERY_PARAMS = Object.freeze({}); + +const 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 this component will forward `href`, `title`, `rel`, `tabindex`, and `target` + arguments to attributes on the component's element. When invoked with `{{link-to}}`, you can + only customize these attributes. When invoked with ``, you can just use HTML + attributes directly. + + @property attributeBindings + @type Array | String + @default ['title', 'rel', 'tabindex', 'target'] + @public + */ + attributeBindings: ['href', 'title', 'rel', 'tabindex', 'target'], + + /** + By default this component will set classes on its element when any of the following arguments + are truthy: + + * active + * loading + * disabled + + When these arguments are truthy, a class with the same name will be set on the element. When + falsy, the associated class will not be on the element. + + @property classNameBindings + @type Array + @default ['active', 'loading', 'disabled', 'ember-transitioning-in', 'ember-transitioning-out'] + @public + */ + classNameBindings: ['active', 'loading', 'disabled', 'transitioningIn', 'transitioningOut'], + + /** + By default this component responds to the `click` event. When the component element is an + `` element, activating the link in another way, such as using the keyboard, triggers the + click 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); + + assert( + 'You attempted to use the component within a routeless engine, this is not supported. ' + + 'If you are using the ember-engines addon, use the component instead. ' + + 'See https://ember-engines.com/docs/links for more info.', + !this._isEngine || this._engineMountPoint !== undefined + ); + + // Map desired event name to invoke function + let { eventName } = this; + this.on(eventName, this, this._invoke); + }, + + _routing: injectService('-routing'), + _currentRoute: alias('_routing.currentRouteName'), + _currentRouterState: alias('_routing.currentState'), + _targetRouterState: alias('_routing.targetState'), + + _isEngine: computed(function (this: any) { + return getEngineParent(getOwner(this) as EngineInstance) !== undefined; + }), + + _engineMountPoint: computed(function (this: any) { + return (getOwner(this) as EngineInstance).mountPoint; + }), + + _route: computed('route', '_currentRouterState', function computeLinkToComponentRoute(this: any) { + let { route } = this; + + return route === UNDEFINED ? this._currentRoute : this._namespaceRoute(route); + }), + + _models: computed('model', 'models', function computeLinkToComponentModels(this: any) { + let { model, models } = this; + + assert( + 'You cannot provide both the `@model` and `@models` arguments to the component.', + model === UNDEFINED || models === UNDEFINED + ); + + if (model !== UNDEFINED) { + return [model]; + } else if (models !== UNDEFINED) { + assert('The `@models` argument must be an array.', Array.isArray(models)); + return models; + } else { + return []; + } + }), + + _query: computed('query', function computeLinkToComponentQuery(this: any) { + let { query } = this; + + if (query === UNDEFINED) { + return EMPTY_QUERY_PARAMS; + } else { + return Object.assign({}, query); + } + }), + + /** + Accessed as a classname binding to apply the component'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 occurred + 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 component's `activeClass` + CSS `class` to the element when the link is active. + + This component is considered active when its `currentWhen` property is `true` + or the application's current route is the route this component 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: RouterState): boolean { + if (this.loading) { + return false; + } + + let currentWhen = this['current-when']; + + if (typeof currentWhen === 'boolean') { + return currentWhen; + } + + let { _models: models, _routing: routing } = this; + + if (typeof currentWhen === 'string') { + return currentWhen + .split(' ') + .some((route) => + routing.isActiveForRoute(models, undefined, this._namespaceRoute(route), routerState) + ); + } else { + return routing.isActiveForRoute(models, this._query, this._route, routerState); + } + }, + + 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; + } + } + ), + + _namespaceRoute(route: string): string { + let { _engineMountPoint: mountPoint } = this; + + if (mountPoint === undefined) { + return route; + } else if (route === 'application') { + return mountPoint; + } else { + return `${mountPoint}.${route}`; + } + }, + + /** + 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; + } + + let { bubbles, preventDefault } = this; + let target = this.element.target; + let isSelf = !target || target === '_self'; + + if (preventDefault !== false && isSelf) { + event.preventDefault(); + } + + if (bubbles === false) { + event.stopPropagation(); + } + + if (this._isDisabled) { + return false; + } + + 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; + }, + + _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( + '_currentRouterState', + '_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 + e.message = `While generating link to route "${this.route}": ${e.message}`; + throw e; + } + } 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 + ) + ); + + let { _models: models } = this; + if (models.length > 0) { + let lastModel = models[models.length - 1]; + + if (typeof lastModel === 'object' && lastModel !== null && lastModel.isQueryParams) { + this.query = lastModel.values; + models.pop(); + } + } + + return; + } + + let hasBlock = this[HAS_BLOCK]; + + params = params.slice(); + + // Process the positional arguments, in order. + // 1. Inline link title comes first, if present. + if (!hasBlock) { + this.set('linkTitle', params.shift()); + } + + // 2. The last argument is possibly the `query` object. + let queryParams = params[params.length - 1]; + + if (queryParams && queryParams.isQueryParams) { + this.set('query', params.pop().values); + } else { + this.set('query', UNDEFINED); + } + + // 3. If there is a `route`, it is now at index 0. + if (params.length === 0) { + this.set('route', UNDEFINED); + } else { + this.set('route', params.shift()); + } + + // 4. Any remaining indices (if any) are `models`. + this.set('model', UNDEFINED); + this.set('models', params); + + runInDebug(() => { + params = this.params.slice(); + + let equivalentNamedArgs = []; + let hasQueryParams = false; + + // Process the positional arguments, in order. + // 1. Inline link title comes first, if present. + if (!hasBlock) { + params.shift(); + } + + // 2. The last argument is possibly the `query` object. + let query = params[params.length - 1]; + + if (query && query.isQueryParams) { + params.pop(); + hasQueryParams = true; + } + + // 3. If there is a `route`, it is now at index 0. + if (params.length > 0) { + params.shift(); + equivalentNamedArgs.push('`@route`'); + } + + // 4. Any remaining params (if any) are `models`. + if (params.length === 1) { + equivalentNamedArgs.push('`@model`'); + } else if (params.length > 1) { + equivalentNamedArgs.push('`@models`'); + } + + if (hasQueryParams) { + equivalentNamedArgs.push('`@query`'); + } + + if (equivalentNamedArgs.length > 0) { + let message = 'Invoking the `` component with positional arguments is deprecated.'; + + message += `Please use the equivalent named arguments (${equivalentNamedArgs.join(', ')})`; + + if (hasQueryParams) { + message += ' along with the `hash` helper'; + } + + if (!hasBlock) { + message += " and pass a block for the link's content."; + } + + message += '.'; + + deprecate(message, false, { + id: 'ember-glimmer.link-to.positional-arguments', + until: '4.0.0', + for: 'ember-source', + url: + 'https://deprecations.emberjs.com/v3.x#toc_ember-glimmer-link-to-positional-arguments', + since: { + enabled: '3.26.0-beta.1', + }, + }); + } + }); + }, +}); + +LinkComponent.toString = () => '@ember/routing/link-component'; + +LinkComponent.reopenClass({ + positionalParams: 'params', +}); + +if (EMBER_MODERNIZED_BUILT_IN_COMPONENTS) { + Object.defineProperty(LinkComponent, '_wasReopened', { + configurable: true, + enumerable: false, + writable: true, + value: false, + }); + + Object.defineProperty(LinkComponent, 'reopen', { + configurable: true, + enumerable: false, + writable: true, + value: function reopen(this: typeof LinkComponent, ...args: unknown[]): unknown { + if (this === LinkComponent) { + deprecate( + 'Reopening Ember.LinkComponent is deprecated. Consider implementing your own ' + + 'wrapper component or create a custom subclass.', + false, + { + id: 'ember.built-in-components.reopen', + for: 'ember-source', + since: {}, + until: '4.0.0', + } + ); + + LinkComponent._wasReopened = true; + } + + return CoreObject.reopen.call(this, ...args); + }, + }); + + Object.defineProperty(LinkComponent, 'reopenClass', { + configurable: true, + enumerable: false, + writable: true, + value: function reopenClass(this: typeof LinkComponent, ...args: unknown[]): unknown { + if (this === LinkComponent) { + deprecate( + 'Reopening Ember.LinkComponent is deprecated. Consider implementing your own ' + + 'wrapper component or create a custom subclass.', + false, + { + id: 'ember.built-in-components.reopen', + for: 'ember-source', + since: {}, + until: '4.0.0', + } + ); + + LinkComponent._wasReopened = true; + } + + return CoreObject.reopenClass.call(this, ...args); + }, + }); +} + +export default LinkComponent; diff --git a/packages/@ember/-internals/glimmer/lib/components/abstract-input.ts b/packages/@ember/-internals/glimmer/lib/components/abstract-input.ts index cc08b53de5e..0e8ad503e2f 100644 --- a/packages/@ember/-internals/glimmer/lib/components/abstract-input.ts +++ b/packages/@ember/-internals/glimmer/lib/components/abstract-input.ts @@ -116,6 +116,15 @@ export default abstract class AbstractInput implements DeprecatingInternalComponent { modernized = this.shouldModernize(); + validateArguments(): void { + assert( + `The ${this.constructor} component does not take any positional arguments`, + this.args.positional.length === 0 + ); + + super.validateArguments(); + } + protected shouldModernize(): boolean { return ( Boolean(EMBER_MODERNIZED_BUILT_IN_COMPONENTS) && @@ -125,7 +134,7 @@ export default abstract class AbstractInput ); } - private _value = valueFrom(this.args.value); + private _value = valueFrom(this.args.named.value); get value(): unknown { return this._value.get(); @@ -216,7 +225,7 @@ export function handleDeprecatedFeatures( configurable: true, enumerable: false, value: function listenerFor(this: AbstractInput, name: string): EventListener { - const actionName = this.arg(name); + const actionName = this.named(name); if (typeof actionName === 'string') { deprecate( diff --git a/packages/@ember/-internals/glimmer/lib/components/input.ts b/packages/@ember/-internals/glimmer/lib/components/input.ts index b01be4a3793..b977bc5c6a8 100644 --- a/packages/@ember/-internals/glimmer/lib/components/input.ts +++ b/packages/@ember/-internals/glimmer/lib/components/input.ts @@ -186,7 +186,7 @@ class Input extends AbstractInput { * The HTML type attribute. */ get type(): string { - let type = this.arg('type'); + let type = this.named('type'); if (type === null || type === undefined) { return 'text'; @@ -201,10 +201,10 @@ class Input extends AbstractInput { } get isCheckbox(): boolean { - return this.arg('type') === 'checkbox'; + return this.named('type') === 'checkbox'; } - private _checked = valueFrom(this.args.checked); + private _checked = valueFrom(this.args.named.checked); get checked(): unknown { if (this.isCheckbox) { @@ -214,9 +214,9 @@ class Input extends AbstractInput { 'Did you mean ``?', untrack( () => - this.args.checked !== undefined || - this.args.value === undefined || - typeof valueForRef(this.args.value) === 'string' + this.args.named.checked !== undefined || + this.args.named.value === undefined || + typeof valueForRef(this.args.named.value) === 'string' ), { id: 'ember.built-in-components.input-checkbox-value' } ); @@ -234,9 +234,9 @@ class Input extends AbstractInput { 'Did you mean ``?', untrack( () => - this.args.checked !== undefined || - this.args.value === undefined || - typeof valueForRef(this.args.value) === 'string' + this.args.named.checked !== undefined || + this.args.named.value === undefined || + typeof valueForRef(this.args.named.value) === 'string' ), { id: 'ember.built-in-components.input-checkbox-value' } ); diff --git a/packages/@ember/-internals/glimmer/lib/components/internal.ts b/packages/@ember/-internals/glimmer/lib/components/internal.ts index 1643892ab47..b30bc75812c 100644 --- a/packages/@ember/-internals/glimmer/lib/components/internal.ts +++ b/packages/@ember/-internals/glimmer/lib/components/internal.ts @@ -5,7 +5,7 @@ import { EMBER_MODERNIZED_BUILT_IN_COMPONENTS } from '@ember/canary-features'; import { assert, deprecate } from '@ember/debug'; import { JQUERY_INTEGRATION } from '@ember/deprecated-features'; import { - CapturedNamedArguments, + CapturedArguments, Destroyable, DynamicScope, Environment, @@ -38,7 +38,7 @@ export default class InternalComponent { constructor( protected owner: Owner, - protected readonly args: Record, + protected readonly args: CapturedArguments, protected readonly caller: unknown ) { setOwner(this, owner); @@ -66,13 +66,26 @@ export default class InternalComponent { return 'ember-view'; } - protected arg(name: string): unknown { - let ref = this.args[name]; + protected validateArguments(): void { + for (let name of Object.keys(this.args.named)) { + if (!this.isSupportedArgument(name)) { + this.onUnsupportedArgument(name); + } + } + } + + protected named(name: string): unknown { + let ref = this.args.named[name]; + return ref ? valueForRef(ref) : undefined; + } + + protected positional(index: number): unknown { + let ref = this.args.positional[index]; return ref ? valueForRef(ref) : undefined; } protected listenerFor(name: string): EventListener { - let listener = this.arg(name); + let listener = this.named(name); if (listener) { assert( @@ -95,12 +108,12 @@ export default class InternalComponent { protected onUnsupportedArgument(_name: string): void {} toString(): string { - return `<${this.constructor.toString()}:${guidFor(this)}>`; + return `<${this.constructor}:${guidFor(this)}>`; } } export interface InternalComponentConstructor { - new (owner: Owner, args: CapturedNamedArguments, caller: unknown): T; + new (owner: Owner, args: CapturedArguments, caller: unknown): T; prototype: T; toString(): string; } @@ -188,22 +201,9 @@ class InternalManager let ComponentClass = deopaquify(definition); - assert( - `The ${definition.toString()} component does not take any positional arguments`, - args.positional.length === 0 - ); - - let instance = new ComponentClass(owner, args.named.capture(), valueForRef(caller)); + let instance = new ComponentClass(owner, args.capture(), valueForRef(caller)); - untrack( - function (this: InternalComponent): void { - for (let name of args.named.names) { - if (!this.isSupportedArgument(name)) { - this.onUnsupportedArgument(name); - } - } - }.bind(instance) - ); + untrack(instance['validateArguments'].bind(instance)); return instance; } @@ -274,7 +274,7 @@ export function handleDeprecatedAttributeArguments( ): void { if (EMBER_MODERNIZED_BUILT_IN_COMPONENTS) { let angle = target.toString(); - let curly = angle.toLowerCase(); + let curly = angle.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase(); let { prototype } = target; let descriptorFor = (target: object, property: string): Option => { @@ -329,7 +329,7 @@ export function handleDeprecatedAttributeArguments( configurable: true, enumerable: true, get(this: DeprecatingInternalComponent): unknown { - if (argument in this.args) { + if (argument in this.args.named) { deprecate( `Passing the \`@${argument}\` argument to <${angle}> is deprecated. ` + `Instead, please pass the attribute directly, i.e. \`<${angle} ${attribute}={{...}} />\` ` + @@ -345,9 +345,9 @@ export function handleDeprecatedAttributeArguments( // The `class` attribute is concatenated/merged instead of clobbered if (attribute === 'class' && superDescriptor) { - return `${superGetter.call(this)} ${this.arg(argument)}`; + return `${superGetter.call(this)} ${this.named(argument)}`; } else { - return this.arg(argument); + return this.named(argument); } } else { return superGetter.call(this); @@ -360,7 +360,7 @@ export function handleDeprecatedAttributeArguments( export let handleDeprecatedEventArguments: ( target: DeprecatingInternalComponentConstructor, - extraEvents: Array<[event: string, argument: string]> + extraEvents?: Array<[event: string, argument: string]> ) => void = NOOP; if (EMBER_MODERNIZED_BUILT_IN_COMPONENTS) { @@ -368,33 +368,42 @@ if (EMBER_MODERNIZED_BUILT_IN_COMPONENTS) { const EVENTS = new WeakMap(); + const EMPTY_EVENTS = Object.freeze({}) as EventsMap; + const getEventsMap = (owner: Owner): EventsMap => { let events = EVENTS.get(owner); if (events === undefined) { + events = EMPTY_EVENTS; + let eventDispatcher = owner.lookup>( 'event_dispatcher:main' ); - assert( - '[BUG] missing event dispatcher', - eventDispatcher !== null && typeof eventDispatcher === 'object' - ); + if (eventDispatcher !== null && eventDispatcher !== undefined) { + assert('[BUG] invalid event dispatcher', typeof eventDispatcher === 'object'); + + if ( + '_finalEvents' in eventDispatcher && + eventDispatcher._finalEvents !== null && + eventDispatcher._finalEvents !== undefined + ) { + assert( + '[BUG] invalid _finalEvents on event dispatcher', + typeof eventDispatcher._finalEvents === 'object' + ); - assert( - '[BUG] missing _finalEvents on event dispatcher', - '_finalEvents' in eventDispatcher && - eventDispatcher?._finalEvents !== null && - typeof eventDispatcher?._finalEvents === 'object' - ); + events = eventDispatcher._finalEvents; + } + } - EVENTS.set(owner, (events = eventDispatcher._finalEvents)); + EVENTS.set(owner, events); } return events; }; - handleDeprecatedEventArguments = (target, extraEvents) => { + handleDeprecatedEventArguments = (target, extraEvents = []) => { let angle = target.toString(); let curly = angle.toLowerCase(); let { prototype } = target; @@ -479,7 +488,7 @@ if (EMBER_MODERNIZED_BUILT_IN_COMPONENTS) { ): Option { assert(`[BUG] must be called with <${angle}> component as this`, this instanceof target); - if (argument in this.args) { + if (argument in this.args.named) { deprecate( `Passing the \`@${argument}\` argument to <${angle}> is deprecated. ` + `This would have overwritten the internal \`${argument}\` method on ` + diff --git a/packages/@ember/-internals/glimmer/lib/components/link-to.ts b/packages/@ember/-internals/glimmer/lib/components/link-to.ts index f9117580d39..32b69ea997b 100644 --- a/packages/@ember/-internals/glimmer/lib/components/link-to.ts +++ b/packages/@ember/-internals/glimmer/lib/components/link-to.ts @@ -1,1003 +1,741 @@ -/** -@module ember -*/ - -import { alias, computed } from '@ember/-internals/metal'; -import { getOwner } from '@ember/-internals/owner'; -import RouterState from '@ember/-internals/routing/lib/system/router_state'; +import { Owner } from '@ember/-internals/owner'; +import { RouterState, RoutingService } from '@ember/-internals/routing'; +import { QueryParam } from '@ember/-internals/routing/lib/system/router'; +import { TargetActionSupport } from '@ember/-internals/runtime'; import { isSimpleClick } from '@ember/-internals/views'; -import { assert, deprecate, runInDebug, warn } from '@ember/debug'; +import { EMBER_MODERNIZED_BUILT_IN_COMPONENTS } from '@ember/canary-features'; +import { assert, debugFreeze, deprecate, warn } from '@ember/debug'; +import { JQUERY_INTEGRATION } from '@ember/deprecated-features'; import { EngineInstance, getEngineParent } from '@ember/engine'; import { flaggedInstrument } from '@ember/instrumentation'; -import { inject as injectService } from '@ember/service'; +import { action } from '@ember/object'; +import { inject as service } from '@ember/service'; import { DEBUG } from '@glimmer/env'; -import EmberComponent from '../component'; -import { HAS_BLOCK } from '../component-managers/curly'; -import layout from '../templates/link-to'; - -/** - The `LinkTo` 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 `LinkTo` - becomes the contents of the rendered element: - - ```handlebars - - Great Hamster Photos - - ``` - - This will result in: - - ```html - - Great Hamster Photos - - ``` - - ### Disabling the `LinkTo` component - - The `LinkTo` component can be disabled by using the `disabled` argument. A disabled link - doesn't result in a transition when activated, and adds the `disabled` class to the `` - element. +import { Maybe, Option } from '@glimmer/interfaces'; +import { consumeTag, createCache, getValue, tagFor, untrack } from '@glimmer/validator'; +import { Transition } from 'router_js'; +import Component from '../component'; +import LinkToTemplate from '../templates/link-to'; +import LegacyLinkTo from './-link-to'; +import InternalComponent, { + DeprecatingInternalComponent, + handleDeprecatedArguments, + handleDeprecatedAttributeArguments, + handleDeprecatedEventArguments, + jQueryEventShim, + opaquify, +} from './internal'; + +const EMPTY_ARRAY: {}[] = []; +const EMPTY_QUERY_PARAMS = {}; + +debugFreeze(EMPTY_ARRAY); +debugFreeze(EMPTY_QUERY_PARAMS); + +function isMissing(value: Maybe): value is null | undefined { + return value === null || value === undefined; +} + +function isPresent(value: Maybe): value is T { + return !isMissing(value); +} + +interface QueryParams { + isQueryParams: true; + values: Option<{}>; +} + +function isQueryParams(value: unknown): value is QueryParams { + return typeof value === 'object' && value !== null && value['isQueryParams'] === true; +} + +class LinkTo extends InternalComponent implements DeprecatingInternalComponent { + static toString(): string { + return 'LinkTo'; + } + + modernized = this.shouldModernize(); + + @service('-routing') private declare routing: RoutingService; + + validateArguments(): void { + assert( + 'You attempted to use the component within a routeless engine, this is not supported. ' + + 'If you are using the ember-engines addon, use the component instead. ' + + 'See https://ember-engines.com/docs/links for more info.', + !this.modernized || !this.isEngine || this.engineMountPoint !== undefined + ); - (The class name to apply to the element can be overridden by using the `disabledClass` - argument) + assert( + 'You must provide at least one of the `@route`, `@model`, `@models` or `@query` argument to ``.', + !this.modernized || + 'route' in this.args.named || + 'model' in this.args.named || + 'models' in this.args.named || + 'query' in this.args.named + ); - ```handlebars - - Great Hamster Photos - - ``` + assert( + 'You cannot provide both the `@model` and `@models` arguments to the component.', + !('model' in this.args.named && 'models' in this.args.named) + ); - ### Handling `href` + super.validateArguments(); + } - `` will use your application's Router to fill the element's `href` property with a URL - that matches the path to the supplied `routeName`. + get class(): string { + let classes = 'ember-view'; - ### Handling current route + if (this.isActive) { + classes += this.classFor('active'); - The `LinkTo` component 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', then the following invocation of `LinkTo`: + if (this.willBeActive === false) { + classes += ' ember-transitioning-out'; + } + } else if (this.willBeActive) { + classes += ' ember-transitioning-in'; + } - ```handlebars - - Great Hamster Photos - - ``` + if (this.isLoading) { + classes += this.classFor('loading'); + } - will result in + if (this.isDisabled) { + classes += this.classFor('disabled'); + } - ```html - - Great Hamster Photos - - ``` + return classes; + } - The CSS class used for active classes can be customized by passing an `activeClass` argument: + get href() { + if (this.isLoading) { + return '#'; + } - ```handlebars - - Great Hamster Photos - - ``` + let { routing, route, models, query } = this; - ```html - - Great Hamster Photos - - ``` + assert('[BUG] route can only be missing if isLoading is true', isPresent(route)); - ### Keeping a link active for other routes + // consume the current router state so we invalidate when QP changes + // TODO: can we narrow this down to QP changes only? + consumeTag(tagFor(routing, 'currentState')); - If you need a link to be 'active' even when it doesn't match the current route, you can use the - `current-when` argument. + if (DEBUG) { + try { + return routing.generateURL(route, models, query); + } catch (e) { + // tslint:disable-next-line:max-line-length + e.message = `While generating link to route "${route}": ${e.message}`; + throw e; + } + } else { + return routing.generateURL(route, models, query); + } + } - ```handlebars - - Photo Gallery - - ``` + @action click(event: Event): void { + if (!isSimpleClick(event)) { + return; + } - This may be helpful for keeping links active for: + let element = event.target; + assert('[BUG] must be an element', element instanceof HTMLAnchorElement); - * non-nested routes that are logically related - * some secondary menu approaches - * 'top navigation' with 'sub navigation' scenarios + let isSelf = element.target === '' || element.target === '_self'; - A link will be active if `current-when` is `true` or the current - route is the route this link would transition to. + if (isSelf) { + this.preventDefault(event); + } else { + return; + } - To match multiple routes 'space-separate' the routes: + if (this.isDisabled) { + return; + } - ```handlebars - - Art Gallery - - ``` + if (this.isLoading) { + 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', + } + ); - ### Supplying a model + return; + } - 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: + let { routing, route, models, query, replace } = this; - ```javascript - Router.map(function() { - this.route("photoGallery", {path: "hamster-photos/:photo_id"}); - }); - ``` + let payload = { + routeName: route, + queryParams: query, + transition: undefined as Transition | undefined, + }; - ```handlebars - - {{aPhoto.title}} - - ``` + flaggedInstrument('interaction.link-to', payload, () => { + assert('[BUG] route can only be missing if isLoading is true', isPresent(route)); - ```html - - Tomster - - ``` + // TODO: is the signature wrong? this.query is definitely NOT a QueryParam! + payload.transition = routing.transitionTo(route, models, query as QueryParam, replace); + }); + } - ### Supplying multiple models + private get route(): Maybe { + if ('route' in this.args.named) { + let route = this.named('route'); - For deep-linking to route paths that contain multiple - dynamic segments, the `models` argument can be used. + assert( + 'The `@route` argument to the component must be a string', + isMissing(route) || typeof route === 'string' + ); - As the router transitions through the route path, each - supplied model argument will become the context for the - route with the dynamic segments: + return route && this.namespaceRoute(route); + } else { + return this.currentRoute; + } + } - ```javascript - Router.map(function() { - this.route("photoGallery", { path: "hamster-photos/:photo_id" }, function() { - this.route("comment", {path: "comments/:comment_id"}); - }); + // GH #17963 + private currentRouteCache = createCache>(() => { + consumeTag(tagFor(this.routing, 'currentState')); + return untrack(() => this.routing.currentRouteName); }); - ``` - This argument will become the model context of the linked route: + private get currentRoute(): Maybe { + return getValue(this.currentRouteCache); + } - ```handlebars - - {{comment.body}} - - ``` + // TODO: not sure why generateURL takes {}[] instead of unknown[] + private get models(): {}[] { + if ('models' in this.args.named) { + let models = this.named('models'); - ```html - - A+++ would snuggle again. - - ``` + assert( + 'The `@models` argument to the component must be an array.', + Array.isArray(models) + ); - ### Supplying an explicit dynamic segment value + return models; + } else if ('model' in this.args.named) { + return [this.named('model') as {}]; + } else { + return EMPTY_ARRAY; + } + } - If you don't have a model object available to pass to `LinkTo`, - 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: + // TODO: this should probably be Record or something + private get query(): {} { + if ('query' in this.args.named) { + let query = this.named('query'); - ```javascript - Router.map(function() { - this.route("photoGallery", { path: "hamster-photos/:photo_id" }); - }); - ``` - - ```handlebars - - {{this.aPhoto.title}} - - ``` - - ```html - - Tomster - - ``` - - When transitioning into the linked route, the `model` hook will - be triggered with parameters including this passed identifier. - - ### Supplying a `tagName` - - By default `` renders an `` element. This can be overridden for a single use of - `` by supplying a `tagName` argument: - - ```handlebars - - Great Hamster Photos - - ``` - - This produces: - - ```html -
  • - Great Hamster Photos -
  • - ``` - - In general, this is not recommended. - - ### Supplying query parameters - - If you need to add optional key-value pairs that appear to the right of the ? in a URL, - you can use the `query` argument. - - ```handlebars - - Great Hamster Photos - - ``` - - This will result in: - - ```html -
    - Great Hamster Photos - - ``` - - @for Ember.Templates.components - @method LinkTo - @see {LinkComponent} - @public -*/ - -/** - @module @ember/routing -*/ - -/** - See [Ember.Templates.components.LinkTo](/ember/release/classes/Ember.Templates.components/methods/input?anchor=LinkTo). - - @for Ember.Templates.helpers - @method link-to - @see {Ember.Templates.components.LinkTo} - @public -**/ - -/** - `LinkComponent` is the internal component invoked with `` or `{{link-to}}`. - - @class LinkComponent - @extends Component - @see {Ember.Templates.components.LinkTo} - @public -**/ - -const UNDEFINED = Object.freeze({ - toString() { - return 'UNDEFINED'; - }, -}); - -const EMPTY_QUERY_PARAMS = Object.freeze({}); - -const 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 - @public - **/ - loadingClass: 'loading', - - /** - The CSS class to apply to a `LinkComponent`'s element when its `disabled` - property is `true`. - - @property disabledClass - @type String - @default disabled - @public - **/ - disabledClass: 'disabled', - - /** - Determines whether the `LinkComponent` will trigger routing via - the `replaceWith` routing strategy. - - @property replace - @type Boolean - @default false - @public - **/ - replace: false, - - /** - Determines whether the `LinkComponent` will prevent the default - browser action by calling preventDefault() to avoid reloading - the browser page. - - If you need to trigger a full browser reload pass `@preventDefault={{false}}`: - - ```handlebars - - {{this.aPhotoId.title}} - - ``` - - @property preventDefault - @type Boolean - @default true - @private - **/ - preventDefault: true, - - /** - By default this component will forward `href`, `title`, `rel`, `tabindex`, and `target` - arguments to attributes on the component's element. When invoked with `{{link-to}}`, you can - only customize these attributes. When invoked with ``, you can just use HTML - attributes directly. - - @property attributeBindings - @type Array | String - @default ['title', 'rel', 'tabindex', 'target'] - @public - */ - attributeBindings: ['href', 'title', 'rel', 'tabindex', 'target'], - - /** - By default this component will set classes on its element when any of the following arguments - are truthy: - - * active - * loading - * disabled - - When these arguments are truthy, a class with the same name will be set on the element. When - falsy, the associated class will not be on the element. - - @property classNameBindings - @type Array - @default ['active', 'loading', 'disabled', 'ember-transitioning-in', 'ember-transitioning-out'] - @public - */ - classNameBindings: ['active', 'loading', 'disabled', 'transitioningIn', 'transitioningOut'], - - /** - By default this component responds to the `click` event. When the component element is an - `` element, activating the link in another way, such as using the keyboard, triggers the - click 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')); - } - }); - ``` + assert( + 'The `@query` argument to the component must be an object.', + query !== null && typeof query === 'object' + ); - 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. + return { ...query }; + } else { + return EMPTY_QUERY_PARAMS; + } + } - @method init - @private - */ - init() { - this._super(...arguments); + private get replace(): boolean { + return this.named('replace') === true; + } - assert( - 'You attempted to use the component within a routeless engine, this is not supported. ' + - 'If you are using the ember-engines addon, use the component instead. ' + - 'See https://ember-engines.com/docs/links for more info.', - !this._isEngine || this._engineMountPoint !== undefined - ); + private get isActive(): boolean { + return this.isActiveForState(this.routing.currentState as Maybe); + } - // Map desired event name to invoke function - let { eventName } = this; - this.on(eventName, this, this._invoke); - }, + private get willBeActive(): Option { + let current = this.routing.currentState as Maybe; + let target = this.routing.targetState as Maybe; - _routing: injectService('-routing'), - _currentRoute: alias('_routing.currentRouteName'), - _currentRouterState: alias('_routing.currentState'), - _targetRouterState: alias('_routing.targetState'), + if (current === target) { + return null; + } else { + return this.isActiveForState(target); + } + } - _isEngine: computed(function (this: any) { - return getEngineParent(getOwner(this) as EngineInstance) !== undefined; - }), + private get isLoading(): boolean { + return isMissing(this.route) || this.models.some((model) => isMissing(model)); + } - _engineMountPoint: computed(function (this: any) { - return (getOwner(this) as EngineInstance).mountPoint; - }), + private get isDisabled(): boolean { + return Boolean(this.named('disabled')); + } - _route: computed('route', '_currentRouterState', function computeLinkToComponentRoute(this: any) { - let { route } = this; + private get isEngine(): boolean { + return getEngineParent(this.owner as EngineInstance) !== undefined; + } - return route === UNDEFINED ? this._currentRoute : this._namespaceRoute(route); - }), + private get engineMountPoint(): string | undefined { + return (this.owner as Owner | EngineInstance).mountPoint; + } - _models: computed('model', 'models', function computeLinkToComponentModels(this: any) { - let { model, models } = this; + private classFor(state: 'active' | 'loading' | 'disabled'): string { + let className = this.named(`${state}Class`); assert( - 'You cannot provide both the `@model` and `@models` arguments to the component.', - model === UNDEFINED || models === UNDEFINED + `The \`@${state}Class\` argument to the component must be a string or boolean`, + isMissing(className) || typeof className === 'string' || typeof className === 'boolean' ); - if (model !== UNDEFINED) { - return [model]; - } else if (models !== UNDEFINED) { - assert('The `@models` argument must be an array.', Array.isArray(models)); - return models; + if (className === true || isMissing(className)) { + return ` ${state}`; + } else if (className) { + return ` ${className}`; } else { - return []; + return ''; } - }), + } - _query: computed('query', function computeLinkToComponentQuery(this: any) { - let { query } = this; + private namespaceRoute(route: string): string { + let { engineMountPoint } = this; - if (query === UNDEFINED) { - return EMPTY_QUERY_PARAMS; + if (engineMountPoint === undefined) { + return route; + } else if (route === 'application') { + return engineMountPoint; } else { - return Object.assign({}, query); - } - }), - - /** - Accessed as a classname binding to apply the component'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 - @public - */ - 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 occurred - 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 component's `activeClass` - CSS `class` to the element when the link is active. - - This component is considered active when its `currentWhen` property is `true` - or the application's current route is the route this component 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; - } + return `${engineMountPoint}.${route}`; } - ), - - 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); + private isActiveForState(state: Maybe): boolean { + if (!isPresent(state)) { + return false; } - ), - _isActive(routerState: RouterState): boolean { - if (this.loading) { + if (this.isLoading) { return false; } - let currentWhen = this['current-when']; + let currentWhen = this.named('current-when'); if (typeof currentWhen === 'boolean') { return currentWhen; - } + } else if (typeof currentWhen === 'string') { + let { models, routing } = this; - let { _models: models, _routing: routing } = this; - - if (typeof currentWhen === 'string') { return currentWhen .split(' ') .some((route) => - routing.isActiveForRoute(models, undefined, this._namespaceRoute(route), routerState) + routing.isActiveForRoute(models, undefined, this.namespaceRoute(route), state) ); } else { - return routing.isActiveForRoute(models, this._query, this._route, routerState); - } - }, - - 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; - } - } - ), + let { route, models, query, routing } = this; - _namespaceRoute(route: string): string { - let { _engineMountPoint: mountPoint } = this; + assert('[BUG] route can only be missing if isLoading is true', isPresent(route)); - if (mountPoint === undefined) { - return route; - } else if (route === 'application') { - return mountPoint; - } else { - return `${mountPoint}.${route}`; + // TODO: is the signature wrong? this.query is definitely NOT a QueryParam! + return routing.isActiveForRoute(models, query as QueryParam, route, state); } - }, - - /** - 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; + } + + private preventDefault(event: Event): void { + event.preventDefault(); + } + + private shouldModernize(): boolean { + return ( + Boolean(EMBER_MODERNIZED_BUILT_IN_COMPONENTS) && + Component._wasReopened === false && + TargetActionSupport._wasReopened === false && + LegacyLinkTo._wasReopened === false + ); + } + + protected isSupportedArgument(name: string): boolean { + let supportedArguments = [ + 'route', + 'model', + 'models', + 'query', + 'replace', + 'disabled', + 'current-when', + 'activeClass', + 'loadingClass', + 'disabledClass', + ]; + + return supportedArguments.indexOf(name) !== -1 || super.isSupportedArgument(name); + } +} + +// Deprecated features +if (EMBER_MODERNIZED_BUILT_IN_COMPONENTS) { + let { prototype } = LinkTo; + + let descriptorFor = (target: object, property: string): Option => { + if (target) { + return ( + Object.getOwnPropertyDescriptor(target, property) || + descriptorFor(Object.getPrototypeOf(target), property) + ); + } else { + return null; } + }; + + handleDeprecatedArguments(LinkTo); + + handleDeprecatedAttributeArguments(LinkTo, [ + // Component + 'id', + ['id', 'elementId'], + 'class', + ['class', 'classNames'], + ['role', 'ariaRole'], + + // LinkTo + 'href', + 'title', + 'rel', + 'tabindex', + 'target', + ]); + + handleDeprecatedEventArguments(LinkTo); + + // @tagName + { + let superOnUnsupportedArgument = prototype['onUnsupportedArgument']; + + Object.defineProperty(prototype, 'onUnsupportedArgument', { + configurable: true, + enumerable: false, + value: function onUnsupportedArgument(this: LinkTo, name: string): void { + if (name === 'tagName') { + let tagName = this.named('tagName'); + + deprecate( + `Passing the \`@tagName\` argument to is deprecated. Using a <${tagName}> ` + + 'element for navigation is not recommended as it creates issues with assistive ' + + 'technologies. Remove this argument to use the default element. In the rare ' + + 'cases that calls for using a different element, refactor to use the router ' + + 'service inside a custom event handler instead.', + false, + { + id: 'ember.link-to.tag-name', + for: 'ember-source', + since: {}, + until: '4.0.0', + } + ); + + this.modernized = false; + } else { + superOnUnsupportedArgument.call(this, name); + } + }, + }); + } + + // @bubbles & @preventDefault + { + let superIsSupportedArgument = prototype['isSupportedArgument']; + + Object.defineProperty(prototype, 'isSupportedArgument', { + configurable: true, + enumerable: false, + value: function isSupportedArgument(this: LinkTo, name: string): boolean { + if (this.modernized) { + if (name === 'bubbles') { + deprecate( + 'Passing the `@bubbles` argument to is deprecated. ' + + 'Use the {{on}} modifier to attach a custom event handler to ' + + 'control event propagation.', + false, + { + id: 'ember.built-in-components.legacy-arguments', + for: 'ember-source', + since: {}, + until: '4.0.0', + } + ); + + return true; + } + + if (name === 'preventDefault') { + deprecate( + 'Passing the `@preventDefault` argument to is deprecated. ' + + '`preventDefault()` is called automatically on events that are ' + + 'handled by the component to prevent the browser from ' + + 'navigating away from the page.', + false, + { + id: 'ember.built-in-components.legacy-arguments', + for: 'ember-source', + since: {}, + until: '4.0.0', + } + ); + + return true; + } + } - let { bubbles, preventDefault } = this; - let target = this.element.target; - let isSelf = !target || target === '_self'; - - if (preventDefault !== false && isSelf) { - event.preventDefault(); - } + return superIsSupportedArgument.call(this, name); + }, + }); - if (bubbles === false) { - event.stopPropagation(); - } + Object.defineProperty(prototype, 'preventDefault', { + configurable: true, + enumerable: false, + value: function preventDefault(this: LinkTo, event: Event): void { + let shouldPreventDefault = true; + let shouldStopPropagation = false; + + if ('preventDefault' in this.args.named) { + let value = this.named('preventDefault'); + + if (isMissing(value) || value) { + deprecate( + 'Passing the `@preventDefault` argument to is deprecated. ' + + '`preventDefault()` is called automatically on events that are ' + + 'handled by the component to prevent the browser from ' + + 'navigating away from the page.', + false, + { + id: 'ember.built-in-components.legacy-arguments', + for: 'ember-source', + since: {}, + until: '4.0.0', + } + ); + } else { + deprecate( + 'Passing the `@preventDefault` argument to is deprecated. ' + + '`preventDefault()` should always be called on events that are ' + + 'handled by the component to prevent the browser from ' + + 'navigating away from the page.', + false, + { + id: 'ember.built-in-components.legacy-arguments', + for: 'ember-source', + since: {}, + until: '4.0.0', + } + ); + + shouldPreventDefault = false; + } + } - if (this._isDisabled) { - return false; - } + if ('bubbles' in this.args.named) { + let value = this.named('bubbles'); + + if (value === false) { + deprecate( + 'Passing the `@bubbles` argument to is deprecated. ' + + 'Use the {{on}} modifier to attach a custom event handler to ' + + 'control event propagation.', + false, + { + id: 'ember.built-in-components.legacy-arguments', + for: 'ember-source', + since: {}, + until: '4.0.0', + } + ); + + shouldStopPropagation = true; + } else { + deprecate( + 'Passing the `@bubbles` argument to is deprecated. ' + + '`stopPropagation()` is not automatically called so there is ' + + 'no need to pass this argument when you DO want the event to ' + + 'propagate normally', + false, + { + id: 'ember.built-in-components.legacy-arguments', + for: 'ember-source', + since: {}, + until: '4.0.0', + } + ); + } + } - 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', + if (shouldPreventDefault) { + event.preventDefault(); } - ); - return false; - } - if (!isSelf) { - return false; - } + if (shouldStopPropagation) { + event.stopPropagation(); + } + }, + }); + } + + // @disabledWhen + { + let superIsSupportedArgument = prototype['isSupportedArgument']; + + Object.defineProperty(prototype, 'isSupportedArgument', { + configurable: true, + enumerable: false, + value: function isSupportedArgument(this: LinkTo, name: string): boolean { + if (this.modernized) { + if (name === 'disabledWhen') { + deprecate( + 'Passing the `@disabledWhen` argument to is deprecated. ' + + 'Use the `@disabled` argument instead.', + false, + { + id: 'ember.link-to.disabled-when', + for: 'ember-source', + since: {}, + until: '4.0.0', + } + ); + + return true; + } + } - let { _route: routeName, _models: models, _query: queryParams, replace: shouldReplace } = this; + return superIsSupportedArgument.call(this, name); + }, + }); - let payload = { - queryParams, - routeName, - }; + let superDescriptor = descriptorFor(prototype, 'isDisabled'); - flaggedInstrument( - 'interaction.link-to', - payload, - this._generateTransition(payload, routeName, models, queryParams, shouldReplace) + assert( + `[BUG] expecting isDisabled to be a getter on `, + superDescriptor && typeof superDescriptor.get === 'function' ); - return false; - }, - - _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( - '_currentRouterState', - '_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 - e.message = `While generating link to route "${this.route}": ${e.message}`; - throw e; + let superGetter = superDescriptor.get as (this: LinkTo) => boolean; + + Object.defineProperty(prototype, 'isDisabled', { + configurable: true, + enumerable: false, + get: function isDisabled(this: LinkTo): boolean { + if ('disabledWhen' in this.args.named) { + deprecate( + 'Passing the `@disabledWhen` argument to is deprecated. ' + + 'Use the `@disabled` argument instead.', + false, + { + id: 'ember.link-to.disabled-when', + for: 'ember-source', + since: {}, + until: '4.0.0', + } + ); + + return Boolean(this.named('disabledWhen')); } - } 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' + return superGetter.call(this); + }, + }); + } - @property loadingHref - @type String - @default # - @private - */ - loadingHref: '#', + // QP + { + let superModelsDescriptor = descriptorFor(prototype, 'models'); - didReceiveAttrs() { - let { disabledWhen } = this; + assert( + `[BUG] expecting models to be a getter on `, + superModelsDescriptor && typeof superModelsDescriptor.get === 'function' + ); - if (disabledWhen !== undefined) { - this.set('disabled', disabledWhen); - } + let superModelsGetter = superModelsDescriptor.get as (this: LinkTo) => {}[]; - let { params } = this; + Object.defineProperty(prototype, 'models', { + configurable: true, + enumerable: false, + get: function models(this: LinkTo): {}[] { + let models = superModelsGetter.call(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 - ) - ); - - let { _models: models } = this; - if (models.length > 0) { - let lastModel = models[models.length - 1]; - - if (typeof lastModel === 'object' && lastModel !== null && lastModel.isQueryParams) { - this.query = lastModel.values; - models.pop(); + if (models.length > 0 && !('query' in this.args.named)) { + if (isQueryParams(models[models.length - 1])) { + models = models.slice(0, -1); + } } - } - - return; - } - - let hasBlock = this[HAS_BLOCK]; - - params = params.slice(); - - // Process the positional arguments, in order. - // 1. Inline link title comes first, if present. - if (!hasBlock) { - this.set('linkTitle', params.shift()); - } - - // 2. The last argument is possibly the `query` object. - let queryParams = params[params.length - 1]; - if (queryParams && queryParams.isQueryParams) { - this.set('query', params.pop().values); - } else { - this.set('query', UNDEFINED); - } - - // 3. If there is a `route`, it is now at index 0. - if (params.length === 0) { - this.set('route', UNDEFINED); - } else { - this.set('route', params.shift()); - } - - // 4. Any remaining indices (if any) are `models`. - this.set('model', UNDEFINED); - this.set('models', params); - - runInDebug(() => { - params = this.params.slice(); - - let equivalentNamedArgs = []; - let hasQueryParams = false; - - // Process the positional arguments, in order. - // 1. Inline link title comes first, if present. - if (!hasBlock) { - params.shift(); - } + return models; + }, + }); - // 2. The last argument is possibly the `query` object. - let query = params[params.length - 1]; + let superQueryDescriptor = descriptorFor(prototype, 'query'); - if (query && query.isQueryParams) { - params.pop(); - hasQueryParams = true; - } + assert( + `[BUG] expecting query to be a getter on `, + superQueryDescriptor && typeof superQueryDescriptor.get === 'function' + ); - // 3. If there is a `route`, it is now at index 0. - if (params.length > 0) { - params.shift(); - equivalentNamedArgs.push('`@route`'); - } + let superQueryGetter = superQueryDescriptor.get as (this: LinkTo) => {}; - // 4. Any remaining params (if any) are `models`. - if (params.length === 1) { - equivalentNamedArgs.push('`@model`'); - } else if (params.length > 1) { - equivalentNamedArgs.push('`@models`'); - } + Object.defineProperty(prototype, 'query', { + configurable: true, + enumerable: false, + get: function query(this: LinkTo): {} { + if ('query' in this.args.named) { + let qp = superQueryGetter.call(this); - if (hasQueryParams) { - equivalentNamedArgs.push('`@query`'); - } + if (isQueryParams(qp)) { + return qp.values ?? EMPTY_QUERY_PARAMS; + } else { + return qp; + } + } else { + let models = superModelsGetter.call(this); - if (equivalentNamedArgs.length > 0) { - let message = 'Invoking the `` component with positional arguments is deprecated.'; + if (models.length > 0) { + let qp = models[models.length - 1]; - message += `Please use the equivalent named arguments (${equivalentNamedArgs.join(', ')})`; + if (isQueryParams(qp) && qp.values !== null) { + return qp.values; + } + } - if (hasQueryParams) { - message += ' along with the `hash` helper'; + return EMPTY_QUERY_PARAMS; } - - if (!hasBlock) { - message += " and pass a block for the link's content."; + }, + }); + } + + // Positional Arguments + { + let superValidateArguments = prototype['validateArguments']; + + Object.defineProperty(prototype, 'validateArguments', { + configurable: true, + enumerable: false, + value: function validateArguments(this: LinkTo): void { + if (this.args.positional.length !== 0 || 'params' in this.args.named) { + // Already deprecated in the legacy implementation + this.modernized = false; } - message += '.'; - - deprecate(message, false, { - id: 'ember-glimmer.link-to.positional-arguments', - until: '4.0.0', - for: 'ember-source', - url: - 'https://deprecations.emberjs.com/v3.x#toc_ember-glimmer-link-to-positional-arguments', - since: { - enabled: '3.26.0-beta.1', - }, - }); - } + superValidateArguments.call(this); + }, }); - }, -}); -LinkComponent.toString = () => '@ember/routing/link-component'; + let superOnUnsupportedArgument = prototype['onUnsupportedArgument']; + + Object.defineProperty(prototype, 'onUnsupportedArgument', { + configurable: true, + enumerable: false, + value: function onUnsupportedArgument(this: LinkTo, name: string): void { + if (name !== 'params') { + superOnUnsupportedArgument.call(this, name); + } + }, + }); + } +} -LinkComponent.reopenClass({ - positionalParams: 'params', -}); +if (JQUERY_INTEGRATION) { + jQueryEventShim(LinkTo); +} -export default LinkComponent; +export default opaquify(LinkTo, LinkToTemplate); diff --git a/packages/@ember/-internals/glimmer/lib/setup-registry.ts b/packages/@ember/-internals/glimmer/lib/setup-registry.ts index 696585a6273..7eb9588f2d6 100644 --- a/packages/@ember/-internals/glimmer/lib/setup-registry.ts +++ b/packages/@ember/-internals/glimmer/lib/setup-registry.ts @@ -2,6 +2,7 @@ import { privatize as P, Registry } from '@ember/-internals/container'; import { ENV } from '@ember/-internals/environment'; import { EMBER_MODERNIZED_BUILT_IN_COMPONENTS } from '@ember/canary-features'; import Component from './component'; +import LegacyLinkTo from './components/-link-to'; import LegacyTextArea from './components/-textarea'; import Checkbox from './components/checkbox'; import Input from './components/input'; @@ -59,12 +60,14 @@ export function setupEngineRegistry(registry: Registry): void { registry.register('component:-text-field', TextField); registry.register('component:-checkbox', Checkbox); registry.register('component:input', Input); - registry.register('component:link-to', LinkTo); if (EMBER_MODERNIZED_BUILT_IN_COMPONENTS) { + registry.register('component:link-to', LinkTo); + registry.register('component:-link-to', LegacyLinkTo); registry.register('component:-textarea', LegacyTextArea); registry.register('component:textarea', Textarea); } else { + registry.register('component:link-to', LegacyLinkTo); registry.register('component:textarea', LegacyTextArea); } diff --git a/packages/@ember/-internals/glimmer/lib/templates/-link-to.d.ts b/packages/@ember/-internals/glimmer/lib/templates/-link-to.d.ts new file mode 100644 index 00000000000..30e2f374432 --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/templates/-link-to.d.ts @@ -0,0 +1,3 @@ +import { TemplateFactory } from '@glimmer/interfaces'; +declare const TEMPLATE: TemplateFactory; +export default TEMPLATE; diff --git a/packages/@ember/-internals/glimmer/lib/templates/-link-to.hbs b/packages/@ember/-internals/glimmer/lib/templates/-link-to.hbs new file mode 100644 index 00000000000..e67d811c27e --- /dev/null +++ b/packages/@ember/-internals/glimmer/lib/templates/-link-to.hbs @@ -0,0 +1,5 @@ +{{~#if (has-block)~}} + {{yield}} +{{~else~}} + {{this.linkTitle}} +{{~/if~}} diff --git a/packages/@ember/-internals/glimmer/lib/templates/link-to.hbs b/packages/@ember/-internals/glimmer/lib/templates/link-to.hbs index e67d811c27e..9e018d66388 100644 --- a/packages/@ember/-internals/glimmer/lib/templates/link-to.hbs +++ b/packages/@ember/-internals/glimmer/lib/templates/link-to.hbs @@ -1,5 +1,24 @@ -{{~#if (has-block)~}} - {{yield}} +{{~#if this.modernized~}} + {{yield}} {{~else~}} - {{this.linkTitle}} + {{~#let (component '-link-to') as |LegacyLinkTo|~}} + {{yield}} + {{~/let~}} {{~/if~}} diff --git a/packages/@ember/-internals/glimmer/tests/integration/application/debug-render-tree-test.ts b/packages/@ember/-internals/glimmer/tests/integration/application/debug-render-tree-test.ts index 5b959e641f9..0b7adb9c781 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/application/debug-render-tree-test.ts +++ b/packages/@ember/-internals/glimmer/tests/integration/application/debug-render-tree-test.ts @@ -1428,26 +1428,35 @@ if (ENV._DEBUG_RENDER_TREE) { } async '@test components'() { + this.router.map(function (this: any) { + this.route('foo'); + this.route('bar'); + }); + this.addTemplate( 'application', strip` - Hello World + Foo {{#if this.showSecond}} - Hello World + Bar {{/if}} ` ); await this.visit('/'); + let template = `packages/@ember/-internals/glimmer/lib/templates/${ + EMBER_MODERNIZED_BUILT_IN_COMPONENTS ? 'link-to' : '-link-to' + }.hbs`; + this.assertRenderTree([ { type: 'component', name: 'link-to', - args: { positional: [], named: { id: 'first', route: 'index' } }, - instance: (instance: object) => instance['id'] === 'first', - template: 'packages/@ember/-internals/glimmer/lib/templates/link-to.hbs', + args: { positional: [], named: { route: 'foo' } }, + instance: (instance: object) => instance['route'] === 'foo', + template, bounds: this.nodeBounds(this.element.firstChild), children: [], }, @@ -1461,18 +1470,18 @@ if (ENV._DEBUG_RENDER_TREE) { { type: 'component', name: 'link-to', - args: { positional: [], named: { id: 'first', route: 'index' } }, - instance: (instance: object) => instance['id'] === 'first', - template: 'packages/@ember/-internals/glimmer/lib/templates/link-to.hbs', + args: { positional: [], named: { route: 'foo' } }, + instance: (instance: object) => instance['route'] === 'foo', + template, bounds: this.nodeBounds(this.element.firstChild), children: [], }, { type: 'component', name: 'link-to', - args: { positional: [], named: { id: 'second', route: 'index' } }, - instance: (instance: object) => instance['id'] === 'second', - template: 'packages/@ember/-internals/glimmer/lib/templates/link-to.hbs', + args: { positional: [], named: { route: 'bar' } }, + instance: (instance: object) => instance['route'] === 'bar', + template, bounds: this.nodeBounds(this.element.lastChild), children: [], }, @@ -1486,9 +1495,9 @@ if (ENV._DEBUG_RENDER_TREE) { { type: 'component', name: 'link-to', - args: { positional: [], named: { id: 'first', route: 'index' } }, - instance: (instance: object) => instance['id'] === 'first', - template: 'packages/@ember/-internals/glimmer/lib/templates/link-to.hbs', + args: { positional: [], named: { route: 'foo' } }, + instance: (instance: object) => instance['route'] === 'foo', + template, bounds: this.nodeBounds(this.element.firstChild), children: [], }, 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 index 4b1f8f266c2..65eec9a144e 100644 --- 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 @@ -8,7 +8,10 @@ import Controller, { inject as injectController } from '@ember/controller'; import { A as emberA, RSVP } from '@ember/-internals/runtime'; import { subscribe, reset } from '@ember/instrumentation'; import { Route, NoneLocation } from '@ember/-internals/routing'; -import { EMBER_IMPROVED_INSTRUMENTATION } from '@ember/canary-features'; +import { + EMBER_IMPROVED_INSTRUMENTATION, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS, +} from '@ember/canary-features'; import Engine from '@ember/engine'; import { DEBUG } from '@glimmer/env'; import { compile } from '../../../utils/helpers'; @@ -89,18 +92,21 @@ moduleFor( ); } - async [`@test it doesn't add an href when the tagName isn't 'a'`](assert) { + async [`@test [DEPRECATED] it doesn't add an href when the tagName isn't 'a'`](assert) { this.addTemplate( 'index', `About` ); - await this.visit('/'); - + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@tagName` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assert.strictEqual(this.$('#about-link').attr('href'), null, 'there is no href attribute'); } - async [`@test it applies a 'disabled' class when disabledWhen`](assert) { + async [`@test [DEPRECATED] it applies a 'disabled' class when disabledWhen`](assert) { this.addTemplate( 'index', ` @@ -123,7 +129,11 @@ moduleFor( } ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + 'Passing the `@disabledWhen` argument to is deprecated. Use the `@disabled` argument instead.', + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assert.equal( this.$('#about-link-static.disabled').length, @@ -136,7 +146,11 @@ moduleFor( 'The dynamic link is disabled when its disabledWhen is true' ); - runTask(() => controller.set('dynamicDisabledWhen', false)); + expectDeprecation( + () => runTask(() => controller.set('dynamicDisabledWhen', false)), + 'Passing the `@disabledWhen` argument to is deprecated. Use the `@disabled` argument instead.', + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assert.equal( this.$('#about-link-static.disabled').length, @@ -329,15 +343,23 @@ moduleFor( ); } - async [`@test it does not respond to clicks when disabledWhen`](assert) { + async [`@test [DEPRECATED] it does not respond to clicks when disabledWhen`](assert) { this.addTemplate( 'index', `About` ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + 'Passing the `@disabledWhen` argument to is deprecated. Use the `@disabled` argument instead.', + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); - await this.click('#about-link'); + await expectDeprecationAsync( + () => this.click('#about-link'), + 'Passing the `@disabledWhen` argument to is deprecated. Use the `@disabled` argument instead.', + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assert.strictEqual(this.$('h3.about').length, 0, 'Transitioning did not occur'); } @@ -1190,18 +1212,32 @@ moduleFor( assert.equal(hidden, 1, 'The link bubbles'); } - async [`@test it supports bubbles=false`](assert) { - this.addTemplate( - 'about', - ` -
    - - About - -
    - {{outlet}} - ` - ); + async [`@test [DEPRECATED] it supports bubbles=false`](assert) { + if (EMBER_MODERNIZED_BUILT_IN_COMPONENTS) { + this.addTemplate( + 'about', + ` +
    + + About + +
    + {{outlet}} + ` + ); + } else { + this.addTemplate( + 'about', + ` +
    + + About + +
    + {{outlet}} + ` + ); + } this.addTemplate('about.contact', `

    Contact

    `); @@ -1222,21 +1258,95 @@ moduleFor( } ); - await this.visit('/about'); + await expectDeprecationAsync( + () => this.visit('/about'), + 'Passing the `@bubbles` argument to is deprecated. Use the {{on}} modifier to attach a custom event handler to control event propagation.', + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); - await this.click('#about-contact'); + await expectDeprecationAsync( + () => this.click('#about-contact'), + 'Passing the `@bubbles` argument to is deprecated. Use the {{on}} modifier to attach a custom event handler to control event propagation.', + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assert.equal(this.$('#contact').text(), 'Contact', 'precond - the link worked'); assert.strictEqual(hidden, 0, "The link didn't bubble"); } - async [`@test it supports bubbles=boundFalseyThing`](assert) { + async [`@test [DEPRECATED] it supports bubbles=boundFalseyThing`](assert) { + if (EMBER_MODERNIZED_BUILT_IN_COMPONENTS) { + this.addTemplate( + 'about', + ` +
    + + About + +
    + {{outlet}} + ` + ); + } else { + this.addTemplate( + 'about', + ` +
    + + About + +
    + {{outlet}} + ` + ); + } + + this.addTemplate('about.contact', `

    Contact

    `); + + let hidden = 0; + + this.add( + 'controller:about', + class extends Controller { + boundFalseyThing = false; + + hide() { + hidden++; + } + } + ); + + this.router.map(function () { + this.route('about', function () { + this.route('contact'); + }); + }); + + await expectDeprecationAsync( + () => this.visit('/about'), + /Passing the `@bubbles` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); + + await expectDeprecationAsync( + () => this.click('#about-contact'), + /Passing the `@bubbles` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); + + assert.equal(this.$('#contact').text(), 'Contact', 'precond - the link worked'); + assert.strictEqual(hidden, 0, "The link didn't bubble"); + } + + async [`@feature(EMBER_MODERNIZED_BUILT_IN_COMPONENTS) The propagation of the click event can be stopped`]( + assert + ) { this.addTemplate( 'about', ` -
    - +
    + About
    @@ -1246,31 +1356,34 @@ moduleFor( this.addTemplate('about.contact', `

    Contact

    `); + this.router.map(function () { + this.route('about', function () { + this.route('contact'); + }); + }); + let hidden = 0; this.add( 'controller:about', class extends Controller { - boundFalseyThing = false; - hide() { hidden++; } + + stopPropagation(event) { + event.stopPropagation(); + } } ); - this.router.map(function () { - this.route('about', function () { - this.route('contact'); - }); - }); - await this.visit('/about'); await this.click('#about-contact'); assert.equal(this.$('#contact').text(), 'Contact', 'precond - the link worked'); - assert.strictEqual(hidden, 0, "The link didn't bubble"); + + assert.equal(hidden, 0, "The link didn't bubble"); } async [`@test it moves into the named route with context`](assert) { @@ -1433,7 +1546,7 @@ moduleFor( assertNav({ prevented: true }, () => this.$('#about-link').click(), assert); } - async [`@test it does not call preventDefault if '@preventDefault={{false}}' is passed as an option`]( + async [`@test [DEPRECATED] it does not call preventDefault if '@preventDefault={{false}}]' is passed as an option`]( assert ) { this.router.map(function () { @@ -1445,12 +1558,25 @@ moduleFor( `About` ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@preventDefault` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); - assertNav({ prevented: false }, () => this.$('#about-link').trigger('click'), assert); + assertNav( + { prevented: false }, + () => + expectDeprecation( + () => this.$('#about-link').trigger('click'), + /Passing the `@preventDefault` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ), + assert + ); } - async [`@test it does not call preventDefault if '@preventDefault={{this.boundThing}}' is passed as an option`]( + async [`@test [DEPRECATED] it does not call preventDefault if '@preventDefault={{this.boundThing}}' is passed as an option`]( assert ) { this.router.map(function () { @@ -1476,15 +1602,41 @@ moduleFor( } ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@preventDefault` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); - assertNav({ prevented: false }, () => this.$('#about-link').trigger('click'), assert); + assertNav( + { prevented: false }, + () => + expectDeprecation( + () => this.$('#about-link').trigger('click'), + /Passing the `@preventDefault` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ), + assert + ); runTask(() => controller.set('boundThing', true)); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@preventDefault` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); - assertNav({ prevented: true }, () => this.$('#about-link').trigger('click'), assert); + assertNav( + { prevented: true }, + () => + expectDeprecation( + () => this.$('#about-link').trigger('click'), + /Passing the `@preventDefault` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ), + assert + ); } async [`@test it does not call preventDefault if 'target' attribute is provided`](assert) { diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js index 0431098d342..138a66f7068 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/routing-curly-test.js @@ -8,7 +8,10 @@ import Controller, { inject as injectController } from '@ember/controller'; import { A as emberA, RSVP } from '@ember/-internals/runtime'; import { subscribe, reset } from '@ember/instrumentation'; import { Route, NoneLocation } from '@ember/-internals/routing'; -import { EMBER_IMPROVED_INSTRUMENTATION } from '@ember/canary-features'; +import { + EMBER_IMPROVED_INSTRUMENTATION, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS, +} from '@ember/canary-features'; import Engine from '@ember/engine'; import { DEBUG } from '@glimmer/env'; import { compile } from '../../../utils/helpers'; @@ -89,13 +92,17 @@ moduleFor( ); } - async [`@test it doesn't add an href when the tagName isn't 'a'`](assert) { + async [`@test [DEPRECATED] it doesn't add an href when the tagName isn't 'a'`](assert) { this.addTemplate( 'index', `` ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@tagName` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assert.strictEqual( this.$('#about-link > div').attr('href'), @@ -104,7 +111,7 @@ moduleFor( ); } - async [`@test it applies a 'disabled' class when disabledWhen`](assert) { + async [`@test [DEPRECATED] it applies a 'disabled' class when disabledWhen`](assert) { this.addTemplate( 'index', ` @@ -127,7 +134,11 @@ moduleFor( } ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + 'Passing the `@disabledWhen` argument to is deprecated. Use the `@disabled` argument instead.', + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assert.equal( this.$('#about-link-static > a.disabled').length, @@ -140,7 +151,11 @@ moduleFor( 'The dynamic link is disabled when its disabledWhen is true' ); - runTask(() => controller.set('dynamicDisabledWhen', false)); + expectDeprecation( + () => runTask(() => controller.set('dynamicDisabledWhen', false)), + 'Passing the `@disabledWhen` argument to is deprecated. Use the `@disabled` argument instead.', + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assert.equal( this.$('#about-link-static > a.disabled').length, @@ -340,15 +355,23 @@ moduleFor( ); } - async [`@test it does not respond to clicks when disabledWhen`](assert) { + async [`@test [DEPRECATED] it does not respond to clicks when disabledWhen`](assert) { this.addTemplate( 'index', `` ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + 'Passing the `@disabledWhen` argument to is deprecated. Use the `@disabled` argument instead.', + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); - await this.click('#about-link > a'); + await expectDeprecationAsync( + () => this.click('#about-link > a'), + 'Passing the `@disabledWhen` argument to is deprecated. Use the `@disabled` argument instead.', + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assert.strictEqual(this.$('h3.about').length, 0, 'Transitioning did not occur'); } @@ -544,7 +567,11 @@ moduleFor( } ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@class` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assert.equal( this.$('#about-link > a.foo-is-false').length, @@ -552,7 +579,11 @@ moduleFor( 'The about-link was rendered with the falsy class' ); - runTask(() => controller.set('foo', true)); + await expectDeprecation( + () => runTask(() => controller.set('foo', true)), + /Passing the `@class` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assert.equal( this.$('#about-link > a.foo-is-true').length, @@ -1310,18 +1341,32 @@ moduleFor( assert.equal(hidden, 1, 'The link bubbles'); } - async [`@test it supports bubbles=false`](assert) { - this.addTemplate( - 'about', - ` -
    - {{#link-to route='about.contact' bubbles=false}} - About - {{/link-to}} -
    - {{outlet}} - ` - ); + async [`@test [DEPRECATED] it supports bubbles=false`](assert) { + if (EMBER_MODERNIZED_BUILT_IN_COMPONENTS) { + this.addTemplate( + 'about', + ` +
    + {{#link-to route='about.contact' bubbles=false}} + About + {{/link-to}} +
    + {{outlet}} + ` + ); + } else { + this.addTemplate( + 'about', + ` +
    + {{#link-to route='about.contact' bubbles=false}} + About + {{/link-to}} +
    + {{outlet}} + ` + ); + } this.addTemplate('about.contact', `

    Contact

    `); @@ -1342,27 +1387,49 @@ moduleFor( } ); - await this.visit('/about'); + await expectDeprecationAsync( + () => this.visit('/about'), + /Passing the `@bubbles` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); - await this.click('#about-contact > a'); + await expectDeprecationAsync( + () => this.click('#about-contact > a'), + /Passing the `@bubbles` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assert.equal(this.$('#contact').text(), 'Contact', 'precond - the link worked'); assert.strictEqual(hidden, 0, "The link didn't bubble"); } - async [`@test it supports bubbles=boundFalseyThing`](assert) { - this.addTemplate( - 'about', - ` -
    - {{#link-to route='about.contact' bubbles=this.boundFalseyThing}} - About - {{/link-to}} -
    - {{outlet}} - ` - ); + async [`@test [DEPRECATED] it supports bubbles=boundFalseyThing`](assert) { + if (EMBER_MODERNIZED_BUILT_IN_COMPONENTS) { + this.addTemplate( + 'about', + ` +
    + {{#link-to route='about.contact' bubbles=this.boundFalseyThing}} + About + {{/link-to}} +
    + {{outlet}} + ` + ); + } else { + this.addTemplate( + 'about', + ` +
    + {{#link-to route='about.contact' bubbles=this.boundFalseyThing}} + About + {{/link-to}} +
    + {{outlet}} + ` + ); + } this.addTemplate('about.contact', `

    Contact

    `); @@ -1385,9 +1452,17 @@ moduleFor( }); }); - await this.visit('/about'); + await expectDeprecationAsync( + () => this.visit('/about'), + /Passing the `@bubbles` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); - await this.click('#about-contact > a'); + await expectDeprecationAsync( + () => this.click('#about-contact > a'), + /Passing the `@bubbles` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assert.equal(this.$('#contact').text(), 'Contact', 'precond - the link worked'); assert.strictEqual(hidden, 0, "The link didn't bubble"); @@ -1474,7 +1549,7 @@ moduleFor( assert.equal(this.$('p').text(), 'Erik Brynroflsson', 'The name is correct'); } - async [`@test it binds some anchor html tag common attributes`](assert) { + async [`@test [DEPRECATED] it binds some anchor html tag common attributes`](assert) { this.addTemplate( 'index', ` @@ -1487,7 +1562,11 @@ moduleFor( ` ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@(id|title|rel|tabindex)` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); let link = this.$('#self-link > a'); @@ -1496,7 +1575,7 @@ moduleFor( assert.equal(link.attr('tabindex'), '-1', 'The self-link contains tabindex attribute'); } - async [`@test it supports 'target' attribute`](assert) { + async [`@test [DEPRECATED] it supports 'target' attribute`](assert) { this.addTemplate( 'index', ` @@ -1505,13 +1584,17 @@ moduleFor( ` ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@target` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); let link = this.$('#self-link > a'); assert.equal(link.attr('target'), '_blank', 'The self-link contains `target` attribute'); } - async [`@test it supports 'target' attribute specified as a bound param`](assert) { + async [`@test [DEPRECATED] it supports 'target' attribute specified as a bound param`](assert) { this.addTemplate( 'index', ` @@ -1534,12 +1617,20 @@ moduleFor( } ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@target` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); let link = this.$('#self-link > a'); assert.equal(link.attr('target'), '_blank', 'The self-link contains `target` attribute'); - runTask(() => controller.set('boundLinkTarget', '_self')); + expectDeprecation( + () => runTask(() => controller.set('boundLinkTarget', '_self')), + /Passing the `@target` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assert.equal(link.attr('target'), '_self', 'The self-link contains `target` attribute'); } @@ -1559,7 +1650,7 @@ moduleFor( assertNav({ prevented: true }, () => this.$('#about-link > a').click(), assert); } - async [`@test it does not call preventDefault if 'preventDefault=false' is passed as an option`]( + async [`@test [DEPRECATED] it does not call preventDefault if 'preventDefault=false' is passed as an option`]( assert ) { this.router.map(function () { @@ -1571,12 +1662,25 @@ moduleFor( `` ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@preventDefault` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); - assertNav({ prevented: false }, () => this.$('#about-link > a').trigger('click'), assert); + assertNav( + { prevented: false }, + () => + expectDeprecation( + () => this.$('#about-link > a').trigger('click'), + /Passing the `@preventDefault` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ), + assert + ); } - async [`@test it does not call preventDefault if 'preventDefault=this.boundThing' is passed as an option`]( + async [`@test [DEPRECATED] it does not call preventDefault if 'preventDefault=this.boundThing' is passed as an option`]( assert ) { this.router.map(function () { @@ -1602,18 +1706,46 @@ moduleFor( } ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@preventDefault` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); - assertNav({ prevented: false }, () => this.$('#about-link > a').trigger('click'), assert); + assertNav( + { prevented: false }, + () => + expectDeprecation( + () => this.$('#about-link > a').trigger('click'), + /Passing the `@preventDefault` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ), + assert + ); runTask(() => controller.set('boundThing', true)); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@preventDefault` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); - assertNav({ prevented: true }, () => this.$('#about-link > a').trigger('click'), assert); + assertNav( + { prevented: true }, + () => + expectDeprecation( + () => this.$('#about-link > a').trigger('click'), + /Passing the `@preventDefault` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ), + assert + ); } - async [`@test it does not call preventDefault if 'target' attribute is provided`](assert) { + async [`@test [DEPRECATED] it does not call preventDefault if 'target' attribute is provided`]( + assert + ) { this.addTemplate( 'index', ` @@ -1622,12 +1754,16 @@ moduleFor( ` ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@target` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assertNav({ prevented: false }, () => this.$('#self-link > a').click(), assert); } - async [`@test it should preventDefault when 'target = _self'`](assert) { + async [`@test [DEPRECATED] it should preventDefault when 'target = _self'`](assert) { this.addTemplate( 'index', ` @@ -1636,12 +1772,18 @@ moduleFor( ` ); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@target` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); assertNav({ prevented: true }, () => this.$('#self-link > a').click(), assert); } - async [`@test it should not transition if target is not equal to _self or empty`](assert) { + async [`@test [DEPRECATED] it should not transition if target is not equal to _self or empty`]( + assert + ) { this.addTemplate( 'index', ` @@ -1657,7 +1799,11 @@ moduleFor( this.route('about'); }); - await this.visit('/'); + await expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@target` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); await this.click('#about-link > a'); 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 index fcfde192fc4..03395293b97 100644 --- 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 @@ -1,5 +1,6 @@ import { RSVP } from '@ember/-internals/runtime'; import { Route } from '@ember/-internals/routing'; +import { EMBER_MODERNIZED_BUILT_IN_COMPONENTS } from '@ember/canary-features'; import { moduleFor, ApplicationTestCase, runTask } from 'internal-test-helpers'; function assertHasClass(assert, selector, label) { @@ -86,7 +87,7 @@ moduleFor( let $about = this.$('#about-link'); let $other = this.$('#other-link'); - $about.click(); + runTask(() => $about.click()); assertHasClass(assert, $index, 'active'); assertHasNoClass(assert, $about, 'active'); @@ -120,7 +121,7 @@ moduleFor( let $news = this.$('#news-link'); let $other = this.$('#other-link'); - $news.click(); + runTask(() => $news.click()); assertHasClass(assert, $index, 'active'); assertHasNoClass(assert, $news, 'active'); @@ -152,7 +153,7 @@ moduleFor( ); moduleFor( - ` component: .transitioning-in .transitioning-out CSS classes - nested link-to's`, + ` component: [DEPRECATED] .transitioning-in .transitioning-out CSS classes - nested link-to's`, class extends ApplicationTestCase { constructor(...args) { super(...args); @@ -202,8 +203,12 @@ moduleFor( ); } - beforeEach() { - return this.visit('/'); + async beforeEach() { + return expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@tagName` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); } resolveAbout() { @@ -231,7 +236,7 @@ moduleFor( // outlet is not stable and the second $about.click() is triggered. let $about = this.$('#about-link'); - $about.click(); + runTask(() => $about.click()); let $index = this.$('#index-link'); $about = this.$('#about-link'); @@ -267,7 +272,7 @@ moduleFor( assertHasNoClass(assert, $about, 'ember-transitioning-out'); assertHasNoClass(assert, $other, 'ember-transitioning-out'); - $other.click(); + runTask(() => $other.click()); $index = this.$('#index-link'); $about = this.$('#about-link'); @@ -303,7 +308,7 @@ moduleFor( assertHasNoClass(assert, $about, 'ember-transitioning-out'); assertHasNoClass(assert, $other, 'ember-transitioning-out'); - $about.click(); + runTask(() => $about.click()); $index = this.$('#index-link'); $about = this.$('#about-link'); diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-curly-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-curly-test.js index c32ab6ee15e..8724c4ee335 100644 --- a/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-curly-test.js +++ b/packages/@ember/-internals/glimmer/tests/integration/components/link-to/transitioning-classes-curly-test.js @@ -1,5 +1,6 @@ import { RSVP } from '@ember/-internals/runtime'; import { Route } from '@ember/-internals/routing'; +import { EMBER_MODERNIZED_BUILT_IN_COMPONENTS } from '@ember/canary-features'; import { moduleFor, ApplicationTestCase, runTask } from 'internal-test-helpers'; function assertHasClass(assert, selector, label) { @@ -86,7 +87,7 @@ moduleFor( let $about = this.$('#about-link > a'); let $other = this.$('#other-link > a'); - $about.click(); + runTask(() => $about.click()); assertHasClass(assert, $index, 'active'); assertHasNoClass(assert, $about, 'active'); @@ -120,7 +121,7 @@ moduleFor( let $news = this.$('#news-link > a'); let $other = this.$('#other-link > a'); - $news.click(); + runTask(() => $news.click()); assertHasClass(assert, $index, 'active'); assertHasNoClass(assert, $news, 'active'); @@ -152,7 +153,7 @@ moduleFor( ); moduleFor( - `{{link-to}} component: .transitioning-in .transitioning-out CSS classes - nested link-to's`, + `{{link-to}} component: [DEPRECATED] .transitioning-in .transitioning-out CSS classes - nested link-to's`, class extends ApplicationTestCase { constructor(...args) { super(...args); @@ -202,8 +203,12 @@ moduleFor( ); } - beforeEach() { - return this.visit('/'); + async beforeEach() { + return expectDeprecationAsync( + () => this.visit('/'), + /Passing the `@tagName` argument to is deprecated\./, + EMBER_MODERNIZED_BUILT_IN_COMPONENTS + ); } resolveAbout() { @@ -231,7 +236,7 @@ moduleFor( // outlet is not stable and the second $about.click() is triggered. let $about = this.$('#about-link > a'); - $about.click(); + runTask(() => $about.click()); let $index = this.$('#index-link > a'); $about = this.$('#about-link > a'); @@ -267,7 +272,7 @@ moduleFor( assertHasNoClass(assert, $about, 'ember-transitioning-out'); assertHasNoClass(assert, $other, 'ember-transitioning-out'); - $other.click(); + runTask(() => $other.click()); $index = this.$('#index-link > a'); $about = this.$('#about-link > a'); @@ -303,7 +308,7 @@ moduleFor( assertHasNoClass(assert, $about, 'ember-transitioning-out'); assertHasNoClass(assert, $other, 'ember-transitioning-out'); - $about.click(); + runTask(() => $about.click()); $index = this.$('#index-link > a'); $about = this.$('#about-link > a'); diff --git a/packages/@ember/-internals/routing/index.ts b/packages/@ember/-internals/routing/index.ts index c262f054674..ba9956e5375 100644 --- a/packages/@ember/-internals/routing/index.ts +++ b/packages/@ember/-internals/routing/index.ts @@ -13,9 +13,10 @@ export { } from './lib/system/generate_controller'; export { default as controllerFor } from './lib/system/controller_for'; export { default as RouterDSL } from './lib/system/dsl'; -export { default as Router } from './lib/system/router'; +export { default as Router, QueryParam } from './lib/system/router'; export { default as Route } from './lib/system/route'; export { default as QueryParams } from './lib/system/query_params'; export { default as RoutingService } from './lib/services/routing'; export { default as RouterService } from './lib/services/router'; +export { default as RouterState } from './lib/system/router_state'; export { default as BucketCache } from './lib/system/cache'; diff --git a/packages/ember/tests/routing/query_params_test.js b/packages/ember/tests/routing/query_params_test.js index dc9b423cd2f..66f07ea319b 100644 --- a/packages/ember/tests/routing/query_params_test.js +++ b/packages/ember/tests/routing/query_params_test.js @@ -1444,12 +1444,13 @@ moduleFor( this.assertCurrentPath('/home', 'Setting property to undefined'); } - ['@test {{link-to}} with null or undefined QPs does not get serialized into url'](assert) { - assert.expect(3); - + async ['@test with null or undefined QPs does not get serialized into url'](assert) { this.addTemplate( 'home', - "{{#link-to route='home' query=(hash foo=this.nullValue) id='null-link'}}Home{{/link-to}}{{#link-to route='home' query=(hash foo=this.undefinedValue) id='undefined-link'}}Home{{/link-to}}" + ` + Home + Home + ` ); this.router.map(function () { @@ -1461,10 +1462,10 @@ moduleFor( undefinedValue: undefined, }); - return this.visitAndAssert('/home').then(() => { - assert.equal(this.$('#null-link').attr('href'), '/home'); - assert.equal(this.$('#undefined-link').attr('href'), '/home'); - }); + await this.visitAndAssert('/home'); + + assert.equal(this.$('#null-link').attr('href'), '/home'); + assert.equal(this.$('#undefined-link').attr('href'), '/home'); } ["@test A child of a resource route still defaults to parent route's model even if the child route has a query param"]( diff --git a/packages/ember/tests/routing/query_params_test/query_param_async_get_handler_test.js b/packages/ember/tests/routing/query_params_test/query_param_async_get_handler_test.js index afa1058f619..7c78124c955 100644 --- a/packages/ember/tests/routing/query_params_test/query_param_async_get_handler_test.js +++ b/packages/ember/tests/routing/query_params_test/query_param_async_get_handler_test.js @@ -57,11 +57,9 @@ moduleFor( }; } - ['@test can render a link to an asynchronously loaded route without fetching the route']( + async ['@test can render a link to an asynchronously loaded route without fetching the route']( assert ) { - assert.expect(4); - this.router.map(function () { this.route('post', { path: '/post/:id' }); }); @@ -72,32 +70,32 @@ moduleFor( this.addTemplate( 'application', ` - {{#link-to route='post' model=1337 query=(hash foo='bar') class='post-link is-1337'}}Post{{/link-to}} - {{#link-to route='post' model=7331 query=(hash foo='boo') class='post-link is-7331'}}Post{{/link-to}} - {{outlet}} - ` + Post + Post + {{outlet}} + ` ); }; setupAppTemplate(); - return this.visitAndAssert('/').then(() => { - assert.equal( - this.$('.post-link.is-1337').attr('href'), - '/post/1337?foo=bar', - 'renders correctly with default QP value' - ); - assert.equal( - this.$('.post-link.is-7331').attr('href'), - '/post/7331?foo=boo', - 'renders correctly with non-default QP value' - ); - assert.deepEqual( - this.fetchedHandlers, - ['application', 'index'], - `only fetched the handlers for the route we're on` - ); - }); + await this.visitAndAssert('/'); + + assert.equal( + this.$('.post-link.is-1337').attr('href'), + '/post/1337?foo=bar', + 'renders correctly with default QP value' + ); + assert.equal( + this.$('.post-link.is-7331').attr('href'), + '/post/7331?foo=boo', + 'renders correctly with non-default QP value' + ); + assert.deepEqual( + this.fetchedHandlers, + ['application', 'index'], + `only fetched the handlers for the route we're on` + ); } ['@test can transitionTo to an asynchronously loaded route with simple query params'](assert) { diff --git a/packages/internal-test-helpers/lib/ember-dev/deprecation.ts b/packages/internal-test-helpers/lib/ember-dev/deprecation.ts index bd1a637858e..cf2a60b5df7 100644 --- a/packages/internal-test-helpers/lib/ember-dev/deprecation.ts +++ b/packages/internal-test-helpers/lib/ember-dev/deprecation.ts @@ -215,7 +215,7 @@ class DeprecationAssert extends DebugAssert { true ); } else { - this.expectNoDeprecationAsync(func); + await this.expectNoDeprecationAsync(func); } }