Synchronising query parameters with a component

Hi,

I’ve built an input search component that I’m pretty happy with. The component contains an html input element, and performs a JSON API style search using the text value in the input. I’m currently using Mirage as a mock server, and I’ve included debouncing in the component that looks for a 250ms pause between keystrokes before firing off another search request. All good so far.

{{!-- SearchBoards component JS --}}
export default class SearchBoardsComponent extends Component {
    @tracked query = null;
    @tracked results = [];

    // This task performs a 250ms debounce so that a query to the
    // server is only performed after the user has paused slightly
    // The "restartable" option at the end of the task cancels all
    // but the latest task request when the task is run multiple
    // times concurrently. 

    @(task(function * () {
        yield timeout(250);

        if(this.query) {
            let response = yield fetch(`/search?query=${this.query}`);
            this.results = yield response.json();
        } else {
            this.results = [];
        };
    }).restartable()) updateResults;
}

I have used the “on modifier” in the component’s template to call an update method in the component. As I understand it this is the DDAU approach that we should be taking.

{{!-- SearchBoards component template --}}
<div>
    <Input
        placeholder="search"
        autofocus
        {{!-- The search query text; bound to an attribute in the component --}}
        @value={{this.query}}
        {{!-- Trigger a search after a key press (i.e. keyup event) --}}
        {{on "keyup" (perform this.updateResults)}}
    />
</div>

Next I wanted to incorporate query parameters so that the text value in the search component corresponds (i.e. is synchronised) with the value of the query parameter, but I’m not sure the best way to do this.

Query parameters can be easily implemented on the controller (corresponding to a route) so I can see how I can pass in a parameter to my search component from the route’s template, but then what?

Search route template:

{{!-- app/pods/search/template.hbs --}}

<SearchBoards />

Search route controller:

// app/pods/search/controller.js
import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';

export default class SearchController extends Controller {
    queryParams = ['query'];

    @tracked query = null;
}

Options I have considered, but I know there must be a better way:

  1. Take the component template code and implement it as a route (doing away with the component)
  2. Add the “on modifier” to the route’s template (not the component’s template) and then pass a parameter to the component. …but this doesn’t feel like a clear separation of concerns anymore

I’m really keen to understand the right design pattern (and patterns in general) as I build up my application. I’m finding that learning ember is like riding waves: waves of elation, and waves of despair, but I can see when done right the code can be expressive and neat.

Thanks,

Dave

It’s a good question, thanks for asking it here!

One solution is to practice DDAU by creating a parent component (let’s call it <Search>—you had a notion of this with the controller, but I’ll make a component to reuse code that I have) and a child component (this is your <SearchBoards> component).

The parent’s responsibilities are to initialize data (based on query parameters) and define an action to update the data. The child’s responsibilities are to display the form and send the form values to the parent via the parent’s action. This way, the parent can use the form values to update the data and decide what to do with the updated data.

To make the explanation more concrete, maybe the component can look something like:

/* app/components/search/index.js */

// Imports omitted here...

export default class SearchComponent extends Component {
  @service router;

  @tracked query = this.query;
  @tracked results = [];

  get query() {
    const qps = this.router.currentURL.split('?')[1];

    if (qps) {
      return qps.split('=')[1];
    }

    return null;
  }

  @action performSearch(value) {
    this.query = value;
    this.updateResults();
  }

  async updateResults() {
    if (this.query) {
      let response = await fetch(`/search?query=${this.query}`);
      this.results = await response.json();
    } else {
      this.results = [];
    }
  }
}

Then, in the template, we’d write something like,

{{!-- app/components/search/index.hbs --}}

<SearchBoards
  @parameters={{this.parameters}}
  @onChange={{this.performSearch}}
/>

<SearchResults
  @results={{this.results}}
/>

(I edited the code to fit your scenario better. I realized my initial component handles showing search results differently, my bad.)

1 Like

Thank you Isaac - that really helped me.

(FYI: I’ve also been studying your ember-animated-tutorial-octane project heavily which has helped me get this far.)

It turned out to be extremely easy to get it going. I ended up moving the parent component logic (roughly based on your example) into my search controller along with the defined query params. As per your suggestion I could have just as easily placed the search functionality in a parent component and then called the child component which would handle the presentation/rendering/input.

The key to “getting it” was the original explanation you made which was that the data preparation and actions are setup by the parent and then passed to the child which handles the presentation and user interaction.

One final question out of curiosity. In the example you supplied I can see how you managed to retrieve the query parameters (via an injected router service), but based on my reading of the code snippet you supplied this doesn’t manage the update/synchronisation of the query parameters. I assume taking your approach I would still need a route defined with a controller that calls your component and passes the query parameter. I can see how I could implement that, but I’m just confirming my understanding.

For completeness:

app/pods/search/template.hbs

<Ui::InputResizeToText
    @value={{this.query}}
    {{did-insert (perform this.updateResults)}}
    {{on "keyup" (perform this.updateResults)}}
/>

app/pods/search/controller.js

import Controller from '@ember/controller';
import { tracked } from '@glimmer/tracking';
import { task, timeout } from 'ember-concurrency';

export default class SearchController extends Controller {
    queryParams = ['query'];

    @tracked query = "";
    @tracked results = [];

    @(task(function * () {
        yield timeout(250);

        if(this.query) {
            let response = yield fetch(`/search?query=${this.query}`);
            this.results = yield response.json();
        } else {
            this.results = [];
        };
    }).restartable()) updateResults;    
}

Thanks again,

Dave

1 Like

Hi, Dave. Apologies for a late reply.

For updating the URL, one solution that I thought of was to leverage the model hook to fetch data. This hook can be called again when the query parameter changes. Of course, we don’t want the hook to be called after every character change. Luckily, we can solve this with ember-concurrency, which you already use.

So the idea is to define an action in the controller that updates the query parameter query. Then, we pass that action to the input component. The input component can be responsible for passing the new value of query to this action when the user hasn’t typed for some time.

I updated a demo app to illustrate what I mean more concretely. Feel free to look at code changes for acm-octane-workshop and clone the repo to see the input in action.

(PS. Since you used fetch, you may or may not need to use the setupController hook to pass results array to the controller in the right shape. If you have a native class, you’ll want to call super.setupController.)

Best,

1 Like

I took a look at the code changes as you suggested and that’s really neat. That’s what I was trying to explain when I said that you would likely need to link the controller to the parent component. I really like the clean approach and appreciate your guidance.

1 Like