From 186b9367b58de8314153152f572cd3679e2c4eae Mon Sep 17 00:00:00 2001 From: Geoff Taylor Date: Mon, 1 May 2023 21:05:44 -0400 Subject: [PATCH] devops: Docs refactored heavily and provided meta data. --- ...iguring-graphql-client-for-user-session.md | 147 ++++++++- docs/handling-user-authentication.md | 183 +++++++---- ...g-user-session-and-using-cart-mutations.md | 132 ++++---- docs/installation.md | 9 +- docs/routing-by-uri.md | 290 ++++++++++-------- docs/settings.md | 7 + docs/using-cart-data.md | 188 ++++++++---- docs/using-product-data.md | 30 +- 8 files changed, 664 insertions(+), 322 deletions(-) diff --git a/docs/configuring-graphql-client-for-user-session.md b/docs/configuring-graphql-client-for-user-session.md index 89dbb36b7..6437cdad4 100644 --- a/docs/configuring-graphql-client-for-user-session.md +++ b/docs/configuring-graphql-client-for-user-session.md @@ -1,10 +1,19 @@ +--- +title: "Configuring GraphQL Client for User Session" +description: "A step-by-step guide on how to configure your GraphQL client to handle user sessions, authentication, and more when working with WooGraphQL and WPGraphQL." +keywords: "WooGraphQL, WPGraphQL, WooCommerce, GraphQL, user session, authentication, configuration, client" +author: "Geoff Taylor" +--- + # Configuring a GraphQL Client for WooCommerce User Session Management In this comprehensive guide, we'll walk you through the process of configuring a GraphQL client to manage user sessions and credentials when working with WooGraphQL. By following the steps outlined in this tutorial, you'll learn how to create a GraphQL client that maintains a valid WooCommerce session in the `woocommerce_sessions` DB table. This knowledge will enable you to build robust applications that interact smoothly with WooCommerce while providing a seamless experience for your users and shortening development time. By properly handling the session token, you can implement session pass-off functionality, allowing you to fallback on the cart page, my-account page, or any other page living in WordPress that relies on user sessions. Note that implementing the session pass-off functionality is out of the scope of this guide. So, let's dive in and explore the intricacies of setting up a GraphQL client that effectively manages user sessions for your e-commerce store! -When using WooGraphQL cart and customer functionality, there are certain prerequisites. A WooGraphQL session token, distributed by the QL Session Handler, must be passed as an HTTP header with the name `woocommerce-session`, prefixed with `Session`. This header should be included in all session data-altering mutations. Note that the required name `woocommerce-session` can be changed using WordPress filters. +## Sending the `woocommerce-session` HTTP request header + +When using WooGraphQL cart and customer functionality, there are certain prerequisites. A WooGraphQL session token, distributed by the QL Session Handler, must be passed as an HTTP header with the name `woocommerce-session`, prefixed with `Session `. This header should be included in all session data-altering mutations. Note that the required name `woocommerce-session` can be changed using WordPress filters. For simple requests using `fetch`, this is quite easy to implement. Here's an example of a WooGraphQL request executed with `fetch`, performing a cart query and passing the woocommerce-session header with a value of `Session ${sessionToken}`. The `sessionToken` is read from `localStorage`. @@ -41,7 +50,9 @@ fetch(endpoint, { .then((data) => console.log(data)); ``` -However, if you're using a library or framework like Apollo, configuring a middleware layer is required, which can be confusing if not explained or demonstrated effectively. In this guide, we'll walk you through setting up the Apollo Client and its middleware to work with WooGraphQL. +This works for simple streamlined applications that don't rely heavily on cart functionality. Note that this example also does not retrieve the updated token from the `woocommerce-session` HTTP response header. + +And if you're using a library or framework like Apollo, configuring middleware and afterware layers are required, which makes things more confusing if not explained or demonstrated effectively. In this guide, we'll walk you through setting up the Apollo Client and its middleware/afterware to work with WooGraphQL. ## Creating the Apollo Client instance @@ -56,12 +67,15 @@ const client = new ApolloClient({ link: from([ createSessionLink(), createErrorLink(), + createUpdateLink(), new HttpLink({ uri: endpoint }), ]), cache: new InMemoryCache(), }); ``` +In the example you see the creation of our `client`. It include middleware/afterware callbacks managed by the `ApolloLink` class. For those not familiar with it, the `ApolloLink` class is allowed you to customize the flow of data by defining you networks behavior as a chain of link object. I'm stating this so you know the order of the callbacks is also important and it will be understood why as we define this callbacks themselves. + ## Defining the `createSessionLink` function Next, define the `createSessionLink` function as follows: @@ -83,8 +97,22 @@ function createSessionLink() { return {}; }); } + +//...rest of code ``` +And that's our callback for applying the our session token to each request made through our client. Note that I am using the shorthand method of importing the `setContext` function, however most examples you will find will use the `ApolloLink` class directly to define the link object. + +```javascript +mport { ApolloLink } from '@apollo/client'; + +const consoleLink = new ApolloLink((operation, forward) => { + return operation.setContext(/* our callback */); +}); +``` + +And this works fine too, but is more verbose and kinda overkill if your just make a stateless link like we are here. `Stateless` links are middleware callbacks that don't care to know anything about the context of the operation and just does it own thing regardless of what operation Apollo is about to execute. + ## About the environment variables Before we dive into the guide, it's important to note that the `process.env.*` variables used throughout the tutorial are simply string values stored in an `.env` file and loaded using the [**dotenv**](https://www.npmjs.com/package/dotenv) package. As a reader, you can replace these variables with any values that suit your needs. @@ -99,12 +127,25 @@ AUTH_KEY_TIMEOUT=30000 GRAPHQL_ENDPOINT=http://woographql.local/graphql ``` -## Defining the `getSessionToken` and `fetchSessionToken` functions +With a .env file created you will be ready to move on to what's next, which is defining the `getSessionToken` function. -Here are the `getSessionToken` and `fetchSessionToken` functions: +## Defining the `getSessionToken` function + +```javascript +export async function getSessionToken(forceFetch = false) { + let sessionToken = localStorage.getItem(process.env.SESSION_TOKEN_LS_KEY as string); + if (!sessionToken || forceFetch) { + sessionToken = await fetchSessionToken(); + } + return sessionToken; +} +``` + +The function is rather simple. It attempt to retrieve the `sessionToken` from `localStorage`, and if that fails or `forceFetch` is passed it fetches a new one using `fetchSessionToken()`. And `fetchSessionToken` is defined. ```javascript import { GraphQLClient } from 'graphql-request'; +import { GetCartDocument } from './graphql' // Session Token Management. async function fetchSessionToken() { @@ -127,18 +168,11 @@ async function fetchSessionToken() { return sessionToken; } -export async function getSessionToken(forceFetch = false) { - let sessionToken = localStorage.getItem(process.env.SESSION_TOKEN_LS_KEY as string); - if (!sessionToken || forceFetch) { - sessionToken = await fetchSessionToken(); - } - return sessionToken; -} -``` +// ...rest of code -Defining the `GetCartDocument` +``` -Here's the `GetCartDocument`: +For this example this works for most case but typically you want the obscure the retrieval of the token and the endpoint from the end-user, especially if dealing with authenticated users. There are a number of a ways to do this like serverless functions or Next.js API routes and they should be doing exactly what is done here retrieve the sessionToken and/or user authentication tokens and nothing else. See the `GetCartDocument` below in `./graphql`. ```javascript import { gql } from '@apollo/client'; @@ -153,7 +187,7 @@ export const GetCartDocument = gql` ``` -It's highly recommended to retrieve the session token outside of the Apollo Client for better control of its value. To ensure no request gets sent without a session token, we must define an error link to capture failed queries caused by an invalid or expired session token, delete the current session, and retrieve a new one. Here's the `createErrorLink` function: +By separating retrieval of the `sessionToken` it also enables better control of its value. We can take this further by ensuring no request gets sent without a session token or with an invalid session token. This is where our `createErrorLink` error handling middleware and `createUpdateLink` token updating afterware come into play. First `createErrorLink`, to capture failed queries caused by an invalid or expired session tokens, deleting the currently stored session token, and retrieve a new one. ```javascript import { onError } from '@apollo/client/link/error'; @@ -202,7 +236,6 @@ function createErrorLink() { }); }); } - return message; }); } return observable; @@ -210,6 +243,86 @@ function createErrorLink() { } ``` -With the creation of the error link, we now have an Apollo Client that completely manages the WooCommerce session. Note that this doesn't account for all use cases, specifically dealing with registered WooCommerce customers. In such cases, you'll need to use a second JWT for identifying their WordPress account, called an Authentication Token or auth token for short. For handling user authentication, auth tokens, and refresh tokens, refer to the next guide. +There is a lot going on here but is not very complex once broken down. + +```javascript +const targetErrors = [ + 'The iss do not match with this server', + 'invalid-secret-key | Expired token', + 'invalid-secret-key | Signature verification failed', + 'Expired token', + 'Wrong number of segments', +]; +``` + +This our the error messages we are targeting. Each are exclusively results of an invalid tokens. + +```javascript +let observable; + if (graphQLErrors?.length) { + graphQLErrors.map(({ debugMessage, message }) => { + if (targetErrors.includes(message) || targetErrors.includes(debugMessage)) { + observable = new Observable((observer) => { + getSessionToken(true) + .then((sessionToken) => { + operation.setContext(({ headers = {} }) => { + const nextHeaders = headers; + + if (sessionToken) { + nextHeaders['woocommerce-session'] = `Session ${sessionToken}`; + } else { + delete nextHeaders['woocommerce-session']; + } + + return { + headers: nextHeaders, + }; + }); + }) +``` + +This is the scary looking part if you are not familar with observables, but don't be. Observables are similar to Promises, but instead of handling a single asynchronous event, they handle multiple events over time. While Promises resolve only once and return a single value, Observables emit multiple values and can be canceled, providing greater control over asynchronous data streams. +Our usage here is to tell Apollo to retry the last operation after we have retrieved a new token with `getSessionToken` if the current `graphQLError` matches any of our targetted errors, otherwise `observable` is left as a `undefined` value and Apollo continues as normal. + +Next is the `createUpdateLink` callback, responsible for retrieving an updated `sessionToken` from the `woocommerce-session` HTTP response token. The reason for this is the session token generated by WooGraphQL is self-managing and a new token with an updated expiration time of 14 days from the last action is generated on each request that a `woocommerce-session` HTTP request header is sent. To retrieve a store this updated token we use Apollo afterware. + +## Defining the `createUpdateLink` function + +Next, define the `createUpdateLink` function as follows: + +```javascript +import { setContext } from '@apollo/client/link/context'; + +function createUpdateLink(operation, forward) => { + return forward(operation).map((response) => { + /** + * Check for session header and update session in local storage accordingly. + */ + const context = operation.getContext(); + const { response: { headers } } = context; + const oldSessionToken = localStorage.getItem(process.env.SESSION_TOKEN_LS_KEY as string); + const sessionToken = headers.get('woocommerce-session'); + if (sessionToken) { + if ( oldSessionToken !== session ) { + localStorage.setItem(process.env.SESSION_TOKEN_LS_KEY as string, sessionToken); + } + } + + return response; + }); +} +``` + +This is an our Apollo afterware callback, and if you are wondering how does this differ from Apollo middleware look at the following. + +```javascript +return forward(operation).map((response) => { +``` + +By calling `.map()` on the result of `forward()`, we're telling Apollo to execute this after operation completion, you can even take it a further by modifying the `response` object if necessary. It is not here, but I figured I should at least state that fact. + +We also put after the `createErrorLink` callback in our `from()` call when defining the `ApolloClient` to ensure it's never executed on a request failed due to an invalid token. + +And with the creation of the `createUpdateLink` link, we now have an Apollo Client that completely manages the WooCommerce session. Note that this doesn't account for all use cases, specifically dealing with registered WooCommerce customers. In such cases, you'll need to use a second JWT for identifying their WordPress account, called an Authentication Token or auth token for short. For handling user authentication, auth tokens, and refresh tokens, refer to the next guide. This should provide you with a solid foundation for setting up a GraphQL client that effectively manages user sessions in your e-commerce application. By following the steps outlined, you'll be able to create a seamless experience for your users when interacting with both WooCommerce, ultimately saving development time and effort. diff --git a/docs/handling-user-authentication.md b/docs/handling-user-authentication.md index 42e3f7f7d..1466c09a9 100644 --- a/docs/handling-user-authentication.md +++ b/docs/handling-user-authentication.md @@ -1,10 +1,20 @@ +--- +title: "Handling User Authentication with WooGraphQL" +description: "Learn how to handle user authentication in your headless WooCommerce application using WooGraphQL and WPGraphQL for secure and seamless user experiences." +keywords: "WooGraphQL, WPGraphQL, WooCommerce, GraphQL, user authentication, login, register, secure, headless" +author: "Geoff Taylor" +--- + # Handling User Authentication In this guide, we'll pick where the last one stopped and focus on handling user authentication, auth tokens, and refresh tokens. This will allow your application to not only manage WooCommerce sessions effectively but also handle WordPress authentication, providing a seamless experience for your users. -The execution of this part of the guide should be similar to the first part, with some additional steps to account for the different behavior around validation and renewal of auth tokens. We'll walk you through modifying the `createSessionLink` function, creating the `getAuthToken` function, and implementing the necessary steps to manage auth token renewal. +The execution of this part of the guide should be similar to the first part, with some additional steps to account for the different behavior around validation and renewal of auth tokens. We'll walk you through modifying the `createSessionLink`, `fetchSessionToken`, +and `createErrorLink` functions, creating the `getAuthToken` function, and implementing the necessary steps to manage auth token renewal. + +## Updating the `createSessionLink` function -First, let's modify the `createSessionLink` function: +First, let's start by modifying the `createSessionLink` function: ```javascript function createSessionLink() { @@ -29,6 +39,11 @@ function createSessionLink() { }); } ``` + +Not too much changing here it's still as simple as it was before except now we're set an `Authorization` header too. + +## Creating the `getAuthToken` and `fetchAuthToken` functions. + Next, we'll create a new function called getAuthToken. This function is similar to the getSessionToken function but has some key differences due to the way session tokens and auth tokens handle renewal. Starting with the following mutation. ```javascript @@ -43,19 +58,11 @@ const RefreshAuthTokenDocument = gql` `; ``` -To help you understand the differences, let's briefly discuss how the session token and auth token handle renewal. Session tokens are renewed automatically, and an updated session token is generated on every request. All you have to do is retrieve it. Auth tokens, on the other hand, require you to use the mutation above and the refresh token that's distributed with the auth token to get a new auth token before the auth token expires, which is approximately 15 minutes after creation 😅. +To help you understand the differences, let's briefly discuss how the session token and auth token handle renewal. As stated in the previous guide session tokens are self-managed and renewed automatically by WooGraphQL when sent within the 14 day limit, and an updated session token is generated on every request. All you have to do is retrieve it. Auth tokens, on the other hand, require you to use the mutation above and the refresh token that's distributed with the auth token to get a new auth token before the auth token expires, which is approximately 15 minutes after creation 😅. -```javascript -import { GraphQLClient } from 'graphql-request'; -function saveCredentials(authToken, sessionToken, refreshToken = null) { - sessionStorage.setItem(process.env.AUTH_TOKEN_LS_KEY, authToken); - sessionStorage.setItem(process.env.SESSION_TOKEN_LS_KEY, sessionToken); - if (refreshToken) { - localStorage.setItem(process.env.REFRESH_TOKEN_LS_KEY, refreshToken); - } -} +```javascript export function hasCredentials() { const authToken = sessionStorage.getItem(process.env.AUTH_TOKEN_LS_KEY); const refreshToken = localStorage.getItem(process.env.REFRESH_TOKEN_LS_KEY); @@ -66,13 +73,29 @@ export function hasCredentials() { return false; } +``` + +As the name states it all it confirms the existence of the auth and refresh tokens. + +```javascript +export async function getAuthToken() { + let authToken = sessionStorage.getItem(process.env.AUTH_TOKEN_LS_KEY ); + if (!authToken || !tokenSetter) { + authToken = await fetchAuthToken(); + } + return authToken; +} +``` + +This should look familiar if you read the previous guide, as it's almost identical `getSessionToken()`, only difference is there is no `forceFetch` option because it's simply not needed. +```javascript let tokenSetter; async function fetchAuthToken() { const refreshToken = localStorage.getItem(process.env.REFRESH_TOKEN_LS_KEY); if (!refreshToken) { // No refresh token means the user is not authenticated. - throw new Error('Not authenticated'); + return; } try { @@ -81,30 +104,15 @@ async function fetchAuthToken() { const results = await graphQLClient.request(RefreshAuthTokenDocument, { refreshToken }); const authToken = results?.refreshJwtAuthToken?.authToken; - if (!authToken) { throw new Error('Failed to retrieve a new auth token'); } - - const customerResults = await graphQLClient.request( - GetCartDocument, - undefined, - { Authorization: `Bearer ${authToken}` }, - ); - - const customer = customerResults?.customer; - const sessionToken = customer?.sessionToken; - if (!sessionToken) { - throw new Error('Failed to retrieve a new session token'); - } } catch (err) { - if (isDev()) { - // eslint-disable-next-line no-console - console.error(err); - } + console.error(err); } - saveCredentials(authToken, sessionToken); + // Save token. + sessionStorage.setItem(process.env.AUTH_TOKEN_LS_KEY, authToken); if (tokenSetter) { clearInterval(tokenSetter); } @@ -121,17 +129,50 @@ async function fetchAuthToken() { return authToken; } +``` -export async function getAuthToken() { - let authToken = sessionStorage.getItem(process.env.AUTH_TOKEN_LS_KEY ); - if (!authToken || !tokenSetter) { - authToken = await fetchAuthToken(); +There is a lot going on here, but it's very similar to our `fetchSessionToken()` from the previous guide the different here is the auth token in sessionStorage instead of localStorage, which means it will be deleted when the user closes the browser. A new auth token will be needed every time the user opens the page after closing the browser. To better breakdown the function let's step through the possible outcomes. + +1. The first being the quiet exit if no `refreshToken` is found. This is the scenario of an unauthenticated user. This is pretty much any new user that show up to your application. +2. The next one is the error thrown if no `authToken` is returned. This is the scenario of an user with a invalid/expired refresh token, at which case you meant just want to delete the stored refresh token and quietly exit the function. +3. The error handler is incase anything goes wrong during the `GraphQLClient.query()` call. +4. And last if nothing goes wrong `tokenSetter` is assigned with a new recurring fetcher set for 5 minute interval and the `authToken` is returned. + +The purpose of the `tokenSetter` fetcher is to address the short lifespan of the `authToken`. This also ensures that a invalid `authToken` is never sent, and because of this we don't have update the `createErrorLink` or `createUpdateLink` callbacks from the previous guide, but we do have to update our `fetchSessionToken()` function. + +## Updating the `fetchSessionToken()` function + +```javascript +async function fetchSessionToken() { + const headers = {}; + const authToken = await getAuthToken(); + if (authToken) { + headers.Authorization = `Bearer ${authToken}`; } - return authToken; + + let sessionToken; + try { + const graphQLClient = new GraphQLClient(process.env.GRAPHQL_ENDPOINT, { headers }); + + const cartData = await graphQLClient.request(GetCartDocument); + + // If user doesn't have an account return accountNeeded flag. + sessionToken = cartData?.cart?.sessionToken; + + if (!sessionToken) { + throw new Error('Failed to retrieve a new session token'); + } + } catch (err) { + console.error(err); + } + + return sessionToken; } ``` -In the `saveCredentials` above, we store the auth token in sessionStorage instead of localStorage, which means it will be deleted when the user closes the browser. A new auth token will be needed every time the user opens the page after closing the browser. You'll also notice the use of tokenSetter and setInterval to auto-renew the auth token every 5 minutes in `fetchAuthToken`. +Now we're setting the `Authorization` header if the `authToken` to ensure the `sessionToken` returned is belongs to the authenticated user. + +## Creating the `login` callback. For any of this to work, you need to be able to log the user into WordPress. We recommend using the WPGraphQL-JWT-Authentication plugin, which provides a login mutation. @@ -150,36 +191,64 @@ const LoginDocument = gql` } ``` +We'll start by making a quick helper that'll sort our newly obtained credentials. + +```javascript + +function saveCredentials(authToken, sessionToken, refreshToken = null) { + sessionStorage.setItem(process.env.AUTH_TOKEN_LS_KEY, authToken); + sessionStorage.setItem(process.env.SESSION_TOKEN_LS_KEY, sessionToken); + if (refreshToken) { + localStorage.setItem(process.env.REFRESH_TOKEN_LS_KEY, refreshToken); + } +} +``` + Use the mutation to implement a login callback function in your application to handle the login process: ```javascript +import { rawRequest } from 'graphql-request'; export async function login(username, password) { - try { - const graphQLClient = new GraphQLClient(process.env.SHOP_GRAPHQL_ENDPOINT); - const results = await graphQLClient.request( - LoginDocument, - { username, password }, - ); - const loginResults = results?.login; - const { - authToken, - refreshToken, - customer, - } = loginResults; - - if (!authToken || !refreshToken || !customer?.sessionToken) { - throw new Error( 'Failed to retrieve credentials.'); - } - } catch (error) { - throw new Error(error); + const headers = {}; + const sessionToken = await getSessionToken(); + if (sessionToken) { + headers['woocommerce-session'] = `Session ${sessionToken}`; + } + try { + const graphQLClient = new GraphQLClient(process.env.SHOP_GRAPHQL_ENDPOINT, { headers }); + const { data, headers: responseHeaders, status, } = await rawRequest( + process.env.GRAPHQL_ENDPOINT as string, + LoginDocument, + { username, password }, + headers, + ); + const loginResults = data?.login; + const newSessionToken = responseHeaders.get('woocommerce-session'); + const { + authToken, + refreshToken, + customer, + } = loginResults; + + if (!authToken || !refreshToken || !newSessionToken) { + throw new Error( 'Failed to retrieve credentials.'); } - saveCredentials(authToken, customer.sessionToken, refreshToken); - return customer; + } catch (error) { + throw new Error(error); + } + + saveCredentials(authToken, newSessionToken, refreshToken); + + return customer; } ``` +Just like with `fetchSessionToken()` is highly recommend the you obscure the API calls here by deferring the logic to something like a serverless function or Next.js API route. Note, we are also return the `customer` object here which could potentially be problematic if sensitive information like the user's email or phone number is being pulled. + +## Conclusion + In summary, we demonstrated how to configure a GraphQL client to work with WooGraphQL, manage WooCommerce sessions, and handle WordPress authentication. With this setup, you should be able to create a robust and secure client that manages user authentication efficiently and seamlessly. The next guide will begin teaching how you best utilize the data received from WooGraphQL to create showstopping components. diff --git a/docs/handling-user-session-and-using-cart-mutations.md b/docs/handling-user-session-and-using-cart-mutations.md index 20ef53fa8..f7509111f 100644 --- a/docs/handling-user-session-and-using-cart-mutations.md +++ b/docs/handling-user-session-and-using-cart-mutations.md @@ -1,3 +1,10 @@ +--- +title: "Handling User Session and Using Cart Mutations with WooGraphQL" +description: "Discover how to manage user sessions and perform cart mutations using WooGraphQL and WPGraphQL in your headless WooCommerce application for a smooth shopping experience." +keywords: "WooGraphQL, WPGraphQL, WooCommerce, GraphQL, user session, cart mutations, headless, shopping experience" +author: "Geoff Taylor" +--- + # Handling User Session and Using Cart Mutations In this guide, we will demonstrate how to implement cart controls on the single product page, which will take into account the state of the cart stored in the user session. This guide builds upon the app created in the previous guides, so use the code samples from them as a starting point. The guide is broken down into three parts: The implementation and use of `UserSessionProvider.jsx`, `useCartMutations.js`, and `CartOptions.jsx`. @@ -301,6 +308,7 @@ export const RemoveItemsFromCart = gql` ``` We've included all the queries will be using going forward and leveraging some fragments here and there. Now we can move onto implementing the components sourcing these queries and mutations. +We won't go over them into much detail here but you can learn more about them in the [schema](/schema) docs. ## Step 1: UserSessionProvider.jsx @@ -384,7 +392,7 @@ export function SessionProvider({ children }) { export const useSession = () => useContext(SessionContext); ``` -To use the `SessionProvider`, you should wrap your app component with it and wrap the `SessionProvider` with an ApolloProvider set with our session token managing ApolloClient. Make sure to demonstrate this for the reader against our previous code samples from previous posts. +To use the `SessionProvider`, you should wrap your root app component with it and wrap the `SessionProvider` with an ApolloProvider set with our session token managing ApolloClient. Make sure to demonstrate this for the reader against our previous code samples from previous posts. ## Step 2: useCartMutations.js @@ -528,78 +536,98 @@ With the `useCartMutations` hook implemented, you can use it within your compone You can now use this hook to create and manage cart interactions in your components. For instance, you can create an "Add to Cart" button that adds items to the cart, updates the quantity of an existing item, or removes an item from the cart. -Here's an example of how you could use the useCartMutations hook within a React component: +Here's an example of how you could use the useCartMutations hook within a React component use our SingleProduct component from the previous guide: ```jsx -import React, { useState } from 'react'; -import useCartMutations from './useCartMutations'; +import React, { useEffect, useState } from 'react'; +import { useQuery } from '@apollo/client'; +import { GetProduct } from './graphql'; -const ProductCard = ({ product }) => { +const SingleProduct = ({ productId }) => { const [quantity, setQuantity] = useState(1); - const { productId, variationId, extraData } = product; - const { quantityInCart, mutate, loading } = useCartMutations(productId, variationId, extraData); + const { data, loading, error } = useQuery(GetProduct, { + variables: { id: productId, idType: 'DATABASE_ID' }, + }); + const { quantityInCart: inCart, mutate, loading } = useCartMutations(productId); - const handleAddToCart = () => { - mutate(quantity, 'add'); - }; + useEffect(() => { + if (inCart) { + setQuantity(inCart); + } + }, [inCart]) - const handleUpdateQuantity = () => { - mutate(quantity, 'update'); - }; + if (loading) return

Loading...

; + if (error) return

Error: {error.message}

; - const handleRemoveFromCart = () => { - mutate(0, 'remove'); - }; + const handleAddOrUpdateAction = async () => { + mutate({ quantity }); + } + + const handleRemoveAction = async () => { + mutate({ mutation: 'remove', quantity: 0 }); + } + + const buttonText = inCart ? 'Update' : 'Add To Cart'; return ( -
-

{product.name}

- {product.name} -

Price: {product.price}

- -
- - setQuantity(parseInt(e.target.value))} - min={0} - /> +
+ {/* Rest of component */} +
+ {!product.soldIndividually && ( +
+ + setQuantity(Number.parseInt(event.target.value)))} + /> +
+ )} + {product.stockStatus === 'IN_STOCK' ? ( + <> + + {inCart && ( + + )} + + ) : ( +

Out of stock

+ )}
- - - - {quantityInCart > 0 && ( - <> - - - - - )}
); }; -export default ProductCard; +export default SingleProduct; ``` -In this example, we have a `ProductCard` component that receives a product object as a prop. It uses the `useCartMutations` hook to manage the cart actions. The component renders the product information and provides buttons to add, update, or remove the item from the cart. +In this example, we have our `SingleProduct` component that receives a `productId`. It uses the `useCartMutations` hook to manage the cart actions. The component renders the product information and provides buttons to add, update, or remove the item from the cart. -The `handleAddToCart`, `handleUpdateQuantity`, and `handleRemoveFromCart` functions call the `mutate` function returned by the `useCartMutations` hook with the desired action ('add', 'update', or 'remove'). The `loading` flag is used to disable the buttons while any cart mutations are in progress. +The `handleAddOrUpdateAction` and `handleRemoveAction` functions call the `mutate` function returned by the `useCartMutations`. The `loading` flag is used to disable the buttons while any cart mutations are in progress. -This is just an example of how you could use the `useCartMutations` hook. Depending on your application's requirements and design, you may need to modify or extend the component to fit your needs. +This is just an example of how you could use the `useCartMutations` hook and only using simple products, but as I'm sure you noticed it support a `variationId` as the second parameter. Implementing Variable product support in our `SingleProduct` component is out of the scope this guide, but with what has been provided you should have no problem implementing variable product support. ## Conclusion -In conclusion, we've created a custom React hook, `useCartMutations`, which allows you to manage cart actions like adding, updating, and removing items in an e-commerce application. We've used the Apollo Client's useMutation hook to interact with the GraphQL API and manage the state of the cart. Then, we've demonstrated how to use the custom `useCartMutations` hook within a `ProductCard` component to perform cart-related actions. +In conclusion, we've created a custom React hook, `useCartMutations`, which allows you to manage cart actions like adding, updating, and removing items in an e-commerce application. We've used the Apollo Client's useMutation hook to interact with the GraphQL API and manage the state of the cart. Then, we've demonstrated how to use the custom `useCartMutations` hook within our `SingleProduct` component to perform cart-related actions. -This custom hook can help you create a more organized and modular e-commerce application by abstracting the cart logic and keeping your components clean and focused. You can further modify and extend the `useCartMutations` hook and the `ProductCard` component to suit the specific requirements of your application. +This custom hook can help you create a more organized and modular e-commerce application by abstracting the cart logic and keeping your components clean and focused. You can further modify and extend the `useCartMutations` hook and the `SingleProduct` component to suit the specific requirements of your application. By leveraging the power of custom hooks and GraphQL in your React application, you can create a robust and efficient e-commerce solution that scales well and provides a great user experience. diff --git a/docs/installation.md b/docs/installation.md index fed9bdbe0..45b80a326 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -1,3 +1,10 @@ +--- +title: "WooGraphQL Installation Guide" +author: "Geoff Taylor" +description: "Step-by-step instructions to install and set up WooGraphQL, the WPGraphQL extension that integrates WooCommerce with GraphQL for headless e-commerce solutions." +keywords: "WooGraphQL, WPGraphQL, WooCommerce, GraphQL, installation, setup, headless e-commerce" +--- + # Installation This guide will walk you through the process of installing and configuring WooGraphQL for your WordPress website. @@ -11,7 +18,7 @@ This guide will walk you through the process of installing and configuring WooGr ### 1. Download the WooGraphQL plugin -Visit the official WooGraphQL website (https://woographql.com) and download the latest release as a zip file. +Visit the official WooGraphQL website (https://woographql.com/) and download the latest release as a zip file. ### 2. Install the WooGraphQL plugin diff --git a/docs/routing-by-uri.md b/docs/routing-by-uri.md index 74d9dcc1c..e16757cc2 100644 --- a/docs/routing-by-uri.md +++ b/docs/routing-by-uri.md @@ -1,3 +1,10 @@ +--- +title: "Routing by URI with WooGraphQL" +description: "Discover how to implement routing by URI in your headless WooCommerce application using WooGraphQL and WPGraphQL for efficient and user-friendly navigation." +keywords: "WooGraphQL, WPGraphQL, WooCommerce, GraphQL, routing, URI, headless, navigation" +author: "Geoff Taylor" +--- + # Routing By URI In this guide, we will create a simple app that demonstrates routing with WPGraphQL's `nodeByUri` query. We will use this query to fetch data for a shop page that displays a list of products with their "name", "shortDescription", "price", and "image". The shop page will use the uri parameter to fetch the data and render the page accordingly. @@ -32,38 +39,75 @@ export default App; ## ShopPage Component -In the `ShopPage` component, we will use the `nodeByUri` query to fetch the data for the shop page. Based on the data received, we will render a product listing for either a collection or a single data object. +In the `ShopPage` component, we will use the `nodeByUri` query to fetch the data for the shop page. Based on the data received, we will render a product listing for either a collection or a single data object. We'll start by creating the `graphql.js` file. + +```javascript +import { gql } from '@apollo/client'; + +export const NodeByUri = gql` + query NodeByUri($uri: ID!) { + nodeByUri(uri: $uri) { + ... on Product { + id + name + shortDescription + price + image { + sourceUrl + altText + } + } + contentNodes(first: 100) { + edges { + cursor + node { + ... on Product { + id + name + shortDescription + ... on SimpleProduct { + price + } + ... on VariableProduct { + price + } + image { + sourceUrl + altText + } + } + } + } + pageInfo { + hasNextPage + endCursor + } + } + } + } +`; +``` + ```jsx -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { useQuery } from '@apollo/client'; -import gql from 'graphql-tag'; import ProductListing from './ProductListing'; // Import the NodeByUri query here import { NodeByUri } from './graphql'; const ShopPage = () => { - const [products, setProducts] = useState([]); const { loading, error, data } = useQuery(NodeByUri, { variables: { uri: '/shop' }, }); - useEffect(() => { - if (data && data.nodeByUri) { - const { nodeByUri } = data; - - if (nodeByUri.contentNodes) { - setProducts(nodeByUri.contentNodes.nodes); - } else { - setProducts([nodeByUri]); - } - } - }, [data]); - if (loading) return

Loading...

; if (error) return

Error: {error.message}

; + const products = data?.nodeByUri?.contentNodes?.edges?.map( + ({ node }) => node + ) || []; return ; }; @@ -104,11 +148,34 @@ export default ProductListing; With the `ProductListing` component, we can display the product listing for both collection and single data object. This approach can also be applied to other pages such as `/product-category/*` or `/product-tag/*` pages, with the ability to change there slug names as well in the WP Dashboard. -In the next part, we will focused further on rendering a product listing using the `nodeByUri` query by exploring adding features like pagination, sorting, and filtering to our shop page. +In the next section, we will focused further on rendering a product listing using the `nodeByUri` query by exploring adding features like pagination, sorting, and filtering to our shop page. ## Pagination -To add pagination to our shop page, we will need to update the `NodeByUri` operation to include the `after` and `first` variables. This will allow us to fetch a specific number of products and control the starting point for the fetched data. +To add pagination to our shop page, we will need to create a type policy for our schema. This will tell Apollo how to cache our query results. + +```javascript +import { ApolloClient, from } from '@apollo/client'; +import { relayStylePagination } from '@apollo/client/utilities'; +const typePolicies = { + RootQuery: { + queryType: true, + fields: { + products: relayStylePagination(['where']), + }, + }, +}; +const client = new ApolloClient({ + link: from([ + // ...middleware/afterware/endpoint + ]), + cache: new InMemoryCache({ typePolicies }), +}); +``` + +`relayStylePagination()` is a utility function that merges to the results of a Relay Connection together and as of the writing of this documentation has a slight bug where it only merges the `edges` and not the `nodes`, if your wondering why we're using `edges` instead of `nodes`. + +Next we have to update the `NodeByUri` operation to include the `after` and `first` variables. This will allow us to fetch a specific number of products and control the starting point for the fetched data. _Notice we are not applying the `first` and `after` variables to the query but instead a connection within._ @@ -128,20 +195,23 @@ query NodeByUri($uri: ID!, $first: Int, $after: String) { } } contentNodes(first: $first, after: $after) { - nodes { - ... on Product { - id - name - shortDescription - ... on SimpleProduct { - price - } - ... on VariableProduct { - price - } - image { - sourceUrl - altText + edges { + cursor + node { + ... on Product { + id + name + shortDescription + ... on SimpleProduct { + price + } + ... on VariableProduct { + price + } + image { + sourceUrl + altText + } } } } @@ -157,32 +227,18 @@ query NodeByUri($uri: ID!, $first: Int, $after: String) { Next, update the `ShopPage` component to manage the pagination state and fetch more products using the `fetchMore` function from the `useQuery` hook: ```jsx -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { useQuery } from '@apollo/client'; -import gql from 'graphql-tag'; import ProductListing from './ProductListing'; // Import the NodeByUri query here import { NodeByUri } from './graphql'; const ShopPage = () => { - const [products, setProducts] = useState([]); const { loading, error, data, fetchMore } = useQuery(NodeByUri, { variables: { uri: '/shop', first: 10 }, }); - useEffect(() => { - if (data && data.nodeByUri) { - const { nodeByUri } = data; - - if (nodeByUri.contentNodes) { - setProducts(nodeByUri.contentNodes.nodes); - } else { - setProducts([nodeByUri]); - } - } - }, [data]); - const loadMoreProducts = () => { if (data.nodeByUri.contentNodes.pageInfo.hasNextPage) { fetchMore({ @@ -196,6 +252,10 @@ const ShopPage = () => { if (loading) return

Loading...

; if (error) return

Error: {error.message}

; + const products = data?.nodeByUri?.contentNodes?.edges?.map( + ({ node }) => node + ) || []; + return ( <> @@ -217,74 +277,62 @@ Update the `NodeByUri` query in `graphql.js`: ```graphql query NodeByUri($uri: ID!, $first: Int, $after: String, $where: RootQueryToProductConnectionWhereArgs) { - nodeByUri(uri: $uri) { - ... on Product { + nodeByUri(uri: $uri) { + ... on Product { + id + name + shortDescription + price + image { + sourceUrl + altText + } + } + contentNodes(first: $first, after: $after, where: $where) { + edges { + nodes { + ... on Product { id name shortDescription - price - image { - sourceUrl - altText + ... on SimpleProduct { + price } - } - contentNodes(first: $first, after: $after, where: $where) { - nodes { - ... on Product { - id - name - shortDescription - ... on SimpleProduct { - price - } - ... on VariableProduct { - price - } - image { - sourceUrl - altText - } - } + ... on VariableProduct { + price } - pageInfo { - hasNextPage - endCursor + image { + sourceUrl + altText } + } } + } + pageInfo { + hasNextPage + endCursor + } } + } } ``` Next, add a sorting dropdown component to the `ShopPage` component, and update the state and the `useQuery` hook to handle sorting: ```jsx -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { useQuery } from '@apollo/client'; -import gql from 'graphql-tag'; import ProductListing from './ProductListing'; // Import the NodeByUri query here import { NodeByUri } from './graphql'; const ShopPage = () => { - const [products, setProducts] = useState([]); const [sort, setSort] = useState(null); const { loading, error, data, fetchMore } = useQuery(NodeByUri, { variables: { uri: '/shop', first: 10, where: sort }, }); - useEffect(() => { - if (data && data.nodeByUri) { - const { nodeByUri } = data; - - if (nodeByUri.contentNodes) { - setProducts(nodeByUri.contentNodes.nodes); - } else { - setProducts([nodeByUri]); - } - } - }, [data]); - const loadMoreProducts = () => { if (data.nodeByUri.contentNodes.pageInfo.hasNextPage) { fetchMore({ @@ -302,6 +350,10 @@ const ShopPage = () => { if (loading) return

Loading...

; if (error) return

Error: {error.message}

; + const products = data?.nodeByUri?.contentNodes?.edges?.map( + ({ node }) => node + ) || []; + return ( <> { +const ShippingInfo = () => { const [country, setCountry] = useState(''); const [postalCode, setPostalCode] = useState(''); - const { updateShippingLocale } = useOtherCartMutations(); + const { cart } = useSession(); + const { + updateShippingLocale, + setShippingMethod, + savingShippingInfo, + savingShippingMethod, + } = useOtherCartMutations(); const handleSubmit = async (e) => { e.preventDefault(); await updateShippingLocale({ country, postalCode }); }; - return ( -
-

Shipping Locale

-
- - setCountry(e.target.value)} - /> -
+ const availableShippingRates = (cart?.availableShippingMethods || []) + .reduce( + (rates, nextPackage) => { + rates.push(...(nextPackage?.rates || [])); + + return rates; + }, + [], + ); + + if (cart.needsShipping && !cart.needsShippingAddress) { + return (
- - setPostalCode(e.target.value)} - /> +

Shipping

+ {availableShippingRates.map((shippingRate) => ( + const { cost, id, label } = shippingRate; +
+ setShippingMethod(event.target.value)} + /> + +
+ ))} +

Shipping Tax: {cart.shippingTax}

+

Shipping Total: {cart.shippingTotal}

- + ); + } + + if (cart.needsShipping) { + return ( + +

Shipping Locale

+
+ + setCountry(e.target.value)} + /> +
+
+ + setPostalCode(e.target.value)} + /> +
+ +
+ ); + } + + return null; +}; + +export default ShippingInfo; +``` + +This component works by confirming the session shipping requirements and status before return the proper output. If shipping is needed and a shipping address is set for the customer, the shipping rates are displayed for selection. If shipping is needed and no address is set, then a shipping address form is displayed to set the customer shipping address. If no shipping is needed `null` is returned. + +Simple enough, now the `ApplyCoupon.js` + +```jsx +import React, { useState } from 'react'; +import { useOtherCartMutations } from './useOtherCartMutations'; + +const ApplyCoupon = () => { + const [code, setCode] = useState(''); + const { applyCoupon, removeCoupon } = useOtherCartMutations(); + + const handleSubmit = async (e) => { + e.preventDefault(); + applyCoupon(code); + }; + + return ( +
+ +
); }; -export default ShippingLocaleForm; +export default ApplyCouponForm; ``` -Next, create the `CartPage` component: +Here we're providing a form to apply a coupon code, which calls the `applyCoupon` function from the `useOtherCartMutations` hook. + +Now, onto the `CartPage` component: ```jsx -const CartPage = () => { - const CartPage = () => { +function CartPage () { const { cart } = useSession(); const { applyCoupon } = useOtherCartMutations(); @@ -625,19 +714,7 @@ const CartPage = () => { -
{ - e.preventDefault(); - const couponCode = e.target.elements.coupon.value; - applyCoupon(couponCode); - }} - > - - -
+ @@ -645,26 +722,15 @@ const CartPage = () => {

Cart Totals

- {cart.needsShipping && !cart.needsShippingAddress ? ( -
-

Shipping

- {cart.availableShippingMethods.map((shippingMethod) => ( -
- - -
- ))} -

Shipping Tax: {cart.shippingTax}

-

Shipping Total: {cart.shippingTotal}

-
- ) : cart.needsShipping ? ( - - ) : null} +

Subtotal: {cart.subtotal}

{cart.appliedCoupons.map(({ code, discountAmount }) => (

Coupon: {code} - Discount: {discountAmount} + removeCoupon(code)} style={{ fontWeight: 'bold', color: 'red' }}> + X +

))} @@ -679,10 +745,10 @@ export default CartPage; In the `CartPage` component, we first fetch the `cart` from the `SessionProvider`. If the cart is not available, we show a loading message. Once the cart is loaded, we display the cart items in a table format, allowing users to remove items or update the quantity. -We also provide a form to apply a coupon code, which calls the `applyCoupon` function from the `useOtherCartMutations` hook. +Lastly, we display the cart's subtotal, applied coupons with their respective discounts and removal buttons, and follow that up the cart's total. -If the cart requires shipping, we display the available shipping methods and shipping-related costs. If the cart needs a shipping address, we render the `ShippingLocaleForm` component. +Now, you can use the `CartPage` component in your app, allowing users to interact with the cart, apply coupons, and manage shipping options. -Lastly, we display the cart's subtotal, applied coupons with their respective discounts, and the cart's total. +## Conclusion -Now, you can use the `CartPage` component in your app, allowing users to interact with the cart, apply coupons, and manage shipping options. +With this you're essentially ready to develop a complete application. In the next couple guides we'll be exploring taking the user through checkout by passing the session back to WordPress. diff --git a/docs/using-product-data.md b/docs/using-product-data.md index 66359e699..f59f88590 100644 --- a/docs/using-product-data.md +++ b/docs/using-product-data.md @@ -1,3 +1,10 @@ +--- +title: "Using Product Data with WooGraphQL" +description: "Learn how to efficiently fetch and use product data in your headless WooCommerce application using WooGraphQL and WPGraphQL for a seamless shopping experience." +keywords: "WooGraphQL, WPGraphQL, WooCommerce, GraphQL, product data, headless, shopping experience" +author: "Geoff Taylor" +--- + # Using Product Data In this guide, we will implement the Single Product page using the provided GraphQL query and the JSON result. We will display the product's `name`, `description`, `price`, `regularPrice`, `attributes`, `width`, `height`, `length`, and `weight`. Additionally, we will prepare a section for cart options like desired quantity and an Add to Cart button. @@ -117,17 +124,16 @@ We've included the `GetProduct` query we'll be utilizing going forward and lever First, create a new component for the Single Product page. You can call it `SingleProduct.js`. In this component, we will use the `GetProduct` query from the list of provided queries. ```jsx -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { useQuery } from '@apollo/client'; import { GetProduct } from './graphql'; -import LoadingSpinner from './LoadingSpinner'; const SingleProduct = ({ productId }) => { const { data, loading, error } = useQuery(GetProduct, { variables: { id: productId, idType: 'DATABASE_ID' }, }); - if (loading) return ; + if (loading) return

Loading...

; if (error) return

Error: {error.message}

; const product = data.product; @@ -174,18 +180,20 @@ return ( ## Step 3: Add width, height, length, and weight information -You need to modify the `GetProduct` query to include the dimensions and weight fields for simple and variable products. Then, display the dimensions and weight in the Single Product component. +You need to modify the `GetProduct` query to include the dimensions and weight fields for simple and variable products. Then, display the dimensions and weight in the Single Product component. Add the following fields to the SimpleProduct and VariableProduct fragments in the `graphql.js` file. -```javascript -// Add the following fields to the SimpleProduct and VariableProduct fragments +```graphql dimensions { width height length } weight +``` + +Inside the SingleProduct component, after rendering the attributes -// Inside the SingleProduct component, after rendering the attributes +```jsx
Dimensions: {product.dimensions.width} x {product.dimensions.height} x {product.dimensions.length}
@@ -196,10 +204,9 @@ weight ## Step 4: Add cart options section -Finally, add a section for cart options like desired quantity and the Add to Cart button. Use the `soldIndividually` and `stockStatus` fields to control the state of the cart controls. +Finally, add a section for cart options like desired quantity and the Add to Cart button. Use the `soldIndividually` and `stockStatus` fields to control the state of the cart controls. Add this inside the SingleProduct component, after rendering the weight information -```javascript - // Inside the SingleProduct component, after rendering the weight information +```jsx
{!product.soldIndividually && (
@@ -216,10 +223,9 @@ Finally, add a section for cart options like desired quantity and the Add to Car )}
); - ``` -With this implementation, the Single Product page displays the product information, dimensions, and weight. Additionally, it includes a section for cart options, such as the desired quantity and an Add to Cart button. The availability of the cart options is dictated by the `soldIndividually` and `stockStatus` fields. If the product is not sold individually, the user can select a quantity. The Add to Cart button is only shown if the product is in stock; otherwise, an "Out of stock" message is displayed. +With this implementation, the Single Product page displays the product information, dimensions, and weight. Additionally, it includes a section for cart options, such as the desired quantity and an Add to Cart button. The availability of the cart options is dictated by the `soldIndividually` and `stockStatus` fields. If the product is not sold individually, the user can select a quantity. The Add to Cart button is only shown if the product is in stock; otherwise, an "Out of stock" message is displayed. We could also go a step further and use the product's `stockQuantity` to set a hard max quantity limit, but it's outta of the scope of this guide. ## Conclusion