buschtoens / ember-link

Link primitive to pass around self-contained route references. It's {{link-to}}, but better!
https://buschtoens.github.io/ember-link/
Other
54 stars 12 forks source link

Currying Links ? #669

Open gossi opened 2 years ago

gossi commented 2 years ago

So, let's say this setup:

As routes are the connectivity layer - they have knowledge about route names but at route level the information about what parameters to be put in are not directly present.

FWIW is to curry links. Ie. the route passes in the link/name of the route and the component curries it with the parameters. I assume something like this code:

{{! route.hbs }}
<MyPage @detailLink={{link 'go-to-details'}} />
{{! component }}

<Button @link={{link @detailLink this.model.id}}>...</Button>

Now, this example looks like as if it is enough to pass in the route name, but also on route level you shall be able to pass generic parameters in as well, so within the component it is only to "finalize" the link.

thoughts?

buschtoens commented 2 years ago

I think overall this is a great and useful suggestion. I can definitely see myself wanting to use such a feature in some situations. Thank you.

But I fear the proposed mechanics won't work reliably:

Issues with Models / Dynamic Segments

Partial Application Base Final Wrapper
```hbs {{! route.hbs }} ``` ```hbs {{! components/my-page.hbs }} ```

{{link "go-to-details"}} itself is missing a model, which is then provided one layer down by the wrapping {{link @detailLink this.model.id}} invocation.

While the latter is a valid Link then, the former isn't, but there's no (convenient) way to tell them apart. If you were to access @detailLink.url, things will likely blow up, because RouterService#urlFor(...) is invoked with too few arguments.

Additionally, there may be routes accepting more than one model, e.g. /user/:user_name/address/:address_slug. How would this support statically partially applying one model in the base invocation and dynamically applying the other model in the final wrapper invocation?

Naïve Solution

One option is to naïvely append models from left to right:

{{#let (link "user.address" "buschtoens") as |baseLink|}}
  {{#let (link baseLink "home") as |finalLink|}}
    <a href={{finalLink.url}}>Home</a>
  {{/let}}

  {{#let (link baseLink "work") as |finalLink|}}
    <a href={{finalLink.url}}>Work</a>
  {{/let}}
{{/let}}

In this case :user_name is statically applied as "buschtoens" in the base invocation. The wrapping invocation then can dynamically apply :address_slug: "home" or "work" in this case.

But what if you need :user_name to be dynamic instead? Since models are applied from left to right, that won't work. For routes taking even more models, it gets even worse.

Issues with Query Params

For query params it's not as bad, because they are named instead of positional. However, that introduces a second problem: Wrapping invocations may unintentionally override query params that were set in the base invocation. This can lead to bugs. But in a different scenario, this behavior may be desirable.

Intermittent Solution

I think we need to bikeshed this a bit more and consider various use cases.

For the time being, you could easily achieve this with a custom (inline) helper. It even gives you maximum control over how arguments are applied.

import { helper } from '@ember/helper';
import { inject as service, Registry as Services } from '@ember/service';
import Component  from '@glimmer/component';

export default class MyComponent extends Component {
  @service('link-manager')
  private declare linkManager: Services['link-manager'];

  readonly buildDetailsLink = helper((id: string) => this.createUILink({
    route: 'go-to-details',
    models: [id]
  });
}
<MyPage @detailsLink={{this.buildDetailsLink}} />
<a href={{get (@detailsLink this.model.id) "url"}}>...</a>

Using ember-functions-as-helper-polyfill the component class would become a bit more concise:

import { inject as service, Registry as Services } from '@ember/service';
import Component  from '@glimmer/component';

export default class MyComponent extends Component {
  @service('link-manager')
  private declare linkManager: Services['link-manager'];

  buildDetailsLink(id: string) {
    return this.createUILink({
      route: 'go-to-details',
      models: [id]
    });
  }
}

Potential ember-link Support

Considering that Ember is evolving towards easier & convenient interoperation of templates & JS, this pattern may turn out to be preferable anyway.

We can trim down this code even further with a utility function. We might even add a static method on Link / UILink, like this:

import Component  from '@glimmer/component';
import { UILink } from 'ember-link';

export default class MyComponent extends Component {
  readonly buildDetailsLink = (id: string) => UILink.for(this, {
    route: 'go-to-details',
    models: [id]
  });
}
class Link {
  // ...

  constructor(linkManager: LinkManagerService, params: LinkParams) {
    // ...
  }

  static for(
    ownedObject: object,
    linkParams: LinkParams
  ): this {
    const owner = getOwner(ownedObject);
    const linkManager = owner.lookup('service:link-manager');
    return new this(linkManager, linkParams);
  }
}
gossi commented 2 years ago

Aaaah, thanks for highlighting that currying cannot always work. It was just my initial case in which it worked.

However, really like the idea for the factory method (esp. with ember-functions-as-helper-polyfill).

Let's do this 💪