Setting query params programmatically

I’m interested in ways of thinking about this problem: setting query params programatically. Suppose I have some content coming from a model (layers on a map, for example), and I want those layers to be “toggleable”, off or on, and preserve that state in the URL. I can try this, but it’s either ignored or since the controller is initialized before my data comes down, it won’t update:

// routes/application.js
afterModel() {
  const paramsFromModel = stuff; // munge and extract what should be param-able
  this.controllerFor('application').set('queryParams', paramsFromModel);
  this.controllerFor('application').setProperties( {  /* set their detaults */  } );
}

Over the years, I’ve run into this problem many times, in which I’ve wanted to set up what the query params are from data that comes in from the model hook of the route. I have tried dozens of different approaches, including:

  • Using an application instance-initializer, which gets me access to the store and the needed controller so that I can set up those queryParams in time. The problem is that these initializers aren’t able to be async, as deferReadiness is no longer available in these kinds of initializers (it’s only available in initializers, which won’t let me access the store as it doesn’t exist at that point in booting up the app).
  • Using beforeModel will honor changes made to controller queryParams, but doing anything asynchronously (like fetching data to define those) means they aren’t set “in time”, and those QPs aren’t bound correctly.
  • Using setupController (which nominally seems like the place to do this but is not) simply does not honor newly set queryParams.

Workarounds I’ve tried include:

  • Using an array query param to include a list of properties that are implicitly “true”. This approach lends itself to lots of aliasing properties and general confusion around when and where query params are handled. It also requires setting up separate computed properties to manage status from the original query params.

Ember Parachute helps with a lot of these things, and also includes hooks for managing when query params are changed, but it doesn’t get what I need with the kinds of apps I’m building.

I simply cannot find a decent pattern around this, short of a computed string array on the controller (visibleLayers: []) which implicitly determines visibility state of layers that come in from the server. This mostly works, but it’s very confusing and doesn’t get me all the default behavior from normal QPs (working defaults).

Curious to see how 1) others have solved this issue 2) what other ways to address this issue (models that are “QP-able”, and setting up the app to honor those).

EDIT: Ideally, I would love to be able to do something like this:

  setupController(controller, model) {
    const queryParams = model.layerGroups
      .reduce((acc, curr) => {
        acc[curr.get('id')] = curr.get('visible');
        return acc;
      }, {});

    const layerGroupIDs = model.layerGroups.mapBy('id');
    controller.set('queryParams', layerGroupIDs);

    controller.setProperties({
      ...queryParams,
    });

    this.replaceWith(this.routeName, {
      queryParams,
    });

    super.setupController(controller, model);
  }

But updates to the controller properties never get saved in the URL.

1 Like

The difficulty here is that the query params for the current route are inputs to the route hooks (including even beforeModel), because they can say this.paramsFor(this.routeName) and expect to see the query params in there. So the route hooks are necessarily too late to influence the set of possible query params.

I agree that this makes dynamically altering the set of params very awkward. It’s equivalent to dynamically altering anything else about the set of possible routes.

One solution you may not have considered is extending whichever Location implementation you’re using. That gives you total control over the boundary between Ember and the real browser URL. You can use it to implement an entirely separate strategy for adding query parameters to the URL and reading them out again.

1 Like

Agree with @ef4 to think about entirely separate strategy if you’re flexible in terms of building URL in any way you want. For example, consider using a location hash and parse it for params doing server requests with them. You’ll be able to manipulate browser URL string within the route with browser history preserved.

2 Likes

Thank you, @nik @ef4, I feel a bit saner now.

One solution you may not have considered is extending whichever Location implementation you’re using.

I’m intrigued by the idea of extending the HistoryLocation object, and I’m curious what that might look like so that I can get started on actually solving this issue.

This seems like something I would love to dive into but need just a few hints on how to start. Would I extend it in an initializer and add logic there? How should I access the instance? Do you know of any project-specific repositories or addons that extend the HistoryLocation API to manipulate as needed? Any links would be greatly appreciated.

For example, consider using a location hash and parse it for params doing server requests with them.

When you say location hash, do you mean specifically the HashLocation API (as opposed to the HistoryLocation API), or just xLocation API in general?

EDIT:

I found this: https://github.com/emberjs/ember.js/blob/v3.0.0/packages/ember-routing/lib/location/api.js#L41.

Yeah, start by making app/locations/custom.js like this:

import HistoryLocation from '@ember/routing/history-location';

export default HistoryLocation.extend({
  getURL() {
    let url = this._super();
    console.log(`I customized getURL: ${url}`);
    return url;
  }
});

And turn it on in config/environment.js by setting locationType: 'custom'

4 Likes

Just to note, if you extend history location and still want ember-cli to work properly (e.g. navigate to a sub-url and hit refresh: you should get served the index.html at that URL instead of a 404) you will need to set historySupportMiddleware to true as well as locationType in your config/environment.js.

4 Likes

Thanks, folks. historySupportMiddleware saved me from some future grief.

Here’s where I’ve landed: I’ve set up the behavior I want in a service in this gist: Aliased & observed properties from a collection of models · GitHub. The service gets me an observer that fires events when my aliased QPs change.

However, I’m not sure where I should start interacting with the Location API. I was going to go down this path: move all that service logic into the custom Location object. Does that mean I should use the container lookup in the route (perhaps in the afterModel hook), and lookup my Location API?. This could set up some dynamicQueryParams, handle all the aliasing, etc. From there, I get access to all the Location methods I’d need for this: setURL, formatURL, etc.

:duck:

Sorry for the double post, but I found an old example (of Discourse!) of overriding HistoryLocation and managing QPs (copied to my own gist):

Here they are managing parsing in and out of search strings (query params) to objects, setting up computeds to replaceState, and so on. I’ll keep going down this path with this code as an example.

I guess out of curiosity I thought there might be a better API for pushing new params that (de)serializes them, determines if they’re duplicated, determines if they’re default, etc. One thing I’m tempted to do is to simply lookup the HistoryLocation object from my service and do everything there (since I will need computeds and other state)…

EDIT: FWIW, this is where I landed: GitHub - NYCPlanning/labs-ember-tardy-params: Bind on-the-fly query parameters to your models.

Somewhat related to this topic: is it possible/even advisable to attempt to deal with query params in a component. I have a lot of code that’s duplicated to manage more complex query-param driven queries, and was hoping to extract that into a separate component, but have found it to be pretty challenging.

Hmm I think it depends on what you mean by “deal with”. If I’ve needed a lot of query params and have them accessible in many components, I’d use a service: Global Query Params for Deeply Stateful Ember Apps | by Matt Gardner | NYC Planning Digital | Medium

1 Like