├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── README.md ├── package.json ├── public ├── favicon.ico ├── index.html ├── logo192.png ├── logo512.png ├── manifest.json └── robots.txt ├── src ├── App.tsx ├── custom-build-fields.ts ├── index.css ├── index.tsx ├── queries │ ├── todos.ts │ └── users.ts ├── react-admin.d.ts ├── react-app-env.d.ts └── resources │ ├── todos │ ├── Create.tsx │ ├── Edit.tsx │ ├── List.tsx │ └── index.ts │ └── users │ ├── List.tsx │ ├── Show.tsx │ └── index.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "endOfLine": "lf", 3 | "semi": true, 4 | "singleQuote": true, 5 | "tabWidth": 2, 6 | "trailingComma": "es5" 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true 3 | } 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Admin x Hasura 2 | 3 | This is a demo application showing how to use [ra-data-hasura](https://github.com/hasura/ra-data-hasura) to build a [react-admin](https://marmelab.com/react-admin/) application backed by a [Hasura](https://hasura.io/) GraphQL API. 4 | 5 | You can take a look at the app here. 6 | 7 | It uses [ra-data-hasura](https://github.com/hasura/ra-data-hasura) as the React Admin [Data Provider](https://marmelab.com/react-admin/DataProviders.html), and focusses on showing how to write **completely custom GraphQL queries** - just as GraphQL should be used! 8 | 9 | This repository is forked from [react-admin-low-code](https://github.com/cpursley/react-admin-low-code), using the same Hasura GraphQL backend, but allowing for custom queries to be specified by the React Admin client instead of relying on the default generated queries. 10 | 11 | ## Getting started 12 | 13 | 1. `yarn` to install dependencies 14 | 2. `yarn start` to run the application at `localhost:3000` 15 | 3. (Optional): toggle between branches: `custom-queries` and `extending-queries` (master is equal to `custom-queries`) 16 | 17 | ## How it works 18 | 19 | This demo shows two ways to customise the GraphQL queries sent from your React Admin client to a Hasura backend: 20 | 21 | 1. Defining completely custom `gql` queries - shown by the `custom-queries` branch (same as `master` branch) 22 | 2. Extending the default React Admin queries - shown by the `extending-queries` branch 23 | 24 | Each method has a `custom-build-fields.ts` file which does the same 3 things: 25 | 26 | 1. Defines an `extractFieldsFromQuery` function which can extract just the fields from a GraphQL AST generated by a `gql` query: 27 | 28 | ```js 29 | const extractFieldsFromQuery = (queryAst) => { 30 | return queryAst.definitions[0].selectionSet.selections; 31 | }; 32 | ``` 33 | 34 | 2. Imports the custom queries defined in `src/queries` directory; these custom queries are defined as `gql` template literals 35 | 3. Defines a `customBuildFields` function which applies the custom queries for the relevant resource and fetch types; this function is passed to the `buildHasuraProvider` from `ra-data-hasura-graphql`. 36 | 37 | ## Troubleshooting 38 | 39 | React Admin assumes that a resouces's `GET_LIST` query will return the same fields as the corresponding `GET_ONE` query for that resource. This allows React Admin to using a caching system whereby if a resource has already been fetched by it's `List` view, then the corresponding `Show` view can read the record from the Redux store whilst the fetch for the individual record is loading. 40 | 41 | Using `ra-data-hasura-graphql` in the way we demonstrate here means that it's possible for a `GET_LIST` query fields to differ from from the `GET_ONE` query fields. This could cause React Admin to try and read a field that does not immediately exist on a record. 42 | 43 | For example, if a `todos` resource has the following custom queries defined: 44 | 45 | ```js 46 | const GET_LIST_TODOS = gql` 47 | { 48 | id 49 | title 50 | is_completed 51 | } 52 | `; 53 | 54 | const GET_ONE_TODO = gql` 55 | { 56 | id 57 | title 58 | is_completed 59 | user_id 60 | } 61 | `; 62 | ``` 63 | 64 | Loading a `List` of todos will populate Redux with records that have 3 fields: `id`, `title`, `is_completed`. Navigating to a `Show` view for a todo record will then result in React Admin trying to find a `user_id` field, but this will not exist until the `GET_ONE_TODO` query has resolved. 65 | 66 | ### How to avoid this problem 67 | 68 | There are 2 ways to avoid this problem: 69 | 70 | 1. Ensure that resources have consistent fields for `GET_LIST` and `GET_ONE` queries. 71 | 2. Handle loading states for fields that might not exist until the another query has resolved, for example: 72 | 73 | ```jsx 74 | import { FunctionField } from 'react-admin'; 75 | 76 | { 79 | if (!record.user_id) return

Loading...

; 80 | return record.user_id; 81 | }} 82 | />; 83 | ``` 84 | 85 | ## Taking it further 86 | 87 | This demo shows how to customise a query's fields, but with `ra-data-hasura-graphql` you can take complete control of the entire query including the query variables and the response format. 88 | 89 | For example, you could customise the query variables so that soft deleted records are always filtered out. This could be done by passing a `customBuildVariables` argument which ensures that all queries include a `where: { is_deleted: { is_null: true }}` clause. 90 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "low-code-react-admin", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "react-scripts start", 7 | "build": "react-scripts build", 8 | "test": "react-scripts test", 9 | "eject": "react-scripts eject", 10 | "prettier": "prettier --config ./.prettierrc --write '**/*.{js,ts,tsx,css}'" 11 | }, 12 | "dependencies": { 13 | "@material-ui/core": "^4.10.1", 14 | "@material-ui/icons": "^4.9.1", 15 | "@testing-library/jest-dom": "^4.2.4", 16 | "@testing-library/react": "^9.3.2", 17 | "@testing-library/user-event": "^7.1.2", 18 | "apollo-client": "^2.6.10", 19 | "graphql": "^15.4.0", 20 | "graphql-tag": "^2.11.0", 21 | "ra-data-hasura": "^0.4.0", 22 | "react": "^16.13.1", 23 | "react-admin": "^3.6.0", 24 | "react-dom": "^16.13.1", 25 | "react-scripts": "3.4.1" 26 | }, 27 | "devDependencies": { 28 | "@types/react": "^16.9.35", 29 | "@types/react-dom": "^16.9.8", 30 | "husky": "^4.3.0", 31 | "prettier": "^2.1.2", 32 | "pretty-quick": "^3.1.0", 33 | "typescript": "^3.9.5" 34 | }, 35 | "eslintConfig": { 36 | "extends": "react-app" 37 | }, 38 | "browserslist": { 39 | "production": [ 40 | ">0.2%", 41 | "not dead", 42 | "not op_mini all" 43 | ], 44 | "development": [ 45 | "last 1 chrome version", 46 | "last 1 firefox version", 47 | "last 1 safari version" 48 | ] 49 | }, 50 | "husky": { 51 | "hooks": { 52 | "pre-commit": "pretty-quick --staged" 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpvdeveloper/react-admin-hasura-queries/ea61b7ec52847b7ddeb64b2bd5bedbc41a8b1f1d/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React Admin Low Code 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpvdeveloper/react-admin-hasura-queries/ea61b7ec52847b7ddeb64b2bd5bedbc41a8b1f1d/public/logo192.png -------------------------------------------------------------------------------- /public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpvdeveloper/react-admin-hasura-queries/ea61b7ec52847b7ddeb64b2bd5bedbc41a8b1f1d/public/logo512.png -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Admin, Resource } from 'react-admin'; 3 | import buildHasuraProvider from 'ra-data-hasura'; 4 | import { 5 | TodosList, 6 | TodosCreate, 7 | TodosEdit, 8 | TodosIcon, 9 | } from './resources/todos'; 10 | import { UsersList, UsersShow, UsersIcon } from './resources/users'; 11 | import customBuildFields from './custom-build-fields'; 12 | 13 | const GRAPHQL_URI = 'https://react-admin-low-code.hasura.app/v1/graphql'; 14 | 15 | const clientOptions = { uri: GRAPHQL_URI }; 16 | 17 | function App() { 18 | const [dataProvider, setDataProvider] = useState(null); 19 | 20 | useEffect(() => { 21 | const buildDataProvider = async () => { 22 | const dataProvider = await buildHasuraProvider( 23 | { clientOptions }, 24 | { buildFields: customBuildFields } 25 | ); 26 | setDataProvider(() => dataProvider); 27 | }; 28 | buildDataProvider(); 29 | }, []); 30 | 31 | if (!dataProvider) return

Loading...

; 32 | return ( 33 | 34 | 41 | 47 | 48 | ); 49 | } 50 | 51 | export default App; 52 | -------------------------------------------------------------------------------- /src/custom-build-fields.ts: -------------------------------------------------------------------------------- 1 | import { GET_LIST } from 'react-admin'; 2 | import { BuildFields, buildFields } from 'ra-data-hasura'; 3 | 4 | import { GET_LIST_USERS } from './queries/users'; 5 | import { GET_LIST_TODOS } from './queries/todos'; 6 | 7 | /** 8 | * Extracts just the fields from a GraphQL AST. 9 | * @param {GraphQL AST} queryAst 10 | */ 11 | const extractFieldsFromQuery = (queryAst: any) => 12 | queryAst.definitions[0].selectionSet.selections; 13 | 14 | // An object of all the custom queries we have defined. 15 | const CUSTOM_QUERIES: any = { 16 | users: { 17 | [GET_LIST]: GET_LIST_USERS, 18 | }, 19 | todos: { 20 | [GET_LIST]: GET_LIST_TODOS, 21 | }, 22 | }; 23 | 24 | // Function which defines query overrides for specific resources/fetchTypes. 25 | const customBuildFields: BuildFields = (type, fetchType) => { 26 | const resourceName: string = type.name; 27 | 28 | // First check if the resource has any custom queries defined. 29 | const resourceCustomQueries = CUSTOM_QUERIES[resourceName]; 30 | 31 | // If this specific query i.e. resource and fetchType has a custom query, extract the fields from it. 32 | if (fetchType && resourceCustomQueries?.[fetchType]) { 33 | return extractFieldsFromQuery(resourceCustomQueries[fetchType]); 34 | } 35 | 36 | // No custom query defined, so use the default query fields (all, but none related/nested). 37 | return buildFields(type, fetchType); 38 | }; 39 | 40 | export default customBuildFields; 41 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './index.css'; 3 | import App from './App'; 4 | import ReactDOM from 'react-dom'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ); 12 | -------------------------------------------------------------------------------- /src/queries/todos.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const GET_LIST_TODOS = gql` 4 | { 5 | id 6 | title 7 | user_id 8 | is_completed 9 | } 10 | `; 11 | -------------------------------------------------------------------------------- /src/queries/users.ts: -------------------------------------------------------------------------------- 1 | import gql from 'graphql-tag'; 2 | 3 | export const GET_LIST_USERS = gql` 4 | { 5 | id 6 | name 7 | created_at 8 | todos( 9 | where: { is_completed: { _eq: false } } 10 | order_by: { created_at: asc } 11 | ) { 12 | id 13 | title 14 | } 15 | total_todos_count: todos_aggregate { 16 | aggregate { 17 | count 18 | } 19 | } 20 | pending_todos_count: todos_aggregate( 21 | where: { is_completed: { _eq: false } } 22 | ) { 23 | aggregate { 24 | count 25 | } 26 | } 27 | } 28 | `; 29 | -------------------------------------------------------------------------------- /src/react-admin.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-admin'; 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/resources/todos/Create.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Create, 4 | ReferenceInput, 5 | BooleanInput, 6 | SelectInput, 7 | SimpleForm, 8 | TextInput, 9 | } from 'react-admin'; 10 | 11 | export const TodosCreate = (props: object) => ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ); 22 | 23 | export default TodosCreate; 24 | -------------------------------------------------------------------------------- /src/resources/todos/Edit.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Edit, 4 | ReferenceInput, 5 | BooleanInput, 6 | SelectInput, 7 | SimpleForm, 8 | TextInput, 9 | TopToolbar, 10 | ListButton, 11 | } from 'react-admin'; 12 | 13 | const TodoTitle = ({ record }: { record?: { title: string } }) => { 14 | return Todo: {record ? `${record.title}` : ''}; 15 | }; 16 | 17 | const TodoEditActions = ({ 18 | basePath, 19 | data, 20 | }: { 21 | basePath?: string; 22 | data?: object; 23 | }) => ( 24 | 25 | 26 | 27 | ); 28 | 29 | export const TodosEdit = (props: object) => ( 30 | } actions={} {...props}> 31 | 32 | 33 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | ); 46 | 47 | export default TodosEdit; 48 | -------------------------------------------------------------------------------- /src/resources/todos/List.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Filter, 4 | List, 5 | Datagrid, 6 | TextField, 7 | ReferenceField, 8 | BooleanField, 9 | ReferenceInput, 10 | BooleanInput, 11 | SelectInput, 12 | TextInput, 13 | } from 'react-admin'; 14 | 15 | const TodoFilter = (props: object) => ( 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | ); 24 | 25 | const TodoList = (props: object) => ( 26 | } bulkActionButtons={false} {...props}> 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | ); 37 | 38 | export default TodoList; 39 | -------------------------------------------------------------------------------- /src/resources/todos/index.ts: -------------------------------------------------------------------------------- 1 | import PostIcon from '@material-ui/icons/Book'; 2 | import TodosList from './List'; 3 | import TodosCreate from './Create'; 4 | import TodosEdit from './Edit'; 5 | 6 | export { TodosList, TodosCreate, TodosEdit, PostIcon as TodosIcon }; 7 | -------------------------------------------------------------------------------- /src/resources/users/List.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | List, 4 | Filter, 5 | Datagrid, 6 | TextInput, 7 | TextField, 8 | FunctionField, 9 | } from 'react-admin'; 10 | 11 | interface TodoFromUserQuery { 12 | id: number; 13 | title: string; 14 | } 15 | 16 | const UserFilter = (props: object) => ( 17 | 18 | 19 | 20 | ); 21 | 22 | export const UsersList = (props: object) => ( 23 | } bulkActionButtons={false} {...props}> 24 | 25 | 26 | 27 | 31 | 35 | { 38 | const latestTodo = todos?.[0]; 39 | if (!latestTodo) return 'N/A'; 40 | return {latestTodo.title}; 41 | }} 42 | /> 43 | 44 | 45 | ); 46 | 47 | export default UsersList; 48 | -------------------------------------------------------------------------------- /src/resources/users/Show.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Datagrid, 4 | TextField, 5 | DateField, 6 | BooleanField, 7 | Show, 8 | TabbedShowLayout, 9 | Tab, 10 | ReferenceManyField, 11 | TopToolbar, 12 | ListButton, 13 | } from 'react-admin'; 14 | 15 | const UserTitle = ({ record }: { record?: { name: string } }) => { 16 | return User: {record ? `${record.name}` : ''}; 17 | }; 18 | 19 | const UserShowActions = ({ 20 | basePath, 21 | data, 22 | }: { 23 | basePath?: string; 24 | data?: object; 25 | }) => ( 26 | 27 | 28 | 29 | ); 30 | 31 | export const UsersShow = (props: object) => ( 32 | } actions={} {...props}> 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | ); 52 | 53 | export default UsersShow; 54 | -------------------------------------------------------------------------------- /src/resources/users/index.ts: -------------------------------------------------------------------------------- 1 | import UserIcon from '@material-ui/icons/Group'; 2 | import UsersList from './List'; 3 | import UsersShow from './Show'; 4 | 5 | export { UsersList, UsersShow, UserIcon as UsersIcon }; 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "noEmit": true, 19 | "jsx": "react", 20 | "isolatedModules": true 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------