teamon.eu

blah blah blah

[jQuery] Wybór kategorii jak na allegro

28 grudnia 2007

Kategorie:

Tagi:

  • Javascript
  • PHP
  • Symfony
  • jQuery

Podczas pracy nad pewnym projektem potrzebowałem wyboru kategorii tak jak na allegro przy dodawaniu produktu. Szukałem, szukałem, coś tam znalazłem ale mi się nie podobało. Stwierdziłem, że sam to napiszę, wykorzystując do tego bibliotekę jQuery.

Ogólne założenia:

  • Na początku jedna kolumna z głównymi kategoriami
  • Po kliknięciu na pozycję usunięcie wszystkich kolumn na prawo
  • Ajaxowe sprawdzenie czy dana kategoria posiada podkategorie
    • Jeśli tak - wygenerować kolejną kolumnę z listą podkategorii
    • Jeśli nie - przekazać id kategorii do inputa
  • Możliwość zaznaczenia na początku która kategoria jest wybrana i pokazanie ścieżki kategorii nadrzędnych
  • Możliwość wyboru kategorii poprzez wpisanie jej id lub kliknięcie w button

Live demo & download

Update

- dodany parametr określający stałą liczbę kolumn (zbędne są chowane)

Zmiany #1 Zmiany #2

Całość możemy podzielić na trzy części

No to ruszamy

Początek - index.html

To może od razu przejdę do rzeczy.

Dodajemy w sekcji head:

 
  <script type="text/javascript" src="jquery.js"></script>
  <script type="text/javascript" src="select.js"></script>
 

Dodajemy diva - kontener na kolumny, input na id i kilka buttonów (opcjonalnie)

 
  <div id="selectDiv"></div>
  <input type="text" id="selectValue" />
  <input value="1" type="button" id="btn1" />
  <input value="2" type="button" id="btn2" />
  <input value="14" type="button" id="btn3" />
 

Teraz konfiguracja pluginu. Możliwe opcje:

  • urlSub - url do pobrania podkategori
  • urlParent - url do pobrania ścieżki kategorii nadrzędnych
  • input - input na id
  • size - ilość pozycji w kolumnie (domyślnie: 10)
  • start - id kategori zaznaczonej na początku (domyślnie: 0)
  • type - typ ajaxowego zapytania (domyślnie: 'get')
  • columns - ilość kolumn, 0 - bez limitu (domyślnie: 3)
 
  <script type="text/javascript">
    $(document).ready(function(){
      $('#selectDiv').catSelect({
        urlSub: 'get.php?func=sub',
        urlParent: 'get.php?func=parent',
        input: $('#selectValue'),
        size: 5,
        start: 14,
        type: 'get',
        columns: 3
      });
      // dodanie event handlerów do buttonów (te funkcje można wywołać praktycznie z dowolnego miejsca w kodzie)
      $('#btn1').click(function(){$.fn.catSelect.selectById(1)});
      $('#btn2').click(function(){$.fn.catSelect.selectById(2)});
      $('#btn3').click(function(){$.fn.catSelect.selectById(14)});
    });
  </script>
 

select.js - czy jak to wszystko działa

Poniżej jest dość sporo samego kodu. Jeśli kogoś nie interesuje jak działa plugin może przejść od razu do kolejnej sekcji

 
  // deklaracja pluginu
  $.fn.catSelect = function(options){
    var defaults = {
      urlSub: '',
      urlParent: '',
      size: 10,
      type: 'get',
      container: $(this),
      input: null,
      start: 0
    };
 
    $.fn.catSelect.settings = $.extend(defaults, options);
 
    // wyczyszczenie kontenera
    $.fn.catSelect.reset();
 
    // event handler inputa
    $.fn.catSelect.settings.input.keyup(function(){
      $.fn.catSelect.selectById(this.value);
    });
 
    if($.fn.catSelect.settings.start != 0){
      // pierwszy request
      $.fn.catSelect.selectById($.fn.catSelect.settings.start);
    } else {
      $.fn.catSelect.onChange();
    }
  }
 
  $.fn.catSelect.cache = []; // cache podkategori
  $.fn.catSelect.pCache = []; // cache kategori nadrzędnych
  $.fn.catSelect.obj = null; // ostatnia kolumna
 

