House Hunter

Eight filters, one query, and a shortlist that remembers you.

Twelve listings live in the properties collection of this CMS. Search, filter, sort, expand a card, shortlist a few, and ask for a viewing - the request is written to a collection that publishes create and nothing else.

Live API call /api/v1/properties

Properties

Searching…

Listed · EPC

Nothing matches that. Widen the price, or clear a filter.

Request a viewing

The form is a query

Every control on that form is an observable. One computed reads all eight of them and produces the query object; one effect watches that computed and makes the request. There is no change handler on any input, and no code anywhere that says "when the town changes, refresh the results" - that relationship is the computed reading town.value.

apps/house-hunter.js - eight observables in, one query outview whole file
/**
 * The whole form as one server query.
 *
 * Every filter the visitor has touched contributes a clause; the ones they
 * have not are absent rather than empty, because "no minimum" is not the
 * same request as "minimum zero". This is a `computed`, so it recomputes
 * when any one of eight observables changes and is otherwise never rebuilt.
 */
const query = computed(() => {
    const [field, direction] = sort.value.split(':');
    const filter = {};

    if (town.value) filter.town = town.value;
    if (propertyType.value) filter.propertyType = propertyType.value;
    if (maxPrice.value) filter.price_lte = maxPrice.value;
    if (Number(minBeds.value) > 0) filter.bedrooms_gte = minBeds.value;
    if (garden.value) filter.garden = true;
    if (parking.value) filter.parking = true;
    if (chainFree.value) filter.chainFree = true;

    return {
        filter,
        search: search.value.trim() || undefined,
        sort: field,
        order: direction,
        limit: 50
    };
});

The filters a visitor has not touched are absent rather than empty, because "no minimum" is a different request from "minimum zero". Watch the Live API call line while you use the form: that is the URL, and it is built by the same computed that drives the search.

apps/house-hunter.js - one effect, one dependencyview whole file
/**
 * One effect, one dependency: the query. Every input on the form reaches
 * the server through it, and a request that has been superseded can never
 * land on the page - the token check drops it.
 */
let token = 0;

effect(() => {
    const current = query.value;
    const mine = ++token;

    lastQuery.value = `GET /api/v1/properties${buildQuery(current)}`;
    loading.value = true;
    error.value = '';

    properties.list(current)
        .then(({items, total: count}) => {
            if (mine !== token) return;
            results.set(items);
            total.value = count;
        })
        .catch(err => {
            if (mine !== token) return;
            error.value = `The search failed: ${err.message}`;
            results.removeAll();
        })
        .finally(() => {
            if (mine === token) loading.value = false;
        });
});

The token check is the part every search box needs and most forget: type quickly and requests can land out of order, and a response that has been superseded must never reach the page.

The search box does not need a debounce timer

const search = observable('').extend({rateLimit: 300});

That is the debounce. The write always lands immediately - the input never lags behind your typing - and only the notification waits for you to stop. Knockout's original throttle delayed the write, which is why reading a throttled observable used to give you a stale value; that mistake is not repeated here.

Every card is a component

apps/house-hunter.js - the property cardview whole file
/**
 * One listing. The card owns whether it is expanded - ten cards on a page have
 * ten of those, and none can see the others, which is the whole reason to reach
 * for a component rather than an `expandedId` held on the list.
 *
 * `shortlist` arrives as the observable array itself, so the card writes to the
 * page's shortlist directly; `property` arrives as a plain object, because the
 * expression that passed it (`$data`) evaluated to one. The markup says which
 * of those two things happened, which is the point.
 */
registerComponent('property-card', {
    template: `
        <article class="card property">
            <figure class="property-photo">
                <img data-bind-src="property.image" data-bind-alt="property.title"
                     width="1200" height="750" loading="lazy">
                <figcaption>{{property.imageCredit}}</figcaption>
            </figure>

            <header class="property-head">
                <h4>{{property.title}}</h4>
                <p class="property-price" data-currency="property.price"></p>
            </header>

            <p class="property-where">{{property.street}}, {{property.town}}</p>

            <ul class="property-facts">
                <li>{{property.bedrooms}} bed</li>
                <li>{{property.bathrooms}} bath</li>
                <li>{{property.propertyType}}</li>
                <li>{{property.tenure}}</li>
            </ul>

            <p class="property-summary" data-if="expanded.value">{{property.summary}}</p>

            <div class="property-actions">
                <button class="btn btn-sm btn-ghost" data-on-click="toggle" data-bind-text="more.value"></button>
                <button class="btn btn-sm" data-on-click="shortlistIt" data-bind-text="saveLabel.value"
                        data-bind-class="saved.value && 'btn-success'"></button>
                <button class="btn btn-sm btn-primary" data-on-click="view">Book a viewing</button>
            </div>

            {{#slot footnote}}{{/slot}}
        </article>`,

    create(params) {
        const expanded = observable(false);
        const saved = computed(() => params.shortlist.value.some(p => p.id === params.property.id));

        return {
            property: params.property,
            expanded,
            saved,
            more: computed(() => (expanded.value ? 'Less' : 'More')),
            saveLabel: computed(() => (saved.value ? 'Shortlisted' : 'Shortlist')),

            toggle() { expanded.value = !expanded.value; },
            view() { params.onView(params.property); },

            shortlistIt() {
                if (saved.value) params.shortlist.remove(p => p.id === params.property.id);
                else params.shortlist.push(params.property);
            }
        };
    }
});

Expanding a card is private to that card. Twelve cards have twelve expanded observables and none can see the others, which is the whole reason to reach for a component rather than an expandedId held on the list.

The two params are worth comparing, because the markup says which is which:

<div data-component="'property-card'"
     data-param-property="$data"
     data-param-shortlist="$parent.shortlist"
     data-param-on-view="$parent.startViewing"></div>

$data evaluates to a plain object, so the card gets a snapshot of the listing. $parent.shortlist evaluates to the observable array itself, so the card's Shortlist button writes to the page's shortlist directly - the page and the card are reading one cell, not two copies. Nothing decides this; it falls out of reads being explicit.

The footnote under each card is projected from the page into the component's slot, and it is compiled in the page's scope - which is why it can read listedOn from the row it sits in, even though the component's view model has never heard of that field.

The photograph comes down with the row

Each listing carries an image and an imageCredit, so the card renders a photograph without asking anyone for one:

<figure class="property-photo">
    <img data-bind-src="property.image" data-bind-alt="property.title" loading="lazy">
    <figcaption>{{property.imageCredit}}</figcaption>
</figure>

Worth noticing what that means for the shortlist. The card passes the whole row along, so a shortlisted property keeps its photograph - and because the shortlist writes itself to storage, the thumbnails survive a reload without a second request. The filters never mention images either: filter[town]=Ripon returns rows, and rows have pictures on them.

A custom binding, because the built-ins are not privileged

apps/house-hunter.js - data-currencyview whole file
/**
 * A custom binding. `data-currency="price"` formats a number as sterling.
 *
 * There is nothing privileged about the built-in bindings: all twelve are
 * registered through this same function, with this same handler shape.
 */
registerBinding('currency', {
    attribute: 'data-currency',
    expression: true,     // parse the attribute value; binding.evaluate is set
    tracks: true,         // this binding's dependencies are the expression's
    primes: true,         // run once after the first paint

    update({binding, nodes, context}) {
        const text = money(binding.evaluate(context));
        for (const element of nodes) element.textContent = text;
        return true;
    }
});

registerBinding is not a side door: all twelve built-in bindings are registered through this exact function, with this exact handler shape. Anything data-bind-text can do, data-currency can do.

The shortlist persists itself

const shortlist = observableArray(window.S.get('reactive:shortlist', []));

effect(() => {
    window.S.set('reactive:shortlist', shortlist.value.map(p => ({...})));
});

An effect is the subscription. It reads the array, so it re-runs whenever the array changes - add a property, remove one, and it is written to storage through Domma JS's S wrapper. Reload the page and your shortlist is still there. Nothing subscribed, nothing unsubscribed, nothing to forget.

Writing to a collection you cannot read

Requesting a viewing posts to /api/v1/viewings, a collection whose schema publishes create and nothing else:

"api": {
  "create": {"enabled": true,  "access": "public"},
  "read":   {"enabled": false, "access": "admin"},
  "update": {"enabled": false, "access": "admin"},
  "delete": {"enabled": false, "access": "admin"}
}

So the browser can leave a viewing request and cannot read anybody else's back out - GET /api/v1/viewings answers 403 to the world. The request lands in the CMS admin like any other entry, with the property resolved by reference.

apps/house-hunter.js - POST /api/v1/viewingsview whole file
/**
 * `POST /api/v1/viewings`. That collection publishes create and nothing
 * else - `api.read.enabled` is false - so the browser can leave a viewing
 * request and cannot read anybody else's back out.
 */
async function saveViewing() {
    if (!app.viewingReady.value) return;
    viewingSaving.value = true;

    try {
        await viewings.create({
            propertyId: viewing.value.id,
            name: viewer.name.value.trim(),
            email: viewer.email.value.trim(),
            phone: viewer.phone.value.trim(),
            preferredDate: viewer.date.value,
            slot: viewer.slot.value,
            message: viewer.message.value.trim()
        });

        viewingDone.value = `Thank you - the agent has your request for ${viewing.value.title}.`;
        viewing.value = null;
        viewer.message.value = '';
        window.E.toast('Viewing request saved to the CMS.', {type: 'success'});
    } catch (err) {
        error.value = err.message || 'The request was refused.';
        window.E.toast(error.value, {type: 'error'});
    } finally {
        viewingSaving.value = false;
    }
}

The whole thing

apps/house-hunter.jsview whole file
/**
 * House hunter - a search form that is really a query, and a card that is
 * really a component.
 *
 * The listings live in the `properties` collection in Domma CMS. Nothing about
 * them is hard-coded here: add a house in the admin and it appears in this
 * search. The filters are not applied in the browser either - they are turned
 * into the CMS filter DSL (`filter[price_lte]`, `filter[bedrooms_gte]`,
 * `filter[summary_contains]`, …) and answered by the server, so the page asks
 * for what it is about to show and nothing more.
 *
 * What reactivity buys here: the query is a `computed` over the form's
 * observables, and one `effect` watches that computed. There is no "on change"
 * handler on any input, no debounce timer wired by hand (the search box has
 * `.extend({rateLimit: 300})` instead), and no way for the visible results to
 * belong to a query that is no longer on screen.
 */

import {
    observable, observableArray, computed, effect,
    applyBindings, registerComponent, registerBinding
} from '../domma-reactive.esm.js';

import {collection, money, buildQuery} from '../cms.js';

const properties = collection('properties');
const viewings = collection('viewings');

/**
 * A custom binding. `data-currency="price"` formats a number as sterling.
 *
 * There is nothing privileged about the built-in bindings: all twelve are
 * registered through this same function, with this same handler shape.
 */
registerBinding('currency', {
    attribute: 'data-currency',
    expression: true,     // parse the attribute value; binding.evaluate is set
    tracks: true,         // this binding's dependencies are the expression's
    primes: true,         // run once after the first paint

    update({binding, nodes, context}) {
        const text = money(binding.evaluate(context));
        for (const element of nodes) element.textContent = text;
        return true;
    }
});

/**
 * One listing. The card owns whether it is expanded - ten cards on a page have
 * ten of those, and none can see the others, which is the whole reason to reach
 * for a component rather than an `expandedId` held on the list.
 *
 * `shortlist` arrives as the observable array itself, so the card writes to the
 * page's shortlist directly; `property` arrives as a plain object, because the
 * expression that passed it (`$data`) evaluated to one. The markup says which
 * of those two things happened, which is the point.
 */
registerComponent('property-card', {
    template: `
        <article class="card property">
            <figure class="property-photo">
                <img data-bind-src="property.image" data-bind-alt="property.title"
                     width="1200" height="750" loading="lazy">
                <figcaption>{{property.imageCredit}}</figcaption>
            </figure>

            <header class="property-head">
                <h4>{{property.title}}</h4>
                <p class="property-price" data-currency="property.price"></p>
            </header>

            <p class="property-where">{{property.street}}, {{property.town}}</p>

            <ul class="property-facts">
                <li>{{property.bedrooms}} bed</li>
                <li>{{property.bathrooms}} bath</li>
                <li>{{property.propertyType}}</li>
                <li>{{property.tenure}}</li>
            </ul>

            <p class="property-summary" data-if="expanded.value">{{property.summary}}</p>

            <div class="property-actions">
                <button class="btn btn-sm btn-ghost" data-on-click="toggle" data-bind-text="more.value"></button>
                <button class="btn btn-sm" data-on-click="shortlistIt" data-bind-text="saveLabel.value"
                        data-bind-class="saved.value && 'btn-success'"></button>
                <button class="btn btn-sm btn-primary" data-on-click="view">Book a viewing</button>
            </div>

            {{#slot footnote}}{{/slot}}
        </article>`,

    create(params) {
        const expanded = observable(false);
        const saved = computed(() => params.shortlist.value.some(p => p.id === params.property.id));

        return {
            property: params.property,
            expanded,
            saved,
            more: computed(() => (expanded.value ? 'Less' : 'More')),
            saveLabel: computed(() => (saved.value ? 'Shortlisted' : 'Shortlist')),

            toggle() { expanded.value = !expanded.value; },
            view() { params.onView(params.property); },

            shortlistIt() {
                if (saved.value) params.shortlist.remove(p => p.id === params.property.id);
                else params.shortlist.push(params.property);
            }
        };
    }
});

const SHORTLIST_KEY = 'reactive:shortlist';

export async function mount(element) {
    // ---------------------------------------------------------------- state
    // The search box announces 300ms after typing stops. The write itself is
    // never delayed - only the notification - so the input never lags.
    const search = observable('').extend({rateLimit: 300});
    const town = observable('');
    const propertyType = observable('');
    const maxPrice = observable(800000);
    const minBeds = observable(0);
    const garden = observable(false);
    const parking = observable(false);
    const chainFree = observable(false);
    const sort = observable('price:asc');

    const results = observableArray([]);
    const towns = observableArray([]);
    const total = observable(0);
    const loading = observable(true);
    const error = observable('');
    const lastQuery = observable('');
    const searching = observable(false);

    // Restored from this browser, through Domma's storage wrapper.
    const shortlist = observableArray(window.S.get(SHORTLIST_KEY, []));

    const viewing = observable(null);
    const viewingDone = observable('');
    const viewingSaving = observable(false);
    const viewerNaming = observable(false);
    const viewer = {
        name: observable(''),
        email: observable(''),
        phone: observable(''),
        date: observable(''),
        slot: observable('Morning'),
        message: observable('')
    };

    /**
     * The whole form as one server query.
     *
     * Every filter the visitor has touched contributes a clause; the ones they
     * have not are absent rather than empty, because "no minimum" is not the
     * same request as "minimum zero". This is a `computed`, so it recomputes
     * when any one of eight observables changes and is otherwise never rebuilt.
     */
    const query = computed(() => {
        const [field, direction] = sort.value.split(':');
        const filter = {};

        if (town.value) filter.town = town.value;
        if (propertyType.value) filter.propertyType = propertyType.value;
        if (maxPrice.value) filter.price_lte = maxPrice.value;
        if (Number(minBeds.value) > 0) filter.bedrooms_gte = minBeds.value;
        if (garden.value) filter.garden = true;
        if (parking.value) filter.parking = true;
        if (chainFree.value) filter.chainFree = true;

        return {
            filter,
            search: search.value.trim() || undefined,
            sort: field,
            order: direction,
            limit: 50
        };
    });

    /**
     * One effect, one dependency: the query. Every input on the form reaches
     * the server through it, and a request that has been superseded can never
     * land on the page - the token check drops it.
     */
    let token = 0;

    effect(() => {
        const current = query.value;
        const mine = ++token;

        lastQuery.value = `GET /api/v1/properties${buildQuery(current)}`;
        loading.value = true;
        error.value = '';

        properties.list(current)
            .then(({items, total: count}) => {
                if (mine !== token) return;
                results.set(items);
                total.value = count;
            })
            .catch(err => {
                if (mine !== token) return;
                error.value = `The search failed: ${err.message}`;
                results.removeAll();
            })
            .finally(() => {
                if (mine === token) loading.value = false;
            });
    });

    // The shortlist follows itself into storage. An effect is the whole
    // subscription: it reads the array, so it re-runs whenever the array changes.
    effect(() => {
        window.S.set(SHORTLIST_KEY, shortlist.value.map(p => ({
            id: p.id, title: p.title, town: p.town, price: p.price, image: p.image
        })));
    });

    // ------------------------------------------------------------- derived
    const filtered = computed(() =>
        Boolean(search.value || town.value || propertyType.value || garden.value || parking.value ||
            chainFree.value || Number(minBeds.value) > 0 || Number(maxPrice.value) < 800000));

    const empty = computed(() => !loading.value && results.length === 0 && !error.value);

    const headline = computed(() => {
        if (loading.value) return 'Searching';
        const n = results.length;
        return `${n} ${n === 1 ? 'property' : 'properties'}${town.value ? ` in ${town.value}` : ''}`;
    });

    const shortlistSummary = computed(() => {
        const list = shortlist.value;
        if (!list.length) return '';
        const sum = list.reduce((acc, p) => acc + Number(p.price || 0), 0);
        return `Average ${money(sum / list.length)} · saved in this browser, not on the server.`;
    });

    const viewingProblems = computed(() => {
        const out = [];
        if (viewer.name.value.trim().length < 2) out.push('Your name, so the agent knows who is coming.');
        if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(viewer.email.value)) out.push('An email address we can reply to.');
        if (!viewer.date.value) out.push('Pick a date that suits you.');
        return out.map((text, id) => ({id, text}));
    });

    // ------------------------------------------------------------ view model
    const app = {
        search, town, propertyType, maxPrice, minBeds, garden, parking, chainFree, sort,
        results, towns, loading, error, lastQuery, searching, shortlist,
        viewing, viewingDone, viewer, viewerNaming, viewingProblems,
        filtered, empty, headline, shortlistSummary,

        types: ['House', 'Flat', 'Bungalow', 'Cottage', 'Barn conversion'],
        viewingSlots: ['Morning', 'Afternoon', 'Evening'],
        bedOptions: [
            {beds: 0, label: 'Any'}, {beds: 1, label: '1+'}, {beds: 2, label: '2+'},
            {beds: 3, label: '3+'}, {beds: 4, label: '4+'}, {beds: 5, label: '5+'}
        ],
        sorts: [
            {key: 'price:asc', label: 'Price, low to high'},
            {key: 'price:desc', label: 'Price, high to low'},
            {key: 'bedrooms:desc', label: 'Most bedrooms'},
            {key: 'listedOn:desc', label: 'Newest first'}
        ],

        maxPriceLabel: computed(() => `Up to ${money(maxPrice.value)}`),
        shortlistLabel: computed(() => `Shortlist (${shortlist.length})`),
        viewingTitle: computed(() => (viewing.value ? `${viewing.value.title}, ${viewing.value.town}` : '')),
        viewingReady: computed(() => viewingProblems.value.length === 0 && !viewingSaving.value),
        viewingButton: computed(() => (viewingSaving.value ? 'Sending…' : 'Request it')),

        clear() {
            search.value = '';
            town.value = '';
            propertyType.value = '';
            maxPrice.value = 800000;
            minBeds.value = 0;
            garden.value = false;
            parking.value = false;
            chainFree.value = false;
        },

        drop(item) { shortlist.remove(p => p.id === item.id); },

        startViewing(property) {
            viewing.value = property;
            viewingDone.value = '';
            viewerNaming.value = true;
        },

        cancelViewing() { viewing.value = null; },

        submitViewing() {
            saveViewing();
            return false;
        },

        stopSubmit() { return false; }
    };

    /**
     * `POST /api/v1/viewings`. That collection publishes create and nothing
     * else - `api.read.enabled` is false - so the browser can leave a viewing
     * request and cannot read anybody else's back out.
     */
    async function saveViewing() {
        if (!app.viewingReady.value) return;
        viewingSaving.value = true;

        try {
            await viewings.create({
                propertyId: viewing.value.id,
                name: viewer.name.value.trim(),
                email: viewer.email.value.trim(),
                phone: viewer.phone.value.trim(),
                preferredDate: viewer.date.value,
                slot: viewer.slot.value,
                message: viewer.message.value.trim()
            });

            viewingDone.value = `Thank you - the agent has your request for ${viewing.value.title}.`;
            viewing.value = null;
            viewer.message.value = '';
            window.E.toast('Viewing request saved to the CMS.', {type: 'success'});
        } catch (err) {
            error.value = err.message || 'The request was refused.';
            window.E.toast(error.value, {type: 'error'});
        } finally {
            viewingSaving.value = false;
        }
    }

    applyBindings(app, element);

    // The town list is a facet: one call at startup, so the select offers the
    // towns that actually have listings rather than a hard-coded list.
    try {
        const {items} = await properties.list({limit: 200, sort: 'town', order: 'asc'});
        towns.set([...new Set(items.map(p => p.town))].sort());
    } catch {
        towns.set([]);
    }
}
markup/house-hunter.htmlview whole file
<div class="app app--houses">

    <form class="filters" data-on-submit="stopSubmit">
        <label class="form-label filters-search">Search
            <input class="form-input" type="search" data-model="search.value" data-focus="searching.value" placeholder="barn, cathedral, station&hellip;">
        </label>

        <label class="form-label">Town
            <select class="form-select" data-model="town.value" data-options="towns.value" data-options-caption="'Anywhere'"></select>
        </label>

        <label class="form-label">Type
            <select class="form-select" data-model="propertyType.value" data-options="types" data-options-caption="'Any type'"></select>
        </label>

        <label class="form-label">Max price
            <input class="form-input" type="range" min="150000" max="800000" step="25000" data-model="maxPrice.value">
            <small data-bind-text="maxPriceLabel.value">£800,000</small>
        </label>

        <label class="form-label">Beds (min)
            <select class="form-select" data-model="minBeds.value" data-options="bedOptions" data-options-text="label" data-options-value="beds"></select>
        </label>

        <label class="form-label">Sort by
            <select class="form-select" data-model="sort.value" data-options="sorts" data-options-text="label" data-options-value="key"></select>
        </label>

        <div class="filters-toggles">
            <label><input type="checkbox" data-model="garden.value"> Garden</label>
            <label><input type="checkbox" data-model="parking.value"> Parking</label>
            <label><input type="checkbox" data-model="chainFree.value"> Chain free</label>
        </div>

        <button class="btn btn-ghost btn-sm" type="button" data-on-click="clear" data-bind-disabled="!filtered.value">Clear filters</button>
    </form>

    <p class="app-query">
        <span class="app-query-label">Live API call</span>
        <code data-bind-text="lastQuery.value">/api/v1/properties</code>
    </p>

    <div class="results-head">
        <h3 data-bind-text="headline.value">Properties</h3>
        <p class="app-note" data-if="loading.value">Searching&hellip;</p>
        <p class="app-error" data-if="error.value" data-bind-text="error.value"></p>
    </div>

    <div class="results" data-each="results key=id">
        <div data-component="'property-card'" data-param-property="$data" data-param-shortlist="$parent.shortlist" data-param-on-view="$parent.startViewing">
            <p data-slot="footnote" class="card-footnote">Listed <span data-bind-text="listedOn"></span> &middot; EPC <span data-bind-text="epc"></span></p>
        </div>
    </div>

    <p class="app-empty" data-if="empty.value">Nothing matches that. Widen the price, or clear a filter.</p>

    <section class="shortlist" data-if="shortlist.length">
        <h4><span data-bind-text="shortlistLabel.value"></span></h4>
        <ul class="shortlist-list" data-each="shortlist key=id">
            <li>
                <img class="shortlist-thumb" data-bind-src="image" data-bind-alt="title"
                     width="160" height="100" loading="lazy">
                <b data-bind-text="title"></b>
                <span data-bind-text="town"></span>
                <em data-currency="price"></em>
                <button class="btn btn-sm btn-ghost" type="button" data-on-click="$parent.drop($data)">Remove</button>
            </li>
        </ul>
        <p class="app-note" data-bind-text="shortlistSummary.value"></p>
    </section>

    <section class="viewing" data-if="viewing.value">
        <h4>Request a viewing</h4>
        <p class="viewing-what" data-bind-text="viewingTitle.value"></p>

        <form class="viewing-form" data-on-submit="submitViewing">
            <label class="form-label">Your name
                <input class="form-input" data-model="viewer.name.value" data-focus="viewerNaming.value">
            </label>
            <label class="form-label">Email
                <input class="form-input" type="email" data-model="viewer.email.value">
            </label>
            <label class="form-label">Phone
                <input class="form-input" data-model="viewer.phone.value">
            </label>
            <label class="form-label">Preferred date
                <input class="form-input" type="date" data-model="viewer.date.value">
            </label>
            <label class="form-label">Time
                <select class="form-select" data-model="viewer.slot.value" data-options="viewingSlots"></select>
            </label>
            <label class="form-label form-label--wide">Message
                <textarea class="form-textarea" rows="2" data-model="viewer.message.value"></textarea>
            </label>

            <ul class="problems" data-each="viewingProblems key=id">
                <li data-bind-text="text"></li>
            </ul>

            <div class="viewing-foot">
                <button class="btn btn-primary" type="submit" data-bind-disabled="!viewingReady.value" data-bind-text="viewingButton.value">Request it</button>
                <button class="btn btn-ghost" type="button" data-on-click="cancelViewing">Cancel</button>
            </div>
        </form>
    </section>

    <p class="app-success" data-if="viewingDone.value" data-bind-text="viewingDone.value"></p>

</div>
cms.js - shared by both demosview whole file
/**
 * The Domma CMS collection API, as the demos use it.
 *
 * Every collection on this site (`rooms`, `bookings`, `properties`, `viewings`)
 * is a folder of JSON managed in the CMS admin. Turning on `api.read.access =
 * "public"` in its schema publishes it at `/api/v1/<slug>` - no code, no
 * endpoint to write. That is what these functions talk to.
 *
 * HTTP goes through Domma JS's `H` client rather than `fetch`, because on a
 * Domma CMS page `H` is already there and already does the JSON handling.
 */

const api = window.H;

/**
 * Turn a plain options object into the query string `/api/v1/:slug` accepts.
 *
 * The `filter` key is the interesting one. The CMS filter DSL takes
 * `filter[<field>_<op>]=<value>` with the operators `_eq _ne _gt _gte _lt _lte
 * _in _nin _contains _starts _ends _exists`, so a whole search form can be
 * expressed as data and sent to the server as one URL.
 *
 * @param {object} [opts]
 * @param {number} [opts.limit]   Page size (the API's own default is 50)
 * @param {string} [opts.sort]    Field to sort on
 * @param {'asc'|'desc'} [opts.order]
 * @param {string} [opts.search]  Free-text search across fields
 * @param {object} [opts.filter]  `{price_lte: 500000, town: 'Harrogate'}`
 * @param {boolean} [opts.resolveRefs] Resolve reference fields into `_refs`
 * @returns {string}
 */
export function buildQuery({limit, sort, order, search, filter, resolveRefs} = {}) {
    const params = new URLSearchParams();

    if (limit) params.set('limit', String(limit));
    if (sort) params.set('sort', sort);
    if (order) params.set('order', order);
    if (search) params.set('search', search);
    if (resolveRefs) params.set('resolveRefs', 'true');

    for (const [key, value] of Object.entries(filter || {})) {
        // An absent filter is one the visitor has not filled in - it must not
        // narrow the query at all, which is not the same as filtering on "".
        if (value === '' || value === null || value === undefined) continue;
        params.set(`filter[${key}]`, String(value));
    }

    const query = params.toString();
    return query ? `?${query}` : '';
}

/**
 * A handle on one published collection.
 *
 * @param {string} slug
 */
export function collection(slug) {
    const base = `/api/v1/${slug}`;

    return {
        /**
         * Read entries. The API returns `{id, data, meta}` records; these are
         * flattened to `{id, ...data}` because that is the shape a template
         * wants, and `id` is what `data-each key=id` reconciles on.
         *
         * @param {object} [opts] See {@link buildQuery}
         * @returns {Promise<{items: object[], total: number}>}
         */
        async list(opts) {
            const result = await api.get(base + buildQuery(opts));
            const entries = result.entries || [];
            return {
                items: entries.map(entry => ({id: entry.id, ...entry.data, _refs: entry._refs})),
                total: result.total ?? entries.length
            };
        },

        /**
         * Create an entry. The collection's schema decides whether this is
         * allowed and validates every field before anything is written -
         * `bookings` and `viewings` accept public writes, the rest do not.
         *
         * @param {object} data Field values keyed by field name
         * @returns {Promise<object>} The stored entry, flattened as above
         */
        async create(data) {
            const entry = await api.post(base, {data});
            return {id: entry.id, ...entry.data};
        }
    };
}

/** Money, the way a listing wants to read it. */
export function money(value, {decimals = 0} = {}) {
    return new Intl.NumberFormat('en-GB', {
        style: 'currency',
        currency: 'GBP',
        minimumFractionDigits: decimals,
        maximumFractionDigits: decimals
    }).format(Number(value) || 0);
}

/** A date, via Domma's `D()`, which is on the page already. */
export function formatDate(value, pattern = 'ddd D MMM YYYY') {
    return window.D(value).format(pattern);
}

Back to the examples Why any of this

The houses are invented; the streets are not, and neither are their photographs - Geograph contributors, CC BY-SA, via Wikimedia Commons. Who took which one.