This advanced guide walks through building a simple credit card payment form with validations. It doesn’t use
can-define. Instead it uses Kefir.js streams to make a ViewModel.
can-kefir is used to make the Kefir streams observable to can-stache.
Click on the form so those inputs lose focus. The
Pay button should become enabled.
Click the Pay button to see the Pay button disabled for 2 seconds.
Change the inputs to invalid values. An error message should appear,
the invalid inputs should be highlighted red, and the Pay
button should become disabled.
START THIS TUTORIAL BY CLICKING THE “EDIT ON CODEPEN” BUTTON IN THE TOP RIGHT CORNER OF THE FOLLOWING EMBED::
This CodePen has initial prototype HTML and CSS which is useful for
getting the application to look right.
The following sections are broken down into:
The problem — A description of what the section is trying to accomplish.
What you need to know — Information about CanJS that is useful for solving the problem.
The solution — The solution to the problem.
The following video walks through the entire guide; it was recorded for CanJS 3,
but most of the same basic info applies:
Setup
The problem
We are going to try an alternate form of the basic CanJS setup. We
will have an StacheElement with cc-payment as a custom tag.
The component properties are all Kefir.js
streams.
We will render the static content in the component view, but use a
constant stream to hold the amount value.
What you need to know
Kefir.js allows you to create streams
of events and transform those streams into other streams. For example,
the following numbers stream produces three numbers with interval of 100 milliseconds:
const numbers = Kefir.sequentially(100,[1,2,3]);
Now let’s create another stream based on the first one. As you might guess,
it will produce 2, 4, and 6.
Users will be able to enter a card number like 1234-1234-1234-1234.
Let’s read the card number entered by the user, print it back,
and also print back the cleaned card number (the entered number with no dashes).
What you need to know
can-kefir adds an emitterProperty method that returns a
Kefir property, but also adds an emitter object with with .value() and .error() methods. The end result is a single object that has methods of a stream and property access to its emitter methods.
import{ kefir as Kefir }from"//unpkg.com/can@6/ecosystem.mjs";const age = Kefir.emitterProperty();
age.onValue(function(age){
console.log(age)});
age.emitter.value(20)//-> logs 20
age.emitter.value(30)//-> logs 30
emitterProperty property streams are useful data sinks when getting
user data.
Kefir streams and properties have a map method
that maps values on one stream to values in a new stream:
const source = Kefir.sequentially(100,[1,2,3]);const result = source.map(x=> x +1);// source: ---1---2---3X// result: ---2---3---4X
<input on:input:value:to="KEY"/> Listens to the input events produced
by the <input> element and writes the <input>’s value to KEY.
can-kefir allows you to write to a emitterProperty’s with:
<inputvalue:to="emitterProperty.value">
The solution
Update the JavaScript tab to:
import{ kefir as Kefir, StacheElement }from"//unpkg.com/can@6/ecosystem.mjs";
As someone types a card number, let’s show the user a warning message
about what they need to enter for the card number. It should go away
if the card number is 16 characters.
What you need to know
Add the cardError message above the input like:
<divclass="message">{{cardError.value}}</div>
Validate a card with:
functionvalidateCard(card){if(!card){return"There is no card"}if(card.length !==16){return"There should be 16 characters in a card";}}
The solution
Update the JavaScript tab to:
import{ kefir as Kefir, StacheElement }from"//unpkg.com/can@6/ecosystem.mjs";
});},getcardError(){returnthis.cardNumber.map(this.validateCard);}};validateCard(card){if(!card){return"There is no card";}if(card.length !==16){return"There should be 16 characters in a card";}}}
customElements.define("cc-payment", CCPayment);
Only show the card error when blurred
The problem
Let’s only show the cardNumber error if the user blurs the
card number input. Once the user blurs, we will update the card number error,
if there is one, on every keystroke.
We should also add class="is-error" to the input when it has an error.
For this to work, we will need to track if the user has blurred
the input in a userCardNumberBlurredemitterProperty.
What you need to know
We can call an emitterProperty’s value in the template when something happens like:
One of the most useful patterns in constructing streams is the event-reducer
pattern. On a high-level it involves making streams events, and using those
events to update a stateful object.
For example, we might have a first and a last stream:
const first = Kefir.sequentially(100,["Justin","Ramiya"])const last = Kefir.sequentially(100,["Shah","Meyer"]).delay(50);// first: ---Justin---RamiyaX// last: ------Shah__---Meyer_X
We can promote these to event-like objects with .map:
const firstEvents = first.map(first=>{return{ type:"first", value: first };});const lastEvents = first.map(last=>{return{ type:"last", value: last };});// firstEvents: ---{t:"f"}---{t:"f"}X// lastEvents: ------{t:"l"}---{t:"l"}X
Note:fullName can be derived more simply from Kefir.combine. The reducer
pattern is used here for illustrative purposes. It is able to support a larger
set of stream transformations than Kefir.combine.
On any stream, you can call stream.toProperty() to return a property that
will retain its values. This can be useful if you want a stream’s immediate value.
The solution
Update the JavaScript tab to:
import{ kefir as Kefir, StacheElement }from"//unpkg.com/can@6/ecosystem.mjs";
Let’s make the expiry input element just like the cardNumber
element. The expiry should be entered like 12-17 and be stored as an
array like ["12", "16"]. Make sure to:
validate the expiry
show a warning validation message in a <div class="message"> element
add class="is-error" to the element if we should show the expiry error.
What you need to know
Use expiry.split("-") to convert what a user typed into an array of numbers.
To validate the expiry use:
functionvalidateExpiry(expiry){if(!expiry){return"There is no expiry. Format MM-YY";}if(expiry.length !==2|| expiry[0].length !==2|| expiry[1].length !==2){return"Expiry must be formatted like MM-YY";}}
if(!card){return"There is no card";}if(card.length !==16){return"There should be 16 characters in a card";
}}validateExpiry(expiry){if(!expiry){return"There is no expiry. Format MM-YY";}if(
expiry.length !==2||
expiry[0].length !==2||
expiry[1].length !==2){return"Expiry must be formatted like MM-YY";}}showOnlyWhenBlurredOnce(errorStream, blurredStream){const errorEvent = errorStream.map(error=>{
Let’s make the CVC input element just like the cardNumber and expiry
element. Make sure to:
validate the cvc
show a warning validation message in a <div class="message"> element
add class="is-error" to the element if we should show the CVC error.
What you need to know
The cvc can be saved as whatever the user entered. No special processing necessary.
To validate CVC:
functionvalidateCVC(cvc){if(!cvc){return"There is no CVC code";}if(cvc.length !==3){return"The CVC must be at least 3 numbers";}if(Number.isNaN(parseInt(cvc))){return"The CVC must be numbers";}}
if(!card){return"There is no card";}if(card.length !==16){return"There should be 16 characters in a card";}}validateExpiry(expiry){if(!expiry){return"There is no expiry. Format MM-YY";}if(
expiry.length !==2||
expiry[0].length !==2||
expiry[1].length !==2){return"Expiry must be formatted like MM-YY";
}}validateCVC(cvc){if(!cvc){return"There is no CVC code";}if(cvc.length !==3){return"The CVC must be at least 3 numbers";}if(Number.isNaN(parseInt(cvc))){return"The CVC must be numbers";}}showOnlyWhenBlurredOnce(errorStream, blurredStream){const errorEvent = errorStream.map(error=>{
if(!card){return"There is no card";}if(card.length !==16){return"There should be 16 characters in a card";}}validateExpiry(expiry){if(!expiry){return"There is no expiry. Format MM-YY";}if(
expiry.length !==2||
expiry[0].length !==2||
expiry[1].length !==2){return"Expiry must be formatted like MM-YY";}}validateCVC(cvc){if(!cvc){return"There is no CVC code";}if(cvc.length !==3){return"The CVC must be at least 3 numbers";}if(isNaN(parseInt(cvc))){return"The CVC must be numbers";}}showOnlyWhenBlurredOnce(errorStream, blurredStream){const errorEvent = errorStream.map(error=>{if(!error){return{
type:"valid"};}else{return{
type:"invalid",
message: error
};}});const focusEvents = blurredStream.map(isBlurred=>{if(isBlurred ===undefined){return{};}return isBlurred
?{
type:"blurred"}:{
type:"focused"};});return Kefir.merge([errorEvent, focusEvents]).scan((previous, event)=>{switch(event.type){case"valid":return Object.assign({}, previous,{
isValid:true,
showCardError:false});case"invalid":return Object.assign({}, previous,{
isValid:false,
showCardError: previous.hasBeenBlurred
});case"blurred":return Object.assign({}, previous,{
hasBeenBlurred:true,
showCardError:!previous.isValid
});default:return previous;}},{
hasBeenBlurred:false,
showCardError:false,
isValid:false}).map(state=>{return state.showCardError;});}}
customElements.define("cc-payment", CCPayment);
Implement the payment button
The problem
When the user submits the form, let’s simulate making a 2 second AJAX
request to create a payment. While the request is being made,
we will change the Pay button to say Paying.
What you need to know
Use the following to create a Promise that takes 2 seconds to resolve:
Use on:event to listen to an event on an element and call a method in can-stache. For example, the following calls doSomething() when the <div> is clicked:
);},getcard(){return Kefir.combine([this.cardNumber,this.expiry,this.cvc],(cardNumber, expiry, cvc)=>{return{ cardNumber, expiry, cvc };});},// STREAM< Promise<Number> | undefined >getpaymentPromises(){return Kefir.combine([this.payClicked],[this.card],(payClicked, card)=>{if(payClicked){
console.log("Asking for token with", card);returnnewPromise(resolve=>{setTimeout(()=>{resolve(1000);},2000);});}});},// STREAM< STREAM<STATUS> >// This is a stream of streams of status objects.getpaymentStatusStream(){returnthis.paymentPromises.map(promise=>{if(promise){// STREAM<STATUS>return Kefir.concat([
Kefir.constant({
status:"pending"}),
Kefir.fromPromise(promise).map(value=>{return{
status:"resolved",
value: value
};})]);}else{// STREAMreturn Kefir.constant({
status:"waiting"});}});},// STREAM<STATUS> //{status: "waiting"} | {status: "resolved"}getpaymentStatus(){returnthis.paymentStatusStream.flatMap().toProperty();}};pay(event){
event.preventDefault();this.payClicked.emitter.value(true);}validateCard(card){if(!card){
return"There is no card";}if(card.length !==16){return"There should be 16 characters in a card";}}validateExpiry(expiry){if(!expiry){return"There is no expiry. Format MM-YY";}if(
expiry.length !==2||
expiry[0].length !==2||
expiry[1].length !==2){return"Expiry must be formatted like MM-YY";}}validateCVC(cvc){if(!cvc){return"There is no CVC code";}if(cvc.length !==3){return"The CVC must be at least 3 numbers";}if(isNaN(parseInt(cvc))){return"The CVC must be numbers";}}showOnlyWhenBlurredOnce(errorStream, blurredStream){const errorEvent = errorStream.map(error=>{if(!error){return{
type:"valid"};}else{return{
type:"invalid",
message: error
};}});const focusEvents = blurredStream.map(isBlurred=>{if(isBlurred ===undefined){return{};}return isBlurred
?{
type:"blurred"}:{
type:"focused"};});return Kefir.merge([errorEvent, focusEvents]).scan((previous, event)=>{switch(event.type){case"valid":return Object.assign({}, previous,{
isValid:true,
showCardError:false});case"invalid":return Object.assign({}, previous,{
isValid:false,
showCardError: previous.hasBeenBlurred
});case"blurred":return Object.assign({}, previous,{
hasBeenBlurred:true,
showCardError:!previous.isValid
});default:return previous;}},{
hasBeenBlurred:false,
showCardError:false,
isValid:false}).map(state=>{return state.showCardError;});}}
customElements.define("cc-payment", CCPayment);
Disable the payment button while payments are pending
The problem
Let’s prevent the Pay button from being clicked while the payment is processing.
this.payClicked.emitter.value(true);}validateCard(card){if(!card){return"There is no card";}if(card.length !==16){return"There should be 16 characters in a card";}}validateExpiry(expiry){if(!expiry){return"There is no expiry. Format MM-YY";}if(
expiry.length !==2||
expiry[0].length !==2||
expiry[1].length !==2){return"Expiry must be formatted like MM-YY";}}validateCVC(cvc){if(!cvc){return"There is no CVC code";}if(cvc.length !==3){return"The CVC must be at least 3 numbers";}if(isNaN(parseInt(cvc))){return"The CVC must be numbers";}}showOnlyWhenBlurredOnce(errorStream, blurredStream){const errorEvent = errorStream.map(error=>{if(!error){return{
type:"valid"};}else{return{
type:"invalid",
message: error
};}});const focusEvents = blurredStream.map(isBlurred=>{if(isBlurred ===undefined){return{};}return isBlurred
?{
type:"blurred"}:{
type:"focused"};});return Kefir.merge([errorEvent, focusEvents]).scan((previous, event)=>{switch(event.type){case"valid":return Object.assign({}, previous,{
isValid:true,
showCardError:false});case"invalid":return Object.assign({}, previous,{
isValid:false,
showCardError: previous.hasBeenBlurred
});case"blurred":return Object.assign({}, previous,{
hasBeenBlurred:true,
showCardError:!previous.isValid
});default:return previous;}},{
hasBeenBlurred:false,
showCardError:false,
isValid:false}).map(state=>{return state.showCardError;});}}
customElements.define("cc-payment", CCPayment);
Result
When complete, you should have a working credit card payment form like the following CodePen: