├── .eslintrc ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md └── workflows │ ├── codeql-analysis.yml │ └── test.yml ├── .gitignore ├── .prettierrc.js ├── .yarnrc.yml ├── CHANGELOG.md ├── LICENSE.md ├── Makefile ├── README.md ├── assets └── video.jpg ├── babel.config.js ├── cypress.config.ts ├── cypress ├── e2e │ ├── adminGuesser.cy.ts │ ├── lists.cy.ts │ └── login.ts ├── fixtures │ └── example.json ├── support │ ├── commands.ts │ └── e2e.ts └── tsconfig.json ├── jest.config.js ├── lerna.json ├── package.json ├── packages ├── demo │ ├── .env.local-example │ ├── .gitignore │ ├── README.md │ ├── db-seed.ts │ ├── index.html │ ├── package.json │ ├── public │ │ ├── auth-callback.html │ │ ├── favicon.ico │ │ ├── index.html │ │ ├── logo192.png │ │ ├── logo512.png │ │ ├── logos │ │ │ ├── 0.png │ │ │ ├── 1.png │ │ │ ├── 10.png │ │ │ ├── 11.png │ │ │ ├── 12.png │ │ │ ├── 13.png │ │ │ ├── 14.png │ │ │ ├── 15.png │ │ │ ├── 16.png │ │ │ ├── 17.png │ │ │ ├── 18.png │ │ │ ├── 19.png │ │ │ ├── 2.png │ │ │ ├── 20.png │ │ │ ├── 21.png │ │ │ ├── 22.png │ │ │ ├── 23.png │ │ │ ├── 24.png │ │ │ ├── 25.png │ │ │ ├── 26.png │ │ │ ├── 27.png │ │ │ ├── 28.png │ │ │ ├── 29.png │ │ │ ├── 3.png │ │ │ ├── 30.png │ │ │ ├── 31.png │ │ │ ├── 32.png │ │ │ ├── 33.png │ │ │ ├── 34.png │ │ │ ├── 35.png │ │ │ ├── 36.png │ │ │ ├── 37.png │ │ │ ├── 38.png │ │ │ ├── 39.png │ │ │ ├── 4.png │ │ │ ├── 40.png │ │ │ ├── 41.png │ │ │ ├── 42.png │ │ │ ├── 43.png │ │ │ ├── 44.png │ │ │ ├── 45.png │ │ │ ├── 46.png │ │ │ ├── 47.png │ │ │ ├── 48.png │ │ │ ├── 49.png │ │ │ ├── 5.png │ │ │ ├── 50.png │ │ │ ├── 51.png │ │ │ ├── 52.png │ │ │ ├── 53.png │ │ │ ├── 54.png │ │ │ ├── 55.png │ │ │ ├── 6.png │ │ │ ├── 7.png │ │ │ ├── 8.png │ │ │ ├── 9.png │ │ │ └── Readme.md │ │ ├── manifest.json │ │ └── robots.txt │ ├── src │ │ ├── App.tsx │ │ ├── dataGenerator │ │ │ ├── companies.ts │ │ │ ├── contactNotes.ts │ │ │ ├── contacts.ts │ │ │ ├── dealNotes.ts │ │ │ ├── deals.ts │ │ │ ├── finalize.ts │ │ │ ├── index.ts │ │ │ ├── sales.ts │ │ │ ├── tags.ts │ │ │ ├── tasks.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ │ ├── env.d.ts │ │ ├── index.tsx │ │ ├── logo.svg │ │ ├── react-app-env.d.ts │ │ ├── reportWebVitals.js │ │ ├── setupTests.js │ │ └── types.ts │ ├── tsconfig.json │ └── vite.config.ts ├── ra-supabase-core │ ├── README.md │ ├── babel.config.js │ ├── package.json │ ├── src │ │ ├── authProvider.ts │ │ ├── dataProvider.ts │ │ ├── getSearchString.ts │ │ ├── index.ts │ │ ├── useAPISchema.ts │ │ ├── useRedirectIfAuthenticated.test.tsx │ │ ├── useRedirectIfAuthenticated.ts │ │ ├── useResetPassword.ts │ │ ├── useSetPassword.test.tsx │ │ ├── useSetPassword.ts │ │ ├── useSupabaseAccessToken.test.tsx │ │ └── useSupabaseAccessToken.ts │ └── tsconfig.json ├── ra-supabase-language-english │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── ra-supabase-language-french │ ├── README.md │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── ra-supabase-ui-materialui │ ├── README.md │ ├── package.json │ ├── src │ │ ├── AuthLayout.tsx │ │ ├── ForgotPasswordForm.tsx │ │ ├── ForgotPasswordPage.tsx │ │ ├── LoginForm.tsx │ │ ├── LoginPage.tsx │ │ ├── SetPasswordForm.tsx │ │ ├── SetPasswordPage.tsx │ │ ├── SocialAuthButton.tsx │ │ ├── guessers │ │ │ ├── CreateGuesser.tsx │ │ │ ├── EditGuesser.tsx │ │ │ ├── InferredElement.ts │ │ │ ├── ListGuesser.tsx │ │ │ ├── ShowGuesser.tsx │ │ │ ├── editFieldTypes.tsx │ │ │ ├── exampleSchema.json │ │ │ ├── index.ts │ │ │ ├── inferElementFromType.ts │ │ │ ├── useCrudGuesser.spec.tsx │ │ │ └── useCrudGuesser.tsx │ │ ├── icons.tsx │ │ ├── index.ts │ │ └── mui.d.ts │ └── tsconfig.json └── ra-supabase │ ├── README.md │ ├── assets │ └── demo.png │ ├── package.json │ ├── src │ ├── AdminGuesser.tsx │ ├── defaultI18nProvider.ts │ └── index.ts │ └── tsconfig.json ├── supabase ├── .gitignore ├── config.toml ├── migrations │ ├── 20230126140204_init.sql │ ├── 20230220132931_fts.sql │ ├── 20240606161030_allow_nullable.sql │ └── 20250325160944_contact_tags.sql ├── seed.sql └── templates │ ├── invite.html │ └── recovery.html ├── test-global-setup.js ├── test-setup.js ├── tsconfig.json └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "react-app", 5 | "plugin:prettier/recommended" 6 | ], 7 | "plugins": [ 8 | "jsx-a11y", 9 | "prettier", 10 | "react", 11 | "react-hooks" 12 | ], 13 | "rules": { 14 | "no-use-before-define": "off", 15 | "prettier/prettier": "error", 16 | "no-restricted-imports": [ 17 | "error", 18 | { 19 | "paths": [ 20 | { 21 | "name": "@material-ui/core", 22 | "importNames": ["makeStyles", "createMuiTheme"], 23 | "message": "Please import from @material-ui/core/styles instead. See https://material-ui.com/guides/minimizing-bundle-size/#option-2 for more information" 24 | } 25 | ] 26 | } 27 | ], 28 | "no-redeclare": "off", 29 | "@typescript-eslint/no-redeclare": "error" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /.github/CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at info@marmelab.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [https://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: https://contributor-covenant.org 46 | [version]: https://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | So you want to contribute to react-admin? Awesome! Thank you in advance for your contribution. Here are a few guidelines that will help you along the way. 4 | 5 | ## Asking Questions 6 | 7 | For how-to questions and other non-issues, please use [StackOverflow](https://stackoverflow.com/questions/tagged/react-admin) instead of Github issues. There is a StackOverflow tag called "react-admin" that you can use to tag your questions. 8 | 9 | ## Opening an Issue 10 | 11 | If you think you have found a bug, or have a new feature idea, please start by making sure it hasn't already been [reported or fixed](https://github.com/marmelab/react-admin/issues?q=is%3Aissue+is%3Aclosed). You can search through existing issues and PRs to see if someone has reported one similar to yours. 12 | 13 | Next, create a new issue that briefly explains the problem, and provides a bit of background as to the circumstances that triggered it, and steps to reproduce it. 14 | 15 | For code issues please include: 16 | * React-admin version 17 | * React version 18 | * Browser version 19 | * A code example or link to a repo, gist or running site. (hint: fork [this sandbox](https://codesandbox.io/s/github/marmelab/react-admin/tree/master/examples/simple) to create a reproducible version of your bug) 20 | 21 | For visual or layout problems, images or animated gifs can help explain your issue. 22 | It's even better with a live reproduction test case. 23 | 24 | ### Issue Guidelines 25 | 26 | Please use a succinct description. "doesn't work" doesn't help others find similar issues. 27 | 28 | Please don't group multiple topics into one issue, but instead each should be its own issue. 29 | 30 | And please don't just '+1' an issue. It spams the maintainers and doesn't help move the issue forward. 31 | 32 | ## Submitting a Pull Request 33 | 34 | React-admin is a community project, so pull requests are always welcome, but before working on a large change, it is best to open an issue first to discuss it with the maintainers. In that case, prefix it with "[RFC]" (Request for Comments) 35 | 36 | When in doubt, keep your pull requests small. To give a PR the best chance of getting accepted, don't bundle more than one feature or bug fix per pull request. It's always best to create two smaller PRs than one big one. 37 | 38 | The core team prefix their PRs width "[WIP]" (Work in Progress) or "[RFR]" (ready for Review), don't hesitate to do the same to explain how far you are from completion. 39 | 40 | When adding new features or modifying existing, please attempt to include tests to confirm the new behaviour. 41 | 42 | ### Patches or hotfix: PR on `master` 43 | 44 | Bug fixes that don't break existing apps can be PRed against `master`. Hotfix versions are released usually within days. 45 | 46 | ### New Features or BC breaks: PR on `next` 47 | 48 | At any given time, `next` represents the latest development version of the library. It is merged to master and released on a monthly basis. 49 | 50 | ### Coding style 51 | 52 | You must follow the coding style of the existing files. React-admin uses eslint and [prettier](https://github.com/prettier/prettier). You can reformat all the project files automatically by calling 53 | 54 | ```sh 55 | make prettier 56 | ``` 57 | 58 | **Tip**: If possible, enable linting in your editor to get realtime feedback and/or fixes. 59 | 60 | ## License 61 | 62 | By contributing your code to the marmelab/react-admin GitHub repository, you agree to license your contribution under the MIT license. 63 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: 'Code scanning - action' 2 | 3 | on: 4 | push: 5 | pull_request: 6 | schedule: 7 | - cron: '0 10 * * 3' 8 | 9 | jobs: 10 | CodeQL-Build: 11 | # CodeQL runs on ubuntu-latest and windows-latest 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - name: Cancel Previous Runs 16 | uses: styfle/cancel-workflow-action@0.11.0 17 | - name: Checkout repository 18 | uses: actions/checkout@v2 19 | with: 20 | # We must fetch at least the immediate parents so that if this is 21 | # a pull request then we can checkout the head. 22 | fetch-depth: 2 23 | 24 | # If this run was triggered by a pull request event, then checkout 25 | # the head of the pull request instead of the merge commit. 26 | - run: git checkout HEAD^2 27 | if: ${{ github.event_name == 'pull_request' }} 28 | 29 | # Initializes the CodeQL tools for scanning. 30 | - name: Initialize CodeQL 31 | uses: github/codeql-action/init@v1 32 | # Override language selection by uncommenting this and choosing your languages 33 | # with: 34 | # languages: go, javascript, csharp, python, cpp, java 35 | 36 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 37 | # If this step fails, then you should remove it and run the build manually (see below) 38 | - name: Autobuild 39 | uses: github/codeql-action/autobuild@v1 40 | 41 | # ℹ️ Command-line programs to run using the OS shell. 42 | # 📚 https://git.io/JvXDl 43 | 44 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 45 | # and modify them (or add more) to build your code if your project 46 | # uses a compiled language 47 | 48 | #- run: | 49 | # make bootstrap 50 | # make release 51 | 52 | - name: Perform CodeQL Analysis 53 | uses: github/codeql-action/analyze@v1 54 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: 'Test - action' 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - next 8 | pull_request: 9 | 10 | jobs: 11 | unit-test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Cancel Previous Runs 15 | uses: styfle/cancel-workflow-action@0.11.0 16 | - name: Checkout 17 | uses: actions/checkout@v3 18 | - name: Use Node.js LTS 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '22.x' 22 | - name: Enable corepack 23 | run: corepack enable 24 | - name: Yarn install 25 | run: yarn install --immutable 26 | - name: Build 27 | run: make build 28 | - name: Lint 29 | run: make lint 30 | - name: Unit Tests 31 | run: make test-unit 32 | env: 33 | CI: true 34 | 35 | cypress-run: 36 | runs-on: ubuntu-latest 37 | steps: 38 | - name: Cancel Previous Runs 39 | uses: styfle/cancel-workflow-action@0.11.0 40 | - name: Checkout 41 | uses: actions/checkout@v3 42 | - name: Use Node.js LTS 43 | uses: actions/setup-node@v3 44 | with: 45 | node-version: '22.x' 46 | - name: Enable corepack 47 | run: corepack enable 48 | - name: Yarn install 49 | run: yarn install --immutable 50 | - name: Prepare env 51 | run: cp -n ./packages/demo/.env.local-example ./packages/demo/.env 52 | - name: Build 53 | run: make build build-demo 54 | - uses: supabase/setup-cli@v1.4.0 55 | with: 56 | version: 2.12.1 57 | - name: Start Supabase local development setup 58 | run: supabase start 59 | - name: Setup database 60 | run: make db-migrate db-seed 61 | - name: Cypress run 62 | uses: cypress-io/github-action@v5.0.7 63 | with: 64 | start: make run-prod -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | node_modules 5 | 6 | # testing 7 | coverage 8 | 9 | # production 10 | build 11 | lib 12 | esm 13 | es6 14 | dist 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | .eslintcache 23 | 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | cypress/videos 29 | cypress/screenshots 30 | cypress/downloads 31 | 32 | .pnp.* 33 | .yarn/* 34 | !.yarn/patches 35 | !.yarn/plugins 36 | !.yarn/releases 37 | !.yarn/sdks 38 | !.yarn/versions -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'avoid', 3 | bracketSpacing: true, 4 | jsxBracketSameLine: false, 5 | jsxSingleQuote: false, 6 | printWidth: 80, 7 | quoteProps: 'as-needed', 8 | rangeStart: 0, 9 | rangeEnd: Infinity, 10 | semi: true, 11 | singleQuote: true, 12 | tabWidth: 4, 13 | trailingComma: 'es5', 14 | useTabs: false 15 | } 16 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-present, Francois Zaninotto, Marmelab 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: build help 2 | 3 | help: 4 | @grep -E '^[a-zA-Z0-9_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 5 | 6 | setup-env: ## setup the environment 7 | cp -n ./packages/demo/.env.local-example ./packages/demo/.env 2>/dev/null || : 8 | 9 | install: setup-env package.json ## install dependencies 10 | @if [ "$(CI)" != "true" ]; then \ 11 | echo "Full install..."; \ 12 | yarn; \ 13 | fi 14 | @if [ "$(CI)" = "true" ]; then \ 15 | echo "Frozen install..."; \ 16 | yarn --frozen-lockfile; \ 17 | fi 18 | 19 | run: ## run the demo 20 | @yarn run-demo 21 | 22 | run-prod: ## run the demo in prod 23 | @yarn run-demo-prod 24 | 25 | build-demo: ## compile the demo to static js 26 | @yarn build-demo 27 | 28 | build-ra-supabase-core: 29 | @echo "Transpiling ra-supabase-core files..."; 30 | @cd ./packages/ra-supabase-core && yarn build 31 | 32 | build-ra-supabase-language-english: 33 | @echo "Transpiling ra-supabase-language-english files..."; 34 | @cd ./packages/ra-supabase-language-english && yarn build 35 | 36 | build-ra-supabase-language-french: 37 | @echo "Transpiling ra-supabase-language-french files..."; 38 | @cd ./packages/ra-supabase-language-french && yarn build 39 | 40 | build-ra-supabase-ui-materialui: 41 | @echo "Transpiling ra-supabase-ui-materialui files..."; 42 | @cd ./packages/ra-supabase-ui-materialui && yarn build 43 | 44 | build-ra-supabase: 45 | @echo "Transpiling ra-supabase files..."; 46 | @cd ./packages/ra-supabase && yarn build 47 | 48 | build: build-ra-supabase-core build-ra-supabase-ui-materialui build-ra-supabase-language-english build-ra-supabase-language-french build-ra-supabase ## compile ES6 files to JS 49 | 50 | lint: ## lint the code and check coding conventions 51 | @echo "Running linter..." 52 | @yarn lint 53 | 54 | prettier: ## prettify the source code using prettier 55 | @echo "Running prettier..." 56 | @yarn prettier 57 | 58 | test: build test-unit lint ## launch all tests 59 | 60 | test-unit: ## launch unit tests 61 | @echo "Running unit tests..."; 62 | @yarn test-unit; 63 | 64 | test-unit-watch: ## launch unit tests and watch for changes 65 | @echo "Running unit tests..."; 66 | @yarn test-unit --watch; 67 | 68 | test-e2e: ## launch e2e tests 69 | @echo "Running e2e tests..."; 70 | @yarn test-e2e; 71 | 72 | test-e2e-local: ## launch e2e tests 73 | @echo "Running e2e tests..."; 74 | @yarn test-e2e-local; 75 | 76 | supabase-start: ## start the supabase server 77 | @echo "Starting supabase server..." 78 | @yarn supabase start 79 | 80 | supabase-stop: ## stop the supabase server 81 | @echo "Stopping supabase server..." 82 | @yarn supabase stop 83 | 84 | db-migrate: ## migrate the database 85 | @echo "Apply migrations on the database..." 86 | @yarn db-migrate 87 | 88 | db-seed: ## seed the database 89 | @echo "Seeding the database..." 90 | @yarn db-seed 91 | 92 | db-setup: db-migrate db-seed ## setup the database 93 | -------------------------------------------------------------------------------- /assets/video.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/assets/video.jpg -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-react', 5 | '@babel/preset-typescript', 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | 3 | export default defineConfig({ 4 | e2e: { 5 | baseUrl: 'http://localhost:8000', 6 | }, 7 | }); 8 | -------------------------------------------------------------------------------- /cypress/e2e/adminGuesser.cy.ts: -------------------------------------------------------------------------------- 1 | import { login } from './login'; 2 | 3 | describe('AdminGuesser', () => { 4 | it('should render one menu item per resource', () => { 5 | cy.visit('/'); 6 | login(); 7 | cy.findAllByText('Contacts'); 8 | cy.findByText('Companies').should('exist'); 9 | cy.findByText('Deals').should('exist'); 10 | cy.findByText('Tags').should('exist'); 11 | cy.findByText('Tasks').should('exist'); 12 | cy.findByText('Dealnotes').should('exist'); 13 | cy.findByText('Contactnotes').should('exist'); 14 | cy.findByText('Sales').should('exist'); 15 | }); 16 | }); 17 | -------------------------------------------------------------------------------- /cypress/e2e/lists.cy.ts: -------------------------------------------------------------------------------- 1 | import { login } from './login'; 2 | 3 | const getPaginationText = () => 4 | cy.findByText(/\d+-\d+ of \d+/, { timeout: 10000 }); 5 | 6 | describe('Lists', () => { 7 | it('should render a list', () => { 8 | cy.visit('/'); 9 | login(); 10 | getPaginationText(); 11 | }); 12 | 13 | it('should allow to move through pages', () => { 14 | cy.visit('/'); 15 | login(); 16 | getPaginationText().then(el => { 17 | const page = parseInt(el.text().split('-')[0].trim()); 18 | cy.findByLabelText('Go to page 4').click(); 19 | 20 | let page4 = 0; 21 | // Use should here to allow built-in retry as it may take a few ms for the list to update 22 | getPaginationText().should(el => { 23 | page4 = parseInt(el.text().split('-')[0].trim()); 24 | expect(page4).to.be.greaterThan(page); 25 | }); 26 | 27 | cy.findByLabelText('Go to page 2').click(); 28 | 29 | // Use should here to allow built-in retry as it may take a few ms for the list to update 30 | getPaginationText().should(el => { 31 | const page2 = parseInt(el.text().split('-')[0].trim()); 32 | expect(page2).to.be.greaterThan(page); 33 | expect(page2).to.be.lessThan(page4); 34 | }); 35 | }); 36 | }); 37 | 38 | it('should allow to sort data', () => { 39 | cy.visit('/'); 40 | login(); 41 | cy.findByLabelText('Sort by gender ascending').click(); 42 | cy.findAllByText('female', { timeout: 10000 }).should( 43 | 'have.length', 44 | 10 45 | ); 46 | cy.findByText('male').should('not.exist'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /cypress/e2e/login.ts: -------------------------------------------------------------------------------- 1 | export const login = ( 2 | email: string = 'janedoe@atomic.dev', 3 | password: string = 'password' 4 | ) => { 5 | cy.wait(1000); 6 | cy.findByLabelText('Email *').type(email); 7 | cy.findByLabelText('Password *').type(password); 8 | cy.findByText('Sign in').click(); 9 | }; 10 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/support/commands.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import '@testing-library/cypress/add-commands'; 3 | -------------------------------------------------------------------------------- /cypress/support/e2e.ts: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.ts is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /cypress/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": ".", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "allowJs": false, 9 | "types": ["cypress", "@testing-library/cypress"] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | globalSetup: './test-global-setup.js', 3 | setupFilesAfterEnv: ['./test-setup.js'], 4 | testEnvironment: 'jsdom', 5 | testPathIgnorePatterns: [ 6 | '/node_modules/', 7 | '/lib/', 8 | '/esm/', 9 | '/packages/demo', 10 | ], 11 | transformIgnorePatterns: [ 12 | '[/\\\\]node_modules[/\\\\](?!(@hookform)/).+\\.(js|jsx|mjs|ts|tsx)$', 13 | ], 14 | testEnvironmentOptions: { 15 | customExportConditions: [], // don't load "browser" field 16 | }, 17 | transform: { 18 | // '^.+\\.[tj]sx?$' to process js/ts with `ts-jest` 19 | '^.+\\.[tj]sx?$': [ 20 | 'ts-jest', 21 | { 22 | isolatedModules: true, 23 | useESM: true, 24 | }, 25 | ], 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": ["packages/*"], 3 | "version": "3.5.0", 4 | "useWorkspaces": true, 5 | "npmClient": "yarn" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ra-supabase-monorepo", 3 | "private": true, 4 | "version": "1.0.0", 5 | "repository": "git@github.com:marmelab/ra-supabase.git", 6 | "author": "Gildas Garcia <1122076+djhi@users.noreply.github.com>", 7 | "license": "MIT", 8 | "files": [ 9 | "*.md", 10 | "lib", 11 | "esm", 12 | "src" 13 | ], 14 | "main": "lib/index", 15 | "module": "esm/index.js", 16 | "types": "esm/index.d.ts", 17 | "sideEffects": false, 18 | "devDependencies": { 19 | "@testing-library/cypress": "^9.0.0", 20 | "@types/jest": "^29.5.11", 21 | "@typescript-eslint/eslint-plugin": "^5.62.0", 22 | "@typescript-eslint/parser": "^5.62.0", 23 | "babel-jest": "^29.7.0", 24 | "cypress": "^12.17.4", 25 | "eslint": "^8.56.0", 26 | "eslint-config-prettier": "^8.10.0", 27 | "eslint-config-react-app": "^7.0.1", 28 | "eslint-plugin-cypress": "^2.15.1", 29 | "eslint-plugin-flowtype": "^8.0.3", 30 | "eslint-plugin-import": "^2.29.1", 31 | "eslint-plugin-jsx-a11y": "^6.8.0", 32 | "eslint-plugin-prettier": "^4.2.1", 33 | "eslint-plugin-react": "^7.33.2", 34 | "eslint-plugin-react-hooks": "^4.6.0", 35 | "husky": "^8.0.3", 36 | "jest": "^29.7.0", 37 | "jest-environment-jsdom": "^29.7.0", 38 | "lerna": "^6.6.2", 39 | "prettier": "~2.8.8", 40 | "raf": "^3.4.1", 41 | "supabase": "^2.12.1", 42 | "ts-jest": "^29.1.0", 43 | "typescript": "^5.7.3", 44 | "whatwg-fetch": "^3.0.0" 45 | }, 46 | "resolutions": { 47 | "esbuild": "^0.24.2" 48 | }, 49 | "scripts": { 50 | "build": "lerna run build", 51 | "build-demo": "cd packages/demo && yarn build", 52 | "run-demo": "cd packages/demo && yarn start", 53 | "run-demo-prod": "cd packages/demo && yarn serve", 54 | "test-unit": "jest", 55 | "test-e2e": "cypress run", 56 | "test-e2e-local": "cypress open", 57 | "watch": "lerna run --parallel watch", 58 | "lint": "eslint --fix --ext .js,.ts,.tsx \"./packages/**/src/**/*.{js,ts,tsx}\"", 59 | "prettier": "prettier --write \"./packages/**/src/**/*.{js,ts,tsx}\"", 60 | "db-seed": "cd packages/demo && yarn db-seed", 61 | "db-migrate": "cd packages/demo && yarn supabase db reset", 62 | "cypress:open": "cypress open" 63 | }, 64 | "workspaces": [ 65 | "packages/*" 66 | ], 67 | "packageManager": "yarn@4.6.0" 68 | } 69 | -------------------------------------------------------------------------------- /packages/demo/.env.local-example: -------------------------------------------------------------------------------- 1 | VITE_SUPABASE_URL=http://localhost:54321 2 | VITE_SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0 3 | SUPABASE_SERVICE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU 4 | -------------------------------------------------------------------------------- /packages/demo/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env 15 | .env.local 16 | .env.development.local 17 | .env.test.local 18 | .env.production.local 19 | .eslintcache 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /packages/demo/README.md: -------------------------------------------------------------------------------- 1 | # React-admin CRM 2 | 3 | This is a demo of the [react-admin](https://github.com/marmelab/react-admin) library for React.js. It's a CRM for a fake Web agency with a few sales. You can test it online at https://marmelab.com/react-admin-crm. 4 | 5 | https://user-images.githubusercontent.com/99944/116970434-4a926480-acb8-11eb-8ce2-0602c680e45e.mp4 6 | 7 | React-admin usually requires a REST/GraphQL server to provide data. In this demo however, the API is simulated by the browser (using [FakeRest](https://github.com/marmelab/FakeRest)). The source data is generated at runtime by a package called [data-generator](https://github.com/marmelab/react-admin/tree/master/examples/data-generator). 8 | 9 | To explore the source code, start with [src/App.tsx](https://github.com/marmelab/react-admin/blob/master/examples/crm/src/App.tsx). 10 | 11 | **Note**: This project was bootstrapped with [Vite](https://vitejs.dev/). 12 | 13 | ## How to run 14 | 15 | After having cloned the react-admin repository, run the following commands at the react-admin root: 16 | 17 | ```sh 18 | make install 19 | 20 | make build 21 | 22 | make run-crm 23 | ``` 24 | 25 | ## Available Scripts 26 | 27 | In the project directory, you can run: 28 | 29 | ### `npm start` 30 | 31 | Runs the app in the development mode.
32 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 33 | 34 | The page will reload if you make edits.
35 | You will also see any lint errors in the console. 36 | 37 | ### `npm test` 38 | 39 | Launches the test runner in the interactive watch mode.
40 | See the section about [running tests](#running-tests) for more information. 41 | 42 | ### `npm run build` 43 | 44 | Builds the app for production to the `build` folder.
45 | It correctly bundles React in production mode and optimizes the build for the best performance. 46 | 47 | The build is minified and the filenames include the hashes.
48 | Your app is ready to be deployed! 49 | 50 | ### `npm run deploy` 51 | 52 | Deploy the build to GitHub gh-pages. 53 | -------------------------------------------------------------------------------- /packages/demo/db-seed.ts: -------------------------------------------------------------------------------- 1 | import dotenv from 'dotenv'; 2 | import { createClient } from '@supabase/supabase-js'; 3 | import { generateSales } from './src/dataGenerator/sales'; 4 | import { generateTags } from './src/dataGenerator/tags'; 5 | import { generateCompanies } from './src/dataGenerator/companies'; 6 | import { generateContacts } from './src/dataGenerator/contacts'; 7 | import { generateContactNotes } from './src/dataGenerator/contactNotes'; 8 | import { generateDeals } from './src/dataGenerator/deals'; 9 | import { generateDealNotes } from './src/dataGenerator/dealNotes'; 10 | import { generateTasks } from './src/dataGenerator/tasks'; 11 | 12 | dotenv.config(); 13 | 14 | async function main() { 15 | const supabase = createClient( 16 | process.env.VITE_SUPABASE_URL as string, 17 | process.env.SUPABASE_SERVICE_KEY as string 18 | ); 19 | 20 | const sales = generateSales(); 21 | 22 | console.log('Adding sales...'); 23 | const { data: persistedSales, error: errorSales } = await supabase 24 | .from('sales') 25 | .insert(sales.map(({ id, ...sale }) => sale)) 26 | .select(); 27 | 28 | if (errorSales) { 29 | throw errorSales; 30 | } 31 | 32 | console.log('Adding tags...'); 33 | const tags = generateTags(); 34 | const { data: persistedTags, error: errorTags } = await supabase 35 | .from('tags') 36 | .insert(tags.map(({ id, ...tag }) => tag)) 37 | .select(); 38 | 39 | if (errorTags) { 40 | throw errorTags; 41 | } 42 | 43 | console.log('Adding companies...'); 44 | const companies = generateCompanies({ sales: persistedSales }); 45 | const { data: persistedCompanies, error: errorCompanies } = await supabase 46 | .from('companies') 47 | .insert( 48 | companies.map( 49 | ({ nb_contacts, nb_deals, id, ...company }) => company 50 | ) 51 | ) 52 | .select(); 53 | 54 | if (errorCompanies) { 55 | throw errorCompanies; 56 | } 57 | 58 | console.log('Adding contacts...'); 59 | const contacts = generateContacts({ 60 | tags: persistedTags, 61 | companies: persistedCompanies, 62 | }); 63 | const { data: persistedContacts, error: errorContacts } = await supabase 64 | .from('contacts') 65 | .insert(contacts.map(({ id, ...contact }) => contact)) 66 | .select(); 67 | 68 | if (errorContacts) { 69 | throw errorContacts; 70 | } 71 | 72 | console.log('Adding contact notes...'); 73 | const contactNotes = generateContactNotes({ 74 | contacts: persistedContacts, 75 | }); 76 | const { data: persistedContactNotes, error: errorContactNotes } = 77 | await supabase 78 | .from('contactNotes') 79 | .insert(contactNotes.map(({ id, ...note }) => note)) 80 | .select(); 81 | 82 | if (errorContactNotes) { 83 | throw errorContactNotes; 84 | } 85 | 86 | console.log('Updating contacts status...'); 87 | await Promise.all( 88 | persistedContactNotes 89 | .sort( 90 | (a, b) => 91 | new Date(a.date).valueOf() - new Date(b.date).valueOf() 92 | ) 93 | .map(note => { 94 | const relatedContact = persistedContacts.find( 95 | contact => contact.id === note.contact_id 96 | ); 97 | return supabase.from('contacts').update({ 98 | ...relatedContact, 99 | status: note.status, 100 | }); 101 | }) 102 | ); 103 | 104 | console.log('Adding deals...'); 105 | const deals = generateDeals({ 106 | companies: persistedCompanies, 107 | contacts: persistedContacts, 108 | }); 109 | const { data: persistedDeals, error: errorDeals } = await supabase 110 | .from('deals') 111 | .insert(deals.map(({ id, ...deal }) => deal)) 112 | .select(); 113 | 114 | if (errorDeals) { 115 | throw errorDeals; 116 | } 117 | 118 | console.log('Adding deal notes...'); 119 | const dealNotes = generateDealNotes({ 120 | companies: persistedCompanies, 121 | deals: persistedDeals, 122 | }); 123 | const { error: errorDealNotes } = await supabase 124 | .from('dealNotes') 125 | .insert(dealNotes.map(({ id, ...note }) => note)); 126 | 127 | if (errorDealNotes) { 128 | throw errorDealNotes; 129 | } 130 | 131 | console.log('Adding tasks...'); 132 | const tasks = generateTasks({ 133 | contacts: persistedContacts, 134 | }); 135 | const { error: errorTasks } = await supabase 136 | .from('tasks') 137 | .insert(tasks.map(({ id, ...task }) => task)); 138 | 139 | if (errorTasks) { 140 | throw errorTasks; 141 | } 142 | 143 | console.log('Adding users...'); 144 | for (const sale of sales) { 145 | const { error } = await supabase.auth.admin.createUser({ 146 | email: sale.email, 147 | password: 'password', 148 | email_confirm: true, 149 | }); 150 | 151 | if (error) { 152 | throw error; 153 | } 154 | } 155 | 156 | console.log('Data seeded successfully'); 157 | } 158 | 159 | main().catch(error => { 160 | console.error(error); 161 | 162 | process.exit(1); 163 | }); 164 | -------------------------------------------------------------------------------- /packages/demo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | CRM Demo 13 | 109 | 110 | 114 | 115 | 116 | 117 | 118 |
119 |
120 |
Loading...
121 |
122 |
123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /packages/demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-admin-crm", 3 | "version": "3.5.0", 4 | "private": true, 5 | "dependencies": { 6 | "date-fns": "^3.6.0", 7 | "faker": "~5.4.0", 8 | "ra-supabase": "^3.5.0", 9 | "react": "^18.2.0", 10 | "react-admin": "^5.8.0", 11 | "react-dom": "^18.2.0" 12 | }, 13 | "devDependencies": { 14 | "@types/faker": "~5.1.7", 15 | "@types/react": "^18.0.0", 16 | "@types/react-dom": "^18.0.0", 17 | "@vitejs/plugin-react": "^4.3.4", 18 | "source-map-explorer": "^2.0.0", 19 | "supabase": "^2.12.1", 20 | "tsx": "^4.19.2", 21 | "typescript": "^5.7.3", 22 | "vite": "^6.1.0", 23 | "web-vitals": "^1.0.1" 24 | }, 25 | "scripts": { 26 | "start": "vite --port 8000", 27 | "serve": "vite preview --port 8000", 28 | "build": "vite build", 29 | "test": "vite test", 30 | "lint": "eslint --fix ./src", 31 | "db-seed": "tsx db-seed.ts" 32 | }, 33 | "homepage": ".", 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/demo/public/auth-callback.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | Atomic CRM 13 | 109 | 110 | 111 | 112 | 113 |
114 |
115 |
Loading...
116 |
117 |
118 | 119 | 140 | 141 | -------------------------------------------------------------------------------- /packages/demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/favicon.ico -------------------------------------------------------------------------------- /packages/demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 14 | 15 | 24 | Atomic CRM 25 | 26 | 30 | 31 | 32 | 33 |
34 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /packages/demo/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logo192.png -------------------------------------------------------------------------------- /packages/demo/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logo512.png -------------------------------------------------------------------------------- /packages/demo/public/logos/0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/0.png -------------------------------------------------------------------------------- /packages/demo/public/logos/1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/1.png -------------------------------------------------------------------------------- /packages/demo/public/logos/10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/10.png -------------------------------------------------------------------------------- /packages/demo/public/logos/11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/11.png -------------------------------------------------------------------------------- /packages/demo/public/logos/12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/12.png -------------------------------------------------------------------------------- /packages/demo/public/logos/13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/13.png -------------------------------------------------------------------------------- /packages/demo/public/logos/14.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/14.png -------------------------------------------------------------------------------- /packages/demo/public/logos/15.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/15.png -------------------------------------------------------------------------------- /packages/demo/public/logos/16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/16.png -------------------------------------------------------------------------------- /packages/demo/public/logos/17.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/17.png -------------------------------------------------------------------------------- /packages/demo/public/logos/18.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/18.png -------------------------------------------------------------------------------- /packages/demo/public/logos/19.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/19.png -------------------------------------------------------------------------------- /packages/demo/public/logos/2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/2.png -------------------------------------------------------------------------------- /packages/demo/public/logos/20.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/20.png -------------------------------------------------------------------------------- /packages/demo/public/logos/21.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/21.png -------------------------------------------------------------------------------- /packages/demo/public/logos/22.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/22.png -------------------------------------------------------------------------------- /packages/demo/public/logos/23.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/23.png -------------------------------------------------------------------------------- /packages/demo/public/logos/24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/24.png -------------------------------------------------------------------------------- /packages/demo/public/logos/25.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/25.png -------------------------------------------------------------------------------- /packages/demo/public/logos/26.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/26.png -------------------------------------------------------------------------------- /packages/demo/public/logos/27.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/27.png -------------------------------------------------------------------------------- /packages/demo/public/logos/28.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/28.png -------------------------------------------------------------------------------- /packages/demo/public/logos/29.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/29.png -------------------------------------------------------------------------------- /packages/demo/public/logos/3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/3.png -------------------------------------------------------------------------------- /packages/demo/public/logos/30.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/30.png -------------------------------------------------------------------------------- /packages/demo/public/logos/31.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/31.png -------------------------------------------------------------------------------- /packages/demo/public/logos/32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/32.png -------------------------------------------------------------------------------- /packages/demo/public/logos/33.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/33.png -------------------------------------------------------------------------------- /packages/demo/public/logos/34.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/34.png -------------------------------------------------------------------------------- /packages/demo/public/logos/35.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/35.png -------------------------------------------------------------------------------- /packages/demo/public/logos/36.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/36.png -------------------------------------------------------------------------------- /packages/demo/public/logos/37.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/37.png -------------------------------------------------------------------------------- /packages/demo/public/logos/38.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/38.png -------------------------------------------------------------------------------- /packages/demo/public/logos/39.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/39.png -------------------------------------------------------------------------------- /packages/demo/public/logos/4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/4.png -------------------------------------------------------------------------------- /packages/demo/public/logos/40.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/40.png -------------------------------------------------------------------------------- /packages/demo/public/logos/41.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/41.png -------------------------------------------------------------------------------- /packages/demo/public/logos/42.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/42.png -------------------------------------------------------------------------------- /packages/demo/public/logos/43.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/43.png -------------------------------------------------------------------------------- /packages/demo/public/logos/44.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/44.png -------------------------------------------------------------------------------- /packages/demo/public/logos/45.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/45.png -------------------------------------------------------------------------------- /packages/demo/public/logos/46.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/46.png -------------------------------------------------------------------------------- /packages/demo/public/logos/47.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/47.png -------------------------------------------------------------------------------- /packages/demo/public/logos/48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/48.png -------------------------------------------------------------------------------- /packages/demo/public/logos/49.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/49.png -------------------------------------------------------------------------------- /packages/demo/public/logos/5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/5.png -------------------------------------------------------------------------------- /packages/demo/public/logos/50.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/50.png -------------------------------------------------------------------------------- /packages/demo/public/logos/51.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/51.png -------------------------------------------------------------------------------- /packages/demo/public/logos/52.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/52.png -------------------------------------------------------------------------------- /packages/demo/public/logos/53.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/53.png -------------------------------------------------------------------------------- /packages/demo/public/logos/54.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/54.png -------------------------------------------------------------------------------- /packages/demo/public/logos/55.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/55.png -------------------------------------------------------------------------------- /packages/demo/public/logos/6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/6.png -------------------------------------------------------------------------------- /packages/demo/public/logos/7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/7.png -------------------------------------------------------------------------------- /packages/demo/public/logos/8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/8.png -------------------------------------------------------------------------------- /packages/demo/public/logos/9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/demo/public/logos/9.png -------------------------------------------------------------------------------- /packages/demo/public/logos/Readme.md: -------------------------------------------------------------------------------- 1 | Logos from https://uilogos.co/ -------------------------------------------------------------------------------- /packages/demo/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 | -------------------------------------------------------------------------------- /packages/demo/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /packages/demo/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { AdminGuesser } from 'ra-supabase'; 2 | 3 | const App = () => ( 4 | 8 | ); 9 | 10 | export default App; 11 | -------------------------------------------------------------------------------- /packages/demo/src/dataGenerator/companies.ts: -------------------------------------------------------------------------------- 1 | import { company, internet, address, phone, random } from 'faker/locale/en_US'; 2 | import { randomDate } from './utils'; 3 | 4 | import { Db } from './types'; 5 | import { Company } from '../types'; 6 | 7 | const sectors = [ 8 | 'Communication Services', 9 | 'Consumer Discretionary', 10 | 'Consumer Staples', 11 | 'Energy', 12 | 'Financials', 13 | 'Health Care', 14 | 'Industrials', 15 | 'Information Technology', 16 | 'Materials', 17 | 'Real Estate', 18 | 'Utilities', 19 | ]; 20 | 21 | const sizes = [1, 10, 50, 250, 500]; 22 | 23 | const regex = /\W+/; 24 | 25 | export const generateCompanies = (db: Pick): Company[] => { 26 | return Array.from(Array(55).keys()).map(id => { 27 | const name = company.companyName(); 28 | return { 29 | id, 30 | name: name, 31 | logo: `/logos/${id}.png`, 32 | sector: random.arrayElement(sectors), 33 | size: random.arrayElement(sizes) as 1 | 10 | 50 | 250 | 500, 34 | linkedIn: `https://www.linkedin.com/company/${name 35 | .toLowerCase() 36 | .replace(regex, '_')}`, 37 | website: internet.url(), 38 | phone_number: phone.phoneNumber(), 39 | address: address.streetAddress(), 40 | zipcode: address.zipCode(), 41 | city: address.city(), 42 | stateAbbr: address.stateAbbr(), 43 | nb_contacts: 0, 44 | nb_deals: 0, 45 | // at least 1/3rd of companies for Jane Doe 46 | sales_id: 47 | random.number({ min: 0, max: 2 }) === 0 48 | ? 1 49 | : random.arrayElement(db.sales).id, 50 | created_at: randomDate().toISOString(), 51 | }; 52 | }); 53 | }; 54 | -------------------------------------------------------------------------------- /packages/demo/src/dataGenerator/contactNotes.ts: -------------------------------------------------------------------------------- 1 | import { random, lorem } from 'faker/locale/en_US'; 2 | 3 | import { Db } from './types'; 4 | import { ContactNote } from '../types'; 5 | import { randomDate } from './utils'; 6 | 7 | const status = ['cold', 'cold', 'cold', 'warm', 'warm', 'hot', 'in-contract']; 8 | 9 | export const generateContactNotes = ( 10 | db: Pick 11 | ): ContactNote[] => { 12 | return Array.from(Array(1200).keys()).map(id => { 13 | const contact = random.arrayElement(db.contacts); 14 | const date = randomDate(new Date(contact.first_seen)).toISOString(); 15 | contact.last_seen = date > contact.last_seen ? date : contact.last_seen; 16 | return { 17 | id, 18 | contact_id: contact.id, 19 | text: lorem.paragraphs(random.number({ min: 1, max: 4 })), 20 | date, 21 | sales_id: contact.sales_id, 22 | status: random.arrayElement(status), 23 | }; 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/demo/src/dataGenerator/contacts.ts: -------------------------------------------------------------------------------- 1 | import { 2 | name, 3 | internet, 4 | random, 5 | company as fakerCompany, 6 | phone, 7 | lorem, 8 | } from 'faker/locale/en_US'; 9 | 10 | import { randomDate, weightedBoolean } from './utils'; 11 | import { Db } from './types'; 12 | import { Contact } from '../types'; 13 | 14 | const genders = ['male', 'female']; 15 | const status = ['cold', 'cold', 'cold', 'warm', 'warm', 'hot', 'in-contract']; 16 | const maxContacts = { 17 | 1: 1, 18 | 10: 4, 19 | 50: 12, 20 | 250: 25, 21 | 500: 50, 22 | }; 23 | 24 | export const generateContacts = ( 25 | db: Pick 26 | ): Contact[] => { 27 | const nbAvailblePictures = 223; 28 | let numberOfContacts = 0; 29 | 30 | return Array.from(Array(500).keys()).map(id => { 31 | const has_avatar = 32 | weightedBoolean(25) && numberOfContacts < nbAvailblePictures; 33 | const gender = random.arrayElement(genders); 34 | const first_name = name.firstName(gender as any); 35 | const last_name = name.lastName(); 36 | const email = internet.email(first_name, last_name); 37 | const avatar = has_avatar 38 | ? 'https://marmelab.com/posters/avatar-' + 39 | (223 - numberOfContacts) + 40 | '.jpeg' 41 | : undefined; 42 | const title = fakerCompany.bsAdjective(); 43 | 44 | if (has_avatar) { 45 | numberOfContacts++; 46 | } 47 | 48 | // choose company with people left to know 49 | let company; 50 | do { 51 | company = random.arrayElement(db.companies); 52 | } while (company.nb_contacts >= maxContacts[company.size]); 53 | company.nb_contacts++; 54 | 55 | const first_seen = randomDate( 56 | new Date(company.created_at) 57 | ).toISOString(); 58 | const last_seen = first_seen; 59 | 60 | return { 61 | id, 62 | first_name, 63 | last_name, 64 | gender, 65 | title: title.charAt(0).toUpperCase() + title.substr(1), 66 | company_id: company.id, 67 | email, 68 | phone_number1: phone.phoneNumber(), 69 | phone_number2: phone.phoneNumber(), 70 | background: lorem.sentence(), 71 | acquisition: random.arrayElement(['inbound', 'outbound']), 72 | avatar, 73 | first_seen: first_seen, 74 | last_seen: last_seen, 75 | has_newsletter: weightedBoolean(30), 76 | status: random.arrayElement(status), 77 | tag_ids: random 78 | .arrayElements(db.tags, random.arrayElement([0, 0, 0, 1, 1, 2])) 79 | .map(tag => tag.id), // finalize 80 | sales_id: company.sales_id, 81 | }; 82 | }); 83 | }; 84 | -------------------------------------------------------------------------------- /packages/demo/src/dataGenerator/dealNotes.ts: -------------------------------------------------------------------------------- 1 | import { random, lorem } from 'faker/locale/en_US'; 2 | 3 | import { Db } from './types'; 4 | import { randomDate } from './utils'; 5 | 6 | const type = ['Email', 'Call', 'Call', 'Call', 'Call', 'Meeting', 'Reminder']; 7 | 8 | export const generateDealNotes = (db: Pick) => { 9 | return Array.from(Array(300).keys()).map(id => { 10 | const deal = random.arrayElement(db.deals); 11 | const company = db.companies[deal.company_id as number]; 12 | const date = company 13 | ? randomDate(new Date(company.created_at)).toISOString() 14 | : randomDate().toISOString(); 15 | return { 16 | id, 17 | deal_id: deal.id, 18 | type: random.arrayElement(type), 19 | text: lorem.paragraphs(random.number({ min: 1, max: 4 })), 20 | date, 21 | sales_id: deal.sales_id, 22 | }; 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/demo/src/dataGenerator/deals.ts: -------------------------------------------------------------------------------- 1 | import { random, lorem } from 'faker/locale/en_US'; 2 | import { add } from 'date-fns'; 3 | 4 | import { Db } from './types'; 5 | import { Deal } from '../types'; 6 | import { randomDate } from './utils'; 7 | 8 | const type = [ 9 | 'Other', 10 | 'Copywriting', 11 | 'Print project', 12 | 'UI Design', 13 | 'Website design', 14 | ]; 15 | const stages = [ 16 | 'opportunity', 17 | 'proposal-sent', 18 | 'in-negociation', 19 | 'won', 20 | 'lost', 21 | 'delayed', 22 | ]; 23 | //const tags = ["new deal", "upsell", "SAV"]; 24 | 25 | export const generateDeals = ( 26 | db: Pick 27 | ): Deal[] => { 28 | const deals = Array.from(Array(50).keys()).map(id => { 29 | const company = random.arrayElement(db.companies); 30 | company.nb_deals++; 31 | const contacts = random.arrayElements( 32 | db.contacts.filter(contact => contact.company_id === company.id), 33 | random.number({ min: 1, max: 3 }) 34 | ); 35 | const lowercaseName = lorem.words(); 36 | const created_at = randomDate( 37 | new Date(company.created_at) 38 | ).toISOString(); 39 | return { 40 | id, 41 | name: lowercaseName[0].toUpperCase() + lowercaseName.slice(1), 42 | company_id: company.id, 43 | contact_ids: contacts.map(contact => contact.id), 44 | type: random.arrayElement(type), 45 | stage: random.arrayElement(stages), 46 | description: lorem.paragraphs(random.number({ min: 1, max: 4 })), 47 | amount: random.number(1000) * 100, 48 | created_at: created_at, 49 | updated_at: randomDate(new Date(created_at)).toISOString(), 50 | start_at: randomDate( 51 | new Date(), 52 | add(new Date(), { months: 6 }) 53 | ).toISOString(), 54 | sales_id: company.sales_id, 55 | index: 0, 56 | }; 57 | }); 58 | // compute index based on stage 59 | stages.forEach(stage => { 60 | deals 61 | .filter(deal => deal.stage === stage) 62 | .forEach((deal, index) => { 63 | deals[deal.id].index = index; 64 | }); 65 | }); 66 | return deals; 67 | }; 68 | -------------------------------------------------------------------------------- /packages/demo/src/dataGenerator/finalize.ts: -------------------------------------------------------------------------------- 1 | import { Db } from './types'; 2 | 3 | export const finalize = (db: Db) => { 4 | // set contact status according to the latest note 5 | db.contactNotes 6 | .sort((a, b) => new Date(a.date).valueOf() - new Date(b.date).valueOf()) 7 | .forEach(note => { 8 | db.contacts[note.contact_id as number].status = note.status; 9 | }); 10 | }; 11 | -------------------------------------------------------------------------------- /packages/demo/src/dataGenerator/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/no-anonymous-default-export */ 2 | import { generateSales } from './sales'; 3 | import { generateTags } from './tags'; 4 | import { generateCompanies } from './companies'; 5 | import { generateContacts } from './contacts'; 6 | import { generateContactNotes } from './contactNotes'; 7 | import { generateTasks } from './tasks'; 8 | import { generateDeals } from './deals'; 9 | import { generateDealNotes } from './dealNotes'; 10 | import { finalize } from './finalize'; 11 | import { Db } from './types'; 12 | 13 | export default (): Db => { 14 | const db = {} as Db; 15 | db.sales = generateSales(db); 16 | db.tags = generateTags(db); 17 | db.companies = generateCompanies(db); 18 | db.contacts = generateContacts(db); 19 | db.contactNotes = generateContactNotes(db); 20 | db.deals = generateDeals(db); 21 | db.dealNotes = generateDealNotes(db); 22 | db.tasks = generateTasks(db); 23 | finalize(db); 24 | 25 | return db; 26 | }; 27 | -------------------------------------------------------------------------------- /packages/demo/src/dataGenerator/sales.ts: -------------------------------------------------------------------------------- 1 | import { name, internet } from 'faker/locale/en_US'; 2 | 3 | export const generateSales = () => { 4 | const randomSales = Array.from(Array(10).keys()).map(id => { 5 | const first_name = name.firstName(); 6 | const last_name = name.lastName(); 7 | const email = internet.email(first_name, last_name); 8 | 9 | return { 10 | id: id + 1, 11 | first_name, 12 | last_name, 13 | email, 14 | }; 15 | }); 16 | return [ 17 | { 18 | id: 0, 19 | first_name: 'Jane', 20 | last_name: 'Doe', 21 | email: 'janedoe@atomic.dev', 22 | }, 23 | ...randomSales, 24 | ]; 25 | }; 26 | -------------------------------------------------------------------------------- /packages/demo/src/dataGenerator/tags.ts: -------------------------------------------------------------------------------- 1 | // --champagne-pink: #eddcd2ff; 2 | // --linen: #fff1e6ff; 3 | // --pale-pink: #fde2e4ff; 4 | // --mimi-pink: #fad2e1ff; 5 | // --powder-blue: #c5deddff; 6 | // --mint-cream: #dbe7e4ff; 7 | // --isabelline: #f0efebff; 8 | // --alice-blue: #d6e2e9ff; 9 | // --beau-blue: #bcd4e6ff; 10 | // --pale-cerulean: #99c1deff; 11 | 12 | const tags = [ 13 | { id: 0, name: 'football-fan', color: '#eddcd2' }, 14 | { id: 1, name: 'holiday-card', color: '#fff1e6' }, 15 | { id: 2, name: 'influencer', color: '#fde2e4' }, 16 | { id: 3, name: 'manager', color: '#fad2e1' }, 17 | { id: 4, name: 'musician', color: '#c5dedd' }, 18 | { id: 5, name: 'vip', color: '#dbe7e4' }, 19 | ]; 20 | 21 | export const generateTags = () => { 22 | return [...tags]; 23 | }; 24 | -------------------------------------------------------------------------------- /packages/demo/src/dataGenerator/tasks.ts: -------------------------------------------------------------------------------- 1 | import { random, lorem } from 'faker/locale/en_US'; 2 | 3 | import { Db } from './types'; 4 | import { randomDate } from './utils'; 5 | 6 | const type = [ 7 | 'Email', 8 | 'Email', 9 | 'Email', 10 | 'Email', 11 | 'Email', 12 | 'Email', 13 | 'Call', 14 | 'Call', 15 | 'Call', 16 | 'Call', 17 | 'Call', 18 | 'Call', 19 | 'Call', 20 | 'Call', 21 | 'Call', 22 | 'Call', 23 | 'Call', 24 | 'Demo', 25 | 'Lunch', 26 | 'Meeting', 27 | 'Follow-up', 28 | 'Follow-up', 29 | 'Thank you', 30 | 'Ship', 31 | 'None', 32 | ]; 33 | 34 | export const generateTasks = (db: Pick) => { 35 | return Array.from(Array(400).keys()).map(id => { 36 | const contact = random.arrayElement(db.contacts); 37 | return { 38 | id, 39 | contact_id: contact.id, 40 | type: random.arrayElement(type), 41 | text: lorem.sentence(), 42 | due_date: randomDate(new Date(contact.first_seen)), 43 | sales_id: contact.sales_id, 44 | }; 45 | }); 46 | }; 47 | -------------------------------------------------------------------------------- /packages/demo/src/dataGenerator/types.ts: -------------------------------------------------------------------------------- 1 | import { RaRecord } from 'react-admin'; 2 | import { Company, Contact, ContactNote, Deal, Sale, Tag } from '../types'; 3 | 4 | export interface Db { 5 | companies: Company[]; 6 | contacts: Contact[]; 7 | contactNotes: ContactNote[]; 8 | deals: Deal[]; 9 | dealNotes: RaRecord[]; 10 | sales: Sale[]; 11 | tags: Tag[]; 12 | tasks: RaRecord[]; 13 | } 14 | -------------------------------------------------------------------------------- /packages/demo/src/dataGenerator/utils.ts: -------------------------------------------------------------------------------- 1 | import faker from 'faker/locale/en'; 2 | 3 | export const weightedArrayElement = (values: any[], weights: any) => 4 | faker.random.arrayElement( 5 | values.reduce( 6 | (acc, value, index) => 7 | acc.concat(new Array(weights[index]).fill(value)), 8 | [] 9 | ) 10 | ); 11 | 12 | export const weightedBoolean = (likelyhood: number) => 13 | faker.random.number(99) < likelyhood; 14 | 15 | export const randomDate = (minDate?: Date, maxDate?: Date) => { 16 | const minTs = 17 | minDate instanceof Date 18 | ? minDate.getTime() 19 | : Date.now() - 5 * 365 * 24 * 60 * 60 * 1000; // 5 years 20 | const maxTs = maxDate instanceof Date ? maxDate.getTime() : Date.now(); 21 | const range = maxTs - minTs; 22 | const randomRange = faker.random.number({ max: range }); 23 | // move it more towards today to account for traffic increase 24 | const ts = Math.sqrt(randomRange / range) * range; 25 | return new Date(minTs + ts); 26 | }; 27 | 28 | export const randomFloat = (min: number, max: number) => 29 | parseFloat(faker.random.number({ min, max, precision: 0.01 }).toFixed(2)); 30 | -------------------------------------------------------------------------------- /packages/demo/src/env.d.ts: -------------------------------------------------------------------------------- 1 | interface ImportMetaEnv { 2 | VITE_SUPABASE_URL: string; 3 | VITE_SUPABASE_ANON_KEY: string; 4 | } 5 | 6 | interface ImportMeta { 7 | readonly env: ImportMetaEnv; 8 | } 9 | -------------------------------------------------------------------------------- /packages/demo/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { createRoot } from 'react-dom/client'; 3 | import App from './App'; 4 | import reportWebVitals from './reportWebVitals'; 5 | 6 | const root = createRoot(document.getElementById('root') as HTMLElement); 7 | root.render( 8 | 9 | 10 | 11 | ); 12 | 13 | // If you want to start measuring performance in your app, pass a function 14 | // to log results (for example: reportWebVitals(console.log)) 15 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 16 | reportWebVitals(); 17 | -------------------------------------------------------------------------------- /packages/demo/src/logo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /packages/demo/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /packages/demo/src/reportWebVitals.js: -------------------------------------------------------------------------------- 1 | const reportWebVitals = onPerfEntry => { 2 | if (onPerfEntry && onPerfEntry instanceof Function) { 3 | import('web-vitals').then( 4 | ({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 5 | getCLS(onPerfEntry); 6 | getFID(onPerfEntry); 7 | getFCP(onPerfEntry); 8 | getLCP(onPerfEntry); 9 | getTTFB(onPerfEntry); 10 | } 11 | ); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /packages/demo/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /packages/demo/src/types.ts: -------------------------------------------------------------------------------- 1 | import { Identifier, RaRecord } from 'react-admin'; 2 | 3 | export interface Sale extends RaRecord { 4 | first_name: string; 5 | last_name: string; 6 | email: string; 7 | } 8 | 9 | export interface Company extends RaRecord { 10 | name: string; 11 | logo: string; 12 | sector: string; 13 | size: 1 | 10 | 50 | 250 | 500; 14 | linkedIn: string; 15 | website: string; 16 | phone_number: string; 17 | address: string; 18 | zipcode: string; 19 | city: string; 20 | stateAbbr: string; 21 | nb_contacts: number; 22 | nb_deals: number; 23 | sales_id: Identifier; 24 | created_at: string; 25 | } 26 | 27 | export interface Contact extends RaRecord { 28 | first_name: string; 29 | last_name: string; 30 | title: string; 31 | company_id: Identifier; 32 | email: string; 33 | avatar?: string; 34 | first_seen: string; 35 | last_seen: string; 36 | has_newsletter: Boolean; 37 | tag_ids: Identifier[]; 38 | gender: string; 39 | sales_id: Identifier; 40 | status: string; 41 | background: string; 42 | } 43 | 44 | export interface ContactNote extends RaRecord { 45 | contact_id: Identifier; 46 | text: string; 47 | date: string; 48 | sales_id: Identifier; 49 | status: string; 50 | } 51 | 52 | export interface Deal extends RaRecord { 53 | name: string; 54 | company_id: Identifier; 55 | contact_ids: Identifier[]; 56 | type: string; 57 | stage: string; 58 | description: string; 59 | amount: number; 60 | created_at: string; 61 | updated_at: string; 62 | start_at: string; 63 | sales_id: Identifier; 64 | index: number; 65 | } 66 | 67 | export interface Tag extends RaRecord { 68 | name: string; 69 | color: string; 70 | } 71 | -------------------------------------------------------------------------------- /packages/demo/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 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /packages/demo/vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite'; 2 | import path from 'path'; 3 | import fs from 'fs'; 4 | import react from '@vitejs/plugin-react'; 5 | 6 | const packages = fs.readdirSync(path.resolve(__dirname, '../../packages')); 7 | const aliases = packages.reduce((acc, dirName) => { 8 | if (dirName === 'demo') return acc; 9 | const packageJson = require(path.resolve( 10 | __dirname, 11 | '../../packages', 12 | dirName, 13 | 'package.json' 14 | )); 15 | acc[packageJson.name] = path.resolve( 16 | __dirname, 17 | `${path.resolve('../..')}/packages/${packageJson.name}/src` 18 | ); 19 | return acc; 20 | }, {}); 21 | 22 | // https://vitejs.dev/config/ 23 | export default defineConfig({ 24 | plugins: [react()], 25 | define: { 26 | 'process.env': process.env, 27 | }, 28 | server: { 29 | port: 8000, 30 | open: true, 31 | }, 32 | base: './', 33 | esbuild: { 34 | keepNames: true, 35 | }, 36 | build: { 37 | sourcemap: true, 38 | }, 39 | resolve: { 40 | preserveSymlinks: true, 41 | alias: [ 42 | { 43 | find: 'ra-core', 44 | replacement: path.resolve( 45 | __dirname, 46 | '../../node_modules/ra-core/src' 47 | ), 48 | }, 49 | { 50 | find: 'ra-i18n-polyglot', 51 | replacement: path.resolve( 52 | __dirname, 53 | '../../node_modules/ra-i18n-polyglot/src' 54 | ), 55 | }, 56 | { 57 | find: 'ra-language-english', 58 | replacement: path.resolve( 59 | __dirname, 60 | '../../node_modules/ra-language-english/src' 61 | ), 62 | }, 63 | { 64 | find: 'ra-ui-materialui', 65 | replacement: path.resolve( 66 | __dirname, 67 | '../../node_modules/ra-ui-materialui/src' 68 | ), 69 | }, 70 | { 71 | find: 'react-admin', 72 | replacement: path.resolve( 73 | __dirname, 74 | '../../node_modules/react-admin/src' 75 | ), 76 | }, 77 | // we need to manually follow the symlinks for local packages to allow deep HMR 78 | ...Object.keys(aliases).map(packageName => ({ 79 | find: packageName, 80 | replacement: aliases[packageName], 81 | })), 82 | ], 83 | }, 84 | }); 85 | -------------------------------------------------------------------------------- /packages/ra-supabase-core/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-react', 5 | '@babel/preset-typescript', 6 | ], 7 | }; 8 | -------------------------------------------------------------------------------- /packages/ra-supabase-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ra-supabase-core", 3 | "version": "3.5.0", 4 | "repository": "git@github.com:marmelab/ra-supabase.git", 5 | "author": "Gildas Garcia <1122076+djhi@users.noreply.github.com>", 6 | "license": "MIT", 7 | "files": [ 8 | "*.md", 9 | "lib", 10 | "esm", 11 | "src" 12 | ], 13 | "main": "lib/index", 14 | "module": "esm/index.js", 15 | "types": "esm/index.d.ts", 16 | "sideEffects": false, 17 | "peerDependencies": { 18 | "ra-core": "^5.8.0" 19 | }, 20 | "dependencies": { 21 | "@raphiniert/ra-data-postgrest": "^2.4.1", 22 | "@supabase/supabase-js": "^2.48.1", 23 | "openapi-types": "^12.1.3" 24 | }, 25 | "devDependencies": { 26 | "@testing-library/jest-dom": "^6.4.5", 27 | "@testing-library/react": "^15.0.7", 28 | "@testing-library/user-event": "^14.5.2", 29 | "ra-core": "^5.8.0", 30 | "react": "^18.3.1", 31 | "react-dom": "^18.3.1", 32 | "react-router": "^6.28.1", 33 | "react-router-dom": "^6.28.1", 34 | "rimraf": "^6.0.1", 35 | "typescript": "^5.7.3" 36 | }, 37 | "scripts": { 38 | "build": "yarn run build-cjs && yarn run build-esm", 39 | "build-cjs": "rimraf ./lib && tsc", 40 | "build-esm": "rimraf ./esm && tsc --outDir esm --module es2015", 41 | "watch": "tsc --outDir esm --module es2015 --watch", 42 | "lint": "eslint --fix ./src", 43 | "test-unit": "jest ./src" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/ra-supabase-core/src/dataProvider.ts: -------------------------------------------------------------------------------- 1 | import { DataProvider, fetchUtils } from 'ra-core'; 2 | import postgrestRestProvider, { 3 | IDataProviderConfig, 4 | defaultPrimaryKeys, 5 | defaultSchema, 6 | } from '@raphiniert/ra-data-postgrest'; 7 | import { createClient } from '@supabase/supabase-js'; 8 | import type { SupabaseClient } from '@supabase/supabase-js'; 9 | import type { OpenAPIV2 } from 'openapi-types'; 10 | 11 | /** 12 | * A function that returns a dataProvider for Supabase. 13 | * @param instanceUrl The URL of the Supabase instance 14 | * @param apiKey The API key of the Supabase instance. Prefer the anonymous key. 15 | * @param supabaseClient The Supabase client 16 | * @param httpClient Optional - The httpClient to use. Defaults to a httpClient that handles the authentication. 17 | * @param defaultListOp Optional - The default list filter operator. Defaults to 'eq'. 18 | * @param primaryKeys Optional - The primary keys of the tables. Defaults to 'id'. 19 | * @param schema Optional - The custom schema to use. Defaults to none. 20 | * @returns A dataProvider for Supabase 21 | */ 22 | export const supabaseDataProvider = ({ 23 | instanceUrl, 24 | apiKey, 25 | supabaseClient = createClient(instanceUrl, apiKey), 26 | httpClient = supabaseHttpClient({ apiKey, supabaseClient }), 27 | defaultListOp = 'eq', 28 | primaryKeys = defaultPrimaryKeys, 29 | schema = defaultSchema, 30 | ...rest 31 | }: { 32 | instanceUrl: string; 33 | apiKey: string; 34 | supabaseClient?: SupabaseClient; 35 | } & Partial>): DataProvider => { 36 | const config: IDataProviderConfig = { 37 | apiUrl: `${instanceUrl}/rest/v1`, 38 | httpClient, 39 | defaultListOp, 40 | primaryKeys, 41 | schema, 42 | ...rest, 43 | }; 44 | return { 45 | supabaseClient: (url: string, options?: any) => 46 | httpClient(`${config.apiUrl}/${url}`, options), 47 | getSchema: async (): Promise => { 48 | const { json } = await httpClient(`${config.apiUrl}/`, {}); 49 | if (!json || !json.swagger) { 50 | throw new Error('The Open API schema is not readable'); 51 | } 52 | return json; 53 | }, 54 | ...postgrestRestProvider(config), 55 | }; 56 | }; 57 | 58 | /** 59 | * A function that returns a httpClient for Supabase. It handles the authentication. 60 | * @param apiKey The API key of the Supabase instance. Prefer the anonymous key. 61 | * @param supabaseClient The Supabase client 62 | * @returns A httpClient for Supabase 63 | */ 64 | export const supabaseHttpClient = 65 | ({ 66 | apiKey, 67 | supabaseClient, 68 | }: { 69 | apiKey: string; 70 | supabaseClient: SupabaseClient; 71 | }) => 72 | async (url: string, options: any = {}) => { 73 | const { data } = await supabaseClient.auth.getSession(); 74 | if (!options.headers) options.headers = new Headers({}); 75 | 76 | if (supabaseClient['headers']) { 77 | Object.entries(supabaseClient['headers']).forEach(([name, value]) => 78 | options.headers.set(name, value) 79 | ); 80 | } 81 | if (data.session) { 82 | options.user = { 83 | authenticated: true, 84 | // This ensures that users are identified correctly and that RLS can be applied 85 | token: `Bearer ${data.session.access_token}`, 86 | }; 87 | } 88 | // Always send the apiKey even if there isn't a session 89 | options.headers.set('apiKey', apiKey); 90 | 91 | return fetchUtils.fetchJson(url, options); 92 | }; 93 | -------------------------------------------------------------------------------- /packages/ra-supabase-core/src/getSearchString.ts: -------------------------------------------------------------------------------- 1 | export function getSearchString() { 2 | const search = window.location.search; 3 | const hash = window.location.hash.substring(1); 4 | 5 | return search && search !== '' 6 | ? search 7 | : hash.includes('?') 8 | ? hash.split('?')[1] 9 | : hash; 10 | } 11 | -------------------------------------------------------------------------------- /packages/ra-supabase-core/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './authProvider'; 2 | export * from './dataProvider'; 3 | export * from './useAPISchema'; 4 | export * from './useRedirectIfAuthenticated'; 5 | export * from './useResetPassword'; 6 | export * from './useSetPassword'; 7 | export * from './useSupabaseAccessToken'; 8 | -------------------------------------------------------------------------------- /packages/ra-supabase-core/src/useAPISchema.ts: -------------------------------------------------------------------------------- 1 | import { useDataProvider } from 'react-admin'; 2 | import { useQuery } from '@tanstack/react-query'; 3 | import type { UseQueryOptions } from '@tanstack/react-query'; 4 | import type { OpenAPIV2 } from 'openapi-types'; 5 | 6 | export const useAPISchema = ({ 7 | options, 8 | }: { 9 | options?: Partial< 10 | Omit, 'queryKey' | 'queryFn'> 11 | >; 12 | } = {}) => { 13 | const dataProvider = useDataProvider(); 14 | if (!dataProvider.getSchema) { 15 | throw new Error( 16 | "The data provider doesn't have access to the database schema" 17 | ); 18 | } 19 | return useQuery({ 20 | queryKey: ['getSchema'], 21 | queryFn: () => dataProvider.getSchema() as Promise, 22 | staleTime: 1000 * 60, // 1 minute 23 | ...options, 24 | }); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/ra-supabase-core/src/useRedirectIfAuthenticated.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { CoreAdminContext, TestMemoryRouter } from 'ra-core'; 3 | import { render, waitFor } from '@testing-library/react'; 4 | import { 5 | useRedirectIfAuthenticated, 6 | UseRedirectIfAuthenticatedOptions, 7 | } from './useRedirectIfAuthenticated'; 8 | 9 | describe('useRedirectIfAuthenticated', () => { 10 | const UseRedirectIfAuthenticated = ({ 11 | redirectTo, 12 | }: { 13 | redirectTo?: UseRedirectIfAuthenticatedOptions; 14 | }) => { 15 | useRedirectIfAuthenticated(redirectTo); 16 | 17 | return null; 18 | }; 19 | 20 | test('should not redirect users if they are not authenticated', async () => { 21 | const authProvider = { 22 | login: jest.fn(), 23 | logout: jest.fn(), 24 | checkAuth: jest 25 | .fn() 26 | .mockRejectedValue(new Error('Not authenticated')), 27 | checkError: jest.fn(), 28 | getPermissions: jest.fn(), 29 | setPassword: jest.fn(), 30 | }; 31 | 32 | let location; 33 | render( 34 | { 37 | location = l; 38 | }} 39 | > 40 | 41 | 42 | 43 | 44 | ); 45 | 46 | expect(authProvider.checkAuth).toHaveBeenCalled(); 47 | expect(location).toEqual( 48 | expect.objectContaining({ 49 | hash: '', 50 | pathname: '/login', 51 | search: '', 52 | }) 53 | ); 54 | }); 55 | 56 | test('should redirect users if they are authenticated', async () => { 57 | const authProvider = { 58 | login: jest.fn(), 59 | logout: jest.fn(), 60 | checkAuth: jest.fn().mockResolvedValue(undefined), 61 | checkError: jest.fn(), 62 | getPermissions: jest.fn(), 63 | setPassword: jest.fn(), 64 | }; 65 | 66 | let location; 67 | render( 68 | { 71 | location = l; 72 | }} 73 | > 74 | 75 | 76 | 77 | 78 | ); 79 | 80 | expect(authProvider.checkAuth).toHaveBeenCalled(); 81 | await waitFor(() => { 82 | expect(location).toEqual( 83 | expect.objectContaining({ 84 | hash: '', 85 | pathname: '/', 86 | search: '', 87 | }) 88 | ); 89 | }); 90 | }); 91 | 92 | test('should redirect users to the provided path if they are authenticated', async () => { 93 | const authProvider = { 94 | login: jest.fn(), 95 | logout: jest.fn(), 96 | checkAuth: jest.fn().mockResolvedValue(undefined), 97 | checkError: jest.fn(), 98 | getPermissions: jest.fn(), 99 | setPassword: jest.fn(), 100 | }; 101 | 102 | let location; 103 | render( 104 | { 107 | location = l; 108 | }} 109 | > 110 | 111 | 112 | 113 | 114 | ); 115 | 116 | expect(authProvider.checkAuth).toHaveBeenCalled(); 117 | await waitFor(() => { 118 | expect(location).toEqual( 119 | expect.objectContaining({ 120 | hash: '', 121 | pathname: '/dashboard', 122 | search: '', 123 | }) 124 | ); 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /packages/ra-supabase-core/src/useRedirectIfAuthenticated.ts: -------------------------------------------------------------------------------- 1 | import { useCheckAuth } from 'ra-core'; 2 | import { useEffect } from 'react'; 3 | import { To, useNavigate } from 'react-router'; 4 | 5 | /** 6 | * This hook redirect the user to the provided path (/ by default) if they are authenticated. 7 | * 8 | * @example 9 | * import { useRedirectIfAuthenticated } from 'react-admin'; 10 | * const MyLoginPage = () => { 11 | * useRedirectIfAuthenticated(); 12 | * // UI and logic for authentication 13 | * } 14 | **/ 15 | export const useRedirectIfAuthenticated = ( 16 | redirectTo: UseRedirectIfAuthenticatedOptions = '/' 17 | ) => { 18 | const navigate = useNavigate(); 19 | const checkAuth = useCheckAuth(); 20 | 21 | useEffect(() => { 22 | checkAuth({}, false, undefined) 23 | .then(() => { 24 | // already authenticated, redirect to the home page 25 | navigate(redirectTo); 26 | }) 27 | .catch(() => { 28 | // not authenticated, stay on the login page 29 | }); 30 | }, [checkAuth, navigate, redirectTo]); 31 | }; 32 | 33 | export type UseRedirectIfAuthenticatedOptions = To; 34 | -------------------------------------------------------------------------------- /packages/ra-supabase-core/src/useResetPassword.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OnError, 3 | OnSuccess, 4 | useAuthProvider, 5 | useNotify, 6 | useRedirect, 7 | } from 'ra-core'; 8 | import { useMutation, UseMutationResult } from '@tanstack/react-query'; 9 | import { ResetPasswordParams, SupabaseAuthProvider } from './authProvider'; 10 | 11 | /** 12 | * This hook returns a function to call in order to reset a user password on Supabase. 13 | * 14 | * @example 15 | * import { useSupabaseAccessToken } from 'ra-supabase-core'; 16 | * 17 | * const ResetPasswordPage = () => { 18 | * const [resetPassword] = useResetPassword(); 19 | * 20 | * const handleSubmit = event => { 21 | * resetPassword({ 22 | * email: event.currentTarget.elements.email.value, 23 | * }); 24 | * }; 25 | * 26 | * return ( 27 | *
28 | * 29 | * 30 | * 31 | *
32 | * ); 33 | * }; 34 | **/ 35 | export const useResetPassword = ( 36 | options?: UseResetPasswordOptions 37 | ): [ 38 | UseMutationResult['mutate'], 39 | UseMutationResult 40 | ] => { 41 | const notify = useNotify(); 42 | const redirect = useRedirect(); 43 | const authProvider = useAuthProvider(); 44 | 45 | if (authProvider == null) { 46 | throw new Error( 47 | 'No authProvider found. Did you forget to set up an AuthProvider on the component?' 48 | ); 49 | } 50 | 51 | if (authProvider.resetPassword == null) { 52 | throw new Error( 53 | 'The setPassword() method is missing from the AuthProvider although it is required. You may consider adding it' 54 | ); 55 | } 56 | 57 | const { 58 | onSuccess = () => { 59 | redirect('/login'); 60 | notify('ra-supabase.auth.password_reset', { type: 'info' }); 61 | }, 62 | onError = error => notify(error.message, { type: 'error' }), 63 | } = options || {}; 64 | 65 | const mutation = useMutation({ 66 | mutationFn: params => { 67 | return authProvider.resetPassword(params); 68 | }, 69 | onSuccess, 70 | onError, 71 | retry: false, 72 | }); 73 | 74 | return [mutation.mutate, mutation]; 75 | }; 76 | 77 | export type UseResetPasswordOptions = { 78 | onSuccess?: OnSuccess; 79 | onError?: OnError; 80 | }; 81 | -------------------------------------------------------------------------------- /packages/ra-supabase-core/src/useSetPassword.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { useEffect } from 'react'; 3 | import { CoreAdminContext } from 'ra-core'; 4 | import { render, waitFor } from '@testing-library/react'; 5 | import { useSetPassword, UseSetPasswordOptions } from './useSetPassword'; 6 | 7 | describe('useSetPassword', () => { 8 | const UseSetPassword = ({ onSuccess, onError }: UseSetPasswordOptions) => { 9 | const [setPassword] = useSetPassword({ 10 | onSuccess, 11 | onError, 12 | }); 13 | 14 | useEffect(() => { 15 | setPassword({ 16 | access_token: 'token', 17 | refresh_token: 'refresh', 18 | password: 'bazinga', 19 | }); 20 | }, [setPassword]); 21 | 22 | return null; 23 | }; 24 | 25 | test('should accept a custom onSuccess function', async () => { 26 | const authProvider = { 27 | login: jest.fn(), 28 | logout: jest.fn(), 29 | checkAuth: jest.fn(), 30 | checkError: jest.fn(), 31 | getPermissions: jest.fn(), 32 | setPassword: jest.fn().mockResolvedValue(undefined), 33 | }; 34 | const myOnSuccess = jest.fn(); 35 | 36 | render( 37 | 38 | 39 | 40 | ); 41 | 42 | await waitFor(() => { 43 | expect(authProvider.setPassword).toHaveBeenCalledWith({ 44 | access_token: 'token', 45 | refresh_token: 'refresh', 46 | password: 'bazinga', 47 | }); 48 | }); 49 | 50 | await waitFor(() => { 51 | expect(myOnSuccess).toHaveBeenCalledTimes(1); 52 | }); 53 | }); 54 | 55 | test('should accept a custom onError function', async () => { 56 | const error = new Error('boo'); 57 | const authProvider = { 58 | login: jest.fn(), 59 | logout: jest.fn(), 60 | checkAuth: jest.fn(), 61 | checkError: jest.fn(), 62 | getPermissions: jest.fn(), 63 | setPassword: jest.fn().mockRejectedValue(error), 64 | }; 65 | const myOnError = jest.fn(); 66 | 67 | render( 68 | 69 | 70 | 71 | ); 72 | 73 | await waitFor(() => { 74 | expect(authProvider.setPassword).toHaveBeenCalledWith({ 75 | access_token: 'token', 76 | refresh_token: 'refresh', 77 | password: 'bazinga', 78 | }); 79 | }); 80 | await waitFor(() => { 81 | expect(myOnError).toHaveBeenCalledWith( 82 | error, 83 | { 84 | access_token: 'token', 85 | refresh_token: 'refresh', 86 | password: 'bazinga', 87 | }, 88 | undefined 89 | ); 90 | }); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /packages/ra-supabase-core/src/useSetPassword.ts: -------------------------------------------------------------------------------- 1 | import { 2 | OnError, 3 | OnSuccess, 4 | useAuthProvider, 5 | useNotify, 6 | useRedirect, 7 | } from 'ra-core'; 8 | import { useMutation, UseMutationResult } from '@tanstack/react-query'; 9 | import { SetPasswordParams, SupabaseAuthProvider } from './authProvider'; 10 | 11 | /** 12 | * This hook returns a function to call in order to set a user password on Supabase. 13 | * 14 | * @example 15 | * import { useSupabaseAccessToken } from 'ra-supabase-core'; 16 | * 17 | * const SetPasswordPage = () => { 18 | * const access_token = useSupabaseAccessToken(); 19 | * const setPassword = useSetPassword(); 20 | * 21 | * const handleSubmit = event => { 22 | * setPassword({ 23 | * access_token, 24 | * password: event.currentTarget.elements.password.value, 25 | * }); 26 | * }; 27 | * 28 | * return ( 29 | *
30 | * 31 | * 32 | * 33 | *
34 | * ); 35 | * }; 36 | **/ 37 | export const useSetPassword = ( 38 | options?: UseSetPasswordOptions 39 | ): [ 40 | UseMutationResult['mutate'], 41 | UseMutationResult 42 | ] => { 43 | const notify = useNotify(); 44 | const redirect = useRedirect(); 45 | const authProvider = useAuthProvider(); 46 | 47 | if (authProvider == null) { 48 | throw new Error( 49 | 'No authProvider found. Did you forget to set up an AuthProvider on the component?' 50 | ); 51 | } 52 | 53 | if (authProvider.setPassword == null) { 54 | throw new Error( 55 | 'The setPassword() method is missing from the AuthProvider although it is required. You may consider adding it' 56 | ); 57 | } 58 | 59 | const { 60 | onSuccess = () => redirect('/'), 61 | onError = error => notify(error.message, { type: 'error' }), 62 | } = options || {}; 63 | 64 | const mutation = useMutation({ 65 | mutationFn: params => { 66 | return authProvider.setPassword(params); 67 | }, 68 | onSuccess, 69 | onError, 70 | retry: false, 71 | }); 72 | 73 | return [mutation.mutate, mutation]; 74 | }; 75 | 76 | export type UseSetPasswordOptions = { 77 | onSuccess?: OnSuccess; 78 | onError?: OnError; 79 | }; 80 | -------------------------------------------------------------------------------- /packages/ra-supabase-core/src/useSupabaseAccessToken.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render, waitFor } from '@testing-library/react'; 3 | import { CoreAdminContext, TestMemoryRouter } from 'ra-core'; 4 | import { 5 | useSupabaseAccessToken, 6 | UseSupabaseAccessTokenOptions, 7 | } from './useSupabaseAccessToken'; 8 | 9 | // TODO: fix those tests 10 | describe('useSupabaseAccessToken', () => { 11 | const UseSupabaseAccessToken = (props?: UseSupabaseAccessTokenOptions) => { 12 | const token = useSupabaseAccessToken(props); 13 | 14 | return {token}; 15 | }; 16 | 17 | test('should return the access token if present in the URL', async () => { 18 | window.history.pushState( 19 | {}, 20 | 'React Admin', 21 | '/set-password?access_token=bazinga' 22 | ); 23 | 24 | const { queryByText } = render( 25 | 26 | 27 | 28 | 29 | 30 | ); 31 | 32 | await waitFor(() => { 33 | expect(queryByText('bazinga')).not.toBeNull(); 34 | }); 35 | }); 36 | 37 | test('should return the access token if present in the hash route', async () => { 38 | window.history.pushState( 39 | {}, 40 | 'React Admin', 41 | '/set-password#access_token=bazinga' 42 | ); 43 | 44 | const { queryByText } = render( 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | 52 | await waitFor(() => { 53 | expect(queryByText('bazinga')).not.toBeNull(); 54 | }); 55 | }); 56 | 57 | test('should return the access token from the provided key if present in the URL', async () => { 58 | window.history.pushState( 59 | {}, 60 | 'React Admin', 61 | '/set-password?my_token=bazinga' 62 | ); 63 | 64 | const { queryByText } = render( 65 | 66 | 67 | 68 | 69 | 70 | ); 71 | 72 | await waitFor(() => { 73 | expect(queryByText('bazinga')).not.toBeNull(); 74 | }); 75 | }); 76 | 77 | test.skip('should redirect users if the access token is not present in the URL', async () => { 78 | window.history.pushState({}, 'React Admin', '/set-password'); 79 | 80 | render( 81 | 82 | 83 | 84 | 85 | 86 | ); 87 | 88 | await waitFor(() => { 89 | expect(window.location.pathname).toEqual('/'); 90 | }); 91 | }); 92 | 93 | test.skip('should redirect users to the provided path if the access token is not present in the URL', async () => { 94 | window.history.pushState({}, 'React Admin', '/set-password'); 95 | 96 | render( 97 | 98 | 99 | 100 | 101 | 102 | ); 103 | 104 | await waitFor(() => { 105 | expect(window.location.pathname).toEqual('/login'); 106 | }); 107 | }); 108 | 109 | test.skip('should not redirect users if the access token is not present in the URL and redirectTo is false', async () => { 110 | window.history.pushState({}, 'React Admin', '/set-password'); 111 | 112 | render( 113 | 114 | 115 | 116 | 117 | 118 | ); 119 | 120 | await waitFor(() => { 121 | expect(window.location.pathname).toEqual('/set-password'); 122 | }); 123 | }); 124 | }); 125 | -------------------------------------------------------------------------------- /packages/ra-supabase-core/src/useSupabaseAccessToken.ts: -------------------------------------------------------------------------------- 1 | import { useRedirect } from 'ra-core'; 2 | import { useEffect } from 'react'; 3 | import { getSearchString } from './getSearchString'; 4 | 5 | /** 6 | * This hook gets the access_token from supabase in the current browser URL and redirects to the specified page (/ by default) if there is none. 7 | * To be used in pages such as those which set the password after a reset or an invitation. 8 | * 9 | * @example 10 | * import { useSupabaseAccessToken } from 'ra-supabase-core'; 11 | * 12 | * const SetPasswordPage = () => { 13 | * const access_token = useSupabaseAccessToken(); 14 | * const setPassword = useSetPassword(); 15 | * 16 | * const handleSubmit = event => { 17 | * setPassword({ 18 | * access_token, 19 | * password: event.currentTarget.elements.password.value, 20 | * }); 21 | * }; 22 | * 23 | * return ( 24 | *
25 | * 26 | * 27 | * 28 | *
29 | * ); 30 | * }; 31 | **/ 32 | export const useSupabaseAccessToken = ({ 33 | redirectTo = '/', 34 | parameterName = 'access_token', 35 | }: UseSupabaseAccessTokenOptions = {}) => { 36 | const redirect = useRedirect(); 37 | 38 | const searchStr = getSearchString(); 39 | const urlSearchParams = new URLSearchParams(searchStr); 40 | const access_token = urlSearchParams.get(parameterName); 41 | useEffect(() => { 42 | if (access_token == null) { 43 | if (redirectTo !== false) { 44 | redirect(redirectTo); 45 | } 46 | } 47 | }); 48 | 49 | return access_token; 50 | }; 51 | 52 | export type UseSupabaseAccessTokenOptions = { 53 | redirectTo?: string | false; 54 | parameterName?: string; 55 | }; 56 | -------------------------------------------------------------------------------- /packages/ra-supabase-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "allowJs": false, 9 | "strictNullChecks": true 10 | }, 11 | "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.spec.js"], 12 | "include": ["src"], 13 | } 14 | -------------------------------------------------------------------------------- /packages/ra-supabase-language-english/README.md: -------------------------------------------------------------------------------- 1 | # ra-supabase-language-english 2 | 3 | English translations for [ra-supabase](https://www.npmjs.com/package/ra-supabase). 4 | 5 | ## Installation 6 | 7 | ```sh 8 | yarn add ra-supabase-language-english 9 | # or 10 | npm install ra-supabase-language-english 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```js 16 | // in i18nProvider.js 17 | import { mergeTranslations } from 'ra-core'; 18 | import polyglotI18nProvider from 'ra-i18n-polyglot'; 19 | import englishMessages from 'ra-language-english'; 20 | import frenchMessages from 'ra-language-french'; 21 | import { raSupabaseEnglishMessages } from 'ra-supabase-language-english'; 22 | import { raSupabaseFrenchMessages } from 'ra-supabase-language-french'; 23 | 24 | const allEnglishMessages = mergeTranslations( 25 | englishMessages, 26 | raSupabaseEnglishMessages 27 | ); 28 | const allFrenchMessages = mergeTranslations( 29 | frenchMessages, 30 | raSupabaseFrenchMessages 31 | ); 32 | 33 | export const i18nProvider = polyglotI18nProvider( 34 | locale => (locale === 'fr' ? allFrenchMessages : allEnglishMessages), 35 | 'en' 36 | ); 37 | 38 | // in App.js 39 | import { Admin, Resource, ListGuesser } from 'react-admin'; 40 | import { authRoutes } from 'ra-supabase'; 41 | import { dataProvider } from './dataProvider'; 42 | import { authProvider } from './authProvider'; 43 | import { i18nProvider } from './i18nProvider'; 44 | 45 | export const MyAdmin = () => ( 46 | 52 | 53 | 54 | 55 | ); 56 | ``` 57 | -------------------------------------------------------------------------------- /packages/ra-supabase-language-english/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ra-supabase-language-english", 3 | "version": "3.5.0", 4 | "repository": "git@github.com:marmelab/ra-supabase.git", 5 | "author": "Gildas Garcia <1122076+djhi@users.noreply.github.com>", 6 | "license": "MIT", 7 | "files": [ 8 | "*.md", 9 | "lib", 10 | "esm", 11 | "src" 12 | ], 13 | "main": "lib/index", 14 | "module": "esm/index.js", 15 | "types": "esm/index.d.ts", 16 | "sideEffects": false, 17 | "scripts": { 18 | "build": "yarn run build-cjs && yarn run build-esm", 19 | "build-cjs": "rimraf ./lib && tsc", 20 | "build-esm": "rimraf ./esm && tsc --outDir esm --module es2015", 21 | "watch": "tsc --outDir esm --module es2015 --watch", 22 | "lint": "eslint --fix ./src" 23 | }, 24 | "devDependencies": { 25 | "rimraf": "^6.0.1", 26 | "typescript": "^5.7.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/ra-supabase-language-english/src/index.ts: -------------------------------------------------------------------------------- 1 | export const raSupabaseEnglishMessages = { 2 | 'ra-supabase': { 3 | auth: { 4 | email: 'Email', 5 | confirm_password: 'Confirm password', 6 | sign_in_with: 'Sign in with %{provider}', 7 | forgot_password: 'Forgot password?', 8 | reset_password: 'Reset password', 9 | password_reset: 10 | 'Your password has been reset. You will receive an email containing a link to log in.', 11 | missing_tokens: 'Access and refresh tokens are missing', 12 | back_to_login: 'Back to login', 13 | }, 14 | reset_password: { 15 | forgot_password: 'Forgot password?', 16 | forgot_password_details: 'Enter your email for instructions.', 17 | }, 18 | set_password: { 19 | new_password: 'Choose your password', 20 | }, 21 | validation: { 22 | password_mismatch: 'Passwords do not match', 23 | }, 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /packages/ra-supabase-language-english/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "allowJs": false, 9 | "strictNullChecks": true 10 | }, 11 | "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.spec.js"], 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/ra-supabase-language-french/README.md: -------------------------------------------------------------------------------- 1 | # ra-supabase-language-french 2 | 3 | French translations for [ra-supabase](https://www.npmjs.com/package/ra-supabase). 4 | 5 | ## Installation 6 | 7 | ```sh 8 | yarn add ra-supabase-language-french 9 | # or 10 | npm install ra-supabase-language-french 11 | ``` 12 | 13 | ## Usage 14 | 15 | ```js 16 | // in i18nProvider.js 17 | import { mergeTranslations } from 'ra-core'; 18 | import polyglotI18nProvider from 'ra-i18n-polyglot'; 19 | import englishMessages from 'ra-language-english'; 20 | import frenchMessages from 'ra-language-french'; 21 | import { raSupabaseEnglishMessages } from 'ra-supabase-language-english'; 22 | import { raSupabaseFrenchMessages } from 'ra-supabase-language-french'; 23 | 24 | const allEnglishMessages = mergeTranslations( 25 | englishMessages, 26 | raSupabaseEnglishMessages 27 | ); 28 | const allFrenchMessages = mergeTranslations( 29 | frenchMessages, 30 | raSupabaseFrenchMessages 31 | ); 32 | 33 | export const i18nProvider = polyglotI18nProvider( 34 | locale => (locale === 'fr' ? allFrenchMessages : allEnglishMessages), 35 | 'en' 36 | ); 37 | 38 | // in App.js 39 | import { Admin, Resource, ListGuesser } from 'react-admin'; 40 | import { authRoutes } from 'ra-supabase'; 41 | import { dataProvider } from './dataProvider'; 42 | import { authProvider } from './authProvider'; 43 | import { i18nProvider } from './i18nProvider'; 44 | 45 | export const MyAdmin = () => ( 46 | 52 | 53 | 54 | 55 | ); 56 | ``` 57 | -------------------------------------------------------------------------------- /packages/ra-supabase-language-french/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ra-supabase-language-french", 3 | "version": "3.5.0", 4 | "repository": "git@github.com:marmelab/ra-supabase.git", 5 | "author": "Gildas Garcia <1122076+djhi@users.noreply.github.com>", 6 | "license": "MIT", 7 | "files": [ 8 | "*.md", 9 | "lib", 10 | "esm", 11 | "src" 12 | ], 13 | "main": "lib/index", 14 | "module": "esm/index.js", 15 | "types": "esm/index.d.ts", 16 | "sideEffects": false, 17 | "scripts": { 18 | "build": "yarn run build-cjs && yarn run build-esm", 19 | "build-cjs": "rimraf ./lib && tsc", 20 | "build-esm": "rimraf ./esm && tsc --outDir esm --module es2015", 21 | "watch": "tsc --outDir esm --module es2015 --watch", 22 | "lint": "eslint --fix ./src" 23 | }, 24 | "devDependencies": { 25 | "rimraf": "^6.0.1", 26 | "typescript": "^5.7.3" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /packages/ra-supabase-language-french/src/index.ts: -------------------------------------------------------------------------------- 1 | export const raSupabaseFrenchMessages = { 2 | 'ra-supabase': { 3 | auth: { 4 | email: 'Email', 5 | confirm_password: 'Confirmation du mot de passe', 6 | sign_in_with: 'Se connecter avec %{provider}', 7 | forgot_password: 'Mot de passe oublié ?', 8 | reset_password: 'Réinitialiser le mot de passe', 9 | password_reset: 10 | 'Votre mot de passe a été réinitialisé. Vous recevrez un email contenant un lien pour vous connecter.', 11 | missing_tokens: 12 | "Les jetons d'accès et de rafraîchissement sont manquants", 13 | back_to_login: 'Retour à la page de connexion', 14 | }, 15 | reset_password: { 16 | forgot_password: 'Mot de passe oublié ?', 17 | forgot_password_details: 'Obtenez les instructions par email.', 18 | }, 19 | set_password: { 20 | new_password: 'Nouveau mot de passe', 21 | }, 22 | validation: { 23 | password_mismatch: 'Les mots de passe ne correspondent pas', 24 | }, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /packages/ra-supabase-language-french/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "allowJs": false, 9 | "strictNullChecks": true 10 | }, 11 | "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.spec.js"], 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ra-supabase-ui-materialui", 3 | "version": "3.5.0", 4 | "repository": "git@github.com:marmelab/ra-supabase.git", 5 | "author": "Gildas Garcia <1122076+djhi@users.noreply.github.com>", 6 | "license": "MIT", 7 | "files": [ 8 | "*.md", 9 | "lib", 10 | "esm", 11 | "src" 12 | ], 13 | "main": "lib/index", 14 | "module": "esm/index.js", 15 | "types": "esm/index.d.ts", 16 | "sideEffects": false, 17 | "dependencies": { 18 | "ra-supabase-core": "^3.5.0" 19 | }, 20 | "devDependencies": { 21 | "@mui/icons-material": "^5.16.12", 22 | "@mui/material": "^5.16.12", 23 | "@mui/system": "^5.16.12", 24 | "@supabase/supabase-js": "^2.48.1", 25 | "ra-core": "^5.8.0", 26 | "ra-ui-materialui": "^5.8.0", 27 | "react": "^18.3.1", 28 | "react-dom": "^18.3.1", 29 | "react-router": "^6.23.1", 30 | "rimraf": "^6.0.1", 31 | "typescript": "^5.7.3" 32 | }, 33 | "peerDependencies": { 34 | "@mui/icons-material": "^5.16.12 || ^6.0.0 || ^7.0.0", 35 | "@mui/material": "^5.16.12 || ^6.0.0 || ^7.0.0", 36 | "@supabase/supabase-js": "^2.48.1", 37 | "ra-core": "^5.8.0", 38 | "ra-ui-materialui": "^5.8.0", 39 | "react": "^18.0.0 || ^19.0.0", 40 | "react-dom": "^18.0.0 || ^19.0.0", 41 | "react-router": "^6.28.1 || ^7.1.1" 42 | }, 43 | "scripts": { 44 | "build": "yarn run build-cjs && yarn run build-esm", 45 | "build-cjs": "rimraf ./lib && tsc", 46 | "build-esm": "rimraf ./esm && tsc --outDir esm --module es2015", 47 | "watch": "tsc --outDir esm --module es2015 --watch", 48 | "lint": "eslint --fix ./src" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/AuthLayout.tsx: -------------------------------------------------------------------------------- 1 | import React, { 2 | HtmlHTMLAttributes, 3 | ComponentType, 4 | createElement, 5 | ReactNode, 6 | useRef, 7 | useEffect, 8 | } from 'react'; 9 | import { Card, Avatar, styled } from '@mui/material'; 10 | import LockIcon from '@mui/icons-material/Lock'; 11 | import { TitleComponent } from 'ra-core'; 12 | import { Notification } from 'ra-ui-materialui'; 13 | 14 | /** 15 | * A standalone login page, to serve as authentication gate to the admin 16 | * 17 | * Expects the user to enter a login and a password, which will be checked 18 | * by the `authProvider.login()` method. Redirects to the root page (/) 19 | * upon success, otherwise displays an authentication error message. 20 | * 21 | * Copy and adapt this component to implement your own login logic 22 | * (e.g. to authenticate via email or facebook or anything else). 23 | * 24 | * @example 25 | * import MyLoginPage from './MyLoginPage'; 26 | * const App = () => ( 27 | * 28 | * ... 29 | * 30 | * ); 31 | */ 32 | export const AuthLayout: React.FunctionComponent = props => { 33 | const { 34 | title, 35 | classes: classesOverride, 36 | className, 37 | children, 38 | notification = Notification, 39 | backgroundImage, 40 | ...rest 41 | } = props; 42 | const containerRef = useRef(null); 43 | let backgroundImageLoaded = false; 44 | 45 | const updateBackgroundImage = () => { 46 | if (!backgroundImageLoaded && containerRef.current) { 47 | containerRef.current.style.backgroundImage = `url(${backgroundImage})`; 48 | backgroundImageLoaded = true; 49 | } 50 | }; 51 | 52 | // Load background image asynchronously to speed up time to interactive 53 | const lazyLoadBackgroundImage = () => { 54 | if (backgroundImage) { 55 | const img = new Image(); 56 | img.onload = updateBackgroundImage; 57 | img.src = backgroundImage; 58 | } 59 | }; 60 | 61 | useEffect(() => { 62 | if (!backgroundImageLoaded) { 63 | lazyLoadBackgroundImage(); 64 | } 65 | }); 66 | 67 | return ( 68 | 69 | 70 |
71 | 72 | 73 | 74 |
75 | {children} 76 |
77 | {notification ? createElement(notification) : null} 78 |
79 | ); 80 | }; 81 | 82 | export interface LoginProps 83 | extends Omit, 'title'> { 84 | backgroundImage?: string; 85 | children?: ReactNode; 86 | classes?: object; 87 | className?: string; 88 | notification?: ComponentType; 89 | title?: TitleComponent; 90 | } 91 | 92 | const PREFIX = 'RaAuthLayout'; 93 | 94 | export const AuthLayoutClasses = { 95 | card: `${PREFIX}-card`, 96 | avatar: `${PREFIX}-avatar`, 97 | icon: `${PREFIX}-icon`, 98 | }; 99 | 100 | const Root = styled('div', { 101 | name: PREFIX, 102 | overridesResolver: (props, styles) => styles.root, 103 | })(({ theme }) => ({ 104 | display: 'flex', 105 | flexDirection: 'column', 106 | minHeight: '100vh', 107 | height: '1px', 108 | alignItems: 'center', 109 | justifyContent: 'flex-start', 110 | backgroundRepeat: 'no-repeat', 111 | backgroundSize: 'cover', 112 | backgroundImage: 113 | 'radial-gradient(circle at 50% 14em, #313264 0%, #00023b 60%, #00023b 100%)', 114 | [`& .${AuthLayoutClasses.card}`]: { 115 | minWidth: 300, 116 | marginTop: '6em', 117 | }, 118 | [`& .${AuthLayoutClasses.avatar}`]: { 119 | margin: '1em', 120 | display: 'flex', 121 | justifyContent: 'center', 122 | }, 123 | [`& .${AuthLayoutClasses.icon}`]: { 124 | backgroundColor: (theme.vars || theme).palette.grey[500], 125 | }, 126 | })); 127 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/ForgotPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { CardActions, Stack, styled, Typography } from '@mui/material'; 3 | import { useResetPassword } from 'ra-supabase-core'; 4 | import { Form, required, useNotify, useTranslate } from 'ra-core'; 5 | import { Link, SaveButton, TextInput } from 'ra-ui-materialui'; 6 | 7 | /** 8 | * A component that renders a form for resetting the user password. 9 | */ 10 | export const ForgotPasswordForm = () => { 11 | const notify = useNotify(); 12 | const translate = useTranslate(); 13 | const [, { mutateAsync: resetPassword }] = useResetPassword(); 14 | 15 | const submit = async (values: FormData) => { 16 | try { 17 | await resetPassword({ 18 | email: values.email, 19 | }); 20 | } catch (error) { 21 | notify( 22 | typeof error === 'string' 23 | ? error 24 | : typeof error === 'undefined' || !error.message 25 | ? 'ra.auth.sign_in_error' 26 | : error.message, 27 | { 28 | type: 'warning', 29 | messageArgs: { 30 | _: 31 | typeof error === 'string' 32 | ? error 33 | : error && error.message 34 | ? error.message 35 | : undefined, 36 | }, 37 | } 38 | ); 39 | } 40 | }; 41 | 42 | return ( 43 | 44 |
45 | 46 | 52 | {translate( 53 | 'ra-supabase.reset_password.forgot_password', 54 | { _: 'Forgot password?' } 55 | )} 56 | 57 | 58 | 65 | {translate( 66 | 'ra-supabase.reset_password.forgot_password_details', 67 | { 68 | _: 'Enter your email to receive a reset password link.', 69 | } 70 | )} 71 | 72 | 73 | 74 |
75 | 84 |
85 |
86 | 87 | } 95 | /> 96 | 97 | {translate('ra-supabase.auth.back_to_login', { 98 | _: 'Back to login page', 99 | })} 100 | 101 | 102 |
103 | ); 104 | }; 105 | 106 | interface FormData { 107 | email: string; 108 | } 109 | 110 | const PREFIX = 'RaSupabaseForgotPasswordForm'; 111 | 112 | const SupabaseLoginFormClasses = { 113 | container: `${PREFIX}-container`, 114 | input: `${PREFIX}-input`, 115 | button: `${PREFIX}-button`, 116 | }; 117 | 118 | const Root = styled(Form, { 119 | name: PREFIX, 120 | overridesResolver: (props, styles) => styles.root, 121 | })(({ theme }) => ({ 122 | [`& .${SupabaseLoginFormClasses.container}`]: { 123 | padding: '0 1em 0 1em', 124 | }, 125 | [`& .${SupabaseLoginFormClasses.input}`]: { 126 | marginTop: '1em', 127 | }, 128 | [`& .${SupabaseLoginFormClasses.button}`]: { 129 | width: '100%', 130 | }, 131 | })); 132 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/ForgotPasswordPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { ReactNode } from 'react'; 3 | 4 | import { AuthLayout } from './AuthLayout'; 5 | import { ForgotPasswordForm } from './ForgotPasswordForm'; 6 | 7 | /** 8 | * A component that renders a page for resetting the current user password through Supabase. 9 | * @param props 10 | * @param props.children The content of the page. If not set, it will render a ForgotPasswordForm. 11 | * 12 | * @example 13 | * import { ForgotPasswordPage } from 'ra-supabase-ui-materialui'; 14 | * import { Admin, CustomRoutes } from 'react-admin'; 15 | * 16 | * const App = () => ( 17 | * 18 | * 19 | * } /> 20 | * 21 | * ... 22 | * 23 | * ); 24 | */ 25 | export const ForgotPasswordPage = (props: ForgotPasswordPageProps) => { 26 | const { children = } = props; 27 | 28 | return {children}; 29 | }; 30 | 31 | ForgotPasswordPage.path = '/forgot-password'; 32 | 33 | export type ForgotPasswordPageProps = { 34 | children?: ReactNode; 35 | }; 36 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/LoginForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { ComponentProps } from 'react'; 3 | import { Box, styled } from '@mui/material'; 4 | import { required, useTranslate } from 'ra-core'; 5 | import { 6 | Link, 7 | LoginForm as RaLoginForm, 8 | PasswordInput, 9 | TextInput, 10 | } from 'ra-ui-materialui'; 11 | 12 | import { ForgotPasswordPage } from './ForgotPasswordPage'; 13 | 14 | /** 15 | * A component that renders a form to login to the application with an email and password. 16 | */ 17 | export const LoginForm = ({ 18 | disableForgotPassword, 19 | ...props 20 | }: LoginFormProps) => { 21 | const translate = useTranslate(); 22 | 23 | return ( 24 | 25 | 26 | 36 | 44 | 45 | {!disableForgotPassword ? ( 46 | 52 | 53 | {translate('ra-supabase.auth.forgot_password', { 54 | _: 'Forgot password?', 55 | })} 56 | 57 | 58 | ) : null} 59 | 60 | ); 61 | }; 62 | 63 | export interface LoginFormProps 64 | extends Omit, 'onSubmit' | 'children'> { 65 | disableForgotPassword?: boolean; 66 | } 67 | 68 | const PREFIX = 'RaSupabaseLoginForm'; 69 | 70 | const Root = styled('div', { 71 | name: PREFIX, 72 | overridesResolver: (props, styles) => styles.root, 73 | })(() => ({})); 74 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { ReactNode } from 'react'; 3 | import { Login } from 'ra-ui-materialui'; 4 | import { Provider } from '@supabase/supabase-js'; 5 | import { Divider, Stack } from '@mui/material'; 6 | 7 | import { 8 | AppleButton, 9 | AzureButton, 10 | BitbucketButton, 11 | DiscordButton, 12 | FacebookButton, 13 | GithubButton, 14 | GitlabButton, 15 | GoogleButton, 16 | KeycloakButton, 17 | LinkedInButton, 18 | NotionButton, 19 | SlackButton, 20 | SpotifyButton, 21 | TwitchButton, 22 | TwitterButton, 23 | WorkosButton, 24 | } from './SocialAuthButton'; 25 | import { LoginForm } from './LoginForm'; 26 | 27 | /** 28 | * A component that renders a login page to login to the application through Supabase. It renders a LoginForm by default. It support social login providers. 29 | * @param props 30 | * @param props.children The content of the login page. If not set, it will render a LoginForm. 31 | * @param props.disableEmailPassword If true, the email/password login form will not be rendered. 32 | * @param props.providers The list of social login providers to render. Defaults to no providers. 33 | * @example 34 | * import { LoginPage } from 'ra-supabase-ui-materialui'; 35 | * import { Admin } from 'react-admin'; 36 | * 37 | * const App = () => ( 38 | * 39 | * ... 40 | * 41 | * ); 42 | * 43 | * @example With social login providers 44 | * import { LoginPage } from 'ra-supabase-ui-materialui'; 45 | * import { Admin } from 'react-admin'; 46 | * 47 | * const App = () => ( 48 | * }> 49 | * ... 50 | * 51 | * ); 52 | * 53 | * @example With social login providers 54 | * import { LoginPage } from 'ra-supabase-ui-materialui'; 55 | * import { Admin } from 'react-admin'; 56 | * 57 | * const App = () => ( 58 | * }> 59 | * ... 60 | * 61 | * ); 62 | * 63 | * @example With social login providers and no email/password login form 64 | * import { LoginPage } from 'ra-supabase-ui-materialui'; 65 | * import { Admin } from 'react-admin'; 66 | * 67 | * const App = () => ( 68 | * }> 69 | * ... 70 | * 71 | * ); 72 | */ 73 | export const LoginPage = (props: LoginPageProps) => { 74 | const { 75 | children, 76 | disableEmailPassword = false, 77 | disableForgotPassword = false, 78 | providers = [], 79 | } = props; 80 | 81 | return ( 82 | 83 | {children ?? ( 84 | <> 85 | {disableEmailPassword ? null : ( 86 | 89 | )} 90 | {disableEmailPassword || providers.length === 0 ? null : ( 91 | 92 | )} 93 | {providers && providers.length > 0 ? ( 94 | <> 95 | 101 | {providers.includes('apple') ? ( 102 | 103 | ) : null} 104 | {providers.includes('azure') ? ( 105 | 106 | ) : null} 107 | {providers.includes('bitbucket') ? ( 108 | 109 | ) : null} 110 | {providers.includes('discord') ? ( 111 | 112 | ) : null} 113 | {providers.includes('facebook') ? ( 114 | 115 | ) : null} 116 | {providers.includes('gitlab') ? ( 117 | 118 | ) : null} 119 | {providers.includes('github') ? ( 120 | 121 | ) : null} 122 | {providers.includes('google') ? ( 123 | 124 | ) : null} 125 | {providers.includes('keycloak') ? ( 126 | 127 | ) : null} 128 | {providers.includes('linkedin') ? ( 129 | 130 | ) : null} 131 | {providers.includes('notion') ? ( 132 | 133 | ) : null} 134 | {providers.includes('slack') ? ( 135 | 136 | ) : null} 137 | {providers.includes('spotify') ? ( 138 | 139 | ) : null} 140 | {providers.includes('twitch') ? ( 141 | 142 | ) : null} 143 | {providers.includes('twitter') ? ( 144 | 145 | ) : null} 146 | {providers.includes('workos') ? ( 147 | 148 | ) : null} 149 | 150 | 151 | ) : null} 152 | 153 | )} 154 | 155 | ); 156 | }; 157 | 158 | export type LoginPageProps = { 159 | children?: ReactNode; 160 | disableEmailPassword?: boolean; 161 | disableForgotPassword?: boolean; 162 | providers?: Provider[]; 163 | }; 164 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/SetPasswordForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { CardActions, styled, Typography } from '@mui/material'; 3 | import { Form, required, useNotify, useTranslate } from 'ra-core'; 4 | import { PasswordInput, SaveButton } from 'ra-ui-materialui'; 5 | import { useSetPassword, useSupabaseAccessToken } from 'ra-supabase-core'; 6 | 7 | /** 8 | * A component that renders a form for setting the current user password through Supabase. 9 | * Can be used for the first login after a user has been invited or to reset the password. 10 | */ 11 | export const SetPasswordForm = () => { 12 | const access_token = useSupabaseAccessToken(); 13 | const refresh_token = useSupabaseAccessToken({ 14 | parameterName: 'refresh_token', 15 | }); 16 | 17 | const notify = useNotify(); 18 | const translate = useTranslate(); 19 | const [, { mutateAsync: setPassword }] = useSetPassword(); 20 | 21 | const validate = (values: FormData) => { 22 | if (values.password !== values.confirmPassword) { 23 | return { 24 | password: 'ra-supabase.validation.password_mismatch', 25 | confirmPassword: 'ra-supabase.validation.password_mismatch', 26 | }; 27 | } 28 | return {}; 29 | }; 30 | 31 | if (!access_token || !refresh_token) { 32 | if (process.env.NODE_ENV === 'development') { 33 | console.error( 34 | 'Missing access_token or refresh_token for set password' 35 | ); 36 | } 37 | return ( 38 |
39 |
{translate('ra-supabase.auth.missing_tokens')}
40 |
41 | ); 42 | } 43 | 44 | const submit = async (values: FormData) => { 45 | try { 46 | await setPassword({ 47 | access_token, 48 | refresh_token, 49 | password: values.password, 50 | }); 51 | } catch (error) { 52 | notify( 53 | typeof error === 'string' 54 | ? error 55 | : typeof error === 'undefined' || !error.message 56 | ? 'ra.auth.sign_in_error' 57 | : error.message, 58 | { 59 | type: 'warning', 60 | messageArgs: { 61 | _: 62 | typeof error === 'string' 63 | ? error 64 | : error && error.message 65 | ? error.message 66 | : undefined, 67 | }, 68 | } 69 | ); 70 | } 71 | }; 72 | 73 | return ( 74 | 75 |
76 | 83 | {translate('ra-supabase.set_password.new_password', { 84 | _: 'Choose your password', 85 | })} 86 | 87 | 88 |
89 | 98 |
99 |
100 | 108 |
109 |
110 | 111 | 117 | 118 |
119 | ); 120 | }; 121 | 122 | interface FormData { 123 | password: string; 124 | confirmPassword: string; 125 | } 126 | 127 | const PREFIX = 'RaSupabaseSetPasswordForm'; 128 | 129 | const SupabaseLoginFormClasses = { 130 | container: `${PREFIX}-container`, 131 | input: `${PREFIX}-input`, 132 | button: `${PREFIX}-button`, 133 | }; 134 | 135 | const Root = styled(Form, { 136 | name: PREFIX, 137 | overridesResolver: (props, styles) => styles.root, 138 | })(({ theme }) => ({ 139 | [`& .${SupabaseLoginFormClasses.container}`]: { 140 | padding: '0 1em 0 1em', 141 | }, 142 | [`& .${SupabaseLoginFormClasses.input}`]: { 143 | marginTop: '1em', 144 | }, 145 | [`& .${SupabaseLoginFormClasses.button}`]: { 146 | width: '100%', 147 | }, 148 | })); 149 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/SetPasswordPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { ReactNode } from 'react'; 3 | 4 | import { AuthLayout } from './AuthLayout'; 5 | import { SetPasswordForm } from './SetPasswordForm'; 6 | 7 | /** 8 | * A component that renders a page for setting the current user password through Supabase. 9 | * Can be used for the first login after a user has been invited or to reset the password. 10 | * @param props 11 | * @param props.children The content of the page. If not set, it will render a SetPasswordForm. 12 | * 13 | * @example 14 | * import { SetPasswordPage } from 'ra-supabase-ui-materialui'; 15 | * import { Admin, CustomRoutes } from 'react-admin'; 16 | * 17 | * const App = () => ( 18 | * 19 | * 20 | * } /> 21 | * 22 | * ... 23 | * 24 | * ); 25 | */ 26 | export const SetPasswordPage = (props: SetPasswordPageProps) => { 27 | const { children = } = props; 28 | 29 | return {children}; 30 | }; 31 | 32 | SetPasswordPage.path = '/set-password'; 33 | 34 | export type SetPasswordPageProps = { 35 | children?: ReactNode; 36 | }; 37 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/guessers/CreateGuesser.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { ReactNode } from 'react'; 3 | import { useAPISchema } from 'ra-supabase-core'; 4 | import { CreateBase, useResourceContext } from 'ra-core'; 5 | import { CreateView, Loading } from 'ra-ui-materialui'; 6 | import type { CreateProps, CreateViewProps } from 'ra-ui-materialui'; 7 | import { capitalize, singularize } from 'inflection'; 8 | 9 | import { inferElementFromType } from './inferElementFromType'; 10 | import { InferredElement } from './InferredElement'; 11 | import { editFieldTypes } from './editFieldTypes'; 12 | 13 | export const CreateGuesser = (props: CreateProps & { enableLog?: boolean }) => { 14 | const { 15 | mutationOptions, 16 | resource, 17 | record, 18 | transform, 19 | redirect, 20 | disableAuthentication, 21 | ...rest 22 | } = props; 23 | return ( 24 | 32 | 33 | 34 | ); 35 | }; 36 | 37 | export const CreateGuesserView = ( 38 | props: CreateViewProps & { 39 | enableLog?: boolean; 40 | } 41 | ) => { 42 | const { data: schema, error, isPending } = useAPISchema(); 43 | const resource = useResourceContext(); 44 | const [child, setChild] = React.useState(null); 45 | if (!resource) { 46 | throw new Error('CreateGuesser must be used withing a ResourceContext'); 47 | } 48 | const { enableLog = process.env.NODE_ENV === 'development', ...rest } = 49 | props; 50 | 51 | React.useEffect(() => { 52 | if (isPending || error) { 53 | return; 54 | } 55 | const resourceDefinition = schema.definitions?.[resource]; 56 | const requiredFields = resourceDefinition?.required || []; 57 | if (!resourceDefinition || !resourceDefinition.properties) { 58 | throw new Error( 59 | `The resource ${resource} is not defined in the API schema` 60 | ); 61 | } 62 | const inferredInputs = Object.keys(resourceDefinition.properties) 63 | .filter((source: string) => source !== 'id') 64 | .filter( 65 | source => 66 | resourceDefinition.properties![source].format !== 'tsvector' 67 | ) 68 | .map((source: string) => 69 | inferElementFromType({ 70 | name: source, 71 | types: editFieldTypes, 72 | description: 73 | resourceDefinition.properties![source].description, 74 | format: resourceDefinition.properties![source].format, 75 | type: (resourceDefinition.properties && 76 | resourceDefinition.properties[source] && 77 | typeof resourceDefinition.properties[source].type === 78 | 'string' 79 | ? resourceDefinition.properties![source].type 80 | : 'string') as string, 81 | requiredFields, 82 | schema, 83 | }) 84 | ); 85 | const inferredForm = new InferredElement( 86 | editFieldTypes.form, 87 | null, 88 | inferredInputs 89 | ); 90 | setChild(inferredForm.getElement()); 91 | if (!enableLog) return; 92 | 93 | const representation = inferredForm.getRepresentation(); 94 | 95 | const components = ['Create'] 96 | .concat( 97 | Array.from( 98 | new Set( 99 | Array.from(representation.matchAll(/<([^/\s>]+)/g)) 100 | .map(match => match[1]) 101 | .filter(component => component !== 'span') 102 | ) 103 | ) 104 | ) 105 | .sort(); 106 | 107 | const warnings = inferredInputs 108 | .map(inferredInput => inferredInput.getWarning()) 109 | .filter(warning => warning != null); 110 | 111 | // eslint-disable-next-line no-console 112 | console.log( 113 | `Guessed Create: 114 | 115 | import { ${components.join(', ')} } from 'react-admin'; 116 | 117 | export const ${capitalize(singularize(resource))}Create = () => ( 118 | 119 | ${representation} 120 | 121 | );${ 122 | warnings.length > 0 123 | ? warnings.map(warning => `\n\n${warning}`).join('') 124 | : '' 125 | }` 126 | ); 127 | }, [resource, isPending, error, schema, enableLog]); 128 | 129 | if (isPending) return ; 130 | if (error) return

Error: {error.message}

; 131 | 132 | return {child}; 133 | }; 134 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/guessers/EditGuesser.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { ReactNode } from 'react'; 3 | import { useAPISchema } from 'ra-supabase-core'; 4 | import { EditBase, InferredElement, useResourceContext } from 'ra-core'; 5 | import { 6 | type EditProps, 7 | EditView, 8 | type EditViewProps, 9 | Loading, 10 | } from 'ra-ui-materialui'; 11 | import { capitalize, singularize } from 'inflection'; 12 | 13 | import { inferElementFromType } from './inferElementFromType'; 14 | import { editFieldTypes } from './editFieldTypes'; 15 | 16 | export const EditGuesser = (props: EditProps & { enableLogs?: boolean }) => { 17 | const { 18 | resource, 19 | id, 20 | mutationMode, 21 | mutationOptions, 22 | queryOptions, 23 | redirect, 24 | transform, 25 | disableAuthentication, 26 | ...rest 27 | } = props; 28 | return ( 29 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | export const EditGuesserView = ( 45 | props: EditViewProps & { 46 | enableLog?: boolean; 47 | } 48 | ) => { 49 | const { data: schema, error, isPending } = useAPISchema(); 50 | const resource = useResourceContext(); 51 | const [child, setChild] = React.useState(null); 52 | if (!resource) { 53 | throw new Error('EditGuesser must be used withing a ResourceContext'); 54 | } 55 | const { enableLog = process.env.NODE_ENV === 'development', ...rest } = 56 | props; 57 | 58 | React.useEffect(() => { 59 | if (isPending || error) { 60 | return; 61 | } 62 | const resourceDefinition = schema.definitions?.[resource]; 63 | const requiredFields = resourceDefinition?.required || []; 64 | if (!resourceDefinition || !resourceDefinition.properties) { 65 | throw new Error( 66 | `The resource ${resource} is not defined in the API schema` 67 | ); 68 | } 69 | const inferredInputs = Object.keys(resourceDefinition.properties) 70 | .filter((source: string) => source !== 'id') 71 | .filter( 72 | source => 73 | resourceDefinition.properties![source].format !== 'tsvector' 74 | ) 75 | .map((source: string) => 76 | inferElementFromType({ 77 | name: source, 78 | types: editFieldTypes, 79 | description: 80 | resourceDefinition.properties![source].description, 81 | format: resourceDefinition.properties![source].format, 82 | type: (resourceDefinition.properties && 83 | resourceDefinition.properties[source] && 84 | typeof resourceDefinition.properties[source].type === 85 | 'string' 86 | ? resourceDefinition.properties![source].type 87 | : 'string') as string, 88 | requiredFields, 89 | schema, 90 | }) 91 | ); 92 | const inferredForm = new InferredElement( 93 | editFieldTypes.form, 94 | null, 95 | inferredInputs 96 | ); 97 | setChild(inferredForm.getElement()); 98 | if (!enableLog) return; 99 | 100 | const representation = inferredForm.getRepresentation(); 101 | 102 | const components = ['Edit'] 103 | .concat( 104 | Array.from( 105 | new Set( 106 | Array.from(representation.matchAll(/<([^/\s>]+)/g)) 107 | .map(match => match[1]) 108 | .filter(component => component !== 'span') 109 | ) 110 | ) 111 | ) 112 | .sort(); 113 | 114 | const warnings = inferredInputs 115 | .map(inferredInput => inferredInput.getWarning()) 116 | .filter(warning => warning != null); 117 | 118 | // eslint-disable-next-line no-console 119 | console.log( 120 | `Guessed Edit: 121 | 122 | import { ${components.join(', ')} } from 'react-admin'; 123 | 124 | export const ${capitalize(singularize(resource))}Edit = () => ( 125 | 126 | ${representation} 127 | 128 | );${ 129 | warnings.length > 0 130 | ? warnings.map(warning => `\n\n${warning}`).join('') 131 | : '' 132 | }` 133 | ); 134 | }, [resource, isPending, error, schema, enableLog]); 135 | 136 | if (isPending) return ; 137 | if (error) return

Error: {error.message}

; 138 | 139 | return {child}; 140 | }; 141 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/guessers/InferredElement.ts: -------------------------------------------------------------------------------- 1 | import { InferredElement as CoreInferredElement, InferredType } from 'ra-core'; 2 | 3 | export class InferredElement extends CoreInferredElement { 4 | constructor( 5 | type?: InferredType, 6 | props?: any, 7 | children?: any, 8 | private warning?: string 9 | ) { 10 | super(type, props, children); 11 | } 12 | 13 | getWarning() { 14 | return this.warning; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/guessers/ShowGuesser.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { ReactNode } from 'react'; 3 | import { useAPISchema } from 'ra-supabase-core'; 4 | import { ShowBase, useResourceContext } from 'ra-core'; 5 | import { 6 | Loading, 7 | showFieldTypes, 8 | type ShowProps, 9 | ShowView, 10 | type ShowViewProps, 11 | } from 'ra-ui-materialui'; 12 | import { capitalize, singularize } from 'inflection'; 13 | 14 | import { inferElementFromType } from './inferElementFromType'; 15 | import { InferredElement } from './InferredElement'; 16 | 17 | export const ShowGuesser = (props: ShowProps & { enableLog?: boolean }) => { 18 | const { id, disableAuthentication, queryOptions, resource, ...rest } = 19 | props; 20 | return ( 21 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | export const ShowGuesserView = ( 33 | props: ShowViewProps & { 34 | enableLog?: boolean; 35 | } 36 | ) => { 37 | const { data: schema, error, isPending } = useAPISchema(); 38 | const resource = useResourceContext(); 39 | const [child, setChild] = React.useState(null); 40 | if (!resource) { 41 | throw new Error('ShowGuesser must be used withing a ResourceContext'); 42 | } 43 | const { enableLog = process.env.NODE_ENV === 'development', ...rest } = 44 | props; 45 | 46 | React.useEffect(() => { 47 | if (isPending || error) { 48 | return; 49 | } 50 | const resourceDefinition = schema.definitions?.[resource]; 51 | if (!resourceDefinition || !resourceDefinition.properties) { 52 | throw new Error( 53 | `The resource ${resource} is not defined in the API schema` 54 | ); 55 | } 56 | const inferredFields = Object.keys(resourceDefinition.properties) 57 | .filter( 58 | source => 59 | resourceDefinition.properties![source].format !== 'tsvector' 60 | ) 61 | .map((source: string) => 62 | inferElementFromType({ 63 | name: source, 64 | types: showFieldTypes, 65 | description: 66 | resourceDefinition.properties![source].description, 67 | format: resourceDefinition.properties![source].format, 68 | type: (resourceDefinition.properties && 69 | resourceDefinition.properties[source] && 70 | typeof resourceDefinition.properties[source].type === 71 | 'string' 72 | ? resourceDefinition.properties![source].type 73 | : 'string') as string, 74 | schema, 75 | }) 76 | ); 77 | const inferredLayout = new InferredElement( 78 | showFieldTypes.show, 79 | null, 80 | inferredFields 81 | ); 82 | setChild(inferredLayout.getElement()); 83 | if (!enableLog) return; 84 | 85 | const representation = inferredLayout.getRepresentation(); 86 | 87 | const components = ['Show'] 88 | .concat( 89 | Array.from( 90 | new Set( 91 | Array.from(representation.matchAll(/<([^/\s>]+)/g)) 92 | .map(match => match[1]) 93 | .filter(component => component !== 'span') 94 | ) 95 | ) 96 | ) 97 | .sort(); 98 | 99 | const warnings = inferredFields 100 | .map(inferredInput => inferredInput.getWarning()) 101 | .filter(warning => warning != null); 102 | 103 | // eslint-disable-next-line no-console 104 | console.log( 105 | `Guessed Show: 106 | 107 | import { ${components.join(', ')} } from 'react-admin'; 108 | 109 | export const ${capitalize(singularize(resource))}Show = () => ( 110 | 111 | ${representation} 112 | 113 | );${ 114 | warnings.length > 0 115 | ? warnings.map(warning => `\n\n${warning}`).join('') 116 | : '' 117 | }` 118 | ); 119 | }, [resource, isPending, error, schema, enableLog]); 120 | 121 | if (isPending) return ; 122 | if (error) return

Error: {error.message}

; 123 | 124 | return {child}; 125 | }; 126 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/guessers/editFieldTypes.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { InferredElement, InferredTypeMap } from 'ra-core'; 3 | import { 4 | AutocompleteArrayInput, 5 | AutocompleteArrayInputProps, 6 | AutocompleteInput, 7 | AutocompleteInputProps, 8 | editFieldTypes as defaultEditFieldTypes, 9 | ReferenceArrayInput, 10 | ReferenceArrayInputProps, 11 | ReferenceInput, 12 | ReferenceInputProps, 13 | } from 'ra-ui-materialui'; 14 | 15 | export const editFieldTypes: InferredTypeMap = { 16 | ...defaultEditFieldTypes, 17 | reference: { 18 | component: ReferenceInput, 19 | representation: ( 20 | props: ReferenceInputProps, 21 | children?: InferredElement[] 22 | ) => 23 | children 24 | ? `\n${children 27 | .map( 28 | child => 29 | ` ${child.getRepresentation()}` 30 | ) 31 | .join('\n')}\n ` 32 | : ``, 33 | }, 34 | autocompleteInput: { 35 | component: (props: AutocompleteInputProps) => 36 | props.optionText ? ( 37 | ({ 39 | [`${props.optionText}@ilike`]: `%${searchText}%`, 40 | })} 41 | {...props} 42 | /> 43 | ) : ( 44 | 45 | ), 46 | representation: (props: AutocompleteInputProps) => 47 | ` ({ '${props.optionText}@ilike': \`%\${searchText}%\` })}` 52 | : '' 53 | } />`, 54 | }, 55 | referenceArray: { 56 | component: ReferenceArrayInput, 57 | representation: ( 58 | props: ReferenceArrayInputProps, 59 | children?: InferredElement[] 60 | ) => 61 | children 62 | ? `\n${children 65 | .map(child => `\t\t${child.getRepresentation()}`) 66 | .join('\n')}\n\t` 67 | : ``, 68 | }, 69 | autocompleteArrayInput: { 70 | component: (props: AutocompleteArrayInputProps) => 71 | props.optionText ? ( 72 | ({ 74 | [`${props.optionText}@ilike`]: `%${searchText}%`, 75 | })} 76 | {...props} 77 | /> 78 | ) : ( 79 | 80 | ), 81 | representation: (props: AutocompleteInputProps) => 82 | ` ({ '${props.optionText}@ilike': \`%\${searchText}%\` })}` 87 | : '' 88 | } />`, 89 | }, 90 | }; 91 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/guessers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CreateGuesser'; 2 | export * from './EditGuesser'; 3 | export * from './ListGuesser'; 4 | export * from './useCrudGuesser'; 5 | export * from './ShowGuesser'; 6 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/guessers/inferElementFromType.ts: -------------------------------------------------------------------------------- 1 | import { required, type InferredTypeMap } from 'ra-core'; 2 | import { pluralize } from 'inflection'; 3 | import type { OpenAPIV2 } from 'openapi-types'; 4 | import { InferredElement } from './InferredElement'; 5 | 6 | const hasType = (type, types) => typeof types[type] !== 'undefined'; 7 | 8 | export const inferElementFromType = ({ 9 | name, 10 | description, 11 | format, 12 | type, 13 | requiredFields, 14 | types, 15 | props, 16 | schema, 17 | }: { 18 | name: string; 19 | types: InferredTypeMap; 20 | description?: string; 21 | format?: string; 22 | type?: string; 23 | requiredFields?: string[]; 24 | props?: any; 25 | schema: OpenAPIV2.Document; 26 | }) => { 27 | if (name === 'id' && hasType('id', types)) { 28 | return new InferredElement(types.id, { source: 'id' }); 29 | } 30 | const validate = requiredFields?.includes(name) ? [required()] : undefined; 31 | if ( 32 | description?.startsWith('Note:\nThis is a Foreign Key to') && 33 | hasType('reference', types) 34 | ) { 35 | const reference = description.split('`')[1].split('.')[0]; 36 | const referenceResourceDefinition = schema.definitions?.[reference]; 37 | if (!referenceResourceDefinition) { 38 | throw new Error( 39 | `The referenced resource ${reference} is not defined in the API schema` 40 | ); 41 | } 42 | const referenceRecordRepresentationField = 43 | inferRecordRepresentationField(referenceResourceDefinition); 44 | 45 | return new InferredElement( 46 | types.reference, 47 | { 48 | source: name, 49 | reference, 50 | ...props, 51 | }, 52 | hasType('autocompleteInput', types) && 53 | referenceRecordRepresentationField 54 | ? [ 55 | new InferredElement(types.autocompleteInput, { 56 | optionText: referenceRecordRepresentationField, 57 | }), 58 | ] 59 | : undefined, 60 | hasType('autocompleteInput', types) && 61 | !referenceRecordRepresentationField 62 | ? `Could not infer the field to use to filter referenced ${reference} records. Please provide the \`filterToQuery\` prop to the component. See https://github.com/marmelab/ra-supabase/blob/main/packages/ra-supabase/README.md#autocompleteinput-with-references` 63 | : undefined 64 | ); 65 | } 66 | if ( 67 | name.substring(name.length - 4) === '_ids' && 68 | hasType('referenceArray', types) 69 | ) { 70 | const reference = pluralize(name.substr(0, name.length - 4)); 71 | const referenceResourceDefinition = schema.definitions?.[reference]; 72 | if (!referenceResourceDefinition) { 73 | throw new Error( 74 | `The referenced resource ${reference} is not defined in the API schema` 75 | ); 76 | } 77 | const referenceRecordRepresentationField = 78 | inferRecordRepresentationField(referenceResourceDefinition); 79 | 80 | return new InferredElement( 81 | types.referenceArray, 82 | { 83 | source: name, 84 | reference, 85 | ...props, 86 | }, 87 | hasType('autocompleteArrayInput', types) && 88 | referenceRecordRepresentationField 89 | ? [ 90 | new InferredElement(types.autocompleteArrayInput, { 91 | optionText: referenceRecordRepresentationField, 92 | }), 93 | ] 94 | : undefined, 95 | hasType('autocompleteArrayInput', types) && 96 | !referenceRecordRepresentationField 97 | ? `Could not infer the field to use to filter referenced ${reference} records. Please provide the \`filterToQuery\` prop to the component. See https://github.com/marmelab/ra-supabase/blob/main/packages/ra-supabase/README.md#autocompleteinput-with-references` 98 | : undefined 99 | ); 100 | } 101 | if (type === 'array') { 102 | // FIXME instrospect further 103 | return new InferredElement(types.string, { 104 | source: name, 105 | validate, 106 | }); 107 | } 108 | if (type === 'string') { 109 | if (name === 'email' && hasType('email', types)) { 110 | return new InferredElement(types.email, { 111 | source: name, 112 | validate, 113 | ...props, 114 | }); 115 | } 116 | if (['url', 'website'].includes(name) && hasType('url', types)) { 117 | return new InferredElement(types.url, { 118 | source: name, 119 | validate, 120 | ...props, 121 | }); 122 | } 123 | if ( 124 | format && 125 | [ 126 | 'timestamp with time zone', 127 | 'timestamp without time zone', 128 | ].includes(format) && 129 | hasType('date', types) 130 | ) { 131 | return new InferredElement(types.date, { 132 | source: name, 133 | validate, 134 | ...props, 135 | }); 136 | } 137 | } 138 | if (type === 'integer' && hasType('number', types)) { 139 | return new InferredElement(types.number, { 140 | source: name, 141 | validate, 142 | ...props, 143 | }); 144 | } 145 | if (type && hasType(type, types)) { 146 | return new InferredElement(types[type], { 147 | source: name, 148 | validate, 149 | ...props, 150 | }); 151 | } 152 | return new InferredElement(types.string, { 153 | source: name, 154 | validate, 155 | ...props, 156 | }); 157 | }; 158 | 159 | const inferRecordRepresentationField = ( 160 | referenceResourceDefinition: OpenAPIV2.SchemaObject 161 | ) => { 162 | if (referenceResourceDefinition.properties?.name != null) { 163 | return 'name'; 164 | } 165 | if (referenceResourceDefinition.properties?.title != null) { 166 | return 'title'; 167 | } 168 | if (referenceResourceDefinition.properties?.label != null) { 169 | return 'label'; 170 | } 171 | if (referenceResourceDefinition.properties?.reference != null) { 172 | return 'reference'; 173 | } 174 | }; 175 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/guessers/useCrudGuesser.spec.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { DataProviderContext, TestMemoryRouter } from 'ra-core'; 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 4 | import { render, screen, within } from '@testing-library/react'; 5 | 6 | import { useCrudGuesser } from './useCrudGuesser'; 7 | import exampleSchema from './exampleSchema.json'; 8 | 9 | const dataProvider = { 10 | getSchema: async () => exampleSchema, 11 | } as any; 12 | 13 | const wrapper = ({ children }) => ( 14 | 15 | 16 | 17 | {children} 18 | 19 | 20 | 21 | ); 22 | 23 | describe('useAPISchema', () => { 24 | it('should create one resource per path in the schema', async () => { 25 | const Component = () => { 26 | const resources = useCrudGuesser(); 27 | return ( 28 |
    29 | {resources.map(resource => ( 30 |
  • {resource.name}
  • 31 | ))} 32 |
33 | ); 34 | }; 35 | render(, { wrapper }); 36 | const litItems = await screen.findAllByRole('listitem'); 37 | expect(litItems).toHaveLength(8); 38 | }); 39 | 40 | it('should set the CRUD pages according to the available paths', async () => { 41 | const Component = () => { 42 | const resources = useCrudGuesser(); 43 | return ( 44 |
    45 | {resources.map(resource => ( 46 |
  • 47 | {resource.name} 48 |
      49 | {resource.list &&
    • list
    • } 50 | {resource.show &&
    • show
    • } 51 | {resource.edit &&
    • edit
    • } 52 | {resource.create &&
    • create
    • } 53 |
    54 |
  • 55 | ))} 56 |
57 | ); 58 | }; 59 | render(, { wrapper }); 60 | const companies = await screen.findByText('companies'); 61 | expect(within(companies).queryByText('list')).not.toBeNull(); 62 | expect(within(companies).queryByText('show')).not.toBeNull(); 63 | expect(within(companies).queryByText('edit')).not.toBeNull(); 64 | expect(within(companies).queryByText('create')).not.toBeNull(); 65 | 66 | const sales = await screen.findByText('sales'); 67 | expect(within(sales).queryByText('list')).not.toBeNull(); 68 | expect(within(sales).queryByText('show')).not.toBeNull(); 69 | expect(within(sales).queryByText('edit')).toBeNull(); 70 | expect(within(sales).queryByText('create')).toBeNull(); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/guessers/useCrudGuesser.tsx: -------------------------------------------------------------------------------- 1 | import { useMemo } from 'react'; 2 | import { useAPISchema } from 'ra-supabase-core'; 3 | import type { ResourceProps } from 'ra-core'; 4 | 5 | import { ListGuesser } from './ListGuesser'; 6 | import { CreateGuesser } from './CreateGuesser'; 7 | import { EditGuesser } from './EditGuesser'; 8 | import { ShowGuesser } from './ShowGuesser'; 9 | 10 | export const useCrudGuesser = () => { 11 | const { data: schema, error, isPending } = useAPISchema(); 12 | return useMemo(() => { 13 | if (isPending || error) { 14 | return []; 15 | } 16 | const resourceNames = Object.keys(schema.definitions!); 17 | return resourceNames.map(name => { 18 | const resourcePaths = schema.paths[`/${name}`] ?? {}; 19 | return { 20 | name, 21 | list: resourcePaths.get ? ListGuesser : undefined, 22 | show: resourcePaths.get ? ShowGuesser : undefined, 23 | edit: resourcePaths.patch ? EditGuesser : undefined, 24 | create: resourcePaths.post ? CreateGuesser : undefined, 25 | }; 26 | }); 27 | }, [schema, isPending, error]); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AuthLayout'; 2 | export * from './guessers'; 3 | export * from './LoginForm'; 4 | export * from './LoginPage'; 5 | export * from './ForgotPasswordForm'; 6 | export * from './ForgotPasswordPage'; 7 | export * from './SetPasswordForm'; 8 | export * from './SetPasswordPage'; 9 | export * from './SocialAuthButton'; 10 | export * from './icons'; 11 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/src/mui.d.ts: -------------------------------------------------------------------------------- 1 | import type {} from '@mui/material/themeCssVarsAugmentation'; 2 | -------------------------------------------------------------------------------- /packages/ra-supabase-ui-materialui/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "allowJs": false, 9 | "strictNullChecks": true 10 | }, 11 | "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.spec.js"], 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /packages/ra-supabase/assets/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/packages/ra-supabase/assets/demo.png -------------------------------------------------------------------------------- /packages/ra-supabase/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ra-supabase", 3 | "version": "3.5.0", 4 | "repository": "git@github.com:marmelab/ra-supabase.git", 5 | "author": "Gildas Garcia <1122076+djhi@users.noreply.github.com>", 6 | "license": "MIT", 7 | "files": [ 8 | "*.md", 9 | "lib", 10 | "esm", 11 | "src" 12 | ], 13 | "main": "lib/index", 14 | "module": "esm/index.js", 15 | "types": "esm/index.d.ts", 16 | "sideEffects": false, 17 | "dependencies": { 18 | "ra-supabase-core": "^3.5.0", 19 | "ra-supabase-language-english": "^3.5.0", 20 | "ra-supabase-ui-materialui": "^3.5.0" 21 | }, 22 | "devDependencies": { 23 | "rimraf": "^6.0.1", 24 | "typescript": "^5.7.3" 25 | }, 26 | "scripts": { 27 | "build": "yarn run build-cjs && yarn run build-esm", 28 | "build-cjs": "rimraf ./lib && tsc", 29 | "build-esm": "rimraf ./esm && tsc --outDir esm --module es2015", 30 | "watch": "tsc --outDir esm --module es2015 --watch", 31 | "lint": "eslint --fix ./src" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/ra-supabase/src/AdminGuesser.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { 3 | AdminContext, 4 | AdminUI, 5 | Resource, 6 | Loading, 7 | CustomRoutes, 8 | } from 'react-admin'; 9 | import type { AdminProps, AdminUIProps } from 'react-admin'; 10 | import { Route, BrowserRouter } from 'react-router-dom'; 11 | 12 | import { supabaseDataProvider, supabaseAuthProvider } from 'ra-supabase-core'; 13 | import { 14 | useCrudGuesser, 15 | LoginPage, 16 | SetPasswordPage, 17 | ForgotPasswordPage, 18 | } from 'ra-supabase-ui-materialui'; 19 | import { createClient } from '@supabase/supabase-js'; 20 | import { defaultI18nProvider } from './defaultI18nProvider'; 21 | 22 | export const AdminGuesser = ( 23 | props: AdminProps & { instanceUrl: string; apiKey: string } 24 | ) => { 25 | const { 26 | instanceUrl, 27 | apiKey, 28 | dataProvider, 29 | authProvider, 30 | basename, 31 | darkTheme, 32 | defaultTheme, 33 | i18nProvider = defaultI18nProvider, 34 | lightTheme, 35 | queryClient, 36 | store, 37 | theme, 38 | ...rest 39 | } = props; 40 | 41 | const defaultSupabaseClient = 42 | instanceUrl && apiKey ? createClient(instanceUrl, apiKey) : null; 43 | const defaultDataProvider = 44 | instanceUrl && apiKey && defaultSupabaseClient 45 | ? supabaseDataProvider({ 46 | instanceUrl, 47 | apiKey, 48 | supabaseClient: defaultSupabaseClient, 49 | }) 50 | : undefined; 51 | const defaultAuthProvider = 52 | instanceUrl && apiKey && defaultSupabaseClient 53 | ? supabaseAuthProvider(defaultSupabaseClient, {}) 54 | : undefined; 55 | 56 | return ( 57 | 58 | 70 | 71 | 72 | 73 | ); 74 | }; 75 | 76 | const AdminUIGuesser = (props: AdminUIProps) => { 77 | const resourceDefinitions = useCrudGuesser(); 78 | const { children, ...rest } = props; 79 | // while we're guessing, we don't want to show the not found page 80 | const [CatchAll, setCatchAll] = React.useState< 81 | React.ComponentType | undefined 82 | >(() => Loading); 83 | React.useEffect(() => { 84 | if (!children && resourceDefinitions.length > 0) { 85 | console.log( 86 | `Guessed Admin: 87 | 88 | import { Admin, Resource, CustomRoutes } from 'react-admin'; 89 | import { BrowserRouter, Route } from 'react-router-dom'; 90 | import { createClient } from '@supabase/supabase-js'; 91 | import { 92 | CreateGuesser, 93 | EditGuesser, 94 | ForgotPasswordPage, 95 | ListGuesser, 96 | LoginPage, 97 | SetPasswordPage, 98 | ShowGuesser, 99 | defaultI18nProvider, 100 | supabaseDataProvider, 101 | supabaseAuthProvider 102 | } from 'ra-supabase'; 103 | 104 | const instanceUrl = YOUR_SUPABASE_URL; 105 | const apiKey = YOUR_SUPABASE_API_KEY; 106 | const supabaseClient = createClient(instanceUrl, apiKey); 107 | const dataProvider = supabaseDataProvider({ instanceUrl, apiKey, supabaseClient }); 108 | const authProvider = supabaseAuthProvider(supabaseClient, {}); 109 | 110 | export const App = () => ( 111 | 112 | ${resourceDefinitions 118 | .map( 119 | def => ` 120 | ` 125 | ) 126 | .join('')} 127 | 128 | } /> 129 | } /> 130 | 131 | 132 | 133 | );` 134 | ); 135 | } 136 | }, [resourceDefinitions, children]); 137 | 138 | React.useEffect(() => { 139 | // once we have guessed all the resources, we can show the not found page for unknown paths 140 | if (!children && resourceDefinitions.length > 0) { 141 | setCatchAll(undefined); 142 | } 143 | }, [resourceDefinitions, children]); 144 | 145 | const resourceElements = resourceDefinitions.map(resourceDefinition => ( 146 | 147 | )) as any; 148 | 149 | return ( 150 | 156 | {children ?? resourceElements} 157 | 158 | } 161 | /> 162 | } 165 | /> 166 | 167 | 168 | ); 169 | }; 170 | -------------------------------------------------------------------------------- /packages/ra-supabase/src/defaultI18nProvider.ts: -------------------------------------------------------------------------------- 1 | import { mergeTranslations } from 'react-admin'; 2 | import polyglotI18nProvider from 'ra-i18n-polyglot'; 3 | import englishMessages from 'ra-language-english'; 4 | import { raSupabaseEnglishMessages } from 'ra-supabase-language-english'; 5 | 6 | export const defaultI18nProvider = polyglotI18nProvider(() => { 7 | return mergeTranslations(englishMessages, raSupabaseEnglishMessages); 8 | }, 'en'); 9 | -------------------------------------------------------------------------------- /packages/ra-supabase/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from 'ra-supabase-core'; 2 | export * from 'ra-supabase-ui-materialui'; 3 | export * from 'ra-supabase-language-english'; 4 | 5 | export * from './defaultI18nProvider'; 6 | export * from './AdminGuesser'; 7 | -------------------------------------------------------------------------------- /packages/ra-supabase/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "lib", 5 | "rootDir": "src", 6 | "declaration": true, 7 | "declarationMap": true, 8 | "allowJs": false, 9 | "strictNullChecks": true 10 | }, 11 | "exclude": ["**/*.spec.ts", "**/*.spec.tsx", "**/*.spec.js"], 12 | "include": ["src"] 13 | } 14 | -------------------------------------------------------------------------------- /supabase/.gitignore: -------------------------------------------------------------------------------- 1 | # Supabase 2 | .branches 3 | .temp 4 | -------------------------------------------------------------------------------- /supabase/config.toml: -------------------------------------------------------------------------------- 1 | # A string used to distinguish different Supabase projects on the same host. Defaults to the working 2 | # directory name when running `supabase init`. 3 | project_id = "ra-supabase" 4 | 5 | [api] 6 | # Port to use for the API URL. 7 | port = 54321 8 | # Schemas to expose in your API. Tables, views and stored procedures in this schema will get API 9 | # endpoints. public and storage are always included. 10 | schemas = ["public", "storage", "graphql_public"] 11 | # Extra schemas to add to the search_path of every request. public is always included. 12 | extra_search_path = ["public", "extensions"] 13 | # The maximum number of rows returns from a view, table, or stored procedure. Limits payload size 14 | # for accidental or malicious requests. 15 | max_rows = 1000 16 | 17 | [db] 18 | # Port to use for the local database URL. 19 | port = 54322 20 | # The database major version to use. This has to be the same as your remote database's. Run `SHOW 21 | # server_version;` on the remote database to check. 22 | major_version = 15 23 | 24 | [studio] 25 | # Port to use for Supabase Studio. 26 | port = 54323 27 | 28 | # Email testing server. Emails sent with the local dev setup are not actually sent - rather, they 29 | # are monitored, and you can view the emails that would have been sent from the web interface. 30 | [inbucket] 31 | # Port to use for the email testing server web interface. 32 | port = 54324 33 | smtp_port = 54325 34 | pop3_port = 54326 35 | 36 | [storage] 37 | # The maximum file size allowed (e.g. "5MB", "500KB"). 38 | file_size_limit = "50MiB" 39 | 40 | [auth] 41 | # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used 42 | # in emails. 43 | site_url = "http://localhost:8000" 44 | # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. 45 | additional_redirect_urls = ["http://localhost:8000/auth-callback"] 46 | # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one 47 | # week). 48 | jwt_expiry = 3600 49 | # Allow/disallow new user signups to your project. 50 | enable_signup = true 51 | 52 | [auth.email] 53 | # Allow/disallow new user signups via email to your project. 54 | enable_signup = true 55 | # If enabled, a user will be required to confirm any email change on both the old, and new email 56 | # addresses. If disabled, only the new email is required to confirm. 57 | double_confirm_changes = true 58 | # If enabled, users need to confirm their email address before signing in. 59 | enable_confirmations = false 60 | 61 | [auth.email.template.invite] 62 | subject = "You have been invited" 63 | content_path = "./supabase/templates/invite.html" 64 | 65 | [auth.email.template.recovery] 66 | subject = "Reset Password" 67 | content_path = "./supabase/templates/recovery.html" 68 | 69 | # Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`, 70 | # `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `twitch`, 71 | # `twitter`, `slack`, `spotify`, `workos`, `zoom`. 72 | [auth.external.apple] 73 | enabled = false 74 | client_id = "" 75 | secret = "" 76 | # Overrides the default auth redirectUrl. 77 | redirect_uri = "" 78 | # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, 79 | # or any other third-party OIDC providers. 80 | url = "" 81 | -------------------------------------------------------------------------------- /supabase/migrations/20230126140204_init.sql: -------------------------------------------------------------------------------- 1 | create table "public"."companies" ( 2 | "id" bigint generated by default as identity not null, 3 | "name" character varying not null, 4 | "logo" character varying not null, 5 | "sector" character varying not null, 6 | "size" smallint not null, 7 | "linkedIn" character varying not null, 8 | "website" character varying not null, 9 | "phone_number" character varying not null, 10 | "address" character varying not null, 11 | "zipcode" character varying not null, 12 | "city" character varying not null, 13 | "stateAbbr" character varying not null, 14 | "sales_id" bigint not null, 15 | "created_at" timestamp without time zone not null 16 | ); 17 | 18 | 19 | create table "public"."contactNotes" ( 20 | "id" bigint generated by default as identity not null, 21 | "date" timestamp with time zone not null default now(), 22 | "text" character varying not null, 23 | "sales_id" bigint not null, 24 | "status" character varying not null, 25 | "contact_id" bigint 26 | ); 27 | 28 | 29 | create table "public"."contacts" ( 30 | "id" bigint generated by default as identity not null, 31 | "first_name" character varying not null, 32 | "last_name" character varying not null, 33 | "gender" character varying not null, 34 | "title" character varying not null, 35 | "company_id" bigint not null, 36 | "email" character varying not null, 37 | "phone_number1" character varying not null, 38 | "phone_number2" character varying not null, 39 | "background" character varying not null, 40 | "acquisition" character varying not null, 41 | "avatar" character varying null, 42 | "first_seen" timestamp without time zone not null, 43 | "last_seen" timestamp without time zone not null, 44 | "has_newsletter" boolean not null, 45 | "status" character varying not null, 46 | "tags" bigint[] not null, 47 | "sales_id" bigint not null 48 | ); 49 | 50 | 51 | create table "public"."dealNotes" ( 52 | "id" bigint generated by default as identity not null, 53 | "date" timestamp with time zone not null default now(), 54 | "deal_id" bigint not null, 55 | "sales_id" bigint not null, 56 | "type" character varying not null, 57 | "text" character varying not null 58 | ); 59 | 60 | 61 | create table "public"."deals" ( 62 | "id" bigint generated by default as identity not null, 63 | "created_at" timestamp without time zone not null, 64 | "name" character varying not null, 65 | "company_id" bigint not null, 66 | "contact_ids" bigint[] not null, 67 | "type" character varying not null, 68 | "stage" character varying not null, 69 | "description" character varying not null, 70 | "amount" bigint not null, 71 | "updated_at" timestamp without time zone not null, 72 | "start_at" timestamp without time zone not null, 73 | "sales_id" bigint not null, 74 | "index" bigint not null 75 | ); 76 | 77 | 78 | create table "public"."sales" ( 79 | "id" bigint generated by default as identity not null, 80 | "first_name" character varying not null, 81 | "last_name" character varying not null, 82 | "email" character varying not null 83 | ); 84 | 85 | 86 | create table "public"."tags" ( 87 | "id" bigint generated by default as identity not null, 88 | "name" character varying not null, 89 | "color" character varying not null 90 | ); 91 | 92 | 93 | create table "public"."tasks" ( 94 | "id" bigint generated by default as identity not null, 95 | "due_date" timestamp with time zone, 96 | "contact_id" bigint, 97 | "sales_id" bigint, 98 | "text" character varying, 99 | "type" character varying, 100 | "done_date" timestamp with time zone 101 | ); 102 | 103 | 104 | CREATE UNIQUE INDEX companies_pkey ON public.companies USING btree (id); 105 | 106 | CREATE UNIQUE INDEX "contactNotes_pkey" ON public."contactNotes" USING btree (id); 107 | 108 | CREATE UNIQUE INDEX contacts_pkey ON public.contacts USING btree (id); 109 | 110 | CREATE UNIQUE INDEX "dealNotes_pkey" ON public."dealNotes" USING btree (id); 111 | 112 | CREATE UNIQUE INDEX deals_pkey ON public.deals USING btree (id); 113 | 114 | CREATE UNIQUE INDEX sales_pkey ON public.sales USING btree (id); 115 | 116 | CREATE UNIQUE INDEX tags_pkey ON public.tags USING btree (id); 117 | 118 | CREATE UNIQUE INDEX tasks_pkey ON public.tasks USING btree (id); 119 | 120 | alter table "public"."companies" add constraint "companies_pkey" PRIMARY KEY using index "companies_pkey"; 121 | 122 | alter table "public"."contactNotes" add constraint "contactNotes_pkey" PRIMARY KEY using index "contactNotes_pkey"; 123 | 124 | alter table "public"."contacts" add constraint "contacts_pkey" PRIMARY KEY using index "contacts_pkey"; 125 | 126 | alter table "public"."dealNotes" add constraint "dealNotes_pkey" PRIMARY KEY using index "dealNotes_pkey"; 127 | 128 | alter table "public"."deals" add constraint "deals_pkey" PRIMARY KEY using index "deals_pkey"; 129 | 130 | alter table "public"."sales" add constraint "sales_pkey" PRIMARY KEY using index "sales_pkey"; 131 | 132 | alter table "public"."tags" add constraint "tags_pkey" PRIMARY KEY using index "tags_pkey"; 133 | 134 | alter table "public"."tasks" add constraint "tasks_pkey" PRIMARY KEY using index "tasks_pkey"; 135 | 136 | alter table "public"."companies" add constraint "companies_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) not valid; 137 | 138 | alter table "public"."companies" validate constraint "companies_sales_id_fkey"; 139 | 140 | alter table "public"."contactNotes" add constraint "contactNotes_contact_id_fkey" FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE not valid; 141 | 142 | alter table "public"."contactNotes" validate constraint "contactNotes_contact_id_fkey"; 143 | 144 | alter table "public"."contactNotes" add constraint "contactNotes_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) ON DELETE CASCADE not valid; 145 | 146 | alter table "public"."contactNotes" validate constraint "contactNotes_sales_id_fkey"; 147 | 148 | alter table "public"."contacts" add constraint "contacts_company_id_fkey" FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE not valid; 149 | 150 | alter table "public"."contacts" validate constraint "contacts_company_id_fkey"; 151 | 152 | alter table "public"."contacts" add constraint "contacts_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) not valid; 153 | 154 | alter table "public"."contacts" validate constraint "contacts_sales_id_fkey"; 155 | 156 | alter table "public"."dealNotes" add constraint "dealNotes_deal_id_fkey" FOREIGN KEY (deal_id) REFERENCES deals(id) ON DELETE CASCADE not valid; 157 | 158 | alter table "public"."dealNotes" validate constraint "dealNotes_deal_id_fkey"; 159 | 160 | alter table "public"."dealNotes" add constraint "dealNotes_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) not valid; 161 | 162 | alter table "public"."dealNotes" validate constraint "dealNotes_sales_id_fkey"; 163 | 164 | alter table "public"."deals" add constraint "deals_company_id_fkey" FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE not valid; 165 | 166 | alter table "public"."deals" validate constraint "deals_company_id_fkey"; 167 | 168 | alter table "public"."deals" add constraint "deals_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) not valid; 169 | 170 | alter table "public"."deals" validate constraint "deals_sales_id_fkey"; 171 | 172 | alter table "public"."tasks" add constraint "tasks_contact_id_fkey" FOREIGN KEY (contact_id) REFERENCES contacts(id) ON DELETE CASCADE not valid; 173 | 174 | alter table "public"."tasks" validate constraint "tasks_contact_id_fkey"; 175 | 176 | alter table "public"."tasks" add constraint "tasks_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) not valid; 177 | 178 | alter table "public"."tasks" validate constraint "tasks_sales_id_fkey"; 179 | 180 | 181 | -------------------------------------------------------------------------------- /supabase/migrations/20230220132931_fts.sql: -------------------------------------------------------------------------------- 1 | alter table "public"."companies" add column "fts" tsvector generated always as (to_tsvector('english'::regconfig, (((name)::text || ' '::text) || (website)::text))) stored; 2 | 3 | alter table "public"."contacts" add column "fts" tsvector generated always as (to_tsvector('english'::regconfig, (((((first_name)::text || ' '::text) || (last_name)::text) || ' '::text) || (email)::text))) stored; 4 | 5 | alter table "public"."deals" add column "fts" tsvector generated always as (to_tsvector('english'::regconfig, (((name)::text || ' '::text) || (description)::text))) stored; 6 | 7 | CREATE INDEX companies_fts ON public.companies USING gin (fts); 8 | 9 | CREATE INDEX contacts_fts ON public.contacts USING gin (fts); 10 | 11 | CREATE INDEX deals_fts ON public.deals USING gin (fts); 12 | 13 | 14 | -------------------------------------------------------------------------------- /supabase/migrations/20240606161030_allow_nullable.sql: -------------------------------------------------------------------------------- 1 | alter table "public"."companies" alter column "address" drop not null; 2 | alter table "public"."companies" alter column "city" drop not null; 3 | alter table "public"."companies" alter column "created_at" drop not null; 4 | alter table "public"."companies" alter column "linkedIn" drop not null; 5 | alter table "public"."companies" alter column "logo" drop not null; 6 | alter table "public"."companies" alter column "phone_number" drop not null; 7 | alter table "public"."companies" alter column "sales_id" drop not null; 8 | alter table "public"."companies" alter column "sector" drop not null; 9 | alter table "public"."companies" alter column "size" drop not null; 10 | alter table "public"."companies" alter column "stateAbbr" drop not null; 11 | alter table "public"."companies" alter column "website" drop not null; 12 | alter table "public"."companies" alter column "zipcode" drop not null; 13 | alter table "public"."contacts" alter column "acquisition" drop not null; 14 | alter table "public"."contacts" alter column "background" drop not null; 15 | alter table "public"."contacts" alter column "email" drop not null; 16 | alter table "public"."contacts" alter column "first_seen" drop not null; 17 | alter table "public"."contacts" alter column "gender" drop not null; 18 | alter table "public"."contacts" alter column "last_seen" drop not null; 19 | alter table "public"."contacts" alter column "phone_number1" drop not null; 20 | alter table "public"."contacts" alter column "phone_number2" drop not null; 21 | alter table "public"."contacts" alter column "sales_id" drop not null; 22 | alter table "public"."contacts" alter column "status" drop not null; 23 | alter table "public"."contacts" alter column "title" drop not null; 24 | alter table "public"."deals" alter column "amount" drop not null; 25 | alter table "public"."deals" alter column "contact_ids" drop not null; 26 | alter table "public"."deals" alter column "created_at" drop not null; 27 | alter table "public"."deals" alter column "description" drop not null; 28 | alter table "public"."deals" alter column "sales_id" drop not null; 29 | alter table "public"."deals" alter column "start_at" drop not null; 30 | alter table "public"."deals" alter column "type" drop not null; 31 | alter table "public"."deals" alter column "updated_at" drop not null; -------------------------------------------------------------------------------- /supabase/migrations/20250325160944_contact_tags.sql: -------------------------------------------------------------------------------- 1 | alter table "public"."contacts" rename column "tags" TO "tag_ids"; 2 | 3 | 4 | -------------------------------------------------------------------------------- /supabase/seed.sql: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marmelab/ra-supabase/3e06c12c7b916e067a0af5e4a270e0a8a257dd89/supabase/seed.sql -------------------------------------------------------------------------------- /supabase/templates/invite.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

You have been invited

4 |

You have been invited to create a user on {{ .SiteURL }}. Follow this link to accept the invite:

5 |

Accept the invite

6 | 7 | -------------------------------------------------------------------------------- /supabase/templates/recovery.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Reset Password

4 |

Reset your password

5 | 6 | -------------------------------------------------------------------------------- /test-global-setup.js: -------------------------------------------------------------------------------- 1 | module.exports = async () => { 2 | process.env.TZ = 'Europe/Paris'; 3 | }; 4 | -------------------------------------------------------------------------------- /test-setup.js: -------------------------------------------------------------------------------- 1 | // Ignore warnings about act() 2 | // See https://github.com/testing-library/react-testing-library/issues/281, 3 | // https://github.com/facebook/react/issues/14769 4 | import { TextEncoder, TextDecoder } from 'util'; 5 | 6 | const originalError = console.error; 7 | jest.spyOn(console, 'error').mockImplementation((...args) => { 8 | if (/Warning.*not wrapped in act/.test(args[0])) { 9 | return; 10 | } 11 | originalError.call(console, ...args); 12 | }); 13 | 14 | /** 15 | * Mock fetch objects Response, Request and Headers 16 | */ 17 | const { Response, Headers, Request } = require('whatwg-fetch'); 18 | 19 | global.Response = Response; 20 | global.Headers = Headers; 21 | global.Request = Request; 22 | 23 | Object.assign(global, { TextDecoder, TextEncoder }); 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES5" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017','ES2018' or 'ESNEXT'. */, 4 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */, 5 | "lib": [ 6 | "es2017", 7 | "dom" 8 | ] /* Specify library files to be included in the compilation. */, 9 | "declaration": true, 10 | "declarationMap": true, 11 | "allowJs": true, 12 | "jsx": "react" /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */, 13 | "noImplicitAny": false /* Raise error on expressions and declarations with an implied 'any' type. */, 14 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 15 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 16 | "allowSyntheticDefaultImports": true /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */, 17 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 18 | "skipLibCheck": true, 19 | "resolveJsonModule": true 20 | } 21 | } 22 | --------------------------------------------------------------------------------