diff --git a/examples/with-fauna/README.md b/examples/with-fauna/README.md index 0bb3a8b157e44..651367bfe6660 100644 --- a/examples/with-fauna/README.md +++ b/examples/with-fauna/README.md @@ -1,6 +1,6 @@ -# Fauna GraphQL Guestbook Starter +# Fauna Guestbook Starter -This Guestbook Single-Page Application (SPA) example shows you how to use [Fauna's GraphQL endpoint](https://docs.fauna.com/fauna/current/api/graphql/) in your Next.js project. +This Guestbook Application example shows you how to use [Fauna](https://docs.fauna.com/) in your Next.js project. ## Deploy your own @@ -8,10 +8,6 @@ Deploy the example using [Vercel](https://vercel.com?utm_source=github&utm_mediu [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https://github.com/vercel/next.js/tree/canary/examples/with-fauna&project-name=fauna-nextjs-guestbook&repository-name=fauna-nextjs-guestbook&demo-title=Next.js%20Fauna%20Guestbook%20App&demo-description=A%20simple%20guestbook%20application%20built%20with%20Next.js%20and%20Fauna&integration-ids=oac_Erlbqm8Teb1y4WhioE3r2utY) -## Why Fauna - -By importing a `.gql` or `.graphql` schema into Fauna ([see our sample schema file](./schema.gql)), Fauna will generate required Indexes and GraphQL resolvers for you -- hands free 👐 ([some limitations exist](https://docs.fauna.com/fauna/current/api/graphql/#limitations)). - ## How to use Execute [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app) with [npm](https://docs.npmjs.com/cli/init), [Yarn](https://yarnpkg.com/lang/en/docs/cli/create/), or [pnpm](https://pnpm.io) to bootstrap the example: @@ -26,17 +22,12 @@ pnpm create next-app --example with-fauna with-fauna-app You can start with this template [using `create-next-app`](#using-create-next-app) or by [downloading the repository manually](#download-manually). -To use a live Fauna database, create a database at [dashboard.fauna.com](https://dashboard.fauna.com/) and generate an admin token by going to the **Security** tab on the left and then click **New Key**. Give the new key a name and select the 'Admin' Role. Copy the token since the setup script will ask for it. Do not use it in the frontend, it has superpowers which you don't want to give to your users. - -### Setting Up Your Schema - -The Next.js and Fauna example includes a setup script (`npm run setup`). After providing your admin token, the script will: +### Setting Up Your Fauna Database -- **Import your GraphQL schema:** Fauna automatically sets up collections and indexes to support your queries. You can view these in your [project dashboard](https://dashboard.fauna.com/) under **GraphQL**. -- **Create an index and function:** The script will create a GraphQL resolver that uses [User-defined functions](https://docs.fauna.com/fauna/current/api/graphql/functions?lang=javascript) based on a sorting index. -- **Create a scoped token:** This token is for use on the client side. The admin key can be used on the server side. +Head over to [Fauna Dashboard](https://dashboard.fauna.com/) and create a new database. You can name it whatever you want, but for this example, we'll use `nextjs-guestbook`. Next, create a new collection called `Entry` in your new database. +Finally create a new database access key to connect to your database. -After the script completes, a `.env.local` [file](https://nextjs.org/docs/basic-features/environment-variables) will be created for you with the newly generated client token assigned to an Environment Variable. +Watch [this video](https://www.youtube.com/watch?v=8YJcG2fUPyE&t=43s&ab_channel=FaunaInc.) to learn how to connect to your database. ### Run locally diff --git a/examples/with-fauna/actions/entry.ts b/examples/with-fauna/actions/entry.ts new file mode 100644 index 0000000000000..3f69f7284de0b --- /dev/null +++ b/examples/with-fauna/actions/entry.ts @@ -0,0 +1,22 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { createEntry } from '@/lib/fauna' + +export async function createEntryAction(prevState: any, formData: FormData) { + const name = formData.get('name') as string + const message = formData.get('message') as string + try { + await createEntry(name, message) + revalidatePath('/') + return { + successMessage: 'Thank you for signing the guest book', + errorMessage: null, + } + } catch (error) { + return { + successMessage: null, + errorMessage: 'Something went wrong. Please try again', + } + } +} diff --git a/examples/with-fauna/app/globals.css b/examples/with-fauna/app/globals.css new file mode 100644 index 0000000000000..b5c61c956711f --- /dev/null +++ b/examples/with-fauna/app/globals.css @@ -0,0 +1,3 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; diff --git a/examples/with-fauna/app/guestbook-page.tsx b/examples/with-fauna/app/guestbook-page.tsx new file mode 100644 index 0000000000000..553a61489a495 --- /dev/null +++ b/examples/with-fauna/app/guestbook-page.tsx @@ -0,0 +1,52 @@ +import cn from 'classnames' +import formatDate from 'date-fns/format' +import EntryForm from '@/components/EntryForm' +import { EntryType } from './page' + +const EntryItem = ({ entry }: { entry: EntryType }) => ( +
+
{entry.message}
+
+

{entry.name}

+ / +

+ {formatDate( + new Date(entry.createdAt.isoString), + "d MMM yyyy 'at' h:mm bb" + )} +

+
+
+) + +export default async function GuestbookPage({ + entries, +}: { + entries: EntryType[] +}) { + return ( +
+
+
+ Sign the Guestbook +
+

+ Share a message for a future visitor. +

+ +
+ +
+ {entries?.map((entry) => ( + + ))} +
+
+ ) +} diff --git a/examples/with-fauna/app/layout.tsx b/examples/with-fauna/app/layout.tsx new file mode 100644 index 0000000000000..afbaec7170327 --- /dev/null +++ b/examples/with-fauna/app/layout.tsx @@ -0,0 +1,21 @@ +import './globals.css' + +export const metadata: { + title: string + description: string +} = { + title: 'Next.js + Fauna example', + description: 'Generated by Next.js', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + {children} + + ) +} diff --git a/examples/with-fauna/app/page.tsx b/examples/with-fauna/app/page.tsx new file mode 100644 index 0000000000000..8d7292c155987 --- /dev/null +++ b/examples/with-fauna/app/page.tsx @@ -0,0 +1,16 @@ +import { getAllEntries } from '@/lib/fauna' +import GuestbookPage from './guestbook-page' + +export type EntryType = { + id: string + name: string + message: string + createdAt: { + isoString: string + } +} + +export default async function Page() { + const entries = (await getAllEntries()) as EntryType[] + return +} diff --git a/examples/with-fauna/components/EntryForm.tsx b/examples/with-fauna/components/EntryForm.tsx new file mode 100644 index 0000000000000..71d8675905967 --- /dev/null +++ b/examples/with-fauna/components/EntryForm.tsx @@ -0,0 +1,65 @@ +'use client' + +import cn from 'classnames' +import { createEntryAction } from '@/actions/entry' +// @ts-ignore +import { experimental_useFormState as useFormState } from 'react-dom' +import { experimental_useFormStatus as useFormStatus } from 'react-dom' +import LoadingSpinner from '@/components/LoadingSpinner' +import SuccessMessage from '@/components/SuccessMessage' +import ErrorMessage from '@/components/ErrorMessage' + +const inputClasses = cn( + 'block py-2 bg-white dark:bg-gray-800', + 'rounded-md border-gray-300 focus:ring-blue-500', + 'focus:border-blue-500 text-gray-900 dark:text-gray-100' +) + +const initialState = { + successMessage: null, + errorMessage: null, +} + +export default function EntryForm() { + const [state, formAction] = useFormState(createEntryAction, initialState) + const { pending } = useFormStatus() + + return ( + <> +
+ + + +
+ {state?.successMessage ? ( + {state.successMessage} + ) : null} + {state?.errorMessage ? ( + {state.errorMessage} + ) : null} + + ) +} diff --git a/examples/with-fauna/components/ErrorMessage.js b/examples/with-fauna/components/ErrorMessage.tsx similarity index 85% rename from examples/with-fauna/components/ErrorMessage.js rename to examples/with-fauna/components/ErrorMessage.tsx index 4367acf1e0853..e782d4cf28833 100644 --- a/examples/with-fauna/components/ErrorMessage.js +++ b/examples/with-fauna/components/ErrorMessage.tsx @@ -1,4 +1,8 @@ -export default function ErrorMessage({ children }) { +export default function ErrorMessage({ + children, +}: { + children: React.ReactNode +}) { return (

{ - return process.env.FAUNA_DB_DOMAIN ?? 'db.fauna.com' -} - -module.exports = { - resolveDbDomain, -} diff --git a/examples/with-fauna/lib/fauna.js b/examples/with-fauna/lib/fauna.js deleted file mode 100644 index d8e184b09f20c..0000000000000 --- a/examples/with-fauna/lib/fauna.js +++ /dev/null @@ -1,49 +0,0 @@ -import { GraphQLClient, gql } from 'graphql-request' -import { resolveDbDomain } from './constants' - -const CLIENT_SECRET = - process.env.FAUNA_ADMIN_KEY || process.env.FAUNA_CLIENT_SECRET -const FAUNA_GRAPHQL_DOMAIN = resolveDbDomain().replace('db', 'graphql') -const FAUNA_GRAPHQL_BASE_URL = `https://${FAUNA_GRAPHQL_DOMAIN}/graphql` - -const graphQLClient = new GraphQLClient(FAUNA_GRAPHQL_BASE_URL, { - headers: { - authorization: `Bearer ${CLIENT_SECRET}`, - }, -}) - -export const listGuestbookEntries = () => { - const query = gql` - query Entries($size: Int) { - entries(_size: $size) { - data { - _id - _ts - name - message - createdAt - } - } - } - ` - - return graphQLClient - .request(query, { size: 999 }) - .then(({ entries: { data } }) => data) -} - -export const createGuestbookEntry = (newEntry) => { - const mutation = gql` - mutation CreateGuestbookEntry($input: GuestbookEntryInput!) { - createGuestbookEntry(data: $input) { - _id - _ts - name - message - createdAt - } - } - ` - - return graphQLClient.request(mutation, { input: newEntry }) -} diff --git a/examples/with-fauna/lib/fauna.ts b/examples/with-fauna/lib/fauna.ts new file mode 100644 index 0000000000000..ca3dd3f74b29b --- /dev/null +++ b/examples/with-fauna/lib/fauna.ts @@ -0,0 +1,37 @@ +import 'server-only' +import { + Client, + fql, + QuerySuccess, + QueryValueObject, + QueryFailure, +} from 'fauna' + +const client = new Client({ + secret: process.env.FAUNA_CLIENT_SECRET, +}) + +export const getAllEntries = async () => { + try { + const dbresponse: QuerySuccess = await client.query(fql` + Entry.all() + `) + return dbresponse.data.data + } catch (error: QueryFailure | any) { + throw new Error(error.message) + } +} + +export const createEntry = async (name: string, message: string) => { + try { + const dbresponse = await client.query(fql` + Entry.create({ + name: ${name}, + message: ${message}, + createdAt: Time.now(), + })`) + return dbresponse.data + } catch (error: QueryFailure | any) { + throw new Error(error.message) + } +} diff --git a/examples/with-fauna/next.config.js b/examples/with-fauna/next.config.js new file mode 100644 index 0000000000000..59e9d7140c9d0 --- /dev/null +++ b/examples/with-fauna/next.config.js @@ -0,0 +1,7 @@ +const nextConfig = { + experimental: { + serverActions: true, + }, +} + +module.exports = nextConfig diff --git a/examples/with-fauna/package.json b/examples/with-fauna/package.json index 6d44bdf0fbee4..8757bb2444975 100644 --- a/examples/with-fauna/package.json +++ b/examples/with-fauna/package.json @@ -9,19 +9,17 @@ "dependencies": { "classnames": "2.3.1", "date-fns": "2.28.0", - "faunadb": "4.5.4", - "graphql": "16.8.1", - "graphql-request": "4.3.0", + "fauna": "^1.2.0", "next": "latest", - "react": "18.1.0", - "react-dom": "18.1.0", - "swr": "^2.0.0" + "react": "^18.2.0", + "react-dom": "^18.2.0", + "server-only": "^0.0.1" }, "devDependencies": { - "autoprefixer": "^10.4.7", - "postcss": "^8.4.14", + "autoprefixer": "^10.4.16", + "postcss": "^8.4.31", + "request": "^2.88.2", "stream-to-promise": "3.0.0", - "tailwindcss": "^3.1.2", - "request": "^2.88.2" + "tailwindcss": "^3.3.3" } } diff --git a/examples/with-fauna/pages/api/entries/index.js b/examples/with-fauna/pages/api/entries/index.js deleted file mode 100644 index e62f7a3b0c930..0000000000000 --- a/examples/with-fauna/pages/api/entries/index.js +++ /dev/null @@ -1,30 +0,0 @@ -import { listGuestbookEntries, createGuestbookEntry } from '@/lib/fauna' - -export default async function handler(req, res) { - const handlers = { - GET: async () => { - const entries = await listGuestbookEntries() - - res.json(entries) - }, - - POST: async () => { - const { - body: { name, message }, - } = req - const created = await createGuestbookEntry({ - name, - message, - createdAt: new Date(), - }) - - res.json(created) - }, - } - - if (!handlers[req.method]) { - return res.status(405).end() - } - - await handlers[req.method]() -} diff --git a/examples/with-fauna/pages/index.js b/examples/with-fauna/pages/index.js deleted file mode 100644 index a14e450fe47ea..0000000000000 --- a/examples/with-fauna/pages/index.js +++ /dev/null @@ -1,181 +0,0 @@ -import Head from 'next/head' -import { useState } from 'react' -import cn from 'classnames' -import formatDate from 'date-fns/format' -import useSWR, { mutate, SWRConfig } from 'swr' -import 'tailwindcss/tailwind.css' -import { listGuestbookEntries } from '@/lib/fauna' -import SuccessMessage from '@/components/SuccessMessage' -import ErrorMessage from '@/components/ErrorMessage' -import LoadingSpinner from '@/components/LoadingSpinner' - -const fetcher = (url) => fetch(url).then((res) => res.json()) - -const putEntry = (payload) => - fetch('/api/entries', { - method: 'POST', - body: JSON.stringify(payload), - headers: { - 'Content-Type': 'application/json', - }, - }).then((res) => (res.ok ? res.json() : Promise.reject(res))) - -const useEntriesFlow = ({ fallback }) => { - const { data: entries } = useSWR('/api/entries', fetcher, { - fallbackData: fallback.entries, - }) - const onSubmit = async (payload) => { - await putEntry(payload) - await mutate('/api/entries') - } - - return { - entries, - onSubmit, - } -} - -const AppHead = () => ( - - - - - -) - -const EntryItem = ({ entry }) => ( -

-
{entry.message}
-
-

{entry.name}

- / -

- {formatDate(new Date(entry.createdAt), "d MMM yyyy 'at' h:mm bb")} -

-
-
-) - -const EntryForm = ({ onSubmit: onSubmitProp }) => { - const initial = { - name: '', - message: '', - } - const [values, setValues] = useState(initial) - const [formState, setFormState] = useState('initial') - const isSubmitting = formState === 'submitting' - - const onSubmit = (ev) => { - ev.preventDefault() - - setFormState('submitting') - onSubmitProp(values) - .then(() => { - setValues(initial) - setFormState('submitted') - }) - .catch(() => { - setFormState('failed') - }) - } - - const makeOnChange = - (fieldName) => - ({ target: { value } }) => - setValues({ - ...values, - [fieldName]: value, - }) - - const inputClasses = cn( - 'block py-2 bg-white dark:bg-gray-800', - 'rounded-md border-gray-300 focus:ring-blue-500', - 'focus:border-blue-500 text-gray-900 dark:text-gray-100' - ) - - return ( - <> -
- - - -
- {{ - failed: () => Something went wrong. :(, - - submitted: () => ( - Thanks for signing the guestbook. - ), - }[formState]?.()} - - ) -} - -const Guestbook = ({ fallback }) => { - const { entries, onSubmit } = useEntriesFlow({ fallback }) - return ( - -
- -
-
- Sign the Guestbook -
-

- Share a message for a future visitor. -

- -
-
- {entries?.map((entry) => ( - - ))} -
-
-
- ) -} - -export async function getStaticProps() { - const entries = await listGuestbookEntries() - return { - props: { - fallback: { - entries, - }, - }, - } -} - -export default Guestbook diff --git a/examples/with-fauna/schema.gql b/examples/with-fauna/schema.gql deleted file mode 100644 index f3a0764e7d881..0000000000000 --- a/examples/with-fauna/schema.gql +++ /dev/null @@ -1,14 +0,0 @@ -type GuestbookEntry { - name: String! - message: String! - createdAt: Time! - # _id: Generated by Fauna as each document's unique identifier - # _ts: Timestamp generated by Fauna upon object updating -} - -# A query named 'entries' which returns an array of GuestbookEntry objects -# Implicit arguments: _size (count) and _cursor (location within the Index) -type Query { - entries: [GuestbookEntry!] - @resolver(name: "listLatestEntries", paginated: true) -} diff --git a/examples/with-fauna/scripts/setup.js b/examples/with-fauna/scripts/setup.js deleted file mode 100644 index d83d334a21ce0..0000000000000 --- a/examples/with-fauna/scripts/setup.js +++ /dev/null @@ -1,193 +0,0 @@ -// This script sets up the database to be used for this example application. -// Look at the code to see what is behind the magic -const fs = require('fs') -const readline = require('readline') -const request = require('request') -const { Client, query: Q } = require('faunadb') -const streamToPromise = require('stream-to-promise') -const { resolveDbDomain } = require('../lib/constants') - -const MakeLatestEntriesIndex = () => - Q.CreateIndex({ - name: 'latestEntries', - source: Q.Collection('GuestbookEntry'), - values: [ - { - field: ['data', 'createdAt'], - reverse: true, - }, - { - field: 'ref', - }, - ], - }) - -const MakeListLatestEntriesUdf = () => - Q.Update(Q.Function('listLatestEntries'), { - // https://docs.fauna.com/fauna/current/api/graphql/functions?lang=javascript#paginated - body: Q.Query( - Q.Lambda( - ['size', 'after', 'before'], - Q.Let( - { - match: Q.Match(Q.Index('latestEntries')), - page: Q.If( - Q.Equals(Q.Var('before'), null), - Q.If( - Q.Equals(Q.Var('after'), null), - Q.Paginate(Q.Var('match'), { - size: Q.Var('size'), - }), - Q.Paginate(Q.Var('match'), { - size: Q.Var('size'), - after: Q.Var('after'), - }) - ), - Q.Paginate(Q.Var('match'), { - size: Q.Var('size'), - before: Q.Var('before'), - }) - ), - }, - Q.Map(Q.Var('page'), Q.Lambda(['_', 'ref'], Q.Get(Q.Var('ref')))) - ) - ) - ), - }) - -const MakeGuestbookRole = () => - Q.CreateRole({ - name: 'GuestbookRole', - privileges: [ - { - resource: Q.Collection('GuestbookEntry'), - actions: { - read: true, - write: true, - create: true, - }, - }, - { - resource: Q.Index('latestEntries'), - actions: { - read: true, - }, - }, - { - resource: Q.Function('listLatestEntries'), - actions: { - call: true, - }, - }, - ], - }) - -const MakeGuestbookKey = () => - Q.CreateKey({ - role: Q.Role('GuestbookRole'), - }) - -const isDatabasePrepared = ({ client }) => - client.query(Q.Exists(Q.Index('latestEntries'))) - -const resolveAdminKey = () => { - if (process.env.FAUNA_ADMIN_KEY) { - return Promise.resolve(process.env.FAUNA_ADMIN_KEY) - } - - const rl = readline.createInterface({ - input: process.stdin, - output: process.stdout, - }) - - return new Promise((resolve, reject) => { - rl.question('Please provide the Fauna admin key:\n', (res) => { - rl.close() - - if (!res) { - return reject( - new Error('You need to provide a key, closing. Try again') - ) - } - - resolve(res) - }) - }) -} - -const importSchema = (adminKey) => { - let domain = resolveDbDomain().replace('db', 'graphql') - return streamToPromise( - fs.createReadStream('./schema.gql').pipe( - request.post({ - model: 'merge', - uri: `https://${domain}/import`, - headers: { - Authorization: `Bearer ${adminKey}`, - }, - }) - ) - ).then(String) -} - -const findImportError = (msg) => { - switch (true) { - case msg.startsWith('Invalid database secret'): - return 'The secret you have provided is not valid, closing. Try again' - case !msg.includes('success'): - return msg - default: - return null - } -} - -const main = async () => { - const adminKey = await resolveAdminKey() - - const client = new Client({ - secret: adminKey, - domain: resolveDbDomain(), - }) - - if (await isDatabasePrepared({ client })) { - return console.info( - 'Fauna resources have already been prepared. ' + - 'If you want to install it once again, please, create a fresh database and re-run the script with the other key' - ) - } - - const importMsg = await importSchema(adminKey) - const importErrorMsg = findImportError(importMsg) - - if (importErrorMsg) { - return Promise.reject(new Error(importErrorMsg)) - } - - console.log('- Successfully imported schema') - - for (const Make of [ - MakeLatestEntriesIndex, - MakeListLatestEntriesUdf, - MakeGuestbookRole, - ]) { - await client.query(Make()) - } - - console.log('- Created Fauna resources') - - if (process.env.FAUNA_ADMIN_KEY) { - // Assume it's a Vercel environment, no need for .env.local file - return - } - - const { secret } = await client.query(MakeGuestbookKey()) - - await fs.promises.writeFile('.env.local', `FAUNA_CLIENT_SECRET=${secret}\n`) - - console.log('- Created .env.local file with secret') -} - -main().catch((err) => { - console.error(err) - process.exit(1) -}) diff --git a/examples/with-fauna/tailwind.config.js b/examples/with-fauna/tailwind.config.js index 6aa9dd3191216..0b4c17d12670c 100644 --- a/examples/with-fauna/tailwind.config.js +++ b/examples/with-fauna/tailwind.config.js @@ -1,8 +1,9 @@ /** @type {import('tailwindcss').Config} */ module.exports = { content: [ - './pages/**/*.{js,ts,jsx,tsx}', - './components/**/*.{js,ts,jsx,tsx}', + './app/**/*.{js,ts,jsx,tsx,mdx}', + './pages/**/*.{js,ts,jsx,tsx,mdx}', + './components/**/*.{js,ts,jsx,tsx,mdx}', ], theme: { extend: {}, diff --git a/examples/with-fauna/tsconfig.json b/examples/with-fauna/tsconfig.json new file mode 100644 index 0000000000000..e06a4454ab062 --- /dev/null +++ b/examples/with-fauna/tsconfig.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "target": "es5", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +}