Ajax поиск на UMI.CMS

ajax-poisk-na-umi-cms
В данной статье я постараюсь рассказать, как можно реализовать Ajax поиск на UMI.CMS.

На wiki.umisoft.ru рассказывается как реализовать поиск с подсказкой, но это немного другой функционал. Суть заключается в том, что скрипт вытягивает из базы данных список слов, и далее при вводе поисковой фразы появляются подсказки.

Я пошел немного дальше и реализовал именно Ajax поиск. В качестве примера для реализации я взял с rozetka.com.ua.
Сразу хочу отметить, что код, которой я написал/модернизировал потребует доп. проработки, но для того, чтобы стартовать, его достаточно.
Дополнительно сообщаю, что код протестирован в последних версиях браузеров Firefox и Chrome.

Подготовка.
Для начала вам следует убедится в том, что у вас есть проиндексированные товары. Для этого следует зайти в модуль «Поиск»(/admin/search/index_control/), и убедиться что у вас есть нечто схожее с изображение ниже:
ajax-poisk-na-umi-cms
Если ничего не проиндексировано, то сделайте это.

Программирование.
Если вы используете новый формат хранения шаблонов, то у вас в шаблоне должна присутствовать следующая иерархия — templates\[название_шаблона]\classes\modules\search\.
В папке search должно быть 2 файла:

  • permissions.php
  • class.php

Если же вы предпочитаете использовать старый формат хранения шаблонов, вам нужно в папке \classes\modules\search\ создать три файла, если еще нет :

  • permissions.custom.php
  • __custom.php

Содержание файла permissions.php (permissions.custom.php)

<?php
$permissions['search'] = 'ajaxSearch';
?>

Содержание файла class.php (__custom.php)

<?php

class search_custom extends def_module {
    /*
     * @param $str строка для поиска
     * @param $field поле в котором производить поиск, 'h1' по умолчанию
     * @param $type_id иерархический тип из /admin/data/config/.xml
     * @param $search_branches раздел сайта в которм произовдить поиск
     *
     * @param $photo_field поле из которого нужно вытащить изображение
     * @param $photo_width ширина изображения
     * @param $photo_height высота изображения
     * @param $photo_quality качество изображения изображения
     *
     **/
    public function ajaxSearch($str,
                               $field = 'h1',
                               $type_id,
                               $search_branches,
                               $qty_to_show = 10,
                               $photo_field = 'photo',
                               $photo_width = 50,
                               $photo_height = 50,
                               $photo_quality = 100) {
        $template = 'default';
        list($items_template, $item_template) = def_module::loadTemplates('search/' . $template, 'items', 'item');

        // Если поле $field не указано, указываем принудительно
        if(strlen($field) <= 0) $field = 'h1';

        // если запрошен поиск только по определенным типам
        if (strlen($type_id) > 0) {
            $type_id = preg_split("/[\s,]+/", $type_id);
            $type_id = array_map('intval', $type_id);
        }

        // если запрошен поиск только по определенным веткам
        if (strlen($search_branches) > 0) {
            $arr_search_by_rels = array();

            $arr_branches = preg_split("/[\s,]+/", $search_branches);
            foreach ($arr_branches as $i_branch => $v_branch) {
                $arr_branches[$i_branch] = $this->analyzeRequiredPath($v_branch);
            }

            $arr_branches = array_map('intval', $arr_branches);
            $arr_search_by_rels = array_merge($arr_search_by_rels, $arr_branches);

            $o_selection = new umiSelection;
            $o_selection->addHierarchyFilter($arr_branches, 100, true);
            $o_result = umiSelectionsParser::runSelection($o_selection);

            $sz = sizeof($o_result);

            for ($i = 0; $i < $sz; $i++) $arr_search_by_rels[] = intval($o_result[$i]);
        }

        $hierarchy = umiHierarchy::getInstance();
        $products_id_list = $this->ajaxRunSearch($str, $type_id, $arr_search_by_rels, false);

        $item = array();
        $items = array();
        $count = 0;
        $total = 0;

        foreach($products_id_list as $product_id){
            $product = $hierarchy->getElement($product_id);

            // Поиск вхождения поисковой фразы в указаном поле
            if(stripos(mb_convert_case($product->getValue($field), MB_CASE_LOWER, "UTF-8"), mb_convert_case($str, MB_CASE_LOWER, "UTF-8")) !== false){
                $emarket = cmsController::getInstance()->getModule("emarket"); // "Подключение" к модулю emarket

                $item['attribute:id'] = $product->id;
                $item['attribute:link'] = $product->link;
                $item['name'] = $product->getName();
                $item["$field"] = $product->getValue($field);
                $item["price"] =  $emarket->getPrice($product); // Получение цены с учетом скидки

                // Формирование изображения
                if (strlen($photo_field) > 0) {
                    $system = &system_buildin_load("system"); // "Подключение" к модулю system

                    $photo = '.' . $product->getValue($photo_field);
                    $thumb = makeThumbnailFull($photo, $system->thumbs_path, $photo_width, $photo_height, true, 5, false, $photo_quality);
                    $item['photo'] = $thumb['src'];
                }

                $count++;
                $items[] = def_module::parseTemplate($item_template, $item);

                if($count == $qty_to_show) break;
            }
        }
        $items = array('subnodes:items' => $items);

        foreach($products_id_list as $product_id){
            $product = $hierarchy->getElement($product_id);
            if(stripos($product->getValue($field), $str) !== false){
                $total++;
            }
        }

        $items['total'] = $total;

        return def_module::parseTemplate($items_template, $items);
    }

    /**
     * Искать по поисковому индексу
     * @param String $str поисковая строка
     * @param Array $search_types = NULL если указан, то будут выраны только страницы с необходимым hierarchy-type-id
     * @param Array $hierarchy_rels = NULL если указан, то искать только в определенном разделе сайта
     * @param Boolean $orMode = false если true, то искать в режиме OR, иначе в режиме AND
     * @return Array массив, стостоящий из id найденых страниц
     *
     * Исходная функция расположена в \classes\system\subsystems\models\search
     */
    public function ajaxRunSearch($str, $search_types = NULL, $hierarchy_rels = NULL, $orMode = false) {
        $words_temp = $this->ajaxSplitString($str);
        $words = Array();

        foreach ($words_temp as $word) {
            if (wa_strlen($word) >= 2) {
                $words[] = $word;
            }
        }

        $elements = $this->ajaxBuildQueries($words, $search_types, $hierarchy_rels, $orMode);

        return $elements;
    }

    public static function ajaxSplitString($str) {
        if(is_object($str)) {    //TODO: Temp
            return NULL;
        }

        $to_space = Array("&nbsp;", "&quote;", ". ", ", ", " .", " ,", "?", ":", ";", "%", ")", "(", "/", "<", ">", "- ", " -", "«", "»");

        $str = str_replace(">", "> ", $str);
        $str = str_replace("\"", " ", $str);
        $str = strip_tags($str);
        $str = str_replace($to_space, " ", $str);
        $str = preg_replace("/([ \t\r\n]{1-100})/u", " ", $str);
        $str = wa_strtolower($str);
        $tmp = explode(" ", $str);

        $res = Array();
        foreach ($tmp as $v) {
            $v = trim($v);

            if (wa_strlen($v) <= 1) continue;

            $res[] = $v;
        }

        return $res;
    }

