Dynamic Templates
Initial Setup
To implement "dynamic templates," Vactory uses a type called json_api_collection
on the Drupal side. This allows exposing the results of any entity (Node, taxonomy, user, custom entity, etc.) in compliance with the JSON API standards.
Reference: https://www.drupal.org/docs/core-modules-and-themes/core-modules/jsonapi-module/api-overview
Here's an example configuration:
./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 resource we want to retrieve (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
As we learned in the Static Templates section, we need to define a mapping template in the Next.js app:
./components/modules/contrib/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
Displaying Content
We can now enhance our component to display the news items:
./components/modules/contrib/news/NewsListWidget.jsx
import React from "react"import { normalizeNodes } from "./normalizer"import { EmptyBlock } from "@/ui"import { NewsCard } from "./NewsCard"import { useDataHandling } from "@vactorynext/core/lib"import { useCollectionFetcher } from "@vactorynext/core/hooks"
export const config = { id: "vactory_news:list",}
const NewsListWidget = ({ data }) => { /** * Custom hook for handling various data-related functionalities, * including pagination, filters, translation, and context. **/ const { context, filters } = useDataHandling(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 through news items return ( <div className="relative pb-4 pt-4 lg:pb-10 lg:pt-10"> {posts.length > 0 ? ( <div className="mx-auto grid gap-5 md:grid-cols-2 lg:grid-cols-3"> {posts.map((post) => ( <React.Fragment key={post.id}> <NewsCard {...post} /> </React.Fragment> ))} </div> ) : ( <EmptyBlock /> )} </div> )}
export default NewsListWidget
The normalizer file would look like this:
./components/modules/contrib/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), hasFlag: post?.has_flag, isFlagged: post?.is_flagged, })) || [] )}
Layout Switching
A common requirement for listings is to allow users to switch between different layout views (grid and list). Vactory provides a custom hook to handle this:
./components/modules/contrib/news/NewsListWidget.jsx
import { useListingLayout } from "@vactorynext/core/lib"
const NewsListWidget = ({ data }) => { // Switch Layout functionality const { listingLayout, switchLayout } = useListingLayout()
// Rest of your component...}
Include the LayoutSwitcher
component from the UI package:
import { LayoutSwitcher } from "@/ui"
// Then inside your component render:;<LayoutSwitcher listingLayout={listingLayout} switchLayout={switchLayout} />
Now adapt your rendering based on the selected layout:
<div className={vclsx( "mx-auto gap-5", listingLayout === "grid" ? "grid md:grid-cols-2 lg:grid-cols-3" : "flex flex-col" )}> {posts.map((post, index) => ( <NewsCard {...post} listingLayout={listingLayout} /> ))}</div>
Implementing Pagination
To add pagination to your dynamic content, import the Pagination component from the UI package:
./components/modules/contrib/news/NewsListWidget.jsx
import { Pagination, Container } from "@/ui"
Create a reference for scrolling and extract pagination info from the custom hook:
const { scrollRefPagination, defaultPageLimit, pager, setPager, filters, current_page_alias,} = useDataHandling(data)
Create a URL pattern for pagination:
// Create path with page parameterconst nodeAliasPath = `${current_page_alias}?page={page}`// Shallow URL for paginations element's hrefconst [paginationShallowUrl, setPaginationShallowUrl] = useState(nodeAliasPath)
Now add the Pagination component:
{ parseInt(collection.count) > parseInt(filters?.page?.limit || defaultPageLimit) && ( <Container className="px-4 pb-4 sm:px-6 lg:px-8 lg:pb-8"> <Pagination baseUrl={`${paginationShallowUrl.replace(/page=\d+/, "page={page}")}`} contentRef={scrollRefPagination} pageSize={filters?.page?.limit || defaultPageLimit} current={pager} total={collection.count} isLoading={!collection.isLoading} onChange={(page) => setPager(page)} id="news-pagination" /> </Container> )}
Implement the URL update mechanism:
import { useUpdateEffect } from "@vactorynext/core/hooks"
// Inside your component:useUpdateEffect(() => { setFilters((prev) => { let filters = { ...prev, }
// Update pager filters.page = { ...filters.page, offset: (pager - 1) * (filters?.page?.limit || defaultPageLimit), }
return filters })
// Update URL let newUrl = nodeAliasPath.replace("{page}", pager) setShallowUrl(newUrl) setPaginationShallowUrl(newUrl)}, [pager])
// Update page URL using shallow routinguseUpdateEffect(() => { router.push(shallowUrl, undefined, { shallow: true })}, [shallowUrl])
Advanced Filtering
Vactory provides a comprehensive filtering system with form handling and URL management:
1. Adding Filter Form with react-hook-form
First, import the required components:
import { useForm } from "react-hook-form"import { SelectNative, Button, FilterWrapper } from "@/ui"import { UseListingFilter } from "@vactorynext/core/lib"
Set up form state and default values:
// Generate default values for theme filterconst allThematic = generateDefaultValues( "vactory_news_theme", t("Toutes les thématiques"), context)
// Extract term slug from node alias and convert it to term IDconst defaultTheme = generateIdsFromTermsSlug( systemRoute?._query?.theme, context.terms.vactory_news_theme, allThematic.id)
const defaultSort = router?.query?.sort || "desc"const [selectedTheme, setSelectedTheme] = useState(defaultTheme)const [sortedValue, setSortedValue] = useState(defaultSort)
// Set up the formconst { handleSubmit, reset, control } = useForm({ defaultValues: { theme: defaultTheme, sort: defaultSort, },})
Implement form submission handlers:
// Submit filter formconst submitFilterForm = (data) => { // Get the value of the selected filter const selectedTheme = context.terms?.vactory_news_theme.find((theme) => { return theme.id === data?.theme })
// Track filter usage in dataLayer if needed dlPush("filter_select", { Thématique: selectedTheme.label, "Type contenu": "Actualités", })
if (showFilters) { setshowFilters(false) }
setSelectedTheme(data?.theme) setSortedValue(data?.sort) setPager(1)}
// Reset filter formconst resetFilterForm = () => { reset({ theme: allThematic.id, sort: "desc", })
if (showFilters) { setshowFilters(false) }
setPager(1) setSelectedTheme(allThematic.id) setSortedValue("desc")}
Now create the filter form UI:
<form onSubmit={handleSubmit(submitFilterForm)}> {/* Mobile filters */} <FilterWrapper showFilters={showFilters} setshowFilters={setshowFilters}> <div className="mb-6 flex flex-col gap-5"> <UseListingFilter filters={[ { type: "select", componentType: SelectNative, name: "theme", id: "theme", label: t("Nx:Thématique"), variant: "filter", list: context.terms?.vactory_news_theme, position: 1, }, { type: "select", componentType: SelectNative, name: "sort", id: "sort", label: t("Nx:Filtrer par date"), variant: "filter", list: sortingList, position: 2, }, ]} control={control} /> </div> <div className="flex flex-row items-center justify-center gap-4"> <Button id="news-submit" type="submit" variant="primary"> {t("Nx:Appliquer")} </Button> <Button id="news-reset" type="button" onClick={resetFilterForm} variant="secondary"> {t("Nx:Renitialiser")} </Button> </div> </FilterWrapper>
{/* Desktop filters */} <div className="hidden flex-col space-y-4 md:flex md:flex-row md:space-x-4 md:space-y-0"> <UseListingFilter filters={[ { type: "select", componentType: SelectNative, name: "theme", id: "theme", label: t("Nx:Thématique"), variant: "filter", list: context.terms?.vactory_news_theme, position: 1, }, { type: "select", componentType: SelectNative, name: "sort", id: "sort", label: t("Nx:Filtrer par date"), variant: "filter", list: sortingList, position: 2, }, ]} control={control} />
<div className="flex flex-row items-center justify-center gap-4"> <Button id="news-submit" type="submit" variant="primary"> {t("Nx:Appliquer")} </Button> <Button id="news-reset" type="button" onClick={resetFilterForm} variant="secondary"> {t("Nx:Renitialiser")} </Button> </div> </div></form>
2. Updating Filters and URLs
Update the URL handling to include filters:
// Create a more complex URL pattern with filtersconst firstQuerySeparator = pager === 1 ? "?" : "&"const secondQuerySeparator = pager !== 1 && sortedValue === "desc" && listingLayout === "grid" ? "?" : "&"
const nodeAliasPath = `${current_page_alias}/{theme}?page={page}${firstQuerySeparator}sort={sort}${secondQuerySeparator}display={display}`
// Generate default URLconst defaultUrl = getDefaultUrl( nodeAliasPath, { theme: selectedTheme === allThematic.id ? allThematic.id : selectedTheme, sort: sortedValue === defaultSort ? "" : sortedValue, display: listingLayout === "grid" ? "" : listingLayout, }, [selectedTheme], context)
// Update URL handling functionconst updatePrettyPath = () => { // Update pretty path URL let newNodeAliasPath = selectedTheme === allThematic.id ? nodeAliasPath.replace("/{theme}", "") : nodeAliasPath.replace( "{theme}", generateTermsSlugFromIds( selectedTheme, context.terms.vactory_news_theme, allThematic.id ) )
newNodeAliasPath = newNodeAliasPath.replace("{page}", pager)
if (listingLayout === "grid") { newNodeAliasPath = removeQueryParamValue(newNodeAliasPath, "display={display}") } newNodeAliasPath = newNodeAliasPath.replace("{display}", listingLayout)
newNodeAliasPath = sortedValue === defaultSortEver ? removeQueryParamValue(newNodeAliasPath, "sort={sort}") : newNodeAliasPath.replace("{sort}", sortedValue)
setShallowUrl( pager === 1 ? newNodeAliasPath.replace(/[?&]page=1\b/, "").replace("&sort", "?sort") : newNodeAliasPath.replace(/[?&]page=1\b/, "") ) setPaginationShallowUrl(newNodeAliasPath)}
Update the filter and pager effect:
useUpdateEffect(() => { updatePrettyPath()
setFilters((prev) => { let filters = { ...prev, }
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, }, } }
// Update pager filters.page = { ...filters.page, offset: (pager - 1) * (filters?.page?.limit || defaultPageLimit), }
// Update sort if (sortedValue == "popularite") { filters.sort = { "sort-popularite": { direction: "DESC", path: "field_node_count_view", }, } } else { filters.sort = { "sort-vactory-date": { path: "field_vactory_date", direction: sortedValue, }, } }
return filters })}, [selectedTheme, sortedValue, pager, listingLayout])
Animations and Transitions
Improve the user experience with animations using Framer Motion:
import { motion } from "framer-motion"import { ParentTransition, ChildTransition } from "@/ui"
// Then in your component:;<motion.div variants={ParentTransition} initial={"initial"} className={vclsx( "mx-auto gap-5", listingLayout === "grid" ? "grid md:grid-cols-2 lg:grid-cols-3" : "flex flex-col" )} key={posts.reduce((prev, curr) => prev + curr.id, "")}> {posts.map((post, index) => ( <motion.div key={post.id} variants={ChildTransition(index)} initial="initial" whileInView="animate" viewport={{ once: true, amount: 0.2 }} > <NewsCard {...post} listingLayout={listingLayout} /> </motion.div> ))}</motion.div>
Loading State
Add a loading overlay to improve the user experience during data fetching:
import { LoadingOverlay } from "@/ui"
// Inside your component:<LoadingOverlay active={collection.isLoading} spinner={true}> <div className="relative pb-4 pt-4 lg:pb-10 lg:pt-10"> {posts.length > 0 ? ( // Your content ) : ( <EmptyBlock /> )} </div></LoadingOverlay>
The JSON API Collection Alter Hook
We'll face a problem with our news listing when /en/news
yields the same results as /en/news?page=2
, /en/news?page=3
, etc.
To understand the data flow in Next.js, see this diagram:
Drupal always prepares the news listing data based on the filters in settings.yml
, regardless of the page number in the URL.
To solve this, we implement hook_json_api_collection_alter(&$filters, &$context)
on the Drupal side. First, assign a unique ID to your json_api_collection
item:
./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 resource we want to retrieve (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 # Additional data to fill filters on NextJS side (optional) vocabularies: vactory_news_theme: vactory_news_theme
Then implement the hook to override filters based on the page parameter:
./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; } }}
Creating Pretty URLs
Currently, we use query parameters like /en/news?theme=annonce&page=1
. But what if we could create more SEO-friendly URLs like /en/news/annonce?page=1
?
First, understand how Next.js processes URLs:
The Vactory Starter Kit solves this by configuring /news/{theme}
to point to the same node as /news
:
Visit
/admin/config/system/vactory_router
Add a new route
Fill in these values:
- Libellé:
News - Pretty URL SEO (EN)
- Chemin:
/node/{nid}
- Alias:
/news/{theme}
- Libellé:
With this configuration complete, your pretty URLs will work correctly.
Pre-filtered Blocks/Listings
You can create pre-filtered content blocks by adding an optional_filters
key:
./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 resource we want to retrieve (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
This adds an autocomplete field to pre-filter data:
Finally, implement the hook to apply the pre-filter:
./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']; } }}
Using EntityQueue
Entity queues let you manually order content:
Visit
admin/structure/entityqueue
Define your entity queue
Update your widget configuration:
./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 resource we want to retrieve (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 used for filtering the data
Working with NodeQueue
Node Queue allows users to collect nodes in an arbitrarily ordered list.
To implement Node Queue, use the node_queue type on the Drupal side:
./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 adds a "Noeuds" field for selecting multiple nodes: