Knockout: Создаем поле с авто подстановкой или Autocomplete on knockoutjs

ru-RU | создано: 01.05.2014 | опубликовано: 01.05.2014 | обновлено: 01.01.2018 | просмотров за всё время: 6212

В одном из проектов мне пришлось отказаться от использования jQuery UI, а, следовательно, и от множества контролов доступных в этом фреймворке. В этой статье будем реализовывать поле с авто подстановкой (autocomplete) на Knockout.js.

Задача

Вот так будет выглядеть контрол автоподстановки. В данном случае, контрол постановки меток (тегов) к статьям блога.

139-0

Над полем ввода меток отображаются уже выбранные метки (теги), а под ним те, которые найдены при вводе символов. Добавить новый тег можно кликнув на один подобранных тегов. Таким же образом можно удалить тег, но уже на кликать надо на те, что над полем вода. Если тег не найден, то набрав слово нужно нажать на [+]. Контрол простой и незамысловатый, но работает добротно и эффективно.

Способы реализации

Нужно сказать, что реализация зависит от нескольких важных положений. Первое, это то, как реализована страница, на которой происходит редактирование (в данном случае меток к статье). Если страница генерируется при помощи Razor (ASP.NET MVC), то контрол для отображения меток может выглядеть как-то так:

<div class="form-group">
    @Html.LabelFor(model => model.TagNames)<br />
    @Html.TextBoxFor(model => model.TagNames, new Dictionary<string, object> { { "class", "tagsinput" } })
    @Html.ValidationMessageFor(model => model.TagNames)
</div>

Второе, это когда вся страница генерируется при помощи, например, KnockoutJs или AngularJs, или любым другим движком, который “понимает” шаблоны. При втором варианте, а конкретнее, при использовании Knockout, возможно правильным было бы рассмотреть написание своего собственного bindingHandler. Я буду использовать первый вариант, когда страницу генерирует Razor, и надо на контрол, который выводит теги через запятую, “прилепить” функционал, который позволит подставлять уже существующие теги и добавлять новые.

Расстановка сил

Я буду делать для своего блога функцию редактирования статьи. Хочется помечать статьи метками (тегами). Для начала создаю файл, в который и буду писать весь код на javascript и делаю ссылку на странице редактирования.

@section scripts
{
    <script src="~/Scripts/app/vm/tagSearch.js"></script>
}

В файле создаем стандартную начальную разметку, чтобы код начал выполняться только после того как страница загрузится.

$(function () {

});

Теперь можно начинать реализацию задумки.

Реализация первого способа

Так как всю страницу сгенерировал для меня Razor, то мне нужно для начала найти контрол, в который Razor прописал метки через запятую в поле <input />. Вы уже заметили что самом первом листинге я подставил css с именем “tagsinput”. Используя этот класс, находим элемент:

element = $('.tagsinput'),

Затем инициализируем контрол, создаем и расставляем зависимости, в моем случае всё происходит в методе Init():

init = function () {
    if (element) {
        element.addClass('hidden');
        var container = $('<div  data-bind="blockUI: ds.indicator" />'),
            addTemplate = $('<span class="badge" data-bind="text:Name, click: $parent.add" style="cursor: pointer"></span>'),
            removeTemplate = $('<span class="badge" data-bind="text:Name, click: $parent.remove" style="cursor: pointer"></span>'),
            inputGroup = $('<div class="input-group" />'),
            input = $('<input data-bind="value: ds.queryParams.Search, valueUpdate: \'afterkeydown\'" />'),
            buttonWrap = $('<span class="input-group-btn" />'),
            button = $('<button class="btn btn-info" data-bind="click:create" type="button">+</button>'),
            tagfounded = $('<div data-bind="foreach: ds.items" />'),
            tagSelected = $('<div data-bind="foreach: tagSelected" />');
        input.addClass('form-control');
        tagSelected.append(removeTemplate);
        buttonWrap.append(button);
        tagfounded.append(addTemplate);
        inputGroup.append(input);
        inputGroup.append(buttonWrap);
        container.append(tagSelected);
        container.append(inputGroup);
        container.append(tagfounded);
        element.after(container);
    }
},

Сначала скрываю в строке 3 поле сгенерированное Razor’ом, чтобы не изменять форму ввода. После этого в строке 4-21 создаю новое поле  и кнопку. Накладываю стили и добавляю вновь созданный контрол после оригинального input строка 22.

Для поиска тегов (меток) в базе данных я буду использовать DataSource для JavaScript:

ds = new site.controls.DataSource({
    autoLoad: false,
    service: site.services.tag
}, {
    Search: ko.observable()
}),

Далее мне требуется создать ViewModel, который будет работать в моём контроле. Приведу его целиком:

model = function () {
    var tagSelected = ko.observableArray(),
        tagFounded = ko.observableArray(),
        add = function (item) {
            var contains = site._.find(tagSelected(), function (i) {
                return i.Name() === item.Name();
            });
            if (!contains) {
                tagSelected.push(item);
                ds.clear();
                ds.queryParams.Search('');
            }
        },
        remove = function (item) {
            var contains = site._.find(tagSelected(), function (i) {
                return i.Name() === item.Name();
            });
            if (contains) {
                tagSelected.remove(contains);
            }
        },
        create = function () {
            var name = ds.queryParams.Search();
            if (name) {
                var item = new site.m.Tag({ Name: name });
                var contains = site._.find(tagSelected(), function (i) {
                    return i.Name() === item.Name();
                });
                if (!contains) {
                    tagSelected.push(item);
                    ds.queryParams.Search('');
                }
            }
        };

    if (element.val()) {
        ds.execute.getDataByName(element.val(), function (tags) {
            tagSelected(tags.Item);
        });
    }

    tagSelected.subscribe(function () {
        element.val('');
        var txt = '';
        site._.each(tagSelected(), function (item) {
            txt += item.Name() + ',';
        });
        element.val(txt);
    });
    return {
        tagSelected: tagSelected,
        tagFounded: tagFounded,
        remove: remove,
        create: create,
        add: add,
        ds: ds
    };

};

Выглядит достаточно увесисто, но ничего лишнего. Итак, стока 1 создает экземпляр моего ViewModel для работы контрола. В следующей строке, создаем переменную, в которой будем хранить выбранный тег.

Строка 4 - метод добавления новой метки, где проверяется на наличие уже в списке выбранных меток, если нет, то добавляем и очищаем поле ввода строки для поиска (фильтрации).

В строке 14 расположилась функция удаления метки из списка выбранных. Перед удаление производится проверка на наличие в списке удаляемого объекта.

Строка 22 является методом, который служит создания нового тега. Если подбор не дал результатов, то создаем экземпляр класса тега и так же добавляем его в список выбранных. Класс выглядит таким образом и лежит в другом файле:

(function(site) {

    site.m.Tag = function(data) {
        data = data || {};
        var me = this;
        me.Id = ko.observable(data.Id);
        me.Name = ko.observable(data.Name).extend({ required: true });
        me.selected = ko.observable(false);

        return me;
    };

})(site);

Строка 36: Наверное, эта часть кода может считаться одной из самых важных, потому как именно здесь проверяется есть ли в открываемой записи блога выбранные метки, и если строки input не пустая, то есть метки заполнены (у меня они выдаются на редактирование в виде строки через запятую), то запускается на выполнение метод сервиса для получения списка меток с сервера:

getDataByName = function (params, back) {
    if (typeof back !== "function") throw new Error("callback not a function");
    return site.amplify.request({
        resourceId: "byname",
        data: { tags: params },
        success: function(json) {
            back(new site.controls.ApiResult(json, mapItems));
        },
        error: function(json, status) {
            back(site.controls.ApiResult(null,
                "Ошибка удаления сущности \"Метка (тэг)\" (method \"del\") Tag"));
        }
    });
}

Для запросов на сервер я использую amplifyjs (строка 3), который прекрасно справляется с поставленной задачей. Полученные (и неполученные) результаты с сервера я оборачиваю в результаты ApiResult (строки 7 и 10):

//#region ApiResult

///////////////////////////////////////////////////////////////
//  ApiResult
//  bindingHandlers для обработки результатов ответа
//  на запрос к Web API
//  автор: calabonga.net
//  зависит от:     knockout.js
///////////////////////////////////////////////////////////////

site.controls.ApiResult = function (json, mapper) {
  var options = { timeOut: 0, extendedTimeOut: 0 };
  if (json === null) {
    site.logger.error(mapper, 'Ошибка сервиса', options);
    return;
  }
  var me = {};
  if (json.Success) {
    if (mapper)
      me.Item = new mapper(json.Item || json.Items);
    else
      me.Item = json.Item || json.Items;
  }
  if (json.Info) {
    site.logger.info(json.Info);
  }
  if (json.Warning) {
    me.Item = [];
    site.logger.warning(json.Warning,
                  'Внимание', { timeOut: 7000, extendedTimeOut: 1000 });
  }
  if (json.Error) {
    site.logger.error(json.Error, 'Ошибка', options);
  }
  return me;
};

//#endregion 

Стоит, однако, заметить, что для всех операций поиска и фильтрации используется фреймворк underscorejs, который “скрывается” под референсом “site._”.

Вернемся к нашему ViewModel. В строке 42 подписываемся на изменения выбранных тегов, как только добавляется или удаляется какой-либо тег, срабатывает этот метод. Этот метод преобразует JSON-объекты в строку меток через запятую.

В строке 50 возвращаем Literal Object.

Теперь остановимся непосредственно на самом процессе задания параметров для подбора меток. Очевидно, что при вводе любой буквы на сервер отправляется запрос на поиск введенной комбинации. Если учесть, что некоторые пользователи имеют достаточно большую скорость набора текста, то правильным было бы ограничить количество запросов на сервер, вызывая метод с некоторой отсрочкой, так сказать отложенный вызов. В knockoutjs существуют полезная надстройка (extender) на observable свойство. Называется эта надстройка throttle. Ее я и буду использовать, создав свойство filter:

filter = ko.computed(function () {
    return ds.queryParams.Search();
}).extend({ throttle: 400 }),

Вот теперь всё действительно работает как положено. Я думаю, что будет не лишнем, если я приведу весь текст листинга файла.

$(function () {

    site.controls.tagsinput = function () {
        var
            element = $('.tagsinput'),
            init = function () {
                if (element) {
                    element.addClass('hidden');
                    var container = $('<div  data-bind="blockUI: ds.indicator" />'),
                        addTemplate = $('<span class="badge" data-bind="text:Name, click: $parent.add" style="cursor: pointer"></span>'),
                        removeTemplate = $('<span class="badge" data-bind="text:Name, click: $parent.remove" style="cursor: pointer"></span>'),
                        inputGroup = $('<div class="input-group" />'),
                        input = $('<input data-bind="value: ds.queryParams.Search, valueUpdate: \'afterkeydown\'" />'),
                        buttonWrap = $('<span class="input-group-btn" />'),
                        button = $('<button class="btn btn-info" data-bind="click:create" type="button">+</button>'),
                        tagfounded = $('<div data-bind="foreach: ds.items" />'),
                        tagSelected = $('<div data-bind="foreach: tagSelected" />');
                    input.addClass('form-control');
                    tagSelected.append(removeTemplate);
                    buttonWrap.append(button);
                    tagfounded.append(addTemplate);
                    inputGroup.append(input);
                    inputGroup.append(buttonWrap);
                    container.append(tagSelected);
                    container.append(inputGroup);
                    container.append(tagfounded);
                    element.after(container);
                }
            },
            ds = new site.controls.DataSource({
                autoLoad: false,
                service: site.services.tag
            }, {
                Search: ko.observable()
            }),
            filter = ko.computed(function () {
                return ds.queryParams.Search();
            }).extend({ throttle: 400 }),
            model = function () {
                var tagSelected = ko.observableArray(),
                    tagFounded = ko.observableArray(),
                    add = function (item) {
                        var contains = site._.find(tagSelected(), function (i) {
                            return i.Name() === item.Name();
                        });
                        if (!contains) {
                            tagSelected.push(item);
                            ds.clear();
                            ds.queryParams.Search('');
                        }
                    },
                    remove = function (item) {
                        var contains = site._.find(tagSelected(), function (i) {
                            return i.Name() === item.Name();
                        });
                        if (contains) {
                            tagSelected.remove(contains);
                        }
                    },
                    create = function () {
                        var name = ds.queryParams.Search();
                        if (name) {
                            var item = new site.m.Tag({ Name: name });
                            var contains = site._.find(tagSelected(), function (i) {
                                return i.Name() === item.Name();
                            });
                            if (!contains) {
                                tagSelected.push(item);
                                ds.queryParams.Search('');
                            }
                        }
                    };

                if (element.val()) {
                    ds.execute.getDataByName(element.val(), function (tags) {
                        tagSelected(tags.Item);
                    });
                }

                tagSelected.subscribe(function () {
                    element.val('');
                    var txt = '';
                    site._.each(tagSelected(), function (item) {
                        txt += item.Name() + ',';
                    });
                    element.val(txt);
                });
                return {
                    tagSelected: tagSelected,
                    tagFounded: tagFounded,
                    remove: remove,
                    create: create,
                    add: add,
                    ds: ds
                };

            };

        filter.subscribe(function (value) {
            ds.clear();
            if (value) {
                ds.getData();
            }
        });

        return {
            init: init,
            container: element.parent()[0],
            model: model
        };
    }();

    site.controls.tagsinput.init();
    ko.applyBindings(site.controls.tagsinput.model,
        site.controls.tagsinput.container);

});

Вот и всё. Вопросы, пожелания, замечания и предложения как и прежде принимаются в комментариях.