    public function ajaxBuildQueries($words, $search_types = NULL, $hierarchy_rels = NULL, $orMode = false) {
        $lang_id = cmsController::getInstance()->getCurrentLang()->getId();
        $domain_id = cmsController::getInstance()->getCurrentDomain()->getId();

        $morph_disabled = mainConfiguration::getInstance()->get('system','search-morph-disabled');
        $words_conds = Array();
        $wordsChoose = Array();
        foreach ($words as $i => $word) {
            if (wa_strlen($word) < 2) {
                unset($words[$i]);
                continue;
            }

            $word = l_mysql_real_escape_string($word);
            $word = str_replace(Array("%", "_"), Array("\\%", "\\_"), $word);

            $word_subcond = "siw.word LIKE '{$word}%' ";

            if (!$morph_disabled)  {
                $word_base = language_morph::get_word_base($word);

                if ((wa_strlen($word_base) >= 3) && ($word_base != $word) ) {
                    $word_base = l_mysql_real_escape_string($word_base);
                    $word_subcond .= " OR siw.word LIKE '{$word_base}%'";
                }
            }

            $words_conds[] = "(" . $word_subcond . ")";
            $wordsChoose[] = " WHEN (" . $word_subcond . ") THEN '{$word}'";
        }

        $words_cond = implode(" OR ", $words_conds);
        $wordsChooseString = "(CASE" . implode($wordsChoose) . " END) as search_word";

        if ($words_cond == false) {
            return Array();
        }

        $perms_sql = "";
        $perms_tbl = "";

        if (!permissionsCollection::getInstance()->isSv()) {

            $users = cmsController::getInstance()->getModule("users");
            $user_id = $users->user_id;
            $user = umiObjectsCollection::getInstance()->getObject($user_id);
            $groups = $user->getValue("groups");
            $groups[] = $user_id;
            $groups[] = regedit::getInstance()->getVal("//modules/users/guest_id");
            $groups = array_extract_values($groups);

            $groups = implode(', ', $groups);
            $perms_sql = " AND c3p.level >= 1 AND c3p.owner_id IN({$groups})";
            $perms_tbl = "INNER JOIN cms3_permissions as  `c3p` ON c3p.rel_id = s.rel_id";

        }

        $types_sql = "";
        if (is_array($search_types)) {
            if (sizeof($search_types)) {
                if ($search_types && $search_types[0]) {
                    $types_sql = " AND s.type_id IN (" . l_mysql_real_escape_string(implode(", ", $search_types)) . ")";
                }
            }
        }

        $hierarchy_rels_sql = "";
        if (is_array($hierarchy_rels) && count($hierarchy_rels)) {
            $hierarchy_rels_sql = " AND h.rel IN (" . l_mysql_real_escape_string(implode(", ", $hierarchy_rels)) . ")";
        }

        l_mysql_query("CREATE TEMPORARY TABLE temp_search (rel_id int unsigned, tf float, word varchar(64), search_word varchar(64))");

        $sql = <<<EOF
				INSERT INTO temp_search SELECT SQL_SMALL_RESULT HIGH_PRIORITY
					s.rel_id,
					si.weight,
					siw.word,
					$wordsChooseString
				FROM cms3_search_index_words as `siw`
					INNER JOIN cms3_search_index as `si` ON si.word_id = siw.id
					INNER JOIN cms3_search as `s` ON s.rel_id = si.rel_id
					INNER JOIN cms3_hierarchy as  `h` ON h.id = s.rel_id
					{$perms_tbl}

				WHERE
					({$words_cond}) AND
					s.domain_id = '{$domain_id}' AND
					s.lang_id = '{$lang_id}' AND
					h.is_deleted = '0' AND
					h.is_active = '1'
					{$types_sql}
					{$hierarchy_rels_sql}
					{$perms_sql}
				GROUP BY s.rel_id, si.weight, search_word

EOF;
        $res = Array();

        l_mysql_query($sql);

        if($orMode) {
            $sql = <<<SQL
SELECT rel_id, SUM(tf) AS x
	FROM temp_search
		GROUP BY rel_id
			ORDER BY x DESC
SQL;

        } else {
            $wordsCount = sizeof($words);

            $sql = <<<SQL
SELECT rel_id, SUM(tf) AS x, COUNT(word) AS wc
	FROM temp_search
		GROUP BY rel_id
			HAVING wc >= '{$wordsCount}'
				ORDER BY x DESC
SQL;
        }
        $result = l_mysql_query($sql);

        while(list($element_id) = mysql_fetch_row($result)) {
            $res[] = $element_id;
        }

        l_mysql_query("DROP TEMPORARY TABLE IF EXISTS temp_search");
        return $res;
    }

    public function prepareContext($element_id, $uniqueOnly = false) {
        if (!($element = umiHierarchy::getInstance()->getElement($element_id))) {
            return false;
        }

        if ($element->getValue("is_unindexed")) return false;

        $context = Array();

        $type_id = $element->getObject()->getTypeId();
        $type = umiObjectTypesCollection::getInstance()->getType($type_id);

        $field_groups = $type->getFieldsGroupsList();
        foreach($field_groups as $field_group_id => $field_group) {
            foreach($field_group->getFields() as $field_id => $field) {
                if ($field->getIsInSearch() == false) continue;

                $field_name = $field->getName();
                $data_type = $field->getFieldType()->getDataType();

                $val = $element->getValue($field_name);

                if ($data_type == 'relation') {
                    if (!is_array($val)) {
                        $val = array($val);
                    }
                    foreach ($val as $i => $v) {
                        if ($item = selector::get('object')->id($v)) {
                            $val[$i] = $item->name;
                        }
                    }
                    $val = implode(' ', $val);
                }

                if (is_null($val) || !$val) continue;

                if(is_object($val)) {
                    continue;
                }

                $context[] = $val;
            }
        }

        if ($uniqueOnly) {
            $context = array_unique($context);
        }

        $res = "";
        foreach ($context as $val) {
            if(is_array($val)) {
                continue;
            }
            $res .= $val . " ";
        }

        $res = preg_replace("/%[A-z0-9_]+ [A-z0-9_]+\([^\)]+\)%/im", "", $res);
        $res = str_replace("%", "&#037", $res);
        return $res;
    }
}
?>

После того как вы это сделали, нужно модифицировать шаблон сайта.
Для начала откройте файл в котором хранится форма поиска и:
1. В сроку поиска добавьте id="ajaxSearch"
2. Ниже следует добавить:

<div id="ajaxSearch_repose" class="b-ajaxSearch_repose" />

У вас должно получиться нечто следующее:

<form class="search" action="/search/search_do/" method="get">
    <input id="ajaxSearch" type="text" value="&search-default-text;" name="search_string" class="textinputs" onblur="javascript: if(this.value == '') this.value = '&search-default-text;';" onfocus="javascript: if(this.value == '&search-default-text;') this.value = '';"  x-webkit-speech="" speech="" />
    <div id="ajaxSearch_repose" class="b-ajaxSearch_repose" />
</form>

Данный код взят с файла — \templates\demodizzy\xslt\modules\search\search-form.xsl

Далее в ваш файл стилей добавить стилизации:

.b-ajaxSearch_repose {
    position: absolute;

}

.b-ajaxSearch_repose__items {

}

.b-ajaxSearch_repose__items .b-ajaxSearch_repose__item {
    color: #4A89BA;
    display: block;
    margin-bottom: 10px;
    text-decoration: none;
}

.b-ajaxSearch_repose__items .b-ajaxSearch_repose__item .__item-image {
    padding: 2px;
    border: 1px solid #BABABA;
    background: #fff;

    float: left;
    width: 40px; /* Связь :) */
}

.b-ajaxSearch_repose__items .b-ajaxSearch_repose__item .__item-info {
    margin-left: 50px; /* Связь :) */
}

.b-ajaxSearch_repose__items .b-ajaxSearch_repose__item .__item-title {
    font-size: 14px;
    text-decoration: underline;
}

.b-ajaxSearch_repose__items .b-ajaxSearch_repose__item:hover .__item-title {
    text-decoration: none;
}

.b-ajaxSearch_repose__items .b-ajaxSearch_repose__item .__item-price {
    color: #363636;
    font-weight: bold;
}

.b-ajaxSearch_repose__items .b-ajaxSearch_repose__all {
    text-decoration: none;
    float: right;
}
.spacer {
    clear: both;
}

После этого нужно либо создать новый js-файл или добавить код в существующий:

/**
 * AJAX поиск для UMI.CMS
 */
