{"result":{"depiction_url":"https:\/\/zotonic.com\/image\/2023\/11\/9\/dsc02958.jpg%28800x800%29%28upscale%29%28BnWaJXfhos6LIMdJIB4a2-infg19z6D5ajw9LbAgjiA%29.jpg","edges":{"depiction":{"objects":[{"created":"2023-11-09T16:28:10Z","object_id":{"id":2390,"is_a":["media","image"],"name":null,"title":{"_type":"trans","tr":{"en":"DSC02958.JPG"}},"uri":"https:\/\/zotonic.com\/id\/2390"},"seq":1000000}],"predicate":{"id":304,"is_a":["meta","predicate"],"name":"depiction","title":{"_type":"trans","tr":{"nl":"Afbeelding","en":"Depiction"}},"uri":"http:\/\/xmlns.com\/foaf\/0.1\/depiction"}},"relation":{"objects":[{"created":"2023-11-09T19:14:46Z","object_id":{"id":1323,"is_a":["text","documentation","reference","template_scomp"],"name":"doc_template_scomp_scomp_live","title":"live","uri":"https:\/\/zotonic.com\/id\/1323"},"seq":1000000},{"created":"2023-11-09T19:15:35Z","object_id":{"id":1396,"is_a":["text","documentation","reference","template_scomp"],"name":"doc_template_scomp_scomp_pager","title":"pager","uri":"https:\/\/zotonic.com\/id\/1396"},"seq":1000000},{"created":"2023-11-09T19:16:38Z","object_id":{"id":1638,"is_a":["text","documentation","developerguide"],"name":"doc_developerguide_search","title":{"_type":"trans","tr":{"en":"Search"}},"uri":"https:\/\/zotonic.com\/id\/1638"},"seq":1000000}],"predicate":{"id":303,"is_a":["meta","predicate"],"name":"relation","title":{"_type":"trans","tr":{"nl":"Relatie","en":"Relation"}},"uri":"http:\/\/purl.org\/dc\/terms\/relation"}},"subject":{"objects":[{"created":"2026-09-14T05:42:26Z","object_id":{"id":2556,"is_a":["categorization","keyword","keyword_information_type"],"name":"zotonic_topic_explanation","title":"Explanation","uri":"https:\/\/zotonic.com\/id\/2556"},"seq":1000000},{"created":"2026-09-14T05:42:33Z","object_id":{"id":2563,"is_a":["categorization","keyword","keyword_audience"],"name":"zotonic_topic_frontend_developer","title":"Frontend developer","uri":"https:\/\/zotonic.com\/id\/2563"},"seq":1000000},{"created":"2026-09-14T05:42:49Z","object_id":{"id":2586,"is_a":["categorization","keyword","keyword_domain"],"name":"zotonic_topic_search_and_discovery","title":"Search and discovery","uri":"https:\/\/zotonic.com\/id\/2586"},"seq":1000000},{"created":"2026-09-14T05:42:52Z","object_id":{"id":2583,"is_a":["categorization","keyword","keyword_domain"],"name":"zotonic_topic_user_interface_and_interaction","title":"User interface and interaction","uri":"https:\/\/zotonic.com\/id\/2583"},"seq":1000000},{"created":"2026-09-14T05:43:00Z","object_id":{"id":2582,"is_a":["categorization","keyword","keyword_domain"],"name":"zotonic_topic_forms","title":"Forms","uri":"https:\/\/zotonic.com\/id\/2582"},"seq":1000000},{"created":"2026-09-14T05:43:18Z","object_id":{"id":2620,"is_a":["categorization","keyword","keyword_architecture"],"name":"zotonic_topic_template","title":"Template","uri":"https:\/\/zotonic.com\/id\/2620"},"seq":1000000},{"created":"2026-09-14T05:43:30Z","object_id":{"id":2643,"is_a":["categorization","keyword","keyword_task"],"name":"zotonic_topic_query","title":"Query","uri":"https:\/\/zotonic.com\/id\/2643"},"seq":1000000},{"created":"2026-09-14T05:44:09Z","object_id":{"id":2673,"is_a":["categorization","keyword","keyword_technology"],"name":"zotonic_topic_javascript","title":"JavaScript","uri":"https:\/\/zotonic.com\/id\/2673"},"seq":1000000},{"created":"2026-09-14T05:44:16Z","object_id":{"id":2677,"is_a":["categorization","keyword","keyword_technology"],"name":"zotonic_topic_mqtt","title":"MQTT","uri":"https:\/\/zotonic.com\/id\/2677"},"seq":1000000},{"created":"2026-09-14T05:47:23Z","object_id":{"id":2553,"is_a":["categorization","keyword","keyword_information_type"],"name":"zotonic_topic_tutorial","title":"Tutorial","uri":"https:\/\/zotonic.com\/id\/2553"},"seq":1000000}],"predicate":{"id":308,"is_a":["meta","predicate"],"name":"subject","title":{"_type":"trans","tr":{"en":"Keyword"}},"uri":"http:\/\/purl.org\/dc\/elements\/1.1\/subject"}}},"id":2389,"is_a":["text","article"],"links":[{"rel":"self","target":"https:\/\/zotonic.com\/.zotonic\/websub\/topic\/2389"},{"rel":"hub","target":"https:\/\/zotonic.com\/.zotonic\/websub"}],"medium":null,"medium_url":null,"name":null,"page_url":{"en":"https:\/\/zotonic.com\/page\/2389\/building-a-live-updating-search-page","x-default":"https:\/\/zotonic.com\/page\/2389\/building-a-live-updating-search-page"},"preview_url":null,"resource":{"version":31,"pivot_location_lat":null,"title":{"_type":"trans","tr":{"en":"Building a live updating search page"}},"is_authoritative":true,"body":{"_type":"trans","tr":{"en":"<p>For this implementation we will make use of a combination of Cotonic routing topics together with a small Zotonic widget to programmatically initialize the form from the search parameters in the URL.<\/p>\n<h2>A simple search page<\/h2>\n<aside>\n<p>Files in the templates\/static directory are served if there is no other matching dispatch rule. It is an easy way to add unique pages to your Zotonic site.<\/p>\n<\/aside>\n<p>We start with a simple page, where I will assume that the base template is working correctly. We will make our page on the URL <code>\/formtest<\/code> for this we place the following template in <code>priv\/templates\/static\/formtest.tpl<\/code>:<\/p>\n<pre>{% extends &quot;base.tpl&quot; %}<br \/><br \/>{% block content %}<br \/>    &lt;div id=&quot;live&quot;&gt;&lt;\/div&gt;<br \/><br \/>    {% live topic=&quot;model\/location\/event\/qlist&quot;<br \/>            template=&quot;static\/_formtest.tpl&quot;<br \/>            target=&quot;live&quot;<br \/>            method=&quot;patch&quot;<br \/>            is_new_query<br \/>    %}<br \/>{% endblock %}<\/pre>\n<aside>\n<p>In the future Cotonic models will be loaded on demand. For now we have to manually load them.<\/p>\n<\/aside>\n<p>In the Javascript include of the base template, make sure that the <em>forminit<\/em> widget, the <em>live<\/em> tag and the Cotonic loadmore model are added, we will need it later:<\/p>\n<pre>{% lib &quot;js\/modules\/z.forminit.js&quot;<br \/>       &quot;js\/modules\/z.live.js&quot;<br \/>       &quot;js\/models\/loadmore.js&quot;<br \/>%}<\/pre>\n<p>How does the live tag work? On page load the Cotonic location model parses the current URL. The query arguments are posted as a list to the topic <code>model\/location\/event\/qlist<\/code>.<\/p>\n<p>For example if our URL is:<\/p>\n<pre> https:\/\/example.com\/formtest?qs=hallo&amp;qcat=text<\/pre>\n<aside>\n<p>By using a list instead of an object we can have multiple arguments with the same name and keep the order of the elements.<\/p>\n<\/aside>\n<p>then the following payload will be published to the qlist topic:<\/p>\n<pre>[<br \/>   [ &quot;qtext&quot;, &quot;hallo&quot; ],<br \/>   [ &quot;qcat&quot;, &quot;text&quot; ]<br \/>]<\/pre>\n<p>Because our live tag is subscribed to this topic, it will be triggered and render the template <code>static\/_formtest.tpl<\/code> in the target div, with id #<code>live<\/code>.<\/p>\n<p>The template is loaded using the <em>patch<\/em> method. Instead of just replacing the complete inner HTML of the div this will update the inner HTML to reflect the newly rendered template. This patching ensures that any input elements keep their focus and input values.<\/p>\n<p>The <code>is_new_query<\/code> is passed as <code>true<\/code> to the rendered template, we will need it later when we discuss endless scrolling.<\/p>\n<h2>The search form<\/h2>\n<p>This the template we are loading:<\/p>\n<pre>&lt;h1&gt;Form test&lt;\/h1&gt;<br \/>&lt;form class=&quot;do_forminit&quot;<br \/>      data-onsubmit-topic=&quot;model\/location\/post\/qlist\/submit&quot;<br \/>      data-oninput-topic=&quot;model\/location\/post\/qlist\/submit&quot;<br \/>      method=&quot;GET&quot;&gt;<br \/>    &lt;input type=&quot;text&quot; name=&quot;qtext&quot; value=&quot;&quot;&gt;<br \/>    &lt;input type=&quot;checkbox&quot; name=&quot;qis_published&quot; value=&quot;1&quot;&gt;<br \/>    &lt;select name=&quot;qcat&quot;&gt;<br \/>        &lt;option value=&quot;&quot;&gt;Any category&lt;\/option&gt;<br \/>        &lt;option value=&quot;text&quot;&gt;Text&lt;\/option&gt;<br \/>        &lt;option value=&quot;media&quot;&gt;Media&lt;\/option&gt;<br \/>    &lt;\/select&gt;<br \/>&lt;\/form&gt;<br \/><br \/>{% include &quot;static\/_formtest_page.tpl&quot; %}<\/pre>\n<p>As you can see, this is a simple GET form. Let&#39;s explain the moving parts.<\/p>\n<p>First the class <em>do_forminit<\/em>, as this class is prefixed with do_, it will call the widget <em>forminit<\/em> after the form element is created. The <em>forminit<\/em> widget will take the search arguments from the current document location to initialize the input elements of the form. In this way we do not need to check any checkboxes or add the correct values to text inputs. This makes building the form much simpler.<\/p>\n<p>There are two Cotonic topics added to the form. One for the submit if the enter key is pressed in the text input (note that we don&#39;t have a submit button). The other one is for any changes of input values.<\/p>\n<aside>\n<p>The oninput will wait 500 msec before publishing an event. New events arriving in this period reset the timer and cancel the previous event. This prevents rapid event publishing if somebody is typing.<\/p>\n<\/aside>\n<p>Both topics publish the contents of the form to the topic <code>model\/location\/post\/qlist\/submit<\/code>.<\/p>\n<p>This topic of the location model updates the current search arguments of the document location with the posted values and then published the new query arguments to <code>model\/location\/event\/qlist<\/code>, which will trigger our live tag to re-render and update the content of the #<em>live<\/em> div.<\/p>\n<h2>Searching<\/h2>\n<p>The search form template loads a page with the search results, place this in <code>priv\/templates\/_formtest_page.tpl<\/code>:<\/p>\n<pre>{% with m.search.query::%{ qargs: true, page: q.page } as result %}<br \/>    &lt;h2&gt;Page #{{ result.page }}&lt;\/h2&gt;<br \/><br \/>    &lt;ul&gt;<br \/>        {% for id in result %}<br \/>            &lt;li&gt;&lt;a href=&quot;{{ id.page_url }}&quot;&gt;{{ id.title }}&lt;\/a&gt;&lt;\/li&gt;<br \/>        {% endfor %}<br \/>    &lt;\/ul&gt;<br \/><br \/>    {% pager dispatch=&quot;none&quot;<br \/>             result=result<br \/>             topic=&quot;model\/location\/post\/push&quot;<br \/>             qargs<br \/>    %}<br \/>{% endwith %}<\/pre>\n<p>This does the query, lists the results and shows a pager. For the query we pass the current page number, from <code>q.page<\/code>, and the query arguments by adding the option <code>qargs<\/code>. The system knows which search arguments are query arguments by taking all search arguments starting with a <em>q<\/em>. That is why in the form we used names like <code>qtext<\/code> and <code>qcat<\/code>.<\/p>\n<p>For the list of found pages we simply loop over the returned result and render links to the pages. For this example this is kept simple.<\/p>\n<h2>Pagination<\/h2>\n<p>The pager uses qargs to render the correct URLs with all search arguments. The current page is fetched from the search result. A special dispatch rule <code>none<\/code> is used, which generates links without a path and hostname, for example:<\/p>\n<pre>?qtext=hello&amp;qcat=text&amp;page=2<\/pre>\n<p>When clicked, the browser will add the current hostname and path to these links.<\/p>\n<p>The pager has a topic argument, having this topic prevents the browser to update the current location. Instead the URL is published to the topic. The location model (subscribed to the topic) then updates the current location using <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/History\/pushState\">history.pushState()<\/a>, ensuring that the URL reflects the correct page number and that the back button will indeed go back to the previous page.<\/p>\n<p>After updationg the browser history, the new location is published to the location event topics. Which triggers our <em>live<\/em> tag to re-render the form with the page results.<\/p>\n<h2>A load more button<\/h2>\n<p>Instead of the pager we could also add a <em>load more<\/em> button to load the next page with results:<\/p>\n<pre>{% if result.next %}<br \/>        &lt;div id=&quot;more-results-{{ result.next }}&quot;<br \/>             data-onclick-topic=&quot;model\/loadmore\/post\/replace&quot;<br \/>             data-template=&quot;static\/_formtest_page.tpl&quot;&gt;<br \/>           &lt;a class=&quot;btn btn-primary&quot; href=&quot;{% url none page=result.next qargs %}&quot;&gt;<br \/>              {_ Show more _}<br \/>           &lt;\/a&gt;<br \/>       &lt;\/div&gt;<br \/>{% endif %}<\/pre>\n<p>If there is a next page after the current result page then we show a button with <em>Show more<\/em>.  If the button is clicked, the href-attribute is published to the <em>loadmore<\/em> model. This will replace the div with the contents of the rendered templates.  All search arguments of the href URL are passed as query arguments to the template renderer.<\/p>\n<p>This renders the next page, add it below the current results, and add a new load more button. Repeatingly clicking the button will load more and more pages until there is no next page.<\/p>\n<p>The loadmore model updates the current document location with the new URL, reflecting the currently shown page. It uses the <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/History\/replaceState\">history.replaceState()<\/a> function, so no pages are added to the browser history.<\/p>\n<p>Note that if anything in the search form is changed the subsequent submit will replace the URL again, this time without a page number.<\/p>\n<h2>Endless scrolling<\/h2>\n<p>Instead of manually clicking a load more button we can also automatically load the next page if we scroll far enough.<\/p>\n<p>For this we render an element, and if it becomes visible it will be replaced with the next page. Just like the load more button, but now without clicking:<\/p>\n<pre>{% if result.next %}<br \/>    &lt;div id=&quot;more-results-{{ result.next }}&quot;<br \/>         data-onvisible-topic=&quot;model\/loadmore\/post\/replace&quot;<br \/>         data-template=&quot;static\/_formtest_page.tpl&quot;<br \/>         data-url=&quot;{% url none page=result.next qargs %}&quot;&gt;<br \/>        Loading...<br \/>    &lt;\/div&gt;<br \/>{% endif %}<\/pre>\n<p>The <em>onvisible<\/em> topic uses an <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/Intersection_Observer_API\">intersection observer<\/a> to observe changes to the element&#39;s visibility. It will trigger a publish to the topic whenever the element becomes visible.<\/p>\n<p>We publish to the <em>loadmore<\/em> model topic to replace the div with the newly rendered page. We now use the <em>onvisible<\/em> topic and pass the new URL in the <em>data-url<\/em> argument (as there is no anchor tag with a href).<\/p>\n<p>The current browser location is updated to reflect the new location. The loadmore model uses <code>history.replaceState()<\/code>to prevent adding pages to the browser history. Just like the load more button.<\/p>\n<h2>Reloading a page with load more or endless scrolling<\/h2>\n<p>If a page with a load more button or endless scrolling is reloaded, then it will only display the current page from the location&#39;s search arguments. This because our query searches for that page:<\/p>\n<pre>{% with m.search.query::%{ qargs: true, page: q.page } as result %}<\/pre>\n<p>But on a reload we might want to see all pages up to an including the given result page number, as if the user repeatingly clicked on the load more themselves until they reached the correct page number.<\/p>\n<p>This is easily done by using the <code>is_new_query<\/code> argument we added to the live tag. It makes enables the pager template to see if the whole form and page was loaded because of fresh page load (or new query) or as part of pagination. With the pagination we do not pass this variable, so its presence signals that the template is rendered as part of a complete refresh.<\/p>\n<p>Remember that if we perform a new search that the <em>page<\/em> search argument is not passed, it is only passed with pagination.<\/p>\n<pre><br \/>{% with q.page|default:1|to_integer as page %}<br \/>    {% if is_new_query %}<br \/>        {% for p in 1|range:(page-1) %}<br \/>            {% with m.search.query::%{ qargs: true, page: p } as result %}<br \/><br \/>                &lt;h2&gt;Page #{{ result.page }}&lt;\/h2&gt;<br \/><br \/>                &lt;ul&gt;<br \/>                    {% for id in result %}<br \/>                        &lt;li&gt;&lt;a href=&quot;{{ id.page_url }}&quot;&gt;{{ id.title }}&lt;\/a&gt;&lt;\/li&gt;<br \/>                    {% endfor %}<br \/>                &lt;\/ul&gt;<br \/>            {% endwith %}<br \/>        {% endfor %}<br \/>    {% endif %}<br \/><br \/>    {% with m.search.query::%{ qargs: true, page: page } as result %}<br \/>        &lt;h2&gt;Page #{{ result.page }}&lt;\/h2&gt;<br \/><br \/>        &lt;ul&gt;<br \/>            {% for id in result %}<br \/>                &lt;li&gt;&lt;a href=&quot;{{ id.page_url }}&quot;&gt;{{ id.title }}&lt;\/a&gt;&lt;\/li&gt;<br \/>            {% endfor %}<br \/>        &lt;\/ul&gt;<br \/><br \/>        {% if result.next %}<br \/>            &lt;div id=&quot;more-results-{{ result.next }}&quot;<br \/>                 data-onvisible-topic=&quot;model\/loadmore\/post\/replace&quot;<br \/>                 data-template=&quot;static\/_formtest_page.tpl&quot;<br \/>                 data-url=&quot;{% url none page=result.next qargs %}&quot;<br \/>            &gt;<br \/>                Loading...<br \/>            &lt;\/div&gt;<br \/>        {% endif %}<br \/><br \/>    {% endwith %}<br \/>{% endwith %}<\/pre>\n<aside>\n<p>In real production code you might not want to load more than a certain number of pages, as otherwise this can get really slow with a large enough page number.<\/p>\n<\/aside>\n<p>If the is_new_query argument is set we loop through all pages from 1 to the passed page. Rebuilding the complete search result page as if there was a manual scroll to the given page number.<\/p>\n<p>An alternative is to add a <em>load before<\/em> button, which we leave as an exercise to the reader.<\/p>\n<h2>Conclusion<\/h2>\n<p>We have seen that with simple templates and a combination of Cotonic models it is easy to make live search pages. On these pages you can use a variety of strategies for the pagination.<\/p>\n<p>As the pages are patched by updating the DOM it is possible to make a dynamic form that adapts to the search results. Which is very useful when using faceted search queries.<\/p>"}},"is_mailing_opt_out":false,"slug":"building-a-live-updating-search-page","is_protected":false,"visible_for":0,"tz":"UTC","language":["en"],"is_featured":false,"content_group_id":{"id":339,"is_a":["meta","content_group"],"name":"default_content_group","title":{"_type":"trans","tr":{"en":"Default Content Group"}},"uri":"https:\/\/zotonic.com\/id\/default_content_group"},"category_id":{"id":106,"is_a":["meta","category"],"name":"article","title":{"_type":"trans","tr":{"en":"Article"}},"uri":"https:\/\/test.zotonic.com\/id\/106"},"publication_start":"2023-11-09T00:00:00Z","is_website_redirect":false,"pivot_location_lng":null,"name":null,"is_unfindable":false,"is_published":true,"summary":{"_type":"trans","tr":{"en":"Search pages are often simple forms that reload the page with new arguments. In this article we will show how you can make a search page that updates live whilst typing or selecting options. The location of the page will be updated, so reloading or bookmarking a page will load the expected query."}},"pivot_geocode":null,"custom_slug":false,"created":"2023-11-09T14:43:57Z","uri":null,"date_is_all_day":false,"is_dependent":false,"is_page_path_multiple":false,"publication_end":"9999-08-17T12:00:00Z","modifier_id":{"id":1,"is_a":["person"],"name":"administrator","title":"Site Administrator","uri":"https:\/\/zotonic.com\/id\/1"},"privacy":0,"creator_id":{"id":1,"is_a":["person"],"name":"administrator","title":"Site Administrator","uri":"https:\/\/zotonic.com\/id\/1"},"seo_noindex":false,"modified":"2023-11-10T08:11:05Z","title_slug":{"_type":"trans","tr":{"en":"building-a-live-updating-search-page"}}},"uri":"https:\/\/zotonic.com\/id\/2389","uri_template":"https:\/\/zotonic.com\/id\/:id","websub":{"hub":"https:\/\/zotonic.com\/.zotonic\/websub","topic":"https:\/\/zotonic.com\/.zotonic\/websub\/topic\/2389"}},"status":"ok"}