Zacznijmy od $.fn.catSelect.reset();

 
  $.fn.catSelect.reset = function(){
    $.fn.catSelect.settings.container.html(''); // wyczyszczenie kontenera
    $.fn.catSelect.obj = $(document.createElement('select')); // ustawienie domyślnego obiektu
  }
 

Teraz $.fn.catSelect.onChange() - wywoływane przy każdej zmianie kategori

 
  $.fn.catSelect.onChange = function(){
    var id = $.fn.catSelect.obj.val() || 0;
    // sprawdzenie czy podkategorie są w cache
    if($.fn.catSelect.cache[id]){
      $.fn.catSelect.onSuccess($.fn.catSelect.cache[id]);
    } else {
      // pobranie kategorii
      $.fn.catSelect.get(id);      
    }
  }
 
 
  $.fn.catSelect.get = function(id){
    // ajax request
    $.ajax({
      url: $.fn.catSelect.settings.urlSub,
      type: $.fn.catSelect.settings.type,
      data: 'id='+id,
      dataType: 'json',
      success: function(data){
        $.fn.catSelect.onSuccess(data);
        // zapisanie do cache
        $.fn.catSelect.cache[id] = data;
      }
    });
  }
 

Wszystko to sprowadza się do wywołania funkcji $.fn.catSelect.onSuccess

 
  $.fn.catSelect.onSuccess = function(data){
    // usunięcie zbędnych kolumn
    $.fn.catSelect.obj.nextAll('.catSelect').remove();
 
    if(data.length > 0){
      // sa podkategorie - dodanie selecta
      var select = $(document.createElement('select'))
                      .attr('size', $.fn.catSelect.settings.size)
                      .addClass('catSelect')
                      .change(function(){ // event handler zmian wyboru kategorii
                        $.fn.catSelect.obj = $(this);
                        $.fn.catSelect.onChange();
                      });
 
      // utworzenie listy podkategori
      for(i in data){
        select.append('<option value="'+data[i].id+'">'+data[i].name+'</option>')
      }
      // dodanie selecta do kontenera   
      $.fn.catSelect.settings.container.append(select);
      // usunięcie zaznaczenie z selecta
      select.val(0);
 
    } else {
      // nie ma podkategorii - dodanie wartosc do inputa
      $.fn.catSelect.settings.input.val($.fn.catSelect.obj.val());
    }
    // schowanie zbędnych kolumn
    if($.fn.catSelect.settings.columns != 0){
      var cols = $.fn.catSelect.settings.container.children('select.catSelect').show();
      if(cols.length > $.fn.catSelect.settings.columns){
        for(var i=0, c=cols.length-$.fn.catSelect.settings.columns; i<c ; i++){
          $(cols[i]).hide();
        }
      }
    }
  }
 

Pozostała już tylko do opisania funkcja $.fn.catSelect.selectById(id)

 
  $.fn.catSelect.selectById = function(id){
    $.fn.catSelect.reset(); // reset
    // sprawdzenie cache
    if($.fn.catSelect.pCache[id]){
      $.fn.catSelect.selectByIdOnSuccess($.fn.catSelect.pCache[id]);
    } else {
      // pobranie kategori nadrzednych wraz z wszystkimi podkategoriami
      $.ajax({
        url: $.fn.catSelect.settings.urlParent,
        type: $.fn.catSelect.settings.type,
        data: 'id='+id,
        dataType: 'json',
        success: function(data){
          $.fn.catSelect.selectByIdOnSuccess(data);
          $.fn.catSelect.pCache[id] = data;
        }
      }); 
    }
  }
 
  $.fn.catSelect.selectByIdOnSuccess = function(data){
    // zapisanie do cache podkategorii
    for(i in data){
      $.fn.catSelect.cache[data[i].id] = data[i].data;
    }
    $.fn.catSelect.onChange()
    // wyświetlenie wszystkich kolumn
    for(var i=1,s=null;i<data.length;i++){
      s = $.fn.catSelect.settings.container.children();
      $(s[s.length-1]).val(data[i].id).change();
    }
  }
 

