diff --git a/.gitignore b/.gitignore index 867bb7ae..192a452c 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ brightspot-types brightspot-server .next generated.ts +generated \ No newline at end of file diff --git a/content-management/README.md b/content-management/README.md new file mode 100644 index 00000000..e25cf78b --- /dev/null +++ b/content-management/README.md @@ -0,0 +1,78 @@ +# Content Management +In this tutorial, you will learn how to use a Content Management API Endpoint provided by Brightspot to power a frontend application. + +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +## What you will learn +1. How to create a Content Management API endpoint with Brightspot's JS Classes +2. How to create a front-end application with [Next.js](https://nextjs.org/), [Apollo Client](https://www.apollographql.com/docs/react/), and [GraphQl Code Generator](https://www.the-guild.dev/graphql/codegen/docs/getting-started) that can perform all CRUD operations + +## Running the example application +Refer to the [README](/README.md) at the root of the `react-examples` repository for details on running example applications in depth. If you have run an example application before, make sure you have the docker instance for the example applications running, then follow the quick-start steps: + +brightspot (`http://localhost/cms`): + +``` +cd brightspot +yarn +npx brightspot types download +npx brightspot types upload src + +``` + +frontend: + +``` +cd app +yarn +yarn codegen +yarn dev +``` + +The frontend application will open automatically in the browser. + +## Using the example application +You can start with the frontend application. Click on the form at the top of the dashboard to create a new Note. Make sure to enter the Username that you are using in Brightspot (the name you logged in with). After creating your Note, you should see it appear in the dashboard. Confirm in Brightspot that the Note is also listed on the dashboard. You should see your Username avatar next to the Note. + +## How everything works +Brightspot makes it possible to create content that you can then query for using the GraphQL API. One GraphQL API Brightspot offers is the Content Management API (CMA). Refer to the Brightspot documentation for more information. + +Navigate to `brightspot/src/examples/content_management`. This directory contains the JS Classes files that are uploaded to Brightspot. + +#### JS Classes Files: +- `NotesEndpoint.ts`: the class that create a custom CMA Endpoint with the following configurations: + - `getEntryFields`: specify the class(es) to determine the schema for the custom endpoint +- `NotesEndpointClient.ts`: the class that creates an API Client. The API Client has a client ID and API Key that are stored in the `app/.env` file to access the CMA Endpoint + +> **_Note_** A CMA Endpoint requires an API Client with an client ID and API Key. Normally, this is created by an admin editorially in Brightspot. For convenience, an API Client has been generated programatically. + +#### Queries: +All queries are located in `app/queries`: + +- `GetNotes.graphql`: either query for notes ("\* matches ?") given certain arguments or pass in other filter options (ex: "not \_id matches ?") with arguments. +- `CreateAndUpdateNote.graphql`: either create a new note (if no id is provided) or update an existing one +- `DeleteNote.graphql`: delete a note by id - add `permanently: true` to delete without archiving. + +[GraphQl Code Generator](https://www.the-guild.dev/graphql/codegen/docs/getting-started) is used to generate code from the GraphQl schema based on the content uploaded to Brightspot. This helps makes development faster and more consistent since queries, mutations, hooks etc are already typed. If you update your GraphQL query files, be sure to run `yarn codegen` to update your generated files (found in the `app/generated` directory). + +#### API Routes +All GraphQL queries are made using [API Routes](https://nextjs.org/docs/api-routes/introduction) provided by Next.js. Navigate to `pages/api/notes` to see how the GraphQL query requests are made. By using the API Routes, the client Id and API key are kept from being a part of the front-end bundle for increased security. + +> **_Note_** This application is for demonstration purposes only. In a production-level application, you would want to implement authentication. Although this application requires a username for creating new content and updating content in the frontend, further authentication would be needed. + +## Try it yourself +The following are suggestions for further exploration: + +1. Change one field in a note in the frontend (example, change the title of a note). In Brightspot, change the description of that same note. Refresh both the frontend and Brightspot. What do those fields display? + +2. Remove `permanently: true` from the `DeleteNote.graphql` query (be sure to run `yarn codegen`!). Now try deleting that note, then navigate to Brightspot dashboard. Select `Note` for Content Type, then Status: Archived. Do you see the note you deleted? +## Troubleshooting + +1. I am getting getting a 401 network error on initial page load in the browser for the frontend... + + - Verify that the Notes Endpoint API Client Client ID and API Key (found in Brightspot: Menu Button -> `Admin` -> `APIs` -> `Clients`) are the same values that are in the `app/.env` file. + +2. I get an error when trying to create or update a Note: "Variable 'toolUser' has an invalid value".... + - Verify the the Username you entered in the New Note Form or note Card is a Username in Brightspot. + +Having other issues running the example application? Refer to the [Common Issues](/README.md) section in the respository README for assistance. diff --git a/content-management/app/.babelrc b/content-management/app/.babelrc new file mode 100644 index 00000000..9fcef039 --- /dev/null +++ b/content-management/app/.babelrc @@ -0,0 +1,4 @@ +{ + "presets": ["next/babel"], + "plugins": [] +} diff --git a/content-management/app/.env b/content-management/app/.env new file mode 100644 index 00000000..88bb3850 --- /dev/null +++ b/content-management/app/.env @@ -0,0 +1,4 @@ +NEXT_PUBLIC_HOST=http://localhost:3000 +NEXT_PUBLIC_GRAPHQL_URL=http://localhost/graphql/management/notes +GRAPHQL_CLIENT_ID=d47b5d36c97d3eaf8858c471c124b2e8 +GRAPHQL_CLIENT_SECRET=90a52c3d-96d9-391a-878d-fe4ef4fe431e \ No newline at end of file diff --git a/content-management/app/.eslintrc.json b/content-management/app/.eslintrc.json new file mode 100755 index 00000000..3169f851 --- /dev/null +++ b/content-management/app/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["next/babel", "next/core-web-vitals", "prettier"] +} diff --git a/content-management/app/.prettierignore b/content-management/app/.prettierignore new file mode 100644 index 00000000..7e792df1 --- /dev/null +++ b/content-management/app/.prettierignore @@ -0,0 +1,5 @@ +node_modules +generated +styles +.next +public \ No newline at end of file diff --git a/content-management/app/codegen.yml b/content-management/app/codegen.yml new file mode 100644 index 00000000..91f772e1 --- /dev/null +++ b/content-management/app/codegen.yml @@ -0,0 +1,13 @@ +overwrite: true +schema: + - ${NEXT_PUBLIC_GRAPHQL_URL}: + headers: + X-Client-ID: ${GRAPHQL_CLIENT_ID} + X-Client-Secret: ${GRAPHQL_CLIENT_SECRET} +documents: 'queries/*.graphql' +generates: + generated/graphql.tsx: + plugins: + - 'typescript' + - 'typescript-operations' + - 'typescript-react-apollo' diff --git a/content-management/app/components/Container/Container.module.css b/content-management/app/components/Container/Container.module.css new file mode 100644 index 00000000..ec679f2f --- /dev/null +++ b/content-management/app/components/Container/Container.module.css @@ -0,0 +1,5 @@ +.section { + max-width: 1240px; + padding: 7rem 1rem 0 1rem; + margin: 0 auto; +} diff --git a/content-management/app/components/Container/index.tsx b/content-management/app/components/Container/index.tsx new file mode 100644 index 00000000..76d18ce5 --- /dev/null +++ b/content-management/app/components/Container/index.tsx @@ -0,0 +1,32 @@ +import styles from './Container.module.css' +import { Dispatch, SetStateAction } from 'react' + +import { Brightspot_Example_Content_Management_Note } from 'generated/graphql' +import CreateNoteForm from '../CreateNoteForm' +import Notes from '../Notes' + +type Props = { + items: Brightspot_Example_Content_Management_Note[] + getItems: ( + pageNumber: number, + predicate: boolean, + queryItem?: string, + newItem?: Brightspot_Example_Content_Management_Note + ) => void + pageNumber: number + setPageNumber: Dispatch> +} + +const Container = ({ items, getItems, pageNumber, setPageNumber }: Props) => ( +
+ + +
+) + +export default Container diff --git a/content-management/app/components/CreateNoteForm/CreateNoteForm.module.css b/content-management/app/components/CreateNoteForm/CreateNoteForm.module.css new file mode 100644 index 00000000..ccba3b50 --- /dev/null +++ b/content-management/app/components/CreateNoteForm/CreateNoteForm.module.css @@ -0,0 +1,83 @@ +.form { + max-width: 540px; + background: #fff; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + color: var(--primaryTextColor); + border-radius: 10px; + box-shadow: var(--lightShadow); + margin: 0.7rem auto; +} + +.collapsed { + display: none; +} + +.notCollapsed { + display: unset; +} + +.input { + width: 100%; + font-size: 1rem; + border: 0; + border-radius: 3px; + min-height: 1.7rem; + padding: 5px; + margin-bottom: 7px; + font-family: 'Noto Sans', sans-serif; +} + +.button { + font-size: 1rem; + padding: 7px; + margin-right: 10px; + color: var(--moduleBgColor); + font-weight: 700; + border: 0; + background-color: transparent; + border-radius: var(--mainBorderRadius); +} + +.button:hover { + cursor: pointer; + background-color: var(--grayscaleFour); +} + +.bottom { + width: 100%; + min-height: 30px; + text-align: end; + margin-bottom: 4px; +} + +.error { + font-size: 0.9rem; + color: var(--primaryRed); + font-style: italic; +} + +.errorCloseBtn { + border: 0; + background: transparent; +} + +.errorCloseBtn:hover { + cursor: pointer; +} + +.errorCloseIcon { + margin-right: 4px; + height: 15px; + width: 15px; + color: var(--primaryRed); +} + +.wrapper { + margin: 5px 0; + + width: 100%; + padding: 5px 10px; +} diff --git a/content-management/app/components/CreateNoteForm/index.tsx b/content-management/app/components/CreateNoteForm/index.tsx new file mode 100644 index 00000000..3e4e97e8 --- /dev/null +++ b/content-management/app/components/CreateNoteForm/index.tsx @@ -0,0 +1,195 @@ +import styles from './CreateNoteForm.module.css' +import { useState, useRef, useEffect } from 'react' + +import { IoClose } from 'react-icons/io5' + +import { + Brightspot_Example_Content_Management_Note, + Mutation, + CreateAndUpdateNoteMutationVariables, +} from 'generated/graphql' +import { assertIsNode } from 'helpers/utils' + +type Props = { + getItems: ( + pageNumber: number, + predicate: boolean, + queryItem?: string, + newItem?: Brightspot_Example_Content_Management_Note + ) => void + pageNumber: number +} + +const CreateNoteForm = ({ getItems, pageNumber }: Props) => { + const titleRef = useRef(null) + const descriptionRef = useRef(null) + const usernameRef = useRef(null) + const formRef = useRef(null) + const [error, setError] = useState(null) + const [expanded, setExpanded] = useState(false) + const url = `${process.env.NEXT_PUBLIC_HOST}/api/notes/new` + const params = (data: CreateAndUpdateNoteMutationVariables) => { + return { + body: JSON.stringify(data), + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + } + } + + useEffect(() => { + const closeOnMouseClickOutside = ({ target }: MouseEvent) => { + assertIsNode(target) + if (expanded && formRef && !formRef?.current?.contains(target)) { + setExpanded(false) + } + } + + document.body.addEventListener('mousedown', closeOnMouseClickOutside) + return () => { + document.body.removeEventListener('mousedown', closeOnMouseClickOutside) + } + }, [expanded]) + + const processResponse = (res: Mutation) => { + if (res.brightspot_example_content_management_NoteSave) { + const newItem: Brightspot_Example_Content_Management_Note = + res.brightspot_example_content_management_NoteSave + if (newItem) { + getItems(pageNumber, false, '', newItem) + } + + if (titleRef?.current?.value) { + titleRef.current.value = '' + } + if (descriptionRef?.current?.value) { + descriptionRef.current.value = '' + } + if (usernameRef?.current?.value) { + usernameRef.current.value = '' + } + setExpanded(false) + } else if (res) { + setError(res as unknown as string) + } + } + + const submitNewNote = () => { + setError(null) + const inputTitle = titleRef?.current?.value + const inputDescription = descriptionRef?.current?.value + const inputUsername = usernameRef?.current?.value + const dataToSubmit = { + title: inputTitle, + description: inputDescription, + toolUser: inputUsername, + } + + fetch(url, params(dataToSubmit)) + .then((res) => res.json()) + .then((res) => processResponse(res)) + .catch((err: Error) => { + if (!error) { + setError(err.message) + } + }) + } + + return ( +
{ + e.preventDefault() + submitNewNote() + }} + > +
setExpanded(true)} + tabIndex={0} + onKeyDown={(e) => { + if (e.key === 'Enter' && !expanded) { + setExpanded(true) + } else if (e.key === 'Escape' && expanded) { + setExpanded(false) + } + }} + > + +
+