jQuery(function($) {
    var input_id = $('#ajaxSearch'); // ID поля ввода для поиска
    var repose_id = '#ajaxSearch_repose'; // Элемент, в который будет вставлен результат на странице

    // Шаблон для вставки
    var wrapper_class = 'b-ajaxSearch_repose__container'; // Класс для var wrapper

    var items_wrapper_id = ''; //

    var wrapper = $('<div class="' + wrapper_class + '"></div>').hide(); // Элемент, в который будет вставлен результат

    var html = ''; // строка, в которую будет записывать результат работы AJAX запроса

    var field = 'h1'; // поле в котором производить поиск, 'h1' по умолчанию
    var type_id =  45; // иерархический тип из /admin/data/config/.xml
    var search_branches = ''; // раздел сайта в которм произовдить поиск
    var qty_to_show = 5; // количество отображаемых элементов в результате
    var photo_field = 'photo'; // поле из которого нужно вытащить изображение
    var photo_width = 30; // ширина изображения
    var photo_height = 30; // высота изображения
    var photo_quality = 100; // качество изображения изображения

    var suffix = ' грн.'; // пробел обязателен перед названием

    var timer = null;
    var delay = 1500; // кол-во секунд, через которое скрипт отправит запрос на сервер

    var empty_msg = 'По вашему запросу ничего не найдено.<br />Попробуйте уточнить свой запрос. '; // Ничего не найденно

    var str_cache = null; // Переменная, в которую будет записан результат поиска

    var all_result = '/search/search_do/?search_string=';
    var all_result_text = 'Все результаты поиска →';

    // Стилизация выпадающего блока с результатам поиска
    wrapper.css({
        'display': 'none',
        'background-color': '#F3F3F3',
        'border': '1px solid #DEDEDE',
        'border-radius': '9px',
        'padding': '10px',
        'position': 'relative',
        'top': '15px',
        'width': '250px'
    });

    $(input_id).keyup(function(e) {
        var $this = $(this);

        // фраза для поиска
        var str = $this.val();

        // Если в поле введено меньше 2 символов, то скрипт не сработает
        if(str.length < 2) return false;

        // Если введенная строка эквивалентна предыдущему запросу
        // ajax запрос не совершается а показываются предыдущие результаты
        if(str === str_cache) {
            $('.' + wrapper_class).show(1, function() {
                hideResult(wrapper_class);
            });

            return false;
        } else str_cache = str; // сохраняем предыдущий запрос

        // Чистим timer от предыдущего ввода
        if (timer != null) clearTimeout(timer);

        timer = setTimeout(function() {

            var path = '/udata/search/ajaxSearch';
                path += '/' + str;
                path += '/' + field;
                path += '/' + type_id;
                path += '/' + search_branches;
                path += '/' + qty_to_show;
                path += '/' + photo_field;
                path += '/' + photo_width;
                path += '/' + photo_height;
                path += '/' + photo_quality;

            $.ajax({
                type: "POST",
                dataType: 'json',
                url: path + '.json',
                beforeSend : function(data) {
                    console.log(data);
                },
                success : function(data) {
                    if(html.length > 1) html = '';

                    html += '<div class="b-ajaxSearch_repose__items" id="' + items_wrapper_id + '">';

                    // Если нет объектов в ответе нужно выдать ошибку
                    if(typeof data.items.item != 'undefined') {
                        // Перебор объектов
                        for (var i in data.items.item) {
                            var item = data.items.item[i];

                            html += '<a href="' + item['link'] + '" class="b-ajaxSearch_repose__item">';
                                html += '<div class="__item-image">';
                                    html += '<img src="' + item[photo_field] + '" title="' + item[field] + '" />';
                                html += '</div>';
                                html += '<div class="__item-info">';
                                    html += '<div class="__item-title">' + item[field] + '</div>';
                                    html += '<div class="__item-price">' + item['price'] + suffix + '</div>';
                                html += '</div>';
                                html += '<div class="spacer"></div>';
                            html += '</a>';
                        }

                        // Если найденных элементов больше чем показано в HTML
                        // показываем ссылку на разельтаты поиска
                        if(data.total > qty_to_show) {
                            html += '<a href="'+ all_result + str +'" class="b-ajaxSearch_repose__all">';
                                html += all_result_text;
                            html += '</a>';
                        }
                    } else {
                        html += empty_msg;
                    }

                    html += '<div class="spacer"></div>';
                    html += '</div>';
                },
                complete: function() {
                    $(wrapper).html(''); // Подчищаем предыдущий результат
                    $(html).appendTo(wrapper); // Вставляем только что сгенерированный html
                    $(wrapper).appendTo(repose_id); // Вставляем объект на страницу
                    $(wrapper).show();

                    $('.' + wrapper_class).show(1, function() {
                        hideResult(wrapper_class);
                    });

                },
                error : function(data) {
                    console.log(data);
                }
            });
        }, delay);

    });

    // Ловим фокус на поле ввода
    $(input_id).click(function() {
        // Если есть результаты, то они будут показаны
//        $('.' + wrapper_class).show(1, function() {
        $('.' + wrapper_class).show(1, function() {
            hideResult(wrapper_class);
        });

    });

    function hideResult(class_name) {
        $(document).bind({
            'click.ajaxSearch' : function(e) {
                var target = $(e.target);
                if (target.is('.' + class_name) || target.parents('.' + class_name).length) return;

                $('.' + class_name).hide();
                $(document).unbind('click.ajaxSearch', arguments.callee);
            }
        });

        //$(document).mousedown(function(e){
        //    var target = $(e.target);
        //    console.log(target);
            /*if (target.is('.' + class_name) || target.parents('.' + class_name).length) return;
            $(document).unbind('click', arguments.callee);
            $('.' + class_name).hide();*/
        //});
    }
});

На этом с кодом все.
В результате, если вы введете в строку поиска поисковый запрос, то должны увидеть следующее:
ajax-poisk-na-umi-cms-1

Напоминаю, что демо реализации можно увидеть вот здесь — umi.pontyk.com.ua. Весь исходный код доступен на bitbucket.org

Информация по функции ajaxSearch. Функция принимает следующие параметры:
$str — строка для поиска
$field поле в котором производить поиск, 'h1' по умолчанию, могут быть другие поля.
$type_id иерархический тип данных объекта каталога. Посмотреть можно вот здесь — /admin/data/config/.xml
$search_branches раздел сайта в котором следует производить поиск

$photo_field поле из которого нужно вытащить изображение
$photo_width ширина изображения
$photo_height высота изображения
$photo_quality качество изображения изображения

Информация по js-скрипту приводить не буду, так как в он достаточно хорошо прокомментирован.

Что было доработано:
По умолчанию в UMI.CMS ищет поисковую фразу, которая равна или более 3 символов. Мною были взяты функции UMI, которые отвечали за поиск и модифицированы, чтобы поиск осуществлялся от 2 символов.
И прочие штуки...

UPD 09.04.2014 Спасибо Владиславу за корректировку.