Dynamic templates


Initial setup

To implement the "dynamic template" type, a new type called json_api_collection exists (Drupal side), which allows exposing the results of an entity (Node, taxonomy, user, custom entity, etc.) in compliance with the JSON API standards.

https://www.drupal.org/docs/core-modules-and-themes/core-modules/jsonapi-module/api-overview

Here's an example

./modules/custom/my_custom_module/news/widgets/list/settings.yml

name: "List"
multiple: FALSE
category: "News"
enabled: TRUE
fields:
collection:
type: json_api_collection
label: "JSON:API"
options:
"#required": TRUE
"#default_value":
id: "vactory_news_list"
# The ressource we want retreive. (required)
resource: node--vactory_news
# The JSON API filters. (required)
filters:
- fields[node--vactory_news]=drupal_internal__nid,path,title,field_vactory_news_theme,field_vactory_media,field_vactory_excerpt,field_vactory_date
- fields[taxonomy_term--vactory_news_theme]=tid,name
- fields[media--image]=name,thumbnail
- fields[file--image]=filename,uri
- include=field_vactory_news_theme,field_vactory_media,field_vactory_media.thumbnail
- page[offset]=0
- page[limit]=9
- sort[sort-vactory-date][path]=field_vactory_date
- sort[sort-vactory-date][direction]=DESC
- filter[status][value]=1

So, as we have already learned in the Static templates section, we need to define a mapping template in the Next.js app.

./components/modules/news/NewsListWidget.jsx

export const config = {
id: "vactory_news:list",
}
const NewsListWidget = ({ data }) => {
console.log('news listing data', data)
return <>News listing</>
})
export default NewsListWidget

We can go a step further and show the news items

./components/modules/news/NewsListWidget.jsx

import { normalizeNodes } from "./normalizer"
import { EmptyBlock } from "@/ui"
import { NewsCard } from "./NewsCard"
const NewsListWidget = ({ data }) => {
// Deserialise the JSON API data.
const context = useCollectionContext(data)
// Fetch data based on params filters.
const collection = useCollectionFetcher({
type: "node",
bundle: "vactory_news",
initialPosts: context.nodes,
initialPostsCount: context.count,
params: filters,
})
// Format nodes.
const posts = normalizeNodes(collection.posts)
// Loop into news items.
return (
<div className="relative pt-4 pb-4 lg:pt-10 lg:pb-10">
{posts.length > 0 ? (
<div className="grid gap-5 mx-auto lg:grid-cols-3">
{posts.map((post) => (
<React.Fragment key={post.id}>
<NewsCard {...post} />
</React.Fragment>
))}
</div>
) : (
<EmptyBlock />
)}
</div>
)
})

The normalizer file would look like this :

./components/modules/news/normalizer.js

import get from "lodash.get"
import { stripHtml } from "@vactorynext/core/lib"
import truncate from "truncate"
export const normalizeNodes = (nodes) => {
return (
nodes?.map((post) => ({
id: post.drupal_internal__nid,
title: get(post, "title", null),
url: get(post, "path.alias", "#."),
excerpt: truncate(stripHtml(get(post, "field_vactory_excerpt.processed", "")), 100),
category: get(post, "field_vactory_news_theme", [])
.map((term) => {
return {
name: term.name,
slug: term?.term_2_slug || "",
}
})
.filter((t) => typeof t !== "undefined"),
image: {
src: post?.field_vactory_media?.thumbnail?.uri?.value?._default,
width: post?.field_vactory_media?.thumbnail?.meta?.width,
height: post?.field_vactory_media?.thumbnail?.meta?.height,
},
image_alt: post?.field_vactory_media?.thumbnail?.meta?.alt,
date: get(post, "field_vactory_date", null),
})) || []
)
}

Pagination

You need to import the Pagination from the ui package

./components/modules/news/NewsListWidget.jsx

import { Pagination } from "@/ui"

Start by adding the following constants

./components/modules/news/NewsListWidget.jsx

// If we paginate, the scroll referencing pagination will allow us to scroll back to the top of the listing results.
const scrollRefPagination = useRef()
const router = useRouter()
// The current lang.
const locale = router.locale
// Get the page aliases.
const { path_18n } = useNode()
// Get the page alias for the current lang.
const current_page_alias = path_18n[locale]
// Defaut page limit.
const defaultPageLimit = 9
// Get the current pager from the context.
const [pager, setPager] = useState(context.pager)
// The filters used by the current listing.
const [filters, setFilters] = useState(context.filters)
// The node alias path with page param.
const nodeAliasPath = `${current_page_alias}?page={page}`
// Shallow url allows us to change the URL without running data fetching methods again.
const [shallowUrl, setShallowUrl] = useState(nodeAliasPath)

Next, we'll create a Pagination component to render our pagination

./components/modules/news/NewsListWidget.jsx

{
parseInt(collection.count) > parseInt(filters?.page?.limit || defaultPageLimit) && (
<Container className="px-4 pb-4 sm:px-6 lg:pb-8 lg:px-8">
<Pagination
baseUrl={`${process.env.NEXT_PUBLIC_BASE_URL}${shallowUrl.replace(
/page=\d/,
"page={page}"
)}`}
contentRef={scrollRefPagination}
pageSize={filters?.page?.limit || defaultPageLimit}
current={pager}
total={collection.count}
isLoading={!collection.isLoading}
onChange={(page) => setPager(page)}
/>
</Container>
)
}

Finally, we'll define the useUpdateEffect function to fetch data using the newly selected pager.

./components/modules/news/NewsListWidget.jsx

useUpdateEffect(() => {
// Update shallow url.
setShallowUrl(nodeAliasPath.replace("{page}", pager))
// Update filters.
setFilters((prev) => {
let filters = {
...prev,
}
// Update pager.
filters.page = {
...filters.page,
offset: (pager - 1) * (filters?.page?.limit || defaultPageLimit),
}
return filters
})
}, [pager])
// Update page url using shallow.
useUpdateEffect(() => {
router.push(shallowUrl, undefined, { shallow: true })
}, [shallowUrl])

Filter

In addition to the JSON API filters, we can add another key called "vocabulaires" that allows exposing all the terms of the mentioned taxonomies. This was used to build the filters on the frontend side.

./modules/custom/my_custom_module/news/widgets/list/settings.yml

collection:
type: json_api_collection
label: 'JSON:API'
options:
'#required': TRUE
'#default_value':
id: "vactory_news_list"
# The ressource we want retreive. (required)
resource: node--vactory_news
# The JSON API filters. (required)
filters:
- fields[node--vactory_news]=drupal_internal__nid,path,title,field_vactory_news_theme,field_vactory_media,field_vactory_excerpt,field_vactory_date
- fields[taxonomy_term--vactory_news_theme]=tid,name
- fields[media--image]=name,thumbnail
- fields[file--image]=filename,uri
- include=field_vactory_news_theme,field_vactory_media,field_vactory_media.thumbnail
- page[offset]=0
- page[limit]=9
- sort[sort-vactory-date][path]=field_vactory_date
- sort[sort-vactory-date][direction]=DESC
- filter[status][value]=1
# Aditionnal to filled filters on NextJS side. (optionnal)
vocabularies:
vactory_news_theme: vactory_news_theme

On the client side, our next step is to present an input select that is pre-filled with the taxonomy terms.

The context.terms object holds all of your taxonomy terms.

Let's update ./components/modules/news/NewsListWidget.jsx

The first step, we need to establish a state variable that will keep track of the selected term

./components/modules/news/NewsListWidget.jsx

// Extract term slug from node alias and convert it to term id.
const defaultTheme = generateIdsFromTermsSlug(systemRoute?._query?.theme, allThematic.id)
const [selectedTheme, setSelectedTheme] = useState(defaultTheme)

As we require an input select, we will need to wrappe it within a form element. Consequently, we will utilize the react-hook-form package to handle the form.

./components/modules/news/NewsListWidget.jsx

// Fill filter form with default values.
const { handleSubmit, reset, control } = useForm({
defaultValues: {
theme: defaultTheme,
},
})

We can now display our select input by including the following JSX code.

./components/modules/news/NewsListWidget.jsx

<form onSubmit={handleSubmit(submitFilterForm)}>
<div className="flex flex-col space-y-4 md:flex-row md:space-y-0 md:space-x-4">
<div>
<Controller
name="theme"
control={control}
render={({ field: { onChange, onBlur, value } }) => (
<SelectNative
list={context.terms?.vactory_news_theme || []}
onChange={onChange}
onBlur={onBlur}
id="news-theme"
defaultValue={value}
label={t("Nx:Thématique")}
variant="filter"
/>
)}
/>
</div>
<div className="flex flex-row justify-center items-center gap-4">
<Button id="news-submit" type="submit" variant="primary">
{t("Nx:Appliquer")}
</Button>
</div>
</div>
</form>

Before saving the changes, we must define the submit function called submitFilterForm

./components/modules/news/NewsListWidget.jsx

const submitFilterForm = (data) => {
// Get the value of the selected filter
const selectedTheme = context.terms?.vactory_news_theme.find((theme) => {
return theme.id === data?.theme
})
setSelectedTheme(data?.theme)
// It is necessary to reset the pager to 1.
setPager(1)
}

Additionally, we need to include the selected term in the filters state so that we can fetch new data based on the chosen term.

./components/modules/news/NewsListWidget.jsx

useUpdateEffect(() => {
// Update shallow url.
setShallowUrl(nodeAliasPath.replace("{page}", pager))
// Update filters.
setFilters((prev) => {
let filters = {
...prev,
}
// Update pager.
filters.page = {
...filters.page,
offset: (pager - 1) * (filters?.page?.limit || defaultPageLimit),
}
if (!selectedTheme || selectedTheme === allThematic.id) {
// try to delete previously set theme filters.
delete filters?.filter?.theme
} else {
// Add a theme filter.
filters.filter.theme = {
condition: {
path: "field_vactory_news_theme.drupal_internal__tid",
operator: "=",
value: selectedTheme,
},
}
}
return filters
})
}, [pager, selectedTheme])

The json api collection alter hook

We will encounter a problem with our news listing when requesting the page /en/news yields the same results as /en/news?page=2, /en/news?page=3 ...

To provide a better understanding of the data flow in Next.js, the diagram below illustrates the exact process:

The same data flow occurs when requesting /en/news?page=2. Drupal consistently prepares the news listing data based on the filters specified in the settings.yml file. This means that regardless of the page number specified in the URL, Drupal applies the same filtering criteria to generate the results for the news listing data.

To address this problem, a new hook is implemented on the Drupal side called hook_json_api_collection_alter(&$filters, &$context). This hook allows developers to modify the filters used in the JSON API collection request. By utilizing this hook, custom logic can be applied to ensure that the requested page number is properly taken into account when preparing the news listing data.

Before implementing the hook, it's mandatory to assign a unique ID to our json_api_collection item. This unique ID serves as a key identifier for the specific collection item and allows for easy manipulation and modification within the hook implementation.

./modules/custom/my_custom_module/news/widgets/list/settings.yml

collection:
type: json_api_collection
label: 'JSON:API'
options:
'#required': TRUE
'#default_value':
id: "vactory_news_list"
# The ressource we want retreive. (required)
resource: node--vactory_news
# The JSON API filters. (required)
filters:
- fields[node--vactory_news]=drupal_internal__nid,path,title,field_vactory_news_theme,field_vactory_media,field_vactory_excerpt,field_vactory_date
- fields[taxonomy_term--vactory_news_theme]=tid,name
- fields[media--image]=name,thumbnail
- fields[file--image]=filename,uri
- include=field_vactory_news_theme,field_vactory_media,field_vactory_media.thumbnail
- page[offset]=0
- page[limit]=9
- sort[sort-vactory-date][path]=field_vactory_date
- sort[sort-vactory-date][direction]=DESC
- filter[status][value]=1
# Aditionnal to filled filters on NextJS side. (optionnal)
vocabularies:
vactory_news_theme: vactory_news_theme

Lastly, we will define our hook implementation to override the filters for the news listing. By overriding the filters, we can ensure that the correct set of news listings is fetched based on the specified page.

./modules/custom/my_custom_module/my_custom_module.module

function hook_json_api_collection_alter(&$filters, &$context) {
if ($context['id'] === 'vactory_news_list') {
$query = \Drupal::request()->query->get("q");
if (empty($query)) {
return;
}
if (isset($query["page"])) {
$filters["page[offset]"] = intval($query["page"]) > 0 ? (intval($query["page"]) - 1) * $filters["page[limit]"] : 0;
}
}
}

Pretty URL

At present, one potential solution is to improve the URL by appending query parameters such as page and theme. This approach results in a URL structure like /en/news?theme=annonce&page=1. However, by including the theme parameter, we may encounter a similar issue as with the pager, where users directly request a page using the parameters.

To address this situation, we can implement the hook_json_api_collection_alter() hook as described in the previous section.

But wait, what if we could go one step further ...

Instead of having an URL like this :

/en/news?theme=annonce&page=1

Having an URL like this :

/en/news/annonce?page=1

Before implementing the pretty path, it is essential to understand the process that occurs when a user requests a URL. The diagram below illustrates the exact flow of the first request sent by Next.js :

Based on the information provided, we can conclude that Drupal checks if the requested page already exists in the database before proceeding to prepare the corresponding data.

⚠️
The problem here is that Drupal does not consider /en/news/annonce (where "annonce" is the selected theme) to be referencing the same node as /en/news. Drupal treats these URLs as separate entities.

To resovle this problem, the Vactory Starter Kit provides a solution by configuring the /news/{theme} URL structure to refer to the same node as /news.

  1. Visit /admin/config/system/vactory_router

  2. Add route

  3. Fill in the following values:

  • Libellé: News - Pretty URL SEO (EN)
  • Chemin: /node/{nid}
  • Alias: /news/{theme}

Done. You have completed the Pretty Path configuration.

Pre-filtred Block/Listing

./modules/custom/my_custom_module/news/widgets/list/settings.yml

