Just a little sample using Saturn, Turbolinks and htmx
NOTE:this requires a mongodb server running
git clone
dotnet restore
dotnet watch run --no-restore
While the login is all about htmx/turbolinks, the Products page is a blend between js/ssr possible approaches
check Views\Pages\Products\Products.html
and Handlers.fs:481 (aprox.)
for more information.
This approach makes heavy use of WebComponents (with LitElement since it's the most friendly and the one that works just with esmodules in the browser (i.e no need for extra toolchain setup)).
Both spc-products-list
and spc-product-list-item
are webcomponents and must be defined somewhere in the JS files, they can be added on a general script file (i.e. a script that works just to import components) or import the specified script for the specified page
<article>
<h1>Sending static HTML</h1>
<h2>Making it dynamic with web components on the front</h2>
<!-- Send everything in JSON format and let the component take care of everything from the start -->
<spc-products-list page="{{ page }}" limit="{{ limit }}" paginated='{{ serialized }}'></spc-products-list>
<!-- Send the "wrapper" plus the content server side rendered at the beginning -->
<spc-products-list page="{{ page }}" limit="{{ limit }}" paginated='{{ with_count_only }}' has-ssr-content>
{{ for product in items.list }}
<!-- Render the web components -->
<spc-product-list-item product='{{ product }}'></spc-product-list-item>
{{ end }}
</spc-products-list>
</article>
For the JS Web Component I'll add what I believe to be the relevant parts and omit the rest, for more information check wwwroot\WebComponents\Products.js
import {
LitElement,
html,
css
} from "https://unpkg.com/lit-element@2.4.0/lit-element.js?module";
import { get } from "../utils.js";
class ProductList extends LitElement {
static get properties() {
return {
paginated: { type: Object },
// NOTE: reflect: true is not necessary
page: {type: Number, reflect: true },
// I use it here just to update the attributes on the browser to see when the component updates the values
limit: {type: Number, reflect: true },
hasSsrContent: {type: Boolean, reflect: true, attribute: 'has-ssr-content'},
// NOTE: attribute false means that these are internal properties and their values
// are used to update the view in one way or another, so they must be tracked by LitElement
detailedProduct: { type: Object, attribute: false },
hasRequested: {type: Number, attribute: false }
};
}
// previous/next work in the same way just backwards/forwards I'll omit _prev for those reasons
async _next(e) {
try {
const result = await get(`/api/products?page=${(this.page || 1) + 1}&limit=${this.limit || 5}`)
// we just requested json data
// remove any server side rendered stuff to show the new data right away
if (this.hasSsrContent && this.defaultSlot) {
// NOTE: if the default slot is not removed,
// it shows stale data and possibly hide the new data
// since slots have preference over the internal content of the element
this.defaultSlot.remove();
this.hasSsrContent = false;
}
this.paginated = { ...result };
this.page += 1;
} catch (error) {
console.warn({ error });
}
}
render() {
// Leverage the browser's event bubbling to catch events up and prevent callback drills
// @selected-product is dispatched from ProductListItem (product-list-item)
// @unselected-product is dispatched from ProductItemDetail (product-item-detail)
return html`
<article
@selected-product="${(e) => (this.detailedProduct = e.detail)}"
@unselect-product="${(e) => (this.detailedProduct = null)}">
<section class="product-list">
${this.paginated.list.length > 0 ?
html`
${this.paginated.list.map(this.getItem)}`
: null
}
<slot></slot>
</section>
${this.detailedProduct ? this.detailedTemplate(this.detailedProduct) : null}
</article>
<aside class="pagination">
<p>Showing ${this.showing} of ${this.paginated.count} products</p>
<button .disabled="${this.isPrevDisabled}" @click="${this._previous}">Prev</button>
<button .disabled="${this.isNextDisabled}" @click="${this._next}">Next</button>
</aside>
`;
}
}
while turbolinks/htmx is a really nice way to work stuff, there may still a couple of things you need to take care about, like dynamic parts of your web app (e.g. navbar, sidebars, close buttons, etc.) which requires you to use JS anyways (check wwwroot\index.js
for more information.) I feel a little bit un-ergonomic handling everything from the server, specially things that can be highly dynamic.
the second approach that I'm linking is to create a bunch of web components for things that have to be dynamic and render almost all of it from the server, you just need to prepare your web component to be ready and be aware to know what to do once dynamic stuff on the client is required, like in the example above which is fetching json to get a new page
Depending on how your views are defined and which browsers you need to support, you can get away most of the time with the second approach you may still need some polyfills though
Products collection
{
"_id" : ObjectId("5fea2dd19ddb6b3624258f2c"),
"name" : "Web Cam2",
"price" : 20.57
},
{
"_id" : ObjectId("5fea2dd19ddb6b3624258f2b"),
"name" : "Speakers2",
"price" : 45.3
},
{
"_id" : ObjectId("5fea2dd19ddb6b3624258f2a"),
"name" : "Tv2",
"price" : 1200.2
},
{
"_id" : ObjectId("5fea2dd19ddb6b3624258f29"),
"name" : "Coffee Mug2",
"price" : 5.36
},
{
"_id" : ObjectId("5fea2dd19ddb6b3624258f28"),
"name" : "Mouse2",
"price" : 60.28
},
{
"_id" : ObjectId("5fea2dd19ddb6b3624258f27"),
"name" : "Keyboard2",
"price" : 100
},
{
"_id" : ObjectId("5fea2dd19ddb6b3624258f26"),
"name" : "Paper2",
"price" : 1.2
},
{
"_id" : ObjectId("5fea2dd19ddb6b3624258f25"),
"name" : "Beef2",
"price" : 10.2
},
{
"_id" : ObjectId("5fea2dd19ddb6b3624258f24"),
"name" : "Shoes2",
"price" : 20.57
},
{
"_id" : ObjectId("5fea2dd19ddb6b3624258f23"),
"name" : "Soap2",
"price" : 2.2
},
{
"_id" : ObjectId("5fea2db99ddb6b3624258f22"),
"name" : "Web Cam",
"price" : 20.57
},
{
"_id" : ObjectId("5fea2db99ddb6b3624258f21"),
"name" : "Speakers",
"price" : 45.3
},
{
"_id" : ObjectId("5fea2db99ddb6b3624258f20"),
"name" : "Tv",
"price" : 1200.2
},
{
"_id" : ObjectId("5fea2db99ddb6b3624258f1f"),
"name" : "Coffee Mug",
"price" : 5.36
},
{
"_id" : ObjectId("5fea2db99ddb6b3624258f1e"),
"name" : "Mouse",
"price" : 60.28
},
{
"_id" : ObjectId("5fea2db99ddb6b3624258f1d"),
"name" : "Keyboard",
"price" : 100
},
{
"_id" : ObjectId("5fea2db99ddb6b3624258f1c"),
"name" : "Paper",
"price" : 1.2
},
{
"_id" : ObjectId("5fea2db99ddb6b3624258f1b"),
"name" : "Beef",
"price" : 10.2
},
{
"_id" : ObjectId("5fea2db99ddb6b3624258f1a"),
"name" : "Shoes",
"price" : 20.57
},
{
"_id" : ObjectId("5fea2db99ddb6b3624258f19"),
"name" : "Soap",
"price" : 2.2
}