Cały plik select.js wygląda tak:

 
(function($) {
 
  $.fn.catSelect = function(options){
    var defaults = {
      urlSub: '',
      urlParent: '',
      size: 10,
      type: 'get',
      container: $(this),
      input: null,
      start: 0
    };
 
    $.fn.catSelect.settings = $.extend(defaults, options);
    $.fn.catSelect.reset();
 
    $.fn.catSelect.settings.input.keyup(function(){
      $.fn.catSelect.selectById(this.value);
    });
 
    if($.fn.catSelect.settings.start != 0){
      $.fn.catSelect.selectById($.fn.catSelect.settings.start);
    } else {
      $.fn.catSelect.onChange();
    }
  }
 
  $.fn.catSelect.cache = [];
  $.fn.catSelect.pCache = [];
  $.fn.catSelect.obj = null;
 
  $.fn.catSelect.reset = function(){
    $.fn.catSelect.settings.container.html('');
    $.fn.catSelect.obj = $(document.createElement('select'));
  }
 
  $.fn.catSelect.onChange = function(){
    var id = $.fn.catSelect.obj.val() || 0;
    if($.fn.catSelect.cache[id]){
      $.fn.catSelect.onSuccess($.fn.catSelect.cache[id]);
    } else {
      $.fn.catSelect.get(id);      
    }
  }
 
  $.fn.catSelect.selectById = function(id){
    $.fn.catSelect.reset();
    if($.fn.catSelect.pCache[id]){
      $.fn.catSelect.selectByIdOnSuccess($.fn.catSelect.pCache[id]);
    } else {
      $.ajax({
        url: $.fn.catSelect.settings.urlParent,
        type: $.fn.catSelect.settings.type,
        data: 'id='+id,
        dataType: 'json',
        success: function(data){
          $.fn.catSelect.selectByIdOnSuccess(data);
          $.fn.catSelect.pCache[id] = data;
        }
      }); 
    }
  }
 
  $.fn.catSelect.selectByIdOnSuccess = function(data){
    for(i in data){
      $.fn.catSelect.cache[data[i].id] = data[i].data;
    }
    $.fn.catSelect.onChange()
    for(var i=1,s=null;i<data.length;i++){
      s = $.fn.catSelect.settings.container.children();
      $(s[s.length-1]).val(data[i].id).change();
    }
  }
 
  $.fn.catSelect.onSuccess = function(data){
    $.fn.catSelect.obj.nextAll('.catSelect').remove();
 
    if(data.length > 0){
      var select = $(document.createElement('select'))
                      .attr('size', $.fn.catSelect.settings.size)
                      .addClass('catSelect')
                      .change(function(){
                        $.fn.catSelect.obj = $(this);
                        $.fn.catSelect.onChange();
                      });
 
      for(i in data){
        select.append('<option value="'+data[i].id+'">'+data[i].name+'</option>')
      }
 
      $.fn.catSelect.settings.container.append(select);
      select.val(0);
    } else {
      $.fn.catSelect.settings.input.val($.fn.catSelect.obj.val());
    }
 
    if($.fn.catSelect.settings.columns != 0){
      var cols = $.fn.catSelect.settings.container.children('select.catSelect').show();
      if(cols.length > $.fn.catSelect.settings.columns){
        for(var i=0, c=cols.length-$.fn.catSelect.settings.columns; i<c ; i++){
          $(cols[i]).hide();
        }
      }
    }
  }
 
  $.fn.catSelect.get = function(id){
    $.ajax({
      url: $.fn.catSelect.settings.urlSub,
      type: $.fn.catSelect.settings.type,
      data: 'id='+id,
      dataType: 'json',
      success: function(data){
        $.fn.catSelect.onSuccess(data);
        $.fn.catSelect.cache[id] = data;
      }
    });
  }
 
 
})(jQuery)
 

A co po stronie serwera?

