Een tijd geleden was ik op zoek naar een dashboard en kwam ik op de site van "Cockpit": link, een headless CMS die je zelf kunt draaien en via een API kunt benaderen. De broncode kun je hier op Github vinden: link. Het is een PHP-project, omdat ik zelf dit in het .NET Framework 4.7.2 ging draaien, heb ik zelf mijn eigen Models, Views en Controllers gemaakt, op basis van de bestaande PHP-code. De HTML-interface, met het Riot.js framework heb ik zoveel mogelijk intact gehouden. Riot.js kun je hier online vinden: link. Riot.js wordt simpel en elegant genoemd, maar omdat het toch wel flink wat code is, ik nog iets qua afhankelijkheid met jQuery zie (misschien is dat optioneel), wil ik het zelf "vanaf 0" opzetten met Knockout. Knockout (JS) kun je hier vinden: link.
Misschien had ik ook met Riot.js vanaf 0 kunnen starten, als je namelijk op de site van Riot.js kijkt, zie je dat het een hele lichte codebibliotheek is, kleiner dan angular, vue en react. Dus als jij op zoek bent naar een compact javascript framework, misschien is het wel geschikt voor jouw project.
In ieder geval, ik doe het met Knockout. Het is een item wat voorbij kwam in podcast 243 van Scott Hanselman: link. Bij TRES is het in gebruik genomen door een programmeur die tijdelijk ingehuurd was, Harold Uitslag. In de backoffice van de ticketing-applicatie kunnen ticketing medewerkers een abonnement aan een supporter toevoegen. Daarbij doorloop je een aantal stappen, welk deel van het stadion, welk type kaart (jeugd, volwassen, 65+), de adres-/persoonsgegevens, de betaling (via een factuur, via iDeal, contante betaling). Al die invoervelden wil je natuurlijk niet in één pagina hebben. Dus je krijgt een soort "wizard", in het eerste tabblad de stadion-gegevens, in het tweede tabblad de persoonsgegevens, in het derde tabblad de betaalgegevens, in het vierde tabblad een overzicht van de ingevulde waardes en het laatste tabblad: succes melding en eventueel aanvullende gegevens (ordernummer wat aangemaakt is o.i.d.). Het was voor mij een eye-opener hoe dit ook kan. En daarom wil ik dit nu zelf in praktijk brengen bij een eigen project.
"Vroeger" werden die gegevens stuk voor stuk naar de server gestuurd en kreeg je data terug. Dus keek je even naar een wit scherm en kon je naar de volgende stap. Met ajax is dat niet meer nodig. Je post "op de achtergrond" data naar de server en je krijgt informatie terug die je dan weer in de interface kunt tonen. Een veel gebruikersvriendelijkere werkwijze. Maar als je niet gebruik maakt van knockout, dan moet je zelf alle "on-click"-acties afvangen. Moet je zelf zorgen dat velden gevuld/getoond worden als iemand naar een volgend tabblad gaat of terug gaat naar een vorig tabblad. Dan kun je een hele hoop "spaghetti-code" krijgen.
Met knockout maak je gebruik van "data-binding". Hierdoor kun je bepaalde delen van het scherm verbergen als aan bepaalde voorwaarden niet voldaan wordt. En kun je bepaalde attributen koppelen aan variabelen. Zo koppel je een variabele lastName aan het invoerveld waar iemand zijn/haar achternaam in moet vullen. Die gegevens komen terug in het 4e tabblad, daar kun je een span-element toevoegen waarbij je het text-attribuut koppelt aan de variabele lastName. Elke keer als die waarde verandert, wordt automatisch jouw weergave van de waarde bijgewerkt. Ik werd hier erg blij van :)
Mijn mantra is altijd: je website/web-applicatie moet altijd werken, ook als javascript uitgeschakeld is. Javascript is "alleen" maar voor functionaliteit om de werking mooier/beter te maken, maar het mag nooit de flow van je programma blokkeren. Als je een formulier wilt versturen, maar dat werkt alleen als javascript jouw submit afvangt en er dan extra acties op uitvoert, dan doe je het fout. Want je wilt je website voor iedereen beschikbaar maken, jongeren, volwassenen, ouderen, maar ook visueel beperkte of blinde bezoekers. Deze persoon zou je site kunnen bezoeken via een soort tekst-gebaseerde browser. En mogelijk ondersteunt deze geen javascript. En dan zou deze persoon niet via het contactformulier een berichtje naar jou kunnen sturen? Ik dacht het niet!
Een goed voorbeeld van "extra functionaliteit" met javascript is het inlogscherm van Cockpit. In het PHP-project worden je ingevulde gebruikersnaam en wachtwoord naar een API gestuurd. Zijn je inloggegevens onjuist, dan begint je inlogscherm te trillen. Gaat er iets anders fout, dan krijg je een nette foutmelding in een pop-up (die zelf na 2 seconden verdwijnt). Als je inloggegevens correct zijn, dan wordt het inlog-panel verborgen en wordt het andere panel getoond, daarin staat het bericht "welcome back", met daarboven een gestyled rondje (een canvas) met de initialen van de ingelogde persoon. En vervolgens ga je automatisch naar het dashboard.
Bovenstaande zijn een paar kleine stukken HTML die je extra binnen krijgt en dat kan prima. Maar je zult ook andere situaties tegen komen, je zult dus de afweging moeten maken: doe ik dit server-side of laat ik het client-side (via javascript) afhandelen? Als je normale inlogscherm 1 MB groot zou zijn en die extra melding ook 1 MB, dan zou je elke keer als je naar het inlogscherm gaat 2 MB aan data binnen halen. Terwijl je server-side ervoor zou kunnen zorgen dat alleen het inlogschermdeel van 1 MB teruggegeven wordt en het andere deel na de post.
Ik heb het Cockpit-project voor een aantal eigen zaken gebruikt, de standaard PHP met een eigen voorkant voor het tonen van een lijst: alle concerten en theaters die ik in de toekomst ga bezoeken met daarbij hoeveel dagen het nog duurt. Goed voor de voorpret. Voor een overzicht van al mijn "TODO" acties. En het .NET Framework 4.7.2. project, waar ik een koppeling gemaakt heb met de API van Fitbit. Maar toen kwam later de melding van Endomondo dat ze gingen stoppen: die export-data heb ik er ook bij gekoppeld. En eigenlijk wil ik mijn gegevens van Strava en Polar hier ook nog in krijgen. Mijn project moet dus meer module-gericht worden, dus ik pak het nu meteen goed aan en maak er een .NET Core 3.1 project van.
Ik laat hier zien hoe ik het inlogscherm opgebouwd heb, je kunt de PHP-code van Cockpit hier bekijken: link.
@{
Layout = "~/Views/Shared/Layout.cshtml";
}
<div class="uk-position-relative login-container uk-animation-scale uk-container-vertical-center" role="main">
<form class="uk-form" method="post" action="/api/account/authenticate" data-bind="submit: onSubmit">
@Html.AntiForgeryToken()
<div class="uk-panel-space uk-nbfc uk-text-center uk-animation-slide-bottom" data-bind="if: user">
<p>
<cp-gravatar data-bind="attr:{'email': user().email, alt: user().name}" size="80"></cp-gravatar>
</p>
<hr class="uk-width-1-2 uk-container-center">
<p class="uk-text-center uk-text-bold uk-text-muted uk-text-upper uk-margin-top">
Goed je weer te zien!
</p>
</div>
<div data-bind="ifnot: user">
<div id="login-dialog" data-bind="attr:{class:loginDialogClass}">
<div name="header" class="uk-panel-space uk-text-bold uk-text-center">
<div class="uk-margin login-image"></div>
<h2 class="uk-text-bold uk-text-truncate"><span>Cockpit</span></h2>
</div>
<div class="uk-form-row">
<label class="uk-text-small uk-text-bold uk-text-upper uk-margin-small-bottom">Gebruikersnaam</label>
<input data-bind="value: username" name="user" class="uk-form-large uk-width-1-1" type="text" aria-label="E-mailadres" placeholder="" autofocus required>
</div>
<div class="uk-form-row">
<div class="uk-form-password uk-width-1-1">
<label class="uk-text-small uk-text-bold uk-text-upper uk-margin-small-bottom">Wachtwoord</label>
<input data-bind="value: password" name="password" class="uk-form-large uk-width-1-1" type="password" aria-label="Wachtwoord" placeholder="" required>
</div>
</div>
<div class="uk-margin-large-top">
<button class="uk-button uk-button-outline uk-button-large uk-text-primary uk-width-1-1">Inloggen</button>
</div>
</div>
</div>
<p class="uk-text-center">
<a class="uk-button uk-button-link uk-link-muted" href="/">Terug naar het dashboard</a>
</p>
</form>
</div>
@section FooterScripts{
<script src="/js/http.js" type="text/javascript"></script>
<script src="/js/application.js" type="text/javascript"></script>
<script src="/js/avatar.js" type="text/javascript"></script>
<script type="text/javascript">
(function(){
document.onkeypress = function(e){
try{
if(e.keyCode == 13){
setTimeout(function(){viewModel.onSubmit();},500);
}
}catch(x){}
return true;
}
var viewModel = {
user : ko.observable(false),
username: ko.observable(''),
password: ko.observable(''),
bodyClass : 'login-page uk-height-viewport uk-flex uk-flex-middle uk-flex-center',
loginDialogClassInitial: 'login-dialog uk-panel-box uk-panel-space uk-nbfc',
loginDialogClass: ko.observable('login-dialog uk-panel-box uk-panel-space uk-nbfc'),
onSubmit: function(){
var data = new FormData();
data.append('user', this.username());
data.append('password', this.password());
data.append('__RequestVerificationToken', getCsrfValue());
/*htmlApp.request('/api/account/authenticate', {
auth : {user:this.username(), password:this.password()},
csfr : getCsrfValue()*/
htmlApp.request('/api/account/authenticate', data).then(function(data) {
if (data && data.success) {
viewModel.user(data.user);
setTimeout(function(){
htmlApp.reroute('/');
},2000);
replaceGravatarTags();
} else {
viewModel.loginDialogClass(viewModel.loginDialogClassInitial);
setTimeout(function(){
viewModel.loginDialogClass(viewModel.loginDialogClassInitial+' uk-animation-shake');
}, 50);
}
}, function(res) {
htmlApplication.notify(res && (res.message || res.error) ? (res.message || res.error) : 'Login failed.', 'danger');
});
return false;
}
};
ko.applyBindings(viewModel);
})();
function getCsrfValue()
{
var inputs = document.getElementsByName('__RequestVerificationToken');
for (var k=0; k < inputs.length; k++){
return inputs[k].value;
}
}
</script>
}
De knockout JS delen:
Je ziet dat met data-bind="if: user" je kunt zorgen dat de DIV alleen getoond wordt als iemand ingelogd is: standaard is user false. Ik heb aan de login-dialog een class-property gekoppeld, zodat bij foutieve inloggegevens een class uk-animation-shake toegevoegd kan worden (mogelijk kan dit anders). En je ziet dat de attributen gekoppeld zijn aan de tag cp-gravatar waarmee je de initialen in een canvas te zien krijgt op het moment dat iemand ingelogd is.
Verschil qua aanroep:
Je ziet in het PHP-bestand dat de post als JSON over de lijn gaat en dat er 2 variabelen mee gaan, de auth waarin username en wachtwoord staan en csrf, het unieke teken waarmee je kunt controleren/valideren dat niet iemand vanaf zijn eigen computer jouw inlog-validatie aan het testen is, dus een "brute-force" actie. In PHP moet dat in eigen code gevalideerd worden, maar .NET biedt dit "out-of-the-box" aan. In je C# code (binnen de FORM-tag) plaats je een @Html.AntiForgeryToken() en in de functie in de controller die je gebruikt om gebruikersnaam en wachtwoord te controleren voeg je het attribuut [ValidateAntiForgeryToken] toe. Maar daar zit wel een beperking aan, je moet jouw formulier dan als een "normaal" formulier posten, dat werkt niet met een JSON-post. Daarom zie je dat ik een variabele data vul met alle waardes (var data = new FormData(); data.append("user", this.username());). Het voordeel hiervan is wél dat als javascript uit staat en het formulier "normaal" gepost wordt, hij eigenlijk exact hetzelfde doet als via je knockout-submit.
[HttpPost]
[ValidateAntiForgeryToken]
[Route("authenticate")]
public async Task<IActionResult> Authenticate()
{
var authenticationRequest = new CockpitAuthenticationRequest() { UserData = new UserRequestData() { User=Request.Form["user"], Password=Request.Form["password"] } };
var loginResult = LoginUser(authenticationRequest);if (loginResult.Success)
{
await SetUserCookie(loginResult.User.CockpitAccount);
}if (Request.Headers.ContainsKey("X-Requested-With") && Request.Headers["X-Requested-With"] == "XMLHttpRequest")
{
return new JsonResult(loginResult);
}
return new RedirectResult("/");
}
Hier boven zie je een deel van de code die de post verwerkt. Als je knockout (dus via een ajax-request) de data binnen komt, dan moet je JSON terug geven. Maar als je het formulier op een "normale" manier verstuurt, dan moet je een redirect doen naar het dashboard. Je ziet dat dit simpel te controleren is, in een ajax-request wordt een extra header X-Requested-With: XMLHttpRequest meegestuurd. Dan dus JSON, anders een redirect.
Binnenkort met lynx (een tekst-gebaseerde browser) controleren of het werkt!