diff --git a/.changeset/eleven-elephants-raise.md b/.changeset/eleven-elephants-raise.md new file mode 100644 index 000000000..df7427a08 --- /dev/null +++ b/.changeset/eleven-elephants-raise.md @@ -0,0 +1,29 @@ +--- +'@faustwp/core': minor +--- + +Added the ability to provide multiple queries to a given Faust Template: + +```js +import {GET_POST, GET_LAYOUT} from './queries.js' + +export default function Component(props) { +} + +Component.queries = [ + { + query: GET_LAYOUT + }, + { + query: GET_POST, + variables: (seedNode, ctx) { + return { + id: seedNode.databaseId, + asPreview: ctx?.asPreview + } + } + } +] +``` + +**Note:** Your Faust template can use either `Component.queries` or `Component.query`, but not both. diff --git a/examples/next/faustwp-getting-started/wp-templates/single.js b/examples/next/faustwp-getting-started/wp-templates/single.js index a5edac820..50a66ddf8 100644 --- a/examples/next/faustwp-getting-started/wp-templates/single.js +++ b/examples/next/faustwp-getting-started/wp-templates/single.js @@ -1,17 +1,58 @@ import { gql } from '@apollo/client'; -import * as MENUS from '../constants/menus'; -import { BlogInfoFragment } from '../fragments/GeneralSettings'; +import { useFaustQuery } from '@faustwp/core'; import { - Header, - Footer, - Main, Container, - EntryHeader, - NavigationMenu, ContentWrapper, + EntryHeader, FeaturedImage, + Footer, + Header, + Main, + NavigationMenu, SEO, } from '../components'; +import * as MENUS from '../constants/menus'; +import { BlogInfoFragment } from '../fragments/GeneralSettings'; + +const GET_LAYOUT_QUERY = gql` + ${BlogInfoFragment} + ${NavigationMenu.fragments.entry} + query GetLayout( + $headerLocation: MenuLocationEnum + $footerLocation: MenuLocationEnum + ) { + generalSettings { + ...BlogInfoFragment + } + headerMenuItems: menuItems(where: { location: $headerLocation }) { + nodes { + ...NavigationMenuItemFragment + } + } + footerMenuItems: menuItems(where: { location: $footerLocation }) { + nodes { + ...NavigationMenuItemFragment + } + } + } +`; + +const GET_POST_QUERY = gql` + ${FeaturedImage.fragments.entry} + query GetPost($databaseId: ID!, $asPreview: Boolean = false) { + post(id: $databaseId, idType: DATABASE_ID, asPreview: $asPreview) { + title + content + date + author { + node { + name + } + } + ...FeaturedImageFragment + } + } +`; export default function Component(props) { // Loading state for previews @@ -19,11 +60,14 @@ export default function Component(props) { return <>Loading...; } - const { title: siteTitle, description: siteDescription } = - props?.data?.generalSettings; - const primaryMenu = props?.data?.headerMenuItems?.nodes ?? []; - const footerMenu = props?.data?.footerMenuItems?.nodes ?? []; - const { title, content, featuredImage, date, author } = props.data.post; + const { post } = useFaustQuery(GET_POST_QUERY); + const { generalSettings, headerMenuItems, footerMenuItems } = + useFaustQuery(GET_LAYOUT_QUERY); + + const { title: siteTitle, description: siteDescription } = generalSettings; + const primaryMenu = headerMenuItems?.nodes ?? []; + const footerMenu = footerMenuItems?.nodes ?? []; + const { title, content, featuredImage, date, author } = post ?? {}; return ( <> @@ -55,48 +99,19 @@ export default function Component(props) { ); } -Component.query = gql` - ${BlogInfoFragment} - ${NavigationMenu.fragments.entry} - ${FeaturedImage.fragments.entry} - query GetPost( - $databaseId: ID! - $headerLocation: MenuLocationEnum - $footerLocation: MenuLocationEnum - $asPreview: Boolean = false - ) { - post(id: $databaseId, idType: DATABASE_ID, asPreview: $asPreview) { - title - content - date - author { - node { - name - } - } - ...FeaturedImageFragment - } - generalSettings { - ...BlogInfoFragment - } - headerMenuItems: menuItems(where: { location: $headerLocation }) { - nodes { - ...NavigationMenuItemFragment - } - } - footerMenuItems: menuItems(where: { location: $footerLocation }) { - nodes { - ...NavigationMenuItemFragment - } - } - } -`; - -Component.variables = ({ databaseId }, ctx) => { - return { - databaseId, - headerLocation: MENUS.PRIMARY_LOCATION, - footerLocation: MENUS.FOOTER_LOCATION, - asPreview: ctx?.asPreview, - }; -}; +Component.queries = [ + { + query: GET_LAYOUT_QUERY, + variables: (seedNode, ctx) => ({ + headerLocation: MENUS.PRIMARY_LOCATION, + footerLocation: MENUS.FOOTER_LOCATION, + }), + }, + { + query: GET_POST_QUERY, + variables: ({ databaseId }, ctx) => ({ + databaseId, + asPreview: ctx?.asPreview, + }), + }, +]; diff --git a/package-lock.json b/package-lock.json index 00a79d079..b7f694368 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51,9 +51,9 @@ "dependencies": { "@apollo/client": "^3.8.0", "@apollo/experimental-nextjs-app-support": "^0.5.1", - "@faustwp/cli": "^1.2.0", + "@faustwp/cli": "^1.2.1", "@faustwp/core": "^1.2.0", - "@faustwp/experimental-app-router": "^0.2.1", + "@faustwp/experimental-app-router": "^0.2.2", "graphql": "^16.7.1", "next": "^14.0.1", "react": "^18.3.0-canary-ce2bc58a9-20231102", @@ -253,7 +253,7 @@ "dependencies": { "@apollo/client": "^3.8.8", "@faustwp/blocks": "2.0.0", - "@faustwp/cli": "^1.2.0", + "@faustwp/cli": "^1.2.1", "@faustwp/core": "^1.2.0", "@wordpress/base-styles": "^4.38.0", "@wordpress/block-library": "^8.24.0", @@ -2108,7 +2108,7 @@ "version": "0.1.0", "dependencies": { "@apollo/client": "^3.6.6", - "@faustwp/cli": "^1.2.0", + "@faustwp/cli": "^1.2.1", "@faustwp/core": "^1.2.0", "@wordpress/base-styles": "^4.36.0", "@wordpress/block-library": "^7.19.0", @@ -30799,12 +30799,12 @@ }, "packages/experimental-app-router": { "name": "@faustwp/experimental-app-router", - "version": "0.2.1", + "version": "0.2.2", "license": "MIT", "devDependencies": { "@apollo/client": "^3.8.0", "@apollo/experimental-nextjs-app-support": "^0.5.1", - "@faustwp/cli": "^1.1.3", + "@faustwp/cli": "^1.2.1", "@faustwp/core": "^1.1.2", "@testing-library/jest-dom": "^5.17.0", "@types/node": "^20.4.6", @@ -30856,9 +30856,9 @@ "dev": true }, "packages/experimental-app-router/node_modules/@next/swc-darwin-arm64": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-14.0.3.tgz", - "integrity": "sha512-64JbSvi3nbbcEtyitNn2LEDS/hcleAFpHdykpcnrstITFlzFgB/bW0ER5/SJJwUPj+ZPY+z3e+1jAfcczRLVGw==", + "version": "12.3.4", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-12.3.4.tgz", + "integrity": "sha512-DqsSTd3FRjQUR6ao0E1e2OlOcrF5br+uegcEGPVonKYJpcr0MJrtYmPxd4v5T6UCJZ+XzydF7eQo5wdGvSZAyA==", "cpu": [ "arm64" ], @@ -31293,7 +31293,7 @@ }, "packages/faustwp-cli": { "name": "@faustwp/cli", - "version": "1.2.0", + "version": "1.2.1", "license": "MIT", "dependencies": { "archiver": "^6.0.1", @@ -35720,7 +35720,7 @@ }, "plugins/faustwp": { "name": "@faustwp/wordpress-plugin", - "version": "1.1.1" + "version": "1.1.2" } } } diff --git a/packages/faustwp-core/src/components/FaustProvider.tsx b/packages/faustwp-core/src/components/FaustProvider.tsx index 5a2310653..54680acde 100644 --- a/packages/faustwp-core/src/components/FaustProvider.tsx +++ b/packages/faustwp-core/src/components/FaustProvider.tsx @@ -1,17 +1,16 @@ +import React, { useState } from 'react'; import { ApolloProvider } from '@apollo/client'; -import React from 'react'; // eslint-disable-next-line import/extensions import { useRouter } from 'next/router'; // eslint-disable-next-line import/extensions import { AppProps } from 'next/app'; import { useApollo } from '../client.js'; import { Toolbar } from './Toolbar/index.js'; -import { SeedNode } from '../queries/seedQuery.js'; import { getConfig } from '../config/index.js'; +import { FaustContext, FaustQueries } from '../store/FaustContext.js'; +import { FaustProps } from './WordPressTemplate.js'; -export type FaustPageProps = AppProps['pageProps'] & { - __SEED_NODE__?: SeedNode; -}; +export type FaustPageProps = AppProps['pageProps'] & FaustProps; export function FaustProvider(props: { children: React.ReactNode; @@ -22,16 +21,34 @@ export function FaustProvider(props: { const router = useRouter(); const apolloClient = useApollo(pageProps); + const setQueries = (newQueries: FaustQueries) => { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + setFaustContext((prevContext) => { + return { + ...prevContext, + queries: newQueries, + }; + }); + }; + + const [faustContext, setFaustContext] = useState({ + // eslint-disable-next-line no-underscore-dangle + queries: pageProps.__FAUST_QUERIES__, + setQueries, + }); + return ( - - {experimentalToolbar && ( - - )} - {children} - + + + {experimentalToolbar && ( + + )} + {children} + + ); } diff --git a/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx b/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx index c88126f5f..bceeaa665 100644 --- a/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx +++ b/packages/faustwp-core/src/components/Toolbar/Toolbar.tsx @@ -59,7 +59,7 @@ export type FaustToolbarContext = { * Toolbar props. */ export type ToolbarProps = { - seedNode?: SeedNode; + seedNode?: SeedNode | null; }; /** * The component to actually render the toolbar. At this point we can assume diff --git a/packages/faustwp-core/src/components/Toolbar/nodes/Edit.tsx b/packages/faustwp-core/src/components/Toolbar/nodes/Edit.tsx index 002022fcb..8655d7aac 100644 --- a/packages/faustwp-core/src/components/Toolbar/nodes/Edit.tsx +++ b/packages/faustwp-core/src/components/Toolbar/nodes/Edit.tsx @@ -5,7 +5,7 @@ import { getAdminUrl } from '../../../lib/getAdminUrl.js'; import { ToolbarItem } from '../index.js'; type Props = { - seedNode?: SeedNode; + seedNode?: SeedNode | null; }; export function Edit({ seedNode }: Props) { diff --git a/packages/faustwp-core/src/components/WordPressTemplate.tsx b/packages/faustwp-core/src/components/WordPressTemplate.tsx index d620a9a5e..1146748a1 100644 --- a/packages/faustwp-core/src/components/WordPressTemplate.tsx +++ b/packages/faustwp-core/src/components/WordPressTemplate.tsx @@ -1,17 +1,34 @@ import { QueryOptions } from '@apollo/client'; -import React, { PropsWithChildren, useEffect, useState } from 'react'; -import { ensureAuthorization, getAccessToken } from '../auth/index.js'; -import { getApolloClient } from '../client.js'; +// eslint-disable-next-line import/extensions +import { print } from '@apollo/client/utilities'; +import { sha256 } from 'js-sha256'; +import React, { + PropsWithChildren, + useContext, + useEffect, + useState, +} from 'react'; +import { getApolloAuthClient, getApolloClient } from '../client.js'; import { getConfig } from '../config/index.js'; import { getTemplate } from '../getTemplate.js'; -import { SeedNode, SEED_QUERY } from '../queries/seedQuery.js'; +import { useAuth } from '../hooks/useAuth.js'; +import { SEED_QUERY, SeedNode } from '../queries/seedQuery.js'; +import { FaustContext, FaustQueries } from '../store/FaustContext.js'; import { getQueryParam } from '../utils/convert.js'; -export type WordPressTemplateProps = PropsWithChildren<{ - __SEED_NODE__: SeedNode | null; - __TEMPLATE_QUERY_DATA__: any | null; -}>; +export type FaustProps = { + __SEED_NODE__?: SeedNode | null; + __FAUST_QUERIES__?: FaustQueries | null; + __TEMPLATE_QUERY_DATA__?: any | null; + __TEMPLATE_VARIABLES__?: { [key: string]: any } | null; +}; + +export type WordPressTemplateProps = PropsWithChildren; +/** + * This is an external type for end users. + * @external + */ export type FaustTemplateProps> = Props & { data?: Data; loading?: boolean; @@ -20,6 +37,139 @@ export type FaustTemplateProps> = Props & { __TEMPLATE_VARIABLES__?: { [key: string]: any }; }; +export function WordPressTemplateInternal( + props: WordPressTemplateProps & { + seedNode: SeedNode; + isPreview: boolean; + isAuthenticated: boolean; + loading: boolean; + setLoading: (loading: boolean) => void; + }, +) { + const { templates } = getConfig(); + + if (!templates) { + throw new Error('Templates are required. Please add them to your config.'); + } + + const { + seedNode, + isAuthenticated, + isPreview, + __TEMPLATE_QUERY_DATA__: templateQueryDataProp, + loading, + setLoading, + ...wordpressTemplateProps + } = props; + const template = getTemplate(seedNode, templates); + const [data, setData] = useState(templateQueryDataProp); + const { setQueries } = useContext(FaustContext) || {}; + + if (template && template.queries && template.query) { + throw new Error( + '`Only either `Component.query` or `Component.queries` can be provided, but not both.', + ); + } + + /** + * Fetch the template's queries if defined. + */ + useEffect(() => { + void (async () => { + const client = isPreview ? getApolloAuthClient() : getApolloClient(); + + if (!template) { + return; + } + + if (template.query) { + return; + } + + if (!template.queries) { + return; + } + + if (!setQueries) { + return; + } + + let queries: FaustQueries | null = null; + + const queryCalls = template.queries.map(({ query, variables }) => { + const queryVariables = variables + ? variables(seedNode, { asPreview: isPreview }) + : undefined; + return client.query({ + query, + variables: queryVariables, + }); + }); + + const queriesRes = await Promise.all(queryCalls); + + queries = {}; + + queriesRes.forEach((queryRes, index) => { + if (queries && template.queries) { + queries[sha256(print(template.queries[index].query))] = queryRes.data; + } + }); + + setQueries(queries); + + setLoading(false); + })(); + }, [isAuthenticated, isPreview, seedNode, template, setQueries, setLoading]); + + /** + * Fetch the template's query if defined. + */ + useEffect(() => { + void (async () => { + const client = isPreview ? getApolloAuthClient() : getApolloClient(); + + if (!template || !template?.query || template?.queries || !seedNode) { + return; + } + + if (data) { + return; + } + + setLoading(true); + + const queryArgs: QueryOptions = { + query: template?.query, + variables: template?.variables + ? template?.variables(seedNode, { asPreview: isPreview }) + : undefined, + }; + + const templateQueryRes = await client.query(queryArgs); + + setData(templateQueryRes.data); + + setLoading(false); + })(); + }, [data, template, seedNode, isPreview, isAuthenticated, setLoading]); + + if (!template) { + return null; + } + + const Component = template as React.FC<{ [key: string]: any }>; + + const newProps = { + ...wordpressTemplateProps, + __TEMPLATE_QUERY_DATA__: templateQueryDataProp, + data, + loading, + }; + + return React.createElement(Component, newProps, null); +} + export function WordPressTemplate(props: WordPressTemplateProps) { const { basePath, templates } = getConfig(); @@ -32,21 +182,18 @@ export function WordPressTemplate(props: WordPressTemplateProps) { __TEMPLATE_QUERY_DATA__: templateQueryDataProp, } = props; - const [seedNode, setSeedNode] = useState(seedNodeProp); + const [seedNode, setSeedNode] = useState( + seedNodeProp ?? null, + ); const template = getTemplate(seedNode, templates); - const [data, setData] = useState(templateQueryDataProp); const [loading, setLoading] = useState(template === null); const [isPreview, setIsPreview] = useState( templateQueryDataProp ? false : null, ); - const [isAuthenticated, setIsAuthenticated] = useState< - | true - | { - redirect?: string | undefined; - login?: string | undefined; - } - | null - >(null); + const { isAuthenticated, loginUrl } = useAuth({ + strategy: 'redirect', + shouldRedirect: false, + }); /** * Determine if the URL we are on is for previews @@ -60,25 +207,18 @@ export function WordPressTemplate(props: WordPressTemplateProps) { }, []); /** - * If the URL we are on is for previews, ensure we are authenticated. + * If we are on a preview route and there is no authenticated user, redirect + * them to the login page */ useEffect(() => { - if (isPreview === null || isPreview === false) { + if (!window) { return; } - void (async () => { - const ensureAuthRes = await ensureAuthorization({ - redirectUri: window.location.href, - }); - - if (ensureAuthRes !== true && ensureAuthRes?.redirect) { - window.location.replace(ensureAuthRes.redirect); - } - - setIsAuthenticated(ensureAuthRes); - })(); - }, [isPreview]); + if (isPreview && isAuthenticated === false && loginUrl) { + window.location.assign(loginUrl); + } + }, [isAuthenticated, isPreview, loginUrl]); /** * Execute the seed query. @@ -95,8 +235,12 @@ export function WordPressTemplate(props: WordPressTemplateProps) { return; } + if (seedNode) { + return; + } + void (async () => { - const client = getApolloClient(); + const client = isPreview ? getApolloAuthClient() : getApolloClient(); let seedQueryUri = window.location.href.replace( window.location.origin, @@ -121,7 +265,7 @@ export function WordPressTemplate(props: WordPressTemplateProps) { } } - let queryArgs: QueryOptions = { + const queryArgs: QueryOptions = { query: SEED_QUERY, variables: { // Conditionally add relevant query args. @@ -131,92 +275,31 @@ export function WordPressTemplate(props: WordPressTemplateProps) { }, }; - if (isPreview) { - queryArgs = { - ...queryArgs, - context: { - headers: { - /** - * We know the access token is available here since we ensured - * authorization in the useEffect above - */ - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - Authorization: `bearer ${getAccessToken()!}`, - }, - }, - }; - } - - if (!seedNode) { - setLoading(true); + setLoading(true); - const seedQueryRes = await client.query(queryArgs); + const seedQueryRes = await client.query(queryArgs); - const node = isPreview - ? (seedQueryRes?.data?.contentNode as SeedNode) - : (seedQueryRes?.data?.nodeByUri as SeedNode); + const node = isPreview + ? (seedQueryRes?.data?.contentNode as SeedNode) + : (seedQueryRes?.data?.nodeByUri as SeedNode); - setSeedNode(node); - } + setSeedNode(node); })(); }, [seedNode, isPreview, isAuthenticated, basePath]); - /** - * Finally, get the template's query data. - */ - useEffect(() => { - // We don't know yet if this is a preview route or not - if (isPreview === null) { - return; - } - - // This is a preview route, but we are not authenticated yet. - if (isPreview === true && isAuthenticated !== true) { - return; - } - - void (async () => { - const client = getApolloClient(); - - if (!template || !template?.query || !seedNode) { - return; - } - - if (!data) { - setLoading(true); - - let queryArgs: QueryOptions = { - query: template?.query, - variables: template?.variables - ? template?.variables(seedNode, { asPreview: isPreview }) - : undefined, - }; - - if (isPreview) { - queryArgs = { - ...queryArgs, - context: { - headers: { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - Authorization: `bearer ${getAccessToken()!}`, - }, - }, - }; - } - - const templateQueryRes = await client.query(queryArgs); - - setData(templateQueryRes.data); - - setLoading(false); - } - })(); - }, [data, template, seedNode, isPreview, isAuthenticated]); - - if (!template) { + if (seedNode === null || isPreview === null || isAuthenticated === null) { return null; } - const Component = template as React.FC<{ [key: string]: any }>; - return React.createElement(Component, { ...props, data, loading }, null); + return ( + + ); } diff --git a/packages/faustwp-core/src/getWordPressProps.tsx b/packages/faustwp-core/src/getWordPressProps.tsx index aad6aae30..c66ebb730 100644 --- a/packages/faustwp-core/src/getWordPressProps.tsx +++ b/packages/faustwp-core/src/getWordPressProps.tsx @@ -1,12 +1,16 @@ -import { GetServerSidePropsContext, GetStaticPropsContext } from 'next'; +// eslint-disable-next-line import/extensions +import { print } from '@apollo/client/utilities'; import type { DocumentNode } from 'graphql'; -import { SeedNode, SEED_QUERY } from './queries/seedQuery.js'; -import { getPossibleTemplates, getTemplate } from './getTemplate.js'; -import { FaustTemplateProps } from './components/WordPressTemplate.js'; +import { sha256 } from 'js-sha256'; +import { GetServerSidePropsContext, GetStaticPropsContext } from 'next'; import { addApolloState, getApolloClient } from './client.js'; +import { FaustTemplateProps } from './components/WordPressTemplate.js'; import { getConfig } from './config/index.js'; +import { getPossibleTemplates, getTemplate } from './getTemplate.js'; +import { SEED_QUERY, SeedNode } from './queries/seedQuery.js'; +import { debugLog, infoLog } from './utils/log.js'; import { hooks } from './wpHooks/index.js'; -import { infoLog, debugLog } from './utils/log.js'; +import { FaustQueries } from './store/FaustContext.js'; export const DEFAULT_ISR_REVALIDATE = 60 * 15; // 15 minutes @@ -16,6 +20,14 @@ function isSSR( return (ctx as GetServerSidePropsContext).req !== undefined; } +type QueryVariablesArgs = [ + seedNode: SeedNode, + context?: { + asPreview?: boolean; + locale?: string; + }, + extra?: Record, +]; const createNotFound = ( ctx: GetStaticPropsContext, revalidate?: number | boolean, @@ -26,14 +38,15 @@ const createNotFound = ( export type WordPressTemplate = React.FC & { query?: DocumentNode; - variables?: ( - seedNode: SeedNode, - context?: { - asPreview?: boolean; - locale?: string; - }, - extra?: Record, - ) => { [key: string]: any }; + queries?: { + query: DocumentNode; + variables?: (...args: QueryVariablesArgs) => { + [key: string]: any; + }; + }[]; + variables?: (...args: QueryVariablesArgs) => { + [key: string]: any; + }; }; export interface FaustTemplate @@ -125,6 +138,12 @@ export async function getWordPressProps( return createNotFound(ctx, revalidate); } + if (template.query && template.queries) { + throw new Error( + '`Only either `Component.query` or `Component.queries` can be provided, but not both.', + ); + } + let templateQueryRes; const templateVariables = template?.variables ? template?.variables( @@ -144,6 +163,35 @@ export async function getWordPressProps( }); } + let queries: FaustQueries | null = null; + if (template.queries) { + const queryCalls = template.queries.map(({ query, variables }) => { + const queryVariables = variables + ? variables( + seedNode, + { + asPreview: false, + locale: ctx.locale, + }, + extra, + ) + : undefined; + return client.query({ + query, + variables: queryVariables, + }); + }); + const queriesRes = await Promise.all(queryCalls); + + queries = {}; + + queriesRes.forEach((queryRes, index) => { + if (queries && template.queries) { + queries[sha256(print(template.queries[index].query))] = queryRes.data; + } + }); + } + const appProps = addApolloState(client, { props: { /** @@ -153,6 +201,7 @@ export async function getWordPressProps( __SEED_NODE__: seedNode ?? null, __TEMPLATE_QUERY_DATA__: templateQueryRes?.data ?? null, __TEMPLATE_VARIABLES__: templateVariables ?? null, + __FAUST_QUERIES__: queries ?? null, ...props, }, }); diff --git a/packages/faustwp-core/src/hooks/useFaustContext.tsx b/packages/faustwp-core/src/hooks/useFaustContext.tsx new file mode 100644 index 000000000..e9dcd2727 --- /dev/null +++ b/packages/faustwp-core/src/hooks/useFaustContext.tsx @@ -0,0 +1,19 @@ +import { DocumentNode } from '@apollo/client'; +// eslint-disable-next-line import/extensions +import { print } from '@apollo/client/utilities'; +import { sha256 } from 'js-sha256'; +import { useContext } from 'react'; +import { FaustContext } from '../store/FaustContext.js'; + +export function useFaustQuery(query: DocumentNode): TData { + const context = useContext(FaustContext); + + if (context === undefined) { + throw new Error('useFaustQuery must be used within a FaustProvider'); + } + + const sha = sha256(print(query)); + + // eslint-disable-next-line no-underscore-dangle + return context?.queries?.[sha] as TData; +} diff --git a/packages/faustwp-core/src/index.ts b/packages/faustwp-core/src/index.ts index ad001eab1..ae322d2ed 100644 --- a/packages/faustwp-core/src/index.ts +++ b/packages/faustwp-core/src/index.ts @@ -31,6 +31,8 @@ import { import { useAuth } from './hooks/useAuth.js'; import { useLogin } from './hooks/useLogin.js'; import { useLogout } from './hooks/useLogout.js'; +import { useFaustQuery } from './hooks/useFaustContext.js'; +import { FaustContext } from './store/FaustContext.js'; import { FaustToolbarNodes, @@ -83,4 +85,6 @@ export { FaustTemplate, FaustPage, hooks, + useFaustQuery, + FaustContext, }; diff --git a/packages/faustwp-core/src/store/FaustContext.tsx b/packages/faustwp-core/src/store/FaustContext.tsx new file mode 100644 index 000000000..65a955f11 --- /dev/null +++ b/packages/faustwp-core/src/store/FaustContext.tsx @@ -0,0 +1,11 @@ +import { createContext } from 'react'; + +export type FaustQueries = { [key: string]: any }; + +export const FaustContext = createContext< + | { + queries?: FaustQueries | null; + setQueries: (newQueries: FaustQueries) => void; + } + | undefined +>(undefined); diff --git a/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx b/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx new file mode 100644 index 000000000..4036e4843 --- /dev/null +++ b/packages/faustwp-core/tests/components/WordPressTemplate.test.tsx @@ -0,0 +1,394 @@ +/** @jest-environment jsdom */ +import React from 'react'; +import { act, render, screen } from '@testing-library/react'; +import * as getConfig from '../../src/config/index.js'; +import * as WordPressTemplate from '../../src/components/WordPressTemplate.js'; +import * as useAuth from '../../src/hooks/useAuth.js'; +import * as client from '../../src/client.js'; +import { SEED_QUERY } from '../../src/queries/seedQuery.js'; +import { ApolloClient, NormalizedCacheObject, gql } from '@apollo/client'; +import * as getTemplate from '../../src/getTemplate.js'; +import * as FaustProvider from '../../src/components/FaustProvider.js'; + +describe('', () => { + const windowBackup = window; + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + window = windowBackup; + }); + + test('it throws an error if templates are not defined in config', () => { + const getConfigSpy = jest.spyOn(getConfig, 'getConfig').mockReturnValue({}); + + expect(() => render()).toThrow( + 'Templates are required. Please add them to your config.', + ); + }); + + test('Properly determines whether or not the given URL is a preview or not', async () => { + const getConfigSpy = jest.spyOn(getConfig, 'getConfig').mockReturnValue({ + templates: {}, + }); + + const useAuthSpy = jest.spyOn(useAuth, 'useAuth').mockReturnValue({ + isAuthenticated: false, + isReady: true, + loginUrl: null, + }); + + delete (window as any).location; + window.location = new URL('http://localhost:3000') as any as Location; + + const stringIncludesSpy = jest.spyOn(String.prototype, 'includes'); + + await act(async () => { + render( + , + ); + }); + + expect(window.location.search.includes).toHaveBeenLastCalledWith( + 'preview=true', + ); + expect(window.location.search.includes).toReturnWith(false); + + delete (window as any).location; + window.location = new URL( + 'http://localhost:3000?preview=true&p=40', + ) as any as Location; + + await act(async () => { + render( + , + ); + }); + + expect(window.location.search.includes).toHaveBeenLastCalledWith( + 'preview=true', + ); + expect(window.location.search.includes).toReturnWith(true); + }); + + test('Properly redirects to login URL on preview route with no logged in user', async () => { + const getConfigSpy = jest.spyOn(getConfig, 'getConfig').mockReturnValue({ + templates: {}, + }); + + const loginUrl = 'http://testing.local/wp-login.php'; + + delete (window as any).location; + // Preview route + window.location = new URL( + 'http://localhost:3000/?preview=true&p=40', + ) as any as Location; + window.location.assign = jest.fn(); + + const windowLocationAssignSpy = jest.spyOn(window.location, 'assign'); + + const useAuthSpy = jest.spyOn(useAuth, 'useAuth').mockReturnValue({ + isAuthenticated: false, + isReady: true, + loginUrl, + }); + + await act(async () => { + render( + , + ); + }); + + expect(windowLocationAssignSpy).toHaveBeenCalledWith(loginUrl); + }); + + test('makes a request for the seed query if one does not exist', async () => { + const getConfigSpy = jest.spyOn(getConfig, 'getConfig').mockReturnValue({ + templates: {}, + }); + + const useAuthSpy = jest.spyOn(useAuth, 'useAuth').mockReturnValue({ + isAuthenticated: false, + isReady: true, + loginUrl: null, + }); + + const clientQueryMock = jest.fn(); + const getApolloClientSpy = jest + .spyOn(client, 'getApolloClient') + .mockImplementation( + () => + ({ + query: clientQueryMock, + } as any as ApolloClient), + ); + + delete (window as any).location; + window.location = new URL( + 'http://localhost:3000/my-page', + ) as any as Location; + + await act(async () => { + render(); + }); + + expect(clientQueryMock).toBeCalledWith({ + query: SEED_QUERY, + variables: { + uri: '/my-page', + }, + }); + }); + + test('makes a request for the preview seed query if one does not exist', async () => { + const getConfigSpy = jest.spyOn(getConfig, 'getConfig').mockReturnValue({ + templates: {}, + }); + + const useAuthSpy = jest.spyOn(useAuth, 'useAuth').mockReturnValue({ + isAuthenticated: true, + isReady: true, + loginUrl: null, + }); + + const clientQueryMock = jest.fn(); + const getApolloAuthClientSpy = jest + .spyOn(client, 'getApolloAuthClient') + .mockImplementation( + () => + ({ + query: clientQueryMock, + } as any as ApolloClient), + ); + + delete (window as any).location; + window.location = new URL( + 'http://localhost:3000/preview?preview=true&p=40&previewPathname=%2Fhello-world%2F', + ) as any as Location; + + await act(async () => { + render(); + }); + + expect(clientQueryMock).toBeCalledWith({ + query: SEED_QUERY, + variables: { + asPreview: true, + id: '40', + }, + }); + }); +}); + +describe('', () => { + const windowBackup = window; + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + window = windowBackup; + }); + + const setLoadingMock = jest.fn(); + + const props = { + seedNode: { databaseId: '1' }, + setLoading: setLoadingMock, + loading: false, + isPreview: false, + isAuthenticated: false, + __TEMPLATE_QUERY_DATA__: null, + }; + + const GET_TITLE_QUERY = gql` + query GetTitle { + generalSettings { + title + } + } + `; + + const GET_POST_QUERY = gql` + query GetPost($postId: ID!) { + post(id: $postId, idType: DATABASE_ID) { + title + } + } + `; + + test('it throws an error if templates are not defined in config', () => { + const getConfigSpy = jest.spyOn(getConfig, 'getConfig').mockReturnValue({}); + expect(() => + render(), + ).toThrow('Templates are required. Please add them to your config.'); + }); + + test('it throws an error if the template has both queries and query defined', () => { + const getConfigSpy = jest.spyOn(getConfig, 'getConfig').mockReturnValue({ + templates: {}, + }); + + const MyTemplate = () => { + return <>My component; + }; + + MyTemplate.query = GET_TITLE_QUERY; + MyTemplate.variables = () => ({ + databaseId: '5', + }); + + MyTemplate.queries = [ + { + query: GET_POST_QUERY, + variables: () => ({ + postId: '50', + }), + }, + ]; + + const templateSpy = jest + .spyOn(getTemplate, 'getTemplate') + .mockReturnValue(MyTemplate); + + expect(() => + render(), + ).toThrow( + '`Only either `Component.query` or `Component.queries` can be provided, but not both.', + ); + }); + + test('it queries the template.query properly', async () => { + const getConfigSpy = jest.spyOn(getConfig, 'getConfig').mockReturnValue({ + templates: {}, + }); + + const GET_TITLE_QUERY = gql` + query GetTitle { + generalSettings { + title + } + } + `; + + const MyTemplate = () => { + return <>My component; + }; + + MyTemplate.query = GET_TITLE_QUERY; + MyTemplate.variables = () => ({ + databaseId: '5', + }); + + const templateSpy = jest + .spyOn(getTemplate, 'getTemplate') + .mockReturnValue(MyTemplate); + + const clientQueryMock = jest.fn(); + const getApolloClientSpy = jest + .spyOn(client, 'getApolloClient') + .mockImplementation( + () => + ({ + query: clientQueryMock, + } as any as ApolloClient), + ); + clientQueryMock.mockImplementation(() => ({ + data: { + generalSettings: { + title: 'testing', + }, + }, + })); + + await act(async () => { + render(); + }); + + expect(clientQueryMock).toHaveBeenCalledWith({ + query: GET_TITLE_QUERY, + variables: { + databaseId: '5', + }, + }); + }); + + test('it queries the template.queries properly', async () => { + const getConfigSpy = jest.spyOn(getConfig, 'getConfig').mockReturnValue({ + templates: {}, + }); + + const MyTemplate = () => { + return <>My component; + }; + + MyTemplate.queries = [ + { + query: GET_TITLE_QUERY, + }, + { + query: GET_POST_QUERY, + variables: () => ({ + postId: '50', + }), + }, + ]; + + const templateSpy = jest + .spyOn(getTemplate, 'getTemplate') + .mockReturnValue(MyTemplate); + + const clientQueryMock = jest.fn(); + const getApolloClientSpy = jest + .spyOn(client, 'getApolloClient') + .mockImplementation( + () => + ({ + query: clientQueryMock, + } as any as ApolloClient), + ); + + clientQueryMock.mockImplementationOnce(() => ({ + data: { + generalSettings: { + title: 'testing', + }, + }, + })); + + clientQueryMock.mockImplementationOnce(() => ({ + data: { + post: { + databaseId: '50', + }, + }, + })); + + await act(async () => { + render( + + + , + ); + }); + + expect(clientQueryMock).toHaveBeenCalledWith({ + query: GET_TITLE_QUERY, + }); + + expect(clientQueryMock).toHaveBeenCalledWith({ + query: GET_POST_QUERY, + variables: { + postId: '50', + }, + }); + }); +});