-
-
Notifications
You must be signed in to change notification settings - Fork 182
New Frontend Architecture
CourtListener is currently undergoing a comprehensive redesign of the entire front-end, with the main goal of improving user experience by enhancing both visual design and overall accessibility.
To do this, we're using an incremental approach by rolling out new pages one at a time behind a waffle flag (use_new_design
). This means you can only see redesigned pages as they are created if we have opted you into this waffle. If you have the waffle enabled, using CourtListener becomes more difficult, since some pages will be upgraded and others will not.
This is also an opportunity to improve DX and modernize our frontend stack, which is why we're using entirely different tools from those in current templates. Instead of using Bootstrap, DTL includes, and a lot of custom CSS, we're now using Tailwind CSS for styling, Django Cotton for components (which means we're not using includes
anymore), and AlpineJS for interactivity (so no more jQuery! 🎉). In this wiki you will find an explanation of how we're using each of these tools, and the reasoning behind it.
For... | We now use... |
---|---|
Styling | TailwindCSS (jump to section) |
Making reusable components | Django Cotton (jump to section) |
Interactivity | AlpineJS (jump to section) |
This new stack means we now STOP using some tools from the old templates. In particular, and following RFC2119:
- New templates MUST NOT use jQuery, Bootstrap, Font Awesome, React.
- New templates SHOULD NOT use includes.
The incremental roll-out is managed transparently by the IncrementalNewTemplateMiddleware
, which checks the waffle flag and if enabled, swaps the old template with the new one. To do this, we append v2_
to the name of the template exactly as it's referenced in the view, which means if the template is referenced as "help/index.html"
, then the new template should be in "v2_help/index.html"
, not "help/v2_index.html"
. Please keep in mind new templates should extend from new_base.html
instead of base.html
to use the new stack and design pattern!
To help ensure the old and new templates remain in sync, we include a prominent notice at the very top of the legacy templates as we include their new version. This alerts devs that any new changes to templates must be completed in both versions. Use the following format, updating the path to match the new template:
Notice
{% comment %}
╔═════════════════════════════════════════════════════════════════════════╗
║ ATTENTION! ║
║ This template has a new version behind the use_new_design waffle flag. ║
║ ║
║ When modifying this template, please also update the new version at: ║
║ cl/simple_pages/templates/v2_help/alert_help.html ║
║ ║
║ Once the new design is fully implemented, all legacy templates ║
║ (including this one) and the waffle flag will be removed. ║
╚═════════════════════════════════════════════════════════════════════════╝
{% endcomment %}
Waffle flags are instances of the Flag
model, which has its own admin page (/admin/waffle/flag
). When you load any page in CourtListener in your dev environment for the first time, Django automatically creates the flag in an active state if no flag with the same name existed in your DB already (please note this is not Waffle's default behavior, see our settings here). This is why you get the new templates in your local environment without doing anything, even if you don't see them in the live site.
To turn new templates off, you simply need to modify the flag with the name use_new_design
if it was created by Django, or create it yourself in a disabled state. Waffle flags allow for many different settings, but using the everyone
field overrides all other options, so simply toggling that field is enough to change between old and new templates.
With the above configuration you'll see old templates only.
To keep styles consistent across the entire site and make development easier, we now use TailwindCSS instead of custom CSS in override.css
. This means any new templates must NOT contain any custom CSS from override.css
, as that file is not included in the new base template and will eventually be removed once the new design is launched.
All branding values like colors, spacing and fonts should be added to the tailwind.config.js
file whenever possible, and any other custom styles in the input.css
file (both files are under cl/assets/tailwind
). However, it is highly discouraged to create new utility classes unless strictly necessary to prevent the CSS file from growing too much and therefore getting harder to maintain. In this case, we don't mind some repetition within the same file: we can always use IDE tools like multi-cursor editing.
The way Tailwind generates the final CSS stylesheet is by scanning all the directories included in the content
section in tailwind.config.js
. Any classes added in files not tracked by Tailwind will be missed, so make sure the files where you're using Tailwind classes are listed!
This also means the classes should never be dynamically added to the HTML. If you do something like text-greyscale-${this.isActive ? '800' : '400'}
, it will NOT be recognized by tailwind. Instead you should do ${this.isActive ? 'text-greyscale-800' : 'text-greyscale-400'}
or some other way in which the full class name is visible before any code execution.
- Include branding values in
tailwind.config.js
. - Include custom styles in
input.css
, but avoid doing so unless necessary. - Include files with Tailwind classes in the
content
section of thetailwind.config.js
file. - Do not generate classes dynamically, or Tailwind will NOT include them in the final stylesheet.
Why use another library for components when we can simply use DTL's {% include %}
tags, along with the {% extends %}
and {% block %}
system? As it turns out, for a lot of reasons! Some of them are:
- HTML tag-like syntax allows IDE's to recognize components as HTML elements, unlike
{% include %}
. - Multi-line definition support: this makes development much easier, and is not supported by Django native where you can't have line breaks within tags. Having all attributes visible at once makes it easier to know at a glance what's going on.
- Decouples child and parent templates, unlike the use of
{% block %}
and{% extends %}
tags which are tightly coupled. - Named slots.
- etc...
For all of these reasons, we are not using includes
anymore! Keep reading below to learn how we're approaching the creation of reusable templates.
To create a component, save the HTML under cl/APP_NAME/templates/cotton
using snake_case for the filename, for example my_component.html
. Then, you can call the component using kebab-case prefixed by c-
from any other template, for example <c-my-component />
. No need to import it or register anywhere else: Django Cotton picks it up automatically if you follow those conventions.
In general, most reusable components should be included in cl/assets/templates/cotton
, unless they are only relevant to a specific app in the project, in which case they should be in the appropriate app's templates dir.
Components can declare a set of attributes with or without default values as key-value pairs via the <c-vars />
tag, that should be placed at the top of a component. Only a single <c-vars />
tag is supported per component.
{# my_component.html #}
<c-vars title />
<div>
<h2>{{ title }}</h2>
</div>
{# view.html #}
<c-my-component title="What's up!" />
Passing an attribute to a component will, by default, treat it as a string. To pass dynamic data, like objects from the view context or other python types like integers or lists, prepend :
to the attribute name. Note you should not call context variables using curly braces when using this notation: :some-attr="{{ some_context_var }}"
will not work, do :some-attr="some_context_var"
instead.
The default slot is the content provided between the opening and closing tag of the component. This content will be provided as {{ slot }}
to the component. More complex components can contain multiple slots using named slots: in your component definition use {{ some_slot_name }}
, and include the content inside <c-slot name="some_slot_name">Some content</c-slot>
, which should be a direct child of the component call.
Putting it all together in one example:
{# my_component.html #}
<c-vars some_list title="Hello!" />
<section>
<h2>{{ title }}</h2>
<div>{{ slot }}</div>
<ul>
{% for item in some_list %}
<li>{{ item }}</li>
{% endfor %}
</ul>
<div>{{ slot_name }}</div>
</section>
{# view.html #}
<c-my-component
title="This title overrides the default Hello!"
:some_list="['first', 'second']"
>
This content will be included in the first div
<c-slot name="slot_name">Some content in the bottom div</c-slot>
</c-my-component>
For more advanced uses, please refer to Cotton's docs and README.
We made a Component Library, and we want to keep it updated, so all new components should introduce a new entry in the library with the same four sections as all other entries: "Demo", "Props", "Slots" and "Code". Please include all four even if one or more are empty; this makes it explicit that they are indeed empty and not just missing.
The "Demo" section is to display the component in action, hopefully in a way that showcases most if not all of its features.
The "Props" section lists all component attributes declared in <c-vars />
, with an "Optional" label when appropriate, and with a concise description of what it is and how to use it.
The "Slots" section lists all slots available within the component. Most components will only have a default slot or even none at all.
The "Code" section provides the actual code for the component Demo. Refer to other entries in the library for syntax, but note that you want to use HTML entities for reserved characters: for example, use <
instead of <
, and >
instead of >
.
- Components should be placed in the
templates/cotton
folder. - Component filenames use snake_case, for example
my_component.html
. - Components are called using kebab-case prefixed by 'c-', for example
<c-my-component />
. - Declare component attributes with a single
<c-vars />
at the top of the component. - Prepend ':' to the attribute name to pass dynamic data.
- Named slots are called with
<c-slot name="SLOT_NAME">Your content here</c-slot>
. - Add all components to the Component Library.
AlpineJS is a lightweight framework that provides an easy and powerful way to sprinkle some JavaScript into your templates, so you can build reactive UI's that the users can interact with. Under the hood, Alpine uses Vue's reactivity engine, and you can read more about it here.
Please note that since we're now using Alpine, we will not be using jQuery anymore nor base.js
. These will eventually be removed from the repo!
- Always use the
x-
prefixed directives to make it explicit that it's an Alpine-managed attribute.- Use
x-on:click
instead of@click
. - Use
x-bind:something
instead of:something
.
- Use
Alpine does not operate over the entire DOM, so we need to explicitly define some chunk of HTML as an Alpine section. This is what the x-data
directive is for. You can use a bare x-data
, or reference some Alpine.data(...)
reusable context by passing the Alpine data context name like so: x-data="dataContextName"
.
document.addEventListener('alpine:init', () => {
Alpine.data('dropdown', () => ({
open: false,
toggle() {
this.open = ! this.open
},
}));
});
<div x-data="dropdown">
<button @click="toggle">...</button>
<div x-show="open">...</div>
</div>
Note that inside our Data
object, this
refers to the context instance initialized by the closest parent that has an x-data
directive. In it, you can access:
-
this.$el
: the HTML element that is calling the property -
this.$root
: the parent element that initialized the context (the closest one withx-data
) -
this.$nextTick
: a method that lets you execute an expression after Alpine has made its reactive DOM updates. To understand why this can be useful, see an example here. - Lots more! You can find them in the same link.
CourtListener has very strict CSP, so we can have no inline JavaScript at all; this means we need to use a special Alpine build that is CSP-friendly. Sadly, and this is very important: virtually all Alpine docs use inline JavaScript for examples, so most of them will NOT be applicable to our codebase! But if we can't use inline JavaScript, how are we supposed to sprinkle the JavaScript into the template!? Good question! We have to reference any variables or methods by key. The above example is valid with the CSP-friendly build, but the snippet below is not:
<span x-data="{ enabled: false }">
<button @click.prevent="enabled = !enabled">Toggle</button>
<template x-if="enabled">
<span x-data="timer" x-text="counter"></span>
</template>
</span>
What's problematic here is the { enabled: false }
and enabled = !enabled
bits, because these are not plain keys but rather JS that needs to be parsed, which is disallowed for security reasons. In contrast, nested properties accessed using the dot notation are allowed.
This results in a more verbose codebase with a bit more boilerplate, but it also means we keep concerns neatly separated: our templates are only markup, declaring what is in our document, while the scripts declare how it works.
Warning: Some core Alpine features require usafe-eval
so they are not supported in CSP-friendly build, including x-model
and x-modelable
. This is unfortunate, but we've decided to live with it and lean on data-
attributes and events to achieve two-way binding.
There's 3 types of Alpine scripts:
Type | Location | Description |
---|---|---|
Plugins | cl/assets/static-global/js/alpine/plugins |
Provided by AlpineJS to extend it beyond the default built-in features. |
Components | cl/assets/static-global/js/alpine/components |
Custom scripts for a specific Cotton component. |
Composables | cl/assets/static-global/js/alpine/composables |
Reusable functions that encapsulate stateful logic, but are not specific to a particular component. |
Out of all 3 types, only Components and Composables are custom code, while Plugins are provided by Alpine. To write custom scripts that Alpine picks up on, we add event listeners for the alpine:init
event that is fired by Alpine after initializing, and we include any Alpine code in the event callback:
document.addEventListener('alpine:init', () => {
Alpine.store('darkMode', false);
});
Alpine provides some plugins that extend the functionality of the core framework, like Intersect, Focus, and Collapse. These are not included by default, so whenever a component or template requires them, they should be explicitly injected using the require_script
template tag (cl/custom_filters/templatetags/component_tags.py
):
{% load component_tags %}
{% require_script "js/alpine/plugins/[email protected]" defer=True %}
{# Now you can use the intersect directive: #}
<div x-intersect="someCallback">...</div>
Despite not being included in the above snippet, Alpine directives cannot be used if no parent element has x-data
defined. You can also add x-data
to the element itself, which should also work. Just remember: if your Alpine code isn't running at all, this may be why, so check if you're exposing your HTML section to Alpine correctly.
When injecting the plugins with require_script
, do NOT include the file extension (see example above). This is important so Django only uses the non-minified version when DEBUG=True
. Plugins also need to be deferred, so don't forget to add defer=True
.
As stated above, Alpine plugins are not custom code, but we keep them in our assets to serve them ourselves without relying on a third-party CDN. If you need to use a plugin that we haven't included yet, please keep in mind the following:
- Save the files in
cl/assets/static-global/js/alpine/plugins
. - Add both the minified and non-minified versions and follow conventions: [email protected] for the minified version, and [email protected] for the non-minified one, both in the same
alpine/plugins
directory.
Any JS that is specific to one of our Cotton Components should live in a separate JS file with the same name as said component, and it should be saved under cl/assets/static-global/js/alpine/components
.
For example, the JS script for cl/assets/templates/cotton/my_component.html
should be under cl/assets/static-global/js/alpine/components/my_component.js
.
// cl/assets/static-global/js/alpine/components/my_component.js
document.addEventListener('alpine:init', () => {
Alpine.data('myComponent', () => ({
someTitle: 'This will show first',
changeTitle() {
this.someTitle = 'New title!';
},
}));
});
Then, we simply inject the script in our component definition like so:
{# cl/assets/templates/cotton/my_component.html #}
{% load component_tags %}
{% require_script "js/alpine/components/my_component.js" %}
<div x-data="myComponent">
<h2 x-text="someTitle"></h2>
<button type="button" x-on:click="changeTitle">Change it!</button>
</div>
These are simple utilities that encapsulate some logic for a specific thing. To date, the ones we have are:
-
clipboard.js
to copy some content to the clipboard. -
focus.js
to handle focus programmatically. -
intersect.js
to detect when an element is visible.
To use them, we simply inject them:
{% load component_tags %}
{% require_script "js/alpine/composables/clipboard.js" %}
Now we can add the appropriate x-data
context name to an element to expose the composable to it and all its children, like so:
{% load component_tags %}
{% require_script "js/alpine/composables/focus.js" %}
<div x-data="focus">
<div x-on:keyup.right="focusNext">
<button>First</button>
<button>Second</button>
</div>
</div>
- First and foremost: CSP-friendly build means no inline JavaScript! This means:
- The examples in Alpine's docs won't work in our setup :(
- Always reference JS entities with plain keys from Alpine objects declared in separate scripts.
- Inject scripts using the
require_script
template tag.- Plugins injected this way should NOT include the file extension and SHOULD be deferred (
defer=True
).
- Plugins injected this way should NOT include the file extension and SHOULD be deferred (
- Keep it explicit: Always use the
x-
prefix for any and all Alpine attributes and directives.- Use
x-on:click
instead of@click
. - Use
x-bind:something
instead of:something
.
- Use
- Expose a section of the DOM to Alpine by using
x-data
.
TailwindCSS |
---|
Include branding values in tailwind.config.js . |
Include custom styles in input.css , but avoid doing so unless necessary. |
Include files with Tailwind classes in the content section of the tailwind.config.js file. |
Do not generate classes dynamically, or Tailwind will NOT include them in the final stylesheet. |
Django Cotton |
---|
Components should be placed in the templates/cotton folder. |
Component filenames use snake_case, for example my_component.html . |
Components are called using kebab-case prefixed by 'c-', for example <c-my-component /> . |
Declare component attributes with a single <c-vars /> at the top of the component. |
Prepend ':' to the attribute name to pass dynamic data. |
Named slots are called with <c-slot name="SLOT_NAME">Your content here</c-slot> . |
Add all components to the Component Library. |
AlpineJS |
---|
First and foremost: CSP-friendly build means no inline JavaScript! So remember the examples in Alpine's docs won't work in our setup. |
Inject scripts using the require_script template tag. When injecting plugins, do not include the file extension. |
Keep it explicit: Always use the x- prefix for any and all Alpine attributes and directives. |
Expose a section of the DOM to Alpine by using x-data . |