Асинхронная загрузка опций для фильтров в ember-models-table

TL;DR Демка доступна по ссылке — Filters with server-side data loading

GIF

Немного теории

Фильтрация данных в таблицах — это одна из базовых функций, которую они должны поддерживать. Когда у вас доступен полный набор данных (как в случае использования models-table), то сделать выпадающий список в качестве фильтра — это дело одного флага filterWithSelect для колонки (см. документацию). В случах, когда используется Компонент models-table-server-paginated, из данных доступна только текущая страница. Получается, что выпадающий список можно сделать только с заранее определенными значениями. Они передаются в колонку через свойство predefinedFilterOptions (см. документацию). Это приемлемо далеко не всегда. Сейчас будет рассмотрен вариант, когда опции для такого фильтра будут загружаться с сервера асинхронно по мере ввода пользователем текста (на анимации выше показан результат).

В качестве базового компонента для фильтра возьмем выпадающий список из ember-power-select. Это отличный, проверенный временем и сотнями пользователей, Компонент. Наш фильтр построен поверх него и называется related-model-filter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// app/components/related-model-filter.js
import Component from '@ember/component';
import { reads } from '@ember/object/computed';
 
export default Component.extend({
  instances: null,
  selectedInstance: null,
  searchDebounce: reads('column.searchDebounce'),
  instanceClassName: reads('column.instanceClassName'),
  searchField: reads('column.searchField'),
  dropdownComponent: reads('column.dropdownComponent'),
 
  actions: {
    searchInstances() {},
    updateColumnFilter() {}
  }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
{{! app/templates/components/related-model-filter.hbs }}
{{#power-select
  allowClear=true
  options=instances
  search=(action "searchInstances")
  searchField=searchField
  selected=selectedInstance
  onchange=(action "updateColumnFilter") as |instance|}}
  {{#if dropdownComponent}}
    {{component dropdownComponent instance=instance}}
  {{else}}
    {{get instance searchField}}
  {{/if}}
{{/power-select}}

Фильтры в колонках в качестве одного из аргументов получают объект column — текущую колонку. Часть ее свойств можно задавать из контекста, в котором отрисовывается вся таблица. В данном случае мы используем четыре таких свойства:

  • instanceClassName — имя Модели, по которой идет фильтр
  • searchField — поле, по которому идет поиск внутри power-select (см. документацию)
  • dropdownComponent — имя Компонента, который используется для отображения опций в power-select. Если он не задан, то выводится значение свойста searchField
  • searchDebounce — время задержки запроса загрузки опций для power-select

Обработчик события searchInstances используется для загрузки списка опций по мере ввода пользователем текста в соответствующем поле:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// app/components/related-model-filter.js
import Component from '@ember/component';
import { reads } from '@ember/object/computed';
import { debounce } from '@ember/runloop';
import { inject as service } from '@ember/service';
import RSVP from 'rsvp';
 
export default Component.extend({
  // skipping options declared before
 
  store: service(),
 
  actions: {
    searchInstances(userInput) {
      const searchDebounce = get(this, 'searchDebounce') || 100;
      return new RSVP.Promise((resolve, reject) =>
        debounce(this, this._performSearchInstances, userInput, resolve, reject, searchDebounce));
    }
  },
 
  formatQuery(query, userInput) {
    const searchField = get(this, 'searchField');
    query.search = query.search || {};
    query.search[searchField] = userInput;
    return query;
  },
 
  _performSearchInstances(userInput, resolve, reject) {
    const instances = get(this, 'instances');
    let query = instances ? get(instances, 'query') : {page: 1};
    query = this.formatQuery(query, userInput);
    return get(this, 'store')
      .query(get(this, 'instanceClassName'), query)
      .then(instances => {
        set(this, 'instances', instances);
        resolve(instances);
      })
      .catch(e => reject(e));
  }
});

Функция debounce обеспечивает отложенное выполнения переданного ей метода на заданное время (@ember/runloop/debounce). Соответственно, выполнения Promise, который возвращается из обработчика события, произойдет после срабатывания _performSearchInstances. В этом методе через Сервис store выполняется запрос на загрузку данных. При чем, для этого используется метод query. Формирование объекта query для него может быть каким-угодно. В нашем случае ввод пользователя передается через свойство query.search[searchField]. Загруженные Записи сохраняются в свойство instances Компонента (и используются как опции в power-select).

В случае, когда такой запрос в Компоненте выполнется впервые и в instances находится null, в качестве начального значения для query используется {page: 1}.

Это можно было бы вынести в качестве опции колонки, но в текущих реалиях «сойдет и так».

Метод formatQuery может быть переопределен в дочерних Компонентах, если требуется поменять формирование объекта query.

В методе query Адаптера для Модели из instanceClassName в параметре query будет свойство search. А в нем будет текст, введенный пользователем.

Без внимания остался обработчик события updateColumnFilter. Он вызывается, когда пользователь выбирает какую-то из доступных опций. В таком случае надо обновить значение column.filterString:

1
2
3
4
5
6
updateColumnFilter(instance) {
  set(this, 'selectedInstance', instance);
  const searchField = get(this, 'searchField');
  const filterString = instance ? get(instance, searchField) : '';
  set(this, 'column.filterString', filterString);
}

У таблиц из ember-models-table есть возможность сбросить значения фильтров по клику на кнопке в подвале таблицы. В таком случае выбранное в power-select значение должно быть сброшено на пустое. Небольшой метод-наблюдатель нам в этом поможет:

1
2
3
4
5
columnsFilterStringIsDropped: observer('column.filterString', function () {
  if (!get(this, 'column.filterString')) {
    set(this, 'selectedInstance', null);
  }
}),

Реальный пример

Без реального примера такой материал воспринимает так себе. Ember-Twiddle нам поможет. Демка доступна по ссылке — Filters with server-side data loading . Для учебных целей воспользуемся GitHub API для поиска репозиториев (см. документацию). Будем искать все репозитории, где встречается слово ember. Пример запроса на первую страницу:

1
https://api.github.com/search/repositories?q=ember&page=1&per_page=10

Для поиска репозиториев по пользователю запрос должен выглядеть так:

1
https://api.github.com/search/repositories?q=ember+user:ember-cli&page=1&per_page=10

Для поиска самих пользователей по части их логина запрос такой:

1
https://api.github.com/search/users?q=ember-cli&page=1&per_page=10

Для репозиториев и пользоватем создадим две Модели repository и user:

1
2
3
4
5
6
7
8
9
10
// app/models/repository.js
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { belongsTo, hasMany } from 'ember-data/relationships';
 
export default Model.extend({
  name: attr('string'),
  fullName: attr('string'),
  owner: belongsTo('user')
});
1
2
3
4
5
6
7
8
9
// app/models.user.js
import Model from 'ember-data/model';
import attr from 'ember-data/attr';
import { belongsTo, hasMany } from 'ember-data/relationships';
 
export default Model.extend({
  login: attr('string'),
  avatarUrl: attr('string')
});

Роут для странички, где выводится таблица с репозиториями, выглядит так:

1
2
3
4
5
6
7
8
import Route from '@ember/routing/route';
import {get} from '@ember/object';
 
export default Route.extend({
  model() {
    return get(this, 'store').query('repository', {per_page: 10});
  }
});

А вот ее Контроллер:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import Controller from '@ember/controller';
 
export default Controller.extend({
  columns: [
    {
      propertyName: 'name',
      title: 'Repo Name',
      useFilter: false,
      useSorting: false
    },
    {
      propertyName: 'fullName',
      useFilter: false,
      useSorting: false
    },
    {
      propertyName: 'owner.login',
      title: 'Owner',
      componentForFilterCell: 'related-model-filter',
      instanceClassName: 'user',
      filteredBy: 'user.login',
      searchField: 'login',
      dropdownComponent: 'owner-option',
      useSorting: false
    }
  ],
  filterQueryParameters: {
    page: 'page',
    pageSize: 'per_page'
  }
});

Тут стоит обратить внимание на последний элемент в columns. В этой колонке выводится логин владельца репозитория. В качестве фильтра используется созданный нами Компонент related-model-filter. Значение свойства dropdownComponent — это имя Компонента, который будет отрисован в качестве опций power-select. Он выглядит так:

1
2
3
4
5
6
7
// app/components/owner-option.js
import Component from '@ember/component';
 
export default Component.extend({
  tagName: '',
  instance: null
});
1
2
3
{{! app/templates/components/owner-option.hbs }}
<img style="max-width: 32px; max-height: 32px;" src="{{instance.avatarUrl}}" alt="{{instance.login}}">
  {{instance.login}}

Здесь рядом с логином выводится аватарка пользователя.

Объект filterQueryParameters содержит в себе настройки для серверной навигации для GitHub API (он передается в качестве одноименного параметра в models-table-server-paginated).

Метод query Адаптера для Модели repository выглядит так:

1
2
3
4
5
6
7
8
query(store, type, query) {
  query.q = 'ember';
  if (query['user.login']) {
    query.q += ` user:${query['user.login']}`;
    delete query['user.login'];
  }
  return this._super(...arguments);
}

Для Модели user метод query ее Адаптера выглядит проще:

1
2
3
4
5
query(store, type, query) {
  query.q = query.search.login;
  delete query.search.login;
  return this._super(...arguments);
}

URL для query-запроса в обоих случаях формируется одинаково:

1
2
3
urlForQuery(query, modelName) {
  return `${get(this, 'host')}/search/${this.pathForType(modelName)}`;
}

Тут host — это https://api.github.com.

Вместо послесловия

Асинхронная загрузка опций для фильтра — это не такая сложная задача, как кажется на первый взгляд. Ее решение можно сделать как можно более «общим», однако специфика API каждого проекта все равно внесет коррективы.

Еще раз ссылка на демку — Filters with server-side data loading.

,

Оставить комментарий

Top ↑ | Main page | Back