Helpers to ease access to WP API data

Working on this demo → https://github.com/frontity-juanmaguitar/bootstrap-theme-demo
that took mars-theme as a reference

I realized there are a lot of operations where you need to know exactly the structure of the data received and the location of that data

Trying to apply good patterns in software development I tried to extract that logic into an external file
Something like…

https://github.com/frontity-juanmaguitar/bootstrap-theme-demo/blob/master/packages/bootstrap-theme/src/helpers/index.js

export const getMedia = state => id => {
  const media = state.source.attachment[id];
  const srcset = Object.values(media.media_details.sizes)
.map(item => [item.source_url, item.width])
.reduce(
  (final, current, index, array) =>
    final.concat(
      `${current.join(" ")}w${index !== array.length - 1 ? ", " : ""}`
    ),
  ""
) || null;
  return { media, srcset }
}

export const getTaxonomy = state => taxonomy => idTaxonomy => state.source[taxonomy][idTaxonomy];
export const getTaxonomyCapitalized = taxonomy => taxonomy.charAt(0).toUpperCase() + taxonomy.slice(1);
export const getAuthor = state => idAuthor => state.source.author[idAuthor]
export const getPost  = state => type => idPost => state.source[type][idPost]

I think it would be great for developers creating things w/ Frontity to have a set of helpers directly available from state that they can use to access some common data:

  • getAuthor → get author details given an id
  • getMedia → get media details given an id

…and so on

We could document this in the official documentation and I think this approach has some advantages:

  • We allow people to decouple their code from the current WP Rest API structure (I don’t know how often is changing but it’s usually a good practice)
  • We simplify the development (I think it’s easier to remember state.getAuthor(2) than state.source.author[idAuthor])

Of course the whole state.source should continue to be available but we could make developments w/ Frontity easier by providing a set of tools to access the data in an easier way

The mindset behind is something like → It's not the responsibility of a component to know how to get the details of an author, I'm just going to use this method to get the details given its id

I’m not sure what is the best way to handle this, if there’s some kind of client we can actually use, or if we would complicate things even more by adding more methods to learn

What are your thoughts about this?

To add more info/context regarding this. After talking w/ @luisherranz and @mburridge I realized that some other developers doing things w/ Frontity have reached to the same conclusion

@alexaspalato is working on this custom theme → https://github.com/alexadark/frontity-starter-project
For this she has created the following set of helpers

export const buildUrl = (libraries, path, page, query) =>
  libraries.source.stringify({ path, page, query });

export function isUrl(str) {
  var regexp = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!-/]))?/;
  return regexp.test(str);
}

export const getUrlData = state => state.source.get(state.router.link);

function getSrcSet(media) {
  const srcset =
Object.values(media.media_details.sizes)
  // Get the url and width of each size.
  .map(item => [item.source_url, item.width])
  // Recude them to a string with the format required by `srcset`.
  .reduce(
    (final, current, index, array) =>
      final.concat(
        `${current.join(" ")}w${index !== array.length - 1 ? ", " : ""}`
      ),
    ""
  ) || null;
  return srcset;
}

export function getMediaAttributes(state, id) {
  const media = state.source.attachment[id];
  if (!media) return {};

  const srcSet = getSrcSet(media);

  return {
id,
alt: media.title.rendered,
src: media.source_url,
srcSet
  };
}

export function getTaxonomies(state, post, taxonomy, taxonomies) {
  const allTaxonomies = state.source[taxonomy];
  const taxs =
post[taxonomies] && post[taxonomies].map(itemId => allTaxonomies[itemId]);
  return taxs ? taxs.filter(Boolean) : [];
}

export function getPostAuthor(state, post) {
  return state.source.author[post.author];
}

export function getPostData(state) {
  const data = state.source.get(state.router.link);
  const post = state.source[data.type][data.id];
  return { ...post, isReady: data.isReady, isPage: data.isPage };
}

export function formatPostData(state, post) {
  return {
id: post.id,
author: getPostAuthor(state, post),
publishDate: post.date,
title: post.title && post.title.rendered,
categories: getTaxonomies(state, post, "category", "categories"),
tags: getTaxonomies(state, post, "tag", "tags"),
link: post.link,
featured_media: getMediaAttributes(state, post.featured_media),
content: post.content && post.content.rendered,
excerpt: post.excerpt && post.excerpt.rendered,
acf: post.acf
  };
} 

Also @Segun for his https://github.com/chakra-ui/frontity-chakra-ui-theme has created the following set of helpers

function getSrcSet(media) {
  const srcset =
    Object.values(media.media_details.sizes)
      // Get the url and width of each size.
      .map(item => [item.source_url, item.width])
      // Recude them to a string with the format required by `srcset`.
      .reduce(
        (final, current, index, array) =>
          final.concat(
            `${current.join(" ")}w${index !== array.length - 1 ? ", " : ""}`
          ),
        ""
      ) || null;
  return srcset;
}

export function getMediaAttributes(state, id) {
  const media = state.source.attachment[id];
  if (!media) return {};

  const srcSet = getSrcSet(media);

  return {
    id,
    alt: media.title.rendered,
    src: media.source_url,
    srcSet
  };
}

export function getPostCategories(state, post) {
  const allCategories = state.source.category;
  const categories =
    post.categories && post.categories.map(catId => allCategories[catId]);
  return categories ? categories.filter(Boolean) : [];
}

export function getPostAuthor(state, post) {
  return state.source.author[post.author];
}

export function getPostTags(state, post) {
  const allTags = state.source.tag;
  const tags = post.tags && post.tags.map(tagId => allTags[tagId]);
  return tags ? tags.filter(Boolean) : [];
}

export function getPostData(state) {
  const data = state.source.get(state.router.link);
  const post = state.source[data.type][data.id];
  return { ...post, isReady: data.isReady, isPage: data.isPage };
}

export function formatPostData(state, post) {
  return {
    id: post.id,
    author: getPostAuthor(state, post),
    publishDate: post.date,
    title: post.title.rendered,
    categories: getPostCategories(state, post),
    tags: getPostTags(state, post),
    link: post.link,
    featured_media: getMediaAttributes(state, post.featured_media),
    content: post.content.rendered,
    excerpt: post.excerpt.rendered
  };
}

export function splitPosts(state, routeData) {
  const firstThreePosts = [];
  const otherPosts = [];

  routeData.forEach((item, idx) => {
    const itemData = state.source[item.type][item.id];
    if (idx < 3) firstThreePosts.push(itemData);
    else otherPosts.push(itemData);
  });

  return [firstThreePosts, otherPosts];
}

export function omitConnectProps(props) {
  const out = {};
  const propsToOmit = [
    "state",
    "actions",
    "roots",
    "fills",
    "libraries",
    "getSnapshot"
  ];
  const isGetSnapshot = prop =>
    typeof prop === "function" && prop.name === "getSnapshot";

  for (const prop in props) {
    if (propsToOmit.includes(prop) || isGetSnapshot(prop)) continue;
    out[prop] = props[prop];
  }

  return out;
}

const monthNames = [...];
const formatDay = day => {...};
export function formatDate(date) {...}

export function isUrl(str) {
  var regexp = /(ftp|http|https):\/\/(\w+:{0,1}\w*@)?(\S+)(:[0-9]+)?(\/|\/([\w#!:.?+=&%@!-/]))?/;
  return regexp.test(str);
}

export function debounce(fn) { ... }

Hey @juanma, thanks for starting this conversation. I think offering a set of helpers can be a good idea :slight_smile:

Maybe we could provide hooks instead of getters.

const Post = () => {
  const post = usePost();
  const author = useAuthor();

  return ...
}

Take a look at the Feature Discussion for useConnect: useConnect hook and let us know what you think :slight_smile:

Apart from that, I think the only issue would be to be careful with these abstractions when people is learning Frontity to make sure they also learn where they come from. So we need to keep in mind that if the first time they use Frontity they see something like this:

import { useConnect } from "frontity";

const link = useConnect(({ state }) => state.router.link);

It’s much more clear that:

  • The link is coming from the router.
  • It is stored in state.router.link.
  • They can use useConnect to get things from the state.

Than if you show them something like this:

import { useLink } from "@frontity/hooks";

const link = useLink();

And also I think we would need to teach them to create their own abstractions.

const useCustomHook = useConnect(({ state }) => state.someplace.custom);

This is a very interesting topic.

We have some experience with this because that was how it worked with the old version of the framework. The problem of that approach, and the reason we decided to stop doing an abstract structure on top of the native REST API structure, was that it added an additional layer of complexity between the REST API and the consumer of the data (React). That meant an additional thing to learn and to take care of.

The REST API is extensible, so both devs and plugins can change its output. It’s something quite common actually. When people inspect the output of the REST API, they expect to get the same data structure in the consumer. If they don’t, it may not be clear why or where they have to configure that data.

For exmaple, this post helper that @Segun did for the Chakra theme is great, but it won’t work as soon as someone adds a custom field to their REST API. They have to find and modify that helper and that is not obvious if they don’t know what’s happening under the hood. It gets much worse if the helper is not even accessible because it belongs to some external package. So I think that’s another thing to keep in mind when designing these helpers/hooks.


Finally, I think we can turn this topic into a Feature Discussion itself for the helpers and make it official feature request in Frontity’s roadmap. I’m sure more people would chime in :slight_smile:

Would you mind doing so?

1 Like

Regarding this,@Segun had a project in mind of creating hooks for Frontity, you can take a look at it here: https://www.notion.so/frontity/Frontity-Hooks-Segun-89dd825d05bc4273b38d4095f8043ac9

1 Like

Right, thanks @Pablo. I’m going to copy & paste his ideas here.

Please note that this implementation is not valid because it’s based in an implementation of useConnect that is not possible to do in the current Frontity Connect. useConnect must use a mapState function.

For example, instead of this:

function usePageData(){
 const { state } = useConnect()
 const data = state.source.get(state.router.link)
 return data
}

It has to be like this:

const usePageDate = () =>
  useConnect(({ state }) => state.source.get(state.router.link);

More about that in the Hook Feature Discussion.

Other than that, the ideas are valid.

Frontity + Hooks (by @Segun)

Once we add the useConnect hook, here are some ideas for other custom hooks that might improve productivity.

Add a usePageData react hook to make it easy to get the data for the current link.

function usePageData(){
 const { state } = useConnect()
 const data = state.source.get(state.router.link)
 return data
}

Add a usePostData react hook to format the post data in a way that makes it easy to consume

function usePostData(){
 const {state} = useConnect()
 const {id, type} = usePageData()
 const post = state.source[type][id]

 // a custom function to format the data
 const formattedPost = format(post)

 return formattedPost
}

// The final structure will look like
const result = {
 id: 3, 
 author: {}, // all author data,
 publishDate: "", // date the post was published
 title: "", // the "rendered" part of the title
 categories: [], // a list of all categories data,
 tags: [], // a list of all tags data,
 link: "", // the url of the post,
 featuredMedia: {}, // all featured media attributes (src, srcSet, etc.)
 content: "", // the "rendered" part of the content
 excerpt: "", // the excerpt of the post
 ...rest, // any other data
}

Add a useMedia hook to get the props for any featured media

function useMedia(id){
 const {state} = useConnect()
 const media = state.source.attachment[id]
 
 if(!media) return null
 
 // function that resolves the media object and extracts the srcSet
 const srcSet = getSrcSet(media)

 const result = {
  alt: media.title.rendered,
  src: media.source_url,
  srcSet
 }
 
 return result
}

// This can be consumed in any media related component

Add a simple helper to format post dates to a human readable format. Using toLocaleString doesn’t create a complete readable date.

function formatPostDate(post){
 const date = new Date(post.date)
 const day = date.getDate()

 const month = date.getMonth() + 1,
 const monthName = getMonthName(month)

 const year = date.getFullYear()

 return {day, month, monthName, year}
}

// We'll return an object so users can format the date anyhow they want

Add a useRouter hook that exposes the properties and methods from the router. Ideally, it can also expose listeners for the route changes.

function useRouter(){
 const {state, actions} = useConnect()

 // Custom route change listener so users can so some stuff
 const onRouteChange = (prevUrl, url) => {
  // 1. like close a mobile menu
  // 2. show a promotion popup if you're coming from a particular url
 }
 
 // I know we currently have a set method, i'd like to see if we can add a query params 
 const set = (url, params) => {
 }
 
 // return all router actions, properties, and listeners
 return {}
}

// so if i'm in a search modal, and I need to trigger a search
const router = useRouter()
const onSubmit = () => {
 // url here is the current page url
 router.set(router.url, {s: "testing"})
}

Add a useSearch hook to help users create easy search forms for their website or blog. This hook returns valid HTML props that can be spread unto the form and input elements.

function useSearch(onComplete){
 const {state, actions } = useConnect()

 // get the current page query
 const router = useRouter()
 const searchQuery = router.query["s"]
 
 const inputRef = React.useRef(null)

 const handleSubmit = event => {
  event.preventDefault()

  // get the input's value
  const inputValue = inputRef.current.value
  
  // set the router url
  if(inputValue.length){
    router.set(router.url, {s: inputValue})
  }

  // scroll the page to the top
  window.scrollTo(0, 0);

  // function to run after search
  // users could close any opened modal or call some notifier
  if(onComplete) onComplete()
  
 }

 const formProps = {
  role : "search",
  "aria-label": "Search this website",
  onSubmit: handleSubmit
 }

 const inputProps = {
  defaultValue: searchQuery,
  ref: inputRef
 }

 return { formProps, inputProps }
}

These are my ideas for hooks that could help improve the developer experience of frontity having created 2 themes with Frontity.

1 Like