Tutorial: dashboard web 2.0 dla leniwych…


czyli dobrych programistów ;)

W moim pierwszym tutorialu napiszę jak wydajnie korzystać z tego co daje Ci cakePHP i jQuery. Aktualnie cake opiera się na współpracy z innym frameworkiem javascript – prototype. Ale już w wersji 1.3 core cake’a ma współpracować z jquery. Już nie mogę się doczekać. Ten tutorial oparłem na wersji 1.3.0-beta dlatego, że przy okazji jego pisania chciałem rzucić okiem na kolejną wersję tego świetnego frameworka php. Jednak nie będę korzystał z żadnych helperów jquery, zatem większość z tych rad powinna pasować do wersji 1.2 (w razie problemów – chętnie służę pomocą).
Jest to mój pierwszy tutorial, więc proszę o wskazówki, komentarze i odrobinę wyrozumiałości.

Efekt jaki chcemy osiągnąć można obejrzeć jako demo oraz źródła na świetnym hostingu, który gorąco wszystkim webdeveloperom i ich klientom polecam: vipserv.org.

W tym tekście zakładam, ze znasz podstawy cakePHP i javascript. Czyli przynajmniej zrobiłeś sławny blog tutorial , wiesz jak mniej więcej używać narzędzia bake, oraz potrafisz przynajmniej zniknąć element na stronie i zmienić jego kolor przy pomocy czystego javascript’u.

Dashboard na zakładkach

Zacznijmy od przygotowania przykładowej aplikacji. Wypiekamy (cake bake) aplikację złożoną z dwóch modeli Thing i Item (każda tabela składa się z id i pola tekstowego name).

Następnie tworzymy dla niej zbiorczy widok (tzw. dashboard, metoda all())

// /app/controllers/items_controller.php
class ItemsController extends AppController {
 //...
 function all() {}
}
// /app/views/items/all.ctp
<?php echo $this->requestAction(
            array(
               "controller"=> "items",
               "action"=> "index"),
            array("return")
      ); ?>
<?php echo $this->requestAction(
            array(
               "controller"=> "things",
               "action"=> "index"),
            array("return")
      ); ?>

Na razie nie przejmujcie się kwestią requestAction i wydajności. Zazwyczaj efekty requestAction należy umieszczać w cache’owanych elementach, ale w naszym przypadku już niedługo zrezygnujemy z samego requestAction.

widok zbiorczy - dashboard

tak to wygląda teraz

Zróbmy z tego widoku zakładki modyfikując widok all.ctp w następujący sposób:

<script type="text/javascript">
   $(document).ready(function() {
      $("#tabs").tabs();
   });
</script>


<div id="tabs">
   <ul>
      <li><a href="#tabs-1">Items</a></li>
      <li><a href="#tabs-2">Things</a></li>
   </ul>
   <div id="tabs-1">
      <?php echo $this->requestAction(
                  array(
                     "controller"=> "items",
                     "action"=> "index"),
                  array("return")
            ); ?>
   </div>
   <div id="tabs-2">
      <?php echo $this->requestAction(
                  array(
                     "controller"=> "things",
                     "action"=> "index"),
                  array("return")
            ); ?>
   </div>
</div>

Żeby wypieczone widoki były “otoczone” ramką z zakładki – trzeba w nich dodać taki fragment na samym końcu plików index.ctp:

<div style="float: none; clear: both;">&nbsp;</div>
Widok zakładek

tak wyglądają nasze zakładki

Dodajmy więcej elementów, żeby zobaczyć jak zachowuje się pagination…

insert into items(`name`) values ('Item 4'), ('Item 5'), ('Item 6'), ('Item 7')

i zmieńmy parametry stronicowania, żeby efekty stronicowania zobaczyć szybko ;)

class ItemsController extends AppController {
//...
	var $paginate = array(
	  'limit'=> 5
	);
//...

Widzimy, że w naszym zbiorczym widoku link ciągle prowadzi do items/index. Wolelibyśmy, żeby stronicowanie odbyło się w zakładkach.

Można oczywiście próbować rozwiązania via php. Oprogramować obsługę parametrów w metodzie all(), zmienić cel linków stronicowania i sortowania w widokach. I ogólnie dużo napracować się nad tym, żeby nasz kod wyglądał źle ;). Już za chwilę moc javascript i w szczególności jQuery przyjdzie nam z pomocą. Zanim to nastąpi – zamieńmy nasze zakładki na Ajaxowe zmieniając widok all.ctp:

// views/items/all.ctp
<script type="text/javascript">
   $(document).ready(function() {
      $("#tabs").tabs();
   });
