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: FALSEcategory: "News"enabled: TRUEfields: 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.
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.
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
.
Visit
/admin/config/system/vactory_router
Add route
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: FALSEcategory: 'News'enabled: TRUEfields: 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.
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
Visit
admin/structure/entityqueue
Define your entity queue
Update your widget yaml file
./modules/custom/my_custom_module/news/widgets/list/settings.yml
name: 'List'multiple: FALSEcategory: 'News'enabled: TRUEfields: 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: TRUEcategory: 'Node Queue Categorie'enabled: TRUEfields: 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.