Client and Server§
So far, we've just been declaring our source data statically, and the order information generated by our application doesn't go anywhere.
Let's fix this, and take a look at fetching data from a "server", and pushing data back to it.
We're going to mock the server, hence the quotes. In practice, you can use your favourite server communication protocol and library, or not use a server at all: the tools you see here could be used to, for example, interface with
localStorage
.
Contents
Getting Data§
There is an entire Janus subsystem that provides data request services, for both reading and writing. We are going to skip over most of it to start, and go straight to using the highest-level, most convenient tools.
// "server": //! new!
const getInventory = (callback) => {
const data = [
{ name: 'Green Potion', price: 60 },
{ name: 'Red Potion', price: 120 },
{ name: 'Blue Potion', price: 160 }
];
setTimeout(callback.bind(null, data), 300);
};
// resolvers: //! new!
class InventoryRequest extends Request {};
const inventoryResolver = (request) => {
const result = new Varying(types.result.pending());
getInventory(inventory => {
result.set(types.result.success(Inventory.deserialize(inventory)));
});
return result;
};
// models:
class Item extends Model {};
const Inventory = List.of(Item);
const Sale = Model.build(
//! we define the inventory attribute as a Reference to one of our new classes
attribute('inventory', attribute.Reference.to(new InventoryRequest())),
//! and we base the actual order off of that, accounting for the case where
// we don't yet have the inventory
bind('order', from('inventory').map(inventory =>
(inventory == null) ? new List()
: inventory.map(item => item.shadow(OrderedItem))))
);
const product = (x, y) => x * y;
class OrderedItem extends Model.build(
attribute('order-qty', attribute.Number),
bind('order-subtotal', from('price').and('order-qty').all.map(product)),
initial('action-qty', 1, attribute.Number),
bind('action-subtotal', from('price').and('action-qty').all.map(product))
) {
order() { this.set('order-qty', this.get_('order-qty') + this.get_('action-qty')); }
}
// views:
const itemCommon = (prefix) => template(
find('.name').text(from('name')),
find('.qty').render(from.attribute(`${prefix}-qty`)),
find('.subtotal').text(from(`${prefix}-subtotal`))
);
const ItemOrdererView = DomView.build(
$(`<div><span class="qty"/>x <span class="name"/> @<span class="price"/>
<button>Order (<span class="subtotal"/>)</button></div>`),
template(
itemCommon('action'),
find('.price').text(from('price')),
find('button').on('click', (event, item) => { item.order(); })
)
);
const OrderedItemView = DomView.build(
$('<div><span class="qty"/>x <span class="name"/> (<span class="subtotal"/>)</div>'),
itemCommon('order')
);
const SaleView = DomView.build($(`
<div>
<h1>Inventory</h1> <div class="inventory"/>
<h1>Order</h1> <div class="order"/>
<h1>Order Total</h1> <div class="total"/>
</div>`),
template(
find('.inventory').render(from('order'))
.options({ renderItem: (item => item.context('orderer')) }),
find('.order').render(from('order').map(order =>
order.filter(orderedItem => orderedItem.get('order-qty').map(qty => qty > 0)))),
find('.total').text(from('order').flatMap(order =>
order.flatMap(orderedItem => orderedItem.get('order-subtotal')).sum()))
)
);
// application assembly:
const app = new App();
stdlib.view($).registerWith(app.views);
app.views.register(OrderedItem, ItemOrdererView, { context: 'orderer' });
app.views.register(OrderedItem, OrderedItemView);
app.views.register(Sale, SaleView);
//! an additional registration, this time for our "resolver" things
app.resolvers.register(InventoryRequest, inventoryResolver);
const view = app.view(new Sale());
view.wireEvents();
return view;
Inventory
Order
Order Total
We've added code at the top, but it all appears to be standalone, so let's come back to it. Let's instead start with the changes at the bottom, and work our way up.
First, we've gotten rid of the static data. We've even gotten rid of the static
declaration of our sale
, instead just piping it straight to app.view
. Everything
is now driven directly by the Sale
Model itself, so we need less setup hand-holding
to inject the correct state. So, let's look at Sale
.
The binding for order
, mapping out of inventory
, hasn't fundamentally changed.
But, we've now added a quick null
-check, because we have to account for the case
where we don't actually have an inventory yet. Rather than just bind out null
given null
, we fall back on an empty new List()
, so we don't end up cascading
the need for a null
-check through the whole stack.
So let's look at where inventory
comes from. Just above the order
binding,
we declare an attribute inventory
, which is an attribute.Reference.to
a new
instance of an InventoryRequest
. Now we've gotten back to that new code up top.
So, what's a Request? For one, it's just a classtype we can map resources to, just
like Model and Attribute classtypes can have associated Views. If we look at the
bottom of the code sample, we'll see that indeed we .register
the inventoryResolver
against the InventoryRequest
, but under app.resolvers
rather than app.views
.
In fact, there's not much special about the
Request
class at all, and you do not have to inherit fromRequest
when creating your own.
The other purpose of a Request object is to store parameters about the request, but we aren't apparently using that here. We will in a moment. For now, let's turn our attention to resolvers, and what they are.
A resolver is a function that takes a Request
, and gives back a Varying[types.result[T]]
if it can, or else null
if it can't. We know what a Varying
is by now. We've
also seen types
before, when we dealt with validation.
It is a case class, a sort of enum container type which encodes a known result
type while holding data of some arbitrary type. Back with types.validation
, we
encoded validation results like valid
or error
, while carrying detailed information
about the exact error. In this case, we encode request statuses like pending
,
progress
, success
, or failure
, while carrying data relevant to these states.
If you are familiar with
Future
s orPromise
s, this is how Janus manages that task: we've already built the entire framework around the concept of a value that might change over time. If we can just imbue that concept with the semantics of a typical asynchronous process, we can leverage all the nice tools we've built around Varying when dealing with asynchronous results.
Janus only cares about the overall encoded type; but it does care. Try changing
the types.result.success
to a types.result.failure
and see what happens. It
all just disappears, because the attribute.Reference
understands enough about
the result type to only pass on the carried value if it is a success
.
So, somehow given this Reference
, Janus understands to go get the relevant resolver
function for its Request
type, call it, and put the result back into the Model
in the appropriate spot if it was a success
.
But, when?
class DataRequest extends Request {};
const dataResolver = () => new Varying(types.result.success(42));
const Thing = Model.build(
attribute('data', attribute.Reference.to(new DataRequest())),
bind('data2', from('data'))
);
const app = new App();
app.resolvers.register(DataRequest, dataResolver);
return inspect.panel(new Thing());
Nothing happens, even with the bind
referencing it like before. Well, that's
not too surprising if you look at the code: nothing ever connects the app
to
the thing
. In Janus, the only mechanism that automatically threads app context
is the tree of Views, as built through .render
mutators.
Is there a way to cause these References to resolve without constructing a View?
class DataRequest extends Request {};
const dataResolver = () => new Varying(types.result.success(42));
const Thing = Model.build(
attribute('data', attribute.Reference.to(new DataRequest())),
bind('data2', from('data'))
);
const app = new App();
app.resolvers.register(DataRequest, dataResolver);
const thing = new Thing();
thing.attribute('data').resolveWith(app);
return inspect.panel(thing);
All the data management behind References are contained within the Reference attribute.
It does the work of understanding when the data it could provide is required, initiating
that process, and assigning the result value as appropriate. All it needs is an
App with which to actually resolve its Request. Getting the Reference attribute
with .attribute
and calling .resolveWith(app)
will do this, and this is exactly
what App
itself does, when it is given a Model via app.view
: it combs through
its attributes and calls .resolveWith
when it finds it, in case the View depends
on some Referenced data.
It's worth diving into something mentioned offhand just now: even given an App, Reference will not actually resolve its data unless it sees that someone actually wants it.
class DataRequest extends Request {};
const dataResolver = () => new Varying(types.result.success(42));
const Thing = Model.build(
attribute('data', attribute.Reference.to(new DataRequest()))
);
const app = new App();
app.resolvers.register(DataRequest, dataResolver);
const thing = new Thing();
thing.attribute('data').resolveWith(app);
thing.get('data');
return thing.get_('data');
How does it know? Apparently not on .get
nor on .get_
. No, only when the Varying
returned by .get
is actually observed will the Reference decide that its data
is important. This is explain with more precision—you guessed it, over in the
theory chapter about References.
Reactive References§
But what if we need to parameterize our request? What does that look like?
// "server":
const getInventory = (type, callback) => { //! now we take different types
const data = {
potions: [
{ name: 'Green Potion', price: 60 },
{ name: 'Red Potion', price: 120 },
{ name: 'Blue Potion', price: 160 }
],
equipment: [
{ name: 'Blue Mail', price: 250 },
{ name: 'Red Mail', price: 400 }
]
};
setTimeout(callback.bind(null, data[type]), 300);
};
// resolvers:
class InventoryRequest extends Request {};
const inventoryResolver = (request) => {
const result = new Varying(types.result.pending());
getInventory(request.options.type, inventory => { //! we pass type through here
result.set(types.result.success(Inventory.deserialize(inventory)));
});
return result;
};
// models:
class Item extends Model {};
const Inventory = List.of(Item);
const Sale = Model.build(
attribute('type', class extends attribute.Enum { //! we define possible types
initial() { return 'potions'; }
_values() { return [ 'potions', 'equipment' ]; }
}),
attribute('inventory', attribute.Reference.to(from('type')
.map(type => new InventoryRequest({ type })))), //! and base the Request off it
bind('order', from('inventory').map(inventory =>
(inventory == null) ? new List()
: inventory.map(item => item.shadow(OrderedItem))))
);
const product = (x, y) => x * y;
class OrderedItem extends Model.build(
attribute('order-qty', attribute.Number),
bind('order-subtotal', from('price').and('order-qty').all.map(product)),
initial('action-qty', 1, attribute.Number),
bind('action-subtotal', from('price').and('action-qty').all.map(product))
) {
order() { this.set('order-qty', this.get_('order-qty') + this.get_('action-qty')); }
}
// views:
const itemCommon = (prefix) => template(
find('.name').text(from('name')),
find('.qty').render(from.attribute(`${prefix}-qty`)),
find('.subtotal').text(from(`${prefix}-subtotal`))
);
const ItemOrdererView = DomView.build(
$(`<div><span class="qty"/>x <span class="name"/> @<span class="price"/>
<button>Order (<span class="subtotal"/>)</button></div>`),
template(
itemCommon('action'),
find('.price').text(from('price')),
find('button').on('click', (event, item) => { item.order(); })
)
);
const OrderedItemView = DomView.build(
$('<div><span class="qty"/>x <span class="name"/> (<span class="subtotal"/>)</div>'),
itemCommon('order')
);
const SaleView = DomView.build($(`
<div>
<div class="type"/>
<h1>Inventory</h1> <div class="inventory"/>
<h1>Order</h1> <div class="order"/>
<h1>Order Total</h1> <div class="total"/>
</div>`),
template(
//! and we let the user choose a type
find('.type').render(from.attribute('type')),
find('.inventory').render(from('order'))
.options({ renderItem: (item => item.context('orderer')) }),
find('.order').render(from('order').map(order =>
order.filter(orderedItem => orderedItem.get('order-qty').map(qty => qty > 0)))),
find('.total').text(from('order').flatMap(order =>
order.flatMap(orderedItem => orderedItem.get('order-subtotal')).sum()))
)
);
// application assembly:
const app = new App();
stdlib.view($).registerWith(app.views);
app.views.register(OrderedItem, ItemOrdererView, { context: 'orderer' });
app.views.register(OrderedItem, OrderedItemView);
app.views.register(Sale, SaleView);
app.resolvers.register(InventoryRequest, inventoryResolver);
const view = app.view(new Sale());
view.wireEvents();
return view;
Inventory
Order
Order Total
A remarkably straightforward change. We updated our "server", of course, but in
Janus-land all we had to do was define a type
Enum on our Sale, bind it through
to our Reference, which is now .to
a from
expression that yields a Request
rather than directly to a Request instance, draw the dropdown on screen, and we're
done.
Of course, there is a quirk that when you choose a new type
the sale resets,
but you should know enough by now to imagine ways to solve this, and doing so is
outside the scope of our current discussion.
And so in Janus, because all these operations are functional mappings that tie to data, it becomes very easy to map user input into remote data requests of various parameterizations, the result of which maps back into the interface.
One thing remains clunky. Every time we request inventory data, we do it over from scratch, even if we've just done it recently. In fact, if (as is commonly the case) more than one point in your application relies on the same piece of foreign data, a separate request will be made for each one.
We need some kind of caching layer.
Higher-Order Resolvers and Caching§
To add caching, we're going to need to be more specific on how we'd like Request resolution to work. App lets us do this. We're also going to have to better explain what our Request actually means.
// "server":
const getInventory = (type, callback) => {
const data = {
potions: [
{ name: 'Green Potion', price: 60 },
{ name: 'Red Potion', price: 120 },
{ name: 'Blue Potion', price: 160 }
],
equipment: [
{ name: 'Blue Mail', price: 250 },
{ name: 'Red Mail', price: 400 }
]
};
setTimeout(callback.bind(null, data[type]), 2000);
};
// resolvers:
class InventoryRequest extends Request { //! we implement some methods here
get type() { return types.operation.read(); }
signature() { return this.options.type; }
expires() { return 10; }
};
const inventoryResolver = (request) => {
const result = new Varying(types.result.pending());
getInventory(request.options.type, inventory => {
result.set(types.result.success(Inventory.deserialize(inventory)));
});
return result;
};
// models:
class Item extends Model {};
const Inventory = List.of(Item);
const Sale = Model.build(
attribute('type', class extends attribute.Enum {
initial() { return 'potions'; }
_values() { return [ 'potions', 'equipment' ]; }
}),
attribute('inventory', attribute.Reference.to(from('type')
.map(type => new InventoryRequest({ type })))),
bind('order', from('inventory').map(inventory =>
(inventory == null) ? new List()
: inventory.map(item => item.shadow(OrderedItem))))
);
const product = (x, y) => x * y;
class OrderedItem extends Model.build(
attribute('order-qty', attribute.Number),
bind('order-subtotal', from('price').and('order-qty').all.map(product)),
initial('action-qty', 1, attribute.Number),
bind('action-subtotal', from('price').and('action-qty').all.map(product))
) {
order() { this.set('order-qty', this.get_('order-qty') + this.get_('action-qty')); }
}
// views:
const itemCommon = (prefix) => template(
find('.name').text(from('name')),
find('.qty').render(from.attribute(`${prefix}-qty`)),
find('.subtotal').text(from(`${prefix}-subtotal`))
);
const ItemOrdererView = DomView.build(
$(`<div><span class="qty"/>x <span class="name"/> @<span class="price"/>
<button>Order (<span class="subtotal"/>)</button></div>`),
template(
itemCommon('action'),
find('.price').text(from('price')),
find('button').on('click', (event, item) => { item.order(); })
)
);
const OrderedItemView = DomView.build(
$('<div><span class="qty"/>x <span class="name"/> (<span class="subtotal"/>)</div>'),
itemCommon('order')
);
const SaleView = DomView.build($(`
<div>
<div class="type"/>
<h1>Inventory</h1> <div class="inventory"/>
<h1>Order</h1> <div class="order"/>
<h1>Order Total</h1> <div class="total"/>
</div>`),
template(
find('.type').render(from.attribute('type')),
find('.inventory').render(from('order'))
.options({ renderItem: (item => item.context('orderer')) }),
find('.order').render(from('order').map(order =>
order.filter(orderedItem => orderedItem.get('order-qty').map(qty => qty > 0)))),
find('.total').text(from('order').flatMap(order =>
order.flatMap(orderedItem => orderedItem.get('order-subtotal')).sum()))
)
);
// application:
class ShopApp extends App { //! and we override the resolver() method of App here
resolver() {
return Resolver.caching(new Resolver.MemoryCache(),
Resolver.fromLibrary(this.resolvers));
}
}
// application assembly:
const app = new ShopApp();
stdlib.view($).registerWith(app.views);
app.views.register(OrderedItem, ItemOrdererView, { context: 'orderer' });
app.views.register(OrderedItem, OrderedItemView);
app.views.register(Sale, SaleView);
app.resolvers.register(InventoryRequest, inventoryResolver);
const view = app.view(new Sale());
view.wireEvents();
return view;
Inventory
Order
Order Total
You can see that the first load of each inventory type takes some time, since we bumped the delay up to three full seconds. But once each one is cached, then for a little while you can flip back and forth instantaneously, because the data is being read from cache rather than the "server".
This works because we've modified our App, overriding resolver()
to use the
MemoryCache
module as a caching layer on top of the Library-based approach it
employs by default. But the Memory Cache needs to understand what to cache, and
for how long; we do this by implementing some methods on our derived Request
.
These methods are the only thing that make Request what it its; if you do not intend to use the MemoryCache, there is no reason to derive from
Request
at all.
The signature
is a cache key, and we also define when the cached data expires
,
though expiry is optional. The cache needs to understand the type
of operation
the Request represents as well, so that it can respond appropriately: clearing
the cache on delete
, updating it on update
, and so on.
But there's no reason you need to use the provided cache at all. These are all
just functions, and you can do whatever you want as long as you fulfill the function
contract, which remember is simply Request => Varying[types.result[T]]?
.
const inventoryCache = {};
const inventoryCacher = (request) => inventoryCache[request.type] || null;
class SaleApp extends App {
resolver() { return Resolver.oneOf(inventoryCacher, Resolver.fromLibrary(this.resolvers)); }
}
Hmm. This is a problem. We can use Resolver.oneOf
, which
just tries resolver functions until it gets a non-null result. But while we can
easily read from our cache, the caching function needs to see the eventual result
of the Request resolution in order to actually write to its cache.
This is what Resolver.caching
does, which we already
used above to set up the built-in memory cache. The caching()
resolver relies
on a really basic interface to communicate with any caching module:
let idx = 0;
const mockResolver = (request) => new Varying(types.result.success(++idx));
class InventoryRequest {
constructor(type) { this.type = type; }
}
class InventoryCache {
constructor() { this.data = {}; }
resolve(request) { return this.data[request.type] || null; }
cache(request, result) { this.data[request.type] = result; }
}
class SaleApp extends App {
resolver() {
return Resolver.caching(new InventoryCache(), mockResolver);
}
}
const app = new SaleApp();
return [
app.resolve(new InventoryRequest('potions')),
app.resolve(new InventoryRequest('equipment')),
app.resolve(new InventoryRequest('potions'))
].map(inspect);
The
app.resolve
method is a bit odd; it only gets called once per instance of anApp
, and its result is cached thereafter. Because of this, we can just instantiate theInventoryCache
inline and it is reused appropriately.
So we devised our own caching solution, tailored to our own needs, that didn't
involve those signature
or operation
interfaces at all. If we really wanted
to, we could even do away with Resolver.caching
, because all Janus ever expects
is a function Request => Varying[types.result[T]]?
.
An Exercise
Take this custom approach and adapt it into the sample above in place of the memory cache.
Note how we just accept the Varying
that some other resolver spat out, store
it as-is, and hand it right back. It's the same Varying
instance. This means
that if some rogue code were to make changes to the result of the request, that
change will reflect everywhere.
This can be a good thing! But in general, Reference
attributes are so-called
for a reason. They are meant to be thought of as a computed value, as directly
bound to their inputs as bind
s are. The fact that they are resolved across a
network (or some other I/O gap) are a detail we must unfortunately deal with mechanically,
but we shouldn't think of them semantically any differently.
Sending Data Back§
The use of app.resolve
in the sample above might hint at how we might send our
orders back to a server for processing.
// "server":
const getInventory = (type, callback) => {
const data = {
potions: [
{ name: 'Green Potion', price: 60 },
{ name: 'Red Potion', price: 120 },
{ name: 'Blue Potion', price: 160 }
],
equipment: [
{ name: 'Blue Mail', price: 250 },
{ name: 'Red Mail', price: 400 }
]
};
setTimeout(callback.bind(null, data[type]), 2000);
};
const makeOrder = (items, callback) => { //! a new "server" API
// do something with items.
setTimeout(callback.bind(null, { success: true }), 1000);
};
// resolvers:
class InventoryRequest extends Request {
get type() { return types.operation.read(); }
signature() { return this.options.type; }
expires() { return 10; }
};
const inventoryResolver = (request) => {
const result = new Varying(types.result.pending());
getInventory(request.options.type, inventory => {
result.set(types.result.success(Inventory.deserialize(inventory)));
});
return result;
};
class OrderRequest extends Request {}; //! and a new Request/resolver pair
const orderResolver = (request) => {
const result = new Varying(types.result.pending());
makeOrder(request.options.items, () => { result.set(types.result.success()); });
return result;
};
// models:
class Item extends Model {};
const Inventory = List.of(Item);
const Sale = Model.build(
attribute('type', class extends attribute.Enum {
initial() { return 'potions'; }
_values() { return [ 'potions', 'equipment' ]; }
}),
attribute('inventory', attribute.Reference.to(from('type')
.map(type => new InventoryRequest({ type })))),
bind('order', from('inventory').map(inventory =>
(inventory == null) ? new List()
: inventory.map(item => item.shadow(OrderedItem))))
);
const product = (x, y) => x * y;
class OrderedItem extends Model.build(
attribute('order-qty', attribute.Number),
bind('order-subtotal', from('price').and('order-qty').all.map(product)),
initial('action-qty', 1, attribute.Number),
bind('action-subtotal', from('price').and('action-qty').all.map(product))
) {
order() { this.set('order-qty', this.get_('order-qty') + this.get_('action-qty')); }
}
// views:
const itemCommon = (prefix) => template(
find('.name').text(from('name')),
find('.qty').render(from.attribute(`${prefix}-qty`)),
find('.subtotal').text(from(`${prefix}-subtotal`))
);
const ItemOrdererView = DomView.build(
$(`<div><span class="qty"/>x <span class="name"/> @<span class="price"/>
<button>Order (<span class="subtotal"/>)</button></div>`),
template(
itemCommon('action'),
find('.price').text(from('price')),
find('button').on('click', (event, item) => { item.order(); })
)
);
const OrderedItemView = DomView.build(
$('<div><span class="qty"/>x <span class="name"/> (<span class="subtotal"/>)</div>'),
itemCommon('order')
);
const SaleViewModel = Model.build(
bind('ordered-items', from.subject('order').map(order =>
order.filter(orderedItem => orderedItem.get('order-qty').map(qty => qty > 0))))
);
const SaleView = DomView.build(SaleViewModel, $(`
<div>
<div class="type"/>
<h1>Inventory</h1> <div class="inventory"/>
<h1>Order</h1> <div class="order"/>
<h1>Order Total</h1> <div class="total"/>
<button>Order</button>
</div>`),
template(
find('.type').render(from.attribute('type')),
find('.inventory').render(from('order'))
.options({ renderItem: (item => item.context('orderer')) }),
find('.order').render(from.vm('ordered-items')),
find('.total').text(from('order').flatMap(order =>
order.flatMap(orderedItem => orderedItem.get('order-subtotal')).sum())),
//! we add an Order button for the whole Sale, allow it to work when there
// /is/ an order, and send off the OrderRequest
find('button')
.prop('disabled', from.vm('ordered-items').flatMap(items =>
items.length.map(l => l === 0)))
.on('click', (event, sale, view) => {
const items = view.vm.get_('ordered-items').serialize();
view.options.app.resolve(new OrderRequest({ items }));
})
)
);
// application:
class ShopApp extends App {
resolver() {
return Resolver.caching(new Resolver.MemoryCache(),
Resolver.fromLibrary(this.resolvers));
}
}
// application assembly:
const app = new ShopApp();
stdlib.view($).registerWith(app.views);
app.views.register(OrderedItem, ItemOrdererView, { context: 'orderer' });
app.views.register(OrderedItem, OrderedItemView);
app.views.register(Sale, SaleView);
app.resolvers.register(InventoryRequest, inventoryResolver);
app.resolvers.register(OrderRequest, orderResolver);
const view = app.view(new Sale());
view.wireEvents();
return view;
Inventory
Order
Order Total
…okay, so nothing you haven't seen before, but this isn't terribly spectacular in action. Nothing really happens. Let's recycle the Sale once it's been sent, and start a new one over.
// "server":
const getInventory = (type, callback) => {
const data = {
potions: [
{ name: 'Green Potion', price: 60 },
{ name: 'Red Potion', price: 120 },
{ name: 'Blue Potion', price: 160 }
],
equipment: [
{ name: 'Blue Mail', price: 250 },
{ name: 'Red Mail', price: 400 }
]
};
setTimeout(callback.bind(null, data[type]), 2000);
};
const makeOrder = (items, callback) => {
// do something with items.
setTimeout(callback.bind(null, { success: true }), 1000);
};
// resolvers:
class InventoryRequest extends Request {
get type() { return types.operation.read(); }
signature() { return this.options.type; }
expires() { return 10; }
};
const inventoryResolver = (request) => {
const result = new Varying(types.result.pending());
getInventory(request.options.type, inventory => {
result.set(types.result.success(Inventory.deserialize(inventory)));
});
return result;
};
class OrderRequest extends Request {};
const orderResolver = (request) => {
const result = new Varying(types.result.pending());
makeOrder(request.options.items, () => { result.set(types.result.success()); });
return result;
};
// models:
class Item extends Model {};
const Inventory = List.of(Item);
const Sale = Model.build(
attribute('type', class extends attribute.Enum {
initial() { return 'potions'; }
_values() { return [ 'potions', 'equipment' ]; }
}),
attribute('inventory', attribute.Reference.to(from('type')
.map(type => new InventoryRequest({ type })))),
bind('order', from('inventory').map(inventory =>
(inventory == null) ? new List()
: inventory.map(item => item.shadow(OrderedItem))))
);
const product = (x, y) => x * y;
class OrderedItem extends Model.build(
attribute('order-qty', attribute.Number),
bind('order-subtotal', from('price').and('order-qty').all.map(product)),
initial('action-qty', 1, attribute.Number),
bind('action-subtotal', from('price').and('action-qty').all.map(product))
) {
order() { this.set('order-qty', this.get_('order-qty') + this.get_('action-qty')); }
}
// views:
const itemCommon = (prefix) => template(
find('.name').text(from('name')),
find('.qty').render(from.attribute(`${prefix}-qty`)),
find('.subtotal').text(from(`${prefix}-subtotal`))
);
const ItemOrdererView = DomView.build(
$(`<div><span class="qty"/>x <span class="name"/> @<span class="price"/>
<button>Order (<span class="subtotal"/>)</button></div>`),
template(
itemCommon('action'),
find('.price').text(from('price')),
find('button').on('click', (event, item) => { item.order(); })
)
);
const OrderedItemView = DomView.build(
$('<div><span class="qty"/>x <span class="name"/> (<span class="subtotal"/>)</div>'),
itemCommon('order')
);
const SaleViewModel = Model.build(
bind('ordered-items', from.subject('order').map(order =>
order.filter(orderedItem => orderedItem.get('order-qty').map(qty => qty > 0))))
);
const SaleView = DomView.build(SaleViewModel, $(`
<div>
<div class="type"/>
<h1>Inventory</h1> <div class="inventory"/>
<h1>Order</h1> <div class="order"/>
<h1>Order Total</h1> <div class="total"/>
<button>Order</button>
</div>`),
template(
find('.type').render(from.attribute('type')),
find('.inventory').render(from('order'))
.options({ renderItem: (item => item.context('orderer')) }),
find('.order').render(from.vm('ordered-items')),
find('.total').text(from('order').flatMap(order =>
order.flatMap(orderedItem => orderedItem.get('order-subtotal')).sum())),
find('button')
.prop('disabled', from.vm('ordered-items').flatMap(items =>
items.length.map(l => l === 0)))
.on('click', (event, sale, view) => {
const app = view.options.app;
const items = view.vm.get_('ordered-items').serialize();
//! we actually pay attention to the fate of our OrderRequest now,
// and reset the Sale if it went through
app.resolve(new OrderRequest({ items })).react(status => {
if (types.result.success.match(status)) app.newSale();
});
})
)
);
// application:
class ShopApp extends App {
//! we store the current Sale on the App now, so we implement a simple method
// to actually set/reset that Sale
newSale() { this.set('sale', new Sale()); }
resolver() {
return Resolver.caching(new Resolver.MemoryCache(),
Resolver.fromLibrary(this.resolvers));
}
}
//! a simple wrapper to render the Sale within our App
const ShopView = DomView.build($('<div/>'), find('div').render(from('sale')));
// application assembly:
const app = new ShopApp();
stdlib.view($).registerWith(app.views);
app.views.register(OrderedItem, ItemOrdererView, { context: 'orderer' });
app.views.register(OrderedItem, OrderedItemView);
app.views.register(Sale, SaleView);
app.views.register(App, ShopView);
app.resolvers.register(InventoryRequest, inventoryResolver);
app.resolvers.register(OrderRequest, orderResolver);
app.newSale(); //! here we call that method
const view = app.view(app);
view.wireEvents();
return view;
Inventory
Order
Order Total
Well, at least now we have proof that things are happening. The main things here
that are new are the .react
method, which is how you observe
changes to a Varying, and the .match
method of cases, which
returns a boolean if the given case matches the given type.
The first main thing worth noting about Varying#react
is that you can stop the
reaction using the returned Observation ticket:
const results = [];
const v = new Varying(1);
const observation = v.react(x => { results.push(x); });
v.set(2);
v.set(3);
observation.stop();
v.set(4);
return inspect(results);
You'll notice that the initial value of 1
is included in the results as well.
This has been true this whole time; you'll seldom have to use .react
in most
cases, but behind the scenes when Janus puts your various bindings and declarations
to work .react
is what it uses, and in general if you write a rule that should
always apply, that ought to include the initial state of the value.
If you're sure the initial value does not matter, you can pass a Boolean parameter
at the start of .react
:
const results = [];
const v = new Varying(1);
v.react(false, x => { results.push(x); });
v.set(2);
v.set(3);
return results;
Tracking App Events§
But it would be better still if we had some indication that things were in process. Let's add a loading spinner mechanism. We are going to omit the bulk of the sample code here, as it has not changed at all.
// application:
class ShopApp extends App.build(
//! some new schema bits to track and count Requests
attribute('requests', attribute.List.withInitial()),
bind('requesting', from('requests').flatMap(requests => requests.nonEmpty()))
) {
_initialize() {
//! and some event listening to actually do that tracking
const requests = this.get_('requests');
this.on('resolvedRequest', (request, result) => {
requests.add(request);
result.react(function(status) {
if (types.result.complete.match(status)) {
requests.remove(request);
this.stop();
}
});
});
}
newSale() { this.set('sale', new Sale()); }
resolver() {
return Resolver.caching(new Resolver.MemoryCache(),
Resolver.fromLibrary(this.resolvers));
}
}
const ShopView = DomView.build($('<div/>'), find('div').render(from('sale')));
// application assembly:
const app = new ShopApp();
stdlib.view($).registerWith(app.views);
app.views.register(OrderedItem, ItemOrdererView, { context: 'orderer' });
app.views.register(OrderedItem, OrderedItemView);
app.views.register(Sale, SaleView);
app.views.register(App, ShopView);
app.resolvers.register(InventoryRequest, inventoryResolver);
app.resolvers.register(OrderRequest, orderResolver);
app.newSale();
const view = app.view(app);
view.wireEvents();
return view;
Inventory
Order
Order Total
Finally, some old-fashioned events!
App emits two possible events: createdView
when it creates a view from app.view
,
and resolvedRequest
when it resolves a request from app.resolve
.
By taking advantage of App's pervasive presence and these events, you can do a
lot of very powerful operations in a centralized location. Here, we listen to all
resolved requests and track their completion. We do this in the App _initialize
method, which you can override in any Model (most Janus extendables, really), to
do setup operations when an instance is created.
When a request comes up, we add it to the List. Because we're paying attention
to the length of the list via .nonEmpty
, this triggers the loading state of the
interface (implemented in CSS off the requesting
class). When the request is
complete
(which includes either success
or failure
), we remove it again.
We also showcase the other way to stop a reaction: if you use a traditional function
,
you can just use the fact that this
is bound to the observation and call this.stop()
.
Recap§
In this chapter, you've seen how Janus handles I/O and server communication. We introduced some new things here in service of that, but very few of them are exclusively useful in an I/O context.
- Requests give us a classtype with which to reason about different requests.
There is no reason you have to derive from
Request
. - Resolvers are functions
Request => Varying[types.result[T]]?
.- If the resolver can't resolve the given Request, the convention is to return
null
. Some built-in resolver helpers likeoneOf
andcaching
understand this result value. - We use the returned Varying like a Future or a Promise are commonly used. It is the job of the resolver function to populate the Varying with the result.
- If the resolver can't resolve the given Request, the convention is to return
- The Reference attribute type on a Model allows you to define a Request that
references some foreign piece of data that should exist on the Model.
- It is not resolved unless the attribute sees that it is needed.
- The Request can be the result of a
from
expression bound to other Model properties.
- When References are not appropriate, for example when sending data back to the
server, you can use
app.resolve
to manually resolve a Request. - The built-in caching mechanisms, like the MemoryCache, should work for most situations, but there is no reason you must use them.
We've also taken a look at some of the things you can do with App, taking advantage of its perspective over your whole application.
- App emits events for
createdView
andresolvedRequest
uponapp.view
andapp.resolve
, respectively.- These are used via the standard
.on
/.off
/etc EventEmitter methods. - You can use these events to build things like global spinners, or inject behaviour into all rendered Views.
- These are used via the standard
- In our case, we also used a View bound to App itself as the root View of the application, to provide a box around which we could insert different Sales.
There are some random things we picked up:
- Varying has a
.react
method that allows observation of values.- It returns an Observation ticket with a
.stop
method. - Alternatively, you can pass a traditional
function
to.react
, in which case you can callthis.stop()
from within the function body. - You can pass a boolean
false
as the first parameter if you don't care about the initial value.
- It returns an Observation ticket with a
- The
_initialize
method is a useful way to do basic setup when an instance of a Model is created.
Next Up§
We're almost done with our practical tour.
Most of what we've left unexplored lives on the server-side: one of the unique strengths of Janus is how it's built to allow your application to adapt to different contexts, most particularly between the client and the server. It's why, for example, the App and its Libraries work the way that they do.
While we explore that, we're going to swing back around to some of the things we've glossed over in driving to this point.
Don't worry, the sample has gotten as long as it's going to get. We'll actually pare it back a little bit in the next chapter, as you just saw.
When you're feeling ready for the last big push, click here.