</script>


<div id="tabs">
   <ul>
      <li>
         <?php echo $html->link(
                  "Items",
                  array(
                     "controller"=> "items",
                     "action"=> "index"
                  )
               );
         ?>
      </li>
      <li>
         <?php echo $html->link(
                  "Things",
                  array(
                     "controller"=> "things",
                     "action"=> "index"
                  )
               );
         ?>
      </li>
   </ul>
</div>

…prościzna, prawda? Kwestię requestAction też już mamy z głowy. Jednak pojawił się problem – wnętrze zakładek pojawiło się w powtórzonym layoucie.
Szybko naprawimy to w app_controller.php przy pomocy RequestHanlder:

// /app/app_controller.php
var $components = array("RequestHandler");
function beforeFilter(){
    if($this->RequestHandler->isAjax()){
      $this->layout = "ajax";
    }
}

Wrócmy do kwestii stronicowania (i sortowania przy okazji). Chcemy, aby kolejne strony ładowały się w danej zakładce. Wykorzystajmy do tego potęgę jQuery.
Dodajemy taki prosty skrypt

// views/items/all.ctp
   $('#tabs a[href*=/sort:],#tabs a[href*=/page:]').live('click', function(){
       $('#tabs>div:visible').load($(this).attr('href'));
          return false;
   });

Jeśli interesuje Cię co się w tym fragmencie dzieje, zapraszam na do innego wpisu. W dużym skrócie: łapiemy linki, które mają w adresie “sort:” i “page:” i zmuszamy je aby przeładowały zawartość zakładki, zamiast całej strony.

Share Button

Zcalenie edit() i add() w kontrolerze

Gdy przyjrzysz się bliżej kontrolerom stworzonym przy pomocy narzędzia ‘bake’ (lub takim, jakie powstają po wykonaniu tutoriala) możesz stwierdzić, że łamią one koncepcję DRY. Jesteś w stanie powiedzieć, które z dwóch metod są niemal identyczne? Jeśli nie – sprawdź, które dwa widoki są niemal identyczne. Ok, może spaliłem swoją zagadkę, bo w tytule tego postu jest wyraźna sugestia o czym będę mówił. Zgadza się.

Najpierw rzućmy okiem na widoki (tu w przykładzie dla Modelu Thing, który ma jedno pole – name):

// app/views/things/add.ctp
<div class="things form">
<?php echo $this->Form->create('Thing');?>
   <fieldset>
      <legend>
         <?php printf(__('Add %s', true), __('Thing', true)); ?>
      </legend>
   <?php
      echo $this->Form->input('name');
   ?>
      </fieldset>
<?php echo $this->Form->end(__('Submit', true));?>
</div>
<div class="actions">
   <h3><?php __('Actions'); ?></h3>
   <ul>
      <li>
         <?php echo $this->Html->link(
            sprintf(
               __('List %s', true), 
               __('Things', true)
            ), 
            array('action' => 'index')
         );?>
      </li>
   </ul>
</div>
// app/views/things/edit.ctp
<div class="things form">
<?php echo $this->Form->create('Thing');?>
   <fieldset>
      <legend>
         <?php printf(__('Edit %s', true), __('Thing', true)); ?>
      </legend>
   <?php
      echo $this->Form->input('id');
      echo $this->Form->input('name');
   ?>
   </fieldset>
<?php echo $this->Form->end(__('Submit', true));?>
</div>
<div class="actions">
   <h3><?php __('Actions'); ?></h3>
   <ul>
      <li>
         <?php echo $this->Html->link(
            __('Delete', true), 
            array('action' => 'delete', $this->Form->value('Thing.id')), 
            null, 
            sprintf(
               __('Are you sure you want to delete # %s?', true), 
               $this->Form->value('Thing.id')
            )
         ); ?>
      </li>
      <li>
         <?php echo $this->Html->link(
            sprintf(__('List %s', true), __('Things', true)), 
            array('action' => 'index')
         );?>
      </li>
</ul>
</div>

Różnią się dokładnie w trzech linijkach (na 16 łącznie). Jeśli zmienisz coś w jednym (na przykład dodasz pole) to będziesz musiał poprawić formularz w dwóch plikach. Czysta głupota.
Przyjrzyjmy się jeszcze metodom:

// app/controllers/things_controller.ctp
function add() {
   if (!empty($this->data)) {
      $this->Thing->create();
      if ($this->Thing->save($this->data)) {
         $this->Session->setFlash(__('The thing has been saved', true));
         $this->redirect(array('action' => 'index'));
      } else {
         $this->Session->setFlash(
            __('The thing could not be saved. Please, try again.', true)
         );
      }
   }
}

