From ac7f348b4971bb362a7c1c337b9efc92d9b6ef24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Sat, 2 Jul 2022 11:28:42 +0200 Subject: [PATCH 1/4] Add Remix integration tutorial --- docs/NextJs.md | 10 +- docs/Remix.md | 249 +++++++++++++++++++++++++++++++++++ docs/img/remix-structure.png | Bin 0 -> 24047 bytes 3 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 docs/Remix.md create mode 100644 docs/img/remix-structure.png diff --git a/docs/NextJs.md b/docs/NextJs.md index 46c4ed6929f..2e323850b22 100644 --- a/docs/NextJs.md +++ b/docs/NextJs.md @@ -69,7 +69,7 @@ const Home: NextPage = () => { export default Home; ``` -**Tip**: Why the dynamic import? React-admin is designed as a Single-Page Application, rendered on the client side. It comes with its own [routing sytem](./Routing.md), which conflicts with the Next.js routing system. So we must prevent Next.js from rendering the react-admin component on the server-side. Using `dynamic` allows to disable Server-Side Rendering for the `` component. +**Tip**: Why the dynamic import? React-admin is designed as a Single-Page Application, rendered on the client-side. It comes with its own [routing sytem](./Routing.md), which conflicts with the Next.js routing system. So we must prevent Next.js from rendering the react-admin component on the server-side. Using `dynamic` allows disabling Server-Side Rendering for the `` component. Now, start the server with `yarn dev`, browse to `http://localhost:3000/`, and you should see the working admin: @@ -99,11 +99,11 @@ Now the admin renders at `http://localhost:3000/admin`, and you can use the Next ## Adding an API -[Next.js allows to serve an API](https://nextjs.org/docs/api-routes/introduction) from the same server. You *could* use this to build a CRUD API by hand. However, we consider that building a CRUD API on top of a relational database is a solved problem, and that developers shouldn't spend time reimplemeting it. +[Next.js allows to serve an API](https://nextjs.org/docs/api-routes/introduction) from the same server. You *could* use this to build a CRUD API by hand. However, we consider that building a CRUD API on top of a relational database is a solved problem and that developers shouldn't spend time reimplementing it. For instance, if you store your data in a [PostgreSQL](https://www.postgresql.org/) database, you can use [PostgREST](https://postgrest.org/en/stable/) to expose the data as a REST API with zero configuration. Even better, you can use a Software-as-a-Service like [Supabase](https://supabase.com/) to do that for you. -In such cases, the Next.js API can only serve as a Proxy to authenticate client queries, and pass them down to Supabase. +In such cases, the Next.js API can only serve as a Proxy to authenticate client queries and pass them down to Supabase. Let's see an example in practice. @@ -122,9 +122,9 @@ SUPABASE_URL="https://MY_INSTANCE.supabase.co" SUPABASE_SERVICE_ROLE="MY_SERVICE_ROLE_KEY" ``` -**Tip**: This example uses the **service role key** here and not the anonymous role. This allows write operations without dealing with authorization. **You shouldn't do this in production**, but use the [Supabase authorization](https://supabase.com/docs/guides/auth) feature instead. +**Tip**: This example uses the **service role key** here and not the anonymous role. This allows mutations without dealing with authorization. **You shouldn't do this in production**, but use the [Supabase authorization](https://supabase.com/docs/guides/auth) feature instead. -Create [a "catch all" API route](https://nextjs.org/docs/api-routes/dynamic-api-routes#optional-catch-all-api-routes) in the Next.js app by adding a `pages/api/admin/[[...slug]].ts` file. This API route redirects all calls from the react-admin app to the Supabase CRUD API: +Create [a "catch-all" API route](https://nextjs.org/docs/api-routes/dynamic-api-routes#optional-catch-all-api-routes) in the Next.js app by adding a `pages/api/admin/[[...slug]].ts` file. This API route redirects all calls from the react-admin app to the Supabase CRUD API: ```jsx // in pages/api/admin/[[...slug]].ts diff --git a/docs/Remix.md b/docs/Remix.md new file mode 100644 index 00000000000..76257602563 --- /dev/null +++ b/docs/Remix.md @@ -0,0 +1,249 @@ +--- +layout: default +title: "Remix Integration" +--- + +# Remix Integration + +React-admin runs seamlessly on [Remix](https://remix.run/). + +## Setting Up Remix + +Let's start by creating a new Remix project. Run the following command: + +```sh +npx create-remix@latest +``` + +This script will ask you for more details about your project. You can use the following options: + +- The name you want to give to your project, e.g. `remix-supabase-react-admin` +- "Just the basics" +- "Remix App Server" +- "TypeScript" +- "Don't run npm install" + +The project structure should look something like this: + +![Remix project structure](./img/remix-structure.png) + +## Setting Up React-Admin + +Add the `react-admin` npm package, as well as a data provider package. In this example, we'll use `ra-data-json-server` to connect to a test API provided by [JSONPlaceholder](https://jsonplaceholder.typicode.com). + +```sh +cd remix-supabase-react-admin +yarn add react-admin ra-data-json-server +``` + +Next, create the admin app component in `app/components/App.tsx`: + +```jsx +// in app/components/App.tsx +import { Admin, Resource, ListGuesser } from "react-admin"; +import jsonServerProvider from "ra-data-json-server"; + +const dataProvider = jsonServerProvider("https://jsonplaceholder.typicode.com"); + +const App = () => ( + + + + +); + +export default App; +``` + +This is a minimal admin for 2 resources. React-admin should be able to render a list of posts and a list of comments, guessing the data structure from the API response. + +## Using React-Admin As The Root Application + +If you want to serve the admin app component in the root path ('/'), edit the file called `routes/index.tsx`, and replace the content with the following: + +```jsx +// in app/routes/index.tsx +import App from "../components/App"; +import styles from "~/styles/app.css"; + +export function links() { + return [{ rel: "stylesheet", href: styles }]; +} + +export default App; +``` + +The stylesheet link is necessary to reset the default styles of the admin app. Create it in `app/styles/app.css`: + +```css +body { margin: 0; } +``` + +Remix and react-admin both use [react-router](https://reactrouter.com/) for routing. React-admin detects when it is included inside an existing React Router context and reuses it. This is problematic because Remix uses file-based routing. So when react-admin changes the route to `/posts` for instance, Remix will look for a corresponding `app/routes/posts.tsx` file. As it doesn't exist, Remix will render a 404. + +The solution is to create a [splat route](https://remix.run/docs/en/v1/api/conventions#splat-routes), i.e. a route that matches all URLs. A splat route is named `$.tsx`. Duplicate the `app/routes/index.tsx` code into the `app/routes/$.tsx` file: + +```jsx +// in app/routes/$.tsx +import App from "../components/App"; +import styles from "~/styles/app.css"; + +export function links() { + return [{ rel: "stylesheet", href: styles }]; +} + +export default App; +``` + +**Tip**: Remix doesn't let splat routes catch requests to the index page ('/'), so you must have both the `app/routes/index.tsx` and `app/routes/$.tsx` routes to correctly render the admin app. + +Now, start the server with `yarn dev`, browse to `http://localhost:3000/`, and you should see the working admin: + +![Working Page](./img/nextjs-react-admin.webp) + +## Rendering React-Admin In A Sub Route + +In many cases, the admin is only a part of the application. For instance, you may want to render the admin in a subpath like `/admin`. + +To do so, add a [splat route](https://remix.run/docs/en/v1/api/conventions#splat-routes), i.e. a route that matches all URLs inside a sub path. A splat route is named `$.tsx`. Create a file called `app/routes/admin/$.tsx` file with the following content: + +```jsx +// in app/routes/$.tsx +import App from "../../components/App"; +import styles from "~/styles/app.css"; + +export function links() { + return [{ rel: "stylesheet", href: styles }]; +} + +export default App; +``` + +The stylesheet link is necessary to reset the default styles of the admin app. Create it in `app/styles/app.css`: + +```css +body { margin: 0; } +``` + +And finally, update the react-admin app to specify the `` prop, so that react-admin generates links relative to the "/admin" subpath: + +```diff +// in app/components/App.tsx +import { Admin, Resource, ListGuesser } from "react-admin"; +import jsonServerProvider from "ra-data-json-server"; + +const dataProvider = jsonServerProvider("https://jsonplaceholder.typicode.com"); + +const App = () => ( +- ++ + + + +); + +export default App; +``` + +Now the admin renders at `http://localhost:3000/admin`, and you can use the Remix routing system to add more pages. + +## Adding an API + +[Remix allows to serve an API](https://remix.run/docs/en/v1/guides/api-routes) from the same server. You *could* use this to build a CRUD API by hand. However, we consider that building a CRUD API on top of a relational database is a solved problem and that developers shouldn't spend time reimplementing it. + +For instance, if you store your data in a [PostgreSQL](https://www.postgresql.org/) database, you can use [PostgREST](https://postgrest.org/en/stable/) to expose the data as a REST API with zero configuration. Even better, you can use a Software-as-a-Service like [Supabase](https://supabase.com/) to do that for you. + +In such cases, the Remix API can only serve as a Proxy to authenticate client queries and pass them down to Supabase. + +Let's see an example in practice. + +First, create a Supabase REST API and its associated PostgreSQL database directly on the [Supabase website](https://app.supabase.com/) (it's free for tests and low usage). Once the setup is finished, use the Supabase manager to add the following tables: + +- `posts` with fields: `id`, `title`, and `body` +- `comments` with fields: `id`, `name`, `body`, and `postId` (a foreign key to the `posts.id` field) + +You can populate these tables via the Supabse UI if you want. Supabase exposes a REST API at `https://YOUR_INSTANCE.supabase.co/rest/v1`. + +Next, create a configuration to let the Remix app connect to Supabase. As Remix supports [`dotenv`](https://dotenv.org/) by default in `development` mode, you just need to create a `.env` file: + +```sh +# In `.env` +SUPABASE_URL="https://MY_INSTANCE.supabase.co" +SUPABASE_SERVICE_ROLE="MY_SERVICE_ROLE_KEY" +``` + +**Tip**: This example uses the **service role key** here and not the anonymous role. This allows mutations without dealing with authorization. **You shouldn't do this in production**, but use the [Supabase authorization](https://supabase.com/docs/guides/auth) feature instead. + +Time to bootstrap the API Proxy. Create a new Remix route at `app/routes/admin/api/$.tsx`. Inside this file, a `loader` function should convert the GET requests into Supabase API calls, and an `action` function should do the same for POST, PUT, and DELETE requests. + +```jsx +// in app/routes/admin/api/$.tsx +import type { ActionFunction, LoaderFunction } from '@remix-run/node'; + +// handle read requests (getOne, getList, getMany, getManyReference) +export const loader: LoaderFunction = ({ request }) => { + const apiUrl = getSupabaseUrlFromRequestUrl(request.url); + + return fetch(apiUrl, { + headers: { + prefer: request.headers.get('prefer') ?? '', + accept: request.headers.get('accept') ?? 'application/json', + apiKey: `${process.env.SUPABASE_SERVICE_ROLE}`, + Authorization: `Bearer ${process.env.SUPABASE_SERVICE_ROLE}`, + }, + }); +}; + +// handle write requests (create, update, delete, updateMany, deleteMany) +export const action: ActionFunction = ({ request }) => { + const apiUrl = getSupabaseUrlFromRequestUrl(request.url); + + return fetch(apiUrl, { + method: request.method, + body: request.body, + headers: { + prefer: request.headers.get('prefer') ?? '', + accept: request.headers.get('accept') ?? 'application/json', + 'apiKey': `${process.env.SUPABASE_SERVICE_ROLE}`, + 'Authorization': `Bearer ${process.env.SUPABASE_SERVICE_ROLE}`, + } + }); +} + +const ADMIN_PREFIX = "/admin/api"; + +const getSupabaseUrlFromRequestUrl = (url: string) => { + const startOfRequest = url.indexOf(ADMIN_PREFIX); + const query = url.substring(startOfRequest + ADMIN_PREFIX.length); + return `${process.env.SUPABASE_URL}/rest/v1${query}`; +}; +``` + +**Tip**: Some of this code is really PostgREST-specific. The `prefer` header is required to let PostgREST return one record instead of an array containing one record in response to `getOne` requests. A proxy for another CRUD API will require different parameters. + +Finally, update the react-admin data provider to use the Supabase adapter instead of the JSON Server one. As Supabase provides a PostgREST endpoint, we'll use [`ra-data-postgrest`](https://github.com/promitheus7/ra-data-postgrest): + +```sh +yarn add @promitheus/ra-data-postgrest +``` + +```jsx +// in app/components/App.tsx +import { Admin, Resource, ListGuesser } from 'react-admin'; +import postgrestRestProvider from "@promitheus/ra-data-postgrest"; + +const dataProvider = postgrestRestProvider("/admin/api"); + +const App = () => ( + + + + +); + +export default App; +``` + +That's it! Now Remix both renders the admin app and serves as a proxy to the Supabase API. You can test the app by visiting `http://localhost:3000/admin`, and the API Proxy by visiting `http://localhost:3000/admin/api/posts`. + +Note that the Supabase credentials never leave the server. It's up to you to add your own authentication to the API proxy. \ No newline at end of file diff --git a/docs/img/remix-structure.png b/docs/img/remix-structure.png new file mode 100644 index 0000000000000000000000000000000000000000..6d6ac5ec299724f6f162a55b12a91660a10cadb0 GIT binary patch literal 24047 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

)_Qm!N!^LMs8yuGfPn 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 literal 0 HcmV?d00001 From d4f4132d5ce145623e6120c73533bd1757e2f034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Sat, 2 Jul 2022 11:30:33 +0200 Subject: [PATCH 2/4] Add Getting started section --- docs/navigation.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/navigation.html b/docs/navigation.html index 446db5db55a..f398f17553c 100644 --- a/docs/navigation.html +++ b/docs/navigation.html @@ -1,7 +1,12 @@ -

  • Tutorial
  • What's new?
  • Upgrading to v4
  • + +
      App Configuration
    • <Admin>
    • <Resource>
    • From bb319cb54796a53040a4ccdb43250a6f116b0e5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Sat, 2 Jul 2022 11:31:52 +0200 Subject: [PATCH 3/4] Rename first block in doc index --- docs/documentation.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/documentation.html b/docs/documentation.html index 743b6a74cc3..8da09953b2c 100644 --- a/docs/documentation.html +++ b/docs/documentation.html @@ -47,8 +47,8 @@
      -

      Tutorial

      - Get the basics in 30 mins +

      Getting Started

      + 30 minutes tutorial, installation instructions
      From 768e9e3f6e07e794e006c811f84749ea87c78066 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fran=C3=A7ois=20Zaninotto?= Date: Sat, 2 Jul 2022 11:45:54 +0200 Subject: [PATCH 4/4] Add create-react-app tutorial --- docs/CreateReactApp.md | 68 ++++++++++++++++++++++++++++++++++++++++++ docs/Remix.md | 2 +- docs/navigation.html | 1 + 3 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 docs/CreateReactApp.md diff --git a/docs/CreateReactApp.md b/docs/CreateReactApp.md new file mode 100644 index 00000000000..16687804e68 --- /dev/null +++ b/docs/CreateReactApp.md @@ -0,0 +1,68 @@ +--- +layout: default +title: "Create_React-App Integration" +--- + +# Create-React-App Integration + +[Create-React-App](https://create-react-app.dev/) is the standard way to bootstrap single-page React applications. That's also the recommended way to install and run react-admin. + +## Setting Up Create React App + +Create a new Create React App (CRA) project with the command line: + +```sh +yarn create react-app my-admin +``` + +We recommend using the TypeScript template: + +```sh +yarn create react-app my-admin --template typescript +``` + +## Setting Up React-Admin + +Add the `react-admin` package, as well as a data provider package. In this example, we'll use `ra-data-json-server` to connect to a test API provided by [JSONPlaceholder](https://jsonplaceholder.typicode.com). + +```sh +cd my-admin +yarn add react-admin ra-data-json-server +``` + +Next, create the admin app component in `src/admin/index.tsx`: + +```jsx +// in src/admin/index.tsx +import { Admin, Resource, ListGuesser } from "react-admin"; +import jsonServerProvider from "ra-data-json-server"; + +const dataProvider = jsonServerProvider("https://jsonplaceholder.typicode.com"); + +const App = () => ( + + + + +); + +export default App; +``` + +This is a minimal admin for 2 resources. React-admin should be able to render a list of posts and a list of comments, guessing the data structure from the API response. + +Next, replace the `App.tsx` component with the following: + +```jsx +import MyAdmin from "./admin"; + +const App = () => ; + +export default App; +``` + +Now, start the server with `yarn start`, browse to `http://localhost:3000/`, and you should see the working admin: + +![Working Page](./img/nextjs-react-admin.webp) + +Your app is now up and running, you can start tweaking it. diff --git a/docs/Remix.md b/docs/Remix.md index 76257602563..c1db4446b93 100644 --- a/docs/Remix.md +++ b/docs/Remix.md @@ -5,7 +5,7 @@ title: "Remix Integration" # Remix Integration -React-admin runs seamlessly on [Remix](https://remix.run/). +[Remix](https://remix.run/) is a Node.js framework for server-side-rendered React apps. But even if react-admin is designed to build Single-Page Applications, Remix and react-admin integrate seamlessly. ## Setting Up Remix diff --git a/docs/navigation.html b/docs/navigation.html index f398f17553c..0af9b7b56be 100644 --- a/docs/navigation.html +++ b/docs/navigation.html @@ -3,6 +3,7 @@