name: 'List'
multiple: FALSE
category: 'News'
enabled: TRUE
fields:
collection:
type: json_api_collection
label: 'JSON:API'
options:
'#required': TRUE
'#default_value':
id: "vactory_news_list"
# The ressource we want retreive. (required)
resource: node--vactory_news
# The JSON API filters. (required)
filters:
- fields[node--vactory_news]=drupal_internal__nid,path,title,field_vactory_news_theme,field_vactory_media,field_vactory_excerpt,field_vactory_date
- fields[taxonomy_term--vactory_news_theme]=tid,name
- fields[media--image]=name,thumbnail
- fields[file--image]=filename,uri
- include=field_vactory_news_theme,field_vactory_media,field_vactory_media.thumbnail
- page[offset]=0
- page[limit]=9
- sort[sort-vactory-date][path]=field_vactory_date
- sort[sort-vactory-date][direction]=DESC
- filter[status][value]=1
optional_filters:
vactory_news_theme: taxonomy_term

Aside from the existing JSON API filters, we have the option to include an additional key called optional_filters. This key enables the addition of a new input field (autocomplete) where we can select a value to apply as a pre-filter for data.

The syntax for the value of optional_filters is as follows: "bundle: entity_type".

Once the changes are saved, a new autocomplete field is added to our widget settings form. This field lets us select a value to pre-filter the data with, as shown in the picture below.

And finally, specify how we want to pre-filter the data, using the hook_json_api_collection_alter()

./modules/custom/my_custom_module/my_custom_module.module

/**
* Implements hook_json_api_collection_alter().
*/
function hook_json_api_collection_alter(&$filters, &$context) {
if ($context['id'] === 'vactory_news_list') {
if ($filters['optional_filters_data']['taxonomy_term']['vactory_news_theme']) {
$filters["filter[internal_thematic][condition][path]"] = "field_vactory_news_theme.drupal_internal__tid";
$filters["filter[internal_thematic][condition][operator]"] = "=";
$filters["filter[internal_thematic][condition][value]"] = $filters['optional_filters_data']['taxonomy_term']['vactory_news_theme'];
}
}
}

Entityqueue

  1. Visit admin/structure/entityqueue

  2. Define your entity queue

  3. Update your widget yaml file

./modules/custom/my_custom_module/news/widgets/list/settings.yml

name: 'List'
multiple: FALSE
category: 'News'
enabled: TRUE
fields:
collection:
type: json_api_collection
label: 'JSON:API'
options:
'#required': TRUE
'#default_value':
id: "vactory_news_list"
# The ressource we want retreive. (required)
resource: node--vactory_news
# The JSON API filters. (required)
entity_queue: vactory_news_block_three_columns
entity_queue_field_id: drupal_internal__nid
filters:
- fields[node--vactory_news]=drupal_internal__nid,path,title,field_vactory_news_theme,field_vactory_media,field_vactory_excerpt,field_vactory_date
- fields[taxonomy_term--vactory_news_theme]=tid,name
- fields[media--image]=name,thumbnail
- fields[file--image]=filename,uri
- include=field_vactory_news_theme,field_vactory_media,field_vactory_media.thumbnail
- page[offset]=0
- page[limit]=9
- sort[sort-vactory-date][path]=field_vactory_date
- sort[sort-vactory-date][direction]=DESC
- filter[status][value]=1
  • entity_queue: The entity queue id
  • entity_queue_field_id: The field that will be utilized for filtering the data

NodeQueue

The Node Queue module allows users to collect nodes in an arbitrarily ordered list.

To implement the "Node Queue" type, a new type called node_queue exists on the Drupal side. This allows us to expose the results of nodes in the desired order, in compliance with JSON API standards.

Here's an example

./modules/custom/my_custom_module/news/widgets/node_queue/settings.yml

name: 'Node Queue'
multiple: TRUE
category: 'Node Queue Categorie'
enabled: TRUE
fields:
nodes:
type: node_queue
label: 'Node Queue'
options:
'#default_value':
id: "vactory_news_list"
resource: node--vactory_news
filters:
- fields[node--vactory_news]=drupal_internal__nid,path,title,field_vactory_news_theme,field_vactory_media,field_vactory_excerpt,field_vactory_date
- fields[taxonomy_term--vactory_news_theme]=tid,name
- fields[media--image]=name,thumbnail
- fields[file--image]=filename,uri
- include=field_vactory_news_theme,field_vactory_media,field_vactory_media.thumbnail
- page[offset]=0
- page[limit]=9
- sort[sort-vactory-date][path]=field_vactory_date
- sort[sort-vactory-date][direction]=DESC
- filter[status][value]=1

In the back office, this is how it looks: an additional field called Noeuds is added, where we can define the nodes.

The Noeuds input is a multiple selection field.