Ostatnim elementem systemu jest kod po stronie serwera odpowiedzialny za pobranie z bazy danych odpowiednich kategori.

 
  mysql_connect('localhost', 'root');
  mysql_select_db('ps');
 
  if(function_exists('ajax_'.$_GET['func']))
  {
    call_user_func('ajax_'.$_GET['func']);
  }
 
 
  function ajax_sub()
  {
    $data = array();
    $res = mysql_query('select * from category where parent_id='.(int)$_GET['id']);
    while($row = mysql_fetch_assoc($res))
    {
      $data[] = array(
        'id' => $row['id'],
        'name' => $row['name'],
        'parent_id' => $row['parent_id']
      );
    }  
    echo json_encode($data);
  }
 
  function ajax_parent()
  {
    $ret = array();
 
    $ret = getParent((int)$_GET['id']);
    $ret[] = 0;
 
    $ids = array_reverse($ret);
    $sub = array();
    foreach($ids as $id)
    {
      $data = array();
      $res = mysql_query('select * from category where parent_id='.$id);
      while($row = mysql_fetch_assoc($res))
      {
        $data[] = array(
          'id' => $row['id'],
          'name' => $row['name'],
          'parent_id' => $row['parent_id']
        );
      }  
      $sub[] = array(
        'id' => $id,
        'data' => $data
      );
    }
 
    echo json_encode($sub);
  }
 
  function getParent($id)
  {
    $ret = array();
    $res = mysql_query('select * from category where id='.$id);
    $row = mysql_fetch_assoc($res);
    if($row){
      $ret[] = (int)$id;
      if((int)$row['parent_id'] != 0)
      {
        $ret = array_merge($ret, getParent($row['parent_id']));
      }
    }
    return $ret;
  }
 
 

Istotnym elementem jest tutaj funkcja json_encode() dostępna w PHP dopiero od wersji PHP 5 >= 5.2.0.

Integracja z Symfony

Przedstawiony powyżej przykład można łatwo zaimplementować praktycznie wszędzie, na przykład w projekcie opartym na Symfony. Wszystko zawiera się w kilku prostych krokach.

  1. Zainstalowanie pluginu integrującego jQuery z Symfony
  2. Skopiowanie pliku select.js do katalogu web/js/jq
  3. Dopisanie do pliku MY_MODULE/config/view.yml :
     
    myActionSuccess:
      javascripts: [jq/select]
     
  4. Dodanie do pliku actions.class.php metod:
     
      public function executeAjaxSubcategories()
      {
        if(!$this->getRequest()->isXmlHttpRequest()) return sfView::NONE;
        $c = new Criteria();
        $c->add(ProductCategoryPeer::PARENT_ID, $this->getRequestParameter('id'));
        $categories = ProductCategoryPeer::doSelect($c);
     
        $out = array();
        foreach ($categories as $category)
        {
          $out[] = array(
            'id' => $category->getId(),
            'name' => $category->getName(),
            'parent_id' => $category->getParentId()
          );
        }
     
        $this->renderText(json_encode($out));
        return sfView::NONE;
      }
     
      public function executeAjaxParent()
      {
        if(!$this->getRequest()->isXmlHttpRequest()) return sfView::NONE;
        $ret = array();
     
        $ret = $this->getParentCategory((int)$this->getRequestParameter('id'));
        $ret[] = 0;
     
        $ids = array_reverse($ret);
        $out = array();
        foreach($ids as $id)
        {      
          $data = array();
          $categories = ProductCategoryPeer::retrieveByParentId($id);
          foreach ($categories as $category)
          {
            $data[] = array(
              'id' => $category->getId(),
              'name' => $category->getName(),
              'parent_id' => $category->getParentId()
            );
          }
     
          $out[] = array(
            'id' => $id,
            'data' => $data
          );
        }
     
        $this->renderText(json_encode($out));
        return sfView::NONE;
      }
     
      private function getParentCategory($id)
      {   
        $category = ProductCategoryPeer::retrieveByPK($id);
        if($category)
        {
          $ret[] = (int)$id;
          if((int)$category->getParentId() != 0)
          {
            $ret = array_merge($ret, $this->getParentCategory($category->getParentId()));
          }
        }
     
        return $ret;
      }
     
  5. Dodanie do pliku MY_APP/config/routing.yml:
     
    myModuleAjaxSubcategories:
      url:    /ajax/subcategories
      param:  {module: myModule, action: ajaxSubcategories}
     
    myModuleAjaxParent:
      url:    /ajax/subcategories
      param:  {module: myModule, action: ajaxParent}
     
  6. Umieszczenie skryptu w templatce:
     
      <div id="selectDiv"></div>
      <?php echo label_for('categoryId', '') ?>
      <?php echo input_tag('categoryId', $sf_params->get('categoryId'), 'class=text'); ?>
      <?php echo form_error('categoryId') ?>
     
      <script type="text/javascript">
        $(document).ready(function(){
          $('#selectDiv').catSelect({
            urlSub: '<?php echo url_for('@productAjaxSubcategories') ?>',
            urlParent: '<?php echo url_for('@productAjaxParent') ?>',
            input: $('#categoryId'),
            size: 5,
            start: <?php echo $sf_params->get('categoryId') ?>
          });
        });
      </script>
     
  7. Gotowe ;)

