Programando Con Yii2: Usando Ajax
Spanish (Español) translation by Rafael Chavarría (you can also view the original English article)



Si te preguntas, "Qué es Yii?, revisa Introducción al Framework Yii, el cuál revisa los beneficios de Yii e incluye un vistazo a Yii2, liberado en Octubre de 2014.
En esta serie de Programando Con Yii2, estaré guiando a los lectores en el uso del Framework Yii2 para PHP. En este tutorial, exploraremos la implementación de páginas interactivas usando Ajax. Específicamente, voy a resaltar el uso de Ajax en dos áreas de la aplicación Planeador de Reuniones, de la cuál estoy escribiendo la serie Construyendo Tu Startup en paralelo.
Primero, revisaremos cómo cargar un Mapa de Google en la página en respuesta al usuario ingresando un lugar específico. Como se muestra abajo, después de ingresar Plum Bistro y dar clic en retorno, el mapa a la derecha carga dinámicamente sin refrescar la página.



Después, te mostraré como registrar los cambios que hace el usuario a una reunión durante la fase de planeación. Planeador de Reuniones facilita a los participantes identificar sus lugares y fechas preferidas y después elegir la final.



Ajax hace el proceso mucho más fácil y rápido, permitiendo a la gente deslizar una cantidad de controles para indicar sus preferencias sin actualizar la página.
Solo un recordatorio, yo participo en la sección de comentarios de abajo. Estoy especialmente interesado si tienes aproximaciones diferentes, ideas adicionales o quieres sugerir temas para futuros tutoriales. Si tienes una pregunta o sugerencia de tema, por favor publicala abajo. También puedes contactarme en Twitter @reifman directamente.
Usando Ajax Con Yii



Si apenas estás comenzando con Ajax y quieres comenzar despacio, el Playground de Yii tiene dos ejemplos simples de Ajax que te podrían ser útiles para revisar. Uno cambia texto en una página vía Ajax y el otro carga la respuesta a un formulario en la misma página, ambos sin refrescar y cada uno incluye muestras detalladas de código.



Adentrémonos a nuestros dos ejemplos primarios. Puedes encontrar toda la fuente para estos ejemplos en el repositorio de código de GitHub de Planeador de Reuniones.
Mostrando Mapas de Google Interactivamente
Construyendo el Formulario de Entrada
Cuando se carga inicialmente el formulario Crear un Lugar (/frontend/views/place/create_place_google.php), este incluye el widget de búsqueda en tiempo real de Google Places.



Integrando la API JavaScript de Google Places
El formulario carga la librería JavaScript de Google Maps y la conecta al campo de entrada place-searchbox.
1 |
$gpJsLink= 'https://maps.googleapis.com/maps/api/js?' . http_build_query(array( |
2 |
|
3 |
'key' => Yii::$app->params['google_maps_key'], |
4 |
|
5 |
'libraries' => 'places', |
6 |
|
7 |
));
|
8 |
|
9 |
echo $this->registerJsFile($gpJsLink); |
10 |
|
11 |
|
12 |
|
13 |
$options = '{"types":["establishment"],"componentRestrictions":{"country":"us"}}'; |
14 |
|
15 |
echo $this->registerJs("(function(){ |
16 |
|
17 |
var input = document.getElementById('place-searchbox');
|
18 |
|
19 |
var options = $options;
|
20 |
|
21 |
searchbox = new google.maps.places.Autocomplete(input, options);
|
22 |
|
23 |
setupListeners('place');
|
24 |
|
25 |
})();" , \yii\web\View::POS_END ); |
El formulario parcial _formPlaceGoogle.php incluye algunos campos ocultos en los cuales se pueden almacenar los resultados del mapa antes de que se envíe toda la página, así como un div oculto para mostrar el mapa vía Ajax.
1 |
use frontend\assets\MapAsset; |
2 |
|
3 |
MapAsset::register($this); |
4 |
|
5 |
... |
6 |
|
7 |
<?= BaseHtml::activeHiddenInput($model, 'name'); ?> |
8 |
|
9 |
<?= BaseHtml::activeHiddenInput($model, 'google_place_id'); ?> |
10 |
|
11 |
<?= BaseHtml::activeHiddenInput($model, 'location'); ?> |
12 |
|
13 |
<?= BaseHtml::activeHiddenInput($model, 'website'); ?> |
14 |
|
15 |
<?= BaseHtml::activeHiddenInput($model, 'vicinity'); ?> |
16 |
|
17 |
<?= BaseHtml::activeHiddenInput($model, 'full_address'); ?> |
18 |
|
19 |
... |
20 |
|
21 |
<div class="col-md-6"> |
22 |
|
23 |
<article></article> |
24 |
|
25 |
</div> <!-- end col2 --> |
26 |
La tabla Meeting Planner Place guarda el nombre, place_id, location, website, vicinity y full_address de google para usar dentro de la aplicación.
El MapAsset incluido abajo carga nuestro archivo create_place.js el cuál operan entre Google y nuestro formulario; este básicamente maneja la transmisión y respuesta de información vía Ajax.
Nuestro JavaScript Manejador de Ajax
Te guiaré a través de create_place.js por partes. Primero, está setupListeners(), llamados por el formulario padre.
1 |
function setupListeners(model) { |
2 |
|
3 |
// searchbox is the var for the google places object created on the page
|
4 |
|
5 |
google.maps.event.addListener(searchbox, 'place_changed', function() { |
6 |
|
7 |
var place = searchbox.getPlace(); |
8 |
|
9 |
if (!place.geometry) { |
10 |
|
11 |
// Inform the user that a place was not found and return.
|
12 |
|
13 |
return; |
14 |
|
15 |
} else { |
16 |
|
17 |
// migrates JSON data from Google to hidden form fields
|
18 |
|
19 |
populateResult(place,model); |
20 |
|
21 |
}
|
22 |
|
23 |
});
|
24 |
|
25 |
var place_input = document.getElementById(model+'-searchbox'); |
26 |
|
27 |
google.maps.event.addDomListener(place_input, 'keydown', function(e) { |
28 |
|
29 |
if (e.keyCode == 13) { |
30 |
|
31 |
e.preventDefault(); |
32 |
|
33 |
}
|
34 |
|
35 |
});
|
36 |
|
37 |
}
|
Mientras el usuario comienza a teclear, el widget muestra opciones de lugares del mundo real y el evento place_changed es procesado con cada tecla presionada. El listener keydown de arriba previene que la tecla enter (ASCII 13 o 0xD para los geeks hex) de enviar el formulario.
Asó es como se ve mientras tecleas. Estoy ingresando Plum por Plum Bistro:



Recolectando el Mapa Resultante y su Información
Si la persona ha seleccionado enter o a dado clic sobre un lugar en el menú desplegable, entonces populateResult() es llamado; si no, no tenemos nada.
1 |
function populateResult(place,model) { |
2 |
|
3 |
// moves JSON data retrieve from Google to hidden form fields
|
4 |
|
5 |
// so Yii2 can post the data
|
6 |
|
7 |
$('#'+model+'-location').val(JSON.stringify(place['geometry']['location'])); |
8 |
|
9 |
$('#'+model+'-google_place_id').val(place['place_id']); |
10 |
|
11 |
$('#'+model+'-full_address').val(place['formatted_address']); |
12 |
|
13 |
$('#'+model+'-website').val(place['website']); |
14 |
|
15 |
$('#'+model+'-vicinity').val(place['vicinity']); |
16 |
|
17 |
$('#'+model+'-name').val(place['name']); |
18 |
|
19 |
loadMap(place['geometry']['location'],place['name']); |
20 |
|
21 |
}
|
22 |
Esto llena todos los campos ocultos con información de Google y llama a loadMap() para mostrar el mapa.



La función loadMap() es muy específica para la API de Google Place y muestra el mapa que ves abajo a la derecha:
1 |
function loadMap(gps,name) { |
2 |
|
3 |
var gps_parse = gps.toString().replace("(", "").replace(")", "").split(", "); |
4 |
|
5 |
var gps_lat = parseFloat(gps_parse[0]); |
6 |
|
7 |
var gps_lng = parseFloat(gps_parse[1]); |
8 |
|
9 |
|
10 |
|
11 |
if (document.querySelector('article').children.length==0) { |
12 |
|
13 |
var mapcanvas = document.createElement('div'); |
14 |
|
15 |
mapcanvas.id = 'mapcanvas'; |
16 |
|
17 |
mapcanvas.style.height = '300px'; |
18 |
|
19 |
mapcanvas.style.width = '300px'; |
20 |
|
21 |
mapcanvas.style.border = '1px solid black'; |
22 |
|
23 |
document.querySelector('article').appendChild(mapcanvas); |
24 |
|
25 |
}
|
26 |
|
27 |
var latlng = new google.maps.LatLng(gps_lat,gps_lng); // gps['k'], gps['D']); |
28 |
|
29 |
|
30 |
|
31 |
var myOptions = { |
32 |
|
33 |
zoom: 16, |
34 |
|
35 |
center: latlng, |
36 |
|
37 |
mapTypeControl: false, |
38 |
|
39 |
navigationControlOptions: {style: google.maps.NavigationControlStyle.SMALL}, |
40 |
|
41 |
mapTypeId: google.maps.MapTypeId.ROADMAP |
42 |
|
43 |
};
|
44 |
|
45 |
|
46 |
|
47 |
var map = new google.maps.Map(document.getElementById("mapcanvas"), myOptions); |
48 |
|
49 |
var marker = new google.maps.Marker({ |
50 |
|
51 |
position: latlng, |
52 |
|
53 |
map: map, |
54 |
|
55 |
title:name |
56 |
|
57 |
});
|
58 |
|
59 |
}
|
La experiencia de usuario es rápida e impresionante. ¡Pruébalo!
Registrar Cambios de Reunión Dinámicamente
Ahora, veamos como registrar cambios en los planes de reunión en tiempo real. No hay API de Google aquí, es más AJAX dentro del Framework Yii.
Mientras la gente agrega fechas, horarios y lugares a sus planes de reunión, verás una página como esta:



Las columnas You y Them muestran la disposición de cada participante hacia lugares y fechas. El slider más grande Choose permita a la persona tomar la decisión final acerca del lugar y la hora de la reunión.
Hay mucha información de las personas para recolectar y no queremos que cada cambio requiera que se refresque la página. Ajax es la solución ideal para este problema.
Te llevaré a través del código para el panel Reunión-Lugar de arriba. El panel Reunión-Horario de arriba trabaja de manera similar.
Siguiendo el Código
Debido al framwork MVC y mi deseo de rechazar codigos parciales, este flujo podría percibirse com odifícil de seguir. Las funciones helper de PHP y Javascript algunas veces tenían que ser colocadas en archivos padre, no los parciales a los que están más cercanamente relacionados. Trataré de darte un vistazo rápido primero. Te aliento a que lo leas unas cuántas veces para poder entenderlo completamente. Y de nuevo, puedes explorar el código vía GitHub.
Pista: Ten en mente que los nombres de archivos para parciales generalmente comienzan con un guión bajo.
- La página Planeador de Reuniones se carga en /frontend/views/meeting/view.php. Este archivo también incluye las funciones helper de JavaScript para manejar el estado de los botones como Enviar y Finalizar (e.j. ¿después de este cambio, el usuario puede ahora mandar esta invitación? Con Planeador de Reuniones, un lugar y un horario generalmente deben ser seleccionados antes de que pueda ser enviado) y mostrar notificaciones visuales de que los cambios han sido enviados por correo a otros participantes cuando el usuario termina.
- Cuando se muestra el panel Where para lugares, este carga /frontend/views/meeting-place/_panel.php. Este archivo incluye las funciones helper de PHP
showOwnerStatus()yshowParticipantStatus(), las cuáles serán reutilizadas por sus hijos, _list.php. Pero, más importante, _panel.php incluye los métodos PHP para el eventoswitchChangedel slider Bootstrap. - El archivo _panel.php usa _list.php para mostrar cada renglón individual para cada lugar. Este archivo genera los sliders Bootstrap llamando las funciones
showOwnerStatus()yshowParticipantStatus()de calling_panel.php. - Las funciones
switchChangeharán llamadas Ajax a MeetingPlaceChoiceController.php. - Y finalmente, MeetingPlaceChoiceController.php llama al modelo MeetingPlaceChoice.php que registra los cambios en la base de datos.
Lo siento porque la colocación de código relevante es complicada y dispersa.
Ahora, te guiaré a través de los componentes clave paso a paso.
Código Ajax Paso a Paso
Aquí está Meeting/view.php generando Meeting-Place/_panel.php. Esto muestra el parcial para las filas de posibles lugares y las selecciones de los participantes:
1 |
<?php |
2 |
|
3 |
// where |
4 |
|
5 |
if (!($model->meeting_type == \frontend\models\Meeting::TYPE_PHONE || $model->meeting_type == \frontend\models\Meeting::TYPE_VIDEO)) {
|
6 |
|
7 |
echo $this->render('../meeting-place/_panel', [
|
8 |
|
9 |
'model'=>$model, |
10 |
|
11 |
'placeProvider' => $placeProvider, |
12 |
|
13 |
'isOwner' => $isOwner, |
14 |
|
15 |
'viewer' => $viewer, |
16 |
|
17 |
]); |
18 |
|
19 |
} |
20 |
|
21 |
?> |
22 |
Abajo está el JavaScript relacionado a las acciones que responden a los resultados Ajax pero que no son directamente necesarios para Ajax. No necesitas entender lo que hacen estas funciones para entender este ejemplo de Ajax, pero las incluí ya que son llamadas en respuesta a eventos de Ajax.
1 |
<?php
|
2 |
|
3 |
$script = <<< JS |
4 |
|
5 |
var notifierOkay; // meeting sent already and no page change session flash |
6 |
|
7 |
|
8 |
|
9 |
if ($('#notifierOkay').val() == 'on') { |
10 |
|
11 |
notifierOkay = true; |
12 |
|
13 |
} else { |
14 |
|
15 |
notifierOkay = false; |
16 |
|
17 |
}
|
18 |
|
19 |
|
20 |
|
21 |
function displayNotifier(mode) { |
22 |
|
23 |
if (notifierOkay) { |
24 |
|
25 |
if (mode == 'time') { |
26 |
|
27 |
$('#notifierTime').show(); |
28 |
|
29 |
} else if (mode == 'place') { |
30 |
|
31 |
$('#notifierPlace').show(); |
32 |
|
33 |
} else { |
34 |
|
35 |
alert("We\'ll automatically notify the organizer when you're done making changes."); |
36 |
|
37 |
}
|
38 |
|
39 |
notifierOkay=false; |
40 |
|
41 |
}
|
42 |
|
43 |
}
|
44 |
|
45 |
|
46 |
|
47 |
function refreshSend() { |
48 |
|
49 |
$.ajax({ |
50 |
|
51 |
url: '$urlPrefix/meeting/cansend', |
52 |
|
53 |
data: {id: $model->id, 'viewer_id': $viewer}, |
54 |
|
55 |
success: function(data) { |
56 |
|
57 |
if (data) |
58 |
|
59 |
$('#actionSend').removeClass("disabled"); |
60 |
|
61 |
else
|
62 |
|
63 |
$('#actionSend').addClass("disabled"); |
64 |
|
65 |
return true; |
66 |
|
67 |
}
|
68 |
|
69 |
});
|
70 |
|
71 |
}
|
72 |
|
73 |
|
74 |
|
75 |
function refreshFinalize() { |
76 |
|
77 |
$.ajax({ |
78 |
|
79 |
url: '$urlPrefix/meeting/canfinalize', |
80 |
|
81 |
data: {id: $model->id, 'viewer_id': $viewer}, |
82 |
|
83 |
success: function(data) { |
84 |
|
85 |
if (data) |
86 |
|
87 |
$('#actionFinalize').removeClass("disabled"); |
88 |
|
89 |
else
|
90 |
|
91 |
$('#actionFinalize').addClass("disabled"); |
92 |
|
93 |
return true; |
94 |
|
95 |
}
|
96 |
|
97 |
});
|
98 |
|
99 |
}
|
100 |
|
101 |
|
102 |
|
103 |
JS; |
104 |
|
105 |
$position = \yii\web\View::POS_READY; |
106 |
|
107 |
$this->registerJs($script, $position); |
108 |
|
109 |
?>
|
Aquí en Meeting-Place/_panel.php, la tabla que muestra lugares y selecciones es creada, invocando _list.php:
1 |
<table class="table"> |
2 |
|
3 |
<thead>
|
4 |
|
5 |
<tr class="small-header"> |
6 |
|
7 |
<td></td>
|
8 |
|
9 |
<td ><?=Yii::t('frontend','You') ?></td> |
10 |
|
11 |
<td ><?=Yii::t('frontend','Them') ?></td> |
12 |
|
13 |
<td > |
14 |
|
15 |
<?php
|
16 |
|
17 |
if ($placeProvider->count>1 && ($isOwner || $model->meetingSettings['participant_choose_place'])) echo Yii::t('frontend','Choose'); |
18 |
|
19 |
?></td> |
20 |
|
21 |
</tr>
|
22 |
|
23 |
</thead>
|
24 |
|
25 |
<?= ListView::widget([ |
26 |
|
27 |
'dataProvider' => $placeProvider, |
28 |
|
29 |
'itemOptions' => ['class' => 'item'], |
30 |
|
31 |
'layout' => '{items}', |
32 |
|
33 |
'itemView' => '_list', |
34 |
|
35 |
'viewParams' => ['placeCount'=>$placeProvider->count,'isOwner'=>$isOwner,'participant_choose_place'=>$model->meetingSettings['participant_choose_place']], |
36 |
|
37 |
]) ?> |
38 |
|
39 |
</table>
|
40 |
|
41 |
<?php else: ?> |
42 |
|
43 |
<?php endif; ?> |
Más importante, también incluye el JavaScript de abajo, el cuál usamos para hacer llamadas Ajax cuando el usuario mueve un switch, cambiando su estado. Las funciones de elección corresponder a la opción del slider azul más grande, mientras que las funciones de opción corresponden a los sliders de preferencias.
1 |
$script = <<< JS |
2 |
|
3 |
placeCount = $placeProvider->count; |
4 |
|
5 |
// allows user to set the final place
|
6 |
|
7 |
$('input[name="place-chooser"]').on('switchChange.bootstrapSwitch', function(e, s) { |
8 |
|
9 |
// console.log(e.target.value); // true | false
|
10 |
|
11 |
// turn on mpc for user
|
12 |
|
13 |
$.ajax({ |
14 |
|
15 |
url: '$urlPrefix/meeting-place/choose', |
16 |
|
17 |
data: {id: $model->id, 'val': e.target.value}, |
18 |
|
19 |
// e.target.value is selected MeetingPlaceChoice model
|
20 |
|
21 |
success: function(data) { |
22 |
|
23 |
displayNotifier('place'); |
24 |
|
25 |
refreshSend(); |
26 |
|
27 |
refreshFinalize(); |
28 |
|
29 |
return true; |
30 |
|
31 |
}
|
32 |
|
33 |
});
|
34 |
|
35 |
});
|
36 |
|
37 |
|
38 |
|
39 |
// users can say if a place is an option for them
|
40 |
|
41 |
$('input[name="meeting-place-choice"]').on('switchChange.bootstrapSwitch', function(e, s) { |
42 |
|
43 |
//console.log(e.target.id,s); // true | false
|
44 |
|
45 |
// set intval to pass via AJAX from boolean state
|
46 |
|
47 |
if (s) |
48 |
|
49 |
state = 1; |
50 |
|
51 |
else
|
52 |
|
53 |
state =0; |
54 |
|
55 |
$.ajax({ |
56 |
|
57 |
url: '$urlPrefix/meeting-place-choice/set', |
58 |
|
59 |
data: {id: e.target.id, 'state': state}, |
60 |
|
61 |
success: function(data) { |
62 |
|
63 |
displayNotifier('place'); |
64 |
|
65 |
refreshSend(); |
66 |
|
67 |
refreshFinalize(); |
68 |
|
69 |
return true; |
70 |
|
71 |
}
|
72 |
|
73 |
});
|
74 |
|
75 |
});
|
76 |
|
77 |
|
78 |
|
79 |
JS; |
80 |
|
81 |
$position = \yii\web\View::POS_READY; |
82 |
|
83 |
$this->registerJs($script, $position); |
84 |
|
85 |
?>
|
Las funciones de arriba hacen la llamada a actionSet() en MeetingPlaceChoiceController para responder al cambio de switch usando peticiones Ajax:
1 |
public function actionSet($id,$state) |
2 |
|
3 |
{
|
4 |
|
5 |
Yii::$app->response->format = \yii\web\Response::FORMAT_JSON; |
6 |
|
7 |
// caution - incoming AJAX type issues with val
|
8 |
|
9 |
$id=str_replace('mpc-','',$id); |
10 |
|
11 |
//if (Yii::$app->user->getId()!=$mpc->user_id) return false;
|
12 |
|
13 |
if (intval($state) == 0 or $state=='false') |
14 |
|
15 |
$status = MeetingPlaceChoice::STATUS_NO; |
16 |
|
17 |
else
|
18 |
|
19 |
$status = MeetingPlaceChoice::STATUS_YES; |
20 |
|
21 |
//$mpc->save();
|
22 |
|
23 |
MeetingPlaceChoice::set($id,$status,Yii::$app->user->getId()); |
24 |
|
25 |
return $id; |
26 |
|
27 |
}
|
Las acciones de controlador que responden vía Ajax necesitan tener un formato de repsuesta JSON (de esta manera Yii sabe que no son para entregar HTML):
1 |
Yii::$app->response->format = \yii\web\Response::FORMAT_JSON; |
Aquí está el método MeetingPlaceChoice::set(), el cual registra las acciones del usuario en la base de datos y crea una entrada MeetingLog, la cuál vigila todos los cambios durante la planeación.
1 |
public static function set($id,$status,$user_id = 0,$bulkMode=false) |
2 |
|
3 |
{
|
4 |
|
5 |
$mpc = MeetingPlaceChoice::findOne($id); |
6 |
|
7 |
if ($mpc->user_id==$user_id) { |
8 |
|
9 |
$mpc->status = $status; |
10 |
|
11 |
$mpc->save(); |
12 |
|
13 |
if (!$bulkMode) { |
14 |
|
15 |
// log only when not in bulk mode i.e. accept all
|
16 |
|
17 |
// see setAll for more details
|
18 |
|
19 |
if ($status==MeetingPlaceChoice::STATUS_YES) { |
20 |
|
21 |
$command = MeetingLog::ACTION_ACCEPT_PLACE; |
22 |
|
23 |
} else { |
24 |
|
25 |
$command = MeetingLog::ACTION_REJECT_PLACE; |
26 |
|
27 |
}
|
28 |
|
29 |
MeetingLog::add($mpc->meetingPlace->meeting_id,$command,$mpc->user_id,$mpc->meeting_place_id); |
30 |
|
31 |
}
|
32 |
|
33 |
return $mpc->id; |
34 |
|
35 |
} else { |
36 |
|
37 |
return false; |
38 |
|
39 |
}
|
40 |
|
41 |
}
|
Características Relacionadas con Cambios de Reunión
En el Planeador de Reuniones, mantengo un bitácora de cada cambio. Esto me permite saber cuando han pasado unos cuántos minutos desde el último cambio de una persona y notificar a otros participantes de la reunión. Es un experimento que estoy probando con este servicio, en vez de requerir que los participantes presionen enviar cada vez que quieren hacer cambios.
Sin embargo, esto requiere capacitarlos para que entiendan que está bien cambiarlo y salir, por ejemplo, cerrar la ventana del navegador. Así que las funciones displayNotifier() muestran algunas alertas para ayudar con esto--finalmente puliré esto con el tiempo y las quitaré para usuarios experimentados.
El MettingLog también me permite generar un resumen de texto del historia de planeación de la reunión. Si estás interesado en aprender más acerca de esto, he escrito al respecto en Construyendo Tu Startup: Notificando a la Gente Sobre Cambios de Reunión y Entregando Notificaciones.
¿Qué Sigue?
Espero que estos ejemplos te ayuden a comprender los básicos de Ajax en Yii. Si estás interesado en Ajax más avanzado, estoy planeando incluir formularios cargados con Ajax en la serie Planeador de Reuniones. Y, es cierto que, Ajax es un área en donde la comunidad de Yii no ha compartido muchos ejemplos. Generalmente, Ajax trabaja de manera similar en Yii a como lo hace en PHP y otros Frameworks, así que puedes aprender de ejemplos de comunidades de otros framworks.
Está al pendiente de próximos tutoriales en nuestra serie Programando con Yii2 mientras continuamos sumergiéndonos en diferentes aspectos del framework. También deberías querer revisar nuestra serie Construyendo Tu Startup con PHP, la cual usa una plantilla avanzada de Yii2 mientras construimos una aplicación del mundo real.
Si quieres saber cuando llegue el siguiente tutorial de Yii2, sígueme @reifman en Twitter o revisa mi página de instructor. Mi página de instructor incluirá todos los artículos de esta serie tan pronto sean publicados.
Enlaces Relacionados
- Ajax (Wikipedia)
- Comenzando - Ajax (Red de Desarrolladores Mozilla)
- Intercambio de Desarrolladores Yii2, mi sitio de recursos de Yii2



