Jak szybko i przyjemnie stworzyć aplikację webową w środowisku .NET i Ext JS? (część 2/2)
To jest druga część tego artykułu, pierwszą znajdziesz tutaj.
7. Strona startowa projektu
Strona, która zostanie wyświetlona domyślnie po uruchomieniu aplikacji zostanie określona w pliku Default.aspx, który powinien znajdować się w głównym folderze aplikacji.
Jej zawartość jest następująca:
<!DOCTYPE html>
<html>
<head>
<link href="https://cdnjs.cloudflare.com/ajax/libs/extjs/6.2.0/classic/theme-
classic/resources/theme-classic-all.css" rel="stylesheet" />
<script type="text/javascript"
src="https://cdnjs.cloudflare.com/ajax/libs/extjs/6.2.0/ext-all.js"></script>
<script
src="https://ajax.googleapis.com/ajax/libs/jquery/3.2.1/jquery.min.js"></script>
<script type="text/javascript" src="app/knockout-3.4.2.js"></script>
<script type="text/javascript" src="app/app.js"></script>
<link rel="stylesheet" href="app/view/Viewport.css" />
</head>
<body>
</body>
</html>
Widzimy tutaj wyłącznie zadeklarowaną część head dokumentu html w której określone zostały referencje do bibliotek (ExtJS, Knockout, JQuery), a także odwołanie do głównego pliku aplikacji (app.js) oraz pliku ze stylami CSS.
Plik app.js zawiera definicję samej aplikacji tzn. jej nazwę, ścieżkę do głównego folderu aplikacji i listę stores tj. obiektów przechowujących dane po stronie klienta (ich definicja zostanie przedstawiona w dalszej części artykułu).
Ext.application({
name: 'TrainTimetableApp',
appFolder: 'app',
autoCreateViewport: true,
stores:['TrainTimetableApp.store.TimetableTreeStore',
'TrainTimetableApp.store.StationStore']
});
8. Layout aplikacji
Layout aplikacji zostanie stworzony za pomocą komponentu Viewport. Viewport skojarzony jest ze stroną internetową (a dokładniej jej sekcją body) wyświetlaną w ramach dostępnego okna przeglądarki. Zgodnie z tym co widzieliśmy na mockupie layout aplikacji składa się z 3 głównych sekcji:
- TreeGrid z dostępnymi trasami przejazdu,
- Formatka edycyjna,
- Grid z listą stacji pośrednich.
Layout będzie miał formę tabeli składającej się z 2 wierszy i 2 kolumn. Pierwszy wiersz zawierający komponent TreeGrid będzie miał scalone komórki (ustawienie colspan=2). W drugim wierszu w pierwszej kolumnie znajdzie się formatka edycyjna, a w drugiej kolumnie grid z listą stacji pośrednich.
Ext.define("TrainTimetableApp.view.Viewport", {
extend: "Ext.container.Viewport",
requires: ["TrainTimetableApp.view.TimetableTreeGrid",
"TrainTimetableApp.view.TimetableForm",
"TrainTimetableApp.view.StationGrid"
],
initComponent: function() {
this.layout = {
columns: 2,
type: "table"
};
this.items = [{
xtype: "TimetableTreeGrid",
colspan: 2,
width: "1900px",
style: "padding:10px 10px 10px 10px"
},
{
xtype: "TimetableForm",
width: "600px"
},
{
xtype: "StationGrid",
width: "1285px",
height: "400px",
style: "margin-top:20px"
}
];
this.callParent(arguments);
}
});
9. TreeGrid z listą dostępnych tras kolejowych
9.1 Widok
Definicja TreeGrida odbywa się poprzez obiekt rozszerzający klasę Ext.tree.Panel. Żródło danych grida określone jest poprzez właściwość store, a więc kontener przechowujący zbiór danych. Store w tym przypadku będzie zasilany poprzez odwołanie do odpowiedniej akcji serwera udostępnionej jako zasób Web Api aplikacji.
Kolejny element to lista kolumn. Dla każdej z nich określamy 3 parametry:
- text – nagłówek kolumny.
- flex – szerokość kolumny, podawana relatywnie w stosunku do szerokości innych kolumn (np. jeśli ustalimy parametr flex na 4 to taka kolumna będzie dwukrotnie szersza od kolumn z parametrem flex równym 2).
- dataIndex – nazwa pola w modelu danych (model danych wykorzystywany jest poprzez kontener (store))
Wyjątkowym rodzajem kolumny jest actioncolumn. W tej kolumnie można zdefiniować elementy funkcyjne np. przycisk do usuwania rekordu. Dla takiej kolumny definiujemy następujące parametry:
- icon – ścieżka do pliku z ikoną wyświetlaną w danej kolumnie,
- handler – funkcja wywoływana po naciśnięciu danego elementu,
- getClass – parametr opcjonalny. W tym przypadku ukrywamy element jeśli dany wiersz zawiera element podrzędny (tj. nie samo połączenie kolejowe, ale jedną ze stacji pośrednich – wówczas wartość pola TrainName będzie niezdefiniowana i przycisk do usuwania elementu nie będzie wyświetlany).
Ext.define('TrainTimetableApp.view.TimetableTreeGrid', {
extend: 'Ext.tree.Panel',
alias: 'widget.TimetableTreeGrid',
initComponent: function() {
this.store = "TrainTimetableApp.store.TimetableTreeStore";
this.columns = [{
xtype: 'treecolumn',
text: 'Name',
flex: 2.5,
dataIndex: 'TrainName'
}, {
text: 'Route',
flex: 2,
dataIndex: 'Route',
}, {
text: 'Type',
flex: 2,
dataIndex: 'Type'
}, {
text: 'Station',
flex: 3,
dataIndex: 'Station'
}, {
text: 'Arrival time',
flex: 4,
dataIndex: 'ArrivalTime'
}, {
text: 'Departure time',
flex: 5,
dataIndex: 'DepartureTime'
},
{
xtype: 'actioncolumn',
header: 'Delete',
width: 150,
align: 'center',
items: [{
icon: '/app/images/delete.png',
handler: function(grid, rowIndex, colIndex,
item, e, record) {
record.parentNode.removeChild(record);
},
getClass: function(v, meta, rec) {
if (rec.data["TrainName"] == undefined) {
return "x-hidden-display";
}
},
scope: this
}]
}
]
this.callParent(arguments);
}
});
9.2 Model danych
Model danych dla TreeGrida definiujemy jako klasę rozszerzającą TreeModel. Model ten jest bardzo prosty, zawiera nazwę właściwości przechowującą klucz główny oraz nazwy pozostałych pól, które mają być widoczne w modelu danych.
Ext.define('TrainTimetableApp.model.TimetableTreeModel', {
extend: 'Ext.data.TreeModel',
idProperty: 'TrainId',
fields: [
{
name: "TrainName"
},
{
name: "Route"
},
{
name: "Type"
},
{
name: "Station"
},
{
name: "ArrivalTime"
},
{
name: "DepartureTime"
}
]
});
9.3 Store
Tak jak już wspomnieliśmy store to kontener przechowujący dane. W przypadku źródła danych dla TreeGrida skorzystamy z kontenera typu TreeStore. Podstawowe parametry, które musimy ustalić dla kontenera to:
- model – nazwa klasy w której zdefiniowany został model danych przechowywanych w kontenerze,
- proxy – określa format danych (json), które zostaną pobrane z określonego zasobu. W tym przypadku mamy do czynienia z zasobem REST udostępnianym pod określonym adresem URL. Dodatkowo parametr batchActions wymusza w przypadku operacji na wielu rekordach wysłanie jednego zbiorczego zamiast wielu sekwencyjnych żądań. Taka sytuacja ma miejsce np. przy usuwaniu elementu z grida, kiedy usuwamy element nadrzędny i jednocześnie wszystkie jego elementy podrzędne.
Ext.define('TrainTimetableApp.store.TimetableTreeStore', {
extend: 'Ext.data.TreeStore',
model: 'TrainTimetableApp.model.TimetableTreeModel',
proxy: {
type: 'rest',
reader: 'json',
url: '/api/TrainConnections',
batchActions: true,
appendId: false
},
lazyFill: false
});
9.4 Akcje kontrolera
9.4.1 Pobieranie danych do TreeGrida
Funkcja GetTrainConnections w kontrolerze TrainConnectionsController definiuje zasób dostępny za pomocą metody GET pod adresem /api/TrainConnections. Zastosowany przez nas wzorzec tworzenia zasobów i ich nazw w Web Api jest następujący:
- pierwsza część nazwy funkcji to nazwa metody http (np. Get, Post, Put, Delete)
- druga część nazwy funkcji to nazwa identyfikująca zasób np. TrainConnections
Poniżej podano nazwy przykładowych zasobów i adres url pod którym dany zasób jest dostępny:
Nazwa funkcji | URL | Metoda HTTP |
---|---|---|
GetTranConnections |
/api/TrainConnections |
GET |
PostTrainConnection |
/api/TrainConnections |
POST |
PutTrainConnection |
/api/TrainConnections |
PUT |
DeleteTrainConnection |
/api/TrainConnections |
DELETE |
Tabela nr 2. Przykładowe nazwy funkcji w kontrolerze i odpowiadające im zasoby.
Funkcja GetTrainConnections pobiera z bazy danych wszystkie zapisane połączenia kolejowe i mapuje je na obiekty DTO (ang. Data Transfer Object), które są zwracane do klienta wywołującego zasób. Funkcje kontrolera które zostały wcześniej wygenerowane przez Visual Studio automatycznie na podstawie modelu danych nie korzystały z DTO. Zwracały one bezpośrednio dane w takiej postaci w jakiej zdefiniowane były w modelu danych. Jednak nie zawsze taka sytuacja jest pożądana. W naszym przypadku struktura danych wymuszana jest poprzez komponent TreeGrid, który wymaga podania dodatkowych danych np. czy dany element jest liściem struktury drzewiastej (parametr leaf przyjmujący wartości true lub false). Stąd też niezbędne jest mapowanie danych z obiektów modelu danych na obiekty DTO.
public TrainConnectionDTO GetTrainConnections()
{
List<TrainConnectionItemDTO> itemList =
new List<TrainConnectionItemDTO>();
DbSet<TrainConnection> results = db.TrainConnections;
foreach (TrainConnection trainConnection in results)
{
List<TrainConnectionItemDTO> stations =
new List<TrainConnectionItemDTO>();
foreach(Station station in trainConnection.Stations)
{
stations.Add(new TrainConnectionItemDTO() {
Id = -station.Id, Station = station.Name, ArrivalTime = station.ArrivalTime?.ToString("HH:mm"), DepartureTime = station.DepartureTime?.ToString("HH:mm"), leaf = true });
}
String route = trainConnection.Start + " - " + trainConnection.Destination;
TrainConnectionItemDTO item = new TrainConnectionItemDTO() { Id = trainConnection.Id, TrainName = trainConnection.Name, Route = route, Type = trainConnection.ConnectionType.ToString(), leaf = false, children=stations };
itemList.Add(item);
}
TrainConnectionDTO dto = new TrainConnectionDTO() {
TrainId = "root", leaf = false, children = itemList };
return dto;
}
9.4.2 Usuwanie elementu z TreeGrida
Kolejna funkcja w kontrolerze TrainConnectionsController umożliwia usuwanie elementów z TreeGrida. Funkcja ta przyjmuje jako argument objekt IdObject, który zawiera id usuwanego elementu. W przypadku, gdy na liście znajduje się więcej niż jeden id, oznacza to, że usuwamy połączenie kolejowe z wieloma stacjami pośrednimi. Jednak usunięcie połączenia kolejowego w naszej strukturze bazy danych powoduje również usunięcie wszystkich stacji pośrednich. Interesuje nas zatem tylko pierwsze id z listy na podstawie którego odnajdziemy dane połączenie kolejowe i dokonamy jego usunięcia. Atrybut FromBody specyfikuje, że podany argument pobierany jest z treści żądania http.
[ResponseType(typeof(TrainConnection))]
public IHttpActionResult DeleteTrainConnection([FromBody]Object idObject)
{
int id;
if (idObject.GetType() == typeof(JObject))
{
JObject jsonObject = (JObject)idObject;
id = (int)jsonObject.GetValue("TrainId");
}
else
{
JArray jsonArray = (JArray)idObject;
JToken jsonToken = jsonArray[0];
id = (int)jsonToken["TrainId"];
}
TrainConnection trainConnection = db.TrainConnections.Find(id);
if (trainConnection == null)
{
return NotFound();
}
db.TrainConnections.Remove(trainConnection);
db.SaveChanges();
return Ok(trainConnection);
}
10. Formularz edycyjny
Formularz edycyjny służyć będzie do edycji danych nowego połączenia kolejowego. Zostanie on zbudowany z komponentów HTML5. Komponenty te zostaną powiązane z modelem danych przy użyciu frameworka Knockout JS.
10.1 Widok
Formularz edycyjny zdefiniujemy jako nowy komponent tzn. będzie on dziedziczył po standardowym komponencie frameworka Ext JS czyli Ext.Component. Sam widok komponentu określony zostanie w części html. Funkcja onRender pozwoli zainicjalizować komponenty HTML5 po ich wyrenderowaniu w oknie przeglądarki.
Ext.define('TrainTimetableApp.view.TimetableForm', {
extend: 'Ext.Component',
alias: 'widget.TimetableForm',
width: 280,
padding: 15,
id: 'test',
html: [
…
],
onRender: function () {
…
}
});
Widok html określimy w następujący sposób:
<form>
<fieldset>
<legend>Basic route data:</legend>
<table style='width:100%; height: 200px;'>
<tr>
<td>Name:</td>
<td><input data-bind='value: name' type='text' style='width:100%'></td>
</tr>
<tr>
<td>Start station:</td>
<td><input data-bind='value: startStation' type='text' style='width:100%'></td>
</tr>
<tr>
<td>End station:</td>
<td><input data-bind='value: endStation' type='text' style='width:100%'></td>
</tr>
<tr>
<td>Type:</td>
<td>
<select data-bind='value: type' style='width:100%'>
<option>InterCity</option>
<option>Pendolino</option>
<option>Regio</option>
</select>
</td>
</tr>
<tr>
<td colspan='2' style='text-align:center; padding:10px;'><input type='button' value='Save' data-bind='click: save'> <input type='button' value='Clear' data-bind='click: clear'></td>
</tr>
</table>
</fieldset>
</form>
Interesujące jest tutaj wykorzystanie parametrów data-bind. Określają one powiązanie wartości występującej w danym komponencie (value) z określonym polem modelu danych (model danych dla formularza zostanie zdefiniowany w kolejnym punkcie). Dla przycisków możemy zdefiniować obsługę zdarzenia click poprzez podanie nazwy metody, która ma zostać wywołana po naciśnięciu danego przycisku np. click:save.
10.2 Model danych
Definicja modelu danych nastąpi w metodzie onRender wcześniej opisywanego komponentu TimetableForm. Model ten składa się z listy pól oraz funkcji. Pola oznaczone są jako ko.observable co wymusza dwukierunkowy binding pomiędzy modelem danych, a komponentami powiązanymi z modelem. Oznacza to, że zmiana danych w modelu wymusi zmianę wartości w komponentach i odwrotnie zmiana wartości w komponentach będzie automatycznie odzwierciedlona w modelu danych.
var viewModel = {
name: ko.observable(),
startStation: ko.observable(),
endStation: ko.observable(),
type: ko.observable(),
save: function () {…},
clear: function () {…}
}
ko.applyBindings(viewModel);
10.3 Zapis danych z formularza
Zapis polegać będzie na przygotowaniu danych pobranych z formularza edycyjnego (a właściwie z odpowiadającego mu modelu danych) oraz grida z listą stacji pośrednich (w tym przypadku dane pobrane są z kontenera danych (store)). Tak zebrane dane zostaną przesłane metodą POST do odpowiedniej akcji kontrolera TrainConnectionsController. W przypadku poprawnego zapisu kontroler zwróci kod 201 (Created). Obsługa tego przypadku polega na wyczyszczeniu komponentów edycyjnych i odświeżeniu treegrida z listą połączeń kolejowych. Jeśli zapis nie powiódł się to kontroler zwróci kod 400. Odpowiedź kontrolera zawiera wtedy również listę błędów (zmienna ModelState). Wówczas komunikat z listą błędów wyświetlany jest w oknie dialogowym.
var data = {
"Name": this.name(),
"Start": this.startStation(),
"Destination": this.endStation(),
"ConnectionType": this.type()
};
var stationList = [];
var stationGrid = Ext.getCmp("stationGrid");
stationGrid.getStore().each(function(record) {
var station = {
"Name": record.data["station"],
"ArrivalTime": record.data["arrivalTime"],
"DepartureTime": record.data["departureTime"]
};
stationList.push(station);
});
data.Stations = stationList;
var self = this;
$.post("api/TrainConnections", data)
.done(function(msg) {
self.clear();
var stationGrid = Ext.getCmp("stationGrid");
stationGrid.getStore().removeAll();
var trainConnectionGrid = Ext.getCmp("trainConnectionGrid");
trainConnectionGrid.getStore().load();
})
.fail(function(xhr, status, error) {
var message = "";
var modelState = xhr.responseJSON.ModelState;
for (var property in modelState) {
if (modelState.hasOwnProperty(property)) {
var errorMessage = modelState[property][0];
message += (errorMessage + "<br>");
}
}
Ext.MessageBox.alert('Error', message);
});
10.4 Akcje kontrolera
Zapis danych jest operacją trywialną. Przygotowujemy bowiem po stronie klienta struktury danych odpowiadające modelowi danych po stronie serwerowej. Dzięki temu dane przychodzące w formacie JSON są automatycznie mapowane na obiekt klasy domenowej TrainConnection. Następnie obiekt ten jest zapisywany do bazy danych. W przypadku błędów walidacji zwracany jest obiekt BadRequest z listą błędów, które wystąpiły podczas procesu walidacji danych.
[ResponseType(typeof(TrainConnection))]
public IHttpActionResult PostTrainConnection(TrainConnection trainConnection)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
db.TrainConnections.Add(trainConnection);
db.SaveChanges();
return CreatedAtRoute("DefaultApi", new { id =
trainConnection.Id }, trainConnection);
}
11. Grid z listą stacji pośrednich
W punkcie 10.3 wspomnieliśmy, że zapis danych polega na pobraniu danych z formularza edycyjnego i grida z listą stacji pośrednich. W tym punkcie skupimy się na implementacji tego właśnie grida.
11.1 Widok
Definicja grida odbywa się poprzez obiekt rozszerzający klasę Ext.grid.Panel. Żródło danych grida określone jest poprzez właściwość store. W tym przypadku store jednak nie będzie zasilany z zewnątrz, a tylko lokalnie będzie przechowywał dane, które zostaną dołączone do danych przesyłanych przez formularz edycyjny w celu zapisania nowego połączenia kolejowego z listą stacji pośrednich.
Istotną cechą tego grida jest to, iż ma być on edytowalny tzn. klikając na dany wiersz możemy przejść do edycji poszczególnych jego komórek. Ext JS zapewnia taką funkcjonalność poprzez wbudowany plugin (Ext.grid.plugin.RowEditing), który musimy aktywować.
W dalszej części definiujemy kolumny grida podobnie jak to miało miejsce w przypadku TreeGrida. Za pomocą opcji allowBlank możemy dodać walidację wartości zapisywanych w danej kolumnie. Jeśli ustawimy tę właściwość na false to wartość ta będzie wymagana i w przypadku jej braku pojawi się błąd walidacji. Kolumna typu actioncolumn definiuję przycisk umożliwiający usunięcie danej stacji z listy. Jest ona usuwana z lokalnego store. Nie powoduje to wysyłania żądania do serwera.
Ostatnim elementem grida jest toolbar. W tym pasku narzędziowym umieścimy dwa przyciski. Pierwsze będzie dodawał nowy wiersz do grida (metoda store.insert) i automatycznie wywoływał jego edycję (metoda rowEditing.startEdit). Drugi przycisk pozwala wyczyścić grida ze wszystkich danych (metoda store.removeAll).
Ext.define('TrainTimetableApp.view.StationGrid', {
extend: 'Ext.grid.Panel',
alias: 'widget.StationGrid',
initComponent: function () {
var self = this;
this.id = "stationGrid";
this.minHeight = 222;
this.store = "TrainTimetableApp.store.StationStore";
var rowEditing = Ext.create('Ext.grid.plugin.RowEditing', {
clicksToMoveEditor: 1,
autoCancel: false
});
this.plugins = [rowEditing];
this.columns = [
{
header: 'Station',
dataIndex: 'station',
flex: 4,
editor: {
allowBlank: true
}
},
{
header: 'Arrival time',
dataIndex: 'arrivalTime',
flex: 2,
editor: {
allowBlank: true
}
},
{
header: 'Departure time',
dataIndex: 'departureTime',
flex: 2,
editor: {
allowBlank: true
}
},
{
xtype: 'actioncolumn',
header: 'Delete',
width: 150,
align: 'center',
items: [
{
icon: '/app/images/delete.png',
handler: function (grid, rowIndex, colIndex, item, e,
record) {
grid.getStore().remove(record);
},
scope: this
}
]
}
];
this.tbar = [
{
xtype: 'button',
text: 'Add new item',
handler: function () {
rowEditing.cancelEdit();
var newRecord =
Ext.create("TrainTimetableApp.model.StationModel", {});
var rowNumber = self.store.getCount();
self.store.insert(rowNumber, newRecord);
rowEditing.startEdit(rowNumber, 0);
}
},
{
xtype: 'button',
text: 'Clear items',
handler: function () {
rowEditing.cancelEdit();
self.store.removeAll();
}
}
]
this.callParent(arguments);
}
});
11.2 Model danych
Model danych dla grida definiujemy jako klasę rozszerzającą Ext.data.Model. Jak zwykle model zawiera listę pól, które będą jego składowymi.
Ext.define('TrainTimetableApp.model.StationModel', {
extend: 'Ext.data.Model',
fields: [
'station',
'arrivalTime',
'departureTime'
]
});
11.3 Store
Store również nie wyróżnia się niczym specjalnym od tego, który już widzieliśmy. Różnica w stosunku do TreeGridStore’a polega przede wszystkim na tym, iż w tym przypadku dane odczytywane i przechowywane są w pamięci i store w żaden sposób nie komunikuje się z serwerem.
Ext.define('TrainTimetableApp.store.StationStore', {
extend: 'Ext.data.Store',
autoDestroy: true,
model: 'TrainTimetableApp.model.StationModel',
proxy: {
type: 'memory'
},
data: []
});
12. Podsumowanie
Przechodząc wszystkie powyższe kroki uzyskujemy prostą aplikację bazodanową wykorzystującą Entity Framework. Mamy do dyspozycji mechanizm migracji i inicjalizacji danych. Możemy swobodnie dodawać kolejne encje do modelu danych i je modyfikować na różne sposoby. Za pomocą dwóch poleceń: add-migration oraz update-database możemy w dowolnym momencie wygenerować skrypty migracyjne i zaktualizować bazę danych do bieżącego stanu zgodnego z modelem danych zaimplementowanym w kodzie.
Pokazaliśmy także jak wykorzystać Web API 2 do przygotowania zasobów udostępnianych z wykorzystaniem różnych metod protokołu http. Zasoby te mogą być konsumowane przez inne aplikacje lub też przez stronę klienta odpowiedzialną za działanie interfejsu użytkownika. W naszym przypadku są to komponenty frameworka Ext JS oraz zwykłe komponenty HTML5 oprogramowane z użyciem frameworka Knockout JS. Zaprezentowaliśmy również jak zaimplementować kontenery danych i modele danych w Ext JS, które są niezbędne do tego, aby korzystać z komponentów przechowujących i procesujących dane aplikacji.
Gotowa aplikacja wygląda jak na poniższym rysunku:
Rysunek nr 9. Gotowa aplikacja.
Kod źródłowy aplikacji dostępny jest tutaj.
Autorem artykułu jest Michał Koźlik, Senior Developer w Q-PERIOR sp. z o.o.