Uff... Sporo tego wyszło. Jestem otwarty na wszelkie pytania i propozycje. Postaram się o jakieś live demo. Testowame pod FF 2.0 i IE 6. Jakby ktoś mógł sprawdzić pod resztą przeglądarek byłbym wdzięczny. A tymczasem jest 3:15 - ide spać.

P.S. Plugin powstał podczas słuchania "Alive 2007" Daft Punk ;) (a wpis przy Evanescence)

17 komentarzy

  • Dominik Porada 28 grudnia 2007 04:10:11

    Wszystko fajnie, ale jakbyś nadał każdemu @<input />@owi swoje @id@ i wywaliłbyś brzydkie @onclick@i, to byłoby jeszcze lepiej.

  • Teamon 28 grudnia 2007 08:02:29

    To jest chyba tutaj najmniej istotne ;) Chodziło tylko o pokazanie, jak wywołać funkcje. Ale niech będzie - zaraz zmienie (i tak mialem zmienic pare rzeczy)

    Edit: No, to demo już jest :)

  • Dominik Porada 28 grudnia 2007 09:19:52

    No tak, to tylko kosmetyka. :)

  • zar 28 grudnia 2007 09:39:16

    Przestrzegam, że takich rzeczy należy używać z głową... Np. na witrynie nvidii zdaje to egzamin, ale już szukając produktów na mediamarkt.pl człowieka potrafi szlag trafić - za każdym razem należy klikać od nowa całą hierarchię kategorii. Dlatego do takiego rozwiązania przydaje się pamiętanie ostatnio otwartej kategorii, wg mnie.

  • Dominik Porada 28 grudnia 2007 09:40:05

    Akurat mm.pl to jedna wielka porażka. :)

  • Teamon 28 grudnia 2007 09:40:25

    To już zależy od programisty i sposobu wykorzystania parametru 'start'

  • zar 28 grudnia 2007 09:40:43

    Też fakt. Ładna, acz zupełnie niefunkcjonalna witryna.

  • quaker 23 lipca 2008 12:17:39

    W jaki sposób użyć tego plugina aby w ramach jednej strony stworzyć kilka takich selectów, całkowicie rozdzielnych?

  • Teamon 23 lipca 2008 12:21:41

    Myślę, że różne id kontenerów i akcje powinny załatwić sprawę.

  • quaker 23 lipca 2008 13:49:17

    Własnie mam z tym problem. Nie jestem osobą biegłą w JS. Zobacz pod poniższy, jeżeli oczywiście masz czas.

    http://www.barbara.eu.org/~quaker/jquery/

  • seban 22 września 2008 11:17:55

    A co zrobić, żeby można było wybierać nie tylko kategorie z ostatniego poziomu, ale również kategorie "wyższe"?

  • Teamon 22 września 2008 15:20:27

    Przerobic kod ;]

  • ozyrys 07 października 2008 14:33:58

    Witam,
    Powiedzcie proszę mistrzowie jak to zintegrować z drupalem?

  • slawek 08 października 2008 01:51:09

    Witam!
    Swietny skrypcik ale mam takie (moze infantylne - nie znam dobrze jquery) pytanie...
    Czy da sie zrobic tak aby wszystkie boksy byly widoczne jednoczesnie bez klikania w pierwszym - po prostu zeby na poczatku jeden byl wypelniony a pozostale puste?

  • ja 06 maja 2009 23:51:24

    Jak to jest, że nie można kur... ściągnąć plików .. ?

  • teamon 06 maja 2009 23:54:04

    Bo serwer jest inteligentny i odpowiada tylko ludziom kulturalnym.
    Inna możliwość jest taka, że wpis ma półtora roku a serwer już nie istnieje.

  • ja 07 maja 2009 15:27:40

    teamon
    W języku kulturalnych ludzi nie istnieje słowo "bo". Słowo to istnieje w innym słowniku.

Zostaw komentarz

code