Tutorial: Combo Boxes Aninhados com ExtJS, Spring MVC 3 e Hibernate 3.5

13 Oct 2010
8 mins read

Este é um tutorial passo a passo de como implementar combo boxes aninhados usando ExtJS (no lado cliente) e Spring MVC 3 e Hibernate 3.5 (no lado servidor).

Vou usar o exemplo clássico de comboboxs: estados e cidades. neste exemplo, vou usar os estados e cidades do Brasil.

Qual é o objetivo final? Quando o usuário selecionar um estado no primeiro combo box, a aplicação irá carregar o segundo combo box com as cidades que pertencem ao estado selecionado - sem recarregar a página.

No ExtJS, existem duas maneiras de implentar.

A primeira é carregar o conteúdo dos dois combo boxes, e quando o usuário selecionar um estados, a aplicação irá filtrar os dados do combo box de cidades para mostrar apenas as cidades que pertencem ao estado selecionado.

A segunda forma é carregar apenas as informações necessárias para popular o combo box dos estados. Quando o usuário selecionar um estado, a aplicação irá fazer uma requisição para carregar as informações das cidades do estado escolhido.

Qual é a melhor maneira? Depende da quantidade de dados que será necessário buscar no banco de dados. Por exemplo: você tem um combo box que lista todos os países do mundo. E o segundo combo box representa todas as cidades do mundo (ou cidades de cada país). Neste caso, o cenário número 2 é a melhor opção, porque no cenário 1 seria necessário carregar todas as cidades de uma só vez. Imagina a quantidade enorme de dados que iria carregar do banco de dados? É necessário analisar.

Ok. Vamos ao código fonte. Vou mostrar como implementar ambos os cenários.

Mas primeiro, vou mostrar como o projeto está organizado:

Vamos dar uma olhada no código Java.

BaseDAO:

Contém o hibernate template usado por CityDAO e StateDAO.

[code lang="java" firstline="1" toolbar="true" collapse="false" wraplines="false"]
package com.loiane.dao;

import org.hibernate.SessionFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.orm.hibernate3.HibernateTemplate;
import org.springframework.stereotype.Repository;

@Repository
public abstract class BaseDAO {

private HibernateTemplate hibernateTemplate;

public HibernateTemplate getHibernateTemplate() {
return hibernateTemplate;
}

@Autowired
public void setSessionFactory(SessionFactory sessionFactory) {
hibernateTemplate = new HibernateTemplate(sessionFactory);
}
}
[/code]

CityDAO:

Contém dois métodos: um para carregar todas as cidades do banco (usado no cenário #1), e outro método para carregar todas as cidades que pertencem a um determinado estado (usado no cenário #2).

[code lang="java" firstline="1" toolbar="true" collapse="false" wraplines="false"]
package com.loiane.dao;

import java.util.List;

import org.hibernate.criterion.DetachedCriteria;
import org.hibernate.criterion.Restrictions;
import org.springframework.stereotype.Repository;

import com.loiane.model.City;

@Repository
public class CityDAO extends BaseDAO{

public List<City> getCityListByState(int stateId) {

DetachedCriteria criteria = DetachedCriteria.forClass(City.class);
criteria.add(Restrictions.eq("stateId", stateId));

return this.getHibernateTemplate().findByCriteria(criteria);

}

public List<City> getCityList() {

DetachedCriteria criteria = DetachedCriteria.forClass(City.class);

return this.getHibernateTemplate().findByCriteria(criteria);

}
}
[/code]

StateDAO:

Contém apenas um método para carregar todos os estados do banco.

[code lang="java" firstline="1" toolbar="true" collapse="false" wraplines="false"]
package com.loiane.dao;

import java.util.List;

import org.hibernate.criterion.DetachedCriteria;
import org.springframework.stereotype.Repository;

import com.loiane.model.State;

@Repository
public class StateDAO extends BaseDAO{

public List<State> getStateList() {

DetachedCriteria criteria = DetachedCriteria.forClass(State.class);

return this.getHibernateTemplate().findByCriteria(criteria);

}
}
[/code]

City:

Representa o POJO Cidade/City; representa a tabela Cidade/City.

[code lang="java" firstline="1" toolbar="true" collapse="false" wraplines="false"]
package com.loiane.model;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;

import org.codehaus.jackson.annotate.JsonAutoDetect;

@JsonAutoDetect
@Entity
@Table(name="CITY")
public class City {

private int id;
private int stateId;
private String name;

//getters and setters
}
[/code]

State:

Representa o POJO Estado/State; represeta a cidade Estado/State.

[code lang="java" firstline="1" toolbar="true" collapse="false" wraplines="false"]
package com.loiane.model;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;

import org.codehaus.jackson.annotate.JsonAutoDetect;

@JsonAutoDetect
@Entity
@Table(name="STATE")
public class State {

private int id;
private int countryId;
private String code;
private String name;

//getters and setters
}

[/code]

CityService:

Contém dois métodos: um para carregar todas as cidades do banco (usado no cenário #1), e outro método para carregar todas as cidades que pertencem a um determinado estado (usado no cenário #2).

Faz apenas chamada para a classe CityDAO.

[code lang="java" firstline="1" toolbar="true" collapse="false" wraplines="false"]
package com.loiane.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.loiane.dao.CityDAO;
import com.loiane.model.City;

@Service
public class CityService {

private CityDAO cityDAO;

public List<City> getCityListByState(int stateId) {
return cityDAO.getCityListByState(stateId);
}

public List<City> getCityList() {
return cityDAO.getCityList();
}

@Autowired
public void setCityDAO(CityDAO cityDAO) {
this.cityDAO = cityDAO;
}
}
[/code]

StateService:

Contém apenas um método para carregar todos os estados do banco. Faz apenas uma chamada para a classe StateDAO.

[code lang="java" firstline="1" toolbar="true" collapse="false" wraplines="false"]
package com.loiane.service;

import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import com.loiane.dao.StateDAO;
import com.loiane.model.State;

@Service
public class StateService {

private StateDAO stateDAO;

public List<State> getStateList() {
return stateDAO.getStateList();
}

@Autowired
public void setStateDAO(StateDAO stateDAO) {
this.stateDAO = stateDAO;
}
}
[/code]

CityController:

Contém dois métodos: um para carregar todas as cidades do banco (usado no cenário #1), e outro método para carregar todas as cidades que pertencem a um determinado estado (usado no cenário #2). Faz apenas chamada para a classe CityService. Ambos os métodos retornam um objeto JSON no seguinte formato:

[code lang="js" firstline="1" toolbar="true" collapse="false" wraplines="false"]
{"data":[
{"stateId":1,"name":"Acrelândia","id":1},
{"stateId":1,"name":"Assis Brasil","id":2},
{"stateId":1,"name":"Brasiléia","id":3},
{"stateId":1,"name":"Bujari","id":4},
{"stateId":1,"name":"Capixaba","id":5},
{"stateId":1,"name":"Cruzeiro do Sul","id":6},
{"stateId":1,"name":"Epitaciolândia","id":7},
{"stateId":1,"name":"Feijó","id":8},
{"stateId":1,"name":"Jordão","id":9},
{"stateId":1,"name":"Mâncio Lima","id":10},
]}
[/code]

Classe:

[code lang="java" firstline="1" toolbar="true" collapse="false" wraplines="false"]
package com.loiane.web;

import java.util.HashMap;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;

import com.loiane.service.CityService;

@Controller
@RequestMapping(value="/city")
public class CityController {

private CityService cityService;

@RequestMapping(value="/getCitiesByState.action")
public @ResponseBody Map<String,? extends Object> getCitiesByState(@RequestParam int stateId) throws Exception {

Map<String,Object> modelMap = new HashMap<String,Object>(3);

try{

modelMap.put("data", cityService.getCityListByState(stateId));

return modelMap;

} catch (Exception e) {

e.printStackTrace();

modelMap.put("success", false);

return modelMap;
}
}

@RequestMapping(value="/getAllCities.action")
public @ResponseBody Map<String,? extends Object> getAllCities() throws Exception {

Map<String,Object> modelMap = new HashMap<String,Object>(3);

try{

modelMap.put("data", cityService.getCityList());

return modelMap;

} catch (Exception e) {

e.printStackTrace();

modelMap.put("success", false);

return modelMap;
}
}

@Autowired
public void setCityService(CityService cityService) {
this.cityService = cityService;
}
}
[/code]

StateController:

Contém apenas um método para carregar todos os estados do banco. Faz apenas uma chamada para a classe StateService. O método retorna um objeto JSON no seguinte formato:

[code lang="js" firstline="1" toolbar="true" collapse="false" wraplines="false"]
{"data":[
{"countryId":1,"name":"Acre","id":1,"code":"AC"},
{"countryId":1,"name":"Alagoas","id":2,"code":"AL"},
{"countryId":1,"name":"Amapá","id":3,"code":"AP"},
{"countryId":1,"name":"Amazonas","id":4,"code":"AM"},
{"countryId":1,"name":"Bahia","id":5,"code":"BA"},
{"countryId":1,"name":"Ceará","id":6,"code":"CE"},
{"countryId":1,"name":"Distrito Federal","id":7,"code":"DF"},
{"countryId":1,"name":"Espírito Santo","id":8,"code":"ES"},
{"countryId":1,"name":"Goiás","id":9,"code":"GO"},
{"countryId":1,"name":"Maranhão","id":10,"code":"MA"},
{"countryId":1,"name":"Mato Grosso","id":11,"code":"MT"},
{"countryId":1,"name":"Mato Grosso do Sul","id":12,"code":"MS"},
{"countryId":1,"name":"Minas Gerais","id":13,"code":"MG"},
{"countryId":1,"name":"Pará","id":14,"code":"PA"},
]}
[/code]

Classe:

[code lang="java" firstline="1" toolbar="true" collapse="false" wraplines="false"]
package com.loiane.web;

import java.util.HashMap;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;

import com.loiane.service.StateService;

@Controller
@RequestMapping(value="/state")
public class StateController {

private StateService stateService;

@RequestMapping(value="/view.action")
public @ResponseBody Map<String,? extends Object> view() throws Exception {

Map<String,Object> modelMap = new HashMap<String,Object>(3);

try{

modelMap.put("data", stateService.getStateList());

return modelMap;

} catch (Exception e) {

e.printStackTrace();

modelMap.put("success", false);

return modelMap;
}
}

@Autowired
public void setStateService(StateService stateService) {
this.stateService = stateService;
}
}
[/code]

Dentro da pasta WebContent temos:

Vamos dar uma olhada no código ExtJS.

Cenário Numero 1:

Carregar todos os dados disponíveis do banco de dados para popular os dois combo boxes. Usa um filtro no combo box das cidades.

liked-comboboxes-local.js:

[code lang="js" firstline="1" toolbar="true" collapse="false" wraplines="false"]
var localForm = new Ext.FormPanel({
width: 400
,height: 300
,style:'margin:16px'
,bodyStyle:'padding:10px'
,title:'Linked Combos - Local Filtering'
,defaults: {xtype:'combo'}
,items:[{
fieldLabel:'Select State'
,displayField:'name'
,valueField:'id'
,store: new Ext.data.JsonStore({
url: 'state/view.action',
remoteSort: false,
autoLoad:true,
idProperty: 'id',
root: 'data',
totalProperty: 'total',
fields: ['id','name']
})
,triggerAction:'all'
,mode:'local'
,listeners:{select:{fn:function(combo, value) {
var comboCity = Ext.getCmp('combo-city-local');
comboCity.clearValue();
comboCity.store.filter('stateId', combo.getValue());
}}
}

},{
fieldLabel:'Select City'
,displayField:'name'
,valueField:'id'
,id:'combo-city-local'
,store: new Ext.data.JsonStore({
url: 'city/getAllCities.action',
remoteSort: false,
autoLoad:true,
idProperty: 'id',
root: 'data',
totalProperty: 'total',
fields: ['id','stateId','name']
})
,triggerAction:'all'
,mode:'local'
,lastQuery:''
}]
});
[/code]

O combo box que representa os estados (state) é declarado nas linhas  9 a 28.

O combo box que representa das cidades (city) é declarado nas linhas 31 a 46.

Repare que ambos os combo boxes são carregados quando fazemos o load da página, como pode ser visto nas linhas 15 e 38 (autoload:true).

O combo box que representa os estados possui um select event listener que quando executado, filtra o combo box que representa das cidades baseado na seleção atual do estado. Pode ser visto nas linhas 23 a 28.

O combo box que representa as cidades possui um atributo lastQuery:"". Isso é para "enganar" o combo box quando é feito o load da página. Assim, o combo box pensa que já foi feito um filtro.

Scenario Number 2:

Carrega apenas os dados dos estados do banco de dados. Quando o usuário seleciona um estado, a aplicação irá buscar todas as cidades relacionadas a este estado no banco de dados - sem fazer refresh da página.

liked-comboboxes-remote.js:

[code lang="js" firstline="1" toolbar="true" collapse="false" wraplines="false"]
var dataBaseForm = new Ext.FormPanel({
width: 400
,height: 200
,style:'margin:16px'
,bodyStyle:'padding:10px'
,title:'Linked Combos - Database'
,defaults: {xtype:'combo'}
,items:[{
fieldLabel:'Select State'
,displayField:'name'
,valueField:'id'
,store: new Ext.data.JsonStore({
url: 'state/view.action',
remoteSort: false,
autoLoad:true,
idProperty: 'id',
root: 'data',
totalProperty: 'total',
fields: ['id','name']
})
,triggerAction:'all'
,mode:'local'
,listeners: {
select: {
fn:function(combo, value) {
var comboCity = Ext.getCmp('combo-city');
//set and disable cities
comboCity.setDisabled(true);
comboCity.setValue('');
comboCity.store.removeAll();
//reload city store and enable city combobox
comboCity.store.reload({
params: { stateId: combo.getValue() }
});
comboCity.setDisabled(false);
}
}
}
},{
fieldLabel:'Select City'
,displayField:'name'
,valueField:'id'
,disabled:true
,id:'combo-city'
,store: new Ext.data.JsonStore({
url: 'city/getCitiesByState.action',
remoteSort: false,
idProperty: 'id',
root: 'data',
totalProperty: 'total',
fields: ['id','stateId','name']
})
,triggerAction:'all'
,mode:'local'
,lastQuery:''
}]
});
[/code]

O combo box que representa os estados (state) é declarado nas linhas  9 a 38.

O combo box que representa das cidades (city) é declarado nas linhas 40 a 55.

Repare que apenas o combo box dos estados é carregado quando fazemos o load da página, como pode ser visto na linha 15 (autoload:true).

O combo box que representa os estados possui um select event listener que quando executado, carrega os dados para a store das cidades (passa stateId como parâmetro) baseado no estado selectionado. Pode ser vista nas linhas 24 a 38.

O combo box que representa as cidades possui um atributo lastQuery:"". Isso é para "enganar" o combo box quando é feito o load da página. Assim, o combo box pensa que já foi feito um filtro.

Se desejar, pode fazer o download do projeto completo no meu repositório GitHub: http://github.com/loiane/extjs-linked-combox

Usei Eclipse IDE + TomCat 7 para desenvolver este projeto de exemplo.

Referência: http://www.sencha.com/learn/Tutorial:Linked_Combos_Tutorial_for_Ext_2

Bons códigos! :)