function edit($id = null) {
   if (!$id && empty($this->data)) {
      $this->Session->setFlash(__('Invalid thing', true));
      $this->redirect(array('action' => 'index'));
   }
   if (!empty($this->data)) {
      if ($this->Thing->save($this->data)) {
         $this->Session->setFlash(__('The thing has been saved', true));
         $this->redirect(array('action' => 'index'));
      } else {
         $this->Session->setFlash(
            __('The thing could not be saved. Please, try again.', true)
         );
      }
   }
   if (empty($this->data)) {
      $this->data = $this->Thing->read(null, $id);
   }
}

Praktycznie całe ciało metody add() można by wkleić w miejsce drugiego if’a w metodzie edit() i niewiele by się zmieniło, prawda?

Kilka tricków pozwoli nam naprawić to marnotrawstwo. W zasadzie najważniejsza kwestia jest następująca – jak działa metoda $this->Thing->create()? Wymusi dodanie nowego elementu (dlatego, że metoda save() działa jako create lub update w zależności od stanu modelu). Ale nie przekazując żadnych parametrów do tej metody tak naprawdę sprowadzi się do wyczyszczenia $this->data i $this->id (this oznacza w tym przypadku model). W przypadku metody add() nie ma możliwości, żeby jakieś śmieci były w $this->Thing->data, więc możemy spokojnie usunąć tą linijkę – dalej będzie działać tak samo (czy na pewno? – spoiler).
Teraz całkowicie ciało metody add() mogłoby zawrzeć się w metodzie edit(). Dlatego trzeba ją usunąć (razem z jej widokiem). A wszystkie linki, które prowadziły do /things/add niech teraz prowadzą do /things/edit.
Warto sprawdzić, co się teraz dzieje?

W zależności od wersji – 1.2 się wywali, 1.3 pokaże informacje “Invalid thing”. Teraz skupię się na wersji 1.3 – bo na niej sprawdzam na bieżąco efekty pisząc ten wpis. (dla 1.2 jeśli dobrze pamiętam wystarczy wstępnie zmienić sygnaturę metody edit($id) na edit($id=null), dalsze kroki będą analogiczne, choć kod w szczegółach może się różnić)

Od teraz możemy założyć, że jeśli w url’u nie ma podanego id – to znaczy, że chcemy dodać nowy element, zatem tego if’a możemy usunąć:

// app/controllers/things_controller.php
		if (!$id && empty($this->data)) {
			$this->Session->setFlash(__('Invalid thing', true));
			$this->redirect(array('action' => 'index'));
		}

Możemy spokojnie teraz dodawać nowe elementy przez formularz- wszystko działa.
Warto jeszcze dopieścić widok – bez sensu jest nagłówek “edit” przy dodawaniu elementu, oraz przycisk “delete”,
fragment z legend poprawiamy na taki (dodałem wcięcia dla przejrzystości):

// app/views/things/edit.ctp
 		<legend>
 		   <?php printf(
 		      (!is_null($this->data["Thing"]["id"])?__('Edit %s', true): __('Add %s', true)),
 		      __('Thing', true));
 		   ?>
 		 </legend>

a pierwszy element li otaczamy if’ami tak:

// app/views/things/edit.ctp
<?php if(!is_null($this->data["Thing"]["id"])): ?>
   <li>
      <?php echo $this->Html->link(
         __('Delete', true), 
         array('action' => 'delete', $this->Form->value('Thing.id')), 
         null, 
         sprintf(
            __('Are you sure you want to delete # %s?', true),
            $this->Form->value('Thing.id')
         )
      ); ?>
   </li>
<?php endif; ?>

I tyle.

Usunięcie $this->Thing->create() nie takie bezpieczne?
Napisałem, że po usunięciu tego elementu nic się złego nie stanie. Oczywiście jest to prawdą pod warunkiem, że wykonasz wszystkie czynności opisane w tym poscie. Jeśli tylko usuniesz $this->Thing->create() narażasz się na niebezpieczeństwo, że ktoś wyśle spreparowanego post’a pod ten adres. Jeśli poda w nim pole o nazwie data[Thing][id] – będzie mógł zmienić dane dla elementu o danym id. Jeśli masz sytuację taką, że jedna grupa może dodawać elementy, a inna je edytować i polegasz tylko na acl’ach (dana grupa ma dostęp do edit(), a kolejna do add()) – to w ten sposób możesz stworzyć lukę. Jednak ta sytuacja jest dość rzadka i to jest raczej temat na inny wpis, więc jeśli interesuje Cię rowiązanie tego problemu – zachęcam do komentowania.

Share Button

Skróć swoje listingi

Pewnie w niemal każdym swoim widoku index.ctp masz fragment kodu podobny do tego:

<?php
$i = 0;
foreach ($tracks as $track):
	$class = null;
	if ($i++ % 2 == 0) {
		$class = ' class="altrow"';
	}
?>
	<tr<?php echo $class;?>>
		<td>
			<?php echo $track['Track']['id']; ?>
		</td>
		<td>
			<?php echo $track['Track']['name']; ?>
		</td>
		<td>
			<?php echo $track['Car']['name']; ?>
		</td>
		<td>
			<?php echo $track['Track']['begin']; ?>
		</td>
		<td>
			<?php echo $track['Track']['end']; ?>
		</td>
		<td>
			<?php echo $track['Track']['length']; ?>
		</td>
		<td class="actions">
			<?php echo $html->link(
			   __('Edycja', true),
			   array(
			      'action' => 'edit',
			      $track['Track']['id'] ,
			      $track['Car']['id']
			   )
			  );
			?>

		</td>
	</tr>
<?php endforeach; ?>
</table>

Chce Ci się pisać ten kod kilkadziesiąt razy w jednym projekcie? Mnie też nie. Dlatego napisałem prosty element, który to trochę automatyzuje (nazwałem go “list”):

// app/views/elements/list.ctp
<?php
   $i = 0;
   foreach($elements as $key => $element):
   $class = null;
   if ($i++ % 2 == 0) {
      $class = ' class="altrow"';
   }
?>
   <tr <?php echo $class?>>
      <?php foreach($fields as $path): ?>
         <td>
            <?php echo Set::classicExtract($element, $path) ?>
         </td>
      <?php endforeach; ?>
         <?php if(isset($links)): ?>
            <td>
               <?php foreach($links as $name => $url): ?>
                  <?php
                     if(!is_array($url["params"])) {
                        $url["params"] = array($url["params"]);
                     }
                     foreach($url["params"] as $key => $path){
                        $url[$key] = Set::classicExtract(
                           $element,
                           $path
                        );
                     }
                     unset($url["params"]);
                  ?>
                  <?php echo $html->link($name, $url)?>
               <?php endforeach; ?>
            </td>
         <?php endif; ?>
   </tr>
<?php endforeach; ?>

Jego użycie jest dość proste. Dla przykładu wygenerujemy ten sam “output” dla pierwszego listingu z tego postu:

echo $this->renderElement(
         "list",
         array(
            "elements" => $tracks,
            "fields" => array(
               "Track.name", "Car.name", "Track.begin",
               "Track.end", "Track.length"
            ),
            "links"=> array(
               "edycja" => array(
                  "controller"=> "tracks", "action"=> "edit",
                  "params" => array(
                     "Track.id", "Car.id"
                  )
               )
            )
         )
      )

Opiszę poszczególne parametry przekazywane do tego elementu:

  • “elements” – kolekcja (w tym wypadku tablica) elementów zwrócona na przykład przez $this->Track->find(“all”)
  • “fields” – pola, które mają się pojawić w komórkach tabeli
  • “links” – tu definiujemy jakie linki mają się pojawić w ostatniej kolumnie (zazwyczaj tam znajdują się “opcje”). Indeksy kolejnych elementów to nazwy linków (tutaj “edycja”).
    • “controller” i “action” są identycznie używane jak w metodzie HtmlHelper::link()
    • “params” jest dość ciekawy. Jeśli chcemy w wygenerowanym linku mieć jeden parametr (najczęściej id), robimy to tak:
                  "edycja" => array(
                     "controller"=> "cars", "action"=> "edit", "params"=>"Car.id"
                  ),
      

      gdy parametrów ma być więcej – ścieżki do nich przekazujemy w tablicy (tak jak w przykładzie):

                      "edycja" => array(  
                         "controller"=> "tracks", "action"=> "edit",  
                         "params" => array(  
                            "Track.id", "Car.id"  
                         )  
                      ) 
      

      Można także używać parametrów typu “named”, wystarczy podać dodatkowo indeksy:

                      "edycja" => array(  
                         "controller"=> "tracks", "action"=> "edit",  
                         "params" => array(  
                           "parametr_1"=> "Track.id", "parametr_2"=> "Car.id"  
                         )  
                      ) 
      

Zachęcam do korzystania z tego elementu i zgłaszania wszelkich niedociągnięć :)

Share Button