Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Enabling progressive loading of index page content #3485

Merged
merged 15 commits into from Aug 21, 2019
@@ -0,0 +1,18 @@
# Generated by Django 2.2.3 on 2019-08-07 18:59

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('wagtailpages', '0072_auto_20190815_1723'),
]

operations = [
migrations.AddField(
model_name='indexpage',
name='page_size',
field=models.IntegerField(choices=[(4, '4'), (8, '8'), (12, '12'), (24, '24')], default=12, help_text='The number of entries to show by default, and per incremental load'),
),
]
143 changes: 107 additions & 36 deletions network-api/networkapi/wagtailpages/models.py
Expand Up @@ -3,7 +3,8 @@

from django.db import models
from django.conf import settings
from django.http import HttpResponseRedirect
from django.http import HttpResponseRedirect, JsonResponse
from django.template import loader

from . import customblocks

Expand Down Expand Up @@ -553,12 +554,56 @@ class IndexPage(FoundationMetadataPageMixin, RoutablePageMixin, Page):
help_text='Intro paragraph to show in hero cutout box'
)

DEFAULT_PAGE_SIZE = 12

PAGE_SIZES = (
(4, '4'),
(8, '8'),
(DEFAULT_PAGE_SIZE, str(DEFAULT_PAGE_SIZE)),
(24, '24'),
)

page_size = models.IntegerField(
choices=PAGE_SIZES,
default=DEFAULT_PAGE_SIZE,
help_text='The number of entries to show by default, and per incremental load'
)

content_panels = Page.content_panels + [
FieldPanel('header'),
FieldPanel('intro'),
FieldPanel('page_size'),
]

def filter_entries_for_tag(self, context):
def get_context(self, request):
# bootstrap the render context
context = super().get_context(request)
context = set_main_site_nav_information(self, context, 'Homepage')
context = get_page_tree_information(self, context)

# perform entry pagination and (optional) filterin
entries = self.get_entries(context)
context['has_more'] = self.page_size < len(entries)
context['entries'] = entries[0:self.page_size]
return context

def get_all_entries(self):
"""
Get all (live) child entries, ordered "newest first"
"""
return self.get_children().live().order_by('-first_published_at')

def get_entries(self, context=dict()):
"""
Get all child entries, filtered down if required based on
the `self.filtered` field being set or not.
"""
entries = self.get_all_entries()
if hasattr(self, 'filtered'):
entries = self.filter_entries_for_tag(entries, context)
return entries

def filter_entries_for_tag(self, entries, context):
"""
Realise the 'entries' queryset and filter it for tags presences.
We need to perform this realisation because there is no guarantee
Expand All @@ -570,14 +615,16 @@ def filter_entries_for_tag(self, context):
type = self.filtered.get('type')
context['filtered'] = type

if type == 'tag':
if type == 'tags':
terms = self.filtered.get('terms')
context['terms'] = terms

# "unsluggify" all terms:
context['terms'] = [str(Tag.objects.get(slug=term)) for term in terms]

entries = [
entry
for
entry in context['entries'].specific()
entry in entries.specific()
if
hasattr(entry, 'tags')
and not
Expand All @@ -586,25 +633,61 @@ def filter_entries_for_tag(self, context):
set([tag.slug for tag in entry.tags.all()]).isdisjoint(terms)
]

context['entries'] = entries
context['total_entries'] = len(entries)

def get_context(self, request):
return entries

"""
Sub routes
"""

@route('^entries/')
def generate_entries_set_html(self, request, *args, **kwargs):
"""
JSON endpoint for getting a set of (pre-rendered) entries
"""
Bootstrap this page in similar fashion to the PrimaryPage,
but include an `entries` context variable that represents
all public children under this page.

Additionally, if this is a fall-through render due to a
tag filtering subroute call, perform that filtering of
all entries.
page = 1
if 'page' in request.GET:
page = int(request.GET['page'])

page_size = self.page_size
if 'page_size' in request.GET:
page_size = int(request.GET['page_size'])

start = page * page_size
end = start + page_size
entries = self.get_entries()
has_next = end < len(entries)

html = loader.render_to_string(
'wagtailpages/fragments/entry_cards.html',
context={
'entries': entries[start:end]
},
request=request
)

return JsonResponse({
'entries_html': html,
'has_next': has_next
})

# helper function for /tags/... subroutes
def extract_tag_information(self, tag):
terms = list(filter(None, re.split('/', tag)))
self.filtered = {
'type': 'tags',
'terms': terms
}

@route(r'^tags/(?P<tag>.+)/entries/')
def generate_tagged_entries_set_html(self, request, tag, *args, **kwargs):
"""
context = super().get_context(request)
context = set_main_site_nav_information(self, context, 'Homepage')
context = get_page_tree_information(self, context)
context['entries'] = self.get_children().live().order_by('-first_published_at')
if hasattr(self, 'filtered'):
self.filter_entries_for_tag(context)
return context
JSON endpoint for getting a set of (pre-rendered) tagged entries
"""
self.extract_tag_information(tag)
return self.generate_entries_set_html(request, *args, **kwargs)

@route(r'^tags/(?P<tag>.+)$')
def entries_by_tag(self, request, tag, *args, **kwargs):
Expand All @@ -613,13 +696,7 @@ def entries_by_tag(self, request, tag, *args, **kwargs):
the tags to filter prior to rendering this page. Multiple
tags are specified as subpath: `/tags/tag1/tag2/...`
"""
terms = list(filter(None, re.split('/', tag)))

self.filtered = {
'type': 'tag',
'terms': terms
}

self.extract_tag_information(tag)
return IndexPage.serve(self, request, *args, **kwargs)


Expand All @@ -633,7 +710,6 @@ class BlogPageTag(TaggedItemBase):


class BlogPage(FoundationMetadataPageMixin, Page):
template = 'wagtailpages/blog_page.html'
Pomax marked this conversation as resolved.
Show resolved Hide resolved

author = models.CharField(
verbose_name='Author',
Expand All @@ -659,24 +735,19 @@ class BlogPage(FoundationMetadataPageMixin, Page):
('quote', customblocks.QuoteBlock()),
])

# Editor panels configuration
tags = ClusterTaggableManager(through=BlogPageTag, blank=True)

zen_nav = True

content_panels = Page.content_panels + [
FieldPanel('author'),
StreamFieldPanel('body'),
]

# Promote panels configuration
tags = ClusterTaggableManager(through=BlogPageTag, blank=True)

promote_panels = FoundationMetadataPageMixin.promote_panels + [
FieldPanel('tags'),
]

# Database fields

zen_nav = True

def get_context(self, request):
context = super().get_context(request)
context['related_posts'] = get_content_related_by_tag(self)
Expand Down
@@ -0,0 +1,9 @@
{% for entry in entries %}
{% with type=entry.specific_class.get_verbose_name|lower %}
{% if type == "blog page" %}
{% include "./blog-card.html" with page=entry %}
{% else %}
{% include "./generic-card.html" with page=entry %}
{% endif %}
{% endwith %}
{% endfor %}
Expand Up @@ -20,14 +20,12 @@ <h1 class="h1-heading mb-0 mt-1 pt-2">
</div>
</div>

{% if filtered %} {# Start of IF for filtered page content. Note that this injects an entire row. #}
<div class="row mb-4">
{% if filtered %} {# Start of IF for filtered page content. Note that this injects an entire row. #}
<div class="col-12 d-flex justify-content-between">
<div class="result-count">
<div class="search-result">
<h3 class="body result-info">
{% with count=entries|length %}
{{ count }} result{% if count != 1 %}s{% endif %} for
{% endwith %}
{{ total_entries }} result{% if count != 1 %}s{% endif %} for
<ul class="term-list">
{% for term in terms %}
<li class="term body-small">{{ term }}</li>
Expand All @@ -36,28 +34,31 @@ <h3 class="body result-info">
</h3>
</div>


<div class="clear-link">
<a href="{{ root.url }}">clear filters</a>
</div>
</div>
</div>

<div class="row">
{% endif %} {# End of IF for filtered page content _after_ starting a new row. #}

<div class="index-entries row">
{% else %}
<div class="index-entries row mb-4">
{% endif %} {# End of IF for filtered page content _after_ starting a new row. #}
{% block subcontent %}
{% for entry in entries %}
{% with type=entry.specific_class.get_verbose_name|lower %}
{% if type == "blog page" %}
{% include "./fragments/blog-card.html" with page=entry %}
{% else %}
{% include "./fragments/generic-card.html" with page=entry %}
{% endif %}
{% endwith %}
{% endfor %}

{% include "./fragments/entry_cards.html" %}
{% endblock %}
</div>

<div class="row">
<div class="col-12 text-center mb-5">
{% if has_more %}
<button class="btn btn-primary load-more-index-entries" data-page-size="{{ page.specific.page_size }}">
Load more results
</button>
{% endif %}

{# See main.js for the javascript that hooks into this button #}
</div>
</div>
</div>
{% endblock %}
45 changes: 45 additions & 0 deletions source/js/main.js
Expand Up @@ -520,6 +520,51 @@ let main = {

injectDonateModal(donationModal, modalOptions);
}

// Enable the "load more results" button on index pages
let loadMoreButton = document.querySelector(`.load-more-index-entries`);
if (loadMoreButton) {
const entries = document.querySelector(`.index-entries`);

// Get the page size from the document, which the IndexPage should
// have templated into its button as a data-page-size attribute.
const pageSize = parseInt(loadMoreButton.dataset.pageSize) || 12;

// Start at page 1, as page 0 is the same sat as the initial page set.
let page = 1;

const loadMoreResults = () => {
loadMoreButton.disabled = true;

// Construct our API call as a relative URL:
let url = `./entries/?page=${page++}&page_size=${pageSize}`;

// And then fetch the results and render them into the page.
fetch(url)
mmmavis marked this conversation as resolved.
Show resolved Hide resolved
.then(result => result.json())
.then(data => {
if (!data.has_next) {
loadMoreButton.removeEventListener(`click`, loadMoreResults);
loadMoreButton.parentNode.removeChild(loadMoreButton);
}
return data.entries_html;
})
.then(entries_html => {
const div = document.createElement(`div`);
div.innerHTML = entries_html;
Array.from(div.children).forEach(c => entries.appendChild(c));
})
Pomax marked this conversation as resolved.
Show resolved Hide resolved
.catch(err => {
// TODO: what do we want to do in this case?
console.error(err);
})
.finally(() => {
loadMoreButton.disabled = false;
});
};

loadMoreButton.addEventListener(`click`, loadMoreResults);
}
}
};

Expand Down