diff --git a/addon/helpers/link.ts b/addon/helpers/link.ts index 6a9f5e0c..ef74aad0 100644 --- a/addon/helpers/link.ts +++ b/addon/helpers/link.ts @@ -139,6 +139,7 @@ export default class LinkHelper extends Helper { ) as RouteModel[]) : undefined, query: named.query ?? positionalQueryParameters, + mode: named.mode, onTransitionTo: named.onTransitionTo, onReplaceWith: named.onReplaceWith }; diff --git a/addon/link.ts b/addon/link.ts index faf79eaf..734d5356 100644 --- a/addon/link.ts +++ b/addon/link.ts @@ -10,6 +10,7 @@ import Transition from '@ember/routing/-private/transition'; import RouterService from '@ember/routing/router-service'; import { DEBUG } from '@glimmer/env'; import { tracked } from '@glimmer/tracking'; +import { next } from '@ember/runloop'; import LinkManagerService from './services/link-manager'; @@ -46,6 +47,16 @@ export interface LinkParams { */ query?: QueryParams; + /** + * Sets the mode for the link + * - `all`: all params are included in the link + * - `known`: only params that are known to the target route are included, respects the passed in params + * - `tracked-all`: all params are included in the link, and the link will be updated when any of the params change + * - `tracked-known`: only params that are known to the target route are included, and the link will be updated when any of these params change, it also respects the passed in params + * - `none`: no parent params are included in the link, just the ones passed in + */ + mode?: 'known' | 'all' | 'tracked-all' | 'tracked-known' | 'none'; + /** * An optional callback that will be fired when the Link is transitioned to. * @@ -81,6 +92,13 @@ function isMouseEvent(event: unknown): event is MouseEvent { return typeof event === 'object' && event !== null && 'button' in event; } +interface Qp { + urlKey: string; +} +interface Qps { + qps: Qp[]; +} + export default class Link { @tracked // eslint-disable-next-line @typescript-eslint/naming-convention @@ -89,10 +107,36 @@ export default class Link { // eslint-disable-next-line @typescript-eslint/naming-convention protected _linkManager: LinkManagerService; + @tracked + private _knownRouteQps?: Qps | (() => Qps); + constructor(linkManager: LinkManagerService, params: LinkParams) { setOwner(this, getOwner(linkManager)); this._linkManager = linkManager; this._params = freezeParams(params); + + //In order to support `known` we can't read Route._qp inside the getter, because it will entangle autotracking + //Is there a better way to do this?, router.currentRouteName is not always available + //Maybe this._linkManager.router._router.url instead of window.location.pathname? + if (this._linkManager.router.currentRouteName) { + this._knownRouteQps = getOwner(this).lookup( + `route:${this._linkManager.router.currentRouteName}` + )?._qp; + } else { + const routeName = this._linkManager.router.recognize( + window?.location?.pathname || this._linkManager.router._router.url + )?.name; + const cb = () => { + this._knownRouteQps = getOwner(this).lookup(`route:${routeName}`)?._qp; + }; + if (routeName) { + next(this, cb); + } + } + } + + get mode() { + return this._params.mode ?? 'none'; } private get _routeArgs(): RouteArgs { @@ -103,7 +147,7 @@ export default class Link { ...models, // Cloning `queryParams` is necessary, since we freeze it, but Ember // wants to mutate it. - { queryParams: { ...queryParams } } + { queryParams: { ...queryParams } }, ] as unknown as RouteArgs; } return [routeName, ...models] as RouteArgs; @@ -128,7 +172,7 @@ export default class Link { this._linkManager.currentTransitionStack; // eslint-disable-line @typescript-eslint/no-unused-expressions return this._linkManager.router.isActive( this.routeName, - // Unfortunately TypeScript is not clever enough to support "rest" + // Unfortunately TypeScript is not clever enough to support 'rest' // parameters in the middle. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -164,11 +208,103 @@ export default class Link { * The URL for this link that you can pass to an `` tag as the `href` * attribute. */ - get url(): string { + private createNoneUrl(): string { if (!this._linkManager.isRouterInitialized) return ''; return this._linkManager.router.urlFor(...this._routeArgs); } + private createKnownUrl(routeQps?: Qps | (() => Qps)): string { + if (!this._linkManager.isRouterInitialized) return ''; + const { routeName, models, queryParams = {} } = this; + + let mergedQps: { + [key: string]: unknown; + } = {}; + const cloned = { ...queryParams }; + let final: { + [key: string]: unknown; + } = {}; + + mergedQps = { ...queryParams }; + this._linkManager.routing.normalizeQueryParams( + routeName, + models, + mergedQps + ); + + const qp = typeof routeQps === 'function' ? routeQps() : routeQps; + + if (qp && qp.qps.length > 0) { + const { qps } = qp; + for (const q of qps) { + final[q.urlKey] = mergedQps[q.urlKey]; + } + final = { + ...final, + ...cloned, + }; + } else { + final = { ...cloned }; + } + + final = { queryParams: { ...final } }; + + return this._linkManager.router.urlFor(routeName, ...models, final); + } + + private createAllUrl() { + if (!this._linkManager.isRouterInitialized) return ''; + const { routeName, models, queryParams = {} } = this; + return this._linkManager.routing.generateURL( + routeName, + models, + queryParams + ); + } + + get trackedAllUrl(): string { + this._linkManager.currentTransitionStack; + return this.createAllUrl(); + } + + get trackedKnownUrl(): string { + this._linkManager.currentTransitionStack; + return this.createKnownUrl(() => { + return getOwner(this).lookup( + `route:${this._linkManager.router.currentRouteName}` + )?._qp; + }); + } + + get knownUrl(): string { + return this.createKnownUrl(this._knownRouteQps); + } + + get noneUrl(): string { + return this.createNoneUrl(); + } + + get allUrl(): string { + return this.createAllUrl(); + } + + get url(): string { + switch (this.mode) { + case 'known': + return this.knownUrl; + case 'all': + return this.allUrl; + case 'tracked-known': + return this.trackedKnownUrl; + case 'tracked-all': + return this.trackedAllUrl; + case 'none': + return this.noneUrl; + default: + return this.noneUrl; + } + } + /** * Deprecated alias for `url`. */ @@ -179,8 +315,8 @@ export default class Link { for: 'ember-link', since: { available: '1.1.0', - enabled: '1.1.0' - } + enabled: '1.1.0', + }, }); return this.url; } @@ -191,7 +327,7 @@ export default class Link { * Allows for more ergonomic composition as query parameters. * * ```hbs - * {{link "foo" query=(hash bar=(link "bar"))}} + * {{link 'foo' query=(hash bar=(link 'bar'))}} * ``` */ toString() { @@ -240,7 +376,7 @@ export default class Link { private _isTransitioning(direction: 'from' | 'to') { return ( - this._linkManager.currentTransitionStack?.some(transition => { + this._linkManager.currentTransitionStack?.some((transition) => { return transition[direction]?.name === this.qualifiedRouteName; }) ?? false ); @@ -258,7 +394,7 @@ export default class Link { this._params.onTransitionTo?.(); - return this._linkManager.router.transitionTo(...this._routeArgs); + return this._linkManager.router.transitionTo(this.url); } /** @@ -274,7 +410,7 @@ export default class Link { this._params.onReplaceWith?.(); - return this._linkManager.router.replaceWith(...this._routeArgs); + return this._linkManager.router.replaceWith(this.url); } } diff --git a/addon/services/link-manager.ts b/addon/services/link-manager.ts index 9dcaafeb..27fe89de 100644 --- a/addon/services/link-manager.ts +++ b/addon/services/link-manager.ts @@ -23,6 +23,9 @@ export default class LinkManagerService extends Service { @service('router') readonly router!: RouterServiceWithRecognize; + @service('-routing') + readonly routing!: any; + /** * Whether the router has been initialized. * This will be `false` in render tests. @@ -79,11 +82,11 @@ export default class LinkManagerService extends Service { * Converts a `RouteInfo` object into `LinkParams`. */ static getLinkParamsFromRouteInfo(routeInfo: RouteInfo): LinkParams { - const models = routeInfo.paramNames.map(name => routeInfo.params[name]!); + const models = routeInfo.paramNames.map((name) => routeInfo.params[name]!); return { route: routeInfo.name, query: routeInfo.queryParams, - models + models, }; } @@ -91,7 +94,7 @@ export default class LinkManagerService extends Service { constructor(properties?: object) { super(properties); - // Ignore `Argument of type '"routeWillChange"' is not assignable to parameter of type ...` + // Ignore `Argument of type ''routeWillChange'' is not assignable to parameter of type ...` // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore @@ -118,7 +121,7 @@ export default class LinkManagerService extends Service { handleRouteWillChange(transition: Transition) { this._currentTransitionStack = [ ...(this._currentTransitionStack || []), - transition + transition, ]; } diff --git a/tests/dummy/app/controllers/application.ts b/tests/dummy/app/controllers/application.ts new file mode 100644 index 00000000..3c87e767 --- /dev/null +++ b/tests/dummy/app/controllers/application.ts @@ -0,0 +1,14 @@ +import Controller from '@ember/controller'; +import { tracked } from '@glimmer/tracking'; + +export default class ApplicationController extends Controller { + queryParams = ['applicationCategory', 'applicationColor']; + + @tracked applicationCategory = 'all'; + @tracked applicationColor = 'red'; + + update =(name: string , e: InputEvent) => { + //@ts-expect-error + this[name] = (e.target as HTMLInputElement).value; + } +} diff --git a/tests/dummy/app/controllers/parent.ts b/tests/dummy/app/controllers/parent.ts new file mode 100644 index 00000000..65c793d2 --- /dev/null +++ b/tests/dummy/app/controllers/parent.ts @@ -0,0 +1,17 @@ +import Controller from '@ember/controller'; +import { tracked } from '@glimmer/tracking'; + +export default class ApplicationController extends Controller { + queryParams = [ + 'parentCategory', + 'parentColor' + ]; + + @tracked parentCategory = 'all'; + @tracked parentColor = 'red'; + + update =(name: string , e: InputEvent) => { + //@ts-expect-error + this[name] = (e.target as HTMLInputElement).value; + } +} diff --git a/tests/dummy/app/controllers/parent/index.ts b/tests/dummy/app/controllers/parent/index.ts new file mode 100644 index 00000000..344ed8d9 --- /dev/null +++ b/tests/dummy/app/controllers/parent/index.ts @@ -0,0 +1,111 @@ +import Controller from '@ember/controller'; +import { tracked } from '@glimmer/tracking'; +import { inject as service } from '@ember/service'; +import LinkManagerService from 'ember-link/services/link-manager'; + +export default class ApplicationController extends Controller { + @service declare linkManager: LinkManagerService; + + queryParams = [ + 'parentIndexCategory', + 'parentIndexColor', + ]; + + @tracked parentIndexCategory = 'all'; + @tracked parentIndexColor = 'red'; + + @tracked noneLink; + @tracked knownLink; + @tracked allLink; + @tracked trackedAllLink; + @tracked trackedKnownLink; + + @tracked links = []; + + constructor() { + super(...arguments); + + const noneLink = this.linkManager.createLink({ + route: 'parent.index', + models: [1], + query: { + applicationColor: 'blue', + parentColor: 'green', + parentIndexColor: 'purple', + }, + mode: 'none', + }); + + const knownLink = this.linkManager.createLink({ + route: 'parent.index', + models: [1], + query: { + applicationColor: 'blue', + parentColor: 'green', + parentIndexColor: 'purple', + }, + mode: 'known', + }); + + const allLink = this.linkManager.createLink({ + route: 'parent.index', + models: [1], + query: { + applicationColor: 'blue', + parentColor: 'green', + parentIndexColor: 'purple', + }, + mode: 'all', + }); + + const trackedAllLink = this.linkManager.createLink({ + route: 'parent.index', + models: [1], + query: { + applicationColor: 'blue', + parentColor: 'green', + parentIndexColor: 'purple', + }, + mode: 'tracked-all', + }); + + const trackedKnownLink = this.linkManager.createLink({ + route: 'parent.index', + models: [1], + query: { + applicationColor: 'blue', + parentColor: 'green', + parentIndexColor: 'purple', + }, + mode: 'tracked-known', + }); + + this.links = [ + { + name: 'none', + link: noneLink, + }, + { + name: 'known', + link: knownLink, + }, + { + name: 'all', + link: allLink, + }, + { + name: 'tracked-all', + link: trackedAllLink, + }, + { + name: 'tracked-known', + link: trackedKnownLink, + }, + ]; + } + + update =(name: string , e: InputEvent) => { + //@ts-expect-error + this[name] = (e.target as HTMLInputElement).value; + } +} diff --git a/tests/dummy/app/routes/application.ts b/tests/dummy/app/routes/application.ts new file mode 100644 index 00000000..c0e170a6 --- /dev/null +++ b/tests/dummy/app/routes/application.ts @@ -0,0 +1,15 @@ +import Route from '@ember/routing/route'; +import LinkManagerService from 'ember-link/services/link-manager'; +import { inject as service } from '@ember/service'; +import RouterService from '@ember/routing/router-service'; + +export default class ApplicationRoute extends Route { + @service linkManager!: LinkManagerService; + @service router!: RouterService; + + constructor() { + super(...arguments); + window.linkManager = this.linkManager; + window.router = this.router; + } +} diff --git a/tests/dummy/app/routes/application/template.hbs b/tests/dummy/app/routes/application/template.hbs deleted file mode 100644 index 5230580f..00000000 --- a/tests/dummy/app/routes/application/template.hbs +++ /dev/null @@ -1,3 +0,0 @@ -

Welcome to Ember

- -{{outlet}} \ No newline at end of file diff --git a/tests/dummy/app/routes/parent.ts b/tests/dummy/app/routes/parent.ts new file mode 100644 index 00000000..133508c6 --- /dev/null +++ b/tests/dummy/app/routes/parent.ts @@ -0,0 +1,5 @@ +import Route from '@ember/routing/route'; + +export default class ParentRoute extends Route { + model() {} +} diff --git a/tests/dummy/app/routes/parent/index.ts b/tests/dummy/app/routes/parent/index.ts new file mode 100644 index 00000000..848d6a81 --- /dev/null +++ b/tests/dummy/app/routes/parent/index.ts @@ -0,0 +1,5 @@ +import Route from '@ember/routing/route'; + +export default class ParentIndexRoute extends Route { + model() {} +} diff --git a/tests/dummy/app/templates/application.hbs b/tests/dummy/app/templates/application.hbs new file mode 100644 index 00000000..ab88f9a3 --- /dev/null +++ b/tests/dummy/app/templates/application.hbs @@ -0,0 +1,23 @@ +
+
+ +

Application Route

+ Go to parent index route +

+ We are always passing colors to the generated links, so setting any color wont change any mode. +

+
+ + +
+ +
+ + +
+ + {{outlet}} + + +
+
\ No newline at end of file diff --git a/tests/dummy/app/templates/parent.hbs b/tests/dummy/app/templates/parent.hbs new file mode 100644 index 00000000..39379b30 --- /dev/null +++ b/tests/dummy/app/templates/parent.hbs @@ -0,0 +1,16 @@ +
+
+ +

Parent Route

+
+ + +
+ +
+ + +
+ {{outlet}} +
+
\ No newline at end of file diff --git a/tests/dummy/app/templates/parent/index.hbs b/tests/dummy/app/templates/parent/index.hbs new file mode 100644 index 00000000..3c570d0a --- /dev/null +++ b/tests/dummy/app/templates/parent/index.hbs @@ -0,0 +1,38 @@ + + +
+
+ +

Parent Index Route

+
+
+	Link configuration for all, remember passed in query params take precedence.
+{
+	route: 'parent.index',
+	models: [1],
+	query: {
+		applicationColor: 'blue',
+		parentColor: 'green',
+		parentIndexColor: 'purple',
+	},
+	mode: 'none',
+}
+
+ + +
+ +
+ + +this one shouldt change tracked link because its passed in +
+ {{#each this.links as |link|}} +

{{link.name}}

+ + {{link.link.url}} + +
+{{/each}} +
+
\ No newline at end of file