DEV Community: LB (Ben Johnston)The latest articles on DEV Community by LB (Ben Johnston) (@lb).
https://dev.to/lb
https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F237459%2F87f12b2c-3382-4479-b191-94d773c7221c.pngDEV Community: LB (Ben Johnston)
https://dev.to/lb
enOn mentoring for an an open-source internshipLB (Ben Johnston)Wed, 12 Oct 2022 21:10:28 +0000
https://dev.to/lb/on-mentoring-for-an-an-open-source-internship-2fca
https://dev.to/lb/on-mentoring-for-an-an-open-source-internship-2fca<p>This year I was able to take part in an open-source internship run alongside <a href="proxy.php?url=https://summerofcode.withgoogle.com/">Google Summer of Code</a> but sponsored by Torchbox. This internship allowed one new to open-source developer to submit a project proposal and then work alongside myself and others to build something core into Wagtail. <a href="proxy.php?url=https://wagtail.org/">Wagtail is an open-source CMS</a> built on Django.</p>
<p>It was a really big few months for me - while I was given paid time (4 hours) a week from my work, Virgin Australia, the time taken each week was much more. Especially when we had to consider multiple time zones across the team (UK, India, Australia). It was an incredibly rewarding experience though and taught me a lot about what it is like to help new developers get up to speed and when to push but also when to help with a bit of the solution.</p>
<p>It is easy to forget what it is like to write code or learn a framework for the first time, not just the first time in a new language but the first time completely.</p>
<p>While I have only been a full-time software developer for about five years, I have done programming in some form for many years before that.</p>
<p>At a previous job, not as a software developer, myself and a few others endeavoured to build a CRM for the church I worked at. We opted for <a href="proxy.php?url=http://www.web2py.com/">web2py</a> simply because it was the thing I had first learned after learning Python and had found incredible power in building something that people could use.</p>
<p>Learning this new framework meant learning so many things at once, and also intentionally or probably more so out of blissful ignorance, not learning some things. I never really picked up <code>git</code> until much later and was enamoured by the browser IDE that web2py shipped with, as it meant I did not have to think about that big unknown. To no one’s surprise, this caused many problems with writing directly to a production server without any version control history to fall back on.</p>
<p>These last few months I was able to get a somewhat fresh perspective on what it is like for all of us learn something new that is incredibly complex. You cannot possibly learn it all at once, nor can you really understand where to start learning and what bits matter now or matter later. It has reminded me of the importance of small goals and being ok with splitting those goals up even further until you can get some momentum.</p>
<p>I have had the pleasure over this time to be a mentor of Google Summer of Code (GSoC), technically Torchbox’s internship that ran alongside other GSoC projects for the Wagtail community. My employer, Virgin Australia, generously approved a set time each week for me to be a mentor and help a new developer get up to speed and contribute solidly to Wagtail (an open-source CMS built on Django).</p>
<p>During this project, I have been absolutely inspired by the tenacity and grit of Paarth who has gone from contributing small CSS / documentation fixes to building out full Django template components used across the entire CMS in a matter of weeks. Paarth was the student I got to mentor, he is in his third year of studies in one of the top tech universities in India. He worked incredibly hard even to get approved as a contributor, preparing a full project proposal from a reasonably ambiguous goal. That goal was to Unify the UX across large parts of Wagtail, originally we just called this ‘Apply page editor UX to other parts of Wagtail’ which did not do the enormity of the task justice. Paarth contributed close to 20 pull requests even before the project started, he advised me that he would keep his phone on loud notifications for only Github to keep on top of any new ‘good first issues’ that arose.</p>
<p>As we, Thibaud and Helen, got to know Paarth through the project I am glad to say that we created a bond and it was great for me to hear about his university life. I learned that Paarth had spent the first two years of his course remotely (due to the pandemic), and also how much harder he has had to work to get into uni and get into the course he wanted (well, almost, a course that is close to what he wanted). How far he lives from uni, an overnight train ride just to visit his family, along with many other things that have been hard for him but I had taken for granted as easy during my university days.</p>
<p>All of this alerted me to the fact that I have had it relatively easy here in Australia with my university life (a long time ago) and career. I have had to work hard but I have not had to fight or push in the same way Paarth has. In all of this I am thankful for the opportunities that have come my way but incredibly encouraged that there are legends out there like Paarth getting started while also wanting to be involved with open-source.</p>
<p>As for the project itself, it was an incredible success, with large parts of the Wagtail UI brought up to scratch with the new layouts, designs and cleaner HTML/CSS to manage. There were a lot of bug fixes, some existing and some introduced, along the way with a solid amount of unit tests added to support them. I was able to lean on the incredible advice of Helen and Thibaud from Torchbox in the UK, including when I sneaked off for a trip to Bali (yay) with my family. This helped me get different perspectives on how to approach problems and how to work in a large, long-lived codebase.</p>
<h2>
Core skills
</h2>
<p>Some of the things I was able to help guide.</p>
<ul>
<li>
<strong>Git</strong> - Despite my late learning of the power of git, this is something that came up again and again in our project. Botched rebases, switching branches, code conflicts, adding messages to commits and when a Git GUI is helpful.</li>
<li>
<strong>Linting</strong> - Wagtail has multiple layers of code checks and linting, including Python/JavaScript, HTML linting, being able to understand these errors, setting up your editor, running the formatter are all critical to faster development.</li>
<li>
<strong>Reading the issue</strong> - This is something we spent time on a lot, reading the issue raised (a bug or enhancement), learning to read it twice, asking questions and clarification, and the inevitable de-sope of things to future issues.</li>
<li>
<strong>Reproducibility of the issue or problem</strong> - When solving bugs, it was important to make sure that he could reproduce the issue and truly understand it, before going in to solve it.</li>
<li>
<strong>Unit tests</strong> - Writing, debugging, and refactoring unit tests is a whole universe of complexity but it is something that helps to ensure that improvements to the code can last longer and be more reliable for future development.</li>
<li>
<strong>Reading a file & writing field notes</strong> - We spent a bit of time walking through existing code, just reading it line by line and writing notes. One thing that helped was to encourage the idea of ‘field notes’, just summarising the file in your own words and also breaking it up into bullet points to help understand it.</li>
<li>
<strong>Make small changes and get early feedback</strong> - Instead of solving the whole thing at once, solve a small part of it and get feedback.</li>
</ul>
<h2>
Bonus learnings
</h2>
<p>Some things I learned for me along the way, even if it means a bit more empathy for others getting started.</p>
<ul>
<li>
<strong>SCSS/CSS is hard</strong> - I often see Twitter threads about CSS being easy/hard or not a ‘real’ language. It is incredibly complex and requires a lot of patience and understanding to build things well that work across the myriad of breakpoints and browsers.</li>
<li>
<strong>Long-lived code is a minefield of complexity</strong> - It is very easy to change things in one place and it breaks another place. This is something to not just know on the surface but really understand, it is especially complex for code that is used/extended by others like Wagtail.</li>
<li>
<strong>open-source code needs to consider maintainers</strong> - While the above may not be too different to any other large project. The key difference with a large open-source project is that there are a wider range of contributors with a wider range of skills to consider and also conflicting PRs and work is almost guaranteed when doing anything large.</li>
<li>
<strong>time zones add friction</strong> - I love remote work and even travelled for a year while working for companies back in Australia. However, when there are three time zones to consider (India, UK, AU) and working hours, uni classes and people with families it becomes very much a juggling act to line things up.</li>
<li>
<strong>Zoom has incredibly good noise cancellation</strong> - I tried many others and Zoom is the best, I want Google Hangouts (or whatever it is called now) to be great but nothing matched Zoom. Maybe Google should stop building new chat / call apps.</li>
<li>
<strong>Planning & Deadlines</strong> - Planning in sprints helped break up the work into blocks but in reality it did not follow that at all. Over the project, we found better things are 'themes' or 'blocks' of work in similar areas of code or similar goals with check-ins on those blocks every two weeks.</li>
<li>
<strong>Accessibility first</strong> - Building things well with accessibility baked in solves for a lot of problems later, you have a better DOM structure and more thought-out code to work with.</li>
<li>
<strong>Fast & slow</strong> - Some things are faster than you expect, some things are slower, often by orders of magnitude, your estimates will only be correct when the work is done.</li>
</ul>
<h2>
What was achieved
</h2>
<p>I will not steal the thunder from Paarth, but just call out some of the most exciting features and some stats from the project.</p>
<ul>
<li>Globally - Wagtail has an incredibly fresh UI, that feels much more consistent across parts of the code and is substantially more accessible and usable with other languages.</li>
<li>Two very old pull requests, from 2019 & 2020, were rebased with fixes and merged in.</li>
<li>Was able to close one of the oldest bugs in the project, from 2016.</li>
<li>Paarth moving from no contributions to the 21st most contributions - <a href="proxy.php?url=https://github.com/wagtail/wagtail/graphs/contributors">https://github.com/wagtail/wagtail/graphs/contributors</a>.</li>
<li>Authentication pages, we thought they would be out of scope originally but Paarth was able to get them done and they look so much better.</li>
<li>Less yelling - the first pull request from Paarth, even before being accepted, was to remove all the uppercase styling on buttons/menus - thanks for reducing the yelling in Wagtail. <a href="proxy.php?url=https://github.com/wagtail/wagtail/issues/7624">https://github.com/wagtail/wagtail/issues/7624</a>
</li>
<li>You can read <a href="proxy.php?url=https://wagtail.org/blog/open-source-internships-ux-unification/">Paarth's blog post about his open-source internship experience</a>.</li>
</ul>
<h2>
Acknowledgements
</h2>
<p>It would be remiss of me to not call out those involved throughout this project.</p>
<ul>
<li>
<strong>Paarth</strong> - Congrats on putting yourself out there and working incredibly hard every step of the way, even while going to uni in person for the first time and being away from your family.</li>
<li>
<strong>Thibaud</strong> - Thanks for joining so many of the calls, even when you were busy or about to go on leave. Thanks for always pushing back when needed on solutions that were a bit too overkill while also pushing the UI forward to be better for everyone.</li>
<li>
<strong>Helen</strong> - It was great to get to know you and thanks for the great guidance on our calls on how to work with various generations of CSS and classes.</li>
<li>
<strong>Virgin Australia</strong> - Thanks Ken & Annette, I was unsure what you would say to me asking for 4 hours a week for many months to do this and I really appreciate the trust you have for me to make this work.</li>
<li>
<strong>Torchbox</strong> - Thanks for sponsoring Paarth, even though we did not get all the GSoC slots and thanks for all the resources you put into the Wagtail community generally.</li>
<li>
<strong>Wagtail community</strong> - So many PRs had reviews from other core devs and along the way, there was even work in the UX Unification scope that got randomly contributed by others meaning we could do other things.</li>
<li>
<strong>Bec & Leo</strong> - My wife and son, you made it possible for me to do the late night & early morning calls and have been so supportive of my desire to help with open-source over the last many years.</li>
</ul>
<h2>
Round two?
</h2>
<p>In hindsight it would have been better to have a clearer goal, the project was a bit ambiguous which gave us room to move but felt like some things were not really solid enough to get good momentum on. I do feel that Paarth was an incredible contributor and took on the challenge well, pushing through, and was happy to give things a go a few different ways to see what worked the best.</p>
<p>This was an incredible opportunity to help make Wagtail better, help grow my skills and also get to know some great people, so yeah I would do it again!</p>
<h2>
More about this project
</h2>
<ul>
<li>A list of all things completed and a bit of a round up - more technical focus - on the <a href="proxy.php?url=https://github.com/wagtail/wagtail/discussions/8158#discussioncomment-3697162">UX Unification project discussion</a>
</li>
<li><a href="proxy.php?url=https://wagtail.org/blog/open-source-internships-ux-unification/">Wagtail - open-source internship: UX Unification</a></li>
</ul>
wagtailbeginnersopensourceprogrammingCreating an interactive event budgeting tool within WagtailLB (Ben Johnston)Tue, 04 Oct 2022 00:49:49 +0000
https://dev.to/lb/creating-an-interactive-event-budgeting-tool-within-wagtail-53b3
https://dev.to/lb/creating-an-interactive-event-budgeting-tool-within-wagtail-53b3<p>Wagtail 4.0 has recently been released and it comes with a much nicer design for managing nested InlinePanel (models) or StreamField components.</p>
<p>I thought it might be a good chance to see how we could go about using this new UI to manage more complex data within Wagtail.</p>
<p>This tutorial will walk you through a basic set-up of building an interactive event budgeting tool within the Wagtail page editing interface. While this may not be something that should be done, a spreadsheet may be better, it is a good simple example of nested models along with some JavaScript sprinkles to give the user some instant feedback on their entry.</p>
<h2>
Goal
</h2>
<ul>
<li> Build a basic event budgeting tool within the Wagtail page editor.</li>
<li> We should be able to enter fixed & variable (per person) prices, along with a sale price and see an estimate of how many tickets will need to be sold to break even.</li>
<li> We want the data to be stored in Django models so that we can work with this data server side easily.</li>
</ul>
<h2>
Tutorial
</h2>
<h3>
0. Getting started
</h3>
<ul>
<li> This tutorial assumes you have at least done the <a href="proxy.php?url=https://docs.wagtail.org/en/stable/getting_started/tutorial.html" rel="noopener noreferrer">Wagtail getting started tutorial</a>
</li>
<li> First, we will create a new Wagtail/Django project and then create an <code>events</code> app within that Django project - <code>python manage.py startapp events</code>
</li>
<li> Add <code>"events"</code> to <code>INSTALLED_APPS</code>
</li>
</ul>
<h4>
Versions
</h4>
<ul>
<li> Python 3.10</li>
<li> Wagtail 4.0.2</li>
<li> Django 4.1.1</li>
<li> Stimulus JS 3.1.0 (Node js is not required for this tutorial)</li>
</ul>
<h3>
1. Set up the data Model
</h3>
<ul>
<li> We will create the initial model and Panels, Wagtail provides a nice abstraction around the Django models and fields with the concept of Panels to provide editing form containers.</li>
<li> Wagtail provides an <a href="proxy.php?url=https://docs.wagtail.org/en/stable/reference/pages/panels.html#inline-panels" rel="noopener noreferrer"><code>InlinePanel</code></a> solution that allows nested inline data relations to be edited easily.</li>
<li> We will have two core models, the <code>EventPage</code> which extends the Wagtail <code>Page</code> model and contains things like the <code>Page</code> title and the ticket price.</li>
<li> Our second model will be a <code>ParentalKey</code> (from modelcluster) relation to the <code>EventPage</code> and be called <code>EventPageBudgetItem</code>, each <code>EventPage</code> can also have an orderable set of these budget item rows. The budget item row will have three fields, the description, amount and whether the price is per person (variable) or fixed.</li>
<li> Modify the file <code>events/models.py</code> to add our model, as below.</li>
<li> Run <code>python manage.py makemigrations</code> and then <code>python manage.py migrate</code>.</li>
<li> <strong>Cross-check</strong> Confirm you can now go to the Wagtail admin interface, create a new page and then create an <code>Event</code> page with the budget items.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="kn">from</span> <span class="n">django</span> <span class="kn">import</span> <span class="n">forms</span>
<span class="kn">from</span> <span class="n">django.db</span> <span class="kn">import</span> <span class="n">models</span>
<span class="kn">from</span> <span class="n">modelcluster.fields</span> <span class="kn">import</span> <span class="n">ParentalKey</span>
<span class="kn">from</span> <span class="n">wagtail.admin.panels</span> <span class="kn">import</span> <span class="n">FieldPanel</span><span class="p">,</span> <span class="n">FieldRowPanel</span><span class="p">,</span> <span class="n">InlinePanel</span><span class="p">,</span> <span class="n">MultiFieldPanel</span>
<span class="kn">from</span> <span class="n">wagtail.models</span> <span class="kn">import</span> <span class="n">Orderable</span><span class="p">,</span> <span class="n">Page</span>
<span class="k">class</span> <span class="nc">AbstractBudgetItem</span><span class="p">(</span><span class="n">models</span><span class="p">.</span><span class="n">Model</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
The abstract model for the budget item, complete with panels.
</span><span class="sh">"""</span>
<span class="k">class</span> <span class="nc">PriceType</span><span class="p">(</span><span class="n">models</span><span class="p">.</span><span class="n">TextChoices</span><span class="p">):</span>
<span class="n">PRICE_PER</span> <span class="o">=</span> <span class="sh">"</span><span class="s">PP</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Price per</span><span class="sh">"</span>
<span class="n">FIXED_PRICE</span> <span class="o">=</span> <span class="sh">"</span><span class="s">FP</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Fixed price</span><span class="sh">"</span>
<span class="n">description</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span>
<span class="sh">"</span><span class="s">Description</span><span class="sh">"</span><span class="p">,</span>
<span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">price_type</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span>
<span class="sh">"</span><span class="s">Price type</span><span class="sh">"</span><span class="p">,</span>
<span class="n">max_length</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span>
<span class="n">choices</span><span class="o">=</span><span class="n">PriceType</span><span class="p">.</span><span class="n">choices</span><span class="p">,</span>
<span class="n">default</span><span class="o">=</span><span class="n">PriceType</span><span class="p">.</span><span class="n">FIXED_PRICE</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">amount</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">DecimalField</span><span class="p">(</span>
<span class="sh">"</span><span class="s">Amount</span><span class="sh">"</span><span class="p">,</span>
<span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span>
<span class="n">max_digits</span><span class="o">=</span><span class="mi">6</span><span class="p">,</span>
<span class="n">decimal_places</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">panels</span> <span class="o">=</span> <span class="p">[</span>
<span class="nc">FieldRowPanel</span><span class="p">(</span>
<span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">description</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">price_type</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">amount</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>
<span class="p">)</span>
<span class="p">]</span>
<span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
<span class="n">abstract</span> <span class="o">=</span> <span class="bp">True</span>
<span class="k">class</span> <span class="nc">EventPageBudgetItem</span><span class="p">(</span><span class="n">Orderable</span><span class="p">,</span> <span class="n">AbstractBudgetItem</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
The real model which combines the abstract model, an
Orderable helper class, and what amounts to a ForeignKey link
to the model we want to add related links to (EventPage)
</span><span class="sh">"""</span>
<span class="n">page</span> <span class="o">=</span> <span class="nc">ParentalKey</span><span class="p">(</span>
<span class="sh">"</span><span class="s">events.EventPage</span><span class="sh">"</span><span class="p">,</span>
<span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">CASCADE</span><span class="p">,</span>
<span class="n">related_name</span><span class="o">=</span><span class="sh">"</span><span class="s">related_budget_items</span><span class="sh">"</span><span class="p">,</span>
<span class="p">)</span>
<span class="k">class</span> <span class="nc">EventPage</span><span class="p">(</span><span class="n">Page</span><span class="p">):</span>
<span class="n">ticket_price</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">DecimalField</span><span class="p">(</span>
<span class="sh">"</span><span class="s">Price</span><span class="sh">"</span><span class="p">,</span>
<span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span>
<span class="n">max_digits</span><span class="o">=</span><span class="mi">6</span><span class="p">,</span>
<span class="n">decimal_places</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">content_panels</span> <span class="o">=</span> <span class="n">Page</span><span class="p">.</span><span class="n">content_panels</span> <span class="o">+</span> <span class="p">[</span>
<span class="nc">MultiFieldPanel</span><span class="p">(</span>
<span class="p">[</span>
<span class="nc">InlinePanel</span><span class="p">(</span><span class="sh">"</span><span class="s">related_budget_items</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">ticket_price</span><span class="sh">"</span><span class="p">),</span>
<span class="p">],</span>
<span class="sh">"</span><span class="s">Budget</span><span class="sh">"</span><span class="p">,</span>
<span class="p">),</span>
<span class="p">]</span>
</code></pre>
</div>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5ryilf3hj1g6pgc8gwm5.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F5ryilf3hj1g6pgc8gwm5.png" alt="Page editing interface with basic fields added"></a></p>
<h2>
2. Set up the field widgets
</h2>
<ul>
<li> We will now make the admin interface a bit easier to use and add some data attributes to our fields so we can track them in JavaScript.</li>
<li> We will also avoid the <code>type="number"</code> field and make the numbers a bit easier to work with.</li>
<li> A note about <code>number</code> fields, Django will use the <code>type="number"</code> by default when you use a <code>Decimal</code> field, to keep things simple we will use a text field with some different attributes. See the <a href="proxy.php?url=https://technology.blog.gov.uk/2020/02/24/why-the-gov-uk-design-system-team-changed-the-input-type-for-numbers/" rel="noopener noreferrer">UK design system guidelines</a> and a deep dive on why the <a href="proxy.php?url=https://stackoverflow.blog/2022/09/15/why-the-number-input-is-the-worst-input/" rel="noopener noreferrer">number input is the worst input</a>.</li>
<li> For each of the <code>InlinePanel</code> inner <code>FieldPanel</code>s we will add a simple data attribute so that our JavaScript code can be implemented easily without having to know about the field name / id on the elements.</li>
<li> For the ticket price field we will use a more specific data attribute <code>"data-budget-target": "ticketPrice",</code> so that it is easier to read this value in our Stimulus js controller (more on that later).</li>
<li> <strong>Cross-check</strong> Once updated, you should be able to see that your number fields look more like text fields and when inspecting the DOM you should be able to see the <code>data-*</code> attributes on each field.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="kn">from</span> <span class="n">django</span> <span class="kn">import</span> <span class="n">forms</span>
<span class="kn">from</span> <span class="n">django.db</span> <span class="kn">import</span> <span class="n">models</span>
<span class="kn">from</span> <span class="n">modelcluster.fields</span> <span class="kn">import</span> <span class="n">ParentalKey</span>
<span class="kn">from</span> <span class="n">wagtail.admin.panels</span> <span class="kn">import</span> <span class="n">FieldPanel</span><span class="p">,</span> <span class="n">FieldRowPanel</span><span class="p">,</span> <span class="n">InlinePanel</span><span class="p">,</span> <span class="n">MultiFieldPanel</span>
<span class="kn">from</span> <span class="n">wagtail.models</span> <span class="kn">import</span> <span class="n">Orderable</span><span class="p">,</span> <span class="n">Page</span>
<span class="c1"># added
</span><span class="n">NUMBER_FIELD_ATTRS</span> <span class="o">=</span> <span class="p">{</span>
<span class="sh">"</span><span class="s">inputmode</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">numeric</span><span class="sh">"</span><span class="p">,</span>
<span class="sh">"</span><span class="s">pattern</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">[0-9.]*</span><span class="sh">"</span><span class="p">,</span>
<span class="sh">"</span><span class="s">type</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">text</span><span class="sh">"</span><span class="p">,</span>
<span class="p">}</span>
<span class="k">class</span> <span class="nc">AbstractBudgetItem</span><span class="p">(</span><span class="n">models</span><span class="p">.</span><span class="n">Model</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
The abstract model for the budget item, complete with panels.
</span><span class="sh">"""</span>
<span class="k">class</span> <span class="nc">PriceType</span><span class="p">(</span><span class="n">models</span><span class="p">.</span><span class="n">TextChoices</span><span class="p">):</span>
<span class="n">PRICE_PER</span> <span class="o">=</span> <span class="sh">"</span><span class="s">PP</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Price per</span><span class="sh">"</span>
<span class="n">FIXED_PRICE</span> <span class="o">=</span> <span class="sh">"</span><span class="s">FP</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Fixed price</span><span class="sh">"</span>
<span class="n">description</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span>
<span class="sh">"</span><span class="s">Description</span><span class="sh">"</span><span class="p">,</span>
<span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">price_type</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span>
<span class="sh">"</span><span class="s">Price type</span><span class="sh">"</span><span class="p">,</span>
<span class="n">max_length</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span>
<span class="n">choices</span><span class="o">=</span><span class="n">PriceType</span><span class="p">.</span><span class="n">choices</span><span class="p">,</span>
<span class="n">default</span><span class="o">=</span><span class="n">PriceType</span><span class="p">.</span><span class="n">FIXED_PRICE</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">amount</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">DecimalField</span><span class="p">(</span>
<span class="sh">"</span><span class="s">Amount</span><span class="sh">"</span><span class="p">,</span>
<span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span>
<span class="n">max_digits</span><span class="o">=</span><span class="mi">6</span><span class="p">,</span>
<span class="n">decimal_places</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">panels</span> <span class="o">=</span> <span class="p">[</span>
<span class="nc">FieldRowPanel</span><span class="p">(</span>
<span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span>
<span class="sh">"</span><span class="s">description</span><span class="sh">"</span><span class="p">,</span>
<span class="c1"># updated - using widget
</span> <span class="n">widget</span><span class="o">=</span><span class="n">forms</span><span class="p">.</span><span class="nc">TextInput</span><span class="p">(</span><span class="n">attrs</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">data-description</span><span class="sh">"</span><span class="p">:</span> <span class="sh">""</span><span class="p">}),</span>
<span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span>
<span class="sh">"</span><span class="s">price_type</span><span class="sh">"</span><span class="p">,</span>
<span class="c1"># updated - using widget
</span> <span class="n">widget</span><span class="o">=</span><span class="n">forms</span><span class="p">.</span><span class="nc">Select</span><span class="p">(</span><span class="n">attrs</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">data-type</span><span class="sh">"</span><span class="p">:</span> <span class="sh">""</span><span class="p">}),</span>
<span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span>
<span class="sh">"</span><span class="s">amount</span><span class="sh">"</span><span class="p">,</span>
<span class="c1"># updated - using widget
</span> <span class="n">widget</span><span class="o">=</span><span class="n">forms</span><span class="p">.</span><span class="nc">TextInput</span><span class="p">(</span>
<span class="n">attrs</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">data-amount</span><span class="sh">"</span><span class="p">:</span> <span class="sh">""</span><span class="p">,</span> <span class="o">**</span><span class="n">NUMBER_FIELD_ATTRS</span><span class="p">}</span>
<span class="p">),</span>
<span class="p">),</span>
<span class="p">]</span>
<span class="p">)</span>
<span class="p">]</span>
<span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
<span class="n">abstract</span> <span class="o">=</span> <span class="bp">True</span>
<span class="k">class</span> <span class="nc">EventPageBudgetItem</span><span class="p">(</span><span class="n">Orderable</span><span class="p">,</span> <span class="n">AbstractBudgetItem</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
The real model which combines the abstract model, an
Orderable helper class, and what amounts to a ForeignKey link
to the model we want to add related links to (EventPage)
</span><span class="sh">"""</span>
<span class="n">page</span> <span class="o">=</span> <span class="nc">ParentalKey</span><span class="p">(</span>
<span class="sh">"</span><span class="s">events.EventPage</span><span class="sh">"</span><span class="p">,</span>
<span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">CASCADE</span><span class="p">,</span>
<span class="n">related_name</span><span class="o">=</span><span class="sh">"</span><span class="s">related_budget_items</span><span class="sh">"</span><span class="p">,</span>
<span class="p">)</span>
<span class="k">class</span> <span class="nc">EventPage</span><span class="p">(</span><span class="n">Page</span><span class="p">):</span>
<span class="n">ticket_price</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">DecimalField</span><span class="p">(</span>
<span class="sh">"</span><span class="s">Price</span><span class="sh">"</span><span class="p">,</span>
<span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span>
<span class="n">max_digits</span><span class="o">=</span><span class="mi">6</span><span class="p">,</span>
<span class="n">decimal_places</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">content_panels</span> <span class="o">=</span> <span class="n">Page</span><span class="p">.</span><span class="n">content_panels</span> <span class="o">+</span> <span class="p">[</span>
<span class="nc">MultiFieldPanel</span><span class="p">(</span>
<span class="p">[</span>
<span class="nc">InlinePanel</span><span class="p">(</span><span class="sh">"</span><span class="s">related_budget_items</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span>
<span class="sh">"</span><span class="s">ticket_price</span><span class="sh">"</span><span class="p">,</span>
<span class="c1"># updated - using widget
</span> <span class="n">widget</span><span class="o">=</span><span class="n">forms</span><span class="p">.</span><span class="nc">TextInput</span><span class="p">(</span>
<span class="n">attrs</span><span class="o">=</span><span class="p">{</span>
<span class="sh">"</span><span class="s">data-budget-target</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">ticketPrice</span><span class="sh">"</span><span class="p">,</span>
<span class="o">**</span><span class="n">NUMBER_FIELD_ATTRS</span><span class="p">,</span>
<span class="p">}</span>
<span class="p">),</span>
<span class="p">),</span>
<span class="p">],</span>
<span class="sh">"</span><span class="s">Budget</span><span class="sh">"</span><span class="p">,</span>
<span class="p">),</span>
<span class="p">]</span>
</code></pre>
</div>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4qtckq6mrpgdc42ck3qd.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F4qtckq6mrpgdc42ck3qd.png" alt="Page editing interface with better widgets"></a></p>
<h2>
3. Set up a custom wrapper Panel container
</h2>
<ul>
<li> We will need a nice way to add some kind of summary of the totals for the user, there are a few ways to do this. One way could be via the <a href="proxy.php?url=https://docs.wagtail.org/en/stable/reference/pages/panels.html#helppanel" rel="noopener noreferrer"><code>HelpPanel</code></a> which lets us add arbitrary Django templates to the panel set.</li>
<li> However, a nicer way for what we need is to extend the <code>MultiFieldPanel</code> with a custom template and some additional context passed to that template.</li>
<li> First, we will create a new <code>BudgetGroupPanel</code> that extends the <code>MultiFieldPanel</code>, in a new file <code>events/panels.py</code>.</li>
<li> This file lets us refer to a different template and also inject some extra data into the context.</li>
<li> We will use the <code>field_ids</code> so that we can provide a better experience for users with the <a href="proxy.php?url=https://developer.mozilla.org/en-US/docs/Web/HTML/Element/output" rel="noopener noreferrer"><code>output</code></a> element.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># events/panels.py
</span><span class="kn">from</span> <span class="n">django.forms</span> <span class="kn">import</span> <span class="n">MultiValueField</span>
<span class="kn">from</span> <span class="n">wagtail.admin.panels</span> <span class="kn">import</span> <span class="n">MultiFieldPanel</span>
<span class="k">class</span> <span class="nc">BudgetGroupPanel</span><span class="p">(</span><span class="n">MultiFieldPanel</span><span class="p">):</span>
<span class="k">class</span> <span class="nc">BoundPanel</span><span class="p">(</span><span class="n">MultiFieldPanel</span><span class="p">.</span><span class="n">BoundPanel</span><span class="p">):</span>
<span class="n">template_name</span> <span class="o">=</span> <span class="sh">"</span><span class="s">events/budget_group_panel.html</span><span class="sh">"</span>
<span class="k">def</span> <span class="nf">get_context_data</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">parent_context</span><span class="o">=</span><span class="bp">None</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Prepare a list of ids so that we can reference them in the
output.
</span><span class="sh">"""</span>
<span class="n">context</span> <span class="o">=</span> <span class="nf">super</span><span class="p">().</span><span class="nf">get_context_data</span><span class="p">(</span><span class="n">parent_context</span><span class="p">)</span>
<span class="n">context</span><span class="p">[</span><span class="sh">"</span><span class="s">field_ids</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="nf">filter</span><span class="p">(</span>
<span class="bp">None</span><span class="p">,</span> <span class="p">[</span><span class="n">child</span><span class="p">.</span><span class="nf">id_for_label</span><span class="p">()</span> <span class="k">for</span> <span class="n">child</span> <span class="ow">in</span> <span class="n">self</span><span class="p">.</span><span class="n">visible_children</span><span class="p">]</span>
<span class="p">)</span>
<span class="k">return</span> <span class="n">context</span>
</code></pre>
</div>
<ul>
<li> Next, we will need to prepare the template, create a file in your app's templates folder <code>events/templates/events/budget_group_panel.html</code>.</li>
<li> This HTML has some data attributes that tell our JavaScript what to attach to, along with when to update.</li>
<li> We are using <code>include</code> to include the original Wagtail <code>multi_field_panel</code> template inside our div wrapper.</li>
<li> We are using the <code>output</code> element and a suitable <code>h3</code> title to present the sub-totals and budget estimate to the user.</li>
<li> Finally, these budget totals also have data attributes to advise our JavaScript code where to inject the values.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight html"><code><span class="nt"><div</span>
<span class="na">data-controller=</span><span class="s">"budget"</span>
<span class="na">data-action=</span><span class="s">"change->budget#updateTotals"</span>
<span class="na">data-budget-per-price-value=</span><span class="s">"PP"</span>
<span class="nt">></span>
{% include "wagtailadmin/panels/multi_field_panel.html" %}
<span class="nt"><output</span> <span class="na">for=</span><span class="s">"{{ field_ids|join:' ' }}"</span><span class="nt">></span>
<span class="nt"><h3></span>Budget summary<span class="nt"></h3></span>
<span class="nt"><dl></span>
<span class="nt"><dt></span>Total price per<span class="nt"></dt></span>
<span class="nt"><dd</span> <span class="na">data-budget-target=</span><span class="s">"totalPricePer"</span><span class="nt">></span>-<span class="nt"></dd></span>
<span class="nt"><dt></span>Total fixed<span class="nt"></dt></span>
<span class="nt"><dd</span> <span class="na">data-budget-target=</span><span class="s">"totalFixed"</span><span class="nt">></span>-<span class="nt"></dd></span>
<span class="nt"><dt></span>Break even qty<span class="nt"></dt></span>
<span class="nt"><dd</span> <span class="na">data-budget-target=</span><span class="s">"breakEven"</span><span class="nt">></span>-<span class="nt"></dd></span>
<span class="nt"></dl></span>
<span class="nt"></output></span>
<span class="nt"></div></span>
</code></pre>
</div>
<ul>
<li> Finally, we need to use this custom panel in our <code>EventPage</code> model.</li>
<li> This will be a simple replacement of the <code>MultiFieldPanel</code> with our <code>BudgetGroupPanel</code>.</li>
<li> <strong>Cross-check</strong> Once updated, you should now be able to see the <code>output</code> content and also check the DOM for the relevant data attributes and <code>for</code> attribute.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># events/models.py
</span><span class="kn">from</span> <span class="n">django</span> <span class="kn">import</span> <span class="n">forms</span>
<span class="kn">from</span> <span class="n">django.db</span> <span class="kn">import</span> <span class="n">models</span>
<span class="kn">from</span> <span class="n">modelcluster.fields</span> <span class="kn">import</span> <span class="n">ParentalKey</span>
<span class="kn">from</span> <span class="n">wagtail.admin.panels</span> <span class="kn">import</span> <span class="n">FieldPanel</span><span class="p">,</span> <span class="n">FieldRowPanel</span><span class="p">,</span> <span class="n">InlinePanel</span> <span class="c1"># updated, removing MultiFieldPanel
</span><span class="kn">from</span> <span class="n">wagtail.models</span> <span class="kn">import</span> <span class="n">Orderable</span><span class="p">,</span> <span class="n">Page</span>
<span class="kn">from</span> <span class="n">.panels</span> <span class="kn">import</span> <span class="n">BudgetGroupPanel</span> <span class="c1"># added
</span>
<span class="c1"># ... other models
</span>
<span class="k">class</span> <span class="nc">EventPage</span><span class="p">(</span><span class="n">Page</span><span class="p">):</span>
<span class="n">ticket_price</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">DecimalField</span><span class="p">(</span>
<span class="sh">"</span><span class="s">Price</span><span class="sh">"</span><span class="p">,</span>
<span class="n">default</span><span class="o">=</span><span class="mi">0</span><span class="p">,</span>
<span class="n">max_digits</span><span class="o">=</span><span class="mi">6</span><span class="p">,</span>
<span class="n">decimal_places</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">content_panels</span> <span class="o">=</span> <span class="n">Page</span><span class="p">.</span><span class="n">content_panels</span> <span class="o">+</span> <span class="p">[</span>
<span class="nc">BudgetGroupPanel</span><span class="p">(</span>
<span class="p">[</span>
<span class="nc">InlinePanel</span><span class="p">(</span><span class="sh">"</span><span class="s">related_budget_items</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span>
<span class="sh">"</span><span class="s">ticket_price</span><span class="sh">"</span><span class="p">,</span>
<span class="n">widget</span><span class="o">=</span><span class="n">forms</span><span class="p">.</span><span class="nc">TextInput</span><span class="p">(</span>
<span class="n">attrs</span><span class="o">=</span><span class="p">{</span>
<span class="sh">"</span><span class="s">data-budget-target</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">ticketPrice</span><span class="sh">"</span><span class="p">,</span>
<span class="o">**</span><span class="n">NUMBER_FIELD_ATTRS</span><span class="p">,</span>
<span class="p">}</span>
<span class="p">),</span>
<span class="p">),</span>
<span class="p">],</span>
<span class="sh">"</span><span class="s">Budget</span><span class="sh">"</span><span class="p">,</span>
<span class="p">),</span>
<span class="p">]</span>
</code></pre>
</div>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvxd1zdt91jvnx4xc1sma.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fvxd1zdt91jvnx4xc1sma.png" alt="Page editing interface with output container"></a></p>
<h2>
4. Set up JavaScript sprinkles
</h2>
<p>There are a few good libraries out there that provide ways to add JavaScript 'sprinkles' existing HTML without the need to overhaul your entire system with something like React or Vue. What you use here is an architectural and tooling choice, but the underlying JavaScript that needs to be written is essentially the same.</p>
<ul>
<li> We need a way to load JavaScript code.</li>
<li> We need to ensure we can attach the JavaScript listeners/behaviour to the right elements.</li>
<li> We need a way to do some JavaScript calculations based on the user's values that are entered, also consider when a user re-orders/removes an inline panel item.</li>
<li> We need a way to output the results of these calculations to the DOM at the desired elements.</li>
</ul>
<p>All of this can be done in React, Vue, Angular, Alpine, jQuery and even JavaScript without any libraries at all. However, Stimulus gives us a nice API that moves the 'JavaScript attaching of behaviour' into the HTML and the 'doing logic stuff' into the JavaScript quite nicely.</p>
<p>You can read more about <a href="proxy.php?url=https://stimulus.hotwired.dev/reference/controllers" rel="noopener noreferrer">Stimulus Controllers in their documentation</a>.</p>
<p>If you would like to see this tutorial written with other JavaScript libraries, let me know in the comments and we can explore for comparison.</p>
<p>Now, let's write some JavaScript.</p>
<ul>
<li> Create a new file <code>events/static/js/events.js</code> that will house our Stimulus Controller.</li>
<li> We will use the <code>import / from</code> syntax that is available in modern browsers and import the Stimulus library directly from <code>unpkg</code>. For production projects, it would be better to serve this core module from your own project's static files.</li>
<li> The controller below will attach to any element that loads with the <code>data-controller="budget"</code> data attribute (we put this in our budget group panel).</li>
<li> We wll further refine the JS in the next step, for now let's focus on getting it loading.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="k">import</span> <span class="p">{</span>
<span class="nx">Application</span><span class="p">,</span>
<span class="nx">Controller</span><span class="p">,</span>
<span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">https://unpkg.com/@hotwired/[email protected]/dist/stimulus.js</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">class</span> <span class="nc">BudgetController</span> <span class="kd">extends</span> <span class="nc">Controller</span> <span class="p">{</span>
<span class="kd">static</span> <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span>
<span class="dl">"</span><span class="s2">breakEven</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">ticketPrice</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">totalFixed</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">totalPricePer</span><span class="dl">"</span><span class="p">,</span>
<span class="p">];</span>
<span class="kd">static</span> <span class="nx">values</span> <span class="o">=</span> <span class="p">{</span> <span class="na">perPrice</span><span class="p">:</span> <span class="nb">String</span> <span class="p">};</span>
<span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
<span class="k">this</span><span class="p">.</span><span class="nf">updateTotals</span><span class="p">();</span>
<span class="p">}</span>
<span class="cm">/**
* Update the DOM targets with the calculated totals.
*/</span>
<span class="nf">updateTotals</span><span class="p">()</span> <span class="p">{</span>
<span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">updating totals with dummy values</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">breakEven</span> <span class="o">=</span> <span class="mi">45</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">totalFixed</span> <span class="o">=</span> <span class="mi">56</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">totalPricePer</span> <span class="o">=</span> <span class="mi">78</span><span class="p">;</span>
<span class="k">this</span><span class="p">.</span><span class="nx">totalPricePerTarget</span><span class="p">.</span><span class="nx">innerText</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">totalPricePer</span> <span class="o">||</span> <span class="dl">"</span><span class="s2">-</span><span class="dl">"</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
<span class="k">this</span><span class="p">.</span><span class="nx">totalFixedTarget</span><span class="p">.</span><span class="nx">innerText</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">totalFixed</span> <span class="o">||</span> <span class="dl">"</span><span class="s2">-</span><span class="dl">"</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
<span class="k">this</span><span class="p">.</span><span class="nx">breakEvenTarget</span><span class="p">.</span><span class="nx">innerText</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">breakEven</span> <span class="o">||</span> <span class="dl">"</span><span class="s2">-</span><span class="dl">"</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="kd">const</span> <span class="nx">Stimulus</span> <span class="o">=</span> <span class="nx">Application</span><span class="p">.</span><span class="nf">start</span><span class="p">();</span>
<span class="nx">Stimulus</span><span class="p">.</span><span class="nf">register</span><span class="p">(</span><span class="dl">"</span><span class="s2">budget</span><span class="dl">"</span><span class="p">,</span> <span class="nx">BudgetController</span><span class="p">);</span>
</code></pre>
</div>
<ul>
<li> Wagtail provides a simple way to load JavaScript into the page editor using the <a href="proxy.php?url=https://docs.wagtail.org/en/stable/reference/hooks.html#insert-editor-js" rel="noopener noreferrer"><code>insert_editor_js</code> hook</a>.</li>
<li> Create a new file <code>events/wagtail_hooks.py</code> and add the following code.</li>
<li> This will load a <code>module</code> script that pulls in our js file.</li>
<li> <strong>Cross-check</strong> Once updated, reload the dev server and then check the event page editing. You should see the dummy values load and the console should log out the message from our controller.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="kn">from</span> <span class="n">django.templatetags.static</span> <span class="kn">import</span> <span class="n">static</span>
<span class="kn">from</span> <span class="n">django.utils.html</span> <span class="kn">import</span> <span class="n">format_html</span>
<span class="kn">from</span> <span class="n">wagtail</span> <span class="kn">import</span> <span class="n">hooks</span>
<span class="nd">@hooks.register</span><span class="p">(</span><span class="sh">"</span><span class="s">insert_editor_js</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">editor_css</span><span class="p">():</span>
<span class="k">return</span> <span class="nf">format_html</span><span class="p">(</span>
<span class="sh">'</span><span class="s"><script type=</span><span class="sh">"</span><span class="s">module</span><span class="sh">"</span><span class="s"> src=</span><span class="sh">"</span><span class="s">{}</span><span class="sh">"</span><span class="s">></script></span><span class="sh">'</span><span class="p">,</span>
<span class="nf">static</span><span class="p">(</span><span class="sh">"</span><span class="s">js/events.js</span><span class="sh">"</span><span class="p">),</span>
<span class="p">)</span>
</code></pre>
</div>
<h2>
5. Add total calculation logic to JavaScript
</h2>
<ul>
<li> Update <code>events/static/js/events.js</code> to the following code, we will walk through it after the code snippet.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="k">import</span> <span class="p">{</span>
<span class="nx">Application</span><span class="p">,</span>
<span class="nx">Controller</span><span class="p">,</span>
<span class="p">}</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">https://unpkg.com/@hotwired/[email protected]/dist/stimulus.js</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">class</span> <span class="nc">BudgetController</span> <span class="kd">extends</span> <span class="nc">Controller</span> <span class="p">{</span>
<span class="kd">static</span> <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span>
<span class="dl">"</span><span class="s2">breakEven</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">ticketPrice</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">totalFixed</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">totalPricePer</span><span class="dl">"</span><span class="p">,</span>
<span class="p">];</span>
<span class="kd">static</span> <span class="nx">values</span> <span class="o">=</span> <span class="p">{</span> <span class="na">perPrice</span><span class="p">:</span> <span class="nb">String</span> <span class="p">};</span>
<span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
<span class="k">this</span><span class="p">.</span><span class="nf">updateTotals</span><span class="p">();</span>
<span class="p">}</span>
<span class="cm">/**
* Parse the inline panel children that are not hidden and read the inner field
* values, parsing the values into usable JS results.
*/</span>
<span class="kd">get</span> <span class="nf">items</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">inlinePanelChildren</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">element</span><span class="p">.</span><span class="nf">querySelectorAll</span><span class="p">(</span>
<span class="dl">"</span><span class="s2">[data-inline-panel-child]:not(.deleted)</span><span class="dl">"</span>
<span class="p">);</span>
<span class="k">return</span> <span class="p">[...</span><span class="nx">inlinePanelChildren</span><span class="p">].</span><span class="nf">map</span><span class="p">((</span><span class="nx">element</span><span class="p">)</span> <span class="o">=></span> <span class="p">({</span>
<span class="na">amount</span><span class="p">:</span> <span class="nf">parseFloat</span><span class="p">(</span>
<span class="nx">element</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">[data-amount]</span><span class="dl">"</span><span class="p">).</span><span class="nx">value</span> <span class="o">||</span> <span class="dl">"</span><span class="s2">0</span><span class="dl">"</span>
<span class="p">),</span>
<span class="na">description</span><span class="p">:</span>
<span class="nx">element</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">[data-description]</span><span class="dl">"</span><span class="p">).</span><span class="nx">value</span> <span class="o">||</span> <span class="dl">""</span><span class="p">,</span>
<span class="na">type</span><span class="p">:</span> <span class="nx">element</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">[data-type]</span><span class="dl">"</span><span class="p">).</span><span class="nx">value</span><span class="p">,</span>
<span class="p">}));</span>
<span class="p">}</span>
<span class="cm">/**
* parse ticket price and prepare the totals object to show a summary of
* totals in the items and the break even quantity required.
*/</span>
<span class="kd">get</span> <span class="nf">totals</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">perPriceValue</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">perPriceValue</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">items</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">items</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">ticketPrice</span> <span class="o">=</span> <span class="nf">parseFloat</span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">ticketPriceTarget</span><span class="p">.</span><span class="nx">value</span> <span class="o">||</span> <span class="dl">"</span><span class="s2">0</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">totalPricePer</span><span class="p">,</span> <span class="nx">totalFixed</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">items</span><span class="p">.</span><span class="nf">reduce</span><span class="p">(</span>
<span class="p">(</span>
<span class="p">{</span> <span class="na">totalPricePer</span><span class="p">:</span> <span class="nx">pp</span> <span class="o">=</span> <span class="mi">0</span><span class="p">,</span> <span class="na">totalFixed</span><span class="p">:</span> <span class="nx">pf</span> <span class="o">=</span> <span class="mi">0</span> <span class="p">},</span>
<span class="p">{</span> <span class="nx">amount</span><span class="p">,</span> <span class="nx">type</span> <span class="p">}</span>
<span class="p">)</span> <span class="o">=></span> <span class="p">({</span>
<span class="na">totalPricePer</span><span class="p">:</span> <span class="nx">type</span> <span class="o">===</span> <span class="nx">perPriceValue</span> <span class="p">?</span> <span class="nx">pp</span> <span class="o">+</span> <span class="nx">amount</span> <span class="p">:</span> <span class="nx">pp</span><span class="p">,</span>
<span class="na">totalFixed</span><span class="p">:</span> <span class="nx">type</span> <span class="o">===</span> <span class="nx">perPriceValue</span> <span class="p">?</span> <span class="nx">pf</span> <span class="p">:</span> <span class="nx">pf</span> <span class="o">+</span> <span class="nx">amount</span><span class="p">,</span>
<span class="p">}),</span>
<span class="p">{}</span>
<span class="p">);</span>
<span class="kd">const</span> <span class="nx">totals</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">breakEven</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nx">ticketPrice</span><span class="p">,</span>
<span class="nx">totalFixed</span><span class="p">,</span>
<span class="nx">totalPricePer</span><span class="p">,</span>
<span class="p">};</span>
<span class="c1">// do not attempt to show a break even if there is no ticket price</span>
<span class="k">if </span><span class="p">(</span><span class="nx">ticketPrice</span> <span class="o"><=</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span> <span class="nx">totals</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">ticketMargin</span> <span class="o">=</span> <span class="nx">ticketPrice</span> <span class="o">-</span> <span class="nx">totalPricePer</span><span class="p">;</span>
<span class="c1">// do not attempt to show a break even if ticket price does not cover price per</span>
<span class="k">if </span><span class="p">(</span><span class="nx">ticketMargin</span> <span class="o"><=</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span> <span class="nx">totals</span><span class="p">;</span>
<span class="nx">totals</span><span class="p">.</span><span class="nx">breakEven</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nf">ceil</span><span class="p">(</span><span class="nx">totalFixed</span> <span class="o">/</span> <span class="nx">ticketMargin</span><span class="p">);</span>
<span class="k">return</span> <span class="nx">totals</span><span class="p">;</span>
<span class="p">}</span>
<span class="cm">/**
* Update the DOM targets with the calculated totals.
*/</span>
<span class="nf">updateTotals</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">breakEven</span><span class="p">,</span> <span class="nx">totalFixed</span><span class="p">,</span> <span class="nx">totalPricePer</span> <span class="p">}</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">totals</span><span class="p">;</span>
<span class="k">this</span><span class="p">.</span><span class="nx">totalPricePerTarget</span><span class="p">.</span><span class="nx">innerText</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">totalPricePer</span> <span class="o">||</span> <span class="dl">"</span><span class="s2">-</span><span class="dl">"</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
<span class="k">this</span><span class="p">.</span><span class="nx">totalFixedTarget</span><span class="p">.</span><span class="nx">innerText</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">totalFixed</span> <span class="o">||</span> <span class="dl">"</span><span class="s2">-</span><span class="dl">"</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
<span class="k">this</span><span class="p">.</span><span class="nx">breakEvenTarget</span><span class="p">.</span><span class="nx">innerText</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">breakEven</span> <span class="o">||</span> <span class="dl">"</span><span class="s2">-</span><span class="dl">"</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="kd">const</span> <span class="nx">Stimulus</span> <span class="o">=</span> <span class="nx">Application</span><span class="p">.</span><span class="nf">start</span><span class="p">();</span>
<span class="nx">Stimulus</span><span class="p">.</span><span class="nf">register</span><span class="p">(</span><span class="dl">"</span><span class="s2">budget</span><span class="dl">"</span><span class="p">,</span> <span class="nx">BudgetController</span><span class="p">);</span>
</code></pre>
</div>
<ul>
<li> <code>static targets</code> - Setting up <a href="proxy.php?url=https://stimulus.hotwired.dev/reference/targets" rel="noopener noreferrer">Targets</a> is a convenient way to provide access to specific elements in the DOM from our controller instance. Remember we put these attributes in the HTML template or our widget <code>attrs</code>, each target can now be accessed from the Controller via <code>this.breakEvenTarget</code> or similar.</li>
<li> <code>connect</code> method - This gets called when the Controller is instantiated with a DOM element, it is like <code>constructor</code> in that it runs early and only once.</li>
<li> <code>items</code> getter - This is a custom method that pulls in any non-hidden <code>InlinePanel</code> children and then reads the inner values via simple data attributes. Note that we are using <code>this.element</code> here, which will be the budget group panel with the <code>data-controller</code> set on it.</li>
<li> <code>totals</code> getter - This does all the bulk calculations, working out the break-even price, the sub-totals and returning an object to be used when updating the DOM. This JavaScript would be essentially the same irrespective of what library is used.</li>
<li> <code>updateTotals</code> method - This does the 'work' of updating the DOM, we have kept this method light for readability, it also adds some nicer default values if we get missing/<code>undefined</code> results.</li>
<li> Note: The <code>updateTotals</code> method gets triggered as an <a href="proxy.php?url=https://stimulus.hotwired.dev/reference/actions" rel="noopener noreferrer"><code>action</code></a> because of this line in our HTML <code>data-action="proxy.php?url=change->budget#updateTotals"</code>. This tells Stimulus that whenever any DOM element fires the <code>change</code> event within the group panel container, trigger the <code>updateTotals</code> method. We could enhance this further with <code>data-action="proxy.php?url=change->budget#updateTotals focusout->budget#updateTotals"</code> which will trigger whenever any <code>focusout</code> event also occurs.</li>
<li> <strong>Cross-check</strong> Now, when you refresh, you should be able to see that your totals are being calculated correctly.</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc7cx9xrz64nfsyaywhab.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fc7cx9xrz64nfsyaywhab.png" alt="Page editing interface with calculations working"></a></p>
<h2>
Next steps
</h2>
<p>There are a few avenues for improvement, at some point though an external application may be more suitable.</p>
<ul>
<li> We may want to provide other totals/calculations or even a profit margin value to the <code>output</code>.</li>
<li> We may want to trigger the <code>updateTotals</code> method more frequently based on the user interaction.</li>
<li> We could even add a hidden field that determines if the event will make a profit and block submitting if the price is not suitable.</li>
<li> Graphs!</li>
</ul>
<p>For a simple planning tool and especially if the data is already being stored in Wagtail, this is a powerful way to make the editing interface a bit more reactive for users.</p>
<p>Any feedback below in the comments would be appreciated.</p>
<h2>
Further reading
</h2>
<ul>
<li> Full code snippets are available at this <a href="proxy.php?url=https://gist.github.com/lb-/276ac8b60565e106f5e94ca6d608c80c" rel="noopener noreferrer">gist</a>.</li>
<li> You can read more about the <a href="proxy.php?url=https://docs.wagtail.org/en/stable/releases/4.0.html" rel="noopener noreferrer">Wagtail 4.0 release</a>.</li>
<li> Read more about the <a href="proxy.php?url=https://stimulus.hotwired.dev/handbook/origin" rel="noopener noreferrer">origin of Stimulus js</a>.</li>
</ul>
wagtailjavascriptdjangostimulusTen tasty ingredients for a delicious pull requestLB (Ben Johnston)Wed, 28 Sep 2022 20:46:24 +0000
https://dev.to/lb/ten-tasty-ingredients-for-a-delicious-pull-request-hgc
https://dev.to/lb/ten-tasty-ingredients-for-a-delicious-pull-request-hgc<p>Over the last few years, I have had the incredible opportunity to be a core team member of the <a href="proxy.php?url=https://github.com/wagtail/wagtail/">Wagtail project</a>. In that time, I have reviewed many new pull requests, and I’ve also had the chance to submit many of my own across Wagtail and many other projects.</p>
<p><a href="proxy.php?url=https://res.cloudinary.com/practicaldev/image/fetch/s--Hok09Mzr--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/st91owwwwdn5lr8syudm.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://res.cloudinary.com/practicaldev/image/fetch/s--Hok09Mzr--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/st91owwwwdn5lr8syudm.png" alt="My (lb-) Github profile share of code reviews" width="880" height="325"></a></p>
<blockquote>
<p><a href="proxy.php?url=https://github.com/lb-">lb-</a></p>
</blockquote>
<p>I do enjoy the challenge of improving code, reading others’ code and learning how to write my own code better. I also love helping new developers get their first few pull requests over the line and become contributors. As we lead into <a href="proxy.php?url=https://hacktoberfest.com/">Hacktoberfest</a> I thought it would be a good chance to write down the things that I see time and time again when new contributors get involved and hopefully can help with some tips to give your contributions a better chance of being reviewed.</p>
<p>When you want to make something enticing for others, it not only needs to taste great but you need to think about presentation and also communication. You may have made the most amazing flat white (coffee), but if you leave out the epic latte art, it will not be as good. If your coffee is not what the customer ordered or is the wrong size, you have to start again. When you are able to tell a story or clearly articulate the decisions that went into the menu, it is always a more enjoyable experience.</p>
<h2>
Tasty ingredients
</h2>
<p>These ingredients apply to not just open source contributions but any code contributions at work or even in your own personal projects.</p>
<h3>
1. Read the [development] instructions
</h3>
<p>When you want to contribute, introduce yourself to the community, or even work out how to get started, take the time to read the project's development docs.</p>
<p>First, look at the project's readme for a <a href="proxy.php?url=https://github.com/wagtail/wagtail/#-contributing"><strong>Contributing</strong></a> or maybe a <strong>Development</strong> section. This may give you some basic instructions and hopefully point you in the direction of some documentation links.</p>
<p>Read the entire section, note all links, then read the content on each link. Sometimes, it is easy to skim through these because you want to get started quickly. But it's better to take 20 mins now to read the documentation instead of spending two hours later to start all over again.</p>
<h4>
About ‘fork the code’
</h4>
<p>Many contribution sections gloss over the mammoth task that can be a single line in the documentation similar to “fork the code and get it running locally”. This, on its own, can be a daunting task if you are just getting started, so let’s dive a bit deeper or if you know what this means skip to item two.</p>
<p><a href="proxy.php?url=https://res.cloudinary.com/practicaldev/image/fetch/s--13FXunHI--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mv1bwty2gxf0kr31j5za.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://res.cloudinary.com/practicaldev/image/fetch/s--13FXunHI--/c_limit%2Cf_auto%2Cfl_progressive%2Cq_auto%2Cw_880/https://dev-to-uploads.s3.amazonaws.com/uploads/articles/mv1bwty2gxf0kr31j5za.png" alt="Wagtail development docs with 'fork it' highlighted" width="880" height="730"></a></p>
<p>No single blog post like this can cover the nuance of this for every single project but it is good to call out some principles when it comes to getting started.</p>
<ul>
<li>A. Basic understanding of git vs GitHub and/or GitLab
<code>git</code> is the version control tool, it is something you install on your device and runs usually in the command line (terminal) or via some GUI application.
<ul>
<li>GitHub & GitLab are two prominent websites that provide a web user interface for repositories using git.
Mozilla has a great guide that helps to explain <a href="proxy.php?url=https://developer.mozilla.org/en-US/docs/Learn/Tools_and_testing/GitHub">Git and GitHub</a>
</li>
</ul>
</li>
<li>B. How to clone a remote repository and what that actually even means
<ul>
<li>On GitHub or Gitlab, you will not be allowed to directly create branches or changes in a repository (project) that you do not have access to.</li>
<li>However, you can make a copy (clone) of this repository using your own account, this clone will have all the branches and history that the original repository had.</li>
<li>This is also called ‘fork’ in some cases, as your repository will be a branch of its own that forks the original repository.</li>
<li>See the <a href="proxy.php?url=https://docs.github.com/en/get-started/quickstart/contributing-to-projects">GitHub docs explain forking</a> or <a href="proxy.php?url=https://docs.gitlab.com/ee/user/project/repository/forking_workflow.html">how to fork a repository on GitLab</a>.</li>
</ul>
</li>
<li>C. How to go about getting the code running locally
Once this is done, you still will not have any code locally on your device, you still need to create yet another fork on your own machine. Yes, it’s forks all the way down, there is no spoon.
<ul>
<li>This is where the <code>git</code> command line comes into play and the term <code>clone</code> makes more sense. On your local machine you will <code>git clone some-website.git</code> which will create a new folder with a copy of the project inside.</li>
<li>See <a href="proxy.php?url=https://www.atlassian.com/git/tutorials/setting-up-a-repository/git-clone">Atlassian’s docs on git clone</a> for more details.</li>
</ul>
</li>
</ul>
<p>Your goal at this point, before your first pull request, is to understand how to run the code on your local machine. Being able to make a change somewhere in the code and then re-run or reload whatever is needed to see that change in the development environment. For example, try to change something trivial, such as a template output or a JavaScript console log, and confirm you can actually get that change to show.</p>
<p>Bonus tip: Add a profile image to your Github profile and the community chat profile (Slack/Discord/Zulp). It does not need to be your image, but make it unique.</p>
<h3>
2. Read the issue and comments
</h3>
<p>Hopefully, at this point, you have a good sense of the purpose of the project and are still keen to contribute. Once you have the code forked and running locally, you will probably want to start looking for what to contribute.</p>
<p>Finding something to contribute is not always easy, especially if you are new to the project. Once you have a few candidate issues to investigate, be sure to read the entire issue description, all comments and all linked issues or pull requests. Often, you will find that someone else may have started or finished the issue, or sometimes there are clarifications in the comments about how to approach the problem or whether the problem is even something worth solving.</p>
<blockquote>
<p>Bonus tip: If an issue has a pull request linked, but not merged, read that pull request and the discussion on it. Maybe the previous contributor got stuck or lost momentum, in which case you could pick up where they left off (assuming it's been enough time).</p>
</blockquote>
<h3>
3. Create a fresh branch for your contributions
</h3>
<p>Before writing any code, take a moment to get your <code>git</code> hat on. When you clone the project locally, you will be checked out at the <code>main</code> branch (sometimes called <code>master</code>, or <code>development</code> depending on the project). This branch is not suitable for you to make your changes on. It is meant to be the branch that tracks the core development of the project.</p>
<p>Instead, take a moment to create a <a href="proxy.php?url=https://www.atlassian.com/git/tutorials/using-branches">new branch</a>. You can use the command line or install one of the many great git GUI tools. Don't listen to anyone that says you're not doing it right unless you use the command line. Reduce the things you need to learn today and focus on the <code>git</code> command line interface later. If you have a Mac, I recommend <a href="proxy.php?url=https://git-fork.com/">Fork</a>, otherwise, the <a href="proxy.php?url=https://desktop.github.com/">Github GUI</a> is good enough.</p>
<p>This new branch name should have some context as to what you are fixing and if possible the issue number being fixed. For example <code>git checkout -b 'feature/1234-add-unit-tests-for-inline-panel'</code>. This branch name uses <code>/</code> to represent a folder and also has the issue number <code>1234</code>, finally, it uses <code>lower-kebab-case</code> with a short description of the issue.</p>
<blockquote>
<p>Bonus tip: You may find that your editor has some handy Git tooling and will often be able to tell you what branch you are on or whether you have any changes staged. <a href="proxy.php?url=https://code.visualstudio.com/docs/sourcecontrol/overview">https://code.visualstudio.com/docs/sourcecontrol/overview</a></p>
</blockquote>
<h3>
4. Keep the changes focused
</h3>
<p>As a developer, it is easy to get distracted, maybe a typo here or white space that does not feel 'right' there. Sometimes, even our editor gets distracted and starts adding line breaks at the end of files as we save or it formats code without our consent due to configuration from a different project.</p>
<p>These added changes that are not the primary goal or not strictly required by the project's set-up are noise. This noise makes it harder to review the pull request and also can create confusion for future developers that see these commits and wonder how it relates to the bug that was fixed.</p>
<p>When you go to stage changes, only stage the parts you need or at least review the changes and 'undo' them before you save the commits.</p>
<p>If you do find a different problem, maybe a typo in the docs, this is what branches are for. Save your commits, create a new branch off master <code>fix/fix-documentation-typo</code> and then save that change to that branch., Now you have a small change, one that is easy to merge, which you can prepare a pull request for.</p>
<p>Keep your changes focused on the goal, do not add overhead to the reviewer or to yourself by changing things that do not need it (yet).</p>
<blockquote>
<p>Bonus tip: It's OK to make changes in a 'messy' way locally, with lots of commits that maybe include things that are not needed. However, be sure to take some time to review your commits and clean up anything that is not required before you do your pull request.</p>
</blockquote>
<h3>
5. Write unit tests
</h3>
<p>We are getting close to having a pull request, but the next critical step is unit tests. Anecdotally, I find that testing code you wrote will take 5-10x longer than the actual bug fix. Often, if the use case is right, it is better to write the tests first and get them running (but failing) before you fix the problem.</p>
<p>Finding how and where to write the unit tests can be hard in a new project, but hopefully, the project's development docs contain the clues you need to get started. Wagtai’s docs are hopefully a good example of this with a <a href="proxy.php?url=https://docs.wagtail.org/en/stable/contributing/developing.html#testing">dedicated testing section</a>.</p>
<p>If you fix a bug or introduce a new feature, you want to ensure that fix is long-lived and does not break again. You also want to help yourself by thinking through edge cases or potential problems. Testing helps with this. While regressions do happen, they are less likely to happen when code is tested.</p>
<p>Many projects will not even review a pull request without unit tests. Often, fixing a bug is not hard, ensuring the fix is the 'real' fix and that it does not break again is the hard part. Take the time to do the harder thing. It will help you grow as a developer and help your contributions make a longer lasting difference.</p>
<blockquote>
<p>Bonus tip: A pull request that just adds unit tests to some core functionality that does not yet have tests is a great way to contribute, it helps you learn about the code and makes the project more reliable.</p>
</blockquote>
<h3>
6. Give your pull request a name with context
</h3>
<p>A pull request that has the title 'fixes issue' is unhelpful at best, and spammy at worst. Take a few moments to think about how to give your change a title. Communicate, in a few words, the problem solved, feature added or bug fixed. Instead of 'Fixes 10423', use words and write a title 'Fixes documentation dark mode refresh issue'. No one in a project knows that issue <code>10423</code> is that one about the documentation dark mode refresh issue.</p>
<p>Also, add a proper name when you create the pull request. This will ensure that any notifications that go out to the team have the correct name from the start.</p>
<blockquote>
<p>Bonus tip: Remember you can make a <strong>draft</strong> pull request in both GitHub and GitLab. This is a way to run the CI steps but in a way that indicates you are not ready for a review yet.</p>
</blockquote>
<h3>
7. Reference the issue being fixed or resolved in the pull request
</h3>
<p>Referencing the issue being fixed within the pull request description is just as important as a good title. A pull request without a description is very difficult to review. Add some context and some steps to reproduce the issue or scenario.</p>
<p>It is often good to write yourself a checklist for any pull request and fill in the gaps.</p>
<ul>
<li>Small description of the solution, one sentence.</li>
<li>Link to issue/s that should be resolved if this pull request gets merged.</li>
<li>Questions or assumptions, maybe you made an assumption we no longer support IE11 with your CSS change, if it's not in the docs - write the assumption down.</li>
<li>Details - additional details, context or links that help the reviewer understand the pull request.</li>
<li>Note: Some projects have a checklist or template for all new pull requests, it is best to use that if it is there.</li>
</ul>
<blockquote>
<p>Bonus tip: You can also include issues in the commit message. Adding something like <code>fixes #1234</code> in your commit message will let GitHub know that the change is for that issue.</p>
</blockquote>
<h3>
8. Review & fix the CI failures
</h3>
<p>Once you have created your pull request, there will often be a series of <a href="proxy.php?url=https://about.gitlab.com/topics/ci-cd/">build/check/CI</a> steps that run.</p>
<p>These steps are normally all required to pass before the pull request can be merged. CI is a broad term but usually, the testing and linting will run on the code you have proposed to change. Linting is a tricky one because sometimes the things that are flagged seem trivial, but they are important for code consistency. Re-read the development instructions and see how you can run the linting locally to avoid frustrating back & forth with small linting fixes.</p>
<p>Testing is a bit more complex. Maybe all the tests can be run locally or maybe the CI will run tests on multiple versions of a project or language. Do your best to run all the tests locally, but there may still be issues on the CI when you do. That is OK, and normally you can solve these issues one by one.</p>
<p>The most important thing is to not just ignore CI failures. Read through each error report and try to work out the problem and provide a fix. Ignoring these will likely lead to pull requests that do not get reviewed because they do not get the basics right.</p>
<blockquote>
<p>Bonus tip: GitHub will not run the CI automatically for new contributors in some projects. This is an intentional security feature and a core contributor will need to approve your initial CI run.</p>
</blockquote>
<h3>
9. Push to the same branch with fixes and do not open a new pull request
</h3>
<p>Finally, after you have fixed the failing linting and tests locally, you will want to push those changes to your remote branch. You do not need to open a new pull request. This creates more noise and confusion. Instead, push your changes up to your branch, and the CI will run automatically on those changes.</p>
<p>You can add a comment if you want to the pull request that you have updated, but often this is not really needed.</p>
<p>Avoid opening multiple pull requests for the same fix. Doing that means all the comments and discussion from the previous pull request will get lost and reviewers will have trouble finding them.</p>
<h3>
10. Eagerness balanced with patience
</h3>
<p>When you take time to contribute out of your own personal time, or even that from your paid employer, it can be very frustrating when a pull request does not get reviewed. It is best to temper your expectations with this process and remember that many people on the other side of this are also volunteers or have limited time to prioritise.</p>
<p>It is best to celebrate your accomplishment at this point even if your pull request never gets merged. However, it is good to balance that with an eagerness about getting your amazing fix in place to help people who use the project. Balancing this tension is hard, but the unhelpful thing to do is give up and never contribute or decide that you won’t respond to feedback because it came too late.</p>
<p>Also, remember that it is OK to move on and try something else. Try a different issue or project or area of the code. Don’t just sit waiting for a response on the one thing you did before looking at other challenges.</p>
<blockquote>
<p>Update: <a href="proxy.php?url=https://twitter.com/john_franey/status/1577079073977012227?s=20&t=BtX1OQIucxcrKjotQRNOfA">John Franey on Twitter</a> sent me a link to his post that is a great 'second course' to this post <a href="proxy.php?url=https://johnfraney.ca/blog/preparing-a-gourmet-pull-request/">Preparing a Gourmet Pull Request</a>.</p>
</blockquote>
<h2>
Final thoughts
</h2>
<p>If there can be one summary of all of this, it is to take the time to read and remember that <strong>Slow is fast and fast is slow</strong>. If you rush through and do not take the time to read and truly understand the problem you are solving, you will end up being much slower in the long run.</p>
<p>Finally, remember that you can do all the right things and it may not be something that should be a core feature. The problem you are solving may not be a priority for a number of reasons or sometimes the solution ends up being a minefield of edge cases and complexity.</p>
<p>Be thankful that you are in a position to be able to write code, have a laptop that works and have a brain that can solve problems. There will always be more problems to solve later.</p>
<p>Also, remember that this is hard and that is OK. Anything new is hard and new things in programming often mean learning multiple new things in parallel. Get a notepad (virtual or otherwise) and write each problem down as you encounter it, this way you can work through each one individually without being overwhelmed.</p>
<h3>
Bonus: Learn how to rebase on <code>main</code>/<code>master</code>
</h3>
<p>One of the more complex things to <a href="proxy.php?url=https://en.wikipedia.org/wiki/Grok">grok</a> is the git <code>rebase</code>. I found this very hard to understand when getting started but it is a critical part of developing for open source.</p>
<p>When you have your branch of code, it contains commits. The branch itself is essentially a 'pointer' to the last of those commits. Git is incredible software and works out where these commits 'branch' off the other commits that themselves have pointers.</p>
<p>Often on a project, you will make a change, and then a few days or weeks later see that these changes cannot be merged in easily as they conflict with the core code.</p>
<p>Merging <code>main</code> into your branch is unhelpful here, it will seem like it solves the current problem of conflicts but will create other headaches down the road. Merging <code>main</code> into your branch pulls ALL those commits, there could be hundreds` into your branch. Git now points your branch to the latest commit here, which is a mix of your original commits and all other ancestor changes back to before you started your changes.</p>
<p>Instead, think about this process as a thought exercise;</p>
<ul>
<li>Create a new branch called <code>my-temp-feature-2</code> off the latest <code>main</code> branch.</li>
<li>Go to each of your commits and re-apply them one by one on <code>main</code>.</li>
<li>Each time you get a conflict, resolve it one by one.</li>
<li>Now you have those commits sitting 'on top' of <code>main</code> without any code conflicts.</li>
<li>Delete the previous branch and rename your current <code>temp</code> branch with the same name as the previous branch.</li>
</ul>
<p>This seems convoluted, but this is essentially the exact process of a rebase. It takes each commit, one by one, and writes it on top of another branch. As a conflict happens, you resolve the conflict for that specific commit and move on.</p>
<p>The final step is key though. You now have a local branch that is out of sync with the remote branch. You cannot just <code>push</code> because you will get an error. You must instead use the force and <code>force push</code> commands to overwrite the remote branch. Be careful before you do because these commands are absolute and will completely delete anything on the remote branch.</p>
<p>Some additional links on rebasing</p>
<ul>
<li><a href="proxy.php?url=https://www.atlassian.com/git/tutorials/merging-vs-rebasing">https://www.atlassian.com/git/tutorials/merging-vs-rebasing</a></li>
<li><a href="proxy.php?url=https://git-scm.com/book/en/v2/Git-Branching-Rebasing">https://git-scm.com/book/en/v2/Git-Branching-Rebasing</a></li>
</ul>
<h3>
Credits
</h3>
<ul>
<li>The countless developers who have contributed small and meaningful changes to the free software & open source world.</li>
<li>Cover image - <a href="proxy.php?url=https://unsplash.com/photos/i9Hgm8Cf-20">https://unsplash.com/photos/i9Hgm8Cf-20</a>
</li>
<li>Proofing - Meagen Voss from Torchbox</li>
<li>You, the reader, for learning to read the whole issue before moving on to fixing the problem</li>
<li>This has also been cross-posted on the Wagtail blog - <a href="proxy.php?url=https://wagtail.org/blog/ten-tasty-ingredients-for-a-delicious-pull-request/">https://wagtail.org/blog/ten-tasty-ingredients-for-a-delicious-pull-request/</a>
</li>
</ul>
gitbeginnersopensourcehacktoberfestCreating a schematic editor within Wagtail CMS with StimulusJSLB (Ben Johnston)Sun, 20 Feb 2022 06:55:18 +0000
https://dev.to/lb/creating-a-schematic-editor-within-the-wagtail-cms-with-stimulusjs-n5j
https://dev.to/lb/creating-a-schematic-editor-within-the-wagtail-cms-with-stimulusjs-n5j<h2>
Goal
</h2>
<ul>
<li>Our goal is to create a way to present a product (or anything) visually alongside points over the image that aligns to a description.</li>
<li>Often content like this has to be rendered fully as an image, see the <a href="proxy.php?url=https://www.instructables.com/How-to-use-an-espresso-machine-pulling-shots-st/" rel="noopener noreferrer">Instructables espresso machine article</a> as an example.</li>
<li>However, we want to provide a way to have the image and its labels in separate content, this means the content is more accessible, links can be provided to sub-content and the labels can be translated if needed. See the website for the <a href="proxy.php?url=https://aremde.com.au/nexus/nexus-pro/" rel="noopener noreferrer">Aremde Nexus Prop coffee machine</a> as an example. Not only is this coffee machine amazing, made in Brisbane, Australia but their website has some nice pulsating 'dots' that can be hovered to show features of the machine.</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft6ndf93dqki2z9qr6cqk.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Ft6ndf93dqki2z9qr6cqk.png" alt="Aremde Coffee Machine website - example of our goal"></a></p>
<h2>
Our approach
</h2>
<p>A note on naming - Schematic - this can mean a few different things and maybe <code>diagram</code> would be more appropriate but we will go with <code>schematic</code> to mean the image along with some points with labels and <code>point</code> for the individual points that overlay the image.</p>
<ol>
<li>Create a new Django app to contain the <code>schematic</code> model, we will design the model to contain the image and 'points' that align with the image.</li>
<li>Create a new Page that can add the Schematic and use Wagtail's built-in <code>InlinePanel</code> to allow for basic editing of these points.</li>
<li>Get the points and image showing in the page's template.</li>
<li>Refine the Wagtail CMS editing interface to firstly show the points visually over the image and then allow drag & drop positioning of the points all within the editor.</li>
</ol>
<h3>
Versions
</h3>
<ul>
<li>Python - 3.9</li>
<li>
<a href="proxy.php?url=https://docs.djangoproject.com/en/4.0/" rel="noopener noreferrer">Django</a> - 4.0</li>
<li>
<a href="proxy.php?url=https://docs.wagtail.org/en/stable/" rel="noopener noreferrer">Wagtail</a> - 2.16</li>
<li>
<a href="proxy.php?url=https://stimulus.hotwired.dev/" rel="noopener noreferrer">Stimulus</a> - 3.0.1</li>
</ul>
<h2>
Assumptions
</h2>
<ul>
<li>You have a working Wagtail project running locally, either your own project or something like the <a href="proxy.php?url=https://github.com/wagtail/bakerydemo" rel="noopener noreferrer">bakerydemo</a> project.</li>
<li>You are using the <code>images</code> and <code>snippets</code> Wagtail apps (common in most installations).</li>
<li>You have installed the <a href="proxy.php?url=https://docs.wagtail.org/en/stable/advanced_topics/api/index.html#wagtail-api" rel="noopener noreferrer">Wagtail API</a> and have set up the URLs as per the basic configuration.</li>
<li>You have a basic knowledge of Wagtail, Django, Python and JavaScript.</li>
</ul>
<h2>
Tutorial
</h2>
<h2>
Part 1 - Create a new <code>schematics</code> app plus <code>Schematic</code> & <code>SchematicPoint</code> models
</h2>
<ol>
<li>
<code>python manage.py startapp schematics</code> - create a new Django application to house the models and assets.</li>
<li>Add <code>'schematics'</code> to your <code>INSTALLED_APPS</code> within your Django settings.</li>
<li>Create a <a href="proxy.php?url=https://docs.wagtail.org/en/stable/topics/snippets.html#id1" rel="noopener noreferrer">Wagtail snippet</a> which will hold our <code>Schematic</code> and <code>SchematicPoint</code> models, code and explanation below.</li>
<li>Run <code>./manage.py makemigrations</code>, check the output matches expectations and then <code>./manage.py migrate</code> to migrate your local DB.</li>
<li>Restart your dev server <code>./manage.py runserver 0.0.0.0:8000</code> and validate that the new model is now available within the Snippets section accessible from the sidebar menu.</li>
<li>Now create a single Schematic snippet so that there is some test data to work with and so you get a feel for the editing of this content.</li>
</ol>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fduo24ajq9ec1lcm71j09.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fduo24ajq9ec1lcm71j09.png" alt="Wagtail CMS Snippet editor setup"></a></p>
<h3>
Code - <code>models.py</code>
</h3>
<ul>
<li>We will create two models, <code>Schematic</code> and <code>SchematicPoint</code>, the first will be a Wagtail snippet using the <code>@register_snippet</code> decorator via <code>from wagtail.snippets.models import register_snippet</code>.</li>
<li>The <code>Schematic</code> model has two fields <code>title</code> (a simple CharField) and <code>image</code> (a Wagtail image), the panels will also reference the related <code>points</code> model.</li>
<li>The <code>SchematicPoint</code> model has a <code>ParentalKey</code> (from <a href="proxy.php?url=https://github.com/wagtail/django-modelcluster" rel="noopener noreferrer">modelcluster</a>) which is included with Wagtail, for more information about this read the <a href="proxy.php?url=https://docs.wagtail.org/en/stable/reference/pages/panels.html#inline-panels" rel="noopener noreferrer"><code>InlinePanel</code> & modelclusters section</a> of the Wagtail docs.</li>
<li>The <code>SchematicPoint</code> also has an x and y coordinate (percentages), the reasoning of using percentages is that it maps well to scenarios where the image may change or image may be shown at various sizes, if we go to px we have to solve a whole bunch of problems that present themselves. We also use the <code>DecimalField</code> to allow for up to 2 decimal places of precision within the value, e.g. 0.01 through to 99.99. (We are using max digits 5 because technically 100.00 is valid).</li>
<li>Note that we are using <code>MaxValueValidator</code>/<code>MinValueValidator</code> for the server-side validation of the values and <code>NumberInput</code> widget attrs for the client side (browser) validation. Django <a href="proxy.php?url=https://docs.djangoproject.com/en/4.0/ref/forms/widgets/#django.forms.Widget.attrs" rel="noopener noreferrer">widget attrs</a> is a powerful way to add HTML attributes to the form fields without needing to dig into templates, we will use this more later.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="kn">from</span> <span class="n">django</span> <span class="kn">import</span> <span class="n">forms</span>
<span class="kn">from</span> <span class="n">django.core.validators</span> <span class="kn">import</span> <span class="n">MaxValueValidator</span><span class="p">,</span> <span class="n">MinValueValidator</span>
<span class="kn">from</span> <span class="n">django.db</span> <span class="kn">import</span> <span class="n">models</span>
<span class="kn">from</span> <span class="n">modelcluster.fields</span> <span class="kn">import</span> <span class="n">ParentalKey</span>
<span class="kn">from</span> <span class="n">modelcluster.models</span> <span class="kn">import</span> <span class="n">ClusterableModel</span>
<span class="kn">from</span> <span class="n">wagtail.admin.edit_handlers</span> <span class="kn">import</span> <span class="p">(</span>
<span class="n">FieldPanel</span><span class="p">,</span>
<span class="n">FieldRowPanel</span><span class="p">,</span>
<span class="n">InlinePanel</span><span class="p">,</span>
<span class="p">)</span>
<span class="kn">from</span> <span class="n">wagtail.core.models</span> <span class="kn">import</span> <span class="n">Orderable</span>
<span class="kn">from</span> <span class="n">wagtail.images.edit_handlers</span> <span class="kn">import</span> <span class="n">ImageChooserPanel</span>
<span class="kn">from</span> <span class="n">wagtail.search</span> <span class="kn">import</span> <span class="n">index</span>
<span class="kn">from</span> <span class="n">wagtail.snippets.models</span> <span class="kn">import</span> <span class="n">register_snippet</span>
<span class="nd">@register_snippet</span>
<span class="k">class</span> <span class="nc">Schematic</span><span class="p">(</span><span class="n">index</span><span class="p">.</span><span class="n">Indexed</span><span class="p">,</span> <span class="n">ClusterableModel</span><span class="p">):</span>
<span class="n">title</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span><span class="sh">"</span><span class="s">Title</span><span class="sh">"</span><span class="p">,</span> <span class="n">max_length</span><span class="o">=</span><span class="mi">254</span><span class="p">)</span>
<span class="n">image</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">ForeignKey</span><span class="p">(</span>
<span class="sh">"</span><span class="s">wagtailimages.Image</span><span class="sh">"</span><span class="p">,</span>
<span class="n">null</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
<span class="n">blank</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
<span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">SET_NULL</span><span class="p">,</span>
<span class="n">related_name</span><span class="o">=</span><span class="sh">"</span><span class="s">+</span><span class="sh">"</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">panels</span> <span class="o">=</span> <span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">ImageChooserPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">image</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">InlinePanel</span><span class="p">(</span><span class="sh">"</span><span class="s">points</span><span class="sh">"</span><span class="p">,</span> <span class="n">heading</span><span class="o">=</span><span class="sh">"</span><span class="s">Points</span><span class="sh">"</span><span class="p">,</span> <span class="n">label</span><span class="o">=</span><span class="sh">"</span><span class="s">Point</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>
<span class="k">def</span> <span class="nf">__str__</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="n">title</span> <span class="o">=</span> <span class="nf">getattr</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Schematic</span><span class="sh">"</span><span class="p">)</span>
<span class="k">return</span> <span class="sa">f</span><span class="sh">"</span><span class="s">Schematic - </span><span class="si">{</span><span class="n">title</span><span class="si">}</span><span class="s"> (</span><span class="si">{</span><span class="n">self</span><span class="p">.</span><span class="n">pk</span><span class="si">}</span><span class="s">)</span><span class="sh">"</span>
<span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
<span class="n">verbose_name_plural</span> <span class="o">=</span> <span class="sh">"</span><span class="s">Schematics</span><span class="sh">"</span>
<span class="n">verbose_name</span> <span class="o">=</span> <span class="sh">"</span><span class="s">Schematic</span><span class="sh">"</span>
<span class="k">class</span> <span class="nc">SchematicPoint</span><span class="p">(</span><span class="n">Orderable</span><span class="p">,</span> <span class="n">models</span><span class="p">.</span><span class="n">Model</span><span class="p">):</span>
<span class="n">schematic</span> <span class="o">=</span> <span class="nc">ParentalKey</span><span class="p">(</span>
<span class="sh">"</span><span class="s">schematics.Schematic</span><span class="sh">"</span><span class="p">,</span>
<span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">CASCADE</span><span class="p">,</span>
<span class="n">related_name</span><span class="o">=</span><span class="sh">"</span><span class="s">points</span><span class="sh">"</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">label</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span><span class="sh">"</span><span class="s">Label</span><span class="sh">"</span><span class="p">,</span> <span class="n">max_length</span><span class="o">=</span><span class="mi">254</span><span class="p">)</span>
<span class="n">x</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">DecimalField</span><span class="p">(</span>
<span class="n">verbose_name</span><span class="o">=</span><span class="sh">"</span><span class="s">X →</span><span class="sh">"</span><span class="p">,</span>
<span class="n">max_digits</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span>
<span class="n">decimal_places</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span>
<span class="n">default</span><span class="o">=</span><span class="mf">0.0</span><span class="p">,</span>
<span class="n">validators</span><span class="o">=</span><span class="p">[</span><span class="nc">MaxValueValidator</span><span class="p">(</span><span class="mf">100.0</span><span class="p">),</span> <span class="nc">MinValueValidator</span><span class="p">(</span><span class="mf">0.0</span><span class="p">)],</span>
<span class="p">)</span>
<span class="n">y</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">DecimalField</span><span class="p">(</span>
<span class="n">verbose_name</span><span class="o">=</span><span class="sh">"</span><span class="s">Y ↑</span><span class="sh">"</span><span class="p">,</span>
<span class="n">max_digits</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span>
<span class="n">decimal_places</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span>
<span class="n">default</span><span class="o">=</span><span class="mf">0.0</span><span class="p">,</span>
<span class="n">validators</span><span class="o">=</span><span class="p">[</span><span class="nc">MaxValueValidator</span><span class="p">(</span><span class="mf">100.0</span><span class="p">),</span> <span class="nc">MinValueValidator</span><span class="p">(</span><span class="mi">0</span><span class="p">)],</span>
<span class="p">)</span>
<span class="n">panels</span> <span class="o">=</span> <span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">label</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldRowPanel</span><span class="p">(</span>
<span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span>
<span class="sh">"</span><span class="s">x</span><span class="sh">"</span><span class="p">,</span> <span class="n">widget</span><span class="o">=</span><span class="n">forms</span><span class="p">.</span><span class="nc">NumberInput</span><span class="p">(</span><span class="n">attrs</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">min</span><span class="sh">"</span><span class="p">:</span> <span class="mf">0.0</span><span class="p">,</span> <span class="sh">"</span><span class="s">max</span><span class="sh">"</span><span class="p">:</span> <span class="mf">100.0</span><span class="p">})</span>
<span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span>
<span class="sh">"</span><span class="s">y</span><span class="sh">"</span><span class="p">,</span> <span class="n">widget</span><span class="o">=</span><span class="n">forms</span><span class="p">.</span><span class="nc">NumberInput</span><span class="p">(</span><span class="n">attrs</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">min</span><span class="sh">"</span><span class="p">:</span> <span class="mf">0.0</span><span class="p">,</span> <span class="sh">"</span><span class="s">max</span><span class="sh">"</span><span class="p">:</span> <span class="mf">100.0</span><span class="p">})</span>
<span class="p">),</span>
<span class="p">]</span>
<span class="p">),</span>
<span class="p">]</span>
<span class="k">def</span> <span class="nf">__str__</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="n">schematic_title</span> <span class="o">=</span> <span class="nf">getattr</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">schematic</span><span class="p">,</span> <span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Schematic</span><span class="sh">"</span><span class="p">)</span>
<span class="k">return</span> <span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">schematic_title</span><span class="si">}</span><span class="s"> - </span><span class="si">{</span><span class="n">self</span><span class="p">.</span><span class="n">label</span><span class="si">}</span><span class="sh">"</span>
<span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
<span class="n">verbose_name_plural</span> <span class="o">=</span> <span class="sh">"</span><span class="s">Points</span><span class="sh">"</span>
<span class="n">verbose_name</span> <span class="o">=</span> <span class="sh">"</span><span class="s">Point</span><span class="sh">"</span>
</code></pre>
</div>
<h2>
Part 2 - Create a new <code>ProductPage</code> model that will use the <code>schematic</code> model
</h2>
<ol>
<li>You may want to integrate this into an existing page but for the sake of the tutorial, we will create a simple <code>ProductPage</code> that will have a <code>ForeignKey</code> to our <code>Schematic</code> snippet.</li>
<li>The snippet will be selectable via the <a href="proxy.php?url=https://docs.wagtail.org/en/stable/reference/pages/panels.html#module-wagtail.snippets.edit_handlers" rel="noopener noreferrer"><code>SnippetChooserPanel</code></a> which provides a chooser modal where the snippet can be selected. This also allows the same <code>schematic</code> to be available across multiple instances of the <code>ProductPage</code> or even available in other pages and shared as a discrete bit of content.</li>
<li>Remember to run <code>./manage.py makemigrations</code>, check the output matches expectations and then <code>./manage.py migrate</code> to migrate your local DB.</li>
<li>Finally, be sure to create a new <code>ProductPage</code> in the Wagtail admin and link its schematic to the one created in step 1 to test the snippet chooser is working.</li>
</ol>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi2dq5kyi6447s6bng61p.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fi2dq5kyi6447s6bng61p.png" alt="Page model editing with Snippet Chooser"></a></p>
<h3>
Code - <code>models.py</code>
</h3>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="kn">from</span> <span class="n">django.db</span> <span class="kn">import</span> <span class="n">models</span>
<span class="kn">from</span> <span class="n">wagtail.core.models</span> <span class="kn">import</span> <span class="n">Page</span>
<span class="kn">from</span> <span class="n">wagtail.snippets.edit_handlers</span> <span class="kn">import</span> <span class="n">SnippetChooserPanel</span>
<span class="k">class</span> <span class="nc">ProductPage</span><span class="p">(</span><span class="n">Page</span><span class="p">):</span>
<span class="n">schematic</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">ForeignKey</span><span class="p">(</span>
<span class="sh">"</span><span class="s">schematics.Schematic</span><span class="sh">"</span><span class="p">,</span>
<span class="n">null</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
<span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">SET_NULL</span><span class="p">,</span>
<span class="n">related_name</span><span class="o">=</span><span class="sh">"</span><span class="s">product_page_schematic</span><span class="sh">"</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">content_panels</span> <span class="o">=</span> <span class="n">Page</span><span class="p">.</span><span class="n">content_panels</span> <span class="o">+</span> <span class="p">[</span><span class="nc">SnippetChooserPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">schematic</span><span class="sh">"</span><span class="p">)]</span>
</code></pre>
</div>
<h2>
Part 3 - Output the points over an image in the <code>Page</code>'s template
</h2>
<ol>
<li>Now create a template to output the image along with the points, this is a basic template that gets the general idea across of using the point coordinates to position them over the image.</li>
<li>We will use the <code>wagtailimages_tags</code> to allow the rendering of an image at a specific size and the usage of the <code>self.schematic</code> within the template to get the points data.</li>
</ol>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn0ebsbtzris61x2k2e1m.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fn0ebsbtzris61x2k2e1m.png" alt="Published page with the points showing over the image"></a></p>
<h3>
Code - <code>myapp/templates/schematics/product_page.html</code>
</h3>
<ul>
<li>The template below is built on the <a href="proxy.php?url=https://github.com/wagtail/bakerydemo" rel="noopener noreferrer">bakerydemo</a>, so there is a base template that is extended.</li>
<li>Please note the CSS is not polished and will need to be adjusted to suit your own branding and desired hover behaviour.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight plaintext"><code>{% extends "base.html" %}
{% load wagtailimages_tags %}
{% block head-extra %}
<style>
.schematic {
position: relative;
}
.schematic .points {
margin-bottom: 0;
}
.schematic .point {
position: absolute;
}
.schematic .point::before {
background-color: #fb7575;
border-radius: 50%;
box-shadow: 0 -2px 0 rgba(0, 0, 0, 0.1) inset;
content: "";
display: block;
border: 0.5rem solid transparent;
height: 2.75rem;
background-clip: padding-box; /* ensures the 'hover' target is larger than the visible circle */
position: absolute;
transform: translate(-50%, -50%);
width: 2.75rem;
z-index: 1;
}
.point .label {
opacity: 0; /* hide by default */
position: absolute;
/* vertically center */
top: 50%;
transform: translateY(-50%);
/* move to right */
left: 100%;
margin-left: 1.25rem; /* and add a small left margin */
/* basic styles */
font-family: sans-serif;
width: 12rem;
padding: 5px;
border-radius: 5px;
background: #000;
color: #fff;
text-align: center;
transition: opacity 300ms ease-in-out;
z-index: 10;
}
.schematic .point:hover .label {
opacity: 1;
}
</style>
{% endblock head-extra %}
{% block content %}
{% include "base/include/header.html" %}
<div class="container">
<div class="row">
{% image self.schematic.image width-1920 as schematic_image %}
<div class="schematic col-md-12">
<img src="proxy.php?url={{ schematic_image.url }}" alt="{{ schematic.title }}" />
<ul class="points">
{% for point in self.schematic.points.all %}
<li class="point" style="left: {{ point.x }}%; bottom: {{ point.y }}%">
<span class="label">{{ point.label }}</span>
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% endblock content %}
</code></pre>
</div>
<h2>
Part 4 - Enhance the editor's experience to show a different image size
</h2>
<ul>
<li>Before we can try to show the 'points' within the image in the editor we need to change the behaviour of the built-in <a href="proxy.php?url=https://docs.wagtail.org/en/stable/reference/pages/panels.html#imagechooserpanel" rel="noopener noreferrer"><code>ImageChooserPanel</code></a> to load a larger image when editing. This panel has two modes, editing an existing 'saved' value (shows the image on load) or updating an image by choosing a new one either for the first time or editing, this image is provided from the server.</li>
<li>At this point we will start writing some JavaScript and use the Stimulus 'modest' framework, see the bottom of this article for a bit of a high-level overview of Stimulus if you have not yet heard about it. Essentially, Stimulus gives us a way to assign <code>data-</code> attributes to elements to link their behaviour to a <code>Controller</code> class in JavaScript and avoids a lot of the boilerplate usually needed when working with jQuery or vanilla (no framework) JS such as adding event listeners or targeting elements predictably.</li>
<li>On the server-side we will create a sub-class of <code>ImageChooserPanel</code> which allows us to modify the size of the image that is returned if already saved and add our template overrides so we can update the HTML.</li>
<li>We will break this part into a few sub-steps.</li>
</ul>
<h3>
Part 4a - Adding Stimulus via <code>wagtail_hooks</code>
</h3>
<ul>
<li>Wagtail provides a system of <a href="proxy.php?url=https://docs.wagtail.org/en/stable/reference/hooks.html" rel="noopener noreferrer">'hooks'</a> where you can add a file <code>wagtail_hooks.py</code> to your app and it will be run by Wagtail on load.</li>
<li>We will use the <a href="proxy.php?url=https://docs.wagtail.org/en/stable/reference/hooks.html#id19" rel="noopener noreferrer"><code>insert_editor_js</code></a> hook to add our JavaScript module.</li>
<li>The JavaScript used from here on in assumes you are supporting browsers that have <a href="proxy.php?url=https://caniuse.com/?search=es6" rel="noopener noreferrer"><code>ES6</code></a> support and relies extensively on ES6 modules, arrow functions and classes.</li>
<li>We will be installing Stimulus as an ES6 module in a similar way to the <a href="proxy.php?url=https://stimulus.hotwired.dev/handbook/installing#using-without-a-build-system" rel="noopener noreferrer">Stimulus installation guide - without using a build system</a>.</li>
</ul>
<h4>
Create a new file <code>schematics/wagtail_hooks.py</code>
</h4>
<ul>
<li>Once created, stop your Django dev server and restart it (hooks will not run the first time after the file is added unless you restart).</li>
<li>You can validate this step is working by checking the browser inspector - checking that the script module exists, remember this will only show on editing pages or editing models and not on the dashboard for example due to the Wagtail hook used.</li>
<li>Assuming you are running Django with <code>DEBUG = True</code> in your dev server settings you should also see some console info about the status of Stimulus.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="kn">from</span> <span class="n">django.conf</span> <span class="kn">import</span> <span class="n">settings</span>
<span class="kn">from</span> <span class="n">django.utils.html</span> <span class="kn">import</span> <span class="n">format_html</span>
<span class="kn">from</span> <span class="n">wagtail.core</span> <span class="kn">import</span> <span class="n">hooks</span>
<span class="nd">@hooks.register</span><span class="p">(</span><span class="sh">"</span><span class="s">insert_editor_js</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">insert_stimulus_js</span><span class="p">():</span>
<span class="k">return</span> <span class="nf">format_html</span><span class="p">(</span>
<span class="sh">"""</span><span class="s">
<script type=</span><span class="sh">"</span><span class="s">module</span><span class="sh">"</span><span class="s">>
import {{ Application, Controller }} from </span><span class="sh">"</span><span class="s">https://unpkg.com/@hotwired/stimulus/dist/stimulus.js</span><span class="sh">"</span><span class="s">;
const Stimulus = Application.start();
{}
window.dispatchEvent(new CustomEvent(</span><span class="sh">'</span><span class="s">stimulus:init</span><span class="sh">'</span><span class="s">, {{ detail: {{ Stimulus, Controller }} }}));
</script>
</span><span class="sh">"""</span><span class="p">,</span>
<span class="c1"># set Stimulus to debug mode if running Django in DEBUG mode
</span> <span class="sh">"</span><span class="s">Stimulus.debug = true;</span><span class="sh">"</span> <span class="k">if</span> <span class="n">settings</span><span class="p">.</span><span class="n">DEBUG</span> <span class="k">else</span> <span class="sh">""</span><span class="p">,</span>
<span class="p">)</span>
</code></pre>
</div>
<h3>
Part 4b - Creating <code>schematics/edit_handlers.py</code> with a custom <code>ImageChooserPanel</code>
</h3>
<ol>
<li>Create a new file <code>schematics/edit_handlers.py</code>.</li>
<li>In this file we will sub-class the built-in <code>ImageChooserPanel</code> and its usage of <code>AdminImageChooser</code> to customise the behaviour via a new class <code>SchematicImageChooserPanel</code>.</li>
<li>
<code>SchematicImageChooserPanel</code> extends <code>ImageChooserPanel</code> and does two things; it updates the <code>widget_overrides</code> to use a second custom class <code>AdminPreviewImageChooser</code> and passes down a special data attribute to the input field. This attribute is a <a href="proxy.php?url=https://stimulus.hotwired.dev/reference/targets" rel="noopener noreferrer">Stimulus <code>target</code> attribute</a> and allows our JavaScript to easily access this field.</li>
<li>Within <code>AdminPreviewImageChooser</code> we override the <code>get_value_data</code> method to customise the image preview output, remember that this is only used when editing an existing model with a chosen image. We are using the <a href="proxy.php?url=https://docs.wagtail.org/en/stable/advanced_topics/images/renditions.html#image-renditions" rel="noopener noreferrer"><code>get_rendition</code> method</a> built-in to Wagtail's <code>Image</code> model.</li>
<li>We also need to ensure we use the <code>SchematicImageChooserPanel</code> in our <code>models.py</code>.</li>
<li>Remember to validate before moving on, you can do this by checking the image that is loaded when editing a model that already has a chosen image, it should be a much higher resolution version.
</li>
</ol>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># schematics/edit_handlers.py
</span><span class="kn">from</span> <span class="n">wagtail.images.edit_handlers</span> <span class="kn">import</span> <span class="n">ImageChooserPanel</span>
<span class="kn">from</span> <span class="n">wagtail.images.widgets</span> <span class="kn">import</span> <span class="n">AdminImageChooser</span>
<span class="k">class</span> <span class="nc">AdminPreviewImageChooser</span><span class="p">(</span><span class="n">AdminImageChooser</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Generates a larger version of the AdminImageChooser
Currently limited to showing the large image on load only.
</span><span class="sh">"""</span>
<span class="k">def</span> <span class="nf">get_value_data</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">value</span><span class="p">):</span>
<span class="n">value_data</span> <span class="o">=</span> <span class="nf">super</span><span class="p">().</span><span class="nf">get_value_data</span><span class="p">(</span><span class="n">value</span><span class="p">)</span>
<span class="k">if</span> <span class="n">value_data</span><span class="p">:</span>
<span class="n">image</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">image_model</span><span class="p">.</span><span class="n">objects</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">pk</span><span class="o">=</span><span class="n">value_data</span><span class="p">[</span><span class="sh">"</span><span class="s">id</span><span class="sh">"</span><span class="p">])</span>
<span class="c1"># note: the image string here should match what is used in the template
</span> <span class="n">preview_image</span> <span class="o">=</span> <span class="n">image</span><span class="p">.</span><span class="nf">get_rendition</span><span class="p">(</span><span class="sh">"</span><span class="s">width-1920</span><span class="sh">"</span><span class="p">)</span>
<span class="n">value_data</span><span class="p">[</span><span class="sh">"</span><span class="s">preview</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
<span class="sh">"</span><span class="s">width</span><span class="sh">"</span><span class="p">:</span> <span class="n">preview_image</span><span class="p">.</span><span class="n">width</span><span class="p">,</span>
<span class="sh">"</span><span class="s">height</span><span class="sh">"</span><span class="p">:</span> <span class="n">preview_image</span><span class="p">.</span><span class="n">height</span><span class="p">,</span>
<span class="sh">"</span><span class="s">url</span><span class="sh">"</span><span class="p">:</span> <span class="n">preview_image</span><span class="p">.</span><span class="n">url</span><span class="p">,</span>
<span class="p">}</span>
<span class="k">return</span> <span class="n">value_data</span>
<span class="k">class</span> <span class="nc">SchematicImageChooserPanel</span><span class="p">(</span><span class="n">ImageChooserPanel</span><span class="p">):</span>
<span class="k">def</span> <span class="nf">widget_overrides</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="k">return</span> <span class="p">{</span>
<span class="n">self</span><span class="p">.</span><span class="n">field_name</span><span class="p">:</span> <span class="nc">AdminPreviewImageChooser</span><span class="p">(</span>
<span class="n">attrs</span><span class="o">=</span><span class="p">{</span>
<span class="sh">"</span><span class="s">data-schematic-edit-handler-target</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">imageInput</span><span class="sh">"</span><span class="p">,</span>
<span class="p">}</span>
<span class="p">)</span>
<span class="p">}</span>
</code></pre>
</div>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># schematics/models.py
</span>
<span class="c1"># ... existing imports
</span>
<span class="kn">from</span> <span class="n">.edit_handlers</span> <span class="kn">import</span> <span class="n">SchematicImageChooserPanel</span>
<span class="nd">@register_snippet</span>
<span class="k">class</span> <span class="nc">Schematic</span><span class="p">(</span><span class="n">index</span><span class="p">.</span><span class="n">Indexed</span><span class="p">,</span> <span class="n">ClusterableModel</span><span class="p">):</span>
<span class="c1"># ...fields
</span>
<span class="n">panels</span> <span class="o">=</span> <span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">SchematicImageChooserPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">image</span><span class="sh">"</span><span class="p">),</span> <span class="c1"># ImageChooserPanel("image") - removed
</span> <span class="nc">InlinePanel</span><span class="p">(</span><span class="sh">"</span><span class="s">points</span><span class="sh">"</span><span class="p">,</span> <span class="n">heading</span><span class="o">=</span><span class="sh">"</span><span class="s">Points</span><span class="sh">"</span><span class="p">,</span> <span class="n">label</span><span class="o">=</span><span class="sh">"</span><span class="s">Point</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>
<span class="c1"># .. other model - SchematicPoint
</span>
</code></pre>
</div>
<h3>
Part 4c - Adding a custom <code>EditHandler</code>
</h3>
<ul>
<li>In Wagtail, there is a core class <a href="proxy.php?url=https://docs.wagtail.org/en/stable/topics/pages.html?highlight=EditHandler#editor-panels" rel="noopener noreferrer"><code>EditHandler</code></a> which contains much of the rendering of lists of containers/fields within a page and other editing interfaces (including snippets).</li>
<li>So that we can get more control over how our <code>Schematic</code> editor is presented, we will need to create a sub-class of this called <code>SchematicEditHandler</code>.</li>
<li>Our <code>SchematicEditHandler</code> will add some HTML around the built-in class and also provide the editor specific JS/CSS we need for this content. We could add the CSS/JS via more Wagtail Hooks but then it would load on every single editor page, even if the user is not editing the Schemas.</li>
</ul>
<h4>
In the file <code>schematics/edit_handlers.py</code> create a custom <code>SchematicEditHandler</code>
</h4>
<ul>
<li>This new file (schematics/edit_handlers.py) will contain our custom editor handler classes, we will start with <code>SchematicEditHandler</code> which extends <code>ObjectList</code>.</li>
<li>Using the <code>get_form_class</code> method we generate a <a href="proxy.php?url=https://www.geeksforgeeks.org/create-classes-dynamically-in-python/" rel="noopener noreferrer">new dynamic class with the <code>type</code> function</a> that has a <code>Media</code> class within it.</li>
<li>Django will use the <a href="proxy.php?url=https://docs.djangoproject.com/en/4.0/topics/forms/media/#media-on-forms" rel="noopener noreferrer"><code>Media</code> class on a <code>Form</code></a> to load any JS or CSS files declared but only once and only if the form is shown.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># schematics/edit_handlers.py
</span><span class="kn">from</span> <span class="n">django.utils.html</span> <span class="kn">import</span> <span class="n">format_html</span> <span class="c1"># this import is added
</span>
<span class="kn">from</span> <span class="n">wagtail.admin.edit_handlers</span> <span class="kn">import</span> <span class="n">ObjectList</span> <span class="c1"># this import is added
</span><span class="kn">from</span> <span class="n">wagtail.images.edit_handlers</span> <span class="kn">import</span> <span class="n">ImageChooserPanel</span>
<span class="kn">from</span> <span class="n">wagtail.images.widgets</span> <span class="kn">import</span> <span class="n">AdminImageChooser</span>
<span class="c1"># ... other classes
</span>
<span class="k">class</span> <span class="nc">SchematicEditHandler</span><span class="p">(</span><span class="n">ObjectList</span><span class="p">):</span>
<span class="n">template</span> <span class="o">=</span> <span class="sh">"</span><span class="s">schematics/edit_handlers/schematic_edit_handler.html</span><span class="sh">"</span>
<span class="k">def</span> <span class="nf">get_form_class</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="n">form_class</span> <span class="o">=</span> <span class="nf">super</span><span class="p">().</span><span class="nf">get_form_class</span><span class="p">()</span>
<span class="k">return</span> <span class="nf">type</span><span class="p">(</span>
<span class="n">form_class</span><span class="p">.</span><span class="n">__name__</span><span class="p">,</span>
<span class="p">(</span><span class="n">form_class</span><span class="p">,),</span>
<span class="p">{</span><span class="sh">"</span><span class="s">Media</span><span class="sh">"</span><span class="p">:</span> <span class="n">self</span><span class="p">.</span><span class="n">Media</span><span class="p">},</span>
<span class="p">)</span>
<span class="k">class</span> <span class="nc">Media</span><span class="p">:</span>
<span class="n">css</span> <span class="o">=</span> <span class="p">{</span><span class="sh">"</span><span class="s">all</span><span class="sh">"</span><span class="p">:</span> <span class="p">(</span><span class="sh">"</span><span class="s">css/schematic-edit-handler.css</span><span class="sh">"</span><span class="p">,)}</span>
<span class="n">js</span> <span class="o">=</span> <span class="p">(</span><span class="sh">"</span><span class="s">js/schematic-edit-handler.js</span><span class="sh">"</span><span class="p">,)</span>
</code></pre>
</div>
<h4>
Use the <code>SchematicEditHandler</code> on the <code>Schematic</code> model
</h4>
<ul>
<li>We will need to ensure we use this <code>SchematicEditHandler</code> in our <code>models.py</code>
</li>
<li>Once this is done, you can validate that it is working by reloading the Wagtail admin, editing an existing <code>Schematic</code> snippet and checking the network tools in the browser inspector. It should have tried to load the <code>schematic-edit-handler.css</code> & <code>schematic-edit-handler.js</code> files - which are not yet added - just check that the requests were made.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># schematics/models.py
</span>
<span class="c1"># ... existing imports
</span>
<span class="kn">from</span> <span class="n">.edit_handlers</span> <span class="kn">import</span> <span class="p">(</span>
<span class="n">SchematicEditHandler</span><span class="p">,</span>
<span class="n">SchematicImageChooserPanel</span><span class="p">,</span>
<span class="p">)</span>
<span class="nd">@register_snippet</span>
<span class="k">class</span> <span class="nc">Schematic</span><span class="p">(</span><span class="n">index</span><span class="p">.</span><span class="n">Indexed</span><span class="p">,</span> <span class="n">ClusterableModel</span><span class="p">):</span>
<span class="c1"># ...fields
</span>
<span class="c1"># panels = [ ... put the edit_handler after panels
</span>
<span class="n">edit_handler</span> <span class="o">=</span> <span class="nc">SchematicEditHandler</span><span class="p">(</span><span class="n">panels</span><span class="p">)</span>
<span class="c1"># .. other model - SchematicPoint
</span>
</code></pre>
</div>
<h3>
Part 4d - Adding initial JS & CSS for the schematic edit handler
</h3>
<h4>
Create <code>schematic-edit-handler.js</code> - Stimulus Controller
</h4>
<ul>
<li>This file will be a <a href="proxy.php?url=https://stimulus.hotwired.dev/reference/controllers" rel="noopener noreferrer">Stimulus Controller</a> that gets created once the event <code>stimulus:init</code> fires on the window (added earlier by our <code>wagtail_hooks.py</code>).</li>
<li>
<code>static targets = [...</code> - this tells the controller to look at for a DOM element and 'watch' it to check if it exists or gets created while the controller is active. This will specifically look for the data attribute <code>data-schematic-handler-target="imageInput"</code> and make it available inside the Controller's instance.</li>
<li>
<code>connect</code> is a class method similar to <code>componentDidMount</code> in React or <code>x-init/init()</code> in Alpine.js - it essentially means that there is a DOM element available.</li>
<li>Once connected, we call a method <code>setupImageInputObserver</code> which we have made in this class, it uses the <a href="proxy.php?url=https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver" rel="noopener noreferrer">MutationObserver</a> browser API to listen to the image's input value. The reason we cannot just use the <code>'change'</code> event is due to this value being updated programmatically, we also cannot easily listen to when the chooser modal closes as those are jQuery events that are not compatible with built-in browser events.</li>
<li>Finally, once we know the image input (id) has changed and has a value (e.g. was not just cleared), we can fire of an API call to the internal Wagtail API to get the image path, this happens in the <code>updateImage</code> method. Once resolved, we update the <code>src</code> on the <code>img</code> tag.</li>
<li>You can now validate this by refreshing and then changing an image to a new one via the image chooser, the newly loaded image should get updated to the full size variant of that image.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="c1">// static/js/schematic-edit-handler.js</span>
<span class="nb">window</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">stimulus:init</span><span class="dl">"</span><span class="p">,</span> <span class="p">({</span> <span class="nx">detail</span> <span class="p">})</span> <span class="o">=></span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">Stimulus</span> <span class="o">=</span> <span class="nx">detail</span><span class="p">.</span><span class="nx">Stimulus</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">Controller</span> <span class="o">=</span> <span class="nx">detail</span><span class="p">.</span><span class="nx">Controller</span><span class="p">;</span>
<span class="kd">class</span> <span class="nc">SchematicEditHandler</span> <span class="kd">extends</span> <span class="nc">Controller</span> <span class="p">{</span>
<span class="kd">static</span> <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span><span class="dl">"</span><span class="s2">imageInput</span><span class="dl">"</span><span class="p">];</span>
<span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
<span class="k">this</span><span class="p">.</span><span class="nf">setupImageInputObserver</span><span class="p">();</span>
<span class="p">}</span>
<span class="cm">/**
* Once connected, use DOMMutationObserver to 'listen' to the image chooser's input.
* We are unable to use 'change' event as it is updated by JS programmatically
* and we cannot easily listen to the Bootstrap modal close as it uses jQuery events.
*/</span>
<span class="nf">setupImageInputObserver</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">imageInput</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">imageInputTarget</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">observer</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">MutationObserver</span><span class="p">((</span><span class="nx">mutations</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">oldValue</span> <span class="o">=</span> <span class="dl">""</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">mutations</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="o">||</span> <span class="p">{};</span>
<span class="kd">const</span> <span class="nx">newValue</span> <span class="o">=</span> <span class="nx">imageInput</span><span class="p">.</span><span class="nx">value</span><span class="p">;</span>
<span class="k">if </span><span class="p">(</span><span class="nx">newValue</span> <span class="o">&&</span> <span class="nx">oldValue</span> <span class="o">!==</span> <span class="nx">newValue</span><span class="p">)</span>
<span class="k">this</span><span class="p">.</span><span class="nf">updateImage</span><span class="p">(</span><span class="nx">newValue</span><span class="p">,</span> <span class="nx">oldValue</span><span class="p">);</span>
<span class="p">});</span>
<span class="nx">observer</span><span class="p">.</span><span class="nf">observe</span><span class="p">(</span><span class="nx">imageInput</span><span class="p">,</span> <span class="p">{</span>
<span class="na">attributeFilter</span><span class="p">:</span> <span class="p">[</span><span class="dl">"</span><span class="s2">value</span><span class="dl">"</span><span class="p">],</span>
<span class="na">attributeOldValue</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="na">attributes</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="p">});</span>
<span class="p">}</span>
<span class="cm">/**
* Once we know the image has changed to a new one (not just cleared)
* we use the Wagtail API to find the original image URL so that a more
* accurate preview image can be updated.
*
* @param {String} newValue
*/</span>
<span class="nf">updateImage</span><span class="p">(</span><span class="nx">newValue</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">image</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">imageInputTarget</span>
<span class="p">.</span><span class="nf">closest</span><span class="p">(</span><span class="dl">"</span><span class="s2">.field-content</span><span class="dl">"</span><span class="p">)</span>
<span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">.preview-image img</span><span class="dl">"</span><span class="p">);</span>
<span class="nf">fetch</span><span class="p">(</span><span class="s2">`/api/v2/images/</span><span class="p">${</span><span class="nx">newValue</span><span class="p">}</span><span class="s2">/`</span><span class="p">)</span>
<span class="p">.</span><span class="nf">then</span><span class="p">((</span><span class="nx">response</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="nx">response</span><span class="p">.</span><span class="nx">ok</span><span class="p">)</span> <span class="k">return</span> <span class="nx">response</span><span class="p">.</span><span class="nf">json</span><span class="p">();</span>
<span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="s2">`HTTP error! Status: </span><span class="p">${</span><span class="nx">response</span><span class="p">.</span><span class="nx">status</span><span class="p">}</span><span class="s2">`</span><span class="p">);</span>
<span class="p">})</span>
<span class="p">.</span><span class="nf">then</span><span class="p">(({</span> <span class="nx">meta</span> <span class="p">})</span> <span class="o">=></span> <span class="p">{</span>
<span class="nx">image</span><span class="p">.</span><span class="nf">setAttribute</span><span class="p">(</span><span class="dl">"</span><span class="s2">src</span><span class="dl">"</span><span class="p">,</span> <span class="nx">meta</span><span class="p">.</span><span class="nx">download_url</span><span class="p">);</span>
<span class="p">})</span>
<span class="p">.</span><span class="k">catch</span><span class="p">((</span><span class="nx">e</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="k">throw</span> <span class="nx">e</span><span class="p">;</span>
<span class="p">});</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="c1">// register the above controller</span>
<span class="nx">Stimulus</span><span class="p">.</span><span class="nf">register</span><span class="p">(</span><span class="dl">"</span><span class="s2">schematic-edit-handler</span><span class="dl">"</span><span class="p">,</span> <span class="nx">SchematicEditHandler</span><span class="p">);</span>
<span class="p">});</span>
</code></pre>
</div>
<h4>
Create <code>static/css/schematic-edit-handler.css</code> styles
</h4>
<ul>
<li>This is a base starting point to get the preview image and the action buttons to stack instead of show inline, plus allow the image to get larger based on the actual image used.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight css"><code><span class="c">/* static/css/schematic-edit-handler.css */</span>
<span class="c">/* preview image - container */</span>
<span class="nc">.schematic-edit-handler</span> <span class="nc">.image-chooser</span> <span class="nc">.chosen</span> <span class="p">{</span>
<span class="nl">padding-left</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.schematic-edit-handler</span> <span class="nc">.image-chooser</span> <span class="nc">.preview-image</span> <span class="p">{</span>
<span class="nl">display</span><span class="p">:</span> <span class="n">inline-block</span><span class="p">;</span> <span class="c">/* ensure container matches image size */</span>
<span class="nl">max-width</span><span class="p">:</span> <span class="m">100%</span><span class="p">;</span>
<span class="nl">margin</span><span class="p">:</span> <span class="m">2rem</span> <span class="m">0</span><span class="p">;</span>
<span class="nl">float</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="nl">position</span><span class="p">:</span> <span class="nb">relative</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.schematic-edit-handler</span> <span class="nc">.image-chooser</span> <span class="nc">.preview-image</span> <span class="nt">img</span> <span class="p">{</span>
<span class="nl">max-height</span><span class="p">:</span> <span class="m">100%</span><span class="p">;</span>
<span class="nl">max-width</span><span class="p">:</span> <span class="m">100%</span><span class="p">;</span>
<span class="p">}</span>
</code></pre>
</div>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fja46c1kns2pm6queb5l2.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fja46c1kns2pm6queb5l2.png" alt="Larger preview image used with the custom ImageChooser panel"></a></p>
<h2>
Part 5 - Enhance the editor's experience to show point positioning
</h2>
<ul>
<li>In this next part, our goal is to have the <code>points</code> shown visually over the image.</li>
<li>The styling here is very similar to the styling used in our page template but we need to ensure that the points move when the inputs change.</li>
<li>We will continue to expand on our Stimulus controller to house the JS behaviour and leverage another <code>data-</code> attribute around the InlinePanel used.</li>
<li>Working with the <code>InlinePanel</code> (also called expanding formset) has some nuance, the main thing to remember is that these panels can be deleted but this deletion only happens visually as there are <code>input</code> fields under the hood that get updated. Also, the panels can be reordered and added at will.</li>
</ul>
<h3>
5a - Add a <code>SchematicPointPanel</code> that will use a new template <code>schematics/edit_handlers/schematic_point_panel.html</code>
</h3>
<ul>
<li>We will update <code>schematics/edit_handlers.py</code> with another custom panel, this time extending the <code>MultiFieldPanel</code>, which is essentially just a thin wrapper around a bunch of fields.</li>
<li>This custom class does one thing, point the panel to a new template.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># schematics/edit_handlers.py
</span><span class="kn">from</span> <span class="n">django.utils.html</span> <span class="kn">import</span> <span class="n">format_html</span>
<span class="kn">from</span> <span class="n">wagtail.admin.edit_handlers</span> <span class="kn">import</span> <span class="n">MultiFieldPanel</span><span class="p">,</span> <span class="n">ObjectList</span> <span class="c1"># update - added MultiFieldPanel
</span><span class="kn">from</span> <span class="n">wagtail.images.edit_handlers</span> <span class="kn">import</span> <span class="n">ImageChooserPanel</span>
<span class="kn">from</span> <span class="n">wagtail.images.widgets</span> <span class="kn">import</span> <span class="n">AdminImageChooser</span>
<span class="c1"># ... other classes
</span>
<span class="k">class</span> <span class="nc">SchematicPointPanel</span><span class="p">(</span><span class="n">MultiFieldPanel</span><span class="p">):</span>
<span class="n">template</span> <span class="o">=</span> <span class="sh">"</span><span class="s">schematics/edit_handlers/schematic_point_panel.html</span><span class="sh">"</span>
</code></pre>
</div>
<ul>
<li>Create the new template <code>schematics/edit_handlers/schematic_point_panel.html</code> and all it does is wrap the existing multi_field_panel in a div that will add a class and add another Stimulus target.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight plaintext"><code><div class="schematic-point-panel" data-schematic-edit-handler-target="point">
{% extends "wagtailadmin/edit_handlers/multi_field_panel.html" %}
</div>
</code></pre>
</div>
<h3>
5b - Use the <code>SchematicPointPanel</code> in <code>models.py</code> & update <code>attrs</code>
</h3>
<ul>
<li>Now that we have created <code>SchematicPointPanel</code> we can use it inside our <code>SchematicPoint</code> model to wrap the <code>fields</code>.</li>
<li>We have also reworked the various <code>FieldPanel</code> items to leverage the <code>widget</code> attribute so we can add some more data-attributes.</li>
<li>Note that the <code>data-action</code> is a specific Stimulus attribute that says 'when this input changes fire a method on the Controller. It can be used to add specific event listeners as we will see later but the default behaviour on <code>input</code> elements is the <code>'change'</code> event.</li>
<li>We also add some <code>data-point-</code> attributes, these are not Stimulus specific items but just a convenience attribute to find those elements in our Stimulus controller, we could use more <code>target</code> type attributes but that is not critical for the scope of this tutorial.</li>
<li>A reminder that Django will smartly handle some attributes and when Python <code>True</code> is passed, it will be converted to a string <code>'true'</code> in HTML - thanks Django!
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># schematics/models.py
# ... imports
</span>
<span class="kn">from</span> <span class="n">.edit_handlers</span> <span class="kn">import</span> <span class="p">(</span>
<span class="n">SchematicEditHandler</span><span class="p">,</span>
<span class="n">SchematicImageChooserPanel</span><span class="p">,</span>
<span class="n">SchematicPointPanel</span><span class="p">,</span> <span class="c1"># added
</span><span class="p">)</span>
<span class="c1"># Schematic model
</span>
<span class="k">class</span> <span class="nc">SchematicPoint</span><span class="p">(</span><span class="n">Orderable</span><span class="p">,</span> <span class="n">models</span><span class="p">.</span><span class="n">Model</span><span class="p">):</span>
<span class="c1"># schematic/label fields
</span>
<span class="n">x</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">DecimalField</span><span class="p">(</span>
<span class="n">verbose_name</span><span class="o">=</span><span class="sh">"</span><span class="s">X →</span><span class="sh">"</span><span class="p">,</span>
<span class="n">max_digits</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span>
<span class="n">decimal_places</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span>
<span class="n">default</span><span class="o">=</span><span class="mf">0.0</span><span class="p">,</span>
<span class="n">validators</span><span class="o">=</span><span class="p">[</span><span class="nc">MaxValueValidator</span><span class="p">(</span><span class="mf">100.0</span><span class="p">),</span> <span class="nc">MinValueValidator</span><span class="p">(</span><span class="mf">0.0</span><span class="p">)],</span>
<span class="p">)</span>
<span class="n">y</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">DecimalField</span><span class="p">(</span>
<span class="n">verbose_name</span><span class="o">=</span><span class="sh">"</span><span class="s">Y ↑</span><span class="sh">"</span><span class="p">,</span>
<span class="n">max_digits</span><span class="o">=</span><span class="mi">5</span><span class="p">,</span>
<span class="n">decimal_places</span><span class="o">=</span><span class="mi">2</span><span class="p">,</span>
<span class="n">default</span><span class="o">=</span><span class="mf">0.0</span><span class="p">,</span>
<span class="n">validators</span><span class="o">=</span><span class="p">[</span><span class="nc">MaxValueValidator</span><span class="p">(</span><span class="mf">100.0</span><span class="p">),</span> <span class="nc">MinValueValidator</span><span class="p">(</span><span class="mi">0</span><span class="p">)],</span>
<span class="p">)</span>
<span class="n">fields</span> <span class="o">=</span> <span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span>
<span class="sh">"</span><span class="s">label</span><span class="sh">"</span><span class="p">,</span>
<span class="n">widget</span><span class="o">=</span><span class="n">forms</span><span class="p">.</span><span class="nc">TextInput</span><span class="p">(</span>
<span class="n">attrs</span><span class="o">=</span><span class="p">{</span>
<span class="sh">"</span><span class="s">data-action</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">schematic-edit-handler#updatePoints</span><span class="sh">"</span><span class="p">,</span>
<span class="sh">"</span><span class="s">data-point-label</span><span class="sh">"</span><span class="p">:</span> <span class="bp">True</span><span class="p">,</span>
<span class="p">}</span>
<span class="p">),</span>
<span class="p">),</span>
<span class="nc">FieldRowPanel</span><span class="p">(</span>
<span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span>
<span class="sh">"</span><span class="s">x</span><span class="sh">"</span><span class="p">,</span>
<span class="n">widget</span><span class="o">=</span><span class="n">forms</span><span class="p">.</span><span class="nc">NumberInput</span><span class="p">(</span>
<span class="n">attrs</span><span class="o">=</span><span class="p">{</span>
<span class="sh">"</span><span class="s">data-action</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">schematic-edit-handler#updatePoints</span><span class="sh">"</span><span class="p">,</span>
<span class="sh">"</span><span class="s">data-point-x</span><span class="sh">"</span><span class="p">:</span> <span class="bp">True</span><span class="p">,</span>
<span class="sh">"</span><span class="s">min</span><span class="sh">"</span><span class="p">:</span> <span class="mf">0.0</span><span class="p">,</span>
<span class="sh">"</span><span class="s">max</span><span class="sh">"</span><span class="p">:</span> <span class="mf">100.0</span><span class="p">,</span>
<span class="p">}</span>
<span class="p">),</span>
<span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span>
<span class="sh">"</span><span class="s">y</span><span class="sh">"</span><span class="p">,</span>
<span class="n">widget</span><span class="o">=</span><span class="n">forms</span><span class="p">.</span><span class="nc">NumberInput</span><span class="p">(</span>
<span class="n">attrs</span><span class="o">=</span><span class="p">{</span>
<span class="sh">"</span><span class="s">data-action</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">schematic-edit-handler#updatePoints</span><span class="sh">"</span><span class="p">,</span>
<span class="sh">"</span><span class="s">data-point-y</span><span class="sh">"</span><span class="p">:</span> <span class="bp">True</span><span class="p">,</span>
<span class="sh">"</span><span class="s">min</span><span class="sh">"</span><span class="p">:</span> <span class="mf">0.0</span><span class="p">,</span>
<span class="sh">"</span><span class="s">max</span><span class="sh">"</span><span class="p">:</span> <span class="mf">100.0</span><span class="p">,</span>
<span class="p">}</span>
<span class="p">),</span>
<span class="p">),</span>
<span class="p">]</span>
<span class="p">),</span>
<span class="p">]</span>
<span class="n">panels</span> <span class="o">=</span> <span class="p">[</span><span class="nc">SchematicPointPanel</span><span class="p">(</span><span class="n">fields</span><span class="p">)]</span>
<span class="c1"># ... def/Meta
</span>
<span class="c1"># other classes
</span></code></pre>
</div>
<h3>
5c - Add a <code>template</code> to <code>templates/schematics/edit_handlers/schematic_edit_handler.html</code>
</h3>
<ul>
<li>We need a way to determine how to output a <code>point</code> in the editor UI, and while we can build this up as a string in the Stimulus controller, let's make our lives easier to and use a <a href="proxy.php?url=https://developer.mozilla.org/en-US/docs/Web/HTML/Element/template" rel="noopener noreferrer">HTML <code>template</code> element</a>.</li>
<li>This template will be pre-loaded with the relevant data attributes we need and a <code>label</code> slot to add the label the user has entered. The nice thing about this approach is that we can modify this rendering just by changing the HTML template later.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight plaintext"><code><!-- templates/schematics/edit_handlers/schematic_edit_handler.html -->
<div class="schematic-edit-handler" data-controller="schematic-edit-handler">
<template data-schematic-edit-handler-target="imagePointTemplate">
<li
class="point"
data-schematic-edit-handler-target="imagePoint"
>
<span class="label"></span>
</li>
</template>
{% extends "wagtailadmin/edit_handlers/object_list.html" %}
</div>
</code></pre>
</div>
<h3>
5d - Update the <code>SchematicEditHandler</code> Stimulus controller to output points
</h3>
<ul>
<li>In our Stimulus Controller we will add 4 new targets; <code>imagePoint</code> - shows the point visually over the preview images, <code>imagePoints</code> - container for the <code>imagePoint</code> elements, <code>imagePointTemplate</code> - the template to use, set in the above step, <code>point</code> - each related model added via the <code>InlinePanel</code> children.</li>
<li>Now we can add a <code>pointTargetConnected</code> method, this is a powerful built-in part of the Stimulus controller where each target gets its <a href="proxy.php?url=https://stimulus.hotwired.dev/reference/targets#connected-and-disconnected-callbacks" rel="noopener noreferrer">own connected/disconnected callbacks</a>. These also fire when initially connected so we can have a consistent way to know what <code>InlinePanel</code> children exist on load AND any that are added by the user later without having to do too much of our own code here.</li>
<li>
<code>pointTargetConnected</code> basically adds a 'delete' button listener so we know when to re-update our points.</li>
<li>
<code>updatePoints</code> does the bulk of the heavy lifting here, best to read through the code line by line to understand it. Essentially it goes through each of the <code>point</code> targeted elements and builds up an array of elements based on the <code>imagePointTemplate</code> but only if that panel is not marked as deleted. It then puts those points into a <code>ul</code> element next to the preview image, which itself has a target of <code>imagePoints</code> to be deleted and re-written whenever we need to run another update.</li>
<li>You should be able to validate this by reloading the page and seeing that there are a bunch of new elements added just under the image.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="c1">// static/js/schematic-edit-handler.js</span>
<span class="kd">class</span> <span class="nc">SchematicEditHandler</span> <span class="kd">extends</span> <span class="nc">Controller</span> <span class="p">{</span>
<span class="kd">static</span> <span class="nx">targets</span> <span class="o">=</span> <span class="p">[</span>
<span class="dl">"</span><span class="s2">imageInput</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">imagePoint</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">imagePoints</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">imagePointTemplate</span><span class="dl">"</span><span class="p">,</span>
<span class="dl">"</span><span class="s2">point</span><span class="dl">"</span><span class="p">,</span>
<span class="p">];</span>
<span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
<span class="k">this</span><span class="p">.</span><span class="nf">setupImageInputObserver</span><span class="p">();</span>
<span class="k">this</span><span class="p">.</span><span class="nf">updatePoints</span><span class="p">();</span> <span class="c1">// added</span>
<span class="p">}</span>
<span class="cm">/**
* Once a new point target (for each point within the inline panel) is connected
* add an event listener to the delete button so we know when to re-update the points.
*
* @param {HTMLElement} element
*/</span>
<span class="nf">pointTargetConnected</span><span class="p">(</span><span class="nx">element</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">deletePointButton</span> <span class="o">=</span> <span class="nx">element</span>
<span class="p">.</span><span class="nf">closest</span><span class="p">(</span><span class="dl">"</span><span class="s2">[data-inline-panel-child]</span><span class="dl">"</span><span class="p">)</span>
<span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">[id*="DELETE-button"]</span><span class="dl">'</span><span class="p">);</span>
<span class="nx">deletePointButton</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">click</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="k">this</span><span class="p">.</span><span class="nf">updatePoints</span><span class="p">(</span><span class="nx">event</span><span class="p">);</span>
<span class="p">});</span>
<span class="p">}</span>
<span class="c1">// setupImageInputObserver() ...</span>
<span class="c1">// updateImage() ...</span>
<span class="cm">/**
* Removes the existing points shown and builds up a new list,
* ensuring we do not add a point visually for any inline panel
* items that have been deleted.
*/</span>
<span class="nf">updatePoints</span><span class="p">()</span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="k">this</span><span class="p">.</span><span class="nx">hasImagePointsTarget</span><span class="p">)</span> <span class="k">this</span><span class="p">.</span><span class="nx">imagePointsTarget</span><span class="p">.</span><span class="nf">remove</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">template</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">imagePointTemplateTarget</span><span class="p">.</span><span class="nx">content</span><span class="p">.</span><span class="nx">firstElementChild</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">points</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">pointTargets</span>
<span class="p">.</span><span class="nf">reduce</span><span class="p">((</span><span class="nx">points</span><span class="p">,</span> <span class="nx">element</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">inlinePanel</span> <span class="o">=</span> <span class="nx">element</span><span class="p">.</span><span class="nf">closest</span><span class="p">(</span><span class="dl">"</span><span class="s2">[data-inline-panel-child]</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">isDeleted</span> <span class="o">=</span> <span class="nx">inlinePanel</span><span class="p">.</span><span class="nf">matches</span><span class="p">(</span><span class="dl">"</span><span class="s2">.deleted</span><span class="dl">"</span><span class="p">);</span>
<span class="k">if </span><span class="p">(</span><span class="nx">isDeleted</span><span class="p">)</span> <span class="k">return</span> <span class="nx">points</span><span class="p">;</span>
<span class="k">return</span> <span class="nx">points</span><span class="p">.</span><span class="nf">concat</span><span class="p">({</span>
<span class="na">id</span><span class="p">:</span> <span class="nx">inlinePanel</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">[id$='-id']</span><span class="dl">"</span><span class="p">).</span><span class="nx">id</span><span class="p">,</span>
<span class="na">label</span><span class="p">:</span> <span class="nx">element</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">[data-point-label]</span><span class="dl">"</span><span class="p">).</span><span class="nx">value</span><span class="p">,</span>
<span class="na">x</span><span class="p">:</span> <span class="nc">Number</span><span class="p">(</span><span class="nx">element</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">[data-point-x]</span><span class="dl">"</span><span class="p">).</span><span class="nx">value</span><span class="p">),</span>
<span class="na">y</span><span class="p">:</span> <span class="nc">Number</span><span class="p">(</span><span class="nx">element</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">[data-point-y]</span><span class="dl">"</span><span class="p">).</span><span class="nx">value</span><span class="p">),</span>
<span class="p">});</span>
<span class="p">},</span> <span class="p">[])</span>
<span class="p">.</span><span class="nf">map</span><span class="p">(({</span> <span class="nx">id</span><span class="p">,</span> <span class="nx">x</span><span class="p">,</span> <span class="nx">y</span><span class="p">,</span> <span class="nx">label</span> <span class="p">})</span> <span class="o">=></span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">point</span> <span class="o">=</span> <span class="nx">template</span><span class="p">.</span><span class="nf">cloneNode</span><span class="p">(</span><span class="kc">true</span><span class="p">);</span>
<span class="nx">point</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">id</span> <span class="o">=</span> <span class="nx">id</span><span class="p">;</span>
<span class="nx">point</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">.label</span><span class="dl">"</span><span class="p">).</span><span class="nx">innerText</span> <span class="o">=</span> <span class="nx">label</span><span class="p">;</span>
<span class="nx">point</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">bottom</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">y</span><span class="p">}</span><span class="s2">%`</span><span class="p">;</span>
<span class="nx">point</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">left</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">x</span><span class="p">}</span><span class="s2">%`</span><span class="p">;</span>
<span class="k">return</span> <span class="nx">point</span><span class="p">;</span>
<span class="p">});</span>
<span class="kd">const</span> <span class="nx">newPoints</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span><span class="dl">"</span><span class="s2">ol</span><span class="dl">"</span><span class="p">);</span>
<span class="nx">newPoints</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nf">add</span><span class="p">(</span><span class="dl">"</span><span class="s2">points</span><span class="dl">"</span><span class="p">);</span>
<span class="nx">newPoints</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">schematicEditHandlerTarget</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">imagePoints</span><span class="dl">"</span><span class="p">;</span>
<span class="nx">points</span><span class="p">.</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">point</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="nx">newPoints</span><span class="p">.</span><span class="nf">appendChild</span><span class="p">(</span><span class="nx">point</span><span class="p">);</span>
<span class="p">});</span>
<span class="k">this</span><span class="p">.</span><span class="nx">imageInputTarget</span>
<span class="p">.</span><span class="nf">closest</span><span class="p">(</span><span class="dl">"</span><span class="s2">.field-content</span><span class="dl">"</span><span class="p">)</span>
<span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">.preview-image</span><span class="dl">"</span><span class="p">)</span>
<span class="p">.</span><span class="nf">appendChild</span><span class="p">(</span><span class="nx">newPoints</span><span class="p">);</span>
<span class="p">}</span>
<span class="c1">// rest of controller definition & registration</span>
</code></pre>
</div>
<h3>
5e - Add styles for the points in <code>schematic-edit-handler.css</code>
</h3>
<ul>
<li>There is a fair bit of CSS happening here but our goal is to ensure that the points show correctly over the image and can be positioned absolutely.</li>
<li>We also add a few nice visuals such as a label on hover, a number that shows in the circle and a number against each inline panel so that our users can mentally map these things easier.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight css"><code><span class="c">/* static/css/schematic-edit-handler.css */</span>
<span class="c">/* preview image - container ...(keep as is) */</span>
<span class="c">/* inline panels - add visible numbers */</span>
<span class="nc">.schematic-edit-handler</span> <span class="nc">.multiple</span> <span class="p">{</span>
<span class="nl">counter-reset</span><span class="p">:</span> <span class="n">css-counter</span> <span class="m">0</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.schematic-edit-handler</span> <span class="o">[</span><span class="nt">data-inline-panel-child</span><span class="o">]</span><span class="nd">:not</span><span class="o">(</span><span class="nc">.deleted</span><span class="o">)</span> <span class="p">{</span>
<span class="nl">counter-increment</span><span class="p">:</span> <span class="n">css-counter</span> <span class="m">1</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.schematic-edit-handler</span>
<span class="o">[</span><span class="nt">data-inline-panel-child</span><span class="o">]</span><span class="nd">:not</span><span class="o">(</span><span class="nc">.deleted</span><span class="o">)</span>
<span class="o">></span> <span class="nt">fieldset</span><span class="nd">::before</span> <span class="p">{</span>
<span class="nl">content</span><span class="p">:</span> <span class="n">counter</span><span class="p">(</span><span class="n">css-counter</span><span class="p">)</span> <span class="s1">". "</span><span class="p">;</span>
<span class="p">}</span>
<span class="c">/* preview image - points */</span>
<span class="c">/* tooltip styles based on https://blog.logrocket.com/creating-beautiful-tooltips-with-only-css/ */</span>
<span class="nc">.schematic-edit-handler</span> <span class="nc">.image-chooser</span> <span class="nc">.preview-image</span> <span class="nc">.points</span> <span class="p">{</span>
<span class="nl">counter-reset</span><span class="p">:</span> <span class="n">css-counter</span> <span class="m">0</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.schematic-edit-handler</span> <span class="nc">.image-chooser</span> <span class="nc">.preview-image</span> <span class="nc">.point</span> <span class="p">{</span>
<span class="nl">counter-increment</span><span class="p">:</span> <span class="n">css-counter</span> <span class="m">1</span><span class="p">;</span>
<span class="nl">position</span><span class="p">:</span> <span class="nb">absolute</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.schematic-edit-handler</span> <span class="nc">.image-chooser</span> <span class="nc">.preview-image</span> <span class="nc">.point</span><span class="nd">::before</span> <span class="p">{</span>
<span class="nl">background-clip</span><span class="p">:</span> <span class="n">padding-box</span><span class="p">;</span> <span class="c">/* ensures the 'hover' target is larger than the visible circle */</span>
<span class="nl">background-color</span><span class="p">:</span> <span class="m">#7c4c4c</span><span class="p">;</span>
<span class="nl">border-radius</span><span class="p">:</span> <span class="m">50%</span><span class="p">;</span>
<span class="nl">border</span><span class="p">:</span> <span class="m">0.25rem</span> <span class="nb">solid</span> <span class="nb">transparent</span><span class="p">;</span>
<span class="nl">color</span><span class="p">:</span> <span class="nb">rgb</span><span class="p">(</span><span class="m">236</span><span class="p">,</span> <span class="m">236</span><span class="p">,</span> <span class="m">236</span><span class="p">);</span>
<span class="nl">box-shadow</span><span class="p">:</span> <span class="m">0</span> <span class="m">-2px</span> <span class="m">0</span> <span class="n">rgba</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0.1</span><span class="p">)</span> <span class="nb">inset</span><span class="p">;</span>
<span class="nl">content</span><span class="p">:</span> <span class="n">counter</span><span class="p">(</span><span class="n">css-counter</span><span class="p">);</span>
<span class="nl">text-align</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
<span class="nl">line-height</span><span class="p">:</span> <span class="m">1.75rem</span><span class="p">;</span>
<span class="nl">font-weight</span><span class="p">:</span> <span class="nb">bolder</span><span class="p">;</span>
<span class="nl">display</span><span class="p">:</span> <span class="nb">block</span><span class="p">;</span>
<span class="nl">height</span><span class="p">:</span> <span class="m">1.75rem</span><span class="p">;</span>
<span class="nl">position</span><span class="p">:</span> <span class="nb">absolute</span><span class="p">;</span>
<span class="nl">transform</span><span class="p">:</span> <span class="n">translate</span><span class="p">(</span><span class="m">-50%</span><span class="p">,</span> <span class="m">-50%</span><span class="p">);</span>
<span class="nl">width</span><span class="p">:</span> <span class="m">1.75rem</span><span class="p">;</span>
<span class="nl">z-index</span><span class="p">:</span> <span class="m">1</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.schematic-edit-handler</span> <span class="nc">.image-chooser</span> <span class="nc">.preview-image</span> <span class="nc">.point</span> <span class="nc">.label</span> <span class="p">{</span>
<span class="nl">opacity</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span> <span class="c">/* hide by default */</span>
<span class="nl">position</span><span class="p">:</span> <span class="nb">absolute</span><span class="p">;</span>
<span class="c">/* vertically center */</span>
<span class="nl">top</span><span class="p">:</span> <span class="m">50%</span><span class="p">;</span>
<span class="nl">transform</span><span class="p">:</span> <span class="n">translateY</span><span class="p">(</span><span class="m">-50%</span><span class="p">);</span>
<span class="c">/* move to right */</span>
<span class="nl">left</span><span class="p">:</span> <span class="m">100%</span><span class="p">;</span>
<span class="nl">margin-left</span><span class="p">:</span> <span class="m">1.25rem</span><span class="p">;</span> <span class="c">/* and add a small left margin */</span>
<span class="c">/* basic styles */</span>
<span class="nl">width</span><span class="p">:</span> <span class="m">5rem</span><span class="p">;</span>
<span class="nl">padding</span><span class="p">:</span> <span class="m">5px</span><span class="p">;</span>
<span class="nl">border-radius</span><span class="p">:</span> <span class="m">5px</span><span class="p">;</span>
<span class="nl">background</span><span class="p">:</span> <span class="m">#000</span><span class="p">;</span>
<span class="nl">color</span><span class="p">:</span> <span class="m">#fff</span><span class="p">;</span>
<span class="nl">text-align</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
<span class="nl">transition</span><span class="p">:</span> <span class="n">opacity</span> <span class="m">300ms</span> <span class="n">ease-in-out</span><span class="p">;</span>
<span class="nl">z-index</span><span class="p">:</span> <span class="m">10</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.schematic-edit-handler</span> <span class="nc">.image-chooser</span> <span class="nc">.preview-image</span> <span class="nc">.point</span><span class="nd">:hover</span> <span class="nc">.label</span> <span class="p">{</span>
<span class="nl">opacity</span><span class="p">:</span> <span class="m">1</span><span class="p">;</span>
<span class="p">}</span>
</code></pre>
</div>
<h3>
5f - Validation & congrats
</h3>
<ul>
<li>At this point, you should be able to load the Snippet with some existing points and once the JS runs see those points over the image.</li>
<li>These points should align visually with the same points shown in the public-facing page (frontend) when that Schematic is used.</li>
<li>Back in the Wagtail editor, we should be able to add/delete/reorder points with the <code>InlinePanel</code> UI and the points over the image should update each time.</li>
<li>We should also be able to adjust the label, the number fields bit by bit and see the points also updated.</li>
<li>Try to break it, see what does not work and what could be improved, but congratulate yourself for getting this far and learning something new!</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fidhz9el364z1u2tzowoq.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fidhz9el364z1u2tzowoq.png" alt="Final with points showing over the preview image"></a></p>
<h2>
Part 6 (Bonus) - Drag & Drop!
</h2>
<ul>
<li>If you want to go down the rabbit hole further, grab yourself a fresh shot of espresso or pour an Aeropress and sit down to make this editing experience even more epic.</li>
<li>We will be using the <a href="proxy.php?url=https://developer.mozilla.org/en-US/docs/Web/API/HTML_Drag_and_Drop_API" rel="noopener noreferrer">HTML Drag & Drop API</a> here and it is strongly recommended you read through the MDN overview before proceeding.</li>
<li>There are some caveats, we are working with a kind of lower-level API and there are browser support considerations to make.</li>
<li>Ideally, we would pull in another library to do this for us but it is probably better to build it with plain old Vanilla JS first and then enhance it later once you know this is a good thing to work on.</li>
</ul>
<h3>
6a - Add more data attributes to the point template
</h3>
<ul>
<li>At this point, you probably can tell that data attributes are our friend with Stimulus and Django so let's add some more.</li>
<li>In <code>templates/schematics/edit_handlers/schematic_edit_handler.html</code> we will update our <code>template</code> (which gets used to generate the <code>li</code> point element).</li>
<li>We have added <code>data-action="proxy.php?url=dragstart->schematic-edit-handler#pointDragStart dragend->schematic-edit-handler#pointDragEnd"</code> - this is the <code>data-action</code> from Stimulus showing off how powerful this abstraction is. Here we add two event listeners for specific events and no need to worry about <code>addEventListener</code> as it is done for us.</li>
<li>We also add <code>draggable="true"</code> which is part of the HTML Drag & Drop API requirements.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight html"><code><span class="nt"><div</span> <span class="na">class=</span><span class="s">"schematic-edit-handler"</span> <span class="na">data-controller=</span><span class="s">"schematic-edit-handler"</span><span class="nt">></span>
<span class="nt"><template</span> <span class="na">data-schematic-edit-handler-target=</span><span class="s">"imagePointTemplate"</span><span class="nt">></span>
<span class="nt"><li</span>
<span class="na">class=</span><span class="s">"point"</span>
<span class="na">data-schematic-edit-handler-target=</span><span class="s">"imagePoint"</span>
<span class="na">data-action=</span><span class="s">"dragstart->schematic-edit-handler#pointDragStart dragend->schematic-edit-handler#pointDragEnd"</span>
<span class="na">draggable=</span><span class="s">"true"</span>
<span class="nt">></span>
<span class="nt"><span</span> <span class="na">class=</span><span class="s">"label"</span><span class="nt">></span></span>
<span class="nt"></li></span>
<span class="nt"></template></span>
{% extends "wagtailadmin/edit_handlers/object_list.html" %}
<span class="nt"></div></span>
</code></pre>
</div>
<h3>
6b - Update the <code>SchematicEditHandler</code> Controller to handle drag / drop behaviour
</h3>
<ul>
<li>
<strong>Firstly</strong>, we need to handle the drag (picking up) an element, these events are triggered by the <code>data-action</code> set above.</li>
<li>
<code>pointDragStart</code> - this will tell the browser that this element can 'move' and that we want to pass the <code>dataset.id</code> the eventual drop for tracking. We also make the element semi-transparent to show that it is being dragged, there are lots of other ways to visually show this but this is just a basic start.</li>
<li>
<code>pointDragEnd</code> - resets the style opacity back to normal.</li>
<li>In the <code>connect</code> method we call a new method <code>setupImageDropHandlers</code>, this does the job of our <code>data-action</code> attributes but we cannot easily, without a larger set of Wagtail class overrides, add these attributes so we have to add the event handlers manually.</li>
<li>
<code>setupImageDropHandlers</code> - finds the preview image container and adds a listener for <code>'dragover'</code> to say 'this can drop here' and then the <code>'drop'</code> to do the work of updating the inputs.</li>
<li>
<code>addEventListener("drop"...</code> does a fair bit, essentially it pulls in the data from the drag behaviour, this helps us find what <code>InlinePanel</code> child we need to update. We then work out the x/y percentages of the dropped point relative to the image preview container and round that to 2 decimal places. The x/y values are then updated in the correct fields.</li>
<li>A reminder that when we update the fields programmatically, the <code>'change'</code> event is NOT triggered, so we finally have to ensure we call <code>updatePoints</code> to re-create the points again over the image container.</li>
<li>You can now validate this by actually doing drag & drop and checking things get updated correctly in the UI, save the values and check the front-facing page.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="kd">class</span> <span class="nc">SchematicEditHandler</span> <span class="kd">extends</span> <span class="nc">Controller</span> <span class="p">{</span>
<span class="c1">// ... targets</span>
<span class="nf">connect</span><span class="p">()</span> <span class="p">{</span>
<span class="k">this</span><span class="p">.</span><span class="nf">setupImageInputObserver</span><span class="p">();</span>
<span class="k">this</span><span class="p">.</span><span class="nf">setupImageDropHandlers</span><span class="p">();</span>
<span class="k">this</span><span class="p">.</span><span class="nf">updatePoints</span><span class="p">();</span>
<span class="p">}</span>
<span class="cm">/**
* Once a new point target (for each point within the inline panel) is connected
* add an event listener to the delete button so we know when to re-update the points.
*
* @param {HTMLElement} element
*/</span>
<span class="nf">pointTargetConnected</span><span class="p">(</span><span class="nx">element</span><span class="p">)</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">deletePointButton</span> <span class="o">=</span> <span class="nx">element</span>
<span class="p">.</span><span class="nf">closest</span><span class="p">(</span><span class="dl">"</span><span class="s2">[data-inline-panel-child]</span><span class="dl">"</span><span class="p">)</span>
<span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">'</span><span class="s1">[id*="DELETE-button"]</span><span class="dl">'</span><span class="p">);</span>
<span class="nx">deletePointButton</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">click</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="k">this</span><span class="p">.</span><span class="nf">updatePoints</span><span class="p">(</span><span class="nx">event</span><span class="p">);</span>
<span class="p">});</span>
<span class="p">}</span>
<span class="cm">/**
* Allow the point to be dragged using the 'move' effect and set its data.
*
* @param {DragEvent} event
*/</span>
<span class="nf">pointDragStart</span><span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="p">{</span>
<span class="nx">event</span><span class="p">.</span><span class="nx">dataTransfer</span><span class="p">.</span><span class="nx">dropEffect</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">move</span><span class="dl">"</span><span class="p">;</span>
<span class="nx">event</span><span class="p">.</span><span class="nx">dataTransfer</span><span class="p">.</span><span class="nf">setData</span><span class="p">(</span><span class="dl">"</span><span class="s2">text/plain</span><span class="dl">"</span><span class="p">,</span> <span class="nx">event</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">id</span><span class="p">);</span>
<span class="nx">event</span><span class="p">.</span><span class="nx">target</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">opacity</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">0.5</span><span class="dl">"</span><span class="p">;</span>
<span class="p">}</span>
<span class="cm">/**
* When dragging finishes on a point, reset its opacity.
*
* @param {DragEvent} event
*/</span>
<span class="nf">pointDragEnd</span><span class="p">({</span> <span class="nx">target</span> <span class="p">})</span> <span class="p">{</span>
<span class="nx">target</span><span class="p">.</span><span class="nx">style</span><span class="p">.</span><span class="nx">opacity</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">1</span><span class="dl">"</span><span class="p">;</span>
<span class="p">}</span>
<span class="c1">// setupImageInputObserver() { ...</span>
<span class="cm">/**
* Once connected, set up the dragover and drop events on the preview image container.
* We are unable to easily do this with `data-action` attributes in the template.
*/</span>
<span class="nf">setupImageDropHandlers</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">previewImageContainer</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">imageInputTarget</span>
<span class="p">.</span><span class="nf">closest</span><span class="p">(</span><span class="dl">"</span><span class="s2">.field-content</span><span class="dl">"</span><span class="p">)</span>
<span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">.preview-image</span><span class="dl">"</span><span class="p">);</span>
<span class="nx">previewImageContainer</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">dragover</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="nx">event</span><span class="p">.</span><span class="nf">preventDefault</span><span class="p">();</span>
<span class="nx">event</span><span class="p">.</span><span class="nx">dataTransfer</span><span class="p">.</span><span class="nx">dropEffect</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">move</span><span class="dl">"</span><span class="p">;</span>
<span class="p">});</span>
<span class="nx">previewImageContainer</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">drop</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="nx">event</span><span class="p">.</span><span class="nf">preventDefault</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">inputId</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">dataTransfer</span><span class="p">.</span><span class="nf">getData</span><span class="p">(</span><span class="dl">"</span><span class="s2">text/plain</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">height</span><span class="p">,</span> <span class="nx">width</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">previewImageContainer</span><span class="p">.</span><span class="nf">getBoundingClientRect</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">xNumber</span> <span class="o">=</span> <span class="nx">event</span><span class="p">.</span><span class="nx">offsetX</span> <span class="o">/</span> <span class="nx">width</span> <span class="o">+</span> <span class="nb">Number</span><span class="p">.</span><span class="nx">EPSILON</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">x</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nf">round</span><span class="p">(</span><span class="nx">xNumber</span> <span class="o">*</span> <span class="mi">10000</span><span class="p">)</span> <span class="o">/</span> <span class="mi">100</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">yNumber</span> <span class="o">=</span> <span class="mi">1</span> <span class="o">-</span> <span class="nx">event</span><span class="p">.</span><span class="nx">offsetY</span> <span class="o">/</span> <span class="nx">height</span> <span class="o">+</span> <span class="nb">Number</span><span class="p">.</span><span class="nx">EPSILON</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">y</span> <span class="o">=</span> <span class="nb">Math</span><span class="p">.</span><span class="nf">round</span><span class="p">(</span><span class="nx">yNumber</span> <span class="o">*</span> <span class="mi">10000</span><span class="p">)</span> <span class="o">/</span> <span class="mi">100</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">inlinePanel</span> <span class="o">=</span> <span class="nb">document</span>
<span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="nx">inputId</span><span class="p">)</span>
<span class="p">.</span><span class="nf">closest</span><span class="p">(</span><span class="dl">"</span><span class="s2">[data-inline-panel-child]</span><span class="dl">"</span><span class="p">);</span>
<span class="nx">inlinePanel</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">[data-point-x]</span><span class="dl">"</span><span class="p">).</span><span class="nx">value</span> <span class="o">=</span> <span class="nx">x</span><span class="p">;</span>
<span class="nx">inlinePanel</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">[data-point-y]</span><span class="dl">"</span><span class="p">).</span><span class="nx">value</span> <span class="o">=</span> <span class="nx">y</span><span class="p">;</span>
<span class="k">this</span><span class="p">.</span><span class="nf">updatePoints</span><span class="p">(</span><span class="nx">event</span><span class="p">);</span>
<span class="p">});</span>
<span class="p">}</span>
<span class="c1">// updateImage(newValue) { ... etc & rest of controller</span>
</code></pre>
</div>
<h2>
Finishing Up & Next Steps
</h2>
<ul>
<li>You should now have a functional user interface where we can build a schematic snippet with points visually shown over the image in the editor and in the front-facing page that uses it.</li>
<li>We should be able to update the points via their fields and if you did step 6, via drag and drop on the actual points within the editor.</li>
<li>I would love to hear your <strong>feedback</strong> on this post, let me know what issues you encountered or where you could see improvements.</li>
<li>If you liked this, please <strong>add a comment or reaction</strong> to the post or even <a href="proxy.php?url=https://www.buymeacoffee.com/lb.ee" rel="noopener noreferrer"><strong>shout me a coffee</strong></a>.</li>
<li>You can see the full working code, broken up into discrete commits, on my <a href="proxy.php?url=https://github.com/lb-/bakerydemo/commits/tutorial/schematic-builder" rel="noopener noreferrer">schematic-builder tutorial branch</a>.</li>
</ul>
<h3>
Further Improvements
</h3>
<p>Here are some ideas for improvements you can give a go at yourself.</p>
<ul>
<li>Add colours for points to align with the colours in the inline panels so that the point/field mapping can be easier to work with.</li>
<li>Add better keyboard control, focusable elements and up/down/left/right 'nudging', a lot of this can be done via adding more <code>data-action</code> attributes on the point <code>template</code> and working from there.</li>
<li>Add better handling of drag/drop on mobile devices, the HTML5 Drag & Drop API does not support mobile devices great, maybe an external library would be good to explore.</li>
</ul>
<h3>
Why Stimulus and not ... other things
</h3>
<p>I originally built this in late 2021 when doing some consulting, at the time I called the model <code>Diagram</code> but <code>Schematic</code> sounded better.</p>
<p>The <a href="proxy.php?url=https://gist.github.com/lb-/55fea7ec9a0be6b6c2d9184a9d77f711" rel="noopener noreferrer">original implementation was done in jQuery</a> and adding all the event listeners to the <code>InlinePanel</code> ended up being quite a mess, I could not get a bunch of the functionality to work well that is in this final tutorial and the parts of the JS/HTML were all over the place so it would have been hard to maintain.</p>
<p>Since then, I have been investigating some options for a lightweight JS framework in the Wagtail core codebase. Stimulus kept popping up in discussions but I initially wrote it off and was expecting Alpine.js to be a solid candidate. However, Alpine.js has a much larger API and also has a large CSP compliance risk that pretty much writes it off (yes, the docs say they have a CSP version but as of writing that is not actually released or working, also it pretty much negates all the benefits of Alpine).</p>
<p>After doing some small things with Stimulus, I thought this code I had written would be a good example of a semi-larger thing that needs to interact with existing DOM and dynamic DOM elements without having to dig into the other JS used by the <code>InlinePanel</code> code.</p>
<p>I do not know where the Wagtail decision will head, you can read more of the <a href="proxy.php?url=https://github.com/wagtail/wagtail/discussions/7689#discussioncomment-2037913" rel="noopener noreferrer">UI Technical Debt discussion</a> if you want. However, for lightweight JS interaction where you do not have, or need to have, full control over the entire DOM. Stimulus appears to be a really solid choice without getting in the way. While letting you work in 'vanilla' JS for all the real work and helps you with the common things like targeting elements/initialising JS behaviour and managing event listeners.</p>
<h3>
Updates
</h3>
<ul>
<li>Since posting, I have been made aware of an existing Wagtail package that does something similar <a href="proxy.php?url=https://github.com/neon-jungle/wagtail-annotations" rel="noopener noreferrer">https://github.com/neon-jungle/wagtail-annotations</a> - I have not tried it but it is good to be aware of</li>
</ul>
djangowagtailstimulusjavascriptAdding Tasks with a Checklist to Wagtail WorkflowsLB (Ben Johnston)Wed, 22 Sep 2021 07:54:45 +0000
https://dev.to/lb/adding-tasks-with-a-checklist-to-wagtail-workflows-29b8
https://dev.to/lb/adding-tasks-with-a-checklist-to-wagtail-workflows-29b8<p><strong>Goal:</strong> Create a simple way for Wagtail's CMS admin users to manage custom Workflow Tasks with checklists.</p>
<p><strong>Why:</strong> Wagtail's Workflow feature is incredibly powerful and can be leveraged to help guide our users through their Page publishing process easier.</p>
<h2>
Journey
</h2>
<p><em>Note: Feel free to skip if you just want to see the code.</em></p>
<p>One of my favourite books is <a href="proxy.php?url=https://www.goodreads.com/book/show/7796228-the-checklist-manifesto" rel="noopener noreferrer">The Checklist Manifesto: How to Get Things Right</a> and another I am reading right now is <a href="proxy.php?url=https://www.amazon.com/Everything-Its-Place-Mise-En-Place-Organize/dp/1635650119" rel="noopener noreferrer">Everything in Its Place: The Power of Mise-En-Place to Organise Your Life, Work, and Mind</a>. The core message that is in common with these is the simple practice of writing a checklist or a plan before you start and where possible, manage those checklists for future work of the same kind.</p>
<p>Now, checklists can become a burden to those who are forced to mindlessly tick 700 boxes they never read for the thousandth time this week, so as in all things there is a balance.</p>
<p>However, I do honestly believe that simple checklists for recurring processes can help both new people to a process and those who are veterans, remember the critical aspects of what they do day to day.</p>
<p>One visceral memory I have of checklists is when my son, Leo, was born. My wife had to go into an emergency C-section and while I won't go into any more details, I do vividly remember up on the wall were two huge checklists (just like described in the Checklist Manifesto) with four or five key steps regarding the surgery process.</p>
<p>This memory is obviously key to me for more than just checklists, I got to see my amazing son for the first time. I often think back to that moment and wonder how many times a day, or that week those in the room would have done the same operation but how many lives a simple checklist on the wall would have saved.</p>
<p>It also puts into perspective the code I write and the processes we design for our team. Most likely what we do is not something that could risk the lives of others but nonetheless, we all have a part to play in helping our teams be excellent and remember the little but non-trivial things they do in their job.</p>
<p>So that leads us to Wagtail's Workflow system, which was introduced in <a href="proxy.php?url=https://docs.wagtail.io/en/stable/releases/2.10.html#moderation-workflow" rel="noopener noreferrer">Wagtail 2.10</a>. This system replaced the previous moderation workflow and was a sponsored feature (a special thanks to those who contribute to Open Source / Free Software) and may have flown under the radar for many of those who use Wagtail.</p>
<p>However, this Workflow feature has been built from the ground up to be very extensible and even borrows a lot of the approaches from Wagtail's core <code>Page</code> model itself where a mix of custom code and CMS Admin editing can be combined to make something incredibly flexible and powerful.</p>
<h2>
What We Are Building
</h2>
<p>In this tutorial, you may have guessed, we will be putting together a way for Workflow Tasks to be created with a checklist and then that checklist is presented to those when they approve that specific step in the workflow.</p>
<p>This means that Wagtail Admin users could create a Workflow Checklist Task that is specifically for approval of types of pages or a generic one for all pages.</p>
<h2>
Tutorial
</h2>
<h3>
Step 0 - Getting Started
</h3>
<ul>
<li>It is assumed at this point you have Wagtail running locally and have a basic understanding of the <a href="proxy.php?url=https://docs.wagtail.io/en/stable/editor_manual/administrator_tasks/managing_workflows.html#managing-workflows" rel="noopener noreferrer">Workflow system from a user's perspective</a>.</li>
<li>If not, it would be best to start with the <a href="proxy.php?url=https://docs.wagtail.io/en/stable/getting_started/index.html" rel="noopener noreferrer">Wagtail Getting Started</a> guide.</li>
<li>It is also assumed you have a basic understanding of Django's <a href="proxy.php?url=https://docs.djangoproject.com/en/3.2/topics/db/models/" rel="noopener noreferrer">Model</a> and <a href="proxy.php?url=https://docs.djangoproject.com/en/3.2/topics/forms/" rel="noopener noreferrer">Form</a> systems, although if not maybe this Tutorial will be a good way to get some deeper understanding.</li>
<li>Versions:
<ul>
<li>Django 3.2</li>
<li>Wagtail 2.14</li>
</ul>
</li>
</ul>
<h3>
Step 1 - Create the <code>ChecklistApprovalTask</code> Model
</h3>
<ul>
<li>Firstly, we need to think about what this <code>Task</code> model is and what we want to let the user fill out.</li>
<li>The Wagtail <code>Workflow</code> area has a few key models, the main being a <code>Workflow</code> and a <code>Task</code>.</li>
<li>When creating a <code>Task</code> in the Wagtail admin, the user is presented with what kind of <code>Task</code> to use (similar to when creating a new <code>Page</code>), this UI only shows if there are more than one kind of <code>Task</code> models available (Wagtail will search through your models and find all those that are subclasses of the core <code>Task</code> model).</li>
<li>So, the <code>Task</code> model we are creating contains the fields that the user enters for multiple <code>Task</code>s of that 'kind', which are then mapped to one or more user created <code>Workflow</code>s.</li>
<li>For our base <code>Task</code> model in that case, we do not actually need to define sets of checklists but rather a way for users to enter a set of checklist items and the simplest way to do this would be with a multi-line <code>TextField</code> where each line becomes a checklist item.</li>
<li>Along with that, we will also add a field to determine whether this <code>Task</code> will require ALL checklist items to be ticked when submitting, this way the checklists can be a 'suggestion' or a 'requirement' on a per <code>Task</code> instance basis.</li>
<li>It is important to remember that the <code>Task</code> instance can be changed at any time, so the checklist the user views today could be different tomorrow and as such we will keep this implementation simpler by not tracking that the checklist was submitted and which items were ticked but that could be implemented as an enhancement down the road.</li>
<li>The following code is loosely based on the <a href="proxy.php?url=https://docs.wagtail.io/en/stable/advanced_topics/custom_tasks.html" rel="noopener noreferrer">Wagtail docs How to add new Task Types</a> section, however, to save a bunch of reimplementation, instead of building all the user logic on our own we will just extend the existing (built-in) <a href="proxy.php?url=https://github.com/wagtail/wagtail/blob/main/wagtail/core/models/__init__.py#L3425" rel="noopener noreferrer"><code>GroupApprovalTask</code></a>.</li>
<li>The reason for extending <code>GroupApprovalTask</code> is that our <code>ChecklistApprovalTask</code> is very similar, we want to assign a user group that can approve/reject as a <code>Task</code> but we just want to allow the approve step to show extra content in the approval screen.</li>
<li>Once you implement the code below you should be able to create a new Workflow and add one of the new <code>ChecklistApprovalTask</code> instances. For the rest of this tutorial it would be good to have one ready to go to test with as we build out the features.</li>
</ul>
<h4>
Code <code>models.py</code>
</h4>
<ul>
<li>Create a new model <code>ChecklistApprovalTask</code> that extends <code>GroupApprovalTask</code>.</li>
<li>This new model will contain two fields; <code>checklist</code> a <code>TextField</code> which will be used to generate the checklist items (per line), and a <code>is_checklist_required</code> <code>BooleanField</code> which will be ticked to force each checklist item to be ticked.</li>
<li>Similar to <code>Page</code> <code>panels</code>, we can use the <code>admin_form_fields</code> class attribute to define a List of fields that will be shown when a user creates/edits this <code>Task</code>.</li>
<li>The last part of our model is to leverage the <code>get_description</code> class method and the meta verbose names to provide a user-facing description & name of this <code>Task</code> type, plus we want to override the one that comes with the <code>GroupApprovalTask</code> class.</li>
<li>Once you have built your model, run <code>django-admin makemigrations</code> and then <code>django-admin migrate</code> to apply your new model.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code>
<span class="kn">from</span> <span class="n">django.db</span> <span class="kn">import</span> <span class="n">models</span>
<span class="kn">from</span> <span class="n">wagtail.core.models</span> <span class="kn">import</span> <span class="n">GroupApprovalTask</span>
<span class="k">class</span> <span class="nc">ChecklistApprovalTask</span><span class="p">(</span><span class="n">GroupApprovalTask</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Custom task type where all the features of the GroupApprovalTask will exist but
with the ability to define a custom checklist that may be required to be checked for
Approval of this step. Checklist field will be a multi-line field, each line being
one checklist item.
</span><span class="sh">"""</span>
<span class="c1"># Reminder: Already has 'groups' field as we are extending `GroupApprovalTask`
</span>
<span class="n">checklist</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">TextField</span><span class="p">(</span>
<span class="sh">"</span><span class="s">Checklist</span><span class="sh">"</span><span class="p">,</span>
<span class="n">help_text</span><span class="o">=</span><span class="sh">"</span><span class="s">Each line will become a checklist item shown on the Approve step.</span><span class="sh">"</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">is_checklist_required</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">BooleanField</span><span class="p">(</span>
<span class="sh">"</span><span class="s">Required</span><span class="sh">"</span><span class="p">,</span>
<span class="n">help_text</span><span class="o">=</span><span class="sh">"</span><span class="s">If required, all items in the checklist must be ticked to approve.</span><span class="sh">"</span><span class="p">,</span>
<span class="n">blank</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">admin_form_fields</span> <span class="o">=</span> <span class="n">GroupApprovalTask</span><span class="p">.</span><span class="n">admin_form_fields</span> <span class="o">+</span> <span class="p">[</span>
<span class="sh">"</span><span class="s">checklist</span><span class="sh">"</span><span class="p">,</span>
<span class="sh">"</span><span class="s">is_checklist_required</span><span class="sh">"</span><span class="p">,</span>
<span class="p">]</span>
<span class="nd">@classmethod</span>
<span class="k">def</span> <span class="nf">get_description</span><span class="p">(</span><span class="n">cls</span><span class="p">):</span>
<span class="nf">return </span><span class="p">(</span>
<span class="sh">"</span><span class="s">Members of the chosen User Groups can approve this task with a checklist.</span><span class="sh">"</span>
<span class="p">)</span>
<span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
<span class="n">verbose_name</span> <span class="o">=</span> <span class="sh">"</span><span class="s">Checklist approval task</span><span class="sh">"</span>
<span class="n">verbose_name_plural</span> <span class="o">=</span> <span class="sh">"</span><span class="s">Checklist approval tasks</span><span class="sh">"</span>
</code></pre>
</div>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhrki188v8nwm4fyk58ol.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhrki188v8nwm4fyk58ol.png" alt="Step 1a - Create the Model"></a></p>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb96xndiwoita8j3auh9m.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fb96xndiwoita8j3auh9m.png" alt="Step 1b - Task Model Editing"></a></p>
<p><strong>Before you continue:</strong> Check that when you create a new Task you can now see that there are two options available; 'Group approval task' and 'Checklist approval task'.</p>
<h3>
Step 2 - Revise the Actions to Always Show the Form Modal
</h3>
<ul>
<li>The built-in <code>GroupApprovalTask</code>, when in a Workflow, will give the user two options; 'Approve and Publish' and 'Approve with Comment and Publish', the difference is that the one with the comment will open a form modal when clicked where the user can fill out a comment.</li>
<li>What we want to do for our custom <code>Task</code> is to ensure that the approval step can <strong>only</strong> be completed with a form modal variant, and in this form we will show the checklist.</li>
<li>Each <code>Task</code> has a method <a href="proxy.php?url=https://docs.wagtail.io/en/stable/advanced_topics/custom_tasks.html?highlight=get_actions#customising-behaviour" rel="noopener noreferrer"><code>get_actions</code></a> which will return a list of <code>(action_name, action_verbose_name, action_requires_additional_data_from_modal)</code> tuples.</li>
<li>We will now revise this method to leverage the existing check if the user is in the right group but only return one option with the form modal being required, we will also ensure there is a reject action allowed.</li>
</ul>
<h4>
Code <code>models.py</code>
</h4>
<ul>
<li>Within the <code>CrosscheckApprovalTask</code> built above, create a new method <code>get_actions</code>, this should copy the user check from <a href="proxy.php?url=https://github.com/wagtail/wagtail/blob/main/wagtail/core/models/__init__.py#L3456" rel="noopener noreferrer">the GroupApprovalTask implementation</a> but only return two actions.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="k">class</span> <span class="nc">CrosscheckApprovalTask</span><span class="p">(</span><span class="n">GroupApprovalTask</span><span class="p">):</span>
<span class="c1"># ... checklist etc, from above step
</span>
<span class="k">def</span> <span class="nf">get_actions</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">page</span><span class="p">,</span> <span class="n">user</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Customise the actions returned to have a reject and only one approve.
The approve will have a third value as True which indicates a form is
required.
</span><span class="sh">"""</span>
<span class="k">if</span> <span class="n">self</span><span class="p">.</span><span class="n">groups</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="n">id__in</span><span class="o">=</span><span class="n">user</span><span class="p">.</span><span class="n">groups</span><span class="p">.</span><span class="nf">all</span><span class="p">()).</span><span class="nf">exists</span><span class="p">()</span> <span class="ow">or</span> <span class="n">user</span><span class="p">.</span><span class="n">is_superuser</span><span class="p">:</span>
<span class="n">REJECT</span> <span class="o">=</span> <span class="p">(</span><span class="sh">"</span><span class="s">reject</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Request changes</span><span class="sh">"</span><span class="p">,</span> <span class="bp">True</span><span class="p">)</span>
<span class="n">APPROVE</span> <span class="o">=</span> <span class="p">(</span><span class="sh">"</span><span class="s">approve</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Approve</span><span class="sh">"</span><span class="p">,</span> <span class="bp">True</span><span class="p">)</span>
<span class="k">return</span> <span class="p">[</span><span class="n">REJECT</span><span class="p">,</span> <span class="n">APPROVE</span><span class="p">]</span>
<span class="k">return</span> <span class="p">[]</span>
<span class="c1"># ... @classmethod etc
</span></code></pre>
</div>
<p><strong>Before you continue:</strong> Check that when you put an existing Page into the workflow that contains this new task type, when approving the change it will show only one option 'Approve and Publish' and this should open a form modal. No need to approve just yet though.</p>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3902n6avyawhiwso3tim.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F3902n6avyawhiwso3tim.png" alt="Step 2 - Basic actions form modal"></a></p>
<h3>
Step 3 - Build a Basic Checklist Form
</h3>
<ul>
<li>We now need to create a custom form that leverages the existing form that <code>GroupApprovalTask</code> makes, this form needs to have a <code>MultipleChoiceField</code> where each of the <code>choices</code> is a line in our <code>Task</code> model's <code>checklist</code> field.</li>
<li>We also want to make the form field dynamic based on the <code>Task</code> model's <code>is_checklist_required</code> saved value.</li>
<li>To override the <code>Task</code> form we can add a <a href="proxy.php?url=https://docs.wagtail.io/en/stable/advanced_topics/custom_tasks.html?highlight=get_actions#customising-behaviour" rel="noopener noreferrer"><code>get_form_for_action</code></a> method and when the action is <code>'approve'</code> we can provide a custom Form.</li>
<li>The things we need to answer is what form to extend (we want to ensure we get the comment field still), how do we ensure that the checklist values do not try to be 'saved' to the <code>TaskState</code> (the model that reflects each state for a <code>Task</code> as it is processed).</li>
<li>If we take a look at the implementation of <a href="proxy.php?url=https://github.com/wagtail/wagtail/blob/main/wagtail/core/models/__init__.py#L3319" rel="noopener noreferrer"><code>get_form_for_action</code> on the <code>GroupApprovalTask</code></a> we can see that it returns a <a href="proxy.php?url=https://github.com/wagtail/wagtail/blob/main/wagtail/core/forms.py#L23" rel="noopener noreferrer"><code>TaskStateCommentForm</code></a> which extends a Django <code>Form</code> with one field, <code>comments</code>.</li>
</ul>
<h4>
Code <code>models.py</code>
</h4>
<ul>
<li>To build a dynamic Class in Python, we can declare a new class in a function or we can also use the <a href="proxy.php?url=https://docs.python.org/3/library/functions.html#type" rel="noopener noreferrer"><code>type</code></a> built-in function, passing in three args. A name, base classes tuple and a dict where each key will be used to generate dynamic attributes (fields) and methods (e.g. the clean method).</li>
<li>In this dynamic class we will extend whatever the super's <code>get_form_for_action</code> returns, this way we do not need to think about what this is in the code, but know that it is the <code>TaskStateCommentForm</code> above.</li>
<li>We will also need to add a <a href="proxy.php?url=https://docs.djangoproject.com/en/3.2/ref/forms/api/#django.forms.Form.clean" rel="noopener noreferrer"><code>clean</code> method</a> that will remove any checklist values that are submitted (as we do not want to save these).</li>
<li>We will need to add a field, <code>checklist</code>, which we will pull out to a new class method <code>get_checklist_field</code> which can return a <a href="proxy.php?url=https://docs.djangoproject.com/en/3.2/ref/forms/fields/#multiplechoicefield" rel="noopener noreferrer"><code>forms.MultipleChoiceField</code></a> that has dynamic values for <code>required</code> and the <code>choices</code> based on the <code>Task</code> instance. Note: The default widget used for this field is <code>SelectMultiple</code> which is a bit cluncky, but we will enhance that in the next step.</li>
<li>We will also want to ensure that our <code>checklist</code> field shows before the <code>comment</code> field, for that we can dynamically add a <a href="proxy.php?url=https://docs.djangoproject.com/en/3.2/ref/forms/api/#django.forms.Form.field_order" rel="noopener noreferrer"><code>field_order</code></a> attribute.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="kn">from</span> <span class="n">django</span> <span class="kn">import</span> <span class="n">forms</span>
<span class="kn">from</span> <span class="n">django.db</span> <span class="kn">import</span> <span class="n">models</span>
<span class="kn">from</span> <span class="n">django.utils.translation</span> <span class="kn">import</span> <span class="n">gettext_lazy</span> <span class="k">as</span> <span class="n">_</span>
<span class="c1"># ... other imports
</span>
<span class="k">class</span> <span class="nc">ChecklistApprovalTask</span><span class="p">(</span><span class="n">GroupApprovalTask</span><span class="p">):</span>
<span class="c1"># ... checklist etc, from above step
</span>
<span class="k">def</span> <span class="nf">get_checklist_field</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Prepare a form field that is a list of checklist boxes for each line in the
checklist on this Task instance.
</span><span class="sh">"""</span>
<span class="n">required</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">is_checklist_required</span>
<span class="n">field</span> <span class="o">=</span> <span class="nf">dict</span><span class="p">(</span><span class="n">label</span><span class="o">=</span><span class="nf">_</span><span class="p">(</span><span class="sh">"</span><span class="s">Checklist</span><span class="sh">"</span><span class="p">),</span> <span class="n">required</span><span class="o">=</span><span class="n">required</span><span class="p">)</span>
<span class="n">field</span><span class="p">[</span><span class="sh">"</span><span class="s">choices</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
<span class="p">(</span><span class="n">index</span><span class="p">,</span> <span class="n">label</span><span class="p">)</span> <span class="k">for</span> <span class="n">index</span><span class="p">,</span> <span class="n">label</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">checklist</span><span class="p">.</span><span class="nf">splitlines</span><span class="p">())</span>
<span class="p">]</span>
<span class="k">return</span> <span class="n">forms</span><span class="p">.</span><span class="nc">MultipleChoiceField</span><span class="p">(</span><span class="o">**</span><span class="n">field</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">get_form_for_action</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">action</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
If the action is </span><span class="sh">'</span><span class="s">approve</span><span class="sh">'</span><span class="s">, return a new class (using type) that has access
to the checklist items as a field based on this Task</span><span class="sh">'</span><span class="s">s instance.
</span><span class="sh">"""</span>
<span class="n">form_class</span> <span class="o">=</span> <span class="nf">super</span><span class="p">().</span><span class="nf">get_form_for_action</span><span class="p">(</span><span class="n">action</span><span class="p">)</span>
<span class="k">if</span> <span class="n">action</span> <span class="o">==</span> <span class="sh">"</span><span class="s">approve</span><span class="sh">"</span><span class="p">:</span>
<span class="k">def</span> <span class="nf">clean</span><span class="p">(</span><span class="n">form</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
When this form</span><span class="sh">'</span><span class="s">s clean method is processed (on a POST), ensure we do not pass
the </span><span class="sh">'</span><span class="s">checklist</span><span class="sh">'</span><span class="s"> data any further as no handling of this data is built.
</span><span class="sh">"""</span>
<span class="n">cleaned_data</span> <span class="o">=</span> <span class="nf">super</span><span class="p">(</span><span class="n">form_class</span><span class="p">,</span> <span class="n">form</span><span class="p">).</span><span class="nf">clean</span><span class="p">()</span>
<span class="k">if</span> <span class="sh">"</span><span class="s">checklist</span><span class="sh">"</span> <span class="ow">in</span> <span class="n">cleaned_data</span><span class="p">:</span>
<span class="k">del</span> <span class="n">cleaned_data</span><span class="p">[</span><span class="sh">"</span><span class="s">checklist</span><span class="sh">"</span><span class="p">]</span>
<span class="k">return</span> <span class="n">cleaned_data</span>
<span class="k">return</span> <span class="nf">type</span><span class="p">(</span>
<span class="nf">str</span><span class="p">(</span><span class="sh">"</span><span class="s">ChecklistApprovalTaskForm</span><span class="sh">"</span><span class="p">),</span>
<span class="p">(</span><span class="n">form_class</span><span class="p">,),</span>
<span class="nf">dict</span><span class="p">(</span>
<span class="n">checklist</span><span class="o">=</span><span class="n">self</span><span class="p">.</span><span class="nf">get_checklist_field</span><span class="p">(),</span>
<span class="n">clean</span><span class="o">=</span><span class="n">clean</span><span class="p">,</span>
<span class="n">field_order</span><span class="o">=</span><span class="p">[</span><span class="sh">"</span><span class="s">checklist</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">comment</span><span class="sh">"</span><span class="p">],</span>
<span class="p">),</span>
<span class="p">)</span>
<span class="k">return</span> <span class="n">form_class</span>
<span class="c1"># ... @classmethod etc
</span>
</code></pre>
</div>
<p><strong>Before you continue:</strong> Check that when you click the Approve step on a Page with this Task, you now see a list of checklist items and it is required (or not, based on the data saved on the original Task type).</p>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fubp07ihvacszr2w2pz9u.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fubp07ihvacszr2w2pz9u.png" alt="Step 3 - Basic checklist form"></a></p>
<h3>
Step 4 - Enhance the Checklist Form
</h3>
<ul>
<li>Now, we will modify the <a href="proxy.php?url=https://docs.djangoproject.com/en/3.2/ref/forms/fields/#help-text" rel="noopener noreferrer"><code>help_text</code></a>, <a href="proxy.php?url=https://docs.djangoproject.com/en/3.2/ref/forms/fields/#validators" rel="noopener noreferrer"><code>validators</code></a> and the <a href="proxy.php?url=https://docs.djangoproject.com/en/3.2/ref/forms/fields/#widget" rel="noopener noreferrer"><code>widget</code></a> of our field generated in the method <code>get_checklist_field</code>.</li>
<li>The goals here are to ensure that, when the checklist is required, we show and actually validate against all items being ticked.</li>
<li>I find all of this pretty amazing, how powerful it is to build up blocks of built-in functions and logic into what we want without really writing much code ourselves, just calling the right methods/functions/classes.</li>
</ul>
<h4>
Code <code>models.py</code>
</h4>
<ul>
<li>
<code>help_text</code> needs to be a dynamic value based on the required value we set up in the previous step.</li>
<li>If the checklist is required, we will leverage the built-in <a href="proxy.php?url=https://docs.djangoproject.com/en/3.2/ref/validators/#minlengthvalidator" rel="noopener noreferrer"><code>MinLengthValidator</code></a>, while this is usually used to validate string length it can be used just the same for validating the length of the list of values provided to the field (in our case it will be a list of indices). We will also pass in a custom <code>message</code> kwarg to this validator so it makes sense to the user.</li>
<li>For the <code>widget</code> we will use the built-in <a href="proxy.php?url=https://docs.djangoproject.com/en/3.2/ref/forms/widgets/#django.forms.CheckboxSelectMultiple" rel="noopener noreferrer"><code>CheckboxSelectMultiple</code></a>, but note in the docs that even if we set <code>required</code> on the field the checkbox will not actually put required on the inputs HTML attributes so we need to pass in an extra <code>attrs</code> to the widget to handle this.</li>
<li>Note: the only parts added below are the <code>min_length_validator</code>, <code>help_text</code>, <code>validators</code> and <code>widget</code> lines.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="kn">from</span> <span class="n">django</span> <span class="kn">import</span> <span class="n">forms</span>
<span class="kn">from</span> <span class="n">django.core.validators</span> <span class="kn">import</span> <span class="n">MinLengthValidator</span>
<span class="kn">from</span> <span class="n">django.db</span> <span class="kn">import</span> <span class="n">models</span>
<span class="kn">from</span> <span class="n">django.utils.translation</span> <span class="kn">import</span> <span class="n">gettext_lazy</span> <span class="k">as</span> <span class="n">_</span><span class="p">,</span> <span class="n">ngettext_lazy</span>
<span class="c1"># ... other imports
</span>
<span class="k">class</span> <span class="nc">ChecklistApprovalTask</span><span class="p">(</span><span class="n">GroupApprovalTask</span><span class="p">):</span>
<span class="c1"># ... checklist etc, from above step
</span>
<span class="k">def</span> <span class="nf">get_checklist_field</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Prepare a form field that is a list of checklist boxes for each line in the
checklist on this Task instance.
</span><span class="sh">"""</span>
<span class="n">required</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">is_checklist_required</span>
<span class="n">field</span> <span class="o">=</span> <span class="nf">dict</span><span class="p">(</span><span class="n">label</span><span class="o">=</span><span class="nf">_</span><span class="p">(</span><span class="sh">"</span><span class="s">Checklist</span><span class="sh">"</span><span class="p">),</span> <span class="n">required</span><span class="o">=</span><span class="n">required</span><span class="p">)</span>
<span class="n">field</span><span class="p">[</span><span class="sh">"</span><span class="s">choices</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
<span class="p">(</span><span class="n">index</span><span class="p">,</span> <span class="n">label</span><span class="p">)</span> <span class="k">for</span> <span class="n">index</span><span class="p">,</span> <span class="n">label</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">checklist</span><span class="p">.</span><span class="nf">splitlines</span><span class="p">())</span>
<span class="p">]</span>
<span class="n">min_length_validator</span> <span class="o">=</span> <span class="nc">MinLengthValidator</span><span class="p">(</span>
<span class="nf">len</span><span class="p">(</span><span class="n">field</span><span class="p">[</span><span class="sh">"</span><span class="s">choices</span><span class="sh">"</span><span class="p">]),</span>
<span class="n">message</span><span class="o">=</span><span class="nf">ngettext_lazy</span><span class="p">(</span>
<span class="sh">"</span><span class="s">Only %(show_value)d item has been checked (all %(limit_value)d are required).</span><span class="sh">"</span><span class="p">,</span>
<span class="sh">"</span><span class="s">Only %(show_value)d items have been checked (all %(limit_value)d are required).</span><span class="sh">"</span><span class="p">,</span>
<span class="sh">"</span><span class="s">show_value</span><span class="sh">"</span><span class="p">,</span>
<span class="p">),</span>
<span class="p">)</span>
<span class="n">field</span><span class="p">[</span><span class="sh">"</span><span class="s">help_text</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="p">(</span>
<span class="nf">_</span><span class="p">(</span><span class="sh">"</span><span class="s">Please check all items.</span><span class="sh">"</span><span class="p">)</span> <span class="k">if</span> <span class="n">required</span> <span class="k">else</span> <span class="nf">_</span><span class="p">(</span><span class="sh">"</span><span class="s">Please review all items.</span><span class="sh">"</span><span class="p">)</span>
<span class="p">)</span>
<span class="n">field</span><span class="p">[</span><span class="sh">"</span><span class="s">validators</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span><span class="n">min_length_validator</span><span class="p">]</span> <span class="k">if</span> <span class="n">required</span> <span class="k">else</span> <span class="p">[]</span>
<span class="n">field</span><span class="p">[</span><span class="sh">"</span><span class="s">widget</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="n">forms</span><span class="p">.</span><span class="nc">CheckboxSelectMultiple</span><span class="p">(</span>
<span class="c1"># required attr needed as not rendered by default (even if field required)
</span> <span class="c1"># https://docs.djangoproject.com/en/3.2/ref/forms/widgets/#django.forms.CheckboxSelectMultiple
</span> <span class="n">attrs</span><span class="o">=</span><span class="p">{</span><span class="sh">"</span><span class="s">required</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">required</span><span class="sh">"</span><span class="p">}</span> <span class="k">if</span> <span class="n">required</span> <span class="k">else</span> <span class="p">{}</span>
<span class="p">)</span>
<span class="k">return</span> <span class="n">forms</span><span class="p">.</span><span class="nc">MultipleChoiceField</span><span class="p">(</span><span class="o">**</span><span class="n">field</span><span class="p">)</span>
</code></pre>
</div>
<p><strong>Before you continue:</strong> Check that the Approve modal form now contains actual checkboxes for each checklist item and that validation works as expected.</p>
<h2>
Final Implementation
</h2>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F04v9e6orsuzr9q4qxr8d.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F04v9e6orsuzr9q4qxr8d.png" alt="Step 4 - Final form modal implementation"></a></p>
<ul>
<li>You should now have a fully functional new Task type where your CMS admin users can create for specific workflows that leverage existing user group approval logic along with custom checklists on a per Task instance.</li>
<li>You can view the steps on the <a href="proxy.php?url=https://github.com/lb-/bakerydemo/commits/tutorial/workflow-checklist" rel="noopener noreferrer">tutorial/workflow-checklist</a> branch or the <a href="proxy.php?url=https://github.com/lb-/bakerydemo/blob/tutorial/workflow-checklist/bakerydemo/base/models.py#L29-L150" rel="noopener noreferrer">final code in the models.py file</a>.</li>
<li>I think it is really important to now require all the checklist items by default, but some will insist that this is required no doubt, but checklists work better as reminders and not rigid rules in this context.</li>
<li>Please comment in response if you think something is missing here or have some other useful content relating to Wagtail Workflows that might be helpful.</li>
<li>Thanks to my brother Sam for proofing and to <a href="proxy.php?url=https://unsplash.com/photos/IuLgi9PWETU" rel="noopener noreferrer">Danielle MacInnes</a> for the cover photo.</li>
</ul>
<h2>
Future Improvements Ideas
</h2>
<ul>
<li>Adding a <code>description</code> field to the <code>Task</code> so that users can put content above the checklist that explains part of a process.</li>
<li>Storing the checklist values (or maybe how many were checked), if that is useful for reporting, remember that the Page history will contain which users Approved the step and that may be enough as it is.</li>
<li>Other Form content that applies to an Approval (or some other step like Rejection) and needs to be dynamic, maybe even requiring a comment when the workflow is Rejected.</li>
<li>If you end up building one of these, even as a Github Gist, be sure to put a link to it in the comments.</li>
</ul>
pythonwagtaildjangoprocessHow to create a Zen (Focused) mode for the Wagtail CMS adminLB (Ben Johnston)Sun, 05 Sep 2021 10:30:14 +0000
https://dev.to/lb/how-to-create-a-zen-focused-mode-for-the-wagtail-cms-admin-3ipk
https://dev.to/lb/how-to-create-a-zen-focused-mode-for-the-wagtail-cms-admin-3ipk<p>Hi, I am LB, a full stack developer on the core team for the <a href="proxy.php?url=https://wagtail.io/" rel="noopener noreferrer">Watail CMS</a> and here is a tutorial that will hopefully teach you a bit about Wagtail but also make life better for content editors.</p>
<ul>
<li>
<strong>Goal:</strong> Implement a simple way for users within the Wagtail CMS admin interface to focus on a sub-set of fields, hiding menus/headers and other fields.</li>
<li>
<strong>Why:</strong> Being able to switch 'hats' from content management to content writing is often difficult, providing a different editing mode may allow for focusing.</li>
<li>
<strong>How:</strong> Using the browser's built-in <a href="proxy.php?url=https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API" rel="noopener noreferrer">Fullscreen API</a> to provide a native and accessible experience on most new devices and browsers.</li>
</ul>
<h2>
Overview
</h2>
<ul>
<li>The inspiration for this is the VSCode editor's <a href="proxy.php?url=https://code.visualstudio.com/docs/getstarted/userinterface#_zen-mode" rel="noopener noreferrer">Zen mode</a> that allows you to switch to a fullscreen version of your current editor that hides menus/footers and other UI elements.</li>
<li>Create a custom Edit Handler (Panel) that works the same as <a href="proxy.php?url=https://docs.wagtail.io/en/stable/reference/pages/panels.html#multifieldpanel" rel="noopener noreferrer">MultiFieldPanel</a> which can be used to wrap some fields on a per-model basis (e.g. the main content fields).</li>
<li>Use the <a href="proxy.php?url=https://docs.wagtail.io/en/stable/reference/hooks.html#editor-interface" rel="noopener noreferrer">Wagtail editor interface hooks</a> system to inject custom JS & CSS files that will do the admin frontend work.</li>
</ul>
<h2>
Tutorial
</h2>
<h3>
Step 0 - Getting Started
</h3>
<ul>
<li>This tutorial was written using Wagtail version 2.14, however, you should be able to use this on older versions.</li>
<li>It is assumed you have a working Wagtail project running, otherwise, you will need to go through the <a href="proxy.php?url=https://docs.wagtail.io/en/stable/getting_started/index.html" rel="noopener noreferrer">Wagtail getting started</a> docs.</li>
<li>Read through the MDN <a href="proxy.php?url=https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API/" rel="noopener noreferrer">Fullscreen API</a> docs.</li>
<li>It is worth taking a quick look at the Wagtail source code for the <a href="proxy.php?url=https://github.com/wagtail/wagtail/blob/main/wagtail/admin/edit_handlers.py#L415" rel="noopener noreferrer"><code>MultiFieldPanel</code> implementation</a> (code below), note that it has a custom template and classes and that is pretty much it.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="k">class</span> <span class="nc">MultiFieldPanel</span><span class="p">(</span><span class="n">BaseCompositeEditHandler</span><span class="p">):</span>
<span class="n">template</span> <span class="o">=</span> <span class="sh">"</span><span class="s">wagtailadmin/edit_handlers/multi_field_panel.html</span><span class="sh">"</span>
<span class="k">def</span> <span class="nf">classes</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="n">classes</span> <span class="o">=</span> <span class="nf">super</span><span class="p">().</span><span class="nf">classes</span><span class="p">()</span>
<span class="n">classes</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="sh">"</span><span class="s">multi-field</span><span class="sh">"</span><span class="p">)</span>
<span class="k">return</span> <span class="n">classes</span>
</code></pre>
</div>
<h3>
Step 1 - Build the custom Panel
</h3>
<ul>
<li>Create a new file within your app called <code>edit_handers.py</code> and then a custom class that extends <code>MultiFieldPanel</code>.</li>
<li>We will create a new class <code>ZenModeMultiFieldPanel</code> and override one attribite <code>template</code> and one method <code>classes</code>.</li>
<li>For the template, we will need to create a new file <code>templates/edit_handlers/zen_mode_multi_field_panel.html</code> that will include the existing <code>"wagtailadmin/edit_handlers/multi_field_panel.html"</code> template.</li>
<li>In this new template we will add two buttons, an activate and an exit button, by default the exit button will have a class <code>'hidden'</code>.</li>
<li>In the <code>classes</code> method, we will append one class <code>'zen-mode-panel'</code> to make it easier to target the styles to these Panels.</li>
<li>The full code for this step is below.</li>
<li>Once done, find an existing <code>Page</code> model and wrap one or more fields within this <code>ZenModeMultiFieldPanel</code>, for example:
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code> <span class="nc">ZenModeMultiFieldPanel</span><span class="p">(</span>
<span class="p">[</span><span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">introduction</span><span class="sh">"</span><span class="p">,</span> <span class="n">classname</span><span class="o">=</span><span class="sh">"</span><span class="s">full</span><span class="sh">"</span><span class="p">),</span> <span class="nc">StreamFieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">body</span><span class="sh">"</span><span class="p">)],</span>
<span class="n">heading</span><span class="o">=</span><span class="sh">"</span><span class="s">Content</span><span class="sh">"</span><span class="p">,</span>
<span class="p">),</span>
</code></pre>
</div>
<p><strong>edit_handlers.py</strong><br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="kn">from</span> <span class="n">wagtail.admin.edit_handlers</span> <span class="kn">import</span> <span class="n">MultiFieldPanel</span>
<span class="k">class</span> <span class="nc">ZenModeMultiFieldPanel</span><span class="p">(</span><span class="n">MultiFieldPanel</span><span class="p">):</span>
<span class="n">template</span> <span class="o">=</span> <span class="sh">"</span><span class="s">myapp/edit_handlers/zen_mode_multi_field_panel.html</span><span class="sh">"</span>
<span class="k">def</span> <span class="nf">classes</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="n">classes</span> <span class="o">=</span> <span class="nf">super</span><span class="p">().</span><span class="nf">classes</span><span class="p">()</span>
<span class="n">classes</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="sh">"</span><span class="s">zen-mode-panel</span><span class="sh">"</span><span class="p">)</span>
<span class="k">return</span> <span class="n">classes</span>
</code></pre>
</div>
<p><strong>templates/edit_handlers/zen_mode_multi_field_panel.html</strong><br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight html"><code>{% load wagtailadmin_tags %}
<span class="nt"><button</span>
<span class="na">class=</span><span class="s">"zen-mode activate button button-small button-secondary button--icon"</span>
<span class="nt">></span>
Zen {% icon name="collapse-up" wrapped=1 %}
<span class="nt"></button></span>
<span class="nt"><button</span>
<span class="na">class=</span><span class="s">"zen-mode exit button button-small button-secondary button--icon hidden"</span>
<span class="nt">></span>
Exit {% icon name="collapse-down" wrapped=1 %}
<span class="nt"></button></span>
{% include "wagtailadmin/edit_handlers/multi_field_panel.html" %}
</code></pre>
</div>
<h4>
Before you continue
</h4>
<ul>
<li>Once complete, you should be able to see this new <code>Panel</code> in the browser and see the button + classes on the panel container.</li>
<li>Note that the button may not be visible as it is 'behind' the header, that is fine for now, just check it is in the DOM.</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0ilrsfnc3byem81kd0ed.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0ilrsfnc3byem81kd0ed.png" alt="Step 1 - Build the custom Panel"></a></p>
<h3>
Step 2 - Add initial CSS & JS injection via Hooks
</h3>
<ul>
<li>Firstly, ensure that your <code>static</code> files are set up in Django, if this is not already in place you will need to read the docs about <a href="proxy.php?url=https://docs.djangoproject.com/en/3.2/howto/static-files/" rel="noopener noreferrer">Managing static files</a>.</li>
<li>Create a <code>wagtail_hooks.py</code> file if you do not already have one within your main app.</li>
<li>Create a CSS file in your <code>static</code> folder <code>static/css/zen-mode-multi-field-panel.css</code> and put a basic CSS selector to position the buttons and to test the CSS is being imported.</li>
<li>In your <code>wagtail_hooks.py</code> file, use the <a href="proxy.php?url=https://docs.wagtail.io/en/stable/reference/hooks.html#insert-global-admin-css" rel="noopener noreferrer"><code>insert_global_admin_css</code></a> hook to load the static CSS file.</li>
<li>Create a JS file in your <code>static</code> folder <code>static/js/zen-mode-multi-field-panel.js</code> and put a basic JS initiation file with a selector logging out each <code>Zen MultiFieldPanel</code> based on the classes set in the <code>.zen-mode-panel</code> class.</li>
<li>In your <code>wagtail_hooks.py</code> file, use the <a href="proxy.php?url=https://docs.wagtail.io/en/stable/reference/hooks.html#insert-editor-js" rel="noopener noreferrer"><code>insert_global_admin_js</code></a> hook to load the static JS file.</li>
<li>Note: You can use the page editor scoped hooks <code>insert_editor_css</code> & <code>insert_editor_js</code>, however this means that this custom Panel will not work as desired when using it in other places (such as Snippet or ModelAdmin editing) throughout the admin.</li>
<li>Reminder: When adding static files, you will need to restart your server for these files to be loaded.</li>
</ul>
<p><strong>static/css/zen-mode-multi-field-panel.css</strong><br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight css"><code><span class="nc">.object.multi-field.zen-mode-panel</span> <span class="nc">.button.zen-mode</span> <span class="p">{</span>
<span class="nl">position</span><span class="p">:</span> <span class="nb">absolute</span><span class="p">;</span>
<span class="nl">right</span><span class="p">:</span> <span class="m">4rem</span><span class="p">;</span>
<span class="nl">z-index</span><span class="p">:</span> <span class="m">100</span><span class="p">;</span>
<span class="nl">top</span><span class="p">:</span> <span class="m">0.5rem</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.object.multi-field.zen-mode-panel</span> <span class="nc">.button.zen-mode.hidden</span> <span class="p">{</span>
<span class="nl">display</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="p">}</span>
</code></pre>
</div>
<p><strong>static/js/zen-mode-multi-field-panel.js</strong><br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="nb">window</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">DOMContentLoaded</span><span class="dl">"</span><span class="p">,</span> <span class="p">()</span> <span class="o">=></span> <span class="p">{</span>
<span class="nb">document</span><span class="p">.</span><span class="nf">querySelectorAll</span><span class="p">(</span><span class="dl">"</span><span class="s2">.zen-mode-panel</span><span class="dl">"</span><span class="p">).</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">panel</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">"</span><span class="s2">found panel!</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="nx">panel</span> <span class="p">});</span>
<span class="p">});</span>
<span class="p">});</span>
</code></pre>
</div>
<p><strong>myapp/wagtail_hooks.py</strong><br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="kn">from</span> <span class="n">django.utils.html</span> <span class="kn">import</span> <span class="n">format_html</span>
<span class="kn">from</span> <span class="n">django.templatetags.static</span> <span class="kn">import</span> <span class="n">static</span>
<span class="kn">from</span> <span class="n">wagtail.core</span> <span class="kn">import</span> <span class="n">hooks</span>
<span class="nd">@hooks.register</span><span class="p">(</span><span class="sh">"</span><span class="s">insert_global_admin_css</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">global_admin_zen_mode_multi_field_panel_css</span><span class="p">():</span>
<span class="k">return</span> <span class="nf">format_html</span><span class="p">(</span>
<span class="sh">'</span><span class="s"><link rel=</span><span class="sh">"</span><span class="s">stylesheet</span><span class="sh">"</span><span class="s"> href=</span><span class="sh">"</span><span class="s">{}</span><span class="sh">"</span><span class="s">></span><span class="sh">'</span><span class="p">,</span>
<span class="nf">static</span><span class="p">(</span><span class="sh">"</span><span class="s">css/zen-mode-multi-field-panel.css</span><span class="sh">"</span><span class="p">),</span>
<span class="p">)</span>
<span class="nd">@hooks.register</span><span class="p">(</span><span class="sh">"</span><span class="s">insert_global_admin_js</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">global_adminzen_mode_multi_field_panel__js</span><span class="p">():</span>
<span class="k">return</span> <span class="nf">format_html</span><span class="p">(</span>
<span class="sh">'</span><span class="s"><script src=</span><span class="sh">"</span><span class="s">{}</span><span class="sh">"</span><span class="s">></script></span><span class="sh">'</span><span class="p">,</span>
<span class="nf">static</span><span class="p">(</span><span class="sh">"</span><span class="s">js/zen-mode-multi-field-panel.js</span><span class="sh">"</span><span class="p">),</span>
<span class="p">)</span>
</code></pre>
</div>
<h4>
Before you continue
</h4>
<ul>
<li>Once complete, you should be able to reload your development server and load up the Page editor and see the following;</li>
<li>1. The Zen button should be visible and the Exit button should not.</li>
<li>2. You should see an output in your browser console with logging of the panel.</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq0ngq4wqwfvp702excqa.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fq0ngq4wqwfvp702excqa.png" alt="Step 2 - Add initial CSS & JS injection via Hooks"></a></p>
<h3>
Step 3 - Get JS fully functional
</h3>
<ul>
<li>Now we will work through the JS interaction, our goal is to use the <a href="proxy.php?url=https://developer.mozilla.org/en-US/docs/Web/API/Fullscreen_API/" rel="noopener noreferrer">fullscreen API</a> to make the 'Zen' button trigger the fullscreen mode on the panel's container div and then provide TWO ways to exit, the built-in 'esc' press handling and the pressing of the button 'exit'.</li>
<li>There will still be further CSS refinements to do, but the goal for this step is to get the button functionality working.</li>
<li>Firstly we use <code>document.querySelectorAll(".zen-mode-panel").forEach(...</code> to find ALL of the zen-mode-panel (this means we can support multiple of these throughout the editor if needed).</li>
<li>For each <code>panel</code> we will find that panel's activate and exit buttons and set them to a variable <code>activateButton</code> and <code>exitButton</code>.</li>
<li>Then we check the browser can actually support fullscreen mode by checking that a function <code>requestFullscreen</code> exists on the <code>panel</code> element, if it does not we add the class <code>'hidden'</code> to the <code>activateButton</code> and return so that the button is hidden and no fulscreen activation actions can take place.</li>
<li>After this, we create a listener on the <code>activateButton</code>'s <code>onClick</code> event that will <code>requestFullscreen</code> on the <code>panel</code>, it also needs to call <code>event.preventDefault()</code> so that the form does not submit.</li>
<li>Create a listener on the <code>exitButton</code>'s <code>onClick</code> event that will <code>exitFullscreen</code> on the <code>document</code> (important, not the panel). It also needs to call <code>event.preventDefault()</code> so that the form does not submit.</li>
<li>The last part of the JS is to handle when the fullscreen mode triggers (and exits) to update some classes on our elements, we want to add <code>fullscreen-active</code> to the panel container and also switch the visibility of the activate/exit button.</li>
<li>Note: The reason we are adding an event handler <code>onfullscreenchange</code> on the <code>panel</code> is that it simplifies the overall logic, avoids content from getting out of sync and means the built-in browser handling of <code>esc</code> press will work without issues.</li>
</ul>
<p><strong>static/js/zen-mode-multi-field-panel.js</strong><br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="nb">window</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">DOMContentLoaded</span><span class="dl">"</span><span class="p">,</span> <span class="p">()</span> <span class="o">=></span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">FULLSCREEN_ACTIVE</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">fullscreen-active</span><span class="dl">"</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">HIDDEN</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">hidden</span><span class="dl">"</span><span class="p">;</span>
<span class="nb">document</span><span class="p">.</span><span class="nf">querySelectorAll</span><span class="p">(</span><span class="dl">"</span><span class="s2">.zen-mode-panel</span><span class="dl">"</span><span class="p">).</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">panel</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">activateButton</span> <span class="o">=</span> <span class="nx">panel</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">.zen-mode.activate</span><span class="dl">"</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">exitButton</span> <span class="o">=</span> <span class="nx">panel</span><span class="p">.</span><span class="nf">querySelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">.zen-mode.exit</span><span class="dl">"</span><span class="p">);</span>
<span class="c1">// ---- Hide button & return early if fullscreen is not supported ---- //</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">panel</span><span class="p">.</span><span class="nx">requestFullscreen</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">activateButton</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nf">toggle</span><span class="p">(</span><span class="nx">HIDDEN</span><span class="p">);</span>
<span class="p">}</span>
<span class="c1">// ---- Add button event listeners ---- //</span>
<span class="nx">activateButton</span><span class="p">.</span><span class="nx">onclick</span> <span class="o">=</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="nx">event</span><span class="p">.</span><span class="nf">preventDefault</span><span class="p">();</span> <span class="c1">// ensure the button does not submit the form</span>
<span class="nx">panel</span><span class="p">.</span><span class="nf">requestFullscreen</span><span class="p">();</span>
<span class="p">};</span>
<span class="nx">exitButton</span><span class="p">.</span><span class="nx">onclick</span> <span class="o">=</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="nx">event</span><span class="p">.</span><span class="nf">preventDefault</span><span class="p">();</span> <span class="c1">// ensure the button does not submit the form</span>
<span class="nb">document</span><span class="p">.</span><span class="nf">exitFullscreen</span><span class="p">();</span>
<span class="p">};</span>
<span class="c1">// ---- Add fullscreen event listener ---- //</span>
<span class="nx">panel</span><span class="p">.</span><span class="nx">onfullscreenchange</span> <span class="o">=</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="nx">panel</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nf">toggle</span><span class="p">(</span><span class="nx">FULLSCREEN_ACTIVE</span><span class="p">);</span>
<span class="nx">activateButton</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nf">toggle</span><span class="p">(</span><span class="nx">HIDDEN</span><span class="p">);</span>
<span class="nx">exitButton</span><span class="p">.</span><span class="nx">classList</span><span class="p">.</span><span class="nf">toggle</span><span class="p">(</span><span class="nx">HIDDEN</span><span class="p">);</span>
<span class="p">};</span>
<span class="p">});</span>
<span class="p">});</span>
</code></pre>
</div>
<h4>
Before you continue
</h4>
<ul>
<li>Once complete, you should be able to reload your development server and load up the Page editor and see the following;</li>
<li>1. The Zen button should be visible and when pressed, the browser should show the panel content only in fullscreen.</li>
<li>2. You should be able to press the 'exit' button (which becomes visible) and not see the 'zen' button when in fullscreen.</li>
<li>3. When 'exit' is pressed (or 'esc' on the keyboard) the buttons should switch back and the fullscreen mode should cancel.</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0po2k1qh26obyuffb9g3.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F0po2k1qh26obyuffb9g3.png" alt="Step 3 - Get JS fully functional"></a></p>
<h3>
Step 4 - Refine CSS for various scenarios
</h3>
<ul>
<li>Finally, our goal is to refine our CSS, this is not a perfect step but will be enough to get the UI working.</li>
<li>You will have noticed that the fullscreen mode has a black background, we can fix this with the dynamic class that will get added to the container when in fullscreen <code>fullscreen-active</code> and a <code>background-color</code>.</li>
<li>The last part is some tweaks to how the fields show and how scrolling is handled for longer field content (e.g. long <code>StreamFields</code>). We set a <code>max-width</code> none so content goes to full width, a <code>max-height</code> and <code>overflow-scroll</code> so that content scrolls and then some padding.</li>
</ul>
<p><strong>static/css/zen-mode-multi-field-panel.css</strong><br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight css"><code><span class="nc">.object.multi-field.zen-mode-panel</span> <span class="nc">.button.zen-mode</span> <span class="p">{</span>
<span class="nl">position</span><span class="p">:</span> <span class="nb">absolute</span><span class="p">;</span>
<span class="nl">right</span><span class="p">:</span> <span class="m">4rem</span><span class="p">;</span>
<span class="nl">z-index</span><span class="p">:</span> <span class="m">100</span><span class="p">;</span>
<span class="nl">top</span><span class="p">:</span> <span class="m">0.5rem</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.object.multi-field.zen-mode-panel</span> <span class="nc">.button.zen-mode.hidden</span> <span class="p">{</span>
<span class="nl">display</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.object.multi-field.zen-mode-panel.fullscreen-active</span> <span class="p">{</span>
<span class="nl">background-color</span><span class="p">:</span> <span class="no">white</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.object.multi-field.zen-mode-panel.fullscreen-active</span> <span class="nt">fieldset</span> <span class="p">{</span>
<span class="c">/* ensure that scrolling within content still works */</span>
<span class="nl">max-width</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="nl">max-height</span><span class="p">:</span> <span class="n">calc</span><span class="p">(</span><span class="m">100vh</span> <span class="n">-</span> <span class="m">6rem</span><span class="p">);</span>
<span class="nl">overflow</span><span class="p">:</span> <span class="nb">scroll</span><span class="p">;</span>
<span class="nl">padding-right</span><span class="p">:</span> <span class="m">1rem</span><span class="p">;</span>
<span class="nl">padding-bottom</span><span class="p">:</span> <span class="m">2rem</span><span class="p">;</span>
<span class="p">}</span>
</code></pre>
</div>
<h2>
Final Implementation
</h2>
<ul>
<li>Once complete, you should be able to reload your development server and when in fullscreen mode see a white background and be able to scroll nicely when field content is too long for the screen.</li>
<li>You can view the final code on my <a href="proxy.php?url=https://github.com/lb-/bakerydemo/commits/tutorial/zen-mode" rel="noopener noreferrer">lb-/bakerydemo tutorial</a> branch.</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhvkpo2trbrf147be2omr.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fhvkpo2trbrf147be2omr.png" alt="Step 4 - final"></a></p>
<p><iframe width="710" height="399" src="proxy.php?url=https://www.youtube.com/embed/o41zXkD68Pw">
</iframe>
</p>
<h2>
Future Improvements & Links
</h2>
<ul>
<li>It would be great to have a global keyboard shortcut, similar to the VSCode Zen mode, however, we would need to consider the scenarios where more than one <code>ZenModeMultiFieldPanel</code> is available.</li>
<li>Browser Compatibility could be lacking, this will definitely not support IE11 and there could still be some issues on mobile browsers.</li>
<li>Dark mode, while I do think it is not something useful everywhere, in this Zen mode it might be something helpful.</li>
<li>
<a href="proxy.php?url=https://github.com/rafgraph/fscreen" rel="noopener noreferrer">fscreen</a> provides an API that will work better across multiple browsers, it would be good to use that in the future.</li>
</ul>
wagtailpythondjangoHow to build an interactive guide for users in the Wagtail CMS adminLB (Ben Johnston)Thu, 19 Aug 2021 11:55:12 +0000
https://dev.to/lb/how-to-build-an-interactive-guide-for-users-in-the-wagtail-cms-admin-2dcp
https://dev.to/lb/how-to-build-an-interactive-guide-for-users-in-the-wagtail-cms-admin-2dcp<p><strong>Goal:</strong> Create a simple way for contextual guides to be shown to users while using Wagtail.</p>
<p><strong>Why:</strong> Wagtail's UI is quite intuitive, however, when using anything for the first time it is great to have a bit of help.</p>
<p><strong>How:</strong> We want to provide a way for these guides to be maintained by the admin users (avoiding hard-coded content), they should be simple to create and be shown on specific pages when available.</p>
<h2>
Implementation Overview
</h2>
<ul>
<li>Each <code>guide</code> will be able to be mapped to a page within the admin.</li>
<li>Each <code>guide</code> will be able to have one or more steps with basic text content and the option to align a step with a UI element.</li>
<li>If a guide is available for the current page it will be highlighted in the menu. If no guide is available for the current page the menu will simply load a listing of all guides.</li>
<li>
<a href="proxy.php?url=https://shepherdjs.dev/" rel="noopener noreferrer">Shepherd.js</a> will be used to present the UI steps in an interactive way, this is a great JS library that allows a series of 'steps' to be declared that takes the user through a tour as a series of popovers, some steps can be aligned to an element in the UI and that element will be highlighted.</li>
<li>Wagtail <a href="proxy.php?url=https://docs.wagtail.io/en/stable/reference/contrib/modeladmin/index.html" rel="noopener noreferrer"><code>modelAdmin</code></a> and <a href="proxy.php?url=https://docs.wagtail.io/en/stable/reference/hooks.html" rel="noopener noreferrer"><code>hooks</code></a> will be used to add the customisation.</li>
<li>We can leverage content from the <a href="proxy.php?url=https://docs.wagtail.io/en/stable/editor_manual/index.html" rel="noopener noreferrer">Editor's guide to Wagtail</a> for some of the initial guides.</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm0nkqqth14ckoz86lr86.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fm0nkqqth14ckoz86lr86.png" alt="Shepherd JS Demo page"></a></p>
<h3>
Versions
</h3>
<ul>
<li>Django 3.2</li>
<li>Wagtail 2.14</li>
<li>Shepherd.js 8.3.1</li>
</ul>
<h2>
Tutorial
</h2>
<h3>
0. Before you start
</h3>
<ul>
<li>It is assumed that you will have a Wagtail application running, if not you can use the <a href="proxy.php?url=https://github.com/wagtail/bakerydemo" rel="noopener noreferrer">Wagtail Bakery Demo</a> as your starting point.</li>
<li>It is assumed you will have a basic knowledge of Django and Wagtail and are comfortable with creating Django Models and Python Classes.</li>
<li>It is assumed you have a basic knowledge of Javascript and CSS, you can copy & paste the code but it is good to understand what is happening.</li>
</ul>
<h3>
1. Create the guide app
</h3>
<ul>
<li>Use the Django <a href="proxy.php?url=https://docs.djangoproject.com/en/3.2/ref/django-admin/#startapp" rel="noopener noreferrer"><code>startapp</code></a> command to create a new app <code>'guide'</code> which will contain all the new models and code for this feature.</li>
<li>Run <code>django-admin startapp guide</code>
</li>
<li>Update the settings <code>INSTALLED_APPS</code> with the new <code>guide</code> app created</li>
<li>Run the initial migration <code>./manage.py makemigrations guide</code>
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="n">INSTALLED_APPS</span> <span class="o">=</span> <span class="p">[</span>
<span class="c1"># ...
</span> <span class="sh">'</span><span class="s">guide</span><span class="sh">'</span><span class="p">,</span>
<span class="c1"># ... wagtail & django items
</span><span class="p">]</span>
</code></pre>
</div>
<p><strong>Cross-check (before you continue)</strong></p>
<ul>
<li>You should have a new app folder <code>guide</code> with models, views, etc.</li>
<li>You should be able to run the app without errors.</li>
</ul>
<h3>
2. Create the model
</h3>
<ul>
<li>We will create two new models; <code>Guide</code> and <code>GuideStep</code>.</li>
<li>Where <code>Guide</code> contains a title (for searching), a URL path (to determine what admin UI page it should be shown on) and links to one or more steps. We want to provide the user with a way to order the steps, even re-order them later.</li>
<li>In the <code>Guide</code> we are using the <code>edit_handler</code> to build up a tabbed UI so that some fields will be separate.</li>
<li>Where <code>GuideStep</code> contains a title, text and an optional element selector. The data needed is based on the options that can be passed to the <a href="proxy.php?url=https://shepherdjs.dev/docs/Step.html" rel="noopener noreferrer">Shepherd.js <code>step</code>s</a>.</li>
<li>This code is based on the <a href="proxy.php?url=https://docs.wagtail.io/en/stable/reference/pages/panels.html#inline-panels" rel="noopener noreferrer">Inline Panels and Model Clusters</a> instructions in the Wagtail docs.</li>
<li>You may need to add <code>'modelcluster'</code> to your <code>INSTALLED_APPS</code> if you are having troubles using this when defining your model.</li>
<li>After creating the models, remember to run migrations & migrate <code>/manage.py makemigrations</code> & <code>/manage.py migrate</code>.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># guide/models.py
</span><span class="kn">from</span> <span class="n">django.db</span> <span class="kn">import</span> <span class="n">models</span>
<span class="kn">from</span> <span class="n">modelcluster.fields</span> <span class="kn">import</span> <span class="n">ParentalKey</span>
<span class="kn">from</span> <span class="n">modelcluster.models</span> <span class="kn">import</span> <span class="n">ClusterableModel</span>
<span class="kn">from</span> <span class="n">wagtail.admin.edit_handlers</span> <span class="kn">import</span> <span class="p">(</span>
<span class="n">FieldPanel</span><span class="p">,</span>
<span class="n">InlinePanel</span><span class="p">,</span>
<span class="n">ObjectList</span><span class="p">,</span>
<span class="n">TabbedInterface</span><span class="p">,</span>
<span class="p">)</span>
<span class="kn">from</span> <span class="n">wagtail.core.models</span> <span class="kn">import</span> <span class="n">Orderable</span>
<span class="k">class</span> <span class="nc">GuideStep</span><span class="p">(</span><span class="n">models</span><span class="p">.</span><span class="n">Model</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Each step is a model to represent the step used by
https://shepherdjs.dev/docs/Step.html
This is an abstract model as `GuideRelatedStep` will be used for the actual model with a relation
</span><span class="sh">"""</span>
<span class="n">title</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">)</span>
<span class="n">text</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">)</span>
<span class="n">element</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">blank</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="n">panels</span> <span class="o">=</span> <span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">text</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">element</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>
<span class="k">class</span> <span class="nc">Meta</span><span class="p">:</span>
<span class="n">abstract</span> <span class="o">=</span> <span class="bp">True</span>
<span class="k">class</span> <span class="nc">GuideRelatedStep</span><span class="p">(</span><span class="n">Orderable</span><span class="p">,</span> <span class="n">GuideStep</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Creates an orderable (user can re-order in the admin) and related </span><span class="sh">'</span><span class="s">step</span><span class="sh">'</span><span class="s">
Will be a many to one relation against `Guide`
</span><span class="sh">"""</span>
<span class="n">guide</span> <span class="o">=</span> <span class="nc">ParentalKey</span><span class="p">(</span><span class="sh">"</span><span class="s">guide.Guide</span><span class="sh">"</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">CASCADE</span><span class="p">,</span> <span class="n">related_name</span><span class="o">=</span><span class="sh">"</span><span class="s">steps</span><span class="sh">"</span><span class="p">)</span>
<span class="k">class</span> <span class="nc">Guide</span><span class="p">(</span><span class="n">ClusterableModel</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
`ClusterableModel` used to ensure that this model can have orderable relations
using the modelcluster library (similar to ForeignKey).
edit_handler
</span><span class="sh">"""</span>
<span class="n">title</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">)</span>
<span class="c1"># steps - see GuideRelatedStep
</span> <span class="n">url_path</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">blank</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="n">content_panels</span> <span class="o">=</span> <span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">InlinePanel</span><span class="p">(</span><span class="sh">"</span><span class="s">steps</span><span class="sh">"</span><span class="p">,</span> <span class="n">label</span><span class="o">=</span><span class="sh">"</span><span class="s">Steps</span><span class="sh">"</span><span class="p">,</span> <span class="n">min_num</span><span class="o">=</span><span class="mi">1</span><span class="p">),</span>
<span class="p">]</span>
<span class="n">settings_panels</span> <span class="o">=</span> <span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">url_path</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>
<span class="n">edit_handler</span> <span class="o">=</span> <span class="nc">TabbedInterface</span><span class="p">(</span>
<span class="p">[</span>
<span class="nc">ObjectList</span><span class="p">(</span><span class="n">content_panels</span><span class="p">,</span> <span class="n">heading</span><span class="o">=</span><span class="sh">"</span><span class="s">Content</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">ObjectList</span><span class="p">(</span><span class="n">settings_panels</span><span class="p">,</span> <span class="n">heading</span><span class="o">=</span><span class="sh">"</span><span class="s">Settings</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>
<span class="p">)</span>
</code></pre>
</div>
<p><strong>Cross-check (before you continue)</strong></p>
<ul>
<li>You should have a new file <code>guide/migrations/001_initial.py</code> with your migration.</li>
<li>You should be able to run the app without errors.</li>
</ul>
<h3>
3. Add the hooks for the <code>modelAdmin</code>
</h3>
<ul>
<li>Using the <code>modelAdmin</code> system we will create a basic admin module for our <code>Guide</code> model, this code is based on the <a href="proxy.php?url=https://docs.wagtail.io/en/stable/reference/contrib/modeladmin/index.html#a-simple-example" rel="noopener noreferrer">modelAdmin example in the docs</a>.</li>
<li>Remember to add <code>'wagtail.contrib.modeladmin'</code> to your <code>INSTALLED_APPS</code>.</li>
<li>Using <code>modelAdmin</code> will set up a new menu item in the sidebar by adding the code below to a new file <code>wagtail_hooks.py</code>.</li>
<li>Note that we have turned ON <code>inspect_view_enabled</code>, this is so that a read-only view of each guide is available and it also ensures that non-editors of this model can be given access to this data, these permissions are checked for showing the menu item also.</li>
<li>Remember to give all users permission to 'inspect' Guides (otherwise the menu will not show).</li>
<li>It would be good to now add at least one Guide with the following values.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight plaintext"><code>- Title: Dashboard
- URL Path: /admin/ **(on the settings tab*)*
- Step 1:
- Title: Dashboard
- Text: Clicking the logo returns you to your Dashboard
- Element: a.logo
- Step 2:
- Title: Search
- Text: Search through to find any Pages, Documents, or Images
- Element: .nav-search > div
- Step 3:
- Title: Explorer Menu (Pages)
- Text: Click the Pages button in the sidebar to open the explorer. This allows you to navigate through the sections of the site.
- Element: .menu-item[data-explorer-menu-item]
- Step 4:
- Title: Done
- Text: That's it for now, keep an eye out for the Help menu item on other pages.
- Element: (leave blank)
</code></pre>
</div>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># guide/wagtail_hooks.py
</span><span class="kn">from</span> <span class="n">wagtail.contrib.modeladmin.options</span> <span class="kn">import</span> <span class="n">ModelAdmin</span><span class="p">,</span> <span class="n">modeladmin_register</span>
<span class="kn">from</span> <span class="n">.models</span> <span class="kn">import</span> <span class="n">Guide</span>
<span class="k">class</span> <span class="nc">GuideAdmin</span><span class="p">(</span><span class="n">ModelAdmin</span><span class="p">):</span>
<span class="n">menu_label</span> <span class="o">=</span> <span class="sh">"</span><span class="s">Guide</span><span class="sh">"</span>
<span class="n">model</span> <span class="o">=</span> <span class="n">Guide</span>
<span class="n">menu_icon</span> <span class="o">=</span> <span class="sh">"</span><span class="s">help</span><span class="sh">"</span>
<span class="n">menu_order</span> <span class="o">=</span> <span class="mi">8000</span>
<span class="n">list_display</span> <span class="o">=</span> <span class="p">(</span><span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">url_path</span><span class="sh">"</span><span class="p">)</span>
<span class="n">search_fields</span> <span class="o">=</span> <span class="p">(</span><span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">url_path</span><span class="sh">"</span><span class="p">)</span>
<span class="n">inspect_view_enabled</span> <span class="o">=</span> <span class="bp">True</span>
<span class="nf">modeladmin_register</span><span class="p">(</span><span class="n">GuideAdmin</span><span class="p">)</span>
</code></pre>
</div>
<p><strong>Cross-check (before you continue)</strong></p>
<ul>
<li>You should now see a menu item 'Guide' in the left sidebar within Wagtail admin.</li>
<li>You should be able to log in as a non-admin user and still see this sidebar menu item.</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2bv7p4i1kcr7xw45uzoz.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F2bv7p4i1kcr7xw45uzoz.png" alt="Editing Guide example"></a></p>
<h3>
4. Customise the <code>Guide</code> menu item
</h3>
<ul>
<li>Our goal now is to create a custom <a href="proxy.php?url=https://docs.wagtail.io/en/stable/reference/contrib/modeladmin/menu_item.html#customising-the-menu-item" rel="noopener noreferrer"><code>MenuItem</code></a>, this is a Wagtail class that is used to generate the content for each sidebar menu item.</li>
<li>Instead of extending the class <code>from wagtail.admin.menu import MenuItem</code> we will be using the class <code>from wagtail.contrib.modeladmin.menus import ModelAdminMenuItem</code>. This is because the <code>ModelAdminMenuItem</code> contains some specific <code>ModelAdmin</code> logic we want to keep.</li>
<li>Each <code>MenuItem</code> has a method <code>get_context</code> which provides the template context to the <a href="proxy.php?url=https://github.com/wagtail/wagtail/blob/main/wagtail/admin/templates/wagtailadmin/shared/menu_item.html" rel="noopener noreferrer"><code>menu_item.html</code></a> template.</li>
<li>This template accepts <code>attr_string</code> and <code>classnames</code> which can be leveraged to inject content.</li>
</ul>
<h4>
4a. Add a method to the <code>Guide</code> model
</h4>
<ul>
<li>This method <code>get_data_for_request</code> will allow us to find the first <code>Guide</code> instance where the URL path of the request aligns with the <code>url_path</code> in the guide.</li>
<li>For example - if a Guide is created with the URL path '/admin/images/' then we want to return data about that when we are on that page in the admin. If a Guide is created with the path '/admin/images/#/' then we want the guide to be found whenever is editing any image (note the use of the hash).</li>
<li>
<code>path_to_match = re.sub('[\d]+', '#', request.path)</code> will take the current request path (e.g. <code>/admin/images/53/</code>) and convert it to one where any numbers are replaced with a hash (e.g. <code>/admin/images/#/</code>), this is a simple way to allow fuzzy URL matching.</li>
<li>The data structure returned is intentionally creating a JSON string so it is easier to pass into our model as a data-attribute.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># guide/models.py
</span>
<span class="k">class</span> <span class="nc">Guide</span><span class="p">(</span><span class="n">ClusterableModel</span><span class="p">):</span>
<span class="c1">#...
</span>
<span class="nd">@classmethod</span>
<span class="k">def</span> <span class="nf">get_data_for_request</span><span class="p">(</span><span class="n">cls</span><span class="p">,</span> <span class="n">request</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Returns a dict with data to be sent to the client (for the shepherd.js library)
</span><span class="sh">"""</span>
<span class="n">path_to_match</span> <span class="o">=</span> <span class="n">re</span><span class="p">.</span><span class="nf">sub</span><span class="p">(</span><span class="sh">"</span><span class="s">[\d]+</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">#</span><span class="sh">"</span><span class="p">,</span> <span class="n">request</span><span class="p">.</span><span class="n">path</span><span class="p">)</span>
<span class="n">guide</span> <span class="o">=</span> <span class="n">cls</span><span class="p">.</span><span class="n">objects</span><span class="p">.</span><span class="nf">filter</span><span class="p">(</span><span class="n">url_path</span><span class="o">=</span><span class="n">path_to_match</span><span class="p">).</span><span class="nf">first</span><span class="p">()</span>
<span class="k">if</span> <span class="n">guide</span><span class="p">:</span>
<span class="n">steps</span> <span class="o">=</span> <span class="p">[</span>
<span class="p">{</span>
<span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">:</span> <span class="n">step</span><span class="p">.</span><span class="n">title</span><span class="p">,</span>
<span class="sh">"</span><span class="s">text</span><span class="sh">"</span><span class="p">:</span> <span class="n">step</span><span class="p">.</span><span class="n">text</span><span class="p">,</span>
<span class="sh">"</span><span class="s">element</span><span class="sh">"</span><span class="p">:</span> <span class="n">step</span><span class="p">.</span><span class="n">element</span><span class="p">,</span>
<span class="p">}</span>
<span class="k">for</span> <span class="n">step</span> <span class="ow">in</span> <span class="n">guide</span><span class="p">.</span><span class="n">steps</span><span class="p">.</span><span class="nf">all</span><span class="p">()</span>
<span class="p">]</span>
<span class="n">data</span> <span class="o">=</span> <span class="p">{</span><span class="sh">"</span><span class="s">steps</span><span class="sh">"</span><span class="p">:</span> <span class="n">steps</span><span class="p">,</span> <span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">:</span> <span class="n">guide</span><span class="p">.</span><span class="n">title</span><span class="p">}</span>
<span class="n">value_json</span> <span class="o">=</span> <span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">(</span>
<span class="n">data</span><span class="p">,</span>
<span class="n">separators</span><span class="o">=</span><span class="p">(</span><span class="sh">"</span><span class="s">,</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">:</span><span class="sh">"</span><span class="p">),</span>
<span class="p">)</span>
<span class="n">data</span><span class="p">[</span><span class="sh">"</span><span class="s">value_json</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="n">value_json</span>
<span class="k">return</span> <span class="n">data</span>
<span class="k">return</span> <span class="bp">None</span>
</code></pre>
</div>
<h4>
4b. Create a <code>menu.py</code> file
</h4>
<ul>
<li>This will contain our new menu class, we could put this code in the <code>wagtail_hooks.py</code> file but it is nice to isolate this logic if possible.</li>
<li>Here we override the <code>get_context</code> method for the <code>MenuItem</code> and first call the super's <code>get_context</code> method and then add two items.</li>
<li>Firstly, we add <code>attr_string</code> and build a <code>data-help</code> attribute which will contain the JSON output of our guide (if found). Note: There are many ways to pass data to the client, this is the simplest but it is not perfect.</li>
<li>Secondly, we extend the <code>classnames</code> item with a <code>help-available</code> class if we know we have found a matching Guide for the current admin page.</li>
<li>Remember to <code>return context</code>, otherwise you will just get a blank menu item.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># guide/menu.py
</span>
<span class="kn">from</span> <span class="n">django.utils.html</span> <span class="kn">import</span> <span class="n">format_html</span>
<span class="kn">from</span> <span class="n">wagtail.contrib.modeladmin.menus</span> <span class="kn">import</span> <span class="n">ModelAdminMenuItem</span>
<span class="kn">from</span> <span class="n">.models</span> <span class="kn">import</span> <span class="n">Guide</span>
<span class="k">class</span> <span class="nc">GuideAdminMenuItem</span><span class="p">(</span><span class="n">ModelAdminMenuItem</span><span class="p">):</span>
<span class="k">def</span> <span class="nf">get_context</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">request</span><span class="p">):</span>
<span class="n">context</span> <span class="o">=</span> <span class="nf">super</span><span class="p">().</span><span class="nf">get_context</span><span class="p">(</span><span class="n">request</span><span class="p">)</span>
<span class="n">data</span> <span class="o">=</span> <span class="n">Guide</span><span class="p">.</span><span class="nf">get_data_for_request</span><span class="p">(</span><span class="n">request</span><span class="p">)</span>
<span class="k">if</span> <span class="n">data</span><span class="p">:</span>
<span class="n">context</span><span class="p">[</span><span class="sh">"</span><span class="s">attr_string</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="nf">format_html</span><span class="p">(</span><span class="sh">'</span><span class="s">data-help=</span><span class="sh">"</span><span class="s">{}</span><span class="sh">"'</span><span class="p">,</span> <span class="n">data</span><span class="p">[</span><span class="sh">"</span><span class="s">value_json</span><span class="sh">"</span><span class="p">])</span>
<span class="n">context</span><span class="p">[</span><span class="sh">"</span><span class="s">classnames</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="n">context</span><span class="p">[</span><span class="sh">"</span><span class="s">classnames</span><span class="sh">"</span><span class="p">]</span> <span class="o">+</span> <span class="sh">"</span><span class="s"> help-available</span><span class="sh">"</span>
<span class="k">return</span> <span class="n">context</span>
</code></pre>
</div>
<h4>
4c. Update the Guide admin to use the custom menu item
</h4>
<ul>
<li>By overriding the <code>get_menu_item</code> we can leverage our custom <code>GuideAdminMenuItem</code> instead of the default one.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># guide/wagtail_hooks.py
</span><span class="kn">from</span> <span class="n">wagtail.contrib.modeladmin.options</span> <span class="kn">import</span> <span class="n">ModelAdmin</span><span class="p">,</span> <span class="n">modeladmin_register</span>
<span class="kn">from</span> <span class="n">.menu</span> <span class="kn">import</span> <span class="n">GuideAdminMenuItem</span> <span class="c1"># added
</span><span class="kn">from</span> <span class="n">.models</span> <span class="kn">import</span> <span class="n">Guide</span>
<span class="k">class</span> <span class="nc">GuideAdmin</span><span class="p">(</span><span class="n">ModelAdmin</span><span class="p">):</span>
<span class="c1"># ...
</span> <span class="k">def</span> <span class="nf">get_menu_item</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">order</span><span class="o">=</span><span class="bp">None</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Utilised by Wagtail</span><span class="sh">'</span><span class="s">s </span><span class="sh">'</span><span class="s">register_menu_item</span><span class="sh">'</span><span class="s"> hook to create a menu item
to access the listing view, or can be called by ModelAdminGroup
to create a SubMenu
</span><span class="sh">"""</span>
<span class="k">return</span> <span class="nc">GuideAdminMenuItem</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">order</span> <span class="ow">or</span> <span class="n">self</span><span class="p">.</span><span class="nf">get_menu_order</span><span class="p">())</span>
</code></pre>
</div>
<p><strong>Cross-check (before you continue)</strong></p>
<ul>
<li>When you load the Dashboard page in the Wagtail admin, you should be able to inspect (browser developer tools) the 'Guide' menu item and see the classes & custom data-help attribute.</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkk52zdrnnryf0wv0i1wn.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fkk52zdrnnryf0wv0i1wn.png" alt="Viewing the Menu Item in dev tools"></a></p>
<h3>
5. Adding JS & CSS
</h3>
<ul>
<li>There is a fair bit to unpack in this step, but the goal is to provide the right <code>options</code> to the Shepherd.js library and when the user clicks the menu item button, instead of going to the Guide listing it should trigger the tour.</li>
</ul>
<h4>
5a. Importing the <code>shepherd.js</code> library
</h4>
<ul>
<li>In our <code>wagtail_hooks.py</code> file we will leverage the <a href="proxy.php?url=https://docs.wagtail.io/en/stable/reference/hooks.html#insert-global-admin-js" rel="noopener noreferrer"><code>insert_global_admin_js</code></a> hook to add two files, the first of which is a CDN version of the npm package.</li>
<li>Using a hosted CDN version of the NPM package via <a href="proxy.php?url=https://www.jsdelivr.com/package/npm/shepherd.js" rel="noopener noreferrer">https://www.jsdelivr.com/package/npm/shepherd.js</a> saves time but it may not be suitable for your project.</li>
<li>In the code snippet below we will also use Wagtail's static system to add a js file, however, the code for that file is in step 5c.</li>
<li>
<strong>Cross-check (before you continue)</strong> Remember to restart your dev server, once done you should be able to open up the browser console and type <code>Shepherd</code> to see a value. This means the CDN has worked, you can also look at the network tab to check it gets loaded.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1">#guide/wagtail_hooks.py
</span>
<span class="kn">from</span> <span class="n">django.templatetags.static</span> <span class="kn">import</span> <span class="n">static</span> <span class="c1"># added
</span><span class="kn">from</span> <span class="n">django.utils.html</span> <span class="kn">import</span> <span class="n">format_html</span> <span class="c1"># added
</span>
<span class="kn">from</span> <span class="n">wagtail.contrib.modeladmin.options</span> <span class="kn">import</span> <span class="n">ModelAdmin</span><span class="p">,</span> <span class="n">modeladmin_register</span>
<span class="kn">from</span> <span class="n">wagtail.core</span> <span class="kn">import</span> <span class="n">hooks</span> <span class="c1"># added
</span>
<span class="c1"># .. other imports & GuideAdmin
</span>
<span class="nd">@hooks.register</span><span class="p">(</span><span class="sh">"</span><span class="s">insert_global_admin_js</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">global_admin_js</span><span class="p">():</span>
<span class="sh">"""</span><span class="s">
Sourced from https://www.jsdelivr.com/package/npm/shepherd.js
</span><span class="sh">"""</span>
<span class="k">return</span> <span class="nf">format_html</span><span class="p">(</span>
<span class="sh">'</span><span class="s"><script src=</span><span class="sh">"</span><span class="s">{}</span><span class="sh">"</span><span class="s">></script><script src=</span><span class="sh">"</span><span class="s">{}</span><span class="sh">"</span><span class="s">></script></span><span class="sh">'</span><span class="p">,</span>
<span class="sh">"</span><span class="s">https://cdn.jsdelivr.net/npm/shepherd.js@8/dist/js/shepherd.min.js</span><span class="sh">"</span><span class="p">,</span>
<span class="nf">static</span><span class="p">(</span><span class="sh">"</span><span class="s">js/shepherd.js</span><span class="sh">"</span><span class="p">),</span>
<span class="p">)</span>
</code></pre>
</div>
<h4>
5b. Adding the custom static CSS file
</h4>
<ul>
<li>The CSS code below contains all the base styles supplied with the Shepherd.js library with some tweaks to look a bit more like 'Wagtail', you can just use the CDN version via <code>https://cdn.jsdelivr.net/npm/shepherd.js@8/dist/css/shepherd.css</code> to save time.</li>
<li>It is important to note the styling <code>.menu-item .help-available::after</code> - this is to add a small visual indicator of a <code>*</code> (star) when a known help item is available.</li>
<li>Remember to add <code>'django.contrib.staticfiles'</code> to your <code>INSTALLED_APPS</code> so that static files can be used.</li>
<li>
<strong>Cross-check (before you continue)</strong> Remember to restart your dev server when changing static files, once done you should be able to see that this CSS file was loaded in the network tab.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1">#guide/wagtail_hooks.py
</span>
<span class="c1"># .. other imports & GuideAdmin + insert_global_admin_js
</span>
<span class="nd">@hooks.register</span><span class="p">(</span><span class="sh">"</span><span class="s">insert_global_admin_css</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">global_admin_css</span><span class="p">():</span>
<span class="sh">"""</span><span class="s">
Pulled from https://github.com/shipshapecode/shepherd/releases (assets)
.button styles removed (so we can use Wagtail styles instead)
</span><span class="sh">"""</span>
<span class="k">return</span> <span class="nf">format_html</span><span class="p">(</span><span class="sh">'</span><span class="s"><link rel=</span><span class="sh">"</span><span class="s">stylesheet</span><span class="sh">"</span><span class="s"> href=</span><span class="sh">"</span><span class="s">{}</span><span class="sh">"</span><span class="s">></span><span class="sh">'</span><span class="p">,</span> <span class="nf">static</span><span class="p">(</span><span class="sh">"</span><span class="s">css/shepherd.css</span><span class="sh">"</span><span class="p">))</span>
</code></pre>
</div>
<div class="highlight js-code-highlight">
<pre class="highlight css"><code><span class="c">/* guide/static/css/shepherd.css */</span>
<span class="nc">.shepherd-footer</span> <span class="p">{</span>
<span class="nl">border-bottom-left-radius</span><span class="p">:</span> <span class="m">5px</span><span class="p">;</span>
<span class="nl">border-bottom-right-radius</span><span class="p">:</span> <span class="m">5px</span><span class="p">;</span>
<span class="nl">display</span><span class="p">:</span> <span class="n">flex</span><span class="p">;</span>
<span class="nl">justify-content</span><span class="p">:</span> <span class="n">flex-end</span><span class="p">;</span>
<span class="nl">padding</span><span class="p">:</span> <span class="m">0</span> <span class="m">0.75rem</span> <span class="m">0.75rem</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-footer</span> <span class="nc">.shepherd-button</span><span class="nd">:last-child</span> <span class="p">{</span>
<span class="nl">margin-right</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-cancel-icon</span> <span class="p">{</span>
<span class="nl">background</span><span class="p">:</span> <span class="nb">transparent</span><span class="p">;</span>
<span class="nl">border-radius</span><span class="p">:</span> <span class="m">0.25rem</span><span class="p">;</span>
<span class="nl">border</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="nl">color</span><span class="p">:</span> <span class="nb">inherit</span><span class="p">;</span>
<span class="nl">font-size</span><span class="p">:</span> <span class="m">2em</span><span class="p">;</span>
<span class="nl">cursor</span><span class="p">:</span> <span class="nb">pointer</span><span class="p">;</span>
<span class="nl">font-weight</span><span class="p">:</span> <span class="m">400</span><span class="p">;</span>
<span class="nl">margin</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="nl">padding</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="nl">transition</span><span class="p">:</span> <span class="n">background-color</span> <span class="m">0.5s</span> <span class="n">ease</span><span class="p">;</span>
<span class="nl">width</span><span class="p">:</span> <span class="m">2.2rem</span><span class="p">;</span>
<span class="nl">height</span><span class="p">:</span> <span class="m">2.2rem</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-cancel-icon</span><span class="nd">:hover</span> <span class="p">{</span>
<span class="nl">background-color</span><span class="p">:</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-primary-darker</span><span class="p">);</span>
<span class="p">}</span>
<span class="nc">.shepherd-title</span> <span class="p">{</span>
<span class="nl">display</span><span class="p">:</span> <span class="n">flex</span><span class="p">;</span>
<span class="nl">font-size</span><span class="p">:</span> <span class="m">1.5rem</span><span class="p">;</span>
<span class="nl">font-weight</span><span class="p">:</span> <span class="m">400</span><span class="p">;</span>
<span class="nl">flex</span><span class="p">:</span> <span class="m">1</span> <span class="m">0</span> <span class="nb">auto</span><span class="p">;</span>
<span class="nl">margin</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="nl">padding</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-header</span> <span class="p">{</span>
<span class="nl">align-items</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
<span class="nl">border-top-left-radius</span><span class="p">:</span> <span class="m">5px</span><span class="p">;</span>
<span class="nl">border-top-right-radius</span><span class="p">:</span> <span class="m">5px</span><span class="p">;</span>
<span class="nl">display</span><span class="p">:</span> <span class="n">flex</span><span class="p">;</span>
<span class="nl">justify-content</span><span class="p">:</span> <span class="n">flex-end</span><span class="p">;</span>
<span class="nl">line-height</span><span class="p">:</span> <span class="m">2em</span><span class="p">;</span>
<span class="nl">padding</span><span class="p">:</span> <span class="m">0.75rem</span> <span class="m">0.75rem</span> <span class="m">0</span><span class="p">;</span>
<span class="nl">margin-bottom</span><span class="p">:</span> <span class="m">0.25rem</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-has-title</span> <span class="nc">.shepherd-content</span> <span class="nc">.shepherd-header</span> <span class="p">{</span>
<span class="nl">padding</span><span class="p">:</span> <span class="m">1em</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-text</span> <span class="p">{</span>
<span class="nl">color</span><span class="p">:</span> <span class="n">rgba</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0.75</span><span class="p">);</span>
<span class="nl">font-size</span><span class="p">:</span> <span class="m">1rem</span><span class="p">;</span>
<span class="nl">line-height</span><span class="p">:</span> <span class="m">1.3em</span><span class="p">;</span>
<span class="nl">min-height</span><span class="p">:</span> <span class="m">4em</span><span class="p">;</span>
<span class="nl">padding</span><span class="p">:</span> <span class="m">0.75em</span> <span class="m">1em</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-text</span> <span class="nt">p</span> <span class="p">{</span>
<span class="nl">margin-top</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-text</span> <span class="nt">p</span><span class="nd">:last-child</span> <span class="p">{</span>
<span class="nl">margin-bottom</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-content</span> <span class="p">{</span>
<span class="nl">border-radius</span><span class="p">:</span> <span class="m">5px</span><span class="p">;</span>
<span class="nl">outline</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="nl">padding</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-element</span> <span class="p">{</span>
<span class="nl">background</span><span class="p">:</span> <span class="m">#fff</span><span class="p">;</span>
<span class="nl">border-radius</span><span class="p">:</span> <span class="m">5px</span><span class="p">;</span>
<span class="nl">box-shadow</span><span class="p">:</span> <span class="m">0</span> <span class="m">1px</span> <span class="m">4px</span> <span class="n">rgba</span><span class="p">(</span><span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0</span><span class="p">,</span> <span class="m">0.2</span><span class="p">);</span>
<span class="nl">max-width</span><span class="p">:</span> <span class="m">50em</span><span class="p">;</span>
<span class="nl">opacity</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="nl">outline</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="nl">transition</span><span class="p">:</span> <span class="n">opacity</span> <span class="m">0.3s</span><span class="p">,</span> <span class="n">visibility</span> <span class="m">0.3s</span><span class="p">;</span>
<span class="nl">visibility</span><span class="p">:</span> <span class="nb">hidden</span><span class="p">;</span>
<span class="nl">width</span><span class="p">:</span> <span class="m">100%</span><span class="p">;</span>
<span class="nl">z-index</span><span class="p">:</span> <span class="m">9999</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-enabled.shepherd-element</span> <span class="p">{</span>
<span class="nl">opacity</span><span class="p">:</span> <span class="m">1</span><span class="p">;</span>
<span class="nl">visibility</span><span class="p">:</span> <span class="nb">visible</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-element</span><span class="o">[</span><span class="nt">data-popper-reference-hidden</span><span class="o">]</span><span class="nd">:not</span><span class="o">(</span><span class="nc">.shepherd-centered</span><span class="o">)</span> <span class="p">{</span>
<span class="nl">opacity</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="nl">pointer-events</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="nl">visibility</span><span class="p">:</span> <span class="nb">hidden</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-element</span><span class="o">,</span>
<span class="nc">.shepherd-element</span> <span class="o">*,</span>
<span class="nc">.shepherd-element</span> <span class="nd">:after</span><span class="o">,</span>
<span class="nc">.shepherd-element</span> <span class="nd">:before</span> <span class="p">{</span>
<span class="nl">box-sizing</span><span class="p">:</span> <span class="n">border-box</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-arrow</span><span class="o">,</span>
<span class="nc">.shepherd-arrow</span><span class="nd">:before</span> <span class="p">{</span>
<span class="nl">position</span><span class="p">:</span> <span class="nb">absolute</span><span class="p">;</span>
<span class="nl">width</span><span class="p">:</span> <span class="m">16px</span><span class="p">;</span>
<span class="nl">height</span><span class="p">:</span> <span class="m">16px</span><span class="p">;</span>
<span class="nl">z-index</span><span class="p">:</span> <span class="m">-1</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-arrow</span><span class="nd">:before</span> <span class="p">{</span>
<span class="nl">content</span><span class="p">:</span> <span class="s1">""</span><span class="p">;</span>
<span class="nl">transform</span><span class="p">:</span> <span class="n">rotate</span><span class="p">(</span><span class="m">45deg</span><span class="p">);</span>
<span class="nl">background</span><span class="p">:</span> <span class="m">#fff</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-element</span><span class="o">[</span><span class="nt">data-popper-placement</span><span class="o">^=</span><span class="s1">"top"</span><span class="o">]</span> <span class="o">></span> <span class="nc">.shepherd-arrow</span> <span class="p">{</span>
<span class="nl">bottom</span><span class="p">:</span> <span class="m">-8px</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-element</span><span class="o">[</span><span class="nt">data-popper-placement</span><span class="o">^=</span><span class="s1">"bottom"</span><span class="o">]</span> <span class="o">></span> <span class="nc">.shepherd-arrow</span> <span class="p">{</span>
<span class="nl">top</span><span class="p">:</span> <span class="m">-8px</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-element</span><span class="o">[</span><span class="nt">data-popper-placement</span><span class="o">^=</span><span class="s1">"left"</span><span class="o">]</span> <span class="o">></span> <span class="nc">.shepherd-arrow</span> <span class="p">{</span>
<span class="nl">right</span><span class="p">:</span> <span class="m">-8px</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-element</span><span class="o">[</span><span class="nt">data-popper-placement</span><span class="o">^=</span><span class="s1">"right"</span><span class="o">]</span> <span class="o">></span> <span class="nc">.shepherd-arrow</span> <span class="p">{</span>
<span class="nl">left</span><span class="p">:</span> <span class="m">-8px</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-element.shepherd-centered</span> <span class="o">></span> <span class="nc">.shepherd-arrow</span> <span class="p">{</span>
<span class="nl">opacity</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-element.shepherd-has-title</span><span class="o">[</span><span class="nt">data-popper-placement</span><span class="o">^=</span><span class="s1">"bottom"</span><span class="o">]</span>
<span class="o">></span> <span class="nc">.shepherd-arrow</span><span class="nd">:before</span> <span class="p">{</span>
<span class="nl">background-color</span><span class="p">:</span> <span class="m">#e6e6e6</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-target-click-disabled.shepherd-enabled.shepherd-target</span><span class="o">,</span>
<span class="nc">.shepherd-target-click-disabled.shepherd-enabled.shepherd-target</span> <span class="o">*</span> <span class="p">{</span>
<span class="nl">pointer-events</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-target</span> <span class="p">{</span>
<span class="nl">outline</span><span class="p">:</span> <span class="m">4px</span> <span class="nb">dotted</span> <span class="n">var</span><span class="p">(</span><span class="n">--color-input-focus</span><span class="p">);</span>
<span class="nl">outline-offset</span><span class="p">:</span> <span class="m">-2px</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-modal-overlay-container</span> <span class="p">{</span>
<span class="nl">height</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="nl">left</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="nl">opacity</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="nl">overflow</span><span class="p">:</span> <span class="nb">hidden</span><span class="p">;</span>
<span class="nl">pointer-events</span><span class="p">:</span> <span class="nb">none</span><span class="p">;</span>
<span class="nl">position</span><span class="p">:</span> <span class="nb">fixed</span><span class="p">;</span>
<span class="nl">top</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="nl">transition</span><span class="p">:</span> <span class="n">all</span> <span class="m">0.3s</span> <span class="n">ease-out</span><span class="p">,</span> <span class="n">height</span> <span class="m">0ms</span> <span class="m">0.3s</span><span class="p">,</span> <span class="n">opacity</span> <span class="m">0.3s</span> <span class="m">0ms</span><span class="p">;</span>
<span class="nl">width</span><span class="p">:</span> <span class="m">100vw</span><span class="p">;</span>
<span class="nl">z-index</span><span class="p">:</span> <span class="m">9997</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-modal-overlay-container.shepherd-modal-is-visible</span> <span class="p">{</span>
<span class="nl">height</span><span class="p">:</span> <span class="m">100vh</span><span class="p">;</span>
<span class="nl">opacity</span><span class="p">:</span> <span class="m">0.75</span><span class="p">;</span>
<span class="nl">transition</span><span class="p">:</span> <span class="n">all</span> <span class="m">0.3s</span> <span class="n">ease-out</span><span class="p">,</span> <span class="n">height</span> <span class="m">0s</span> <span class="m">0s</span><span class="p">,</span> <span class="n">opacity</span> <span class="m">0.3s</span> <span class="m">0s</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.shepherd-modal-overlay-container.shepherd-modal-is-visible</span> <span class="nt">path</span> <span class="p">{</span>
<span class="nl">pointer-events</span><span class="p">:</span> <span class="n">all</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.menu-item</span> <span class="nc">.help-available</span><span class="nd">::after</span> <span class="p">{</span>
<span class="nl">content</span><span class="p">:</span> <span class="s1">"*"</span><span class="p">;</span>
<span class="p">}</span>
</code></pre>
</div>
<h4>
5c. Adding the custom static JS file
</h4>
<ul>
<li>The full JS is below, the goal of this JS is to set up a Shepherd.js tour for every element found with the <code>data-help</code> attribute.</li>
<li>This data attribute will be parsed as JSON and if <code>steps</code> are found, the tour will be set up and the element will have a click listener attached to it to trigger the tour.</li>
<li>We have also set up some logic to ensure that the right buttons show for each possible state of a step (for example, the first step should only have a 'next' button).</li>
<li>The Shepherd.js documentation contains information about each of the options passed in and these can be customised based on requirements.</li>
<li>
<strong>Cross-check (before you continue)</strong> Remember to restart your dev server when adding static files, once done you should be able to see that this JS file was loaded in the network tab.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="c1">// guide/static/js/shepherd.js</span>
<span class="p">(()</span> <span class="o">=></span> <span class="p">{</span>
<span class="cm">/* 1. set up buttons for each possible state (first, last, only) of a step */</span>
<span class="kd">const</span> <span class="nx">nextButton</span> <span class="o">=</span> <span class="p">{</span>
<span class="nf">action</span><span class="p">()</span> <span class="p">{</span>
<span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nf">next</span><span class="p">();</span>
<span class="p">},</span>
<span class="na">classes</span><span class="p">:</span> <span class="dl">"</span><span class="s2">button</span><span class="dl">"</span><span class="p">,</span>
<span class="na">text</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Next</span><span class="dl">"</span><span class="p">,</span>
<span class="p">};</span>
<span class="kd">const</span> <span class="nx">backButton</span> <span class="o">=</span> <span class="p">{</span>
<span class="nf">action</span><span class="p">()</span> <span class="p">{</span>
<span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nf">back</span><span class="p">();</span>
<span class="p">},</span>
<span class="na">classes</span><span class="p">:</span> <span class="dl">"</span><span class="s2">button button-secondary</span><span class="dl">"</span><span class="p">,</span>
<span class="na">secondary</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="na">text</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Back</span><span class="dl">"</span><span class="p">,</span>
<span class="p">};</span>
<span class="kd">const</span> <span class="nx">doneButton</span> <span class="o">=</span> <span class="p">{</span>
<span class="nf">action</span><span class="p">()</span> <span class="p">{</span>
<span class="k">return</span> <span class="k">this</span><span class="p">.</span><span class="nf">next</span><span class="p">();</span>
<span class="p">},</span>
<span class="na">classes</span><span class="p">:</span> <span class="dl">"</span><span class="s2">button</span><span class="dl">"</span><span class="p">,</span>
<span class="na">text</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Done</span><span class="dl">"</span><span class="p">,</span>
<span class="p">};</span>
<span class="cm">/* 2. create a function that will maybe return an object with the buttons */</span>
<span class="kd">const</span> <span class="nx">getButtons</span> <span class="o">=</span> <span class="p">({</span> <span class="nx">index</span><span class="p">,</span> <span class="nx">length</span> <span class="p">})</span> <span class="o">=></span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="nx">length</span> <span class="o"><=</span> <span class="mi">1</span><span class="p">)</span> <span class="k">return</span> <span class="p">{</span> <span class="na">buttons</span><span class="p">:</span> <span class="p">[</span><span class="nx">doneButton</span><span class="p">]</span> <span class="p">};</span> <span class="c1">// only a single step, no back needed</span>
<span class="k">if </span><span class="p">(</span><span class="nx">index</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span> <span class="p">{</span> <span class="na">buttons</span><span class="p">:</span> <span class="p">[</span><span class="nx">nextButton</span><span class="p">]</span> <span class="p">};</span> <span class="c1">// first</span>
<span class="k">if </span><span class="p">(</span><span class="nx">index</span> <span class="o">===</span> <span class="nx">length</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="k">return</span> <span class="p">{</span> <span class="na">buttons</span><span class="p">:</span> <span class="p">[</span><span class="nx">backButton</span><span class="p">,</span> <span class="nx">doneButton</span><span class="p">]</span> <span class="p">};</span> <span class="c1">// last</span>
<span class="k">return</span> <span class="p">{};</span>
<span class="p">};</span>
<span class="cm">/* 3. prepare the default step options */</span>
<span class="kd">const</span> <span class="nx">defaultButtons</span> <span class="o">=</span> <span class="p">[</span><span class="nx">backButton</span><span class="p">,</span> <span class="nx">nextButton</span><span class="p">];</span>
<span class="kd">const</span> <span class="nx">defaultStepOptions</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">arrow</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="na">buttons</span><span class="p">:</span> <span class="nx">defaultButtons</span><span class="p">,</span>
<span class="na">cancelIcon</span><span class="p">:</span> <span class="p">{</span> <span class="na">enabled</span><span class="p">:</span> <span class="kc">true</span> <span class="p">},</span>
<span class="na">canClickTarget</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="na">scrollTo</span><span class="p">:</span> <span class="p">{</span> <span class="na">behavior</span><span class="p">:</span> <span class="dl">"</span><span class="s2">smooth</span><span class="dl">"</span><span class="p">,</span> <span class="na">block</span><span class="p">:</span> <span class="dl">"</span><span class="s2">center</span><span class="dl">"</span> <span class="p">},</span>
<span class="p">};</span>
<span class="cm">/* 4. once the DOM is loaded, find all the elements with the data-help attribute
- for each of these elements attempt to parse the JSON into steps and title
- if we find steps then initiate a `Shepherd` tour with those steps
- finally, attach a click listener to the link so that the link will trigger the tour
*/</span>
<span class="nb">window</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">DOMContentLoaded</span><span class="dl">"</span><span class="p">,</span> <span class="p">()</span> <span class="o">=></span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">links</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">querySelectorAll</span><span class="p">(</span><span class="dl">"</span><span class="s2">.help-available[data-help]</span><span class="dl">"</span><span class="p">);</span>
<span class="c1">// if no links found with data-help - return</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">links</span> <span class="o">||</span> <span class="nx">links</span><span class="p">.</span><span class="nx">length</span> <span class="o">===</span> <span class="mi">0</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
<span class="nx">links</span><span class="p">.</span><span class="nf">forEach</span><span class="p">((</span><span class="nx">link</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">data</span> <span class="o">=</span> <span class="nx">link</span><span class="p">.</span><span class="nx">dataset</span><span class="p">.</span><span class="nx">help</span><span class="p">;</span>
<span class="c1">// if data on data-help attribute is empty or missing, do not attempt to parse</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">data</span><span class="p">)</span> <span class="k">return</span><span class="p">;</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">steps</span> <span class="o">=</span> <span class="p">[],</span> <span class="nx">title</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="nx">data</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">tour</span> <span class="o">=</span> <span class="k">new</span> <span class="nx">Shepherd</span><span class="p">.</span><span class="nc">Tour</span><span class="p">({</span>
<span class="nx">defaultStepOptions</span><span class="p">,</span>
<span class="na">steps</span><span class="p">:</span> <span class="nx">steps</span><span class="p">.</span><span class="nf">map</span><span class="p">(({</span> <span class="nx">element</span><span class="p">,</span> <span class="p">...</span><span class="nx">step</span> <span class="p">},</span> <span class="nx">index</span><span class="p">)</span> <span class="o">=></span> <span class="p">({</span>
<span class="p">...</span><span class="nx">step</span><span class="p">,</span>
<span class="p">...(</span><span class="nx">element</span> <span class="p">?</span> <span class="p">{</span> <span class="na">attachTo</span><span class="p">:</span> <span class="p">{</span> <span class="nx">element</span> <span class="p">}</span> <span class="p">}</span> <span class="p">:</span> <span class="p">{}),</span>
<span class="p">...</span><span class="nf">getButtons</span><span class="p">({</span> <span class="nx">index</span><span class="p">,</span> <span class="na">length</span><span class="p">:</span> <span class="nx">steps</span><span class="p">.</span><span class="nx">length</span> <span class="p">}),</span>
<span class="p">})),</span>
<span class="na">tourName</span><span class="p">:</span> <span class="nx">title</span><span class="p">,</span>
<span class="na">useModalOverlay</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="p">});</span>
<span class="nx">link</span> <span class="o">&&</span>
<span class="nx">link</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">click</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">event</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="nx">event</span><span class="p">.</span><span class="nf">preventDefault</span><span class="p">();</span>
<span class="nx">tour</span><span class="p">.</span><span class="nf">start</span><span class="p">();</span>
<span class="p">});</span>
<span class="p">});</span>
<span class="p">});</span>
<span class="p">})();</span>
</code></pre>
</div>
<h2>
Final Implementation
</h2>
<ul>
<li>There should now be a fully functional Tour trigger that is available on the Admin home (dashboard) page, the 'Guide' menu item should have a '*' to indicate help is available.</li>
<li>When clicking this, it should trigger the tour based on the data added in step 3 above.</li>
<li>You can see all the final code on github <a href="proxy.php?url=https://github.com/lb-/bakerydemo/tree/tutorial/guide-app/guide" rel="noopener noreferrer">https://github.com/lb-/bakerydemo/tree/tutorial/guide-app/guide</a>
</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiinau6pooddu8m0qq1ss.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fiinau6pooddu8m0qq1ss.png" alt="Editing Images example"></a><br>
<a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F44mpay8xhs0y4u3f7ruc.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2F44mpay8xhs0y4u3f7ruc.png" alt="Home page example"></a></p>
<ul>
<li>Updated: 20/08/2021 - added reminders about <code>INSTALLED_APPS</code>.</li>
</ul>
<h2>
Future Enhancement Ideas
</h2>
<ul>
<li>Having the same Menu Item trigger the guide AND show the guide listing is not ideal, as this could be confusing for users, plus it might be confusing to admins when they actually want to edit and cannot easily get to the guide listing (if there are lots of guides added).</li>
<li>Make a dashboard panel available to new users if there is a matching guide available for that page, this has been implemented as a bonus step 6 below.</li>
<li>Make the inspect view for Guide items show the full steps in a nice UI, as this will be a helpful resource, even without the interactive tour aspect.</li>
<li>Have a way to track what users click on what guides, especially helpful for new users, maybe even provide feedback.</li>
</ul>
<h3>
6. Add a Dashboard panel with a Guide trigger <strong>Bonus</strong>
</h3>
<ul>
<li>This is a rough implementation but it leverages the same logic in the custom <code>MenuItem</code> to potentially render a homepage panel.</li>
<li>This code is based on the <a href="proxy.php?url=https://docs.wagtail.io/en/stable/reference/hooks.html#construct-homepage-panels" rel="noopener noreferrer"><code>construct_homepage_panels</code></a> Wagtail docs.</li>
<li>Using <code>Guide.get_data_for_request(self.request)</code> we can pull in a potential data object and if found, pass it to the generated HTML.</li>
<li>Note: We need to override the <code>__init__</code> method to ensure this Panel class can be initialised with the <code>request</code>.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># wagtail_hooks.py
</span>
<span class="c1"># imports and other hooks...
</span>
<span class="k">class</span> <span class="nc">GuidePanel</span><span class="p">:</span>
<span class="n">order</span> <span class="o">=</span> <span class="mi">500</span>
<span class="k">def</span> <span class="nf">__init__</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">request</span><span class="p">):</span>
<span class="n">self</span><span class="p">.</span><span class="n">request</span> <span class="o">=</span> <span class="n">request</span>
<span class="k">def</span> <span class="nf">render</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="n">data</span> <span class="o">=</span> <span class="n">Guide</span><span class="p">.</span><span class="nf">get_data_for_request</span><span class="p">(</span><span class="n">self</span><span class="p">.</span><span class="n">request</span><span class="p">)</span>
<span class="k">if</span> <span class="n">data</span><span class="p">:</span>
<span class="k">return</span> <span class="nf">format_html</span><span class="p">(</span>
<span class="sh">"""</span><span class="s">
<section class=</span><span class="sh">"</span><span class="s">panel summary nice-padding</span><span class="sh">"</span><span class="s">>
<h2>Guide</h2>
<div>
<button class=</span><span class="sh">"</span><span class="s">button button-secondary help-available</span><span class="sh">"</span><span class="s"> data-help=</span><span class="sh">"</span><span class="s">{}</span><span class="sh">"</span><span class="s">>Show {} Guide</button>
</div>
</section>
</span><span class="sh">"""</span><span class="p">,</span>
<span class="n">data</span><span class="p">[</span><span class="sh">"</span><span class="s">value_json</span><span class="sh">"</span><span class="p">],</span>
<span class="n">data</span><span class="p">[</span><span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">],</span>
<span class="p">)</span>
<span class="k">return</span> <span class="sh">""</span>
<span class="nd">@hooks.register</span><span class="p">(</span><span class="sh">"</span><span class="s">construct_homepage_panels</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">add_guide_panel</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="n">panels</span><span class="p">):</span>
<span class="n">panels</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="nc">GuidePanel</span><span class="p">(</span><span class="n">request</span><span class="p">))</span>
</code></pre>
</div>
wagtaildjangojavascriptcmsHow to create a Kanban (Trello style) view of your ModelAdmin data in WagtailLB (Ben Johnston)Fri, 06 Aug 2021 11:49:32 +0000
https://dev.to/lb/how-to-create-a-kanban-trello-style-view-of-your-modeladmin-data-in-wagtail-20eg
https://dev.to/lb/how-to-create-a-kanban-trello-style-view-of-your-modeladmin-data-in-wagtail-20eg<p><strong>Goal: Create a ModelAdmin mixin that will make it easy to show an index view as a Kanban board.</strong></p>
<p><strong>Why:</strong> Visual presentation is very helpful for planning and also a high-level understanding of information. Kanban boards provide a recognisable way to show sets of items in columns that represent their 'status' or grouping.</p>
<p><strong>How:</strong> We want this to be as simple as possible, leveraging existing ModelAdmin conventions where possible and keeping as much of the logic being on the server. Drag & drop would be great but happy to sacrifice real-time / async Javascript behaviour to gain simplicity.</p>
<p>Inspiration: Kanban, notion.so, Trello, Github & Gitlab Kanban interface.</p>
<h2>
Getting Started
</h2>
<p>Versions</p>
<ul>
<li>Wagtail 2.14</li>
<li>Python 3.6</li>
<li>Django 3.2</li>
<li>jkanban 1.3 (Javascript/npm library)</li>
</ul>
<p>Key Parts to Understand</p>
<ul>
<li>Terminology</li>
<li>ModelAdmin (Wagtail's not Django's)</li>
<li>Class Mixin</li>
</ul>
<h2>
Tutorial
</h2>
<h3>
1. Prepare a ModelAdmin model
</h3>
<p>For this tutorial we will be using <a href="proxy.php?url=https://arstechnica.com" rel="noopener noreferrer">ArsTechnica</a>'s <a href="proxy.php?url=https://arstechnica.com/newsletters/?subscribe=248910" rel="noopener noreferrer">Rocket Report</a> as inspiration. As of writing the latest report was <a href="proxy.php?url=https://arstechnica.com/science/2021/07/rocket-report-super-heavy-lights-up-china-tries-to-recover-a-fairing/" rel="noopener noreferrer">Rocket Report: Super Heavy lights up</a>.</p>
<p>This regular post contains a <code>title</code>, <code>byline</code>, <code>preamble</code>, a <code>reports</code> section which breaks up the news snippets into class of launch (small, medium and large). At the end of the report there, is a small <code>timeline</code> of upcoming launches. The part we want to focus on for this tutorial is the <code>reports</code> section, and <a href="proxy.php?url=https://docs.wagtail.io/en/stable/topics/snippets.html" rel="noopener noreferrer">Wagtail's snippets</a> are a perfect way to store this kind of related content in a centralised way.</p>
<h4>
Create app
</h4>
<p>We assume you already have a Wagtail application up and running, so our first step will be to find a place to store all our custom logic and models. We will <a href="proxy.php?url=https://docs.djangoproject.com/en/3.0/ref/django-admin/#startapp" rel="noopener noreferrer">start a new app</a> called <code>rocket_report</code>.</p>
<ol>
<li>Run <code>django-admin startapp rocket_report</code>
</li>
<li>Update your <code>settings.py</code>
</li>
</ol>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="n">INSTALLED_APPS</span> <span class="o">=</span> <span class="p">[</span>
<span class="c1"># ...
</span> <span class="sh">'</span><span class="s">rocket_report</span><span class="sh">'</span><span class="p">,</span>
<span class="c1"># ... wagtail & django items
</span> <span class="c1"># ensure that snippets and modeladmin apps are added
</span> <span class="sh">'</span><span class="s">wagtail.snippets</span><span class="sh">'</span><span class="p">,</span>
<span class="sh">'</span><span class="s">wagtail.contrib.modeladmin</span><span class="sh">'</span><span class="p">,</span>
<span class="p">]</span>
</code></pre>
</div>
<p>There will now be an app folder <code>rocket_report</code> with models, views, etc.</p>
<h4>
Create page Model
</h4>
<p>Our next step will be to define our <code>RocketReportPage</code> <a href="proxy.php?url=https://docs.wagtail.io/en/stable/topics/pages.html" rel="noopener noreferrer">page model</a>.</p>
<ol>
<li>Add a page model to your <code>models.py</code> file, code example below.</li>
<li>Run <code>./manage.py makemigrations</code> & <code>./manage.py migrate</code>
</li>
<li>Restart the dev server to validate that we can now add a Rocket Report Page in Wagtail's admin</li>
<li>Add one page for use throughout the rest of the tutorial
</li>
</ol>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="kn">from</span> <span class="n">django.db</span> <span class="kn">import</span> <span class="n">models</span>
<span class="kn">from</span> <span class="n">modelcluster.fields</span> <span class="kn">import</span> <span class="n">ParentalKey</span>
<span class="kn">from</span> <span class="n">wagtail.core.models</span> <span class="kn">import</span> <span class="n">Page</span><span class="p">,</span> <span class="n">Orderable</span>
<span class="kn">from</span> <span class="n">wagtail.core.fields</span> <span class="kn">import</span> <span class="n">RichTextField</span>
<span class="kn">from</span> <span class="n">wagtail.admin.edit_handlers</span> <span class="kn">import</span> <span class="n">FieldPanel</span><span class="p">,</span> <span class="n">InlinePanel</span>
<span class="kn">from</span> <span class="n">wagtail.images.edit_handlers</span> <span class="kn">import</span> <span class="n">ImageChooserPanel</span>
<span class="k">class</span> <span class="nc">RocketReportPage</span><span class="p">(</span><span class="n">Page</span><span class="p">):</span>
<span class="c1"># Database fields
</span> <span class="n">byline</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span><span class="n">blank</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">max_length</span><span class="o">=</span><span class="mi">120</span><span class="p">)</span>
<span class="n">preamble</span> <span class="o">=</span> <span class="nc">RichTextField</span><span class="p">(</span><span class="n">blank</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="n">main_image</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">ForeignKey</span><span class="p">(</span>
<span class="sh">"</span><span class="s">wagtailimages.Image</span><span class="sh">"</span><span class="p">,</span>
<span class="n">null</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
<span class="n">blank</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
<span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">SET_NULL</span><span class="p">,</span>
<span class="n">related_name</span><span class="o">=</span><span class="sh">"</span><span class="s">+</span><span class="sh">"</span><span class="p">,</span>
<span class="p">)</span>
<span class="c1"># Editor panels configuration
</span> <span class="n">content_panels</span> <span class="o">=</span> <span class="n">Page</span><span class="p">.</span><span class="n">content_panels</span> <span class="o">+</span> <span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">byline</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">preamble</span><span class="sh">"</span><span class="p">,</span> <span class="n">classname</span><span class="o">=</span><span class="sh">"</span><span class="s">full</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">ImageChooserPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">main_image</span><span class="sh">"</span><span class="p">),</span>
<span class="c1"># TBC - reports
</span> <span class="nc">InlinePanel</span><span class="p">(</span><span class="sh">"</span><span class="s">related_launches</span><span class="sh">"</span><span class="p">,</span> <span class="n">label</span><span class="o">=</span><span class="sh">"</span><span class="s">Timeline</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>
<span class="k">class</span> <span class="nc">Launch</span><span class="p">(</span><span class="n">Orderable</span><span class="p">):</span>
<span class="n">page</span> <span class="o">=</span> <span class="nc">ParentalKey</span><span class="p">(</span>
<span class="n">RocketReportPage</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">CASCADE</span><span class="p">,</span> <span class="n">related_name</span><span class="o">=</span><span class="sh">"</span><span class="s">related_launches</span><span class="sh">"</span>
<span class="p">)</span>
<span class="n">date</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">DateField</span><span class="p">(</span><span class="sh">"</span><span class="s">Launch date</span><span class="sh">"</span><span class="p">)</span>
<span class="n">details</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">)</span>
<span class="n">panels</span> <span class="o">=</span> <span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">date</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">details</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>
</code></pre>
</div>
<h4>
Create snippet Model
</h4>
<p>Our rocket report items will be <a href="proxy.php?url=https://docs.wagtail.io/en/stable/topics/snippets.html" rel="noopener noreferrer">Wagtail Snippets</a>, this gives a simple way to edit, manage and select these items for our pages.</p>
<ol>
<li>Add the snippet model to your same <code>models.py</code> file, code example below.</li>
<li>Run <code>./manage.py makemigrations</code> & <code>./manage.py migrate</code>.</li>
<li>Restart the dev server to validate that we can now add the snippet in Wagtail's admin.</li>
<li>Add some snippet entries for use throughout the rest of the tutorial.</li>
<li>Note - a reminder to ensure <code>INSTALLED_APPS</code> contains <code>'wagtail.snippets'</code>
</li>
</ol>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="kn">from</span> <span class="n">django.db</span> <span class="kn">import</span> <span class="n">models</span>
<span class="c1"># ... include existing imports from model.py
</span><span class="kn">from</span> <span class="n">wagtail.snippets.models</span> <span class="kn">import</span> <span class="n">register_snippet</span>
<span class="kn">from</span> <span class="n">wagtail.snippets.edit_handlers</span> <span class="kn">import</span> <span class="n">SnippetChooserPanel</span>
<span class="kn">from</span> <span class="n">wagtail.admin.edit_handlers</span> <span class="kn">import</span> <span class="n">FieldPanel</span>
<span class="k">class</span> <span class="nc">RocketReportPage</span><span class="p">(</span><span class="n">Page</span><span class="p">):</span>
<span class="c1"># ...
</span> <span class="n">content_panels</span> <span class="o">=</span> <span class="n">Page</span><span class="p">.</span><span class="n">content_panels</span> <span class="o">+</span> <span class="p">[</span>
<span class="c1"># ... other field panels
</span> <span class="nc">ImageChooserPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">main_image</span><span class="sh">"</span><span class="p">),</span>
<span class="c1"># ... other field panels
</span> <span class="p">]</span>
<span class="nd">@register_snippet</span>
<span class="k">class</span> <span class="nc">RocketReport</span><span class="p">(</span><span class="n">models</span><span class="p">.</span><span class="n">Model</span><span class="p">):</span>
<span class="n">STATUS_CHOICES</span> <span class="o">=</span> <span class="p">[</span>
<span class="p">(</span><span class="sh">"</span><span class="s">SUBMITTED</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Submitted</span><span class="sh">"</span><span class="p">),</span>
<span class="p">(</span><span class="sh">"</span><span class="s">REVIEWED</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Reviewed</span><span class="sh">"</span><span class="p">),</span>
<span class="p">(</span><span class="sh">"</span><span class="s">PROPOSED</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Proposed</span><span class="sh">"</span><span class="p">),</span>
<span class="p">(</span><span class="sh">"</span><span class="s">HOLD</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Hold</span><span class="sh">"</span><span class="p">),</span>
<span class="p">(</span><span class="sh">"</span><span class="s">CURRENT</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Current</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>
<span class="n">CATEGORY_CHOICES</span> <span class="o">=</span> <span class="p">[</span>
<span class="p">(</span><span class="sh">"</span><span class="s">BLANK</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Uncategorised</span><span class="sh">"</span><span class="p">),</span>
<span class="p">(</span><span class="sh">"</span><span class="s">SMALL</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Small</span><span class="sh">"</span><span class="p">),</span>
<span class="p">(</span><span class="sh">"</span><span class="s">MEDIUM</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Medium</span><span class="sh">"</span><span class="p">),</span>
<span class="p">(</span><span class="sh">"</span><span class="s">LARGE</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Large</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>
<span class="n">submitted_url</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">URLField</span><span class="p">(</span><span class="n">null</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">blank</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="n">submitted_by</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">blank</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="n">status</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">blank</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">choices</span><span class="o">=</span><span class="n">STATUS_CHOICES</span><span class="p">)</span>
<span class="n">title</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span><span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">)</span>
<span class="n">content</span> <span class="o">=</span> <span class="nc">RichTextField</span><span class="p">(</span><span class="n">blank</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span>
<span class="n">category</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span>
<span class="n">max_length</span><span class="o">=</span><span class="mi">255</span><span class="p">,</span> <span class="n">choices</span><span class="o">=</span><span class="n">CATEGORY_CHOICES</span><span class="p">,</span> <span class="n">default</span><span class="o">=</span><span class="sh">"</span><span class="s">BLANK</span><span class="sh">"</span>
<span class="p">)</span>
<span class="n">panels</span> <span class="o">=</span> <span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">status</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">category</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">content</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">submitted_url</span><span class="sh">"</span><span class="p">),</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">submitted_by</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>
<span class="k">def</span> <span class="nf">__str__</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="n">title</span>
<span class="k">class</span> <span class="nc">RocketReportPageReportPlacement</span><span class="p">(</span><span class="n">Orderable</span><span class="p">,</span> <span class="n">models</span><span class="p">.</span><span class="n">Model</span><span class="p">):</span>
<span class="n">page</span> <span class="o">=</span> <span class="nc">ParentalKey</span><span class="p">(</span>
<span class="n">RocketReportPage</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">CASCADE</span><span class="p">,</span> <span class="n">related_name</span><span class="o">=</span><span class="sh">"</span><span class="s">rocket_reports</span><span class="sh">"</span>
<span class="p">)</span>
<span class="n">rocket_report</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">ForeignKey</span><span class="p">(</span>
<span class="n">RocketReport</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">CASCADE</span><span class="p">,</span> <span class="n">related_name</span><span class="o">=</span><span class="sh">"</span><span class="s">+</span><span class="sh">"</span>
<span class="p">)</span>
<span class="n">panels</span> <span class="o">=</span> <span class="p">[</span>
<span class="nc">SnippetChooserPanel</span><span class="p">(</span><span class="sh">"</span><span class="s">rocket_report</span><span class="sh">"</span><span class="p">),</span>
<span class="p">]</span>
<span class="k">def</span> <span class="nf">__str__</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="n">page</span><span class="p">.</span><span class="n">title</span> <span class="o">+</span> <span class="sh">"</span><span class="s"> -> </span><span class="sh">"</span> <span class="o">+</span> <span class="n">self</span><span class="p">.</span><span class="n">rocket_report</span><span class="p">.</span><span class="n">title</span>
</code></pre>
</div>
<h4>
Register with ModelAdmin
</h4>
<p>Now we can register the report model using <a href="proxy.php?url=https://docs.wagtail.io/en/stable/reference/contrib/modeladmin/index.html" rel="noopener noreferrer"><code>ModelAdmin</code></a>. Note that this is Wagtail's ModelAdmin not Django's.</p>
<ol>
<li>Add <code>wagtail.contrib.modeladmin</code> to your <code>INSTALLED_APPS</code> in your <code>settings.py</code>
</li>
<li>Add a new <code>ModelAdmin</code> class in admin.py, code example below.</li>
<li>Register this class in a new file <code>wagtail_hooks.py</code>, code example below.</li>
<li>Validate that we now have an admin sidebar item for 'Rocket Reports' which will show the default ModelAdmin item list.
</li>
</ol>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># admin.py
</span><span class="kn">from</span> <span class="n">wagtail.contrib.modeladmin.options</span> <span class="kn">import</span> <span class="n">ModelAdmin</span>
<span class="kn">from</span> <span class="n">.models</span> <span class="kn">import</span> <span class="n">RocketReport</span>
<span class="k">class</span> <span class="nc">RocketReportAdmin</span><span class="p">(</span><span class="n">ModelAdmin</span><span class="p">):</span>
<span class="n">model</span> <span class="o">=</span> <span class="n">RocketReport</span>
<span class="n">menu_icon</span> <span class="o">=</span> <span class="sh">"</span><span class="s">fa-rocket</span><span class="sh">"</span>
<span class="n">list_display</span> <span class="o">=</span> <span class="p">(</span><span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">status</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">category</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">submitted_by</span><span class="sh">"</span><span class="p">)</span>
<span class="n">list_filter</span> <span class="o">=</span> <span class="p">(</span><span class="sh">"</span><span class="s">status</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">category</span><span class="sh">"</span><span class="p">)</span>
<span class="n">search_fields</span> <span class="o">=</span> <span class="p">(</span><span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">status</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">category</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">submitted_by</span><span class="sh">"</span><span class="p">)</span>
</code></pre>
</div>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># wagtail_hooks.py
</span><span class="kn">from</span> <span class="n">wagtail.contrib.modeladmin.options</span> <span class="kn">import</span> <span class="n">modeladmin_register</span>
<span class="kn">from</span> <span class="n">.admin</span> <span class="kn">import</span> <span class="n">RocketReportAdmin</span>
<span class="nf">modeladmin_register</span><span class="p">(</span><span class="n">RocketReportAdmin</span><span class="p">)</span>
</code></pre>
</div>
<h3>
2. Create a template, view & mixin
</h3>
<p>We are going to now set up a custom <code>KanbanMixin</code> that will house the customisations to our <code>ModelAdmin</code>. We could put all of these customisations directly on our <code>RocketReportAdmin</code> but we want to set up something reusable. It would be good to have a basic understanding of how to <a href="proxy.php?url=https://docs.wagtail.io/en/stable/reference/contrib/modeladmin/indexview.html" rel="noopener noreferrer">customise the index view (listing)</a> before reading on.</p>
<h4>
Create Kanban index template
</h4>
<p>We will be using a Javascript library to do the client-side rendering and handling of interaction for our basic Kanban board. There are a lot of <a href="proxy.php?url=https://github.com/topics/kanban?l=javascript" rel="noopener noreferrer">Kanban JS libraries on Github</a> and a few <a href="proxy.php?url=https://dev.toKanban%20packages%20on%20NPM">Kanban packages on NPM</a>.</p>
<p>The package we will use is <a href="proxy.php?url=https://www.riccardotartaglia.it/jkanban/" rel="noopener noreferrer">Jkanban</a>, it has a simple API and does not rely on third-party dependencies. For simplicity, we will use the jsdelivr service to provide our script and CSS, find the package and use the <a href="proxy.php?url=https://www.jsdelivr.com/package/npm/jkanban?path=dist" rel="noopener noreferrer">dist directory to get your script and style tags</a>.</p>
<ol>
<li>Create a template file <code>/templates/modeladmin/kanban_index.html</code>
</li>
<li>To inherit the existing modeladmin index listing layout (header, search bar, title etc) add <code>{% extends "modeladmin/index.html" %}</code> at the top</li>
<li>The content blocks we will use, provided by the above template are <code>extra_css</code>, <code>extra_js</code> and <code>content_main</code>.</li>
<li>Remember to add <code>{{ block.super }}</code> to the js & css blocks so that existing scripts and styles will be used.</li>
<li>
<code>content_main</code> block - add a div that will contain the Kanban with class <code>kanban-wrapper listing</code> and an inner div with an id <code>kanban-mount</code> which is used by JKanban to add the rendered kanban board</li>
<li>
<code>extra_css</code> block - Add the <code>link</code> tag from jsdelivr and some basic styles within a <code><style></code> tag, in the code below we are starting with some margins and handling of longer boards</li>
<li>
<code>extra_js</code> block - our goal is to simply load up some dummy data based on the <a href="proxy.php?url=https://github.com/riktar/jkanban#var-kanban--new-jkanbanoptions" rel="noopener noreferrer">options docs for jKanban</a>
</li>
</ol>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="nb">document</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">"</span><span class="s2">DOMContentLoaded</span><span class="dl">"</span><span class="p">,</span> <span class="nf">function </span><span class="p">()</span> <span class="p">{</span>
<span class="kd">var</span> <span class="nx">options</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">boards</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span>
<span class="na">id</span><span class="p">:</span> <span class="dl">"</span><span class="s2">column-0</span><span class="dl">"</span><span class="p">,</span>
<span class="na">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Column A</span><span class="dl">"</span><span class="p">,</span>
<span class="na">item</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">"</span><span class="s2">item-1</span><span class="dl">"</span><span class="p">,</span> <span class="na">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Item 1</span><span class="dl">"</span> <span class="p">},</span>
<span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">"</span><span class="s2">item-2</span><span class="dl">"</span><span class="p">,</span> <span class="na">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Item 2</span><span class="dl">"</span> <span class="p">},</span>
<span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">"</span><span class="s2">item-1</span><span class="dl">"</span><span class="p">,</span> <span class="na">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Item 3</span><span class="dl">"</span> <span class="p">},</span>
<span class="p">],</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="na">id</span><span class="p">:</span> <span class="dl">"</span><span class="s2">column-1</span><span class="dl">"</span><span class="p">,</span>
<span class="na">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Column B</span><span class="dl">"</span><span class="p">,</span>
<span class="na">item</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">"</span><span class="s2">item-4</span><span class="dl">"</span><span class="p">,</span> <span class="na">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Item 4</span><span class="dl">"</span> <span class="p">},</span>
<span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">"</span><span class="s2">item-5</span><span class="dl">"</span><span class="p">,</span> <span class="na">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Item 4</span><span class="dl">"</span> <span class="p">},</span>
<span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">"</span><span class="s2">item-5</span><span class="dl">"</span><span class="p">,</span> <span class="na">title</span><span class="p">:</span> <span class="dl">"</span><span class="s2">Item 6</span><span class="dl">"</span> <span class="p">},</span>
<span class="p">],</span>
<span class="p">},</span>
<span class="p">],</span>
<span class="p">};</span>
<span class="c1">// build the kanban board with supplied options</span>
<span class="kd">var</span> <span class="nx">kanban</span> <span class="o">=</span> <span class="k">new</span> <span class="nf">jKanban</span><span class="p">(</span>
<span class="nb">Object</span><span class="p">.</span><span class="nf">assign</span><span class="p">({},</span> <span class="nx">options</span><span class="p">,</span> <span class="p">{</span> <span class="na">element</span><span class="p">:</span> <span class="dl">"</span><span class="s2">#kanban-mount</span><span class="dl">"</span> <span class="p">})</span>
<span class="p">);</span>
<span class="p">});</span>
</code></pre>
</div>
<h5>
Full template code
</h5>
<div class="highlight js-code-highlight">
<pre class="highlight html"><code>{% extends "modeladmin/index.html" %}
{% comment %} templates/modeladmin/kanban_index.html {% endcomment %}
{% block extra_css %}
{{ block.super }}
<span class="nt"><link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">href=</span><span class="s">"https://cdn.jsdelivr.net/npm/jkanban@1/dist/jkanban.min.css"</span><span class="nt">></span>
<span class="nt"><style></span>
<span class="nc">.kanban-wrapper</span> <span class="p">{</span>
<span class="nl">width</span><span class="p">:</span> <span class="m">100%</span><span class="p">;</span>
<span class="nl">overflow-x</span><span class="p">:</span> <span class="nb">auto</span><span class="p">;</span> <span class="c">/* add horizontal scrolling for wide boards */</span>
<span class="nl">margin-top</span><span class="p">:</span> <span class="m">1rem</span><span class="p">;</span>
<span class="nl">margin-bottom</span><span class="p">:</span> <span class="m">1rem</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.kanban-item</span> <span class="p">{</span>
<span class="nl">min-height</span><span class="p">:</span> <span class="m">4rem</span><span class="p">;</span>
<span class="p">}</span>
<span class="nt"></style></span>
{% endblock %}
{% block extra_js %}
{{ block.super }}
<span class="nt"><script </span><span class="na">src=</span><span class="s">"https://cdn.jsdelivr.net/npm/jkanban@1/dist/jkanban.js"</span><span class="nt">></script></span>
<span class="nt"><script></span>
<span class="nb">document</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">DOMContentLoaded</span><span class="dl">'</span><span class="p">,</span> <span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">var</span> <span class="nx">options</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">boards</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span>
<span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">column-0</span><span class="dl">'</span><span class="p">,</span>
<span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Column A</span><span class="dl">'</span><span class="p">,</span>
<span class="na">item</span><span class="p">:</span> <span class="p">[{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">item-1</span><span class="dl">'</span><span class="p">,</span> <span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Item 1</span><span class="dl">'</span><span class="p">},</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">item-2</span><span class="dl">'</span><span class="p">,</span> <span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Item 2</span><span class="dl">'</span><span class="p">},</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">item-1</span><span class="dl">'</span><span class="p">,</span> <span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Item 3</span><span class="dl">'</span><span class="p">}]}</span>
<span class="p">,</span>
<span class="p">{</span>
<span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">column-1</span><span class="dl">'</span><span class="p">,</span>
<span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Column B</span><span class="dl">'</span><span class="p">,</span>
<span class="na">item</span><span class="p">:</span> <span class="p">[{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">item-4</span><span class="dl">'</span><span class="p">,</span> <span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Item 4</span><span class="dl">'</span><span class="p">},</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">item-5</span><span class="dl">'</span><span class="p">,</span> <span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Item 4</span><span class="dl">'</span><span class="p">},</span> <span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="dl">'</span><span class="s1">item-5</span><span class="dl">'</span><span class="p">,</span> <span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Item 6</span><span class="dl">'</span><span class="p">}]</span>
<span class="p">}</span>
<span class="p">]</span>
<span class="p">};</span>
<span class="c1">// build the kanban board with supplied options</span>
<span class="c1">// see: https://github.com/riktar/jkanban#var-kanban--new-jkanbanoptions</span>
<span class="kd">var</span> <span class="nx">kanban</span> <span class="o">=</span> <span class="k">new</span> <span class="nf">jKanban</span><span class="p">(</span><span class="nb">Object</span><span class="p">.</span><span class="nf">assign</span><span class="p">({},</span> <span class="nx">options</span><span class="p">,</span> <span class="p">{</span><span class="na">element</span><span class="p">:</span> <span class="dl">'</span><span class="s1">#kanban-mount</span><span class="dl">'</span><span class="p">}));</span>
<span class="p">});</span>
<span class="nt"></script></span>
{% endblock %}
{% block content_main %}
<span class="nt"><div</span> <span class="na">class=</span><span class="s">"kanban-wrapper listing"</span><span class="nt">></span>
<span class="nt"><div</span> <span class="na">id=</span><span class="s">"kanban-mount"</span><span class="nt">></div></span>
<span class="nt"></div></span>
{% endblock %}
</code></pre>
</div>
<h4>
Create Mixin with template override
</h4>
<p>A template is only good if we can get our <code>ModelAdmin</code> to use it when rendering the index listing view instead of the default. We can leverage a mixin approach to override the <code>ModelAdmin</code> methods while still honouring the <a href="proxy.php?url=https://docs.wagtail.io/en/stable/reference/contrib/modeladmin/primer.html#overriding-templates" rel="noopener noreferrer">existing config on a per app or model basis</a>.</p>
<ol>
<li>We will store our mixin in the <code>admin.py</code> file.</li>
<li>
<code>ModelAdmin</code> uses a method <code>get_index_template</code> to get the index listing template, simply override this to call the defined <code>index_template_name</code> or <code>get_templates("kanban_index")</code>.</li>
<li>This will ensure that the template made above will be found at <code>templates/modeladmin/kanban_index.html</code>
</li>
<li>Be sure to add the mixin to your <code>RocketReportAdmin</code> class, before the <code>ModelAdmin</code> usage.
</li>
</ol>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># rocket_report/admin.py
</span>
<span class="k">class</span> <span class="nc">KanbanMixin</span><span class="p">:</span>
<span class="k">def</span> <span class="nf">get_index_template</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="c1"># leverage the get_template to allow individual override on a per model basis
</span> <span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="n">index_template_name</span> <span class="ow">or</span> <span class="n">self</span><span class="p">.</span><span class="nf">get_templates</span><span class="p">(</span><span class="sh">"</span><span class="s">kanban_index</span><span class="sh">"</span><span class="p">)</span>
<span class="k">class</span> <span class="nc">RocketReportAdmin</span><span class="p">(</span><span class="n">KanbanMixin</span><span class="p">,</span> <span class="n">ModelAdmin</span><span class="p">):</span>
<span class="n">model</span> <span class="o">=</span> <span class="n">RocketReport</span>
<span class="c1"># ...
</span></code></pre>
</div>
<h4>
Create View to supply mock data to the kanban board
</h4>
<p>Our goal is to keep as much logic on the server, so we need a way to provide the board data from our Django view to our client. Doing this comes with some issues of encoding/decoding and ensuring that server generated content cannot inject Javascript.</p>
<p>Thankfully, Django helps us out with its builtin tag <a href="proxy.php?url=https://docs.djangoproject.com/en/3.0/ref/templates/builtins/#json-script" rel="noopener noreferrer"><code>json_script</code></a> which provides a way for sever generated content to be provided to JS in a view in a safe way.</p>
<ol>
<li>Add a new view to the app's <code>views.py</code> called <code>KanbanView</code>
</li>
<li>This view will inherit the modeladmin <code>wagtail.contrib.modeladmin.views.IndexView</code>
</li>
<li>Override <code>get_context_data</code>, calling super and then adding <code>kanban_options</code> with similar dummy data that we used in the template</li>
<li>Use this <code>KanbanView</code> within the <code>KanbanMixin</code>
</li>
<li>Update the <code>kanban_index.html</code> to inject the JSON data via json-script
</li>
</ol>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># views.py
</span><span class="kn">from</span> <span class="n">wagtail.contrib.modeladmin.views</span> <span class="kn">import</span> <span class="n">IndexView</span>
<span class="k">class</span> <span class="nc">KanbanView</span><span class="p">(</span><span class="n">IndexView</span><span class="p">):</span>
<span class="k">def</span> <span class="nf">get_kanban_data</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">context</span><span class="p">):</span>
<span class="k">return</span> <span class="p">[</span>
<span class="p">{</span>
<span class="sh">"</span><span class="s">id</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">column-id-%s</span><span class="sh">"</span> <span class="o">%</span> <span class="n">index</span><span class="p">,</span>
<span class="sh">"</span><span class="s">item</span><span class="sh">"</span><span class="p">:</span> <span class="p">[</span>
<span class="p">{</span><span class="sh">"</span><span class="s">id</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">item-id-%s</span><span class="sh">"</span> <span class="o">%</span> <span class="n">obj</span><span class="p">[</span><span class="sh">"</span><span class="s">pk</span><span class="sh">"</span><span class="p">],</span> <span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">:</span> <span class="n">obj</span><span class="p">[</span><span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">],}</span>
<span class="k">for</span> <span class="n">index</span><span class="p">,</span> <span class="n">obj</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span>
<span class="p">[</span>
<span class="p">{</span><span class="sh">"</span><span class="s">pk</span><span class="sh">"</span><span class="p">:</span> <span class="n">index</span> <span class="o">+</span> <span class="mi">1</span><span class="p">,</span> <span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">%s Item 1</span><span class="sh">"</span> <span class="o">%</span> <span class="n">column</span><span class="p">},</span>
<span class="p">{</span><span class="sh">"</span><span class="s">pk</span><span class="sh">"</span><span class="p">:</span> <span class="n">index</span> <span class="o">+</span> <span class="mi">2</span><span class="p">,</span> <span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">%s Item 2</span><span class="sh">"</span> <span class="o">%</span> <span class="n">column</span><span class="p">},</span>
<span class="p">{</span><span class="sh">"</span><span class="s">pk</span><span class="sh">"</span><span class="p">:</span> <span class="n">index</span> <span class="o">+</span> <span class="mi">3</span><span class="p">,</span> <span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">%s Item 3</span><span class="sh">"</span> <span class="o">%</span> <span class="n">column</span><span class="p">},</span>
<span class="p">]</span>
<span class="p">)</span>
<span class="p">],</span>
<span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">:</span> <span class="n">column</span><span class="p">,</span>
<span class="p">}</span>
<span class="k">for</span> <span class="n">index</span><span class="p">,</span> <span class="n">column</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">([</span><span class="sh">"</span><span class="s">column a</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">column b</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">column c</span><span class="sh">"</span><span class="p">])</span>
<span class="p">]</span>
<span class="k">def</span> <span class="nf">get_context_data</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
<span class="n">context</span> <span class="o">=</span> <span class="nf">super</span><span class="p">().</span><span class="nf">get_context_data</span><span class="p">(</span><span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
<span class="c1"># replace object_list in context as we do not want it to be paginated
</span> <span class="n">context</span><span class="p">[</span><span class="sh">"</span><span class="s">object_list</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">queryset</span>
<span class="c1"># see: https://github.com/riktar/jkanban#var-kanban--new-jkanbanoptions
</span> <span class="n">context</span><span class="p">[</span><span class="sh">"</span><span class="s">kanban_options</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="p">{</span>
<span class="sh">"</span><span class="s">addItemButton</span><span class="sh">"</span><span class="p">:</span> <span class="bp">False</span><span class="p">,</span>
<span class="sh">"</span><span class="s">boards</span><span class="sh">"</span><span class="p">:</span> <span class="n">self</span><span class="p">.</span><span class="nf">get_kanban_data</span><span class="p">(</span><span class="n">context</span><span class="p">),</span>
<span class="sh">"</span><span class="s">dragBoards</span><span class="sh">"</span><span class="p">:</span> <span class="bp">False</span><span class="p">,</span>
<span class="sh">"</span><span class="s">dragItems</span><span class="sh">"</span><span class="p">:</span> <span class="bp">False</span><span class="p">,</span>
<span class="p">}</span>
<span class="k">return</span> <span class="n">context</span>
</code></pre>
</div>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># rocket_report/admin.py
</span><span class="kn">from</span> <span class="n">wagtail.contrib.modeladmin.options</span> <span class="kn">import</span> <span class="n">ModelAdmin</span>
<span class="kn">from</span> <span class="n">.views</span> <span class="kn">import</span> <span class="n">KanbanView</span>
<span class="kn">from</span> <span class="n">.models</span> <span class="kn">import</span> <span class="n">RocketReport</span>
<span class="k">class</span> <span class="nc">KanbanMixin</span><span class="p">:</span>
<span class="n">index_view_class</span> <span class="o">=</span> <span class="n">KanbanView</span>
<span class="k">def</span> <span class="nf">get_index_template</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="c1">#...
</span>
<span class="k">class</span> <span class="nc">RocketReportAdmin</span><span class="p">(</span><span class="n">KanbanMixin</span><span class="p">,</span> <span class="n">ModelAdmin</span><span class="p">):</span>
<span class="n">model</span> <span class="o">=</span> <span class="n">RocketReport</span>
<span class="c1"># ...
</span></code></pre>
</div>
<div class="highlight js-code-highlight">
<pre class="highlight html"><code>{% comment %} templates/modeladmin/kanban_index.html (just the JS block shown) {% endcomment %}
{% block extra_js %}
{{ block.super }}
<span class="nt"><script </span><span class="na">src=</span><span class="s">"https://cdn.jsdelivr.net/npm/jkanban@1/dist/jkanban.js"</span><span class="nt">></script></span>
{{ kanban_options|json_script:"kanban-options" }}
<span class="nt"><script></span>
<span class="nb">document</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">DOMContentLoaded</span><span class="dl">'</span><span class="p">,</span> <span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
<span class="c1">// load the options from server</span>
<span class="kd">var</span> <span class="nx">options</span> <span class="o">=</span> <span class="nx">JSON</span><span class="p">.</span><span class="nf">parse</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">kanban-options</span><span class="dl">'</span><span class="p">).</span><span class="nx">textContent</span><span class="p">);</span>
<span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">loaded</span><span class="dl">'</span><span class="p">,</span> <span class="p">{</span> <span class="nx">options</span> <span class="p">})</span>
<span class="c1">// build the kanban board with supplied options</span>
<span class="c1">// see: https://github.com/riktar/jkanban#var-kanban--new-jkanbanoptions</span>
<span class="kd">var</span> <span class="nx">kanban</span> <span class="o">=</span> <span class="k">new</span> <span class="nf">jKanban</span><span class="p">(</span><span class="nb">Object</span><span class="p">.</span><span class="nf">assign</span><span class="p">({},</span> <span class="nx">options</span><span class="p">,</span> <span class="p">{</span><span class="na">element</span><span class="p">:</span> <span class="dl">'</span><span class="s1">#kanban-mount</span><span class="dl">'</span><span class="p">}));</span>
<span class="p">});</span>
<span class="nt"></script></span>
{% endblock %}
</code></pre>
</div>
<h3>
3. Render items columns from actual data
</h3>
<p>Our goal here is to finish this basic implementation by generating columns and items from the correct <code>Model</code>. To achieve this we will revise the <code>KanbanMixin</code> to have some methods for smaller templates (used for the title/content) and methods to determine what field will be used for the columns. After that, we can revise the View to prepare all the data.</p>
<h4>
Revise the KanbanMixin and add small templates
</h4>
<ol>
<li>Add a method <code>get_kanban_item_template</code> to look for a template with the name <code>kanban_item</code> but also allow the Mixin usage to declare an attribute <code>kanban_item_template_name</code>. This way we can have simple defaults but allow each <code>KanbanMixin</code> to declare custom templates for the items on a per model basis.</li>
<li>Add a method <code>get_kanban_column_title_template</code> that is similar to the above but for the column title.</li>
<li>Add a method <code>get_kanban_column_field</code> which will return the first field name from the <code>list_filter</code> attribute on the Mixin usage, this means we can leverage the existing ModelAdmin attributes approach.</li>
<li>Finally, add a method <code>get_kanban_column_name_default</code> for a default column name, this will be used when there is no value for the Kanban column field (e.g. a drop-down where a None/blank value is selected).
</li>
</ol>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># rocket_report/admin.py
</span>
<span class="k">class</span> <span class="nc">KanbanMixin</span><span class="p">:</span>
<span class="n">index_view_class</span> <span class="o">=</span> <span class="n">KanbanView</span>
<span class="k">def</span> <span class="nf">get_index_template</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="c1"># leverage the get_template to allow individual override on a per model basis
</span> <span class="k">return</span> <span class="n">self</span><span class="p">.</span><span class="n">index_template_name</span> <span class="ow">or</span> <span class="n">self</span><span class="p">.</span><span class="nf">get_templates</span><span class="p">(</span><span class="sh">"</span><span class="s">kanban_index</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">get_kanban_item_template</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="c1"># leverage the get_template to allow individual override on a per model basis
</span> <span class="k">return</span> <span class="nf">getattr</span><span class="p">(</span>
<span class="n">self</span><span class="p">,</span> <span class="sh">"</span><span class="s">kanban_item_template_name</span><span class="sh">"</span><span class="p">,</span> <span class="n">self</span><span class="p">.</span><span class="nf">get_templates</span><span class="p">(</span><span class="sh">"</span><span class="s">kanban_item</span><span class="sh">"</span><span class="p">)</span>
<span class="p">)</span>
<span class="k">def</span> <span class="nf">get_kanban_column_title_template</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="c1"># leverage the get_template to allow individual override on a per model basis
</span> <span class="k">return</span> <span class="nf">getattr</span><span class="p">(</span>
<span class="n">self</span><span class="p">,</span>
<span class="sh">"</span><span class="s">kanban_column_title_template_name</span><span class="sh">"</span><span class="p">,</span>
<span class="n">self</span><span class="p">.</span><span class="nf">get_templates</span><span class="p">(</span><span class="sh">"</span><span class="s">kanban_column_title</span><span class="sh">"</span><span class="p">),</span>
<span class="p">)</span>
<span class="k">def</span> <span class="nf">get_kanban_column_field</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="c1"># return a field to use to determine which column the item will be shown in
</span> <span class="c1"># pull in the first value from list_filter if no specific column set
</span> <span class="n">list_filter</span> <span class="o">=</span> <span class="nf">getattr</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="sh">"</span><span class="s">list_filter</span><span class="sh">"</span><span class="p">,</span> <span class="p">[])</span>
<span class="n">field</span> <span class="o">=</span> <span class="n">list_filter</span><span class="p">[</span><span class="mi">0</span><span class="p">]</span> <span class="k">if</span> <span class="n">list_filter</span> <span class="k">else</span> <span class="bp">None</span>
<span class="k">return</span> <span class="n">field</span>
<span class="k">def</span> <span class="nf">get_kanban_column_name_default</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="c1"># used for the column title name for None or no column scenarios
</span> <span class="k">return</span> <span class="nf">getattr</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="sh">"</span><span class="s">kanban_column_name_default</span><span class="sh">"</span><span class="p">,</span> <span class="sh">"</span><span class="s">Other</span><span class="sh">"</span><span class="p">)</span>
<span class="c1"># ...
</span></code></pre>
</div>
<h4>
Revise the KanbanMixin and add small templates
</h4>
<p>There is a lot changed in this final step, the main methods added to the <code>KanbanView</code> are to generate all the various parts (columns/items) and use the template in each of those parts set up in the <code>KanbanMixin</code>.</p>
<ol>
<li>Add a method <code>render_kanban_item_html</code> which will pull in the action buttons (part of <code>ModelAdmin</code>), the template and then pass all the data to the template from the <code>get_kanban_item_template</code> method. This will return a string (HTML) which will, in turn, be passed to the JSON data for the Kanban board.</li>
<li>Add a method <code>render_kanban_column_title_html</code> which will pass the context to the configured title template.</li>
<li>Add a method <code>get_kanban_columns</code> that uses a query which will gather ALL the Model instances and prepare the data which has groupings of those Models by their column, along with the columns (with their names) also.</li>
<li>Replace the method <code>get_kanban_data</code> with a series of List parsing that goes through the column data and prepares the items to be placed within each column in a format that, when converted to JSON, is suitable for jKanban.
</li>
</ol>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="kn">from</span> <span class="n">django.contrib.admin.templatetags.admin_list</span> <span class="kn">import</span> <span class="n">result_headers</span>
<span class="kn">from</span> <span class="n">django.template.loader</span> <span class="kn">import</span> <span class="n">render_to_string</span>
<span class="kn">from</span> <span class="n">django.db.models</span> <span class="kn">import</span> <span class="n">CharField</span><span class="p">,</span> <span class="n">Count</span><span class="p">,</span> <span class="n">F</span><span class="p">,</span> <span class="n">Value</span>
<span class="kn">from</span> <span class="n">wagtail.contrib.modeladmin.templatetags.modeladmin_tags</span> <span class="kn">import</span> <span class="n">result_list</span>
<span class="kn">from</span> <span class="n">wagtail.contrib.modeladmin.views</span> <span class="kn">import</span> <span class="n">IndexView</span>
<span class="k">class</span> <span class="nc">KanbanView</span><span class="p">(</span><span class="n">IndexView</span><span class="p">):</span>
<span class="k">def</span> <span class="nf">render_kanban_item_html</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">context</span><span class="p">,</span> <span class="n">obj</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Allow for template based rendering of the content that goes inside each item
Prepare action buttons that will be the same as the classic modeladmin index
</span><span class="sh">"""</span>
<span class="n">kwargs</span><span class="p">[</span><span class="sh">"</span><span class="s">obj</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="n">obj</span>
<span class="n">kwargs</span><span class="p">[</span><span class="sh">"</span><span class="s">action_buttons</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">get_buttons_for_obj</span><span class="p">(</span><span class="n">obj</span><span class="p">)</span>
<span class="n">context</span><span class="p">.</span><span class="nf">update</span><span class="p">(</span><span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
<span class="n">template</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">model_admin</span><span class="p">.</span><span class="nf">get_kanban_item_template</span><span class="p">()</span>
<span class="k">return</span> <span class="nf">render_to_string</span><span class="p">(</span><span class="n">template</span><span class="p">,</span> <span class="n">context</span><span class="p">,</span> <span class="n">request</span><span class="o">=</span><span class="n">self</span><span class="p">.</span><span class="n">request</span><span class="p">,)</span>
<span class="k">def</span> <span class="nf">render_kanban_column_title_html</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">context</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Allow for template based rendering of the content that goes at the top of a column
</span><span class="sh">"""</span>
<span class="n">context</span><span class="p">.</span><span class="nf">update</span><span class="p">(</span><span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
<span class="n">template</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">model_admin</span><span class="p">.</span><span class="nf">get_kanban_column_title_template</span><span class="p">()</span>
<span class="k">return</span> <span class="nf">render_to_string</span><span class="p">(</span><span class="n">template</span><span class="p">,</span> <span class="n">context</span><span class="p">,</span> <span class="n">request</span><span class="o">=</span><span class="n">self</span><span class="p">.</span><span class="n">request</span><span class="p">,)</span>
<span class="k">def</span> <span class="nf">get_kanban_columns</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Gather all column related data
columns: name & count queryset
default: label of a column that either has None value or does not exist on the field
field: field name that is used to get the value from the instance
key: internal use key to refer to the annotated column name label value
queryset original queryset annotated with the column name label
</span><span class="sh">"""</span>
<span class="n">object_list</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">queryset</span>
<span class="n">column_field</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">model_admin</span><span class="p">.</span><span class="nf">get_kanban_column_field</span><span class="p">()</span>
<span class="n">column_name_default</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">model_admin</span><span class="p">.</span><span class="nf">get_kanban_column_name_default</span><span class="p">()</span>
<span class="n">column_key</span> <span class="o">=</span> <span class="sh">"</span><span class="s">__column_name</span><span class="sh">"</span>
<span class="n">queryset</span> <span class="o">=</span> <span class="n">object_list</span><span class="p">.</span><span class="nf">annotate</span><span class="p">(</span>
<span class="n">__column_name</span><span class="o">=</span><span class="nc">F</span><span class="p">(</span><span class="n">column_field</span><span class="p">)</span>
<span class="k">if</span> <span class="n">column_field</span>
<span class="k">else</span> <span class="nc">Value</span><span class="p">(</span><span class="n">column_name_default</span><span class="p">,</span> <span class="n">output_field</span><span class="o">=</span><span class="nc">CharField</span><span class="p">())</span>
<span class="p">)</span>
<span class="n">order</span> <span class="o">=</span> <span class="nc">F</span><span class="p">(</span><span class="n">column_key</span><span class="p">).</span><span class="nf">asc</span><span class="p">(</span><span class="n">nulls_first</span><span class="o">=</span><span class="bp">True</span><span class="p">)</span> <span class="k">if</span> <span class="n">column_field</span> <span class="k">else</span> <span class="n">column_key</span>
<span class="n">columns</span> <span class="o">=</span> <span class="p">(</span>
<span class="n">queryset</span><span class="p">.</span><span class="nf">values</span><span class="p">(</span><span class="n">column_key</span><span class="p">).</span><span class="nf">order_by</span><span class="p">(</span><span class="n">order</span><span class="p">).</span><span class="nf">annotate</span><span class="p">(</span><span class="n">count</span><span class="o">=</span><span class="nc">Count</span><span class="p">(</span><span class="sh">"</span><span class="s">pk</span><span class="sh">"</span><span class="p">))</span>
<span class="p">)</span>
<span class="k">return</span> <span class="p">{</span>
<span class="sh">"</span><span class="s">columns</span><span class="sh">"</span><span class="p">:</span> <span class="n">columns</span><span class="p">,</span>
<span class="sh">"</span><span class="s">default</span><span class="sh">"</span><span class="p">:</span> <span class="n">column_name_default</span><span class="p">,</span>
<span class="sh">"</span><span class="s">field</span><span class="sh">"</span><span class="p">:</span> <span class="n">column_field</span><span class="p">,</span>
<span class="sh">"</span><span class="s">key</span><span class="sh">"</span><span class="p">:</span> <span class="n">column_key</span><span class="p">,</span>
<span class="sh">"</span><span class="s">queryset</span><span class="sh">"</span><span class="p">:</span> <span class="n">queryset</span><span class="p">,</span>
<span class="p">}</span>
<span class="k">def</span> <span class="nf">get_kanban_data</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">context</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Prepares the data that is used by the Kanban js library
An array of columns, each with an id, title (html) and item
Item value in each column contains an array of items which has a column, id & title (html)
</span><span class="sh">"""</span>
<span class="n">columns</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">get_kanban_columns</span><span class="p">()</span>
<span class="c1"># use existing model_admin utility to build headers/values
</span> <span class="n">result_data</span> <span class="o">=</span> <span class="nf">result_list</span><span class="p">(</span><span class="n">context</span><span class="p">)</span>
<span class="c1"># set up items (for ALL columns)
</span> <span class="n">items</span> <span class="o">=</span> <span class="p">[</span>
<span class="p">{</span>
<span class="sh">"</span><span class="s">column</span><span class="sh">"</span><span class="p">:</span> <span class="nf">getattr</span><span class="p">(</span><span class="n">obj</span><span class="p">,</span> <span class="n">columns</span><span class="p">[</span><span class="sh">"</span><span class="s">key</span><span class="sh">"</span><span class="p">]),</span>
<span class="sh">"</span><span class="s">id</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">item-id-%s</span><span class="sh">"</span> <span class="o">%</span> <span class="n">obj</span><span class="p">.</span><span class="n">pk</span><span class="p">,</span>
<span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">:</span> <span class="n">self</span><span class="p">.</span><span class="nf">render_kanban_item_html</span><span class="p">(</span>
<span class="n">context</span><span class="p">,</span>
<span class="n">obj</span><span class="p">,</span>
<span class="n">fields</span><span class="o">=</span><span class="p">[</span>
<span class="p">{</span><span class="sh">"</span><span class="s">label</span><span class="sh">"</span><span class="p">:</span> <span class="n">label</span><span class="p">,</span> <span class="sh">"</span><span class="s">value</span><span class="sh">"</span><span class="p">:</span> <span class="n">result_data</span><span class="p">[</span><span class="sh">"</span><span class="s">results</span><span class="sh">"</span><span class="p">][</span><span class="n">index</span><span class="p">][</span><span class="n">idx</span><span class="p">]}</span>
<span class="k">for</span> <span class="n">idx</span><span class="p">,</span> <span class="n">label</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="n">result_data</span><span class="p">[</span><span class="sh">"</span><span class="s">result_headers</span><span class="sh">"</span><span class="p">])</span>
<span class="p">],</span>
<span class="p">),</span>
<span class="p">}</span>
<span class="k">for</span> <span class="n">index</span><span class="p">,</span> <span class="n">obj</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="n">columns</span><span class="p">[</span><span class="sh">"</span><span class="s">queryset</span><span class="sh">"</span><span class="p">])</span>
<span class="p">]</span>
<span class="c1"># set up columns (aka boards) with sets of filtered items inside
</span> <span class="k">return</span> <span class="p">[</span>
<span class="p">{</span>
<span class="sh">"</span><span class="s">id</span><span class="sh">"</span><span class="p">:</span> <span class="sh">"</span><span class="s">column-id-%s</span><span class="sh">"</span> <span class="o">%</span> <span class="n">index</span><span class="p">,</span>
<span class="sh">"</span><span class="s">item</span><span class="sh">"</span><span class="p">:</span> <span class="p">[</span>
<span class="n">item</span> <span class="k">for</span> <span class="n">item</span> <span class="ow">in</span> <span class="n">items</span> <span class="k">if</span> <span class="n">item</span><span class="p">[</span><span class="sh">"</span><span class="s">column</span><span class="sh">"</span><span class="p">]</span> <span class="o">==</span> <span class="n">column</span><span class="p">[</span><span class="n">columns</span><span class="p">[</span><span class="sh">"</span><span class="s">key</span><span class="sh">"</span><span class="p">]]</span>
<span class="p">],</span>
<span class="sh">"</span><span class="s">title</span><span class="sh">"</span><span class="p">:</span> <span class="n">self</span><span class="p">.</span><span class="nf">render_kanban_column_title_html</span><span class="p">(</span>
<span class="n">context</span><span class="p">,</span>
<span class="n">count</span><span class="o">=</span><span class="n">column</span><span class="p">[</span><span class="sh">"</span><span class="s">count</span><span class="sh">"</span><span class="p">],</span>
<span class="n">name</span><span class="o">=</span><span class="n">column</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">columns</span><span class="p">[</span><span class="sh">"</span><span class="s">key</span><span class="sh">"</span><span class="p">],</span> <span class="n">columns</span><span class="p">[</span><span class="sh">"</span><span class="s">default</span><span class="sh">"</span><span class="p">])</span>
<span class="ow">or</span> <span class="n">columns</span><span class="p">[</span><span class="sh">"</span><span class="s">default</span><span class="sh">"</span><span class="p">],</span>
<span class="p">),</span>
<span class="p">}</span>
<span class="k">for</span> <span class="n">index</span><span class="p">,</span> <span class="n">column</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="n">columns</span><span class="p">[</span><span class="sh">"</span><span class="s">columns</span><span class="sh">"</span><span class="p">])</span>
<span class="p">]</span>
<span class="k">def</span> <span class="nf">get_context_data</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
<span class="c1"># ... (same as before)
</span></code></pre>
</div>
<h2>
Final Solution
</h2>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fipspx50b7gng6ve72af4.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Farticles%2Fipspx50b7gng6ve72af4.png" alt="Screenshot of Kanban page" width="800" height="395"></a></p>
<ul>
<li>Code can be found <a href="proxy.php?url=https://github.com/lb-/bakerydemo/tree/tutorial/kanban-model-admin-ready/rocket_report" rel="noopener noreferrer">Github / lb-</a>
</li>
</ul>
<h2>
Future Improvements & Feedback
</h2>
<ul>
<li>It took a while to get this published, so hopefully it all came together well but I would love any feedback and hope this is helpful to someone.</li>
<li>Better handling of pre-setting 'values' for each item's field/value, currently renders inside <code><td></code> tags due to existing <code>ModelAdmin</code> assumptions.</li>
<li>Handling drag & drop (even real-time) with column number updates and toast style messages, you can view a rough version of this in the repo above (see commit <a href="proxy.php?url=https://github.com/lb-/bakerydemo/commit/27edc8b48e91a7c90ae8f382b50368b32cddcd8a" rel="noopener noreferrer">ORIGINAL ROUGH IMPLEMENTATION</a>).</li>
</ul>
<h2>
Notes
</h2>
<ul>
<li>Updated 20/08/2021 - added notes about updating <code>INSTALLED_APPS</code>
</li>
</ul>
pythonwagtailtrellokanbanImage Uploads in Wagtail FormsLB (Ben Johnston)Thu, 24 Sep 2020 22:10:08 +0000
https://dev.to/lb/image-uploads-in-wagtail-forms-39pl
https://dev.to/lb/image-uploads-in-wagtail-forms-39pl<p><em>For developers using the Wagtail CMS who want to add image upload fields.</em></p>
<blockquote>
<p>Heads up - This is an update of my earlier post <a href="proxy.php?url=https://posts-by.lb.ee/image-uploads-in-wagtail-forms-3121c9b35d27" rel="noopener noreferrer">Image Uploads in Wagtail Forms</a> which was written for Wagtail v1.12, this new post is written for v2.10/v2.11.</p>
</blockquote>
<p><strong>The Problem</strong> --- Your team are loving the custom form builder in Wagtail CMS and want to let people upload an image along with the form.</p>
<p><strong>The Solution</strong> --- Define a new form field type that is selectable when editing fields in the CMS Admin, this field type will be called 'Upload Image'. This field should show up in the view as a normal upload field with restrictions on file type and size, just like the Wagtail Images system.</p>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fhz9j1ujyvrh58o3y52on.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fhz9j1ujyvrh58o3y52on.png" alt="Note the Field Type: 'Upload Image' --- that is what we want to build."></a></p>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fcid5yluk7nx1wn4brye1.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fcid5yluk7nx1wn4brye1.png" alt="Published form with an upload image field"></a></p>
<p>Goal: When you add an 'Upload Image' field, it will show up on the form view for you.</p>
<h2>
Wagtail, Images and Forms
</h2>
<p>Skip ahead if you know the basics here.</p>
<p><a href="proxy.php?url=https://wagtail.io/" rel="noopener noreferrer">Wagtail</a> is a Content Management System (CMS) that is built on top of the <a href="proxy.php?url=https://www.djangoproject.com/" rel="noopener noreferrer">Django Web Framework</a>. What I love about Wagtail is that it embraces the Django ecosystem and way of doing things. It also has a really nice admin interface that makes it easy for users to interact with the content.</p>
<p>Wagtail has a built in interface and framework for uploading, storing and serving images. This is aptly named Wagtail Images, you can review the docs about <a href="proxy.php?url=https://docs.wagtail.io/en/v2.10.1/topics/images.html" rel="noopener noreferrer">Using Images in Templates</a> or <a href="proxy.php?url=https://docs.wagtail.io/en/v2.10.1/advanced_topics/images/index.html" rel="noopener noreferrer">Advanced Image Usage</a> for more information.</p>
<p>Wagtail comes with a great <a href="proxy.php?url=https://docs.wagtail.io/en/v2.10.1/reference/contrib/forms/index.html" rel="noopener noreferrer">Form Builder</a> module, it lets users build their own forms in the admin interface. These forms can have a series of fields such as Text, Multi-line Text, Email, URL, Checkbox, and others that build up a form page that can be viewed on the front end of the website. Users can customise the default value, whether the field is required and also some help text that relates to the field.</p>
<h2>
Before We Start
</h2>
<p>Before we start changing (breaking) things, it is important that you have the following items completed.</p>
<ol>
<li>Wagtail v2.10.x or v2.11.x up and running as per the <a href="proxy.php?url=https://docs.wagtail.io/en/v2.10.1/" rel="noopener noreferrer">main documentation</a>.</li>
<li>
<a href="proxy.php?url=https://docs.wagtail.io/en/v2.10.1/reference/contrib/forms/index.html#usage" rel="noopener noreferrer">Wagtailforms module</a> is installed, running and you have forms working. Remember to add <code>'wagtail.contrib.forms'</code> to your <code>INSTALLED_APPS</code>.</li>
</ol>
<h2>
Adding Image Upload Fields to Forms in Wagtail
</h2>
<h3>
Planning our Changes
</h3>
<p>We want to enable the following user interaction:</p>
<ol>
<li>The admin interface should provide the ability to edit an existing form and create a new form as normal.</li>
<li>When editing a form page, there should be a new dropdown option on the 'Field Type' field called 'Upload Image'.</li>
<li>The form page view should have one file upload field for every 'Upload Image' field that was defined in the admin.</li>
<li>The form page view should accept images with the same restrictions as Wagtail Images (< 10mb, only PNG/JPG/GIF*).</li>
<li>The form page view should require the image if the field is defined as 'required' in admin.</li>
<li>When an image is valid, it should save this image into the Wagtail Images area.</li>
<li>A link to the image should be saved to the form submission (aka form response), this will ensure it appears on emails or reports.</li>
</ol>
<p>* Default GIF support is quite basic in Wagtail, if you want to support animated GIFs you should read these docs regarding <a href="proxy.php?url=https://docs.wagtail.io/en/v2.10.1/advanced_topics/images/animated_gifs.html" rel="noopener noreferrer">Animated GIFs</a>.</p>
<h3>
1. Extend the <code>AbstractFormField</code> Class
</h3>
<p>In your models file that contains your <code>FormPage</code> class definition, you should also have a definition for a <code>FormField</code> class. In the original definition, the <a href="proxy.php?url=https://github.com/wagtail/wagtail/blob/v2.10.1/wagtail/contrib/forms/models.py#L80" rel="noopener noreferrer">AbstractFormField class</a> uses a fixed tuple of <a href="proxy.php?url=https://github.com/wagtail/wagtail/blob/v2.10.1/wagtail/contrib/forms/models.py#L24" rel="noopener noreferrer">FORM_FIELD_CHOICES</a>. We need to override the field_type with an appended set of choices.<br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># models.py
</span>
<span class="kn">from</span> <span class="n">wagtail.contrib.forms.models</span> <span class="kn">import</span> <span class="n">AbstractForm</span><span class="p">,</span> <span class="n">AbstractFormField</span><span class="p">,</span> <span class="n">FORM_FIELD_CHOICES</span>
<span class="k">class</span> <span class="nc">FormField</span><span class="p">(</span><span class="n">AbstractFormField</span><span class="p">):</span>
<span class="n">field_type</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span>
<span class="n">verbose_name</span><span class="o">=</span><span class="sh">'</span><span class="s">field type</span><span class="sh">'</span><span class="p">,</span>
<span class="n">max_length</span><span class="o">=</span><span class="mi">16</span><span class="p">,</span>
<span class="n">choices</span><span class="o">=</span><span class="nf">list</span><span class="p">(</span><span class="n">FORM_FIELD_CHOICES</span><span class="p">)</span> <span class="o">+</span> <span class="p">[(</span><span class="sh">'</span><span class="s">image</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">Upload Image</span><span class="sh">'</span><span class="p">)]</span>
<span class="p">)</span>
<span class="n">page</span> <span class="o">=</span> <span class="nc">ParentalKey</span><span class="p">(</span><span class="sh">'</span><span class="s">FormPage</span><span class="sh">'</span><span class="p">,</span> <span class="n">related_name</span><span class="o">=</span><span class="sh">'</span><span class="s">form_fields</span><span class="sh">'</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">CASCADE</span><span class="p">)</span>
</code></pre>
</div>
<p>In the above code you can see that we imported the original <code>FORM_FIELD_CHOICES</code> from wagtail.contrib.forms.models. We then converted it to a list, added our new field type and then this is used in the choices argument of the <code>field_type</code> field.</p>
<p>When you do this, you will need to <a href="proxy.php?url=https://docs.djangoproject.com/en/3.1/ref/django-admin/#django-admin-makemigrations" rel="noopener noreferrer">make a migration, and run that migration</a>. Test it out, the form in admin will now let you select this type, but it will not do much else yet.</p>
<h3>
2. Extend the <code>FormBuilder</code> Class
</h3>
<p>In your models file you will now need to create an extended form builder class. In the original definition the <a href="proxy.php?url=https://github.com/wagtail/wagtail/blob/v2.10.1/wagtail/contrib/forms/forms.py#L22" rel="noopener noreferrer">FormBuilder class</a> builds a form based on the <code>field_type</code> list that is stored in each FormPage instance. We can follow the example in the docs about <a href="proxy.php?url=https://docs.wagtail.io/en/v2.10.1/reference/contrib/forms/customisation.html#adding-a-custom-field-type" rel="noopener noreferrer">Adding a custom field type</a>.</p>
<p>We will need to create a method that follows the convention based on the field name ('image' in our case) to a method name <code>create_image_field</code> which is then called and should return an instance of a <a href="proxy.php?url=https://docs.djangoproject.com/en/3.1/ref/forms/widgets/#widget" rel="noopener noreferrer">Django form widget</a>. Rather than building our own custom Image field that works with Wagtail, we can use their own <a href="proxy.php?url=https://github.com/wagtail/wagtail/blob/v2.10.1/wagtail/images/fields.py#L14" rel="noopener noreferrer">WagtailImageField</a>.<br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># models.py
</span>
<span class="kn">from</span> <span class="n">wagtail.contrib.forms.forms</span> <span class="kn">import</span> <span class="n">FormBuilder</span>
<span class="kn">from</span> <span class="n">wagtail.images.fields</span> <span class="kn">import</span> <span class="n">WagtailImageField</span>
<span class="k">class</span> <span class="nc">CustomFormBuilder</span><span class="p">(</span><span class="n">FormBuilder</span><span class="p">):</span>
<span class="k">def</span> <span class="nf">create_image_field</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">field</span><span class="p">,</span> <span class="n">options</span><span class="p">):</span>
<span class="k">return</span> <span class="nc">WagtailImageField</span><span class="p">(</span><span class="o">**</span><span class="n">options</span><span class="p">)</span>
</code></pre>
</div>
<p>In the above code, we have imported <code>FormBuilder</code> from <code>wagtail.contrib.forms.forms</code> and <code>WagtailImageField</code> from <code>wagtail.images.fields</code>, then created our own custom <code>FormBuilder</code> with a new class. We have added a method <code>create_image_field</code> that returns a created <code>WagtailImageField</code>, passing in any options provided.</p>
<h3>
3. Set the FormPage class to use <code>CustomFormBuilder</code>
</h3>
<p>This step is pretty straight forward, we want to override the <code>form_builder</code> definition in our FormPage model. This is a very nifty way that Wagtail enables you to override the form_builder you use.<br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># models.py
</span>
<span class="kn">from</span> <span class="n">wagtail.contrib.forms.models</span> <span class="kn">import</span> <span class="n">AbstractForm</span>
<span class="k">class</span> <span class="nc">FormPage</span><span class="p">(</span><span class="n">AbstractForm</span><span class="p">):</span>
<span class="n">form_builder</span> <span class="o">=</span> <span class="n">CustomFormBuilder</span>
<span class="c1">#... rest of the FormPage definition
</span>
</code></pre>
</div>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fhz9j1ujyvrh58o3y52on.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fhz9j1ujyvrh58o3y52on.png" alt="Form Page editor with the 'upload image' field type available"></a></p>
<h3>
4. Update form page template to accept File Data
</h3>
<p>The form page view should have a <code><form /></code> tag in it, the implementation suggested by Wagtail does not allow files data to be submitted in the form.<br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight html"><code><span class="c"><!-- templates/form_page.html --></span>
{% extends "base.html" %}
{% load wagtailcore_tags %}
{% block content %}
{{ self.intro }}
<span class="nt"><form</span> <span class="na">action=</span><span class="s">"{% pageurl self %}"</span> <span class="na">method=</span><span class="s">"POST"</span> <span class="na">enctype=</span><span class="s">"multipart/form-data"</span><span class="nt">></span>
{% csrf_token %}
{{ form.as_p }}
<span class="nt"><input</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="nt">/></span>
<span class="nt"></form></span>
{% endblock %}
</code></pre>
</div>
<p>The only difference to the basic form is that we have added <code>enctype="multipart/form-data"</code> to our form attributes. <strong>If you do not do this you will never get any files sent through the request and no errors to advise you why</strong>.</p>
<p>For more information about why we need to do this, you can view the <a href="proxy.php?url=https://docs.djangoproject.com/en/3.1/topics/http/file-uploads/#basic-file-uploads" rel="noopener noreferrer">Django Docs File Uploads</a> page and have a deep dive into the <a href="proxy.php?url=https://developer.mozilla.org/en-US/docs/Web/HTML/Element/form#attr-enctype" rel="noopener noreferrer">enctype form attribute on MDN</a>.</p>
<h3>
5. Add ability to select a collection for uploaded images
</h3>
<p>When uploading images via the admin interface, there is an option to add each image to a collection, this defaults to 'Root' and these act like folders for your images.</p>
<p>Rather than just dumping all uploaded images from form submissions into 'Root' we want to give the user the option to determine which <code>Collection</code> the images for each page form will be added to.<br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># models.py
</span>
<span class="kn">from</span> <span class="n">wagtail.admin.edit_handlers</span> <span class="kn">import</span> <span class="n">FieldPanel</span>
<span class="kn">from</span> <span class="n">wagtail.core.models</span> <span class="kn">import</span> <span class="n">Collection</span>
<span class="k">class</span> <span class="nc">FormPage</span><span class="p">(</span><span class="n">AbstractForm</span><span class="p">):</span>
<span class="n">form_builder</span> <span class="o">=</span> <span class="n">CustomFormBuilder</span>
<span class="c1"># other fields...
</span>
<span class="n">uploaded_image_collection</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">ForeignKey</span><span class="p">(</span>
<span class="sh">'</span><span class="s">wagtailcore.Collection</span><span class="sh">'</span><span class="p">,</span>
<span class="n">null</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
<span class="n">blank</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
<span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">SET_NULL</span><span class="p">,</span>
<span class="p">)</span>
<span class="c1"># content_panels...
</span>
<span class="n">settings_panels</span> <span class="o">=</span> <span class="n">AbstractForm</span><span class="p">.</span><span class="n">settings_panels</span> <span class="o">+</span> <span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">'</span><span class="s">uploaded_image_collection</span><span class="sh">'</span><span class="p">)</span>
<span class="p">]</span>
<span class="k">def</span> <span class="nf">get_uploaded_image_collection</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Returns a Wagtail Collection, using this form</span><span class="sh">'</span><span class="s">s saved value if present,
otherwise returns the </span><span class="sh">'</span><span class="s">Root</span><span class="sh">'</span><span class="s"> Collection.
</span><span class="sh">"""</span>
<span class="n">collection</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">uploaded_image_collection</span>
<span class="k">return</span> <span class="n">collection</span> <span class="ow">or</span> <span class="n">Collection</span><span class="p">.</span><span class="nf">get_first_root_node</span><span class="p">()</span>
</code></pre>
</div>
<p>In the code above we import the <code>Collections</code> model, added a new field to our <code>FormPage</code> model called <code>uploaded_image_collection</code> which is a <a href="proxy.php?url=https://docs.djangoproject.com/en/3.1/ref/models/fields/#django.db.models.ForeignKey" rel="noopener noreferrer">ForeignKey relation</a> to the <code>'wagtailCore.Collection'</code> model.</p>
<p>We also added a class method to retrieve this from the Page and fall back to the root collection via the <a href="proxy.php?url=https://django-treebeard.readthedocs.io/en/latest/api.html?#treebeard.models.Node.get_first_root_node" rel="noopener noreferrer">get_first_root_node</a> method (as Wagtail Collections use Treebeard to define a tree like structure).</p>
<p>Once this code step is completed, you will need to <a href="proxy.php?url=https://docs.djangoproject.com/en/3.1/ref/django-admin/#django-admin-makemigrations" rel="noopener noreferrer">make a migration, and run that migration</a>.</p>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2F20pkhcyuia1yv5u5addk.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2F20pkhcyuia1yv5u5addk.png" alt="Selecting an image collection via the Settings tab"></a></p>
<h3>
6. Process the Image (file) Data after Validation
</h3>
<p>We will now override the <code>process_form_submission</code> on our FormPage class. The original definition of the <a href="proxy.php?url=https://github.com/wagtail/wagtail/blob/v2.10.1/wagtail/contrib/forms/models.py#L260" rel="noopener noreferrer">process_form_submission method</a> has no notion of processing anything other than the <code>request.POST</code> data. It will simply convert the cleaned data to JSON for storing on the form submission instance. We will iterate through each field and find any instances of WagtailImageField then get the data, create a new Wagtail Image with that file data, finally we will store a link to the image in the response.<br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># models.py
</span>
<span class="kn">import</span> <span class="n">json</span>
<span class="kn">from</span> <span class="n">os.path</span> <span class="kn">import</span> <span class="n">splitext</span>
<span class="kn">from</span> <span class="n">django.core.serializers.json</span> <span class="kn">import</span> <span class="n">DjangoJSONEncoder</span>
<span class="kn">from</span> <span class="n">wagtail.images</span> <span class="kn">import</span> <span class="n">get_image_model</span>
<span class="k">class</span> <span class="nc">FormPage</span><span class="p">(</span><span class="n">AbstractForm</span><span class="p">):</span>
<span class="n">form_builder</span> <span class="o">=</span> <span class="n">CustomFormBuilder</span>
<span class="c1"># fields & panels definitions...
</span>
<span class="nd">@staticmethod</span>
<span class="k">def</span> <span class="nf">get_image_title</span><span class="p">(</span><span class="n">filename</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Generates a usable title from the filename of an image upload.
Note: The filename will be provided as a </span><span class="sh">'</span><span class="s">path/to/file.jpg</span><span class="sh">'</span><span class="s">
</span><span class="sh">"""</span>
<span class="k">if</span> <span class="n">filename</span><span class="p">:</span>
<span class="n">result</span> <span class="o">=</span> <span class="nf">splitext</span><span class="p">(</span><span class="n">filename</span><span class="p">)[</span><span class="mi">0</span><span class="p">]</span>
<span class="n">result</span> <span class="o">=</span> <span class="n">result</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sh">'</span><span class="s">-</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s"> </span><span class="sh">'</span><span class="p">).</span><span class="nf">replace</span><span class="p">(</span><span class="sh">'</span><span class="s">_</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s"> </span><span class="sh">'</span><span class="p">)</span>
<span class="k">return</span> <span class="n">result</span><span class="p">.</span><span class="nf">title</span><span class="p">()</span>
<span class="k">return</span> <span class="sh">''</span>
<span class="k">def</span> <span class="nf">process_form_submission</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">form</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Processes the form submission, if an Image upload is found, pull out the
files data, create an actual Wgtail Image and reference its ID only in the
stored form response.
</span><span class="sh">"""</span>
<span class="n">cleaned_data</span> <span class="o">=</span> <span class="n">form</span><span class="p">.</span><span class="n">cleaned_data</span>
<span class="k">for</span> <span class="n">name</span><span class="p">,</span> <span class="n">field</span> <span class="ow">in</span> <span class="n">form</span><span class="p">.</span><span class="n">fields</span><span class="p">.</span><span class="nf">items</span><span class="p">():</span>
<span class="k">if</span> <span class="nf">isinstance</span><span class="p">(</span><span class="n">field</span><span class="p">,</span> <span class="n">WagtailImageField</span><span class="p">):</span>
<span class="n">image_file_data</span> <span class="o">=</span> <span class="n">cleaned_data</span><span class="p">[</span><span class="n">name</span><span class="p">]</span>
<span class="k">if</span> <span class="n">image_file_data</span><span class="p">:</span>
<span class="n">ImageModel</span> <span class="o">=</span> <span class="nf">get_image_model</span><span class="p">()</span>
<span class="n">kwargs</span> <span class="o">=</span> <span class="p">{</span>
<span class="sh">'</span><span class="s">file</span><span class="sh">'</span><span class="p">:</span> <span class="n">cleaned_data</span><span class="p">[</span><span class="n">name</span><span class="p">],</span>
<span class="sh">'</span><span class="s">title</span><span class="sh">'</span><span class="p">:</span> <span class="n">self</span><span class="p">.</span><span class="nf">get_image_title</span><span class="p">(</span><span class="n">cleaned_data</span><span class="p">[</span><span class="n">name</span><span class="p">].</span><span class="n">name</span><span class="p">),</span>
<span class="sh">'</span><span class="s">collection</span><span class="sh">'</span><span class="p">:</span> <span class="n">self</span><span class="p">.</span><span class="nf">get_uploaded_image_collection</span><span class="p">(),</span>
<span class="p">}</span>
<span class="k">if</span> <span class="n">form</span><span class="p">.</span><span class="n">user</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">form</span><span class="p">.</span><span class="n">user</span><span class="p">.</span><span class="n">is_anonymous</span><span class="p">:</span>
<span class="n">kwargs</span><span class="p">[</span><span class="sh">'</span><span class="s">uploaded_by_user</span><span class="sh">'</span><span class="p">]</span> <span class="o">=</span> <span class="n">form</span><span class="p">.</span><span class="n">user</span>
<span class="n">image</span> <span class="o">=</span> <span class="nc">ImageModel</span><span class="p">(</span><span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
<span class="n">image</span><span class="p">.</span><span class="nf">save</span><span class="p">()</span>
<span class="c1"># saving the image id
</span> <span class="c1"># alternatively we can store a path to the image via image.get_rendition
</span> <span class="n">cleaned_data</span><span class="p">.</span><span class="nf">update</span><span class="p">({</span><span class="n">name</span><span class="p">:</span> <span class="n">image</span><span class="p">.</span><span class="n">pk</span><span class="p">})</span>
<span class="k">else</span><span class="p">:</span>
<span class="c1"># remove the value from the data
</span> <span class="k">del</span> <span class="n">cleaned_data</span><span class="p">[</span><span class="n">name</span><span class="p">]</span>
<span class="n">submission</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">get_submission_class</span><span class="p">().</span><span class="n">objects</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span>
<span class="n">form_data</span><span class="o">=</span><span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">(</span><span class="n">form</span><span class="p">.</span><span class="n">cleaned_data</span><span class="p">,</span> <span class="n">cls</span><span class="o">=</span><span class="n">DjangoJSONEncoder</span><span class="p">),</span> <span class="c1"># note: Wagtail 3.0 & beyond will no longer need to wrap this in json.dumps as it uses Django's JSONField under the hood now - https://docs.wagtail.org/en/stable/releases/3.0.html#replaced-form-data-textfield-with-jsonfield-in-abstractformsubmission
</span> <span class="n">page</span><span class="o">=</span><span class="n">self</span><span class="p">,</span>
<span class="p">)</span>
<span class="c1"># important: if extending AbstractEmailForm, email logic must be re-added here
</span> <span class="c1"># if self.to_address:
</span> <span class="c1"># self.send_mail(form)
</span>
<span class="k">return</span> <span class="n">submission</span>
</code></pre>
</div>
<p>Once this is applied, you should be able to submit a form response with an uploaded image.</p>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fcid5yluk7nx1wn4brye1.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fcid5yluk7nx1wn4brye1.png" alt="Published form with an upload image field"></a></p>
<p>A few items of note here:</p>
<ul>
<li>Using <a href="proxy.php?url=https://docs.wagtail.io/en/stable/advanced_topics/images/custom_image_model.html#module-wagtail.images" rel="noopener noreferrer"><code>get_image_model</code></a> is the best practice way to get the Image Model that Wagtail is using.</li>
<li>
<code>cleaned_data</code> contains the File Data (for any files), the Django form module does this for us. File Data cannot be parsed by the JSON parser, hence us having to process into a URL or Image ID for these cases.</li>
<li>The staticmethod <code>get_image_title</code> can look like whatever you want, I stripped out dashes and made the file title case. You do not have to do this but you do have to ensure there is some title when inserting a <code>WagtailImage</code>.</li>
<li>If our FormPage is actually extending <code>AbstractEmailForm</code> (ie. the form submits AND sends an email) we must ensure that the <a href="proxy.php?url=https://github.com/wagtail/wagtail/blob/v2.10.1/wagtail/contrib/forms/models.py#L342" rel="noopener noreferrer">send_mail code</a> is added.</li>
<li>You must use <code>cleaned_data.update</code> to save a JSON seralizable reference to your image, hence the file data will not work.</li>
</ul>
<h3>
7. Viewing the image via the form submissions listing
</h3>
<p>The final step is to provide a way for this image to be easily viewed in the submission listing view, we can do this by <a href="proxy.php?url=https://docs.wagtail.io/en/v2.10.1/reference/contrib/forms/customisation.html#customise-form-submissions-listing-in-wagtail-admin" rel="noopener noreferrer">customising how this list generates</a>.</p>
<p>We have stored an id of the image but we want to use <code>image.get_rendition</code>, which is a very useful function detailed in the <a href="proxy.php?url=https://docs.wagtail.io/en/v2.10.1/advanced_topics/images/renditions.html" rel="noopener noreferrer">Wagtail Documentation</a>. This function mimics the template helper but can be used in Python. By default the URL will be relative (it will not contain the http/https, or the domain), this will mean links sent to email will not work. It is up to you to work out how to best solve this if it is an issue.<br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># models.py
</span>
<span class="kn">from</span> <span class="n">django.utils.html</span> <span class="kn">import</span> <span class="n">format_html</span>
<span class="kn">from</span> <span class="n">django.urls</span> <span class="kn">import</span> <span class="n">reverse</span>
<span class="kn">from</span> <span class="n">wagtail.contrib.forms.views</span> <span class="kn">import</span> <span class="n">SubmissionsListView</span>
<span class="k">class</span> <span class="nc">CustomSubmissionsListView</span><span class="p">(</span><span class="n">SubmissionsListView</span><span class="p">):</span>
<span class="k">def</span> <span class="nf">get_context_data</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
<span class="n">context</span> <span class="o">=</span> <span class="nf">super</span><span class="p">().</span><span class="nf">get_context_data</span><span class="p">(</span><span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
<span class="k">if</span> <span class="ow">not</span> <span class="n">self</span><span class="p">.</span><span class="n">is_export</span><span class="p">:</span>
<span class="c1"># generate a list of field types, the first being the injected 'submission date'
</span> <span class="n">field_types</span> <span class="o">=</span> <span class="p">[</span><span class="sh">'</span><span class="s">submission_date</span><span class="sh">'</span><span class="p">]</span> <span class="o">+</span> <span class="p">[</span><span class="n">field</span><span class="p">.</span><span class="n">field_type</span> <span class="k">for</span> <span class="n">field</span> <span class="ow">in</span> <span class="n">self</span><span class="p">.</span><span class="n">form_page</span><span class="p">.</span><span class="nf">get_form_fields</span><span class="p">()]</span>
<span class="n">data_rows</span> <span class="o">=</span> <span class="n">context</span><span class="p">[</span><span class="sh">'</span><span class="s">data_rows</span><span class="sh">'</span><span class="p">]</span>
<span class="n">ImageModel</span> <span class="o">=</span> <span class="nf">get_image_model</span><span class="p">()</span>
<span class="k">for</span> <span class="n">data_row</span> <span class="ow">in</span> <span class="n">data_rows</span><span class="p">:</span>
<span class="n">fields</span> <span class="o">=</span> <span class="n">data_row</span><span class="p">[</span><span class="sh">'</span><span class="s">fields</span><span class="sh">'</span><span class="p">]</span>
<span class="k">for</span> <span class="n">idx</span><span class="p">,</span> <span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="n">field_type</span><span class="p">)</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="nf">zip</span><span class="p">(</span><span class="n">fields</span><span class="p">,</span> <span class="n">field_types</span><span class="p">)):</span>
<span class="k">if</span> <span class="n">field_type</span> <span class="o">==</span> <span class="sh">'</span><span class="s">image</span><span class="sh">'</span> <span class="ow">and</span> <span class="n">value</span><span class="p">:</span>
<span class="n">image</span> <span class="o">=</span> <span class="n">ImageModel</span><span class="p">.</span><span class="n">objects</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">pk</span><span class="o">=</span><span class="n">value</span><span class="p">)</span>
<span class="n">rendition</span> <span class="o">=</span> <span class="n">image</span><span class="p">.</span><span class="nf">get_rendition</span><span class="p">(</span><span class="sh">'</span><span class="s">fill-100x75|jpegquality-40</span><span class="sh">'</span><span class="p">)</span>
<span class="n">preview_url</span> <span class="o">=</span> <span class="n">rendition</span><span class="p">.</span><span class="n">url</span>
<span class="n">url</span> <span class="o">=</span> <span class="nf">reverse</span><span class="p">(</span><span class="sh">'</span><span class="s">wagtailimages:edit</span><span class="sh">'</span><span class="p">,</span> <span class="n">args</span><span class="o">=</span><span class="p">(</span><span class="n">image</span><span class="p">.</span><span class="nb">id</span><span class="p">,))</span>
<span class="c1"># build up a link to the image, using the image title & id
</span> <span class="n">fields</span><span class="p">[</span><span class="n">idx</span><span class="p">]</span> <span class="o">=</span> <span class="nf">format_html</span><span class="p">(</span>
<span class="sh">"</span><span class="s"><a href=</span><span class="sh">'</span><span class="s">{}</span><span class="sh">'</span><span class="s">><img alt=</span><span class="sh">'</span><span class="s">Uploaded image - {}</span><span class="sh">'</span><span class="s"> src=</span><span class="sh">'</span><span class="s">{}</span><span class="sh">'</span><span class="s"> />{} ({})</a></span><span class="sh">"</span><span class="p">,</span>
<span class="n">url</span><span class="p">,</span>
<span class="n">image</span><span class="p">.</span><span class="n">title</span><span class="p">,</span>
<span class="n">preview_url</span><span class="p">,</span>
<span class="n">image</span><span class="p">.</span><span class="n">title</span><span class="p">,</span>
<span class="n">value</span>
<span class="p">)</span>
<span class="k">return</span> <span class="n">context</span>
<span class="k">class</span> <span class="nc">FormPage</span><span class="p">(</span><span class="n">AbstractForm</span><span class="p">):</span>
<span class="n">form_builder</span> <span class="o">=</span> <span class="n">CustomFormBuilder</span>
<span class="n">submissions_list_view_class</span> <span class="o">=</span> <span class="n">CustomSubmissionsListView</span> <span class="c1"># added
</span>
</code></pre>
</div>
<p>In the code above we have added a new <code>CustomSubmissionsListView</code> that extends the Wagtail <code>SubmissionsListView</code> which will have a custom <code>get_context_data</code> method. In this method we call the original <a href="proxy.php?url=https://github.com/wagtail/wagtail/blob/v2.10.1/wagtail/contrib/forms/views.py#L249" rel="noopener noreferrer">get_context_data</a> to get the generated context data.</p>
<p>Then we check if we are showing the submissions to the user (instead of exporting them) and map through each submission row, checking with values are images and updating the shown value with some HTML. This HTML will contain a preview of the image (using rendition) with some description based on the title and id within a link to the admin page for that image.</p>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fg9kccvai289zqltw2nuv.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fg9kccvai289zqltw2nuv.png" alt="Submission listing view with image previews"></a></p>
<h2>
Finishing Up
</h2>
<p>Your Form models.py file will now look something like the following.</p>
<p>
<strong>Full code snippet</strong>
<br>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># models.py
</span>
<span class="kn">import</span> <span class="n">json</span>
<span class="kn">from</span> <span class="n">os.path</span> <span class="kn">import</span> <span class="n">splitext</span>
<span class="kn">from</span> <span class="n">django.core.serializers.json</span> <span class="kn">import</span> <span class="n">DjangoJSONEncoder</span>
<span class="kn">from</span> <span class="n">django.db</span> <span class="kn">import</span> <span class="n">models</span>
<span class="kn">from</span> <span class="n">django.utils.html</span> <span class="kn">import</span> <span class="n">format_html</span>
<span class="kn">from</span> <span class="n">django.urls</span> <span class="kn">import</span> <span class="n">reverse</span>
<span class="kn">from</span> <span class="n">modelcluster.fields</span> <span class="kn">import</span> <span class="n">ParentalKey</span>
<span class="kn">from</span> <span class="n">wagtail.admin.edit_handlers</span> <span class="kn">import</span> <span class="p">(</span>
<span class="n">FieldPanel</span><span class="p">,</span>
<span class="n">FieldRowPanel</span><span class="p">,</span>
<span class="n">InlinePanel</span><span class="p">,</span>
<span class="n">MultiFieldPanel</span><span class="p">,</span>
<span class="n">PageChooserPanel</span><span class="p">,</span>
<span class="n">StreamFieldPanel</span><span class="p">,</span>
<span class="p">)</span>
<span class="kn">from</span> <span class="n">wagtail.core.models</span> <span class="kn">import</span> <span class="n">Collection</span>
<span class="kn">from</span> <span class="n">wagtail.contrib.forms.forms</span> <span class="kn">import</span> <span class="n">FormBuilder</span>
<span class="kn">from</span> <span class="n">wagtail.contrib.forms.models</span> <span class="kn">import</span> <span class="n">AbstractForm</span><span class="p">,</span> <span class="n">AbstractFormField</span><span class="p">,</span> <span class="n">FORM_FIELD_CHOICES</span>
<span class="kn">from</span> <span class="n">wagtail.contrib.forms.views</span> <span class="kn">import</span> <span class="n">SubmissionsListView</span>
<span class="kn">from</span> <span class="n">wagtail.images</span> <span class="kn">import</span> <span class="n">get_image_model</span>
<span class="kn">from</span> <span class="n">wagtail.images.fields</span> <span class="kn">import</span> <span class="n">WagtailImageField</span>
<span class="k">class</span> <span class="nc">CustomFormBuilder</span><span class="p">(</span><span class="n">FormBuilder</span><span class="p">):</span>
<span class="k">def</span> <span class="nf">create_image_field</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">field</span><span class="p">,</span> <span class="n">options</span><span class="p">):</span>
<span class="k">return</span> <span class="nc">WagtailImageField</span><span class="p">(</span><span class="o">**</span><span class="n">options</span><span class="p">)</span>
<span class="k">class</span> <span class="nc">CustomSubmissionsListView</span><span class="p">(</span><span class="n">SubmissionsListView</span><span class="p">):</span>
<span class="k">def</span> <span class="nf">get_context_data</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="o">**</span><span class="n">kwargs</span><span class="p">):</span>
<span class="n">context</span> <span class="o">=</span> <span class="nf">super</span><span class="p">().</span><span class="nf">get_context_data</span><span class="p">(</span><span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
<span class="k">if</span> <span class="ow">not</span> <span class="n">self</span><span class="p">.</span><span class="n">is_export</span><span class="p">:</span>
<span class="c1"># generate a list of field types, the first being the injected 'submission date'
</span> <span class="n">field_types</span> <span class="o">=</span> <span class="p">[</span><span class="sh">'</span><span class="s">submission_date</span><span class="sh">'</span><span class="p">]</span> <span class="o">+</span> <span class="p">[</span><span class="n">field</span><span class="p">.</span><span class="n">field_type</span> <span class="k">for</span> <span class="n">field</span> <span class="ow">in</span> <span class="n">self</span><span class="p">.</span><span class="n">form_page</span><span class="p">.</span><span class="nf">get_form_fields</span><span class="p">()]</span>
<span class="n">data_rows</span> <span class="o">=</span> <span class="n">context</span><span class="p">[</span><span class="sh">'</span><span class="s">data_rows</span><span class="sh">'</span><span class="p">]</span>
<span class="n">ImageModel</span> <span class="o">=</span> <span class="nf">get_image_model</span><span class="p">()</span>
<span class="k">for</span> <span class="n">data_row</span> <span class="ow">in</span> <span class="n">data_rows</span><span class="p">:</span>
<span class="n">fields</span> <span class="o">=</span> <span class="n">data_row</span><span class="p">[</span><span class="sh">'</span><span class="s">fields</span><span class="sh">'</span><span class="p">]</span>
<span class="k">for</span> <span class="n">idx</span><span class="p">,</span> <span class="p">(</span><span class="n">value</span><span class="p">,</span> <span class="n">field_type</span><span class="p">)</span> <span class="ow">in</span> <span class="nf">enumerate</span><span class="p">(</span><span class="nf">zip</span><span class="p">(</span><span class="n">fields</span><span class="p">,</span> <span class="n">field_types</span><span class="p">)):</span>
<span class="k">if</span> <span class="n">field_type</span> <span class="o">==</span> <span class="sh">'</span><span class="s">image</span><span class="sh">'</span> <span class="ow">and</span> <span class="n">value</span><span class="p">:</span>
<span class="n">image</span> <span class="o">=</span> <span class="n">ImageModel</span><span class="p">.</span><span class="n">objects</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="n">pk</span><span class="o">=</span><span class="n">value</span><span class="p">)</span>
<span class="n">rendition</span> <span class="o">=</span> <span class="n">image</span><span class="p">.</span><span class="nf">get_rendition</span><span class="p">(</span><span class="sh">'</span><span class="s">fill-100x75|jpegquality-40</span><span class="sh">'</span><span class="p">)</span>
<span class="n">preview_url</span> <span class="o">=</span> <span class="n">rendition</span><span class="p">.</span><span class="n">url</span>
<span class="n">url</span> <span class="o">=</span> <span class="nf">reverse</span><span class="p">(</span><span class="sh">'</span><span class="s">wagtailimages:edit</span><span class="sh">'</span><span class="p">,</span> <span class="n">args</span><span class="o">=</span><span class="p">(</span><span class="n">image</span><span class="p">.</span><span class="nb">id</span><span class="p">,))</span>
<span class="c1"># build up a link to the image, using the image title & id
</span> <span class="n">fields</span><span class="p">[</span><span class="n">idx</span><span class="p">]</span> <span class="o">=</span> <span class="nf">format_html</span><span class="p">(</span>
<span class="sh">"</span><span class="s"><a href=</span><span class="sh">'</span><span class="s">{}</span><span class="sh">'</span><span class="s">><img alt=</span><span class="sh">'</span><span class="s">Uploaded image - {}</span><span class="sh">'</span><span class="s"> src=</span><span class="sh">'</span><span class="s">{}</span><span class="sh">'</span><span class="s"> />{} ({})</a></span><span class="sh">"</span><span class="p">,</span>
<span class="n">url</span><span class="p">,</span>
<span class="n">image</span><span class="p">.</span><span class="n">title</span><span class="p">,</span>
<span class="n">preview_url</span><span class="p">,</span>
<span class="n">image</span><span class="p">.</span><span class="n">title</span><span class="p">,</span>
<span class="n">value</span>
<span class="p">)</span>
<span class="k">return</span> <span class="n">context</span>
<span class="k">class</span> <span class="nc">FormField</span><span class="p">(</span><span class="n">AbstractFormField</span><span class="p">):</span>
<span class="n">field_type</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">CharField</span><span class="p">(</span>
<span class="n">verbose_name</span><span class="o">=</span><span class="sh">'</span><span class="s">field type</span><span class="sh">'</span><span class="p">,</span>
<span class="n">max_length</span><span class="o">=</span><span class="mi">16</span><span class="p">,</span>
<span class="n">choices</span><span class="o">=</span><span class="nf">list</span><span class="p">(</span><span class="n">FORM_FIELD_CHOICES</span><span class="p">)</span> <span class="o">+</span> <span class="p">[(</span><span class="sh">'</span><span class="s">image</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">Upload Image</span><span class="sh">'</span><span class="p">)]</span>
<span class="p">)</span>
<span class="n">page</span> <span class="o">=</span> <span class="nc">ParentalKey</span><span class="p">(</span><span class="sh">'</span><span class="s">FormPage</span><span class="sh">'</span><span class="p">,</span> <span class="n">related_name</span><span class="o">=</span><span class="sh">'</span><span class="s">form_fields</span><span class="sh">'</span><span class="p">,</span> <span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">CASCADE</span><span class="p">)</span>
<span class="k">class</span> <span class="nc">FormPage</span><span class="p">(</span><span class="n">AbstractForm</span><span class="p">):</span>
<span class="n">form_builder</span> <span class="o">=</span> <span class="n">CustomFormBuilder</span>
<span class="n">submissions_list_view_class</span> <span class="o">=</span> <span class="n">CustomSubmissionsListView</span>
<span class="c1"># ... fields
</span>
<span class="n">uploaded_image_collection</span> <span class="o">=</span> <span class="n">models</span><span class="p">.</span><span class="nc">ForeignKey</span><span class="p">(</span>
<span class="sh">'</span><span class="s">wagtailcore.Collection</span><span class="sh">'</span><span class="p">,</span>
<span class="n">null</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
<span class="n">blank</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span>
<span class="n">on_delete</span><span class="o">=</span><span class="n">models</span><span class="p">.</span><span class="n">SET_NULL</span><span class="p">,</span>
<span class="p">)</span>
<span class="n">content_panels</span> <span class="o">=</span> <span class="n">AbstractForm</span><span class="p">.</span><span class="n">content_panels</span> <span class="o">+</span> <span class="p">[</span>
<span class="c1"># ... panels
</span> <span class="p">]</span>
<span class="n">settings_panels</span> <span class="o">=</span> <span class="n">AbstractForm</span><span class="p">.</span><span class="n">settings_panels</span> <span class="o">+</span> <span class="p">[</span>
<span class="nc">FieldPanel</span><span class="p">(</span><span class="sh">'</span><span class="s">uploaded_image_collection</span><span class="sh">'</span><span class="p">)</span>
<span class="p">]</span>
<span class="k">def</span> <span class="nf">get_uploaded_image_collection</span><span class="p">(</span><span class="n">self</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Returns a Wagtail Collection, using this form</span><span class="sh">'</span><span class="s">s saved value if present,
otherwise returns the </span><span class="sh">'</span><span class="s">Root</span><span class="sh">'</span><span class="s"> Collection.
</span><span class="sh">"""</span>
<span class="n">collection</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="n">uploaded_image_collection</span>
<span class="k">return</span> <span class="n">collection</span> <span class="ow">or</span> <span class="n">Collection</span><span class="p">.</span><span class="nf">get_first_root_node</span><span class="p">()</span>
<span class="nd">@staticmethod</span>
<span class="k">def</span> <span class="nf">get_image_title</span><span class="p">(</span><span class="n">filename</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Generates a usable title from the filename of an image upload.
Note: The filename will be provided as a </span><span class="sh">'</span><span class="s">path/to/file.jpg</span><span class="sh">'</span><span class="s">
</span><span class="sh">"""</span>
<span class="k">if</span> <span class="n">filename</span><span class="p">:</span>
<span class="n">result</span> <span class="o">=</span> <span class="nf">splitext</span><span class="p">(</span><span class="n">filename</span><span class="p">)[</span><span class="mi">0</span><span class="p">]</span>
<span class="n">result</span> <span class="o">=</span> <span class="n">result</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sh">'</span><span class="s">-</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s"> </span><span class="sh">'</span><span class="p">).</span><span class="nf">replace</span><span class="p">(</span><span class="sh">'</span><span class="s">_</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s"> </span><span class="sh">'</span><span class="p">)</span>
<span class="k">return</span> <span class="n">result</span><span class="p">.</span><span class="nf">title</span><span class="p">()</span>
<span class="k">return</span> <span class="sh">''</span>
<span class="k">def</span> <span class="nf">process_form_submission</span><span class="p">(</span><span class="n">self</span><span class="p">,</span> <span class="n">form</span><span class="p">):</span>
<span class="sh">"""</span><span class="s">
Processes the form submission, if an Image upload is found, pull out the
files data, create an actual Wgtail Image and reference its ID only in the
stored form response.
</span><span class="sh">"""</span>
<span class="n">cleaned_data</span> <span class="o">=</span> <span class="n">form</span><span class="p">.</span><span class="n">cleaned_data</span>
<span class="k">for</span> <span class="n">name</span><span class="p">,</span> <span class="n">field</span> <span class="ow">in</span> <span class="n">form</span><span class="p">.</span><span class="n">fields</span><span class="p">.</span><span class="nf">items</span><span class="p">():</span>
<span class="k">if</span> <span class="nf">isinstance</span><span class="p">(</span><span class="n">field</span><span class="p">,</span> <span class="n">WagtailImageField</span><span class="p">):</span>
<span class="n">image_file_data</span> <span class="o">=</span> <span class="n">cleaned_data</span><span class="p">[</span><span class="n">name</span><span class="p">]</span>
<span class="k">if</span> <span class="n">image_file_data</span><span class="p">:</span>
<span class="n">ImageModel</span> <span class="o">=</span> <span class="nf">get_image_model</span><span class="p">()</span>
<span class="n">kwargs</span> <span class="o">=</span> <span class="p">{</span>
<span class="sh">'</span><span class="s">file</span><span class="sh">'</span><span class="p">:</span> <span class="n">cleaned_data</span><span class="p">[</span><span class="n">name</span><span class="p">],</span>
<span class="sh">'</span><span class="s">title</span><span class="sh">'</span><span class="p">:</span> <span class="n">self</span><span class="p">.</span><span class="nf">get_image_title</span><span class="p">(</span><span class="n">cleaned_data</span><span class="p">[</span><span class="n">name</span><span class="p">].</span><span class="n">name</span><span class="p">),</span>
<span class="sh">'</span><span class="s">collection</span><span class="sh">'</span><span class="p">:</span> <span class="n">self</span><span class="p">.</span><span class="nf">get_uploaded_image_collection</span><span class="p">(),</span>
<span class="p">}</span>
<span class="k">if</span> <span class="n">form</span><span class="p">.</span><span class="n">user</span> <span class="ow">and</span> <span class="ow">not</span> <span class="n">form</span><span class="p">.</span><span class="n">user</span><span class="p">.</span><span class="n">is_anonymous</span><span class="p">:</span>
<span class="n">kwargs</span><span class="p">[</span><span class="sh">'</span><span class="s">uploaded_by_user</span><span class="sh">'</span><span class="p">]</span> <span class="o">=</span> <span class="n">form</span><span class="p">.</span><span class="n">user</span>
<span class="n">image</span> <span class="o">=</span> <span class="nc">ImageModel</span><span class="p">(</span><span class="o">**</span><span class="n">kwargs</span><span class="p">)</span>
<span class="n">image</span><span class="p">.</span><span class="nf">save</span><span class="p">()</span>
<span class="c1"># saving the image id
</span> <span class="c1"># alternatively we can store a path to the image via image.get_rendition
</span> <span class="n">cleaned_data</span><span class="p">.</span><span class="nf">update</span><span class="p">({</span><span class="n">name</span><span class="p">:</span> <span class="n">image</span><span class="p">.</span><span class="n">pk</span><span class="p">})</span>
<span class="k">else</span><span class="p">:</span>
<span class="c1"># remove the value from the data
</span> <span class="k">del</span> <span class="n">cleaned_data</span><span class="p">[</span><span class="n">name</span><span class="p">]</span>
<span class="n">submission</span> <span class="o">=</span> <span class="n">self</span><span class="p">.</span><span class="nf">get_submission_class</span><span class="p">().</span><span class="n">objects</span><span class="p">.</span><span class="nf">create</span><span class="p">(</span>
<span class="n">form_data</span><span class="o">=</span><span class="n">json</span><span class="p">.</span><span class="nf">dumps</span><span class="p">(</span><span class="n">form</span><span class="p">.</span><span class="n">cleaned_data</span><span class="p">,</span> <span class="n">cls</span><span class="o">=</span><span class="n">DjangoJSONEncoder</span><span class="p">),</span>
<span class="n">page</span><span class="o">=</span><span class="n">self</span><span class="p">,</span>
<span class="p">)</span>
<span class="c1"># important: if extending AbstractEmailForm, email logic must be re-added here
</span> <span class="c1"># if self.to_address:
</span> <span class="c1"># self.send_mail(form)
</span>
<span class="k">return</span> <span class="n">submission</span>
</code></pre>
</div>
</p>
<p>Forms can now have one or more Image Upload fields that are defined by the CMS editors. These images will be available in Admin in the Images section and can be used throughout the rest of Wagtail. You also get all the benefits that come with Wagtail Images like search indexing, usage in templates and URLS for images of various compressed sizes.</p>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fcid5yluk7nx1wn4brye1.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fi%2Fcid5yluk7nx1wn4brye1.png" alt="Published form with an upload image field"></a></p>
<p>The Admin view of form responses will now show whatever you store from the <code>clean_data</code>.</p>
<p>Let me know if you run into issues or find some typos/bugs in this article. Thank you to the amazing team at Torchbox and all the developers of Wagtail for making this amazing tool. Show your support of Wagtail by starring the <a href="proxy.php?url=https://github.com/wagtail/wagtail/" rel="noopener noreferrer">Wagtail repo on Github</a>.</p>
<p>You can see all the original code changes in Github via the <a href="proxy.php?url=https://github.com/lb-/bakerydemo/tree/blog/image-uploads-in-wagtail-forms-2020" rel="noopener noreferrer">image-uploads branch</a>.</p>
<p>One suggestion from the comments: It's a good idea to add a <code>post_delete</code> signal on the form submission model. So that when you delete an entry from the submission list, it will also delete the uploaded image.</p>
<p>Thanks to my friend Adam for helping me proof this.</p>
wagtailpythondjangoAdding a React component in Wagtail AdminLB (Ben Johnston)Thu, 12 Dec 2019 21:28:39 +0000
https://dev.to/lb/adding-a-react-component-in-wagtail-admin-3e
https://dev.to/lb/adding-a-react-component-in-wagtail-admin-3e<p>I’m a full-stack developer and a member of the core team for <a href="proxy.php?url=https://wagtail.io/" rel="noopener noreferrer">Wagtail</a>, the open-source CMS built on top of Django. I also work full time for <a href="proxy.php?url=https://www.virgin.com/company/virgin-australia" rel="noopener noreferrer">Virgin Australia</a> as a front end developer.</p>
<p><strong>Wagtail uses React in parts of its admin, so it should be pretty straightforward to add a custom React component right?</strong></p>
<p>A few months ago I was doing some investigating for a project at work and found this awesome React timeline component, <a href="proxy.php?url=https://github.com/namespace-ee/react-calendar-timeline" rel="noopener noreferrer">React Calendar Timeline</a>. React Calendar Tiemline is a fully interactive timeline component that lets you do anything, from simply viewing a timeline through to complete interaction, such as dragging & dropping to move items around the timeline. This timeline component is really well put together and appears to be actively maintained and improved by the team at <a href="proxy.php?url=https://namespace.ee/" rel="noopener noreferrer">Namespace</a>.</p>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnamespace-ee%2Freact-calendar-timeline%2Fmaster%2Fdemo.gif" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fraw.githubusercontent.com%2Fnamespace-ee%2Freact-calendar-timeline%2Fmaster%2Fdemo.gif" alt="React Calendar Timeline Demo"></a></p>
<p>I thought it would be great to be able to visualise and eventually control key Wagtail Page events such as updates and publishing dates.</p>
<p>The article below is 80% tutorial and 20% journey of the frustrations and fun in working with React in a slightly non-standard way. Some of this will apply to Django development as Wagtail is essentially just Django.</p>
<h2>
Step 1 - Define The Goal & Constraints
</h2>
<ul>
<li>We want to incorporate a single React component into Wagtail's Admin.</li>
<li>We want to leverage the existing React library that comes with Wagtail Admin along with the existing sidebar, page title, search and messaging structure that Wagtail uses, so it feels like Wagtail.</li>
<li>We want our development environment to be easy to use so we can leverage the npm ecosystem.</li>
<li>We want a build output that is simple to integrate with an existing Django/Wagtail project.</li>
</ul>
<p><strong>Goal: Add a single page within the Wagtail Admin that looks like a normal page but uses the React Calendar Timeline component to render a timeline of published pages.</strong></p>
<h2>
Step 2 - Set up a new Django App & Wagtail Admin Page
</h2>
<p><strong>Important</strong> If you do not have an existing Wagtail project running locally, please follow the <a href="proxy.php?url=http://docs.wagtail.io/en/v2.7/getting_started/tutorial.html" rel="noopener noreferrer">Wagtail Getting Started</a> guide.</p>
<ul>
<li><p><em>Note:</em> We will leverage the <a href="proxy.php?url=https://github.com/wagtail/bakerydemo" rel="noopener noreferrer">Wagtail Bakery Demo</a> for this tutorial.</p></li>
<li><p>Create a Django App named timeline - this can be done quickly via the <a href="proxy.php?url=https://docs.djangoproject.com/en/2.2/ref/django-admin/#startapp" rel="noopener noreferrer">django-admin command</a> <code>./manage.py startapp timeline</code></p></li>
<li><p>Update your settings to include this app by adding to the <code>INSTALLED_APPS</code> list.</p></li>
<li><p>Reminder: When updating settings, you will need to restart Django for the changes to take effect.</p></li>
<li><p>Create a simple 'timeline' view and template that simply render a header and content. We will use some of the existing admin template includes, these are not all documented but looking at the Wagtail code can help us discover what's available.</p></li>
<li><p>Create a <a href="proxy.php?url=http://docs.wagtail.io/en/v2.7/reference/hooks.html" rel="noopener noreferrer"><code>wagtail_hooks.py</code></a> file to register the timeline view as an admin URL (via the hook <code>register_admin_urls</code>) and also to add a link to the admin settings menu via the hook <code>register_admin_menu_item</code>.</p></li>
<li><p>Code snippets below.<br>
</p></li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight html"><code>{% comment %} timeline/templates/timeline.html {% endcomment %}
{% extends "wagtailadmin/base.html" %}
{% load static %}
{% block titletag %}{{ title }}{% endblock %}
{% block bodyclass %}timeline{% endblock %}
{% block content %}
{% include "wagtailadmin/shared/header.html" with title=title %}
<span class="nt"><div</span> <span class="na">class=</span><span class="s">"container"</span><span class="nt">></span>
<span class="nt"><div</span> <span class="na">id=</span><span class="s">"timeline"</span><span class="nt">></span>
{{ title }}
<span class="nt"></div></span>
<span class="nt"></div></span>
{% endblock %}
</code></pre>
</div>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># timeline/views.py
</span>
<span class="kn">from</span> <span class="n">django.shortcuts</span> <span class="kn">import</span> <span class="n">render</span>
<span class="k">def</span> <span class="nf">timeline_view</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
<span class="k">return</span> <span class="nf">render</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="sh">"</span><span class="s">timeline.html</span><span class="sh">"</span><span class="p">,</span> <span class="p">{</span>
<span class="sh">'</span><span class="s">title</span><span class="sh">'</span><span class="p">:</span> <span class="sh">'</span><span class="s">Timeline</span><span class="sh">'</span><span class="p">,</span>
<span class="p">})</span>
</code></pre>
</div>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># timeline/wagtail_hooks.py
</span>
<span class="kn">from</span> <span class="n">django.conf.urls</span> <span class="kn">import</span> <span class="n">url</span>
<span class="kn">from</span> <span class="n">django.urls</span> <span class="kn">import</span> <span class="n">reverse</span>
<span class="kn">from</span> <span class="n">wagtail.admin.menu</span> <span class="kn">import</span> <span class="n">MenuItem</span>
<span class="kn">from</span> <span class="n">wagtail.core</span> <span class="kn">import</span> <span class="n">hooks</span>
<span class="kn">from</span> <span class="n">.views</span> <span class="kn">import</span> <span class="n">timeline_view</span>
<span class="nd">@hooks.register</span><span class="p">(</span><span class="sh">'</span><span class="s">register_admin_urls</span><span class="sh">'</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">urlconf_time</span><span class="p">():</span>
<span class="k">return</span> <span class="p">[</span>
<span class="nf">url</span><span class="p">(</span><span class="sa">r</span><span class="sh">'</span><span class="s">^timeline/$</span><span class="sh">'</span><span class="p">,</span> <span class="n">timeline_view</span><span class="p">,</span> <span class="n">name</span><span class="o">=</span><span class="sh">'</span><span class="s">timeline</span><span class="sh">'</span><span class="p">),</span>
<span class="p">]</span>
<span class="nd">@hooks.register</span><span class="p">(</span><span class="sh">'</span><span class="s">register_admin_menu_item</span><span class="sh">'</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">register_timeline_menu_item</span><span class="p">():</span>
<span class="k">return</span> <span class="nc">MenuItem</span><span class="p">(</span>
<span class="sh">'</span><span class="s">Timeline</span><span class="sh">'</span><span class="p">,</span>
<span class="nf">reverse</span><span class="p">(</span><span class="sh">'</span><span class="s">timeline</span><span class="sh">'</span><span class="p">),</span>
<span class="n">classnames</span><span class="o">=</span><span class="sh">'</span><span class="s">icon icon-time</span><span class="sh">'</span><span class="p">,</span>
<span class="n">order</span><span class="o">=</span><span class="mi">10000</span> <span class="c1"># very last
</span> <span class="p">)</span>
</code></pre>
</div>
<h2>
Step 3 - Add an in-line basic React Component
</h2>
<p>Here we want to simply confirm we can get <em>something</em> rendering with React, using the global React object provided by Wagtail Admin.</p>
<ul>
<li>Add a small Javascript script tag that will render a simple React Component. This will use the <a href="proxy.php?url=https://reactjs.org/docs/react-dom.html#render" rel="noopener noreferrer"><code>ReactDOM.render</code></a> and <a href="proxy.php?url=https://reactjs.org/docs/react-api.html#createelement" rel="noopener noreferrer"><code>React.createElement</code></a> functions.</li>
<li>Remember: As this code is not transpiled, we're unable to use the more-familiar JSX syntax, and need to consider what features the target browsers support, for example, we can't use arrow functions here as they aren't supported by IE11.</li>
<li>Save the changes to the template, refresh the view and you should see the text <code>TIMELINE CONTENT HERE</code> visible.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight html"><code>
{% block extra_js %}
{{ block.super }}
<span class="nt"><script></span>
<span class="c1">// templates/timeline/timeline.html</span>
<span class="nb">document</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">DOMContentLoaded</span><span class="dl">'</span><span class="p">,</span> <span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
<span class="nx">ReactDOM</span><span class="p">.</span><span class="nf">render</span><span class="p">(</span>
<span class="nx">React</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span>
<span class="dl">'</span><span class="s1">div</span><span class="dl">'</span><span class="p">,</span>
<span class="p">{</span>
<span class="na">children</span><span class="p">:</span> <span class="dl">'</span><span class="s1">TIMELINE CONTENT HERE</span><span class="dl">'</span><span class="p">,</span>
<span class="na">className</span><span class="p">:</span> <span class="dl">'</span><span class="s1">timeline-content</span><span class="dl">'</span>
<span class="p">}</span>
<span class="p">),</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">timeline</span><span class="dl">'</span><span class="p">));</span>
<span class="p">});</span>
<span class="nt"></script></span>
{% endblock %}
</code></pre>
</div>
<h2>
Step 4 - Use a React toolkit to build a React Component
</h2>
<p><strong>Story Time</strong></p>
<ul>
<li>Initially, I tried to use <a href="proxy.php?url=https://create-react-app.dev/" rel="noopener noreferrer">create-react-app</a> as this has worked great for me in the past. However, it did not take long for me to realise that this was not really the right tool for what we were doing. Firstly - this is not a <a href="proxy.php?url=https://en.wikipedia.org/wiki/Single-page_application" rel="noopener noreferrer">single page app</a>, this is an existing Django application that we want to integrate a stand-alone React component within a subset of the view.</li>
<li>I did not want to eject and start to dig into Webpack configuration if I could avoid it so I went exploring.</li>
<li>I found that what I was looking for is called a 'React Toolkit' (knowing the right term helps with the Googles) and found lots of lists, even some on the create-react-app documentation.</li>
<li>After trying a bunch quickly, I landed two great solutions, <a href="proxy.php?url=https://github.com/insin/nwb" rel="noopener noreferrer"><strong>nwb</strong></a> and <a href="proxy.php?url=https://neutrinojs.org/" rel="noopener noreferrer"><strong>neutrinojs</strong></a>.</li>
<li>As seems to be the case when wanting to use something open source in the Javascript ecosystem, both of these libraries were in varying states of being "production ready".</li>
<li>
<code>nwb</code> was easy to get started with but the lack of updates over the past few months made it feel like it might not receive regular maintenance.</li>
<li>
<code>neutrinojs</code> was the opposite, being by a team at Mozilla, it has had a massive number of updates but of course all of these were for the version 9 release candidate but the docs were for version 8.</li>
<li>I ended up doing almost all of this tutorial in both nwb and neutrinojs and found that neutrinojs ended up being my pick. The documentation is more complete and overall it appears to be more flexible and requires only slightly more "config" to get working compared to nwb.</li>
<li>I will put links at the end of this post for the roughly working code branch where nwb was used.</li>
</ul>
<p><strong>Code Time</strong></p>
<ul>
<li>Set up a <code>client</code> app within the Django <code>timeline</code> app, this approach means we will have a client folder within the timeline folder. There are many ways to organise your JS/CSS within a Django app so do whatever works for you.</li>
<li>Important: We will be using the version 9 release candidate, there are a few reasons for this decision. But essentially it's <a href="proxy.php?url=https://github.com/neutrinojs/neutrino/issues/1129" rel="noopener noreferrer">better</a> and will hopefully make the shelf life of this post a bit longer. As of the time of writing, the docs for version 9 can be found here - <a href="proxy.php?url=https://master.neutrinojs.org/" rel="noopener noreferrer">https://master.neutrinojs.org/</a>.</li>
<li>In the <code>timeline</code> folder run the command <code>npx @neutrinojs/create-project@next client</code>. This creates a new folder, named <code>client</code>, with the scaffolded project.</li>
<li>The scaffold CLI is really hhelpful, here are the answers to the questions:
<ul>
<li>First up, what would you like to create? <strong>Components</strong>
</li>
<li>Next, what kind of components would you like to create? <strong>React Components</strong>
</li>
<li>Would you like to add a test runner to your project? <strong>Jest</strong>
</li>
<li>Would you like to add linting to your project? <strong>Airbnb style rules</strong>
</li>
</ul>
</li>
<li>Test out the local dev server run <code>npm start</code> from the client folder and you should see the demo component load in your browser at <code><a href="proxy.php?url=http://localhost:5000/" rel="noopener noreferrer">http://localhost:5000/</a></code>
</li>
<li>Add styles - add a <code>style.css</code> file to the example component folder - <code>client/src/components/Example/style.css</code> and import it in the component <code>client/src/components/Example/index.jsx</code>. Plain CSS works out of the box and can be imported using <code>import './style.css';</code>. Adding a trivial rule to the CSS such as <code>button { background: lightblue; }</code> allows us to test that the styles have been imported correctly.</li>
<li>Save the changes and confirm that the styles have been imported and used in the client demo server by opening <a href="proxy.php?url=http://localhost:5000/" rel="noopener noreferrer"></a><a href="proxy.php?url=http://localhost:5000/" rel="noopener noreferrer">http://localhost:5000/</a>.</li>
</ul>
<h2>
Step 5 - Render the Example component in the Django view.
</h2>
<p><strong>Story Time</strong></p>
<ul>
<li>This step took the most amount of time to work out, literally days of trying things, coming back to it, switching back to nwb and then encountering similar but still frustrating issues and switching back.</li>
<li>I ended up having to dig into the internals of Neutrino, nwb, Webpack and a tricksy little library called <code>webpack-node-externals</code>.</li>
<li>The major disconnect here is that we are building this in a bit of a blurry world, in terms of what common requirements are expected.</li>
<li>Toolkits, plugins, Webpack, etc make a lot of assumptions and those are that you will be building something that is either a library (ie. publish to npm and it is imported/required into your project) or a SPA (you want to build EVERYTHING you need to get this app running with nothing but a bare index.html file).</li>
<li>On top of that, my knowledge about any ends of this spectrum was limited.</li>
<li>
<code>webpack-node-externals</code> is used by default in a lot of build tools and makes the hard assumption that ANY import is external. Which makes sense when you want to build a small NPM utility that depends on lodash and leftpad. You really do not want to bundle these with your library.</li>
<li>This makes sense in terms of a common use case of Neutrino js - to output a small bundle of a 'component' without needing React and the whole universe alongside.</li>
<li>The other issue is that we actually do not want to bundle everything, only some things. We do not want to bundle React with this build output either as we know it is available in Django as a global that is already imported.</li>
<li>Thankfully Webpack is pretty amazing and lets you configure all the things including this exact scenario - which things are bundled and which things are not (along with a plethora of config about how those things are available to the build file). You can read more here <a href="proxy.php?url=https://webpack.js.org/configuration/externals/" rel="noopener noreferrer">https://webpack.js.org/configuration/externals/#externals</a>.</li>
<li>So with that rant out of the way, let's get to the one line of code that took so long.</li>
</ul>
<p><strong>Code Time</strong></p>
<ul>
<li>Configure neutrinojs to use the global <code>React</code> instead of importing/requiring it. We add one more function after <code>jest()</code> that will determine if the build is for production and then revise part of the config accordingly.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="c1">// timeline/client/.neutrinorc.js</span>
<span class="kd">const</span> <span class="nx">airbnb</span> <span class="o">=</span> <span class="nf">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">@neutrinojs/airbnb</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">reactComponents</span> <span class="o">=</span> <span class="nf">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">@neutrinojs/react-components</span><span class="dl">'</span><span class="p">);</span>
<span class="kd">const</span> <span class="nx">jest</span> <span class="o">=</span> <span class="nf">require</span><span class="p">(</span><span class="dl">'</span><span class="s1">@neutrinojs/jest</span><span class="dl">'</span><span class="p">);</span>
<span class="nx">module</span><span class="p">.</span><span class="nx">exports</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">options</span><span class="p">:</span> <span class="p">{</span>
<span class="na">root</span><span class="p">:</span> <span class="nx">__dirname</span><span class="p">,</span>
<span class="p">},</span>
<span class="na">use</span><span class="p">:</span> <span class="p">[</span>
<span class="nf">airbnb</span><span class="p">(),</span>
<span class="nf">reactComponents</span><span class="p">(),</span>
<span class="nf">jest</span><span class="p">(),</span>
<span class="cm">/**
* Ensure that react is read from global - and webpack-node-externals is NOT used.
*
* By default the react-components plugin uses webpack-node-externals to build
* the externals object. This will simply get all dependencies and assume they are
* external AND assume that requirejs is used.
*
* However, for a web usage, we want only some external dependencies set up and
* want them to read from global (aka root), hence we map the 'react' import to 'React' global.
* See:
*
* https://www.npmjs.com/package/webpack-node-externals
* https://webpack.js.org/configuration/externals/#externals
*/</span>
<span class="nx">neutrino</span> <span class="o">=></span> <span class="p">{</span>
<span class="nx">neutrino</span><span class="p">.</span><span class="nx">config</span><span class="p">.</span><span class="nf">when</span><span class="p">(</span><span class="nx">process</span><span class="p">.</span><span class="nx">env</span><span class="p">.</span><span class="nx">NODE_ENV</span> <span class="o">===</span> <span class="dl">'</span><span class="s1">production</span><span class="dl">'</span><span class="p">,</span> <span class="nx">config</span> <span class="o">=></span> <span class="p">{</span>
<span class="nx">config</span><span class="p">.</span><span class="nf">externals</span><span class="p">({</span> <span class="na">react</span><span class="p">:</span> <span class="dl">'</span><span class="s1">React</span><span class="dl">'</span> <span class="p">});</span>
<span class="p">});</span>
<span class="p">},</span>
<span class="p">],</span>
<span class="p">};</span>
</code></pre>
</div>
<ul>
<li>Update the Django settings to have access to this folder as a static assets folder. (Note: We can configure neutrinojs to build to any folder, but this is the simplest way forward for now).
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="n">STATICFILES_DIRS</span> <span class="o">=</span> <span class="p">[</span>
<span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">PROJECT_DIR</span><span class="p">,</span> <span class="sh">'</span><span class="s">static</span><span class="sh">'</span><span class="p">),</span>
<span class="n">os</span><span class="p">.</span><span class="n">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="n">PROJECT_DIR</span><span class="p">,</span> <span class="sh">'</span><span class="s">timeline/client/build</span><span class="sh">'</span><span class="p">),</span> <span class="c1"># add the default neutrino.js 'build' folder
</span><span class="p">]</span>
</code></pre>
</div>
<ul>
<li>Now run the build output via <code>npm run build</code> and note that there is now a client/build folder with four files (Example.js, Example.css and a .map file for each).</li>
<li>Finally, update our Django template to import the Example.js and Example.css for the example component rendering. We will add the <code>extra_css</code> section to import the static file <code>Example.css</code> and add the <code>script</code> tag to import Example.js and update the <code>createElement</code> function to use <code>Example.default</code>
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight html"><code>{% extends "wagtailadmin/base.html" %}
{% comment %} timeline/templates/timeline.html {% endcomment %}
{% load static %}
{% block titletag %}{{ title }}{% endblock %}
{% block bodyclass %}timeline{% endblock %}
{% block extra_css %}
{{ block.super }}
<span class="nt"><link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">type=</span><span class="s">"text/css"</span> <span class="na">href=</span><span class="s">"{% static 'Example.css' %}"</span><span class="nt">></span>
{% endblock %}
{% block extra_js %}
{{ block.super }}
<span class="nt"><script </span><span class="na">src=</span><span class="s">"{% static 'Example.js' %}"</span><span class="nt">></script></span>
<span class="nt"><script></span>
<span class="nb">document</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">DOMContentLoaded</span><span class="dl">'</span><span class="p">,</span> <span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
<span class="nx">ReactDOM</span><span class="p">.</span><span class="nf">render</span><span class="p">(</span>
<span class="nx">React</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span>
<span class="nx">Example</span><span class="p">.</span><span class="k">default</span><span class="p">,</span> <span class="c1">// note - using .default here as this is how the global is set up</span>
<span class="p">{</span>
<span class="na">children</span><span class="p">:</span> <span class="dl">'</span><span class="s1">TIMELINE CONTENT HERE</span><span class="dl">'</span><span class="p">,</span>
<span class="na">className</span><span class="p">:</span> <span class="dl">'</span><span class="s1">timeline-content</span><span class="dl">'</span>
<span class="p">}</span>
<span class="p">),</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">timeline</span><span class="dl">'</span><span class="p">));</span>
<span class="p">});</span>
<span class="nt"></script></span>
{% endblock %}
{% block content %}
{% include "wagtailadmin/shared/header.html" with title=title %}
<span class="nt"><div</span> <span class="na">class=</span><span class="s">"container"</span><span class="nt">></span>
<span class="nt"><div</span> <span class="na">id=</span><span class="s">"timeline"</span><span class="nt">></span>
{{ title }}
<span class="nt"></div></span>
<span class="nt"></div></span>
{% endblock %}
</code></pre>
</div>
<ul>
<li>Save changes, refresh your Django dev server and check the Example component is rendered.</li>
</ul>
<h2>
Step 6 - Development Workflow
</h2>
<ul>
<li>Just a recap, we now have <strong>two</strong> dev servers.</li>
</ul>
<p><strong>client</strong></p>
<ul>
<li>Run by Neutrino, using <code>npm start</code> and avaialable at <code>http://localhost:5000/</code>.</li>
<li>This server has no awareness of Django and is purely a way to quickly work with your React client code.</li>
<li>Hot reloading works here, save a JS file and the dev server will update instantly.</li>
<li>You can modify the file <code>timeline/client/src/index.jsx</code> to be anything you want to make it easier for this, this file will NOT be built and is only for development.</li>
</ul>
<p><strong>server</strong></p>
<ul>
<li>Run by Django, this is your Wagtail application where you can view admin along with any of your CMS output.</li>
<li>This will only have access to your static assets, hence the 'production' code from your client.</li>
<li>Hot reloading will not work here, changing your JS file will have no effect until you run <code>npm run build</code> AND refresh your Django site.</li>
<li>Depending on your browser settings, you may need to disable caching (see your browser's dev tools). Django does a nice job of caching your styles, but this is not needed when making frequent changes.</li>
</ul>
<p><strong>making changes</strong></p>
<ul>
<li>Try to break up your work into client/server, switching between the two less frequently. This helps you batch changes into the two areas of the code and lets you build the compiled output less frequently saving you time.</li>
<li>Try to make your dev demo file reflect data and parts of the Django admin you want to be thinking about (eg. you may want to add a simple sidebar). <code>timeline/client/src/index.jsx</code>.</li>
<li>Biggest thing - remember that after saving the JS and CSS files that you need to run the Neutrino build again to make the changes available to Django.</li>
</ul>
<h2>
Step 7 - Make a Timeline.jsx component
</h2>
<ul>
<li>We will need to install a few npm libraries:
<ul>
<li>
<code>react-calendar-timeline</code> which also has a peer dependency <code>interactjs</code>
</li>
<li>
<code>classnames</code> - a great helper util used to generate clean classNames for React components</li>
<li>
<code>moment</code> - needed for date management and also is a peer dependency of <code>react-calendar-timeline</code>
</li>
</ul>
</li>
<li>These can be imported by running <code>npm install react-calendar-timeline classnames moment interactjs</code>
</li>
<li>Let's leave Example.js as is for now and create a new component by following the example in the <a href="proxy.php?url=https://github.com/namespace-ee/react-calendar-timeline" rel="noopener noreferrer">react-calendar-timeline README</a>.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="c1">// timeline/client/src/components/Timeline/index.js</span>
<span class="k">export</span> <span class="p">{</span> <span class="k">default</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./Timeline</span><span class="dl">'</span><span class="p">;</span>
</code></pre>
</div>
<div class="highlight js-code-highlight">
<pre class="highlight css"><code><span class="c">/* timeline/client/src/components/Timeline/timeline.css */</span>
<span class="nc">.timeline</span> <span class="p">{</span>
<span class="nl">background</span><span class="p">:</span> <span class="no">lightblue</span><span class="p">;</span>
<span class="p">}</span>
</code></pre>
</div>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="c1">// timeline/client/src/components/Timeline/Timeline.jsx</span>
<span class="k">import</span> <span class="nx">React</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">PropTypes</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">prop-types</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">classNames</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">classnames</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">moment</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">moment</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">CalendarTimeline</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react-calendar-timeline</span><span class="dl">'</span><span class="p">;</span>
<span class="c1">// styles</span>
<span class="k">import</span> <span class="dl">'</span><span class="s1">react-calendar-timeline/lib/Timeline.css</span><span class="dl">'</span><span class="p">;</span> <span class="c1">// must include to ensure the timeline itself is styled</span>
<span class="k">import</span> <span class="dl">'</span><span class="s1">./timeline.css</span><span class="dl">'</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">Timeline</span> <span class="o">=</span> <span class="p">({</span> <span class="nx">className</span> <span class="p">})</span> <span class="o">=></span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">groups</span> <span class="o">=</span> <span class="p">[</span>
<span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">group 1</span><span class="dl">'</span> <span class="p">},</span>
<span class="p">{</span> <span class="na">id</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span> <span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">group 2</span><span class="dl">'</span> <span class="p">},</span>
<span class="p">];</span>
<span class="kd">const</span> <span class="nx">items</span> <span class="o">=</span> <span class="p">[</span>
<span class="p">{</span>
<span class="na">id</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="na">group</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">item 1</span><span class="dl">'</span><span class="p">,</span>
<span class="na">start_time</span><span class="p">:</span> <span class="nf">moment</span><span class="p">(),</span>
<span class="na">end_time</span><span class="p">:</span> <span class="nf">moment</span><span class="p">().</span><span class="nf">add</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="dl">'</span><span class="s1">hour</span><span class="dl">'</span><span class="p">),</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="na">id</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span>
<span class="na">group</span><span class="p">:</span> <span class="mi">2</span><span class="p">,</span>
<span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">item 2</span><span class="dl">'</span><span class="p">,</span>
<span class="na">start_time</span><span class="p">:</span> <span class="nf">moment</span><span class="p">().</span><span class="nf">add</span><span class="p">(</span><span class="o">-</span><span class="mf">0.5</span><span class="p">,</span> <span class="dl">'</span><span class="s1">hour</span><span class="dl">'</span><span class="p">),</span>
<span class="na">end_time</span><span class="p">:</span> <span class="nf">moment</span><span class="p">().</span><span class="nf">add</span><span class="p">(</span><span class="mf">0.5</span><span class="p">,</span> <span class="dl">'</span><span class="s1">hour</span><span class="dl">'</span><span class="p">),</span>
<span class="p">},</span>
<span class="p">{</span>
<span class="na">id</span><span class="p">:</span> <span class="mi">3</span><span class="p">,</span>
<span class="na">group</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span>
<span class="na">title</span><span class="p">:</span> <span class="dl">'</span><span class="s1">item 3</span><span class="dl">'</span><span class="p">,</span>
<span class="na">start_time</span><span class="p">:</span> <span class="nf">moment</span><span class="p">().</span><span class="nf">add</span><span class="p">(</span><span class="mi">2</span><span class="p">,</span> <span class="dl">'</span><span class="s1">hour</span><span class="dl">'</span><span class="p">),</span>
<span class="na">end_time</span><span class="p">:</span> <span class="nf">moment</span><span class="p">().</span><span class="nf">add</span><span class="p">(</span><span class="mi">3</span><span class="p">,</span> <span class="dl">'</span><span class="s1">hour</span><span class="dl">'</span><span class="p">),</span>
<span class="p">},</span>
<span class="p">];</span>
<span class="k">return </span><span class="p">(</span>
<span class="o"><</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="p">{</span><span class="nf">classNames</span><span class="p">(</span><span class="dl">'</span><span class="s1">timeline</span><span class="dl">'</span><span class="p">,</span> <span class="nx">className</span><span class="p">)}</span><span class="o">></span>
<span class="o"><</span><span class="nx">CalendarTimeline</span>
<span class="nx">groups</span><span class="o">=</span><span class="p">{</span><span class="nx">groups</span><span class="p">}</span>
<span class="nx">items</span><span class="o">=</span><span class="p">{</span><span class="nx">items</span><span class="p">}</span>
<span class="nx">defaultTimeStart</span><span class="o">=</span><span class="p">{</span><span class="nf">moment</span><span class="p">().</span><span class="nf">add</span><span class="p">(</span><span class="o">-</span><span class="mi">12</span><span class="p">,</span> <span class="dl">'</span><span class="s1">hour</span><span class="dl">'</span><span class="p">)}</span>
<span class="nx">defaultTimeEnd</span><span class="o">=</span><span class="p">{</span><span class="nf">moment</span><span class="p">().</span><span class="nf">add</span><span class="p">(</span><span class="mi">12</span><span class="p">,</span> <span class="dl">'</span><span class="s1">hour</span><span class="dl">'</span><span class="p">)}</span>
<span class="sr">/</span><span class="err">>
</span> <span class="o"><</span><span class="sr">/div</span><span class="err">>
</span> <span class="p">);</span>
<span class="p">};</span>
<span class="nx">Timeline</span><span class="p">.</span><span class="nx">propTypes</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">className</span><span class="p">:</span> <span class="nx">PropTypes</span><span class="p">.</span><span class="nx">string</span><span class="p">,</span>
<span class="p">};</span>
<span class="nx">Timeline</span><span class="p">.</span><span class="nx">defaultProps</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">className</span><span class="p">:</span> <span class="dl">''</span><span class="p">,</span>
<span class="p">};</span>
<span class="k">export</span> <span class="k">default</span> <span class="nx">Timeline</span><span class="p">;</span>
</code></pre>
</div>
<ul>
<li>Important: We need to update our demo page (Remember: Used only while developing the client code) to use the <code>Timeline</code> component not <code>Example</code>.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="c1">// timeline/client/src/index.jsx</span>
<span class="k">import</span> <span class="nx">React</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">render</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react-dom</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">Timeline</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./components/Timeline</span><span class="dl">'</span><span class="p">;</span>
<span class="nf">render</span><span class="p">(</span>
<span class="o"><</span><span class="nx">main</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">main</span><span class="dl">"</span><span class="o">></span>
<span class="o"><</span><span class="nx">header</span> <span class="nx">role</span><span class="o">=</span><span class="dl">"</span><span class="s2">banner</span><span class="dl">"</span><span class="o">></span>
<span class="o"><</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">row nice-padding</span><span class="dl">"</span><span class="o">></span>
<span class="o"><</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">left</span><span class="dl">"</span><span class="o">></span>
<span class="o"><</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">col header-title</span><span class="dl">"</span><span class="o">></span>
<span class="o"><</span><span class="nx">h1</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">icon icon-</span><span class="dl">"</span><span class="o">></span><span class="nx">Timeline</span><span class="o"><</span><span class="sr">/h1</span><span class="err">>
</span> <span class="o"><</span><span class="sr">/div</span><span class="err">>
</span> <span class="o"><</span><span class="sr">/div</span><span class="err">>
</span> <span class="o"><</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">right</span><span class="dl">"</span> <span class="o">/></span>
<span class="o"><</span><span class="sr">/div</span><span class="err">>
</span> <span class="o"><</span><span class="sr">/header</span><span class="err">>
</span> <span class="o"><</span><span class="nx">Timeline</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">additional-class</span><span class="dl">"</span> <span class="o">/></span>
<span class="o"><</span><span class="sr">/main></span><span class="err">,
</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">root</span><span class="dl">'</span><span class="p">),</span>
<span class="p">);</span>
</code></pre>
</div>
<ul>
<li>Test this all works on your client dev server, confirm the CSS is used and you have a basic timeline rendering.</li>
<li>Run <code>npm run build</code> to build your static assets.</li>
<li>Update timeline.html (the Django view) to use the new component.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight html"><code>{% block extra_css %}
{{ block.super }}
<span class="nt"><link</span> <span class="na">rel=</span><span class="s">"stylesheet"</span> <span class="na">type=</span><span class="s">"text/css"</span> <span class="na">href=</span><span class="s">"{% static 'Timeline.css' %}"</span><span class="nt">></span>
{% endblock %}
{% block extra_js %}
{{ block.super }}
<span class="nt"><script </span><span class="na">src=</span><span class="s">"{% static 'Timeline.js' %}"</span><span class="nt">></script></span>
<span class="nt"><script></span>
<span class="nb">document</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">DOMContentLoaded</span><span class="dl">'</span><span class="p">,</span> <span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
<span class="nx">ReactDOM</span><span class="p">.</span><span class="nf">render</span><span class="p">(</span>
<span class="nx">React</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span>
<span class="nx">Timeline</span><span class="p">.</span><span class="k">default</span><span class="p">,</span> <span class="c1">// note - using .default here as this is how the global is set up</span>
<span class="p">{</span>
<span class="na">className</span><span class="p">:</span> <span class="dl">'</span><span class="s1">timeline-content</span><span class="dl">'</span>
<span class="p">}</span>
<span class="p">),</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">timeline</span><span class="dl">'</span><span class="p">));</span>
<span class="p">});</span>
<span class="nt"></script></span>
{% endblock %}
</code></pre>
</div>
<ul>
<li>Refresh your Django dev server and confirm you have a basic timeline rendering.</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.com%2Flb-%2Fbakerydemo%2Fraw%2Ftutorial%2Freact-timeline-in-wagtail-admin%2Fbakerydemo%2Ftimeline%2Fclient%2Fscreenshots%2Fstep-7-basic-timeline.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.com%2Flb-%2Fbakerydemo%2Fraw%2Ftutorial%2Freact-timeline-in-wagtail-admin%2Fbakerydemo%2Ftimeline%2Fclient%2Fscreenshots%2Fstep-7-basic-timeline.png" alt="Step 7 - Basic timeline with example data"></a></p>
<h2>
Step 8 - Connect to Wagtail's API
</h2>
<p>Our goal out of this step is to be able to read a response from <a href="proxy.php?url=http://docs.wagtail.io/en/latest/advanced_topics/api/index.html" rel="noopener noreferrer">Wagtail's API</a> in our React component.</p>
<p>It is important to note that while developing with the API, we need to have two things running. Firstly we need to have our client running via <code>npm start</code> and also our Django app running which will handle the API requests.</p>
<ul>
<li>Update API max response <code>WAGTAILAPI_LIMIT_MAX = 100</code> in our Django settings, the default is 20 and we want to allow for returning more Pages in our use case.</li>
<li>Run the client app and the Django app. Client - <code>npm start</code>, server - <code>./manage.py runserver</code>.</li>
<li>Set up the proxy, this is a development feature in neutrinojs which will let us redirect our JavaScript client dev server requests to the Wagtail API.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="c1">// client/.neutrinorc.js</span>
<span class="c1">// replace `reactComponents()` with the same call but with an objects object passed in.</span>
<span class="nf">reactComponents</span><span class="p">({</span>
<span class="cm">/** Change options related to starting a webpack-dev-server
* https://webpack.js.org/configuration/dev-server/#devserverproxy
* Proxy requests to /api to Wagtail local Django server
*/</span>
<span class="na">devServer</span><span class="p">:</span> <span class="p">{</span> <span class="na">proxy</span><span class="p">:</span> <span class="p">{</span> <span class="dl">'</span><span class="s1">/api</span><span class="dl">'</span><span class="p">:</span> <span class="dl">'</span><span class="s1">http://localhost:8000</span><span class="dl">'</span> <span class="p">}</span> <span class="p">},</span>
<span class="p">}),</span>
</code></pre>
</div>
<ul>
<li>Now we can build a React component that fetches the API's data and transforms it into data we want for our rendering. This step may be a big jump if you are new to React, but we will explain more after the code snippets.</li>
</ul>
<p><strong>New File - Messages.jsx</strong></p>
<ul>
<li>This will render our loading message and potentially any error message using class names that already exist in the Wagtail Admin CSS.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="c1">// client/src/Timeline/Messages.jsx</span>
<span class="k">import</span> <span class="nx">React</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">PropTypes</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">prop-types</span><span class="dl">'</span><span class="p">;</span>
<span class="cm">/**
* A verbose example of a Functional component. Messages renders the loading or
* error message states.
* @param {Object} props
*/</span>
<span class="kd">const</span> <span class="nx">Messages</span> <span class="o">=</span> <span class="p">({</span> <span class="nx">error</span><span class="p">,</span> <span class="nx">isLoading</span> <span class="p">})</span> <span class="o">=></span> <span class="p">(</span>
<span class="o"><</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">messages</span><span class="dl">"</span><span class="o">></span>
<span class="o"><</span><span class="nx">ul</span><span class="o">></span>
<span class="p">{</span><span class="nx">isLoading</span> <span class="o">&&</span> <span class="o"><</span><span class="nx">li</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">success</span><span class="dl">"</span><span class="o">></span><span class="nx">Loading</span><span class="p">...</span><span class="o"><</span><span class="sr">/li></span><span class="err">}
</span> <span class="p">{</span><span class="nx">error</span> <span class="o">&&</span> <span class="p">(</span>
<span class="o"><</span><span class="nx">li</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">error</span><span class="dl">"</span><span class="o">></span>
<span class="o"><</span><span class="nx">span</span><span class="o">></span><span class="na">Error</span><span class="p">:</span> <span class="o"><</span><span class="sr">/span</span><span class="err">>
</span> <span class="p">{</span><span class="nx">error</span><span class="p">.</span><span class="nx">message</span><span class="p">}</span>
<span class="o"><</span><span class="sr">/li</span><span class="err">>
</span> <span class="p">)}</span>
<span class="o"><</span><span class="sr">/ul</span><span class="err">>
</span> <span class="o"><</span><span class="sr">/div</span><span class="err">>
</span><span class="p">);</span>
<span class="nx">Messages</span><span class="p">.</span><span class="nx">defaultProps</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">isLoading</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="na">error</span><span class="p">:</span> <span class="p">{},</span>
<span class="p">};</span>
<span class="nx">Messages</span><span class="p">.</span><span class="nx">propTypes</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">isLoading</span><span class="p">:</span> <span class="nx">PropTypes</span><span class="p">.</span><span class="nx">bool</span><span class="p">,</span>
<span class="na">error</span><span class="p">:</span> <span class="nx">PropTypes</span><span class="p">.</span><span class="nf">shape</span><span class="p">({</span>
<span class="na">message</span><span class="p">:</span> <span class="nx">PropTypes</span><span class="p">.</span><span class="nx">string</span><span class="p">,</span>
<span class="p">}),</span>
<span class="p">};</span>
<span class="k">export</span> <span class="k">default</span> <span class="nx">Messages</span><span class="p">;</span>
</code></pre>
</div>
<p><strong>New File - get-transformed-response.js</strong></p>
<ul>
<li>This is a pure function, takes the response from the API and prepares the data we need for our Timeline component.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="c1">// client/src/components/Timeline/get-transformed-response.js</span>
<span class="cm">/* eslint-disable camelcase */</span>
<span class="k">import</span> <span class="nx">moment</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">moment</span><span class="dl">'</span><span class="p">;</span>
<span class="kd">const</span> <span class="nx">getTransformedItems</span> <span class="o">=</span> <span class="p">({</span> <span class="nx">items</span> <span class="o">=</span> <span class="p">[]</span> <span class="p">}</span> <span class="o">=</span> <span class="p">{})</span> <span class="o">=></span>
<span class="nx">items</span><span class="p">.</span><span class="nf">map</span><span class="p">(({</span> <span class="na">meta</span><span class="p">:</span> <span class="p">{</span> <span class="nx">first_published_at</span><span class="p">,</span> <span class="nx">type</span><span class="p">,</span> <span class="p">...</span><span class="nx">meta</span> <span class="p">},</span> <span class="p">...</span><span class="nx">item</span> <span class="p">})</span> <span class="o">=></span> <span class="p">({</span>
<span class="p">...</span><span class="nx">item</span><span class="p">,</span>
<span class="p">...</span><span class="nx">meta</span><span class="p">,</span>
<span class="na">group</span><span class="p">:</span> <span class="nx">type</span><span class="p">,</span>
<span class="na">start_time</span><span class="p">:</span> <span class="nf">moment</span><span class="p">(</span><span class="nx">first_published_at</span><span class="p">),</span>
<span class="na">end_time</span><span class="p">:</span> <span class="nf">moment</span><span class="p">().</span><span class="nf">add</span><span class="p">(</span><span class="mi">1</span><span class="p">,</span> <span class="dl">'</span><span class="s1">year</span><span class="dl">'</span><span class="p">),</span> <span class="c1">// indicates they are live</span>
<span class="p">}));</span>
<span class="kd">const</span> <span class="nx">getGroups</span> <span class="o">=</span> <span class="nx">items</span> <span class="o">=></span>
<span class="nx">items</span>
<span class="p">.</span><span class="nf">map</span><span class="p">(({</span> <span class="nx">group</span> <span class="p">})</span> <span class="o">=></span> <span class="nx">group</span><span class="p">)</span>
<span class="p">.</span><span class="nf">reduce</span><span class="p">((</span><span class="nx">groups</span><span class="p">,</span> <span class="nx">group</span><span class="p">,</span> <span class="nx">index</span><span class="p">,</span> <span class="nx">arr</span><span class="p">)</span> <span class="o">=></span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="nx">arr</span><span class="p">.</span><span class="nf">indexOf</span><span class="p">(</span><span class="nx">group</span><span class="p">)</span> <span class="o">>=</span> <span class="nx">index</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">groups</span><span class="p">.</span><span class="nf">concat</span><span class="p">({</span>
<span class="na">id</span><span class="p">:</span> <span class="nx">group</span><span class="p">,</span>
<span class="cm">/* convert 'base.IndexPage' to 'Index Page' */</span>
<span class="na">title</span><span class="p">:</span> <span class="nx">group</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sr">/</span><span class="se">([</span><span class="sr">a-z</span><span class="se">](?=[</span><span class="sr">A-Z</span><span class="se">]))</span><span class="sr">/g</span><span class="p">,</span> <span class="dl">'</span><span class="s1">$1 </span><span class="dl">'</span><span class="p">).</span><span class="nf">split</span><span class="p">(</span><span class="dl">'</span><span class="s1">.</span><span class="dl">'</span><span class="p">)[</span><span class="mi">1</span><span class="p">],</span>
<span class="p">});</span>
<span class="p">}</span>
<span class="k">return</span> <span class="nx">groups</span><span class="p">;</span>
<span class="p">},</span> <span class="p">[]);</span>
<span class="kd">const</span> <span class="nx">getDefaultTimes</span> <span class="o">=</span> <span class="nx">items</span> <span class="o">=></span>
<span class="nx">items</span><span class="p">.</span><span class="nf">reduce</span><span class="p">(({</span> <span class="nx">start</span> <span class="o">=</span> <span class="kc">null</span><span class="p">,</span> <span class="nx">end</span> <span class="o">=</span> <span class="kc">null</span> <span class="p">},</span> <span class="p">{</span> <span class="nx">start_time</span><span class="p">,</span> <span class="nx">end_time</span> <span class="p">})</span> <span class="o">=></span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">start</span> <span class="o">&&</span> <span class="o">!</span><span class="nx">end</span><span class="p">)</span> <span class="k">return</span> <span class="p">{</span> <span class="na">start</span><span class="p">:</span> <span class="nx">start_time</span><span class="p">,</span> <span class="na">end</span><span class="p">:</span> <span class="nx">end_time</span> <span class="p">};</span>
<span class="k">return</span> <span class="p">{</span>
<span class="na">start</span><span class="p">:</span> <span class="nx">start_time</span><span class="p">.</span><span class="nf">isBefore</span><span class="p">(</span><span class="nx">start</span><span class="p">)</span> <span class="p">?</span> <span class="nx">start_time</span> <span class="p">:</span> <span class="nx">start</span><span class="p">,</span>
<span class="na">end</span><span class="p">:</span> <span class="nx">end_time</span><span class="p">.</span><span class="nf">isAfter</span><span class="p">(</span><span class="nx">end</span><span class="p">)</span> <span class="p">?</span> <span class="nx">end_time</span> <span class="p">:</span> <span class="nx">end</span><span class="p">,</span>
<span class="p">};</span>
<span class="p">},</span> <span class="p">{});</span>
<span class="kd">const</span> <span class="nx">getTransformedResponse</span> <span class="o">=</span> <span class="nx">response</span> <span class="o">=></span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">items</span> <span class="o">=</span> <span class="nf">getTransformedItems</span><span class="p">(</span><span class="nx">response</span><span class="p">);</span>
<span class="k">return</span> <span class="p">{</span>
<span class="na">defaultTimes</span><span class="p">:</span> <span class="nf">getDefaultTimes</span><span class="p">(</span><span class="nx">items</span><span class="p">),</span>
<span class="na">groups</span><span class="p">:</span> <span class="nf">getGroups</span><span class="p">(</span><span class="nx">items</span><span class="p">),</span>
<span class="nx">items</span><span class="p">,</span>
<span class="p">};</span>
<span class="p">};</span>
<span class="k">export</span> <span class="k">default</span> <span class="nx">getTransformedResponse</span><span class="p">;</span>
</code></pre>
</div>
<p><strong>Revised File - Timeline.jsx</strong><br>
</p>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="c1">// timeline/client/src/components/Timeline/Timeline.jsx</span>
<span class="k">import</span> <span class="nx">React</span><span class="p">,</span> <span class="p">{</span> <span class="nx">PureComponent</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">PropTypes</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">prop-types</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">classNames</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">classnames</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">CalendarTimeline</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react-calendar-timeline</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">Messages</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./Messages</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">getTransformedResponse</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./get-transformed-response</span><span class="dl">'</span><span class="p">;</span>
<span class="c1">// styles</span>
<span class="k">import</span> <span class="dl">'</span><span class="s1">react-calendar-timeline/lib/Timeline.css</span><span class="dl">'</span><span class="p">;</span> <span class="c1">// must include to ensure the timeline itself is styled</span>
<span class="k">import</span> <span class="dl">'</span><span class="s1">./timeline.css</span><span class="dl">'</span><span class="p">;</span>
<span class="kd">class</span> <span class="nc">Timeline</span> <span class="kd">extends</span> <span class="nc">PureComponent</span> <span class="p">{</span>
<span class="nx">state</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">defaultTimes</span><span class="p">:</span> <span class="p">{},</span>
<span class="na">error</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="na">groups</span><span class="p">:</span> <span class="p">[],</span>
<span class="na">isLoading</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="na">items</span><span class="p">:</span> <span class="p">[],</span>
<span class="p">};</span>
<span class="nf">componentDidMount</span><span class="p">()</span> <span class="p">{</span>
<span class="k">this</span><span class="p">.</span><span class="nf">fetchData</span><span class="p">();</span>
<span class="p">}</span>
<span class="cm">/** set state to loading and then call the API for the items data */</span>
<span class="nf">fetchData</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">apiUrl</span> <span class="p">}</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">props</span><span class="p">;</span>
<span class="k">this</span><span class="p">.</span><span class="nf">setState</span><span class="p">({</span> <span class="na">isLoading</span><span class="p">:</span> <span class="kc">true</span> <span class="p">});</span>
<span class="nf">fetch</span><span class="p">(</span><span class="nx">apiUrl</span><span class="p">)</span>
<span class="p">.</span><span class="nf">then</span><span class="p">(</span><span class="nx">response</span> <span class="o">=></span> <span class="nx">response</span><span class="p">.</span><span class="nf">json</span><span class="p">())</span>
<span class="p">.</span><span class="nf">then</span><span class="p">(({</span> <span class="nx">message</span><span class="p">,</span> <span class="p">...</span><span class="nx">data</span> <span class="p">})</span> <span class="o">=></span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="nx">message</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="nx">message</span><span class="p">);</span>
<span class="k">return</span> <span class="nx">data</span><span class="p">;</span>
<span class="p">})</span>
<span class="p">.</span><span class="nf">then</span><span class="p">(</span><span class="nx">getTransformedResponse</span><span class="p">)</span>
<span class="p">.</span><span class="nf">then</span><span class="p">(({</span> <span class="nx">items</span><span class="p">,</span> <span class="nx">defaultTimes</span><span class="p">,</span> <span class="nx">groups</span> <span class="p">})</span> <span class="o">=></span>
<span class="k">this</span><span class="p">.</span><span class="nf">setState</span><span class="p">({</span>
<span class="nx">defaultTimes</span><span class="p">,</span>
<span class="na">error</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nx">groups</span><span class="p">,</span>
<span class="na">isLoading</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nx">items</span><span class="p">,</span>
<span class="p">}),</span>
<span class="p">)</span>
<span class="p">.</span><span class="k">catch</span><span class="p">(</span><span class="nx">error</span> <span class="o">=></span> <span class="k">this</span><span class="p">.</span><span class="nf">setState</span><span class="p">({</span> <span class="nx">error</span><span class="p">,</span> <span class="na">isLoading</span><span class="p">:</span> <span class="kc">false</span> <span class="p">}));</span>
<span class="p">}</span>
<span class="nf">render</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">className</span> <span class="p">}</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">props</span><span class="p">;</span>
<span class="kd">const</span> <span class="p">{</span>
<span class="na">defaultTimes</span><span class="p">:</span> <span class="p">{</span> <span class="nx">start</span><span class="p">,</span> <span class="nx">end</span> <span class="p">},</span>
<span class="nx">error</span><span class="p">,</span>
<span class="nx">groups</span><span class="p">,</span>
<span class="nx">isLoading</span><span class="p">,</span>
<span class="nx">items</span><span class="p">,</span>
<span class="p">}</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">state</span><span class="p">;</span>
<span class="k">return </span><span class="p">(</span>
<span class="o"><</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="p">{</span><span class="nf">classNames</span><span class="p">(</span><span class="dl">'</span><span class="s1">timeline</span><span class="dl">'</span><span class="p">,</span> <span class="nx">className</span><span class="p">)}</span><span class="o">></span>
<span class="p">{</span><span class="nx">isLoading</span> <span class="o">||</span> <span class="nx">error</span> <span class="p">?</span> <span class="p">(</span>
<span class="o"><</span><span class="nx">Messages</span> <span class="nx">error</span><span class="o">=</span><span class="p">{</span><span class="nx">error</span><span class="p">}</span> <span class="nx">isLoading</span><span class="o">=</span><span class="p">{</span><span class="nx">isLoading</span><span class="p">}</span> <span class="sr">/</span><span class="err">>
</span> <span class="p">)</span> <span class="p">:</span> <span class="p">(</span>
<span class="o"><</span><span class="nx">CalendarTimeline</span>
<span class="nx">defaultTimeEnd</span><span class="o">=</span><span class="p">{</span><span class="nx">end</span><span class="p">}</span>
<span class="nx">defaultTimeStart</span><span class="o">=</span><span class="p">{</span><span class="nx">start</span><span class="p">}</span>
<span class="nx">groups</span><span class="o">=</span><span class="p">{</span><span class="nx">groups</span><span class="p">}</span>
<span class="nx">items</span><span class="o">=</span><span class="p">{</span><span class="nx">items</span><span class="p">}</span>
<span class="nx">sidebarWidth</span><span class="o">=</span><span class="p">{</span><span class="mi">250</span><span class="p">}</span>
<span class="nx">stackItems</span>
<span class="o">/></span>
<span class="p">)}</span>
<span class="o"><</span><span class="sr">/div</span><span class="err">>
</span> <span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="nx">Timeline</span><span class="p">.</span><span class="nx">defaultProps</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">apiUrl</span><span class="p">:</span> <span class="dl">'</span><span class="s1">/api/v2/pages/?limit=100</span><span class="dl">'</span><span class="p">,</span>
<span class="na">className</span><span class="p">:</span> <span class="dl">''</span><span class="p">,</span>
<span class="p">};</span>
<span class="nx">Timeline</span><span class="p">.</span><span class="nx">propTypes</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">apiUrl</span><span class="p">:</span> <span class="nx">PropTypes</span><span class="p">.</span><span class="nx">string</span><span class="p">,</span>
<span class="na">className</span><span class="p">:</span> <span class="nx">PropTypes</span><span class="p">.</span><span class="nx">string</span><span class="p">,</span>
<span class="p">};</span>
<span class="k">export</span> <span class="k">default</span> <span class="nx">Timeline</span><span class="p">;</span>
</code></pre>
</div>
<p><strong>Explanation</strong></p>
<ul>
<li>Our Timeline React Component has been changed to a class component.</li>
<li>The component has its own <a href="proxy.php?url=https://reactjs.org/docs/faq-state.html" rel="noopener noreferrer">state</a> and on <a href="proxy.php?url=https://reactjs.org/docs/react-component.html#componentdidmount" rel="noopener noreferrer">componentDidMount</a> it will call its own <code>fetchData</code> function.</li>
<li>
<code>fetchData</code> sets the component's <code>isLoading</code> state to true, reads the api url from props (which defaults to <a href="proxy.php?url=http://docs.wagtail.io/en/latest/advanced_topics/api/v2/usage.html#fetching-content" rel="noopener noreferrer">Wagtail's pages endpoint</a>) and does some basic error handling, JSON parsing and finally sends the response data through our transformer, setting the state to our transformed results.</li>
<li>The <code>render</code> method on our component will output the data from state into our timline, but may render the <code>Messages</code> component while the data is still loading or if any errors occured.</li>
<li>Our transformer file does the heavy lifting of working out what dates to show on the calendar based on the dates from the pages response, also prepares the groups based on the page type. We also do a bit of formatting on the native page type to make it read nicer.</li>
<li>The transformer also prepares the default start/end dates based on the overall dates of the response's pages.</li>
<li>We should be able to see the data from the API now in the component.</li>
<li>Run <code>npm run build</code> and then you can see the changes within your Wagtail application.</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.com%2Flb-%2Fbakerydemo%2Fraw%2Ftutorial%2Freact-timeline-in-wagtail-admin%2Fbakerydemo%2Ftimeline%2Fclient%2Fscreenshots%2Fstep-8-api-data-in-timeline.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.com%2Flb-%2Fbakerydemo%2Fraw%2Ftutorial%2Freact-timeline-in-wagtail-admin%2Fbakerydemo%2Ftimeline%2Fclient%2Fscreenshots%2Fstep-8-api-data-in-timeline.png" alt="Step 7 - Timeline with data from Wagtail's API"></a></p>
<h2>
Step 9 - Integrate with the Wagtail Admin search box
</h2>
<ul>
<li>Now we want to show an example of Wagtail's Django templates and views working with our React component.</li>
<li>First, update the view to include handling and passing of the search query in the URL params. The existing <code>wagtailadmin/shared/header.html</code> include in the timeline.html template will read the <code>search_form</code> from context.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight python"><code><span class="c1"># timeline/views.py
</span>
<span class="kn">from</span> <span class="n">django.shortcuts</span> <span class="kn">import</span> <span class="n">render</span>
<span class="kn">from</span> <span class="n">wagtail.admin.forms.search</span> <span class="kn">import</span> <span class="n">SearchForm</span>
<span class="k">def</span> <span class="nf">timeline_view</span><span class="p">(</span><span class="n">request</span><span class="p">):</span>
<span class="c1"># Search Handling
</span> <span class="n">query_string</span> <span class="o">=</span> <span class="bp">None</span>
<span class="k">if</span> <span class="sh">'</span><span class="s">q</span><span class="sh">'</span> <span class="ow">in</span> <span class="n">request</span><span class="p">.</span><span class="n">GET</span><span class="p">:</span>
<span class="n">search_form</span> <span class="o">=</span> <span class="nc">SearchForm</span><span class="p">(</span><span class="n">request</span><span class="p">.</span><span class="n">GET</span><span class="p">,</span> <span class="n">placeholder</span><span class="o">=</span><span class="sh">'</span><span class="s">Search timeline</span><span class="sh">'</span><span class="p">)</span>
<span class="k">if</span> <span class="n">search_form</span><span class="p">.</span><span class="nf">is_valid</span><span class="p">():</span>
<span class="n">query_string</span> <span class="o">=</span> <span class="n">search_form</span><span class="p">.</span><span class="n">cleaned_data</span><span class="p">[</span><span class="sh">'</span><span class="s">q</span><span class="sh">'</span><span class="p">]</span>
<span class="k">else</span><span class="p">:</span>
<span class="n">search_form</span> <span class="o">=</span> <span class="nc">SearchForm</span><span class="p">(</span><span class="n">placeholder</span><span class="o">=</span><span class="sh">'</span><span class="s">Search timeline</span><span class="sh">'</span><span class="p">)</span>
<span class="k">return</span> <span class="nf">render</span><span class="p">(</span><span class="n">request</span><span class="p">,</span> <span class="sh">"</span><span class="s">timeline.html</span><span class="sh">"</span><span class="p">,</span> <span class="p">{</span>
<span class="sh">'</span><span class="s">icon</span><span class="sh">'</span><span class="p">:</span> <span class="sh">'</span><span class="s">time</span><span class="sh">'</span><span class="p">,</span> <span class="c1"># pass in an icon to show in the header
</span> <span class="sh">'</span><span class="s">query_string</span><span class="sh">'</span><span class="p">:</span> <span class="n">query_string</span> <span class="ow">or</span> <span class="sh">''</span><span class="p">,</span>
<span class="sh">'</span><span class="s">search_form</span><span class="sh">'</span><span class="p">:</span> <span class="n">search_form</span><span class="p">,</span>
<span class="sh">'</span><span class="s">search_url</span><span class="sh">'</span><span class="p">:</span> <span class="sh">'</span><span class="s">timeline</span><span class="sh">'</span><span class="p">,</span> <span class="c1"># url name set by wagtail_hooks
</span> <span class="sh">'</span><span class="s">title</span><span class="sh">'</span><span class="p">:</span> <span class="sh">'</span><span class="s">Timeline</span><span class="sh">'</span><span class="p">,</span>
<span class="p">})</span>
</code></pre>
</div>
<ul>
<li>Then we need to pass in the search form's id and current query to our React component. This will mean we can update the timeline live as the user types into the search form, <em>and</em> handle the cases where a URL is copied or the user presses enter to submit the search form.</li>
<li>Here we only need to change the <code>block extra_js</code>, essentially adding two props, the <code>initialSearchValue</code> and the <code>searchFormId</code>. Note: <code>id_q</code> is just the existing convention that Wagtail has, it is set up automatically by Wagtail.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight html"><code>{% block extra_js %}
{{ block.super }}
<span class="nt"><script </span><span class="na">src=</span><span class="s">"{% static 'Timeline.js' %}"</span><span class="nt">></script></span>
<span class="nt"><script></span>
<span class="nb">document</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">DOMContentLoaded</span><span class="dl">'</span><span class="p">,</span> <span class="kd">function</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">const</span> <span class="nx">props</span> <span class="o">=</span> <span class="p">{</span> <span class="na">className</span><span class="p">:</span> <span class="dl">'</span><span class="s1">inner timeline-content</span><span class="dl">'</span><span class="p">,</span> <span class="na">initialSearchValue</span><span class="p">:</span> <span class="dl">'</span><span class="s1">{{ query_string }}</span><span class="dl">'</span><span class="p">,</span> <span class="na">searchFormId</span><span class="p">:</span> <span class="dl">'</span><span class="s1">id_q</span><span class="dl">'</span> <span class="p">};</span>
<span class="nx">ReactDOM</span><span class="p">.</span><span class="nf">render</span><span class="p">(</span>
<span class="nx">React</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span>
<span class="nx">Timeline</span><span class="p">.</span><span class="k">default</span><span class="p">,</span> <span class="c1">// note - using .default here as this is how the global is set up</span>
<span class="nx">props</span>
<span class="p">),</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">timeline</span><span class="dl">'</span><span class="p">));</span>
<span class="p">});</span>
<span class="nt"></script></span>
{% endblock %}
</code></pre>
</div>
<ul>
<li>Now we can set up an event listener on our form, along with basic text search filtering.</li>
<li>Below we have added three new methods;
<ul>
<li>
<code>onSearch</code> - handles the input as the user types in the search box.</li>
<li>
<code>setUpSearchForm</code> - called on mount and sets up the listener and initial state.</li>
<li>
<code>getFilteredItems</code> - returns a filtered array of items based on the search string.</li>
</ul>
</li>
<li>We have also revised the props & default props to include <code>initialSearchValue</code> and <code>searchFormId</code>.</li>
<li>Finally, we have customised the actual timeline rendering to show the searched string in the header, plus returning only the filtered items to the calendar timeline.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="c1">// timeline/client/src/components/Timeline/Timeline.jsx</span>
<span class="k">import</span> <span class="nx">React</span><span class="p">,</span> <span class="p">{</span> <span class="nx">PureComponent</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">PropTypes</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">prop-types</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">classNames</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">classnames</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">CalendarTimeline</span><span class="p">,</span> <span class="p">{</span>
<span class="nx">DateHeader</span><span class="p">,</span>
<span class="nx">SidebarHeader</span><span class="p">,</span>
<span class="nx">TimelineHeaders</span><span class="p">,</span>
<span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react-calendar-timeline</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">Messages</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./Messages</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">getTransformedResponse</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./get-transformed-response</span><span class="dl">'</span><span class="p">;</span>
<span class="c1">// styles</span>
<span class="k">import</span> <span class="dl">'</span><span class="s1">react-calendar-timeline/lib/Timeline.css</span><span class="dl">'</span><span class="p">;</span> <span class="c1">// must include to ensure the timeline itself is styled</span>
<span class="k">import</span> <span class="dl">'</span><span class="s1">./timeline.css</span><span class="dl">'</span><span class="p">;</span>
<span class="kd">class</span> <span class="nc">Timeline</span> <span class="kd">extends</span> <span class="nc">PureComponent</span> <span class="p">{</span>
<span class="nx">state</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">defaultTimes</span><span class="p">:</span> <span class="p">{},</span>
<span class="na">error</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="na">groups</span><span class="p">:</span> <span class="p">[],</span>
<span class="na">isLoading</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
<span class="na">items</span><span class="p">:</span> <span class="p">[],</span>
<span class="na">searchValue</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="p">};</span>
<span class="nf">componentDidMount</span><span class="p">()</span> <span class="p">{</span>
<span class="k">this</span><span class="p">.</span><span class="nf">fetchData</span><span class="p">();</span>
<span class="k">this</span><span class="p">.</span><span class="nf">setUpSearchForm</span><span class="p">();</span>
<span class="p">}</span>
<span class="cm">/** handler for search form changing */</span>
<span class="nf">onSearch</span><span class="p">({</span> <span class="na">target</span><span class="p">:</span> <span class="p">{</span> <span class="nx">value</span> <span class="p">}</span> <span class="o">=</span> <span class="p">{}</span> <span class="p">}</span> <span class="o">=</span> <span class="p">{})</span> <span class="p">{</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">searchValue</span> <span class="p">}</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">state</span><span class="p">;</span>
<span class="k">if </span><span class="p">(</span><span class="nx">value</span> <span class="o">!==</span> <span class="nx">searchValue</span><span class="p">)</span> <span class="p">{</span>
<span class="k">this</span><span class="p">.</span><span class="nf">setState</span><span class="p">({</span> <span class="na">searchValue</span><span class="p">:</span> <span class="nx">value</span> <span class="p">});</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="cm">/** set up a listener on a search field that is outside this component
* (rendered by Django/Wagtail) */</span>
<span class="nf">setUpSearchForm</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">initialSearchValue</span><span class="p">,</span> <span class="nx">searchFormId</span> <span class="p">}</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">props</span><span class="p">;</span>
<span class="k">this</span><span class="p">.</span><span class="nf">setState</span><span class="p">({</span> <span class="na">searchValue</span><span class="p">:</span> <span class="nx">initialSearchValue</span> <span class="p">});</span>
<span class="cm">/** set up a listener on a search field that is outside this component
* (rendered by Django/Wagtail) */</span>
<span class="kd">const</span> <span class="nx">searchForm</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="nx">searchFormId</span><span class="p">);</span>
<span class="k">if </span><span class="p">(</span><span class="nx">searchForm</span><span class="p">)</span> <span class="p">{</span>
<span class="nx">searchForm</span><span class="p">.</span><span class="nf">addEventListener</span><span class="p">(</span><span class="dl">'</span><span class="s1">keyup</span><span class="dl">'</span><span class="p">,</span> <span class="nx">event</span> <span class="o">=></span> <span class="k">this</span><span class="p">.</span><span class="nf">onSearch</span><span class="p">(</span><span class="nx">event</span><span class="p">));</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="cm">/** return filtered items based on the searchValue and that
* value being included in either the group (eg. Location Page) or title.
* Ensure we handle combinations of upper/lowercase in either part of data.
*/</span>
<span class="nf">getFilteredItems</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">items</span><span class="p">,</span> <span class="nx">searchValue</span> <span class="p">}</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">state</span><span class="p">;</span>
<span class="k">if </span><span class="p">(</span><span class="nx">searchValue</span><span class="p">)</span> <span class="p">{</span>
<span class="k">return</span> <span class="nx">items</span><span class="p">.</span><span class="nf">filter</span><span class="p">(({</span> <span class="nx">group</span><span class="p">,</span> <span class="nx">title</span> <span class="p">})</span> <span class="o">=></span>
<span class="p">[</span><span class="nx">group</span><span class="p">,</span> <span class="nx">title</span><span class="p">]</span>
<span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="dl">'</span><span class="s1"> </span><span class="dl">'</span><span class="p">)</span>
<span class="p">.</span><span class="nf">toLowerCase</span><span class="p">()</span>
<span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="nx">searchValue</span><span class="p">.</span><span class="nf">toLowerCase</span><span class="p">()),</span>
<span class="p">);</span>
<span class="p">}</span>
<span class="k">return</span> <span class="nx">items</span><span class="p">;</span>
<span class="p">}</span>
<span class="cm">/** set state to loading and then call the API for the items data */</span>
<span class="nf">fetchData</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">apiUrl</span> <span class="p">}</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">props</span><span class="p">;</span>
<span class="k">this</span><span class="p">.</span><span class="nf">setState</span><span class="p">({</span> <span class="na">isLoading</span><span class="p">:</span> <span class="kc">true</span> <span class="p">});</span>
<span class="nf">fetch</span><span class="p">(</span><span class="nx">apiUrl</span><span class="p">)</span>
<span class="p">.</span><span class="nf">then</span><span class="p">(</span><span class="nx">response</span> <span class="o">=></span> <span class="nx">response</span><span class="p">.</span><span class="nf">json</span><span class="p">())</span>
<span class="p">.</span><span class="nf">then</span><span class="p">(({</span> <span class="nx">message</span><span class="p">,</span> <span class="p">...</span><span class="nx">data</span> <span class="p">})</span> <span class="o">=></span> <span class="p">{</span>
<span class="k">if </span><span class="p">(</span><span class="nx">message</span><span class="p">)</span> <span class="k">throw</span> <span class="k">new</span> <span class="nc">Error</span><span class="p">(</span><span class="nx">message</span><span class="p">);</span>
<span class="k">return</span> <span class="nx">data</span><span class="p">;</span>
<span class="p">})</span>
<span class="p">.</span><span class="nf">then</span><span class="p">(</span><span class="nx">getTransformedResponse</span><span class="p">)</span>
<span class="p">.</span><span class="nf">then</span><span class="p">(({</span> <span class="nx">items</span><span class="p">,</span> <span class="nx">defaultTimes</span><span class="p">,</span> <span class="nx">groups</span> <span class="p">})</span> <span class="o">=></span>
<span class="k">this</span><span class="p">.</span><span class="nf">setState</span><span class="p">({</span>
<span class="nx">defaultTimes</span><span class="p">,</span>
<span class="na">error</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="nx">groups</span><span class="p">,</span>
<span class="na">isLoading</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
<span class="nx">items</span><span class="p">,</span>
<span class="p">}),</span>
<span class="p">)</span>
<span class="p">.</span><span class="k">catch</span><span class="p">(</span><span class="nx">error</span> <span class="o">=></span> <span class="k">this</span><span class="p">.</span><span class="nf">setState</span><span class="p">({</span> <span class="nx">error</span><span class="p">,</span> <span class="na">isLoading</span><span class="p">:</span> <span class="kc">false</span> <span class="p">}));</span>
<span class="p">}</span>
<span class="nf">render</span><span class="p">()</span> <span class="p">{</span>
<span class="kd">const</span> <span class="p">{</span> <span class="nx">className</span> <span class="p">}</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">props</span><span class="p">;</span>
<span class="kd">const</span> <span class="p">{</span>
<span class="na">defaultTimes</span><span class="p">:</span> <span class="p">{</span> <span class="nx">start</span><span class="p">,</span> <span class="nx">end</span> <span class="p">},</span>
<span class="nx">error</span><span class="p">,</span>
<span class="nx">groups</span><span class="p">,</span>
<span class="nx">isLoading</span><span class="p">,</span>
<span class="nx">searchValue</span><span class="p">,</span>
<span class="p">}</span> <span class="o">=</span> <span class="k">this</span><span class="p">.</span><span class="nx">state</span><span class="p">;</span>
<span class="k">return </span><span class="p">(</span>
<span class="o"><</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="p">{</span><span class="nf">classNames</span><span class="p">(</span><span class="dl">'</span><span class="s1">timeline</span><span class="dl">'</span><span class="p">,</span> <span class="nx">className</span><span class="p">)}</span><span class="o">></span>
<span class="p">{</span><span class="nx">isLoading</span> <span class="o">||</span> <span class="nx">error</span> <span class="p">?</span> <span class="p">(</span>
<span class="o"><</span><span class="nx">Messages</span> <span class="nx">error</span><span class="o">=</span><span class="p">{</span><span class="nx">error</span><span class="p">}</span> <span class="nx">isLoading</span><span class="o">=</span><span class="p">{</span><span class="nx">isLoading</span><span class="p">}</span> <span class="sr">/</span><span class="err">>
</span> <span class="p">)</span> <span class="p">:</span> <span class="p">(</span>
<span class="o"><</span><span class="nx">CalendarTimeline</span>
<span class="nx">defaultTimeEnd</span><span class="o">=</span><span class="p">{</span><span class="nx">end</span><span class="p">}</span>
<span class="nx">defaultTimeStart</span><span class="o">=</span><span class="p">{</span><span class="nx">start</span><span class="p">}</span>
<span class="nx">groups</span><span class="o">=</span><span class="p">{</span><span class="nx">groups</span><span class="p">}</span>
<span class="nx">items</span><span class="o">=</span><span class="p">{</span><span class="k">this</span><span class="p">.</span><span class="nf">getFilteredItems</span><span class="p">()}</span>
<span class="nx">sidebarWidth</span><span class="o">=</span><span class="p">{</span><span class="mi">250</span><span class="p">}</span>
<span class="nx">stackItems</span>
<span class="o">></span>
<span class="o"><</span><span class="nx">TimelineHeaders</span><span class="o">></span>
<span class="o"><</span><span class="nx">SidebarHeader</span><span class="o">></span>
<span class="p">{({</span> <span class="nx">getRootProps</span> <span class="p">})</span> <span class="o">=></span> <span class="p">(</span>
<span class="o"><</span><span class="nx">div</span> <span class="p">{...</span><span class="nf">getRootProps</span><span class="p">()}</span><span class="o">></span>
<span class="p">{</span><span class="nx">searchValue</span> <span class="o">&&</span> <span class="p">(</span>
<span class="o"><</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">search</span><span class="dl">"</span><span class="o">></span>
<span class="o"><</span><span class="nx">strong</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">search-label</span><span class="dl">"</span><span class="o">></span><span class="na">Search</span><span class="p">:</span> <span class="o"><</span><span class="sr">/strong</span><span class="err">>
</span> <span class="o"><</span><span class="nx">span</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">search-value</span><span class="dl">"</span><span class="o">></span><span class="p">{</span><span class="nx">searchValue</span><span class="p">}</span><span class="o"><</span><span class="sr">/span</span><span class="err">>
</span> <span class="o"><</span><span class="sr">/div</span><span class="err">>
</span> <span class="p">)}</span>
<span class="o"><</span><span class="sr">/div</span><span class="err">>
</span> <span class="p">)}</span>
<span class="o"><</span><span class="sr">/SidebarHeader</span><span class="err">>
</span> <span class="o"><</span><span class="nx">DateHeader</span> <span class="nx">unit</span><span class="o">=</span><span class="dl">"</span><span class="s2">primaryHeader</span><span class="dl">"</span> <span class="o">/></span>
<span class="o"><</span><span class="nx">DateHeader</span> <span class="o">/></span>
<span class="o"><</span><span class="sr">/TimelineHeaders</span><span class="err">>
</span> <span class="o"><</span><span class="sr">/CalendarTimeline</span><span class="err">>
</span> <span class="p">)}</span>
<span class="o"><</span><span class="sr">/div</span><span class="err">>
</span> <span class="p">);</span>
<span class="p">}</span>
<span class="p">}</span>
<span class="nx">Timeline</span><span class="p">.</span><span class="nx">defaultProps</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">apiUrl</span><span class="p">:</span> <span class="dl">'</span><span class="s1">/api/v2/pages/?limit=100</span><span class="dl">'</span><span class="p">,</span>
<span class="na">className</span><span class="p">:</span> <span class="dl">''</span><span class="p">,</span>
<span class="na">initialSearchValue</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="na">searchFormId</span><span class="p">:</span> <span class="kc">null</span><span class="p">,</span>
<span class="p">};</span>
<span class="nx">Timeline</span><span class="p">.</span><span class="nx">propTypes</span> <span class="o">=</span> <span class="p">{</span>
<span class="na">apiUrl</span><span class="p">:</span> <span class="nx">PropTypes</span><span class="p">.</span><span class="nx">string</span><span class="p">,</span>
<span class="na">className</span><span class="p">:</span> <span class="nx">PropTypes</span><span class="p">.</span><span class="nx">string</span><span class="p">,</span>
<span class="na">initialSearchValue</span><span class="p">:</span> <span class="nx">PropTypes</span><span class="p">.</span><span class="nx">string</span><span class="p">,</span>
<span class="na">searchFormId</span><span class="p">:</span> <span class="nx">PropTypes</span><span class="p">.</span><span class="nx">string</span><span class="p">,</span>
<span class="p">};</span>
<span class="k">export</span> <span class="k">default</span> <span class="nx">Timeline</span><span class="p">;</span>
</code></pre>
</div>
<ul>
<li>For the sake of development testing, we can revise our demo (index.jsx) to include a search box.
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight javascript"><code><span class="c1">// timeline/client/src/index.jsx</span>
<span class="k">import</span> <span class="nx">React</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">render</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">react-dom</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">Timeline</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">./components/Timeline</span><span class="dl">'</span><span class="p">;</span>
<span class="nf">render</span><span class="p">(</span>
<span class="o"><</span><span class="nx">main</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">main</span><span class="dl">"</span><span class="o">></span>
<span class="o"><</span><span class="nx">header</span> <span class="nx">role</span><span class="o">=</span><span class="dl">"</span><span class="s2">banner</span><span class="dl">"</span><span class="o">></span>
<span class="o"><</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">row nice-padding</span><span class="dl">"</span><span class="o">></span>
<span class="o"><</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">left</span><span class="dl">"</span><span class="o">></span>
<span class="o"><</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">col header-title</span><span class="dl">"</span><span class="o">></span>
<span class="o"><</span><span class="nx">h1</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">icon icon-</span><span class="dl">"</span><span class="o">></span><span class="nx">Timeline</span><span class="o"><</span><span class="sr">/h1</span><span class="err">>
</span> <span class="o"><</span><span class="sr">/div</span><span class="err">>
</span> <span class="o"><</span><span class="sr">/div</span><span class="err">>
</span> <span class="o"><</span><span class="nx">div</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">right</span><span class="dl">"</span><span class="o">></span>
<span class="o"><</span><span class="nx">label</span> <span class="nx">htmlFor</span><span class="o">=</span><span class="dl">"</span><span class="s2">id_q</span><span class="dl">"</span><span class="o">></span>
<span class="nx">Search</span> <span class="nx">term</span><span class="p">:</span>
<span class="o"><</span><span class="nx">input</span> <span class="nx">type</span><span class="o">=</span><span class="dl">"</span><span class="s2">text</span><span class="dl">"</span> <span class="nx">name</span><span class="o">=</span><span class="dl">"</span><span class="s2">q</span><span class="dl">"</span> <span class="nx">id</span><span class="o">=</span><span class="dl">"</span><span class="s2">id_q</span><span class="dl">"</span> <span class="nx">placeholder</span><span class="o">=</span><span class="dl">"</span><span class="s2">Search</span><span class="dl">"</span> <span class="o">/></span>
<span class="o"><</span><span class="sr">/label</span><span class="err">>
</span> <span class="o"><</span><span class="sr">/div</span><span class="err">>
</span> <span class="o"><</span><span class="sr">/div</span><span class="err">>
</span> <span class="o"><</span><span class="sr">/header</span><span class="err">>
</span> <span class="o"><</span><span class="nx">Timeline</span> <span class="nx">className</span><span class="o">=</span><span class="dl">"</span><span class="s2">additional-class</span><span class="dl">"</span> <span class="nx">searchFormId</span><span class="o">=</span><span class="dl">"</span><span class="s2">id_q</span><span class="dl">"</span> <span class="o">/></span>
<span class="o"><</span><span class="sr">/main></span><span class="err">,
</span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">root</span><span class="dl">'</span><span class="p">),</span>
<span class="p">);</span>
</code></pre>
</div>
<ul>
<li>Add a bit of CSS polish, align the colours with Wagtail's Admin & make the timeline header sticky (Note: Will not work on IE11).
</li>
</ul>
<div class="highlight js-code-highlight">
<pre class="highlight css"><code><span class="c">/* timeline/client/src/components/Timeline/timeline.css */</span>
<span class="nc">.timeline</span> <span class="nc">.react-calendar-timeline</span> <span class="nc">.rct-header-root</span> <span class="p">{</span>
<span class="nl">background</span><span class="p">:</span> <span class="m">#007d7e</span><span class="p">;</span> <span class="c">/* wagtail teal */</span>
<span class="nl">position</span><span class="p">:</span> <span class="n">sticky</span><span class="p">;</span>
<span class="nl">top</span><span class="p">:</span> <span class="m">0</span><span class="p">;</span>
<span class="nl">z-index</span><span class="p">:</span> <span class="m">90</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.timeline</span> <span class="nc">.search</span> <span class="p">{</span>
<span class="nl">align-items</span><span class="p">:</span> <span class="nb">center</span><span class="p">;</span>
<span class="nl">color</span><span class="p">:</span> <span class="no">white</span><span class="p">;</span>
<span class="nl">display</span><span class="p">:</span> <span class="n">flex</span><span class="p">;</span>
<span class="nl">height</span><span class="p">:</span> <span class="m">100%</span><span class="p">;</span>
<span class="nl">padding</span><span class="p">:</span> <span class="m">1rem</span><span class="p">;</span>
<span class="p">}</span>
<span class="nc">.timeline</span> <span class="nc">.search</span> <span class="nc">.search-label</span> <span class="p">{</span>
<span class="nl">text-transform</span><span class="p">:</span> <span class="nb">uppercase</span><span class="p">;</span>
<span class="nl">padding-right</span><span class="p">:</span> <span class="m">0.25rem</span><span class="p">;</span>
<span class="p">}</span>
</code></pre>
</div>
<h2>
Step 10 - Final View & Future Improvements
</h2>
<ul>
<li>Now, run <code>npm run build</code> and test on your Wagtail instance. Also test submitting the form (pressing enter) after typing in the search box.</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.com%2Flb-%2Fbakerydemo%2Fraw%2Ftutorial%2Freact-timeline-in-wagtail-admin%2Fbakerydemo%2Ftimeline%2Fclient%2Fscreenshots%2Fwagtail-timeline-screenshot-final-state.png" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.com%2Flb-%2Fbakerydemo%2Fraw%2Ftutorial%2Freact-timeline-in-wagtail-admin%2Fbakerydemo%2Ftimeline%2Fclient%2Fscreenshots%2Fwagtail-timeline-screenshot-final-state.png" alt="Final - Timeline with API data & header search"></a></p>
<ul>
<li>Here is an animation of the final state.</li>
</ul>
<p><a href="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.com%2Flb-%2Fbakerydemo%2Fraw%2Ftutorial%2Freact-timeline-in-wagtail-admin%2Fbakerydemo%2Ftimeline%2Fclient%2Fscreenshots%2Fwagtail-timeline-animation-final-state.gif" class="article-body-image-wrapper"><img src="proxy.php?url=https://media.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fgithub.com%2Flb-%2Fbakerydemo%2Fraw%2Ftutorial%2Freact-timeline-in-wagtail-admin%2Fbakerydemo%2Ftimeline%2Fclient%2Fscreenshots%2Fwagtail-timeline-animation-final-state.gif" alt="Final - Timeline with API data & header search (animation)"></a></p>
<h3>
Future Improvements
</h3>
<ul>
<li>This is a read-only timeline, and there are lots of ways this could be improved upon.</li>
<li>You could add milestones or coloured parts of the timeline bar to indicate when the Page has had changes or whether the Page is live or still a draft.</li>
<li>You may want to add the ability to click on a Page in the timeline and then a popover will show additional info and links.</li>
<li>Grouping should be specific to your Wagtail use case, you could even have various versions of the timeline that group in different ways (adding a Django view button to the header that will then be listened to by the React component).</li>
<li>Finally, you could add the ability to drag & drop or edit in the timeline, possibly to even determine when posts or pages will go live.</li>
</ul>
<h2>
References & Links
</h2>
<p>Thanks to some of the Wagtail core team and Adam who helped proofread this.</p>
<h3>
Links
</h3>
<ul>
<li><a href="proxy.php?url=https://neutrinojs.org/" rel="noopener noreferrer">Neutrinojs Docs</a></li>
<li><a href="proxy.php?url=https://github.com/facebook/create-react-app#popular-alternatives" rel="noopener noreferrer">Create React App Alternatives</a></li>
<li><a href="proxy.php?url=http://docs.wagtail.io/en/latest/" rel="noopener noreferrer">Wagtail Docs</a></li>
<li><a href="proxy.php?url=https://github.com/wagtail/wagtail/blob/stable/2.7.x/package.json" rel="noopener noreferrer">Wagtail package.json @ 2.7</a></li>
<li><a href="proxy.php?url=https://github.com/lb-/bakerydemo/tree/tutorial/react-timeline-in-wagtail-admin/bakerydemo/timeline" rel="noopener noreferrer">Full code branch for this tutorial</a></li>
<li>
<a href="proxy.php?url=https://github.com/lb-/bakerydemo/blob/demo/publishing-timeline-nwb-round-two/bakerydemo/timeline/client/README.md" rel="noopener noreferrer">nwb implementation of this tutorial</a> <em>quite rough, not polished</em>
</li>
</ul>
<h3>
Versions used
</h3>
<p><em>As at writing.</em></p>
<ul>
<li>Django 2.3</li>
<li>Wagtail 2.7 (LTS)</li>
<li>Python 3</li>
<li>React 16.4</li>
<li>Node 10</li>
<li>Neutrinojs 9.0.0-rc.5 <strong>Pre-release</strong>
</li>
<li>React Calendar Timeline 0.27</li>
</ul>
reactwagtaildjangoneutrino