├── .npmignore ├── api ├── migrations │ ├── .gitignore │ ├── Version20210930074739.php │ └── Version20250306152729.php ├── src │ ├── Entity │ │ ├── .gitignore │ │ ├── Greeting.php │ │ ├── Review.php │ │ └── Book.php │ ├── ApiResource │ │ └── .gitignore │ ├── Controller │ │ └── .gitignore │ ├── Repository │ │ └── .gitignore │ └── Kernel.php ├── config │ ├── packages │ │ ├── cache.yaml │ │ ├── twig.yaml │ │ ├── mercure.yaml │ │ ├── debug.yaml │ │ ├── doctrine_migrations.yaml │ │ ├── routing.yaml │ │ ├── nelmio_cors.yaml │ │ ├── validator.yaml │ │ ├── web_profiler.yaml │ │ ├── api_platform.yaml │ │ ├── doctrine.yaml │ │ ├── framework.yaml │ │ ├── security.yaml │ │ └── monolog.yaml │ ├── routes │ │ ├── api_platform.yaml │ │ ├── security.yaml │ │ ├── framework.yaml │ │ └── web_profiler.yaml │ ├── routes.yaml │ ├── preload.php │ ├── bundles.php │ ├── services.yaml │ └── bootstrap.php ├── frankenphp │ ├── conf.d │ │ ├── app.prod.ini │ │ ├── app.dev.ini │ │ └── app.ini │ ├── worker.Caddyfile │ ├── docker-entrypoint.sh │ └── Caddyfile ├── README.md ├── public │ └── index.php ├── .php-cs-fixer.dist.php ├── .env.test ├── tests │ ├── bootstrap.php │ └── Api │ │ └── GreetingsTest.php ├── .gitignore ├── bin │ └── console ├── .dockerignore ├── templates │ └── base.html.twig ├── phpunit.xml.dist ├── .env ├── Dockerfile └── composer.json ├── .dockerignore ├── .yarnrc.yml ├── tsconfig.eslint.json ├── .gitignore ├── prettier.config.cjs ├── src ├── globals.d.ts ├── mercure │ ├── index.ts │ ├── createSubscription.ts │ ├── useMercureSubscription.ts │ └── manager.ts ├── layout │ ├── index.ts │ ├── Layout.tsx │ ├── LoginPage.tsx │ ├── AppBar.tsx │ └── themes.ts ├── removeTrailingSlash.ts ├── dataProvider │ ├── index.ts │ ├── useUpdateCache.ts │ ├── adminDataProvider.ts │ └── restDataProvider.ts ├── introspection │ ├── useIntrospection.ts │ ├── SchemaAnalyzerContext.ts │ ├── IntrospectionContext.ts │ ├── useIntrospect.ts │ ├── ResourcesIntrospecter.tsx │ ├── schemaAnalyzer.ts │ ├── Introspecter.tsx │ └── getRoutesAndResourcesFromNodes.tsx ├── openapi │ ├── index.ts │ ├── OpenApiAdmin.tsx │ ├── schemaAnalyzer.ts │ └── dataProvider.ts ├── hydra │ ├── index.ts │ ├── HydraAdmin.tsx │ ├── fetchHydra.ts │ ├── schemaAnalyzer.ts │ └── fetchHydra.test.ts ├── useDisplayOverrideCode.ts ├── stories │ ├── auth │ │ ├── Auth.mdx │ │ ├── Admin.tsx │ │ ├── basicAuth.ts │ │ └── Auth.stories.ts │ ├── Basic.tsx │ ├── layout │ │ └── DevtoolsLayout.tsx │ ├── Basic.stories.tsx │ └── custom │ │ └── UsingGuessers.stories.tsx ├── field │ ├── EnumField.tsx │ ├── FieldGuesser.test.tsx │ └── FieldGuesser.tsx ├── getIdentifierValue.ts ├── core │ ├── __snapshots__ │ │ ├── ResourceGuesser.test.tsx.snap │ │ └── AdminGuesser.test.tsx.snap │ ├── AdminResourcesGuesser.tsx │ ├── ResourceGuesser.tsx │ ├── AdminGuesser.test.tsx │ ├── AdminGuesser.tsx │ └── ResourceGuesser.test.tsx ├── index.ts ├── list │ ├── FilterGuesser.tsx │ └── ListGuesser.tsx ├── getIdentifierValue.test.ts ├── show │ └── ShowGuesser.tsx ├── create │ ├── CreateGuesser.tsx │ └── CreateGuesser.test.tsx ├── __fixtures__ │ └── parsedData.ts ├── useOnSubmit.test.tsx ├── edit │ └── EditGuesser.tsx └── useOnSubmit.ts ├── compose.ci.yaml ├── .editorconfig ├── .storybook ├── preview.ts ├── test-runner-jest.ts └── main.ts ├── .babelrc.json ├── .env ├── jest.setup.ts ├── UPGRADE.md ├── jest.config.ts ├── Dockerfile ├── .github └── workflows │ ├── ci.yml │ ├── release.yml │ └── storybook.yml ├── tsconfig.json ├── LICENSE ├── .eslintrc.cjs ├── compose.yaml ├── package.json └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | api 2 | -------------------------------------------------------------------------------- /api/migrations/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/src/Entity/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /api/src/ApiResource/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/src/Controller/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /api/src/Repository/.gitignore: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | -------------------------------------------------------------------------------- /api/config/packages/cache.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | cache: 3 | -------------------------------------------------------------------------------- /tsconfig.eslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [] 4 | } 5 | -------------------------------------------------------------------------------- /api/config/routes/api_platform.yaml: -------------------------------------------------------------------------------- 1 | api_platform: 2 | resource: . 3 | type: api_platform 4 | -------------------------------------------------------------------------------- /api/frankenphp/conf.d/app.prod.ini: -------------------------------------------------------------------------------- 1 | opcache.preload_user = root 2 | opcache.preload = /app/config/preload.php 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /lib/ 2 | node_modules/ 3 | /yarn.lock 4 | .vscode/ 5 | .env.local 6 | .yarn/ 7 | storybook-static/ 8 | .cache/ -------------------------------------------------------------------------------- /api/config/routes/security.yaml: -------------------------------------------------------------------------------- 1 | _security_logout: 2 | resource: security.route_loader.logout 3 | type: service 4 | -------------------------------------------------------------------------------- /api/frankenphp/worker.Caddyfile: -------------------------------------------------------------------------------- 1 | worker { 2 | file ./public/index.php 3 | env APP_RUNTIME Runtime\FrankenPhpSymfony\Runtime 4 | } 5 | -------------------------------------------------------------------------------- /api/config/packages/twig.yaml: -------------------------------------------------------------------------------- 1 | twig: 2 | file_name_pattern: '*.twig' 3 | 4 | when@test: 5 | twig: 6 | strict_variables: true 7 | -------------------------------------------------------------------------------- /api/config/routes.yaml: -------------------------------------------------------------------------------- 1 | controllers: 2 | resource: 3 | path: ../src/Controller/ 4 | namespace: App\Controller 5 | type: attribute 6 | -------------------------------------------------------------------------------- /prettier.config.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true, 3 | trailingComma: 'all', 4 | bracketSameLine: true, 5 | endOfLine: 'auto', 6 | }; 7 | -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # API 2 | 3 | The API will be here. 4 | 5 | Refer to the [Getting Started Guide](https://api-platform.com/docs/distribution) for more information. 6 | -------------------------------------------------------------------------------- /api/config/routes/framework.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | _errors: 3 | resource: '@FrameworkBundle/Resources/config/routing/errors.xml' 4 | prefix: /_error 5 | -------------------------------------------------------------------------------- /src/globals.d.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-var 2 | declare var process: { 3 | env: { 4 | ENTRYPOINT: string; 5 | NODE_ENV: string; 6 | }; 7 | }; 8 | -------------------------------------------------------------------------------- /src/mercure/index.ts: -------------------------------------------------------------------------------- 1 | import createSubscription from './createSubscription.js'; 2 | import manager from './manager.js'; 3 | 4 | export { createSubscription, manager as mercureManager }; 5 | -------------------------------------------------------------------------------- /compose.ci.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | pwa: 3 | build: 4 | context: . 5 | target: ci 6 | environment: 7 | ENTRYPOINT: http://php 8 | volumes: 9 | - /srv/app/node_modules 10 | -------------------------------------------------------------------------------- /src/layout/index.ts: -------------------------------------------------------------------------------- 1 | import Error from './Error.js'; 2 | import Layout from './Layout.js'; 3 | import LoginPage from './LoginPage.js'; 4 | 5 | export { Error, Layout, LoginPage }; 6 | export * from './themes.js'; 7 | -------------------------------------------------------------------------------- /api/config/preload.php: -------------------------------------------------------------------------------- 1 | { 2 | if (url.endsWith('/')) { 3 | return url.slice(0, -1); 4 | } 5 | 6 | return url; 7 | }; 8 | 9 | export default removeTrailingSlash; 10 | -------------------------------------------------------------------------------- /api/public/index.php: -------------------------------------------------------------------------------- 1 | useContext(IntrospectionContext).introspect; 5 | 6 | export default useIntrospection; 7 | -------------------------------------------------------------------------------- /api/src/Kernel.php: -------------------------------------------------------------------------------- 1 | in(__DIR__) 5 | ->exclude('var') 6 | ; 7 | 8 | return (new PhpCsFixer\Config()) 9 | ->setRules([ 10 | '@Symfony' => true, 11 | ]) 12 | ->setFinder($finder) 13 | ; 14 | -------------------------------------------------------------------------------- /api/config/packages/debug.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | debug: 3 | # Forwards VarDumper Data clones to a centralized server allowing to inspect dumps on CLI or in your browser. 4 | # See the "server:dump" command to start a new server. 5 | dump_destination: "tcp://%env(VAR_DUMPER_SERVER)%" 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | end_of_line = LF 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | 8 | [*.{js,ts,tsx}] 9 | indent_style = space 10 | charset = utf-8 11 | indent_size = 2 12 | 13 | [*.yml] 14 | indent_style = space 15 | charset = utf-8 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /src/openapi/index.ts: -------------------------------------------------------------------------------- 1 | import dataProvider from './dataProvider.js'; 2 | import OpenApiAdmin from './OpenApiAdmin.js'; 3 | import type { OpenApiAdminProps } from './OpenApiAdmin.js'; 4 | import schemaAnalyzer from './schemaAnalyzer.js'; 5 | 6 | export { dataProvider, OpenApiAdmin, OpenApiAdminProps, schemaAnalyzer }; 7 | -------------------------------------------------------------------------------- /api/config/routes/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | web_profiler_wdt: 3 | resource: '@WebProfilerBundle/Resources/config/routing/wdt.xml' 4 | prefix: /_wdt 5 | 6 | web_profiler_profiler: 7 | resource: '@WebProfilerBundle/Resources/config/routing/profiler.xml' 8 | prefix: /_profiler 9 | -------------------------------------------------------------------------------- /src/introspection/SchemaAnalyzerContext.ts: -------------------------------------------------------------------------------- 1 | import { 2 | /* tree-shaking no-side-effects-when-called */ createContext, 3 | } from 'react'; 4 | import type { SchemaAnalyzer } from '../types.js'; 5 | 6 | const SchemaAnalyzerContext = createContext(null); 7 | 8 | export default SchemaAnalyzerContext; 9 | -------------------------------------------------------------------------------- /.storybook/preview.ts: -------------------------------------------------------------------------------- 1 | import type { Preview } from '@storybook/react'; 2 | 3 | const preview: Preview = { 4 | parameters: { 5 | controls: { 6 | matchers: { 7 | color: /(background|color)$/i, 8 | date: /Date$/i, 9 | }, 10 | }, 11 | }, 12 | }; 13 | 14 | export default preview; 15 | -------------------------------------------------------------------------------- /api/config/packages/doctrine_migrations.yaml: -------------------------------------------------------------------------------- 1 | doctrine_migrations: 2 | migrations_paths: 3 | # namespace is arbitrary but should be different from App\Migrations 4 | # as migrations classes should NOT be autoloaded 5 | 'DoctrineMigrations': '%kernel.project_dir%/migrations' 6 | enable_profiler: false 7 | -------------------------------------------------------------------------------- /src/introspection/IntrospectionContext.ts: -------------------------------------------------------------------------------- 1 | import { 2 | /* tree-shaking no-side-effects-when-called */ createContext, 3 | } from 'react'; 4 | 5 | // eslint-disable-next-line @typescript-eslint/no-empty-function 6 | const IntrospectionContext = createContext({ introspect: () => {} }); 7 | 8 | export default IntrospectionContext; 9 | -------------------------------------------------------------------------------- /api/.env.test: -------------------------------------------------------------------------------- 1 | # define your env variables for the test env here 2 | KERNEL_CLASS='App\Kernel' 3 | APP_SECRET='$ecretf0rt3st' 4 | SYMFONY_DEPRECATIONS_HELPER=999999 5 | PANTHER_APP_ENV=panther 6 | PANTHER_ERROR_SCREENSHOT_DIR=./var/error-screenshots 7 | 8 | # API Platform distribution 9 | TRUSTED_HOSTS=^example\.com|localhost$ 10 | -------------------------------------------------------------------------------- /.babelrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "sourceType": "unambiguous", 3 | "presets": [ 4 | [ 5 | "@babel/preset-env", 6 | { 7 | "targets": { 8 | "chrome": 100, 9 | "safari": 15, 10 | "firefox": 91 11 | } 12 | } 13 | ], 14 | "@babel/preset-typescript", 15 | "@babel/preset-react" 16 | ], 17 | "plugins": [] 18 | } -------------------------------------------------------------------------------- /api/frankenphp/conf.d/app.dev.ini: -------------------------------------------------------------------------------- 1 | ; See https://docs.docker.com/desktop/networking/#i-want-to-connect-from-a-container-to-a-service-on-the-host 2 | ; See https://github.com/docker/for-linux/issues/264 3 | ; The `client_host` below may optionally be replaced with `discover_client_host=yes` 4 | ; Add `start_with_request=yes` to start debug session on each request 5 | xdebug.client_host = xdebug://gateway 6 | -------------------------------------------------------------------------------- /src/hydra/index.ts: -------------------------------------------------------------------------------- 1 | import dataProvider from './dataProvider.js'; 2 | import fetchHydra from './fetchHydra.js'; 3 | import HydraAdmin from './HydraAdmin.js'; 4 | import type { HydraAdminProps } from './HydraAdmin.js'; 5 | import schemaAnalyzer from './schemaAnalyzer.js'; 6 | 7 | export { 8 | dataProvider, 9 | fetchHydra, 10 | HydraAdmin, 11 | HydraAdminProps, 12 | schemaAnalyzer, 13 | }; 14 | -------------------------------------------------------------------------------- /api/config/packages/routing.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | router: 3 | utf8: true 4 | 5 | # Configure how to generate URLs in non-HTTP contexts, such as CLI commands. 6 | # See https://symfony.com/doc/current/routing.html#generating-urls-in-commands 7 | #default_uri: http://localhost 8 | 9 | when@prod: 10 | framework: 11 | router: 12 | strict_requirements: null 13 | -------------------------------------------------------------------------------- /api/config/packages/nelmio_cors.yaml: -------------------------------------------------------------------------------- 1 | nelmio_cors: 2 | defaults: 3 | origin_regex: true 4 | allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] 5 | allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] 6 | allow_headers: ['Content-Type', 'Authorization', 'Preload', 'Fields'] 7 | expose_headers: ['Link'] 8 | max_age: 3600 9 | paths: 10 | '^/': null 11 | -------------------------------------------------------------------------------- /api/config/packages/validator.yaml: -------------------------------------------------------------------------------- 1 | framework: 2 | validation: 3 | email_validation_mode: html5 4 | 5 | # Enables validator auto-mapping support. 6 | # For instance, basic validation constraints will be inferred from Doctrine's metadata. 7 | #auto_mapping: 8 | # App\Entity\: [] 9 | 10 | when@test: 11 | framework: 12 | validation: 13 | not_compromised_password: false 14 | -------------------------------------------------------------------------------- /api/config/packages/web_profiler.yaml: -------------------------------------------------------------------------------- 1 | when@dev: 2 | web_profiler: 3 | toolbar: true 4 | intercept_redirects: false 5 | 6 | framework: 7 | profiler: 8 | only_exceptions: false 9 | collect_serializer_data: true 10 | 11 | when@test: 12 | web_profiler: 13 | toolbar: false 14 | intercept_redirects: false 15 | 16 | framework: 17 | profiler: { collect: false } 18 | -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * .env contains default values for the environment variables needed by the app 5 | # * .env.local uncommitted file with local overrides 6 | # 7 | # Real environment variables win over .env files. 8 | # 9 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 10 | -------------------------------------------------------------------------------- /api/tests/bootstrap.php: -------------------------------------------------------------------------------- 1 | bootEnv(dirname(__DIR__).'/.env'); 11 | } 12 | 13 | if ($_SERVER['APP_DEBUG']) { 14 | umask(0000); 15 | } 16 | -------------------------------------------------------------------------------- /src/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import type { FunctionComponent } from 'react'; 2 | import React from 'react'; 3 | import { Layout } from 'react-admin'; 4 | import type { LayoutProps } from 'react-admin'; 5 | 6 | import AppBar from './AppBar.js'; 7 | import Error from './Error.js'; 8 | 9 | const CustomLayout = (props: LayoutProps) => ( 10 | 11 | ); 12 | 13 | export default CustomLayout; 14 | -------------------------------------------------------------------------------- /api/frankenphp/conf.d/app.ini: -------------------------------------------------------------------------------- 1 | variables_order = EGPCS 2 | expose_php = 0 3 | date.timezone = UTC 4 | apc.enable_cli = 1 5 | session.use_strict_mode = 1 6 | zend.detect_unicode = 0 7 | 8 | ; https://symfony.com/doc/current/performance.html 9 | realpath_cache_size = 4096K 10 | realpath_cache_ttl = 600 11 | opcache.interned_strings_buffer = 16 12 | opcache.max_accelerated_files = 20000 13 | opcache.memory_consumption = 256 14 | opcache.enable_file_override = 1 15 | -------------------------------------------------------------------------------- /src/useDisplayOverrideCode.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | const useDisplayOverrideCode = () => { 4 | const [displayed, setDisplayed] = useState(false); 5 | 6 | return (code: string) => { 7 | if (process.env.NODE_ENV === 'production') return; 8 | 9 | if (!displayed) { 10 | // eslint-disable-next-line no-console 11 | console.info(code); 12 | setDisplayed(true); 13 | } 14 | }; 15 | }; 16 | 17 | export default useDisplayOverrideCode; 18 | -------------------------------------------------------------------------------- /jest.setup.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line import/no-extraneous-dependencies 2 | import '@testing-library/jest-dom'; 3 | import { TextEncoder, TextDecoder } from 'util'; 4 | // eslint-disable-next-line import/no-extraneous-dependencies 5 | import { Request } from 'node-fetch'; 6 | // eslint-disable-next-line import/no-extraneous-dependencies 7 | import 'cross-fetch/polyfill'; 8 | 9 | global.TextEncoder = TextEncoder; 10 | global.TextDecoder = TextDecoder as any; 11 | global.Request = Request as any; 12 | -------------------------------------------------------------------------------- /UPGRADE.md: -------------------------------------------------------------------------------- 1 | # Upgrade to 4.0 2 | 3 | API Platform Admin 4.0 has the same API as API Platform admin 3.4, but now requires react-admin v5. 4 | 5 | If your application only uses components from the '@api-platform/admin' package, it should work out of the box with API Platform Admin 4.0. 6 | 7 | If you have done some customization based on the 'react-admin' package, you will probably have to make some changes. Read the [UPGRADE guide from react-admin](https://marmelab.com/react-admin/doc/5.0/Upgrade.html) for further details. 8 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | /docker/db/data 2 | 3 | ###> symfony/framework-bundle ### 4 | /.env.local 5 | /.env.local.php 6 | /.env.*.local 7 | /config/secrets/prod/prod.decrypt.private.php 8 | /public/bundles/ 9 | /var/ 10 | /vendor/ 11 | ###< symfony/framework-bundle ### 12 | 13 | ###> friendsofphp/php-cs-fixer ### 14 | /.php-cs-fixer.php 15 | /.php-cs-fixer.cache 16 | ###< friendsofphp/php-cs-fixer ### 17 | 18 | ###> symfony/phpunit-bridge ### 19 | .phpunit.result.cache 20 | /phpunit.xml 21 | ###< symfony/phpunit-bridge ### 22 | -------------------------------------------------------------------------------- /src/stories/auth/Auth.mdx: -------------------------------------------------------------------------------- 1 | import { Canvas, Meta } from '@storybook/blocks'; 2 | 3 | import * as AuthStories from './Auth.stories'; 4 | 5 | 6 | 7 | # Protected HydraAdmin 8 | 9 | The `` component protected by the `authProvider` which is a basic authentication provider. 10 | 11 | > Login with: **john** / **123** 12 | 13 | See the [React-admin documentation](https://marmelab.com/react-admin/Authentication.html) to learn more. 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /src/layout/LoginPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Login, LoginClasses } from 'react-admin'; 3 | import type { LoginProps } from 'react-admin'; 4 | 5 | const LoginPage = (props: LoginProps) => ( 6 | 16 | ); 17 | 18 | export default LoginPage; 19 | -------------------------------------------------------------------------------- /src/stories/Basic.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { HydraAdmin, type HydraAdminProps } from '../hydra'; 3 | import DevtoolsLayout from './layout/DevtoolsLayout'; 4 | 5 | /** 6 | * # Basic `` component 7 | * The `` component without any parameter. 8 | */ 9 | const Basic = ({ entrypoint }: BasicProps) => ( 10 | 11 | ); 12 | 13 | export default Basic; 14 | 15 | export interface BasicProps extends Pick {} 16 | -------------------------------------------------------------------------------- /api/bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | { 7 | const client = useQueryClient(); 8 | return ( 9 | 10 | {children} 11 | 12 | 13 | ); 14 | }; 15 | 16 | export default DevtoolsLayout; 17 | -------------------------------------------------------------------------------- /api/.dockerignore: -------------------------------------------------------------------------------- 1 | **/*.log 2 | **/*.md 3 | **/*.php~ 4 | **/*.dist.php 5 | **/*.dist 6 | **/*.cache 7 | **/._* 8 | **/.dockerignore 9 | **/.DS_Store 10 | **/.git/ 11 | **/.gitattributes 12 | **/.gitignore 13 | **/.gitmodules 14 | **/compose.*.yaml 15 | **/compose.*.yml 16 | **/compose.yaml 17 | **/compose.yml 18 | **/docker-compose.*.yaml 19 | **/docker-compose.*.yml 20 | **/docker-compose.yaml 21 | **/docker-compose.yml 22 | **/Dockerfile 23 | **/Thumbs.db 24 | .github/ 25 | docs/ 26 | public/bundles/ 27 | tests/ 28 | var/ 29 | vendor/ 30 | .editorconfig 31 | .env.*.local 32 | .env.local 33 | .env.local.php 34 | .env.test 35 | -------------------------------------------------------------------------------- /api/config/packages/api_platform.yaml: -------------------------------------------------------------------------------- 1 | api_platform: 2 | title: Hello API Platform 3 | version: 1.0.0 4 | # Mercure integration, remove if unwanted 5 | mercure: 6 | include_type: true 7 | formats: 8 | jsonld: ['application/ld+json'] 9 | json: ["application/json"] 10 | docs_formats: 11 | jsonld: ['application/ld+json'] 12 | jsonopenapi: ['application/vnd.openapi+json'] 13 | html: ['text/html'] 14 | # Good defaults for REST APIs 15 | defaults: 16 | stateless: true 17 | cache_headers: 18 | vary: ['Content-Type', 'Authorization', 'Origin'] 19 | -------------------------------------------------------------------------------- /api/templates/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {% block title %}Welcome!{% endblock %} 6 | 7 | {% block stylesheets %} 8 | {% endblock %} 9 | 10 | {% block javascripts %} 11 | {% endblock %} 12 | 13 | 14 | {% block body %}{% endblock %} 15 | 16 | 17 | -------------------------------------------------------------------------------- /.storybook/test-runner-jest.ts: -------------------------------------------------------------------------------- 1 | import { getJestConfig } from '@storybook/test-runner'; 2 | 3 | /** 4 | * Jest configuration for running tests in Storybook. 5 | */ 6 | module.exports = { 7 | // The default Jest configuration comes from @storybook/test 8 | ...getJestConfig(), 9 | 10 | /** 11 | * Add your own overrides below, and make sure 12 | * to merge testRunnerConfig properties with your own. 13 | * @see https://jestjs.io/docs/configuration 14 | */ 15 | testTimeout: 30000, 16 | testEnvironmentOptions: { 17 | 'jest-playwright': { 18 | launchOptions: { 19 | args: ['--ignore-certificate-errors'] 20 | } 21 | } 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /src/introspection/useIntrospect.ts: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@tanstack/react-query'; 2 | import { useDataProvider } from 'react-admin'; 3 | import type { UseQueryOptions } from '@tanstack/react-query'; 4 | import type { 5 | ApiPlatformAdminDataProvider, 6 | IntrospectPayload, 7 | } from '../types.js'; 8 | 9 | const useIntrospect = (options?: UseQueryOptions) => { 10 | const dataProvider = useDataProvider(); 11 | 12 | return useQuery({ 13 | queryKey: ['introspect'], 14 | queryFn: () => dataProvider.introspect(), 15 | enabled: false, 16 | ...options, 17 | }); 18 | }; 19 | 20 | export default useIntrospect; 21 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { JestConfigWithTsJest } from 'ts-jest'; 2 | 3 | const config: JestConfigWithTsJest = { 4 | extensionsToTreatAsEsm: ['.ts', '.tsx'], 5 | setupFilesAfterEnv: ['./jest.setup.ts'], 6 | testEnvironment: 'jsdom', 7 | testPathIgnorePatterns: ['/lib/'], 8 | maxWorkers: 1, 9 | moduleNameMapper: { 10 | '^(\\.{1,2}/.*/llhttp\\.wasm\\.js)$': '$1', 11 | '^(\\.{1,2}/.*)\\.js$': '$1', 12 | '^@tanstack/react-query$': 13 | '/node_modules/@tanstack/react-query/build/modern/index.cjs', 14 | }, 15 | transform: { 16 | '^.+\\.tsx?$': [ 17 | 'ts-jest', 18 | { 19 | useESM: true, 20 | }, 21 | ], 22 | }, 23 | }; 24 | 25 | export default config; 26 | -------------------------------------------------------------------------------- /src/stories/auth/Admin.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { HydraAdmin, type HydraAdminProps } from '../../hydra'; 3 | import authProvider from './basicAuth'; 4 | import DevtoolsLayout from '../layout/DevtoolsLayout'; 5 | 6 | /** 7 | * # Protected `` 8 | * The `` component protected by the `authProvider` which is a basic authentication provider. 9 | * 10 | * Login with: john/123 11 | */ 12 | const Admin = ({ entrypoint }: JwtAuthProps) => ( 13 | 19 | ); 20 | 21 | export default Admin; 22 | 23 | export interface JwtAuthProps extends Pick {} 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | #syntax=docker/dockerfile:1.4 2 | 3 | 4 | # Versions 5 | FROM node:20-bookworm AS node_upstream 6 | 7 | 8 | # Base stage for dev and build 9 | FROM node_upstream AS base 10 | 11 | WORKDIR /srv/app 12 | 13 | RUN corepack enable && \ 14 | corepack prepare --activate yarn@4 15 | 16 | ENV HOSTNAME localhost 17 | EXPOSE 3000 18 | ENV PORT 3000 19 | 20 | COPY --link package.json yarn.lock .yarnrc.yml ./ 21 | 22 | RUN set -eux; \ 23 | yarn && yarn playwright install --with-deps && yarn cache clean 24 | 25 | # copy sources 26 | COPY --link . ./ 27 | 28 | # Development image 29 | FROM base as dev 30 | 31 | CMD ["sh", "-c", "yarn storybook --no-open"] 32 | 33 | FROM base as ci 34 | 35 | CMD ["sh", "-c", "yarn storybook:build && yarn storybook:serve -p 3000"] 36 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | - pull_request 5 | - push 6 | 7 | jobs: 8 | ci: 9 | name: Continuous integration 10 | runs-on: ubuntu-latest 11 | steps: 12 | - 13 | name: Checkout 14 | uses: actions/checkout@v4 15 | - 16 | name: Setup Node 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: 'current' 20 | - 21 | name: Enable corepack 22 | run: corepack enable 23 | - 24 | name: Install dependencies 25 | run: yarn install 26 | - 27 | name: Check build 28 | run: yarn build 29 | - 30 | name: Check coding standards 31 | run: yarn lint 32 | - 33 | name: Run tests 34 | run: yarn test 35 | -------------------------------------------------------------------------------- /api/tests/Api/GreetingsTest.php: -------------------------------------------------------------------------------- 1 | request('POST', '/greetings', [ 12 | 'json' => [ 13 | 'name' => 'Kévin', 14 | ], 15 | 'headers' => [ 16 | 'Content-Type' => 'application/ld+json', 17 | ], 18 | ]); 19 | 20 | $this->assertResponseStatusCodeSame(201); 21 | $this->assertJsonContains([ 22 | '@context' => '/contexts/Greeting', 23 | '@type' => 'Greeting', 24 | 'name' => 'Kévin', 25 | ]); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES6", 4 | "lib": ["ESNext", "dom", "dom.iterable"], 5 | "module": "esnext", 6 | "moduleResolution": "node", 7 | "sourceMap": true, 8 | "outDir": "./lib", 9 | "declaration": true, 10 | "declarationMap": true, 11 | "rootDir": "./src", 12 | "isolatedModules": true, 13 | "esModuleInterop": true, 14 | "jsx": "react", 15 | "strict": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "noImplicitAny": true, 19 | "noUnusedLocals": true, 20 | "noUnusedParameters": true, 21 | "noImplicitReturns": true, 22 | "noUncheckedIndexedAccess": true 23 | }, 24 | "exclude": ["./src/**/*.test.ts", "./src/**/*.test.tsx"], 25 | "include": ["./src"] 26 | } 27 | -------------------------------------------------------------------------------- /src/layout/AppBar.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AppBar as RaAppBAr, TitlePortal, useAuthProvider } from 'react-admin'; 3 | import type { AppBarProps } from 'react-admin'; 4 | import { Box, useMediaQuery } from '@mui/material'; 5 | import type { Theme } from '@mui/material'; 6 | 7 | import Logo from './Logo.js'; 8 | 9 | const AppBar = ({ classes, userMenu, ...props }: AppBarProps) => { 10 | const authProvider = useAuthProvider(); 11 | const isLargeEnough = useMediaQuery((theme) => 12 | theme.breakpoints.up('sm'), 13 | ); 14 | return ( 15 | 16 | 17 | {isLargeEnough && } 18 | {isLargeEnough && } 19 | 20 | ); 21 | }; 22 | 23 | export default AppBar; 24 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | workflow_dispatch: ~ 8 | 9 | jobs: 10 | release: 11 | name: Create and publish a release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - 15 | name: Checkout 16 | uses: actions/checkout@v4 17 | - 18 | name: Setup Node 19 | uses: actions/setup-node@v4 20 | with: 21 | node-version: 'current' 22 | registry-url: https://registry.npmjs.org 23 | - 24 | name: Install dependencies 25 | run: yarn install 26 | - 27 | name: Check build 28 | run: yarn build 29 | - 30 | name: Check coding standards 31 | run: yarn lint 32 | - 33 | name: Run tests 34 | run: yarn test 35 | - 36 | name: Publish to npm 37 | run: npm publish 38 | env: 39 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 40 | -------------------------------------------------------------------------------- /.storybook/main.ts: -------------------------------------------------------------------------------- 1 | import type { StorybookConfig } from '@storybook/react-webpack5'; 2 | 3 | const config: StorybookConfig = { 4 | stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], 5 | addons: [ 6 | '@storybook/addon-links', 7 | '@storybook/addon-essentials', 8 | '@storybook/addon-onboarding', 9 | '@storybook/addon-interactions', 10 | '@storybook/addon-mdx-gfm', 11 | '@storybook/addon-webpack5-compiler-babel' 12 | ], 13 | framework: { 14 | name: '@storybook/react-webpack5', 15 | options: {}, 16 | }, 17 | docs: { 18 | autodocs: 'tag', 19 | }, 20 | env: (config) => ({ 21 | ...config, 22 | ENTRYPOINT: process.env.ENTRYPOINT ?? 'https://localhost', 23 | }), 24 | async webpackFinal(config, { configType }) { 25 | config.resolve = { 26 | ...config.resolve, 27 | extensionAlias: { 28 | '.js': ['.ts', '.js', '.tsx'], 29 | }, 30 | }; 31 | return config; 32 | }, 33 | }; 34 | 35 | export default config; 36 | -------------------------------------------------------------------------------- /src/stories/auth/basicAuth.ts: -------------------------------------------------------------------------------- 1 | const authProvider = { 2 | login: ({ username, password }: { username: string; password: string }) => { 3 | if (username !== 'john' || password !== '123') { 4 | return Promise.reject(); 5 | } 6 | localStorage.setItem('username', username); 7 | return Promise.resolve(); 8 | }, 9 | logout: () => { 10 | localStorage.removeItem('username'); 11 | return Promise.resolve(); 12 | }, 13 | checkAuth: () => 14 | localStorage.getItem('username') ? Promise.resolve() : Promise.reject(), 15 | checkError: (error: { status: number }) => { 16 | const { status } = error; 17 | if (status === 401 || status === 403) { 18 | return Promise.reject(); 19 | } 20 | // other error code (404, 500, etc): no need to log out 21 | return Promise.resolve(); 22 | }, 23 | getIdentity: () => 24 | Promise.resolve({ 25 | id: 'user', 26 | fullName: 'John Doe', 27 | }), 28 | getPermissions: () => Promise.resolve(''), 29 | }; 30 | 31 | export default authProvider; 32 | -------------------------------------------------------------------------------- /src/field/EnumField.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | ArrayField, 4 | SingleFieldList, 5 | TextField, 6 | useRecordContext, 7 | } from 'react-admin'; 8 | import type { TextFieldProps } from 'react-admin'; 9 | import type { EnumFieldProps } from '../types.js'; 10 | 11 | const EnumField = ({ transformEnum, source, ...props }: EnumFieldProps) => { 12 | const record = useRecordContext(); 13 | 14 | if (!record || typeof source === 'undefined') { 15 | return null; 16 | } 17 | 18 | const value = record[source]; 19 | const enumRecord = { 20 | [source]: (Array.isArray(value) ? value : [value]).map((v) => ({ 21 | value: transformEnum ? transformEnum(v) : v, 22 | })), 23 | }; 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | ); 32 | }; 33 | 34 | EnumField.displayName = 'EnumField'; 35 | 36 | export default EnumField; 37 | -------------------------------------------------------------------------------- /api/config/packages/doctrine.yaml: -------------------------------------------------------------------------------- 1 | doctrine: 2 | dbal: 3 | url: '%env(resolve:DATABASE_URL)%' 4 | 5 | # IMPORTANT: You MUST configure your server version, 6 | # either here or in the DATABASE_URL env var (see .env file) 7 | #server_version: '15' 8 | 9 | profiling_collect_backtrace: '%kernel.debug%' 10 | orm: 11 | auto_generate_proxy_classes: true 12 | enable_lazy_ghost_objects: true 13 | report_fields_where_declared: true 14 | validate_xml_mapping: true 15 | naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware 16 | auto_mapping: true 17 | mappings: 18 | App: 19 | type: attribute 20 | is_bundle: false 21 | dir: '%kernel.project_dir%/src/Entity' 22 | prefix: 'App\Entity' 23 | alias: App 24 | 25 | when@test: 26 | doctrine: 27 | dbal: 28 | # "TEST_TOKEN" is typically set by ParaTest 29 | dbname_suffix: '_test%env(default::TEST_TOKEN)%' 30 | -------------------------------------------------------------------------------- /api/config/bundles.php: -------------------------------------------------------------------------------- 1 | ['all' => true], 5 | Symfony\Bundle\SecurityBundle\SecurityBundle::class => ['all' => true], 6 | Symfony\Bundle\MercureBundle\MercureBundle::class => ['all' => true], 7 | Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], 8 | ApiPlatform\Symfony\Bundle\ApiPlatformBundle::class => ['all' => true], 9 | Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], 10 | // Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], 11 | Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], 12 | Symfony\Bundle\DebugBundle\DebugBundle::class => ['dev' => true, 'test' => true], 13 | Symfony\Bundle\MonologBundle\MonologBundle::class => ['all' => true], 14 | Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true], 15 | Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true], 16 | Symfony\Bundle\WebProfilerBundle\WebProfilerBundle::class => ['dev' => true, 'test' => true], 17 | ]; 18 | -------------------------------------------------------------------------------- /api/config/packages/framework.yaml: -------------------------------------------------------------------------------- 1 | # see https://symfony.com/doc/current/reference/configuration/framework.html 2 | framework: 3 | secret: '%env(APP_SECRET)%' 4 | #csrf_protection: true 5 | annotations: false 6 | http_method_override: false 7 | handle_all_throwables: true 8 | 9 | trusted_proxies: '%env(TRUSTED_PROXIES)%' 10 | trusted_hosts: '%env(TRUSTED_HOSTS)%' 11 | # See https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#headers 12 | trusted_headers: [ 'x-forwarded-for', 'x-forwarded-proto' ] 13 | 14 | # Enables session support. Note that the session will ONLY be started if you read or write from it. 15 | # Remove or comment this section to explicitly disable session support. 16 | #session: 17 | # handler_id: null 18 | # cookie_secure: auto 19 | # cookie_samesite: lax 20 | 21 | #esi: true 22 | #fragments: true 23 | php_errors: 24 | log: true 25 | 26 | when@test: 27 | framework: 28 | test: true 29 | #session: 30 | # storage_factory_id: session.storage.factory.mock_file 31 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT license 2 | 3 | Copyright (c) 2020 Kévin Dunglas 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 furnished 10 | 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 NONINFRINGEMENT. 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /api/src/Entity/Greeting.php: -------------------------------------------------------------------------------- 1 | 'partial'])] 16 | #[ApiFilter(OrderFilter::class, properties: ['name'], arguments: ['orderParameterName' => 'order'])] 17 | class Greeting 18 | { 19 | /** 20 | * The entity ID 21 | */ 22 | #[ORM\Id] 23 | #[ORM\Column(type: 'integer')] 24 | #[ORM\GeneratedValue(strategy: 'SEQUENCE')] 25 | #[ORM\SequenceGenerator(sequenceName: 'greeting_seq', initialValue: 1, allocationSize: 100)] 26 | private ?int $id = null; 27 | 28 | /** 29 | * A nice person 30 | */ 31 | #[ORM\Column] 32 | #[Assert\NotBlank] 33 | public string $name = ''; 34 | 35 | public function getId(): ?int 36 | { 37 | return $this->id; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /api/migrations/Version20210930074739.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE greeting (id INT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(id))'); 24 | $this->addSql('CREATE SEQUENCE greeting_seq INCREMENT BY 100 MINVALUE 1 START 1'); 25 | } 26 | 27 | public function down(Schema $schema): void 28 | { 29 | // this down() migration is auto-generated, please modify it to your needs 30 | $this->addSql('CREATE SCHEMA public'); 31 | $this->addSql('DROP SEQUENCE greeting_seq CASCADE'); 32 | $this->addSql('DROP TABLE greeting'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /api/config/services.yaml: -------------------------------------------------------------------------------- 1 | # This file is the entry point to configure your own services. 2 | # Files in the packages/ subdirectory configure your dependencies. 3 | 4 | # Put parameters here that don't need to change on each machine where the app is deployed 5 | # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration 6 | parameters: 7 | 8 | services: 9 | # default configuration for services in *this* file 10 | _defaults: 11 | autowire: true # Automatically injects dependencies in your services. 12 | autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. 13 | 14 | # makes classes in src/ available to be used as services 15 | # this creates a service per class whose id is the fully-qualified class name 16 | App\: 17 | resource: '../src/' 18 | exclude: 19 | - '../src/DependencyInjection/' 20 | - '../src/Entity/' 21 | - '../src/Kernel.php' 22 | 23 | # add more service definitions when explicit configuration is needed 24 | # please note that last definitions always *replace* previous ones 25 | -------------------------------------------------------------------------------- /src/getIdentifierValue.ts: -------------------------------------------------------------------------------- 1 | import type { Field } from '@api-platform/api-doc-parser'; 2 | import type { SchemaAnalyzer } from './types.js'; 3 | 4 | export const isIdentifier = (field: Field, fieldType: string) => 5 | ['integer_id', 'id'].includes(fieldType) || field.name === 'id'; 6 | 7 | const getIdentifierValue = ( 8 | schemaAnalyzer: SchemaAnalyzer, 9 | resource: string, 10 | fields: Field[], 11 | fieldName: string, 12 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 13 | value: any, 14 | ) => { 15 | const prefix = `/${resource}/`; 16 | 17 | if (typeof value === 'string' && value.indexOf(prefix) === 0) { 18 | const field = fields.find((fieldObj) => fieldObj.name === fieldName); 19 | if (!field) { 20 | return value; 21 | } 22 | const fieldType = schemaAnalyzer.getFieldType(field); 23 | if (isIdentifier(field, fieldType)) { 24 | const id = value.substring(prefix.length); 25 | if (['integer_id', 'integer'].includes(fieldType)) { 26 | return parseInt(id, 10); 27 | } 28 | return id; 29 | } 30 | } 31 | 32 | return value; 33 | }; 34 | 35 | export default getIdentifierValue; 36 | -------------------------------------------------------------------------------- /api/config/bootstrap.php: -------------------------------------------------------------------------------- 1 | =1.2) 13 | if (is_array($env = @include dirname(__DIR__).'/.env.local.php') && (!isset($env['APP_ENV']) || ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? $env['APP_ENV']) === $env['APP_ENV'])) { 14 | (new Dotenv())->usePutenv(false)->populate($env); 15 | } else { 16 | // load all the .env files 17 | (new Dotenv())->usePutenv(false)->loadEnv(dirname(__DIR__).'/.env'); 18 | } 19 | 20 | $_SERVER += $_ENV; 21 | $_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = ($_SERVER['APP_ENV'] ?? $_ENV['APP_ENV'] ?? null) ?: 'dev'; 22 | $_SERVER['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? $_ENV['APP_DEBUG'] ?? 'prod' !== $_SERVER['APP_ENV']; 23 | $_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = (int) $_SERVER['APP_DEBUG'] || filter_var($_SERVER['APP_DEBUG'], FILTER_VALIDATE_BOOLEAN) ? '1' : '0'; 24 | -------------------------------------------------------------------------------- /src/hydra/HydraAdmin.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import dataProviderFactory from './dataProvider.js'; 3 | import /* tree-shaking no-side-effects-when-called */ schemaAnalyzer from './schemaAnalyzer.js'; 4 | import AdminGuesser from '../core/AdminGuesser.js'; 5 | import type { AdminGuesserProps } from '../core/AdminGuesser.js'; 6 | import type { MercureOptions } from '../types.js'; 7 | 8 | type AdminGuesserPartialProps = Omit< 9 | AdminGuesserProps, 10 | 'dataProvider' | 'schemaAnalyzer' 11 | > & 12 | Partial>; 13 | 14 | export interface HydraAdminProps extends AdminGuesserPartialProps { 15 | entrypoint: string; 16 | mercure?: MercureOptions | boolean; 17 | } 18 | 19 | const hydraSchemaAnalyzer = schemaAnalyzer(); 20 | 21 | const HydraAdmin = ({ 22 | entrypoint, 23 | mercure, 24 | dataProvider = dataProviderFactory({ 25 | entrypoint, 26 | mercure: mercure ?? true, 27 | }), 28 | schemaAnalyzer: adminSchemaAnalyzer = hydraSchemaAnalyzer, 29 | ...props 30 | }: HydraAdminProps) => ( 31 | 36 | ); 37 | 38 | export default HydraAdmin; 39 | -------------------------------------------------------------------------------- /src/stories/Basic.stories.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { Meta, StoryObj } from '@storybook/react'; 3 | import { within } from '@storybook/test'; 4 | import { HydraAdmin, type HydraAdminProps } from '../hydra'; 5 | import { OpenApiAdmin } from '../openapi'; 6 | import DevtoolsLayout from './layout/DevtoolsLayout'; 7 | 8 | /** 9 | * # Basic `` component 10 | * The `` component without any parameter. 11 | */ 12 | const Basic = ({ entrypoint }: BasicProps) => ( 13 | 14 | ); 15 | 16 | interface BasicProps extends Pick {} 17 | 18 | const meta = { 19 | title: 'Admin/Basic', 20 | component: Basic, 21 | parameters: { 22 | layout: 'fullscreen', 23 | }, 24 | } satisfies Meta; 25 | 26 | export default meta; 27 | 28 | type Story = StoryObj; 29 | 30 | export const Hydra: Story = { 31 | play: async ({ canvasElement }) => { 32 | const canvas = within(canvasElement); 33 | await canvas.findByText('Greetings'); 34 | }, 35 | args: { 36 | entrypoint: process.env.ENTRYPOINT, 37 | }, 38 | }; 39 | 40 | export const OpenApi = () => ( 41 | 45 | ); 46 | -------------------------------------------------------------------------------- /api/phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | tests 23 | 24 | 25 | 26 | 27 | 28 | src 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/core/__snapshots__/ResourceGuesser.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders with create 1`] = ` 4 | 9 | `; 10 | 11 | exports[` renders with edit 1`] = ` 12 | 17 | `; 18 | 19 | exports[` renders with list 1`] = ` 20 | 25 | `; 26 | 27 | exports[` renders with show 1`] = ` 28 | 33 | `; 34 | 35 | exports[` renders without create 1`] = ` 36 | 40 | `; 41 | 42 | exports[` renders without edit 1`] = ` 43 | 47 | `; 48 | 49 | exports[` renders without list 1`] = ` 50 | 54 | `; 55 | 56 | exports[` renders without show 1`] = ` 57 | 61 | `; 62 | -------------------------------------------------------------------------------- /src/openapi/OpenApiAdmin.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import dataProviderFactory from './dataProvider.js'; 3 | import { restDataProvider } from '../dataProvider/index.js'; 4 | import /* tree-shaking no-side-effects-when-called */ schemaAnalyzer from './schemaAnalyzer.js'; 5 | import AdminGuesser from '../core/AdminGuesser.js'; 6 | import type { AdminGuesserProps } from '../core/AdminGuesser.js'; 7 | import type { MercureOptions } from '../types.js'; 8 | 9 | type AdminGuesserPartialProps = Omit< 10 | AdminGuesserProps, 11 | 'dataProvider' | 'schemaAnalyzer' 12 | > & 13 | Partial>; 14 | 15 | export interface OpenApiAdminProps extends AdminGuesserPartialProps { 16 | entrypoint: string; 17 | docEntrypoint: string; 18 | mercure?: MercureOptions | false; 19 | } 20 | 21 | const openApiSchemaAnalyzer = schemaAnalyzer(); 22 | 23 | const OpenApiAdmin = ({ 24 | entrypoint, 25 | docEntrypoint, 26 | mercure, 27 | dataProvider = dataProviderFactory({ 28 | dataProvider: restDataProvider(entrypoint), 29 | entrypoint, 30 | docEntrypoint, 31 | mercure: mercure ?? false, 32 | }), 33 | schemaAnalyzer: adminSchemaAnalyzer = openApiSchemaAnalyzer, 34 | ...props 35 | }: OpenApiAdminProps) => ( 36 | 41 | ); 42 | 43 | export default OpenApiAdmin; 44 | -------------------------------------------------------------------------------- /src/mercure/createSubscription.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | ApiPlatformAdminRecord, 3 | DataTransformer, 4 | MercureOptions, 5 | MercureSubscription, 6 | } from '../types.js'; 7 | 8 | const createSubscription = ( 9 | mercure: MercureOptions, 10 | topic: string, 11 | callback: (document: ApiPlatformAdminRecord) => void, 12 | transformData: DataTransformer = (parsedData) => 13 | parsedData as ApiPlatformAdminRecord, 14 | ): MercureSubscription => { 15 | if (mercure.hub === null) { 16 | return { 17 | subscribed: false, 18 | topic, 19 | callback, 20 | count: 1, 21 | }; 22 | } 23 | 24 | const url = new URL(mercure.hub, window.origin); 25 | url.searchParams.append('topic', new URL(topic, mercure.topicUrl).toString()); 26 | 27 | if (mercure.jwt !== null) { 28 | document.cookie = `mercureAuthorization=${mercure.jwt}; Path=${mercure.hub}; Secure; SameSite=None`; 29 | } 30 | 31 | const eventSource = new EventSource(url.toString(), { 32 | withCredentials: mercure.jwt !== null, 33 | }); 34 | const eventListener = (event: MessageEvent) => { 35 | const document = transformData(JSON.parse(event.data)); 36 | // this callback is for updating RA's state 37 | callback(document); 38 | }; 39 | eventSource.addEventListener('message', eventListener); 40 | 41 | return { 42 | subscribed: true, 43 | topic, 44 | callback, 45 | eventSource, 46 | eventListener, 47 | count: 1, 48 | }; 49 | }; 50 | 51 | export default createSubscription; 52 | -------------------------------------------------------------------------------- /api/frankenphp/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | 4 | if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then 5 | if [ -z "$(ls -A 'vendor/' 2>/dev/null)" ]; then 6 | composer install --prefer-dist --no-progress --no-interaction 7 | fi 8 | 9 | if grep -q ^DATABASE_URL= .env; then 10 | echo "Waiting for database to be ready..." 11 | ATTEMPTS_LEFT_TO_REACH_DATABASE=60 12 | until [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ] || DATABASE_ERROR=$(php bin/console dbal:run-sql -q "SELECT 1" 2>&1); do 13 | if [ $? -eq 255 ]; then 14 | # If the Doctrine command exits with 255, an unrecoverable error occurred 15 | ATTEMPTS_LEFT_TO_REACH_DATABASE=0 16 | break 17 | fi 18 | sleep 1 19 | ATTEMPTS_LEFT_TO_REACH_DATABASE=$((ATTEMPTS_LEFT_TO_REACH_DATABASE - 1)) 20 | echo "Still waiting for database to be ready... Or maybe the database is not reachable. $ATTEMPTS_LEFT_TO_REACH_DATABASE attempts left." 21 | done 22 | 23 | if [ $ATTEMPTS_LEFT_TO_REACH_DATABASE -eq 0 ]; then 24 | echo "The database is not up or not reachable:" 25 | echo "$DATABASE_ERROR" 26 | exit 1 27 | else 28 | echo "The database is now ready and reachable" 29 | fi 30 | 31 | if [ "$( find ./migrations -iname '*.php' -print -quit )" ]; then 32 | php bin/console doctrine:migrations:migrate --no-interaction 33 | fi 34 | fi 35 | 36 | setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var 37 | setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var 38 | fi 39 | 40 | exec docker-php-entrypoint "$@" 41 | -------------------------------------------------------------------------------- /src/stories/auth/Auth.stories.ts: -------------------------------------------------------------------------------- 1 | import type { Meta, StoryObj } from '@storybook/react'; 2 | import { userEvent, within } from '@storybook/test'; 3 | 4 | import Admin from './Admin'; 5 | 6 | const meta = { 7 | title: 'Admin/Auth', 8 | component: Admin, 9 | parameters: { 10 | layout: 'fullscreen', 11 | }, 12 | } satisfies Meta; 13 | 14 | export default meta; 15 | 16 | type Story = StoryObj; 17 | 18 | export const Basic: Story = { 19 | play: async ({ canvasElement, step }) => { 20 | const canvas = within(canvasElement); 21 | await canvas.findByText('Sign in'); 22 | await step('Enter email and password', async () => { 23 | await userEvent.type(canvas.getByLabelText('Username *'), 'john'); 24 | await userEvent.type(canvas.getByLabelText('Password *'), '123'); 25 | }); 26 | }, 27 | args: { 28 | entrypoint: process.env.ENTRYPOINT, 29 | }, 30 | }; 31 | 32 | export const Loggedin: Story = { 33 | play: async ({ canvasElement, step }) => { 34 | const canvas = within(canvasElement); 35 | const signIn = await canvas.findByText('Sign in'); 36 | await step('Enter email and password', async () => { 37 | await userEvent.type(canvas.getByLabelText('Username *'), 'john'); 38 | await userEvent.type(canvas.getByLabelText('Password *'), '123'); 39 | }); 40 | 41 | await step('Submit form', async () => { 42 | await userEvent.click(signIn); 43 | }); 44 | 45 | await canvas.findByText('John Doe'); 46 | }, 47 | args: { 48 | entrypoint: process.env.ENTRYPOINT, 49 | }, 50 | }; 51 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import AdminGuesser from './core/AdminGuesser.js'; 2 | import CreateGuesser from './create/CreateGuesser.js'; 3 | import EditGuesser from './edit/EditGuesser.js'; 4 | import FieldGuesser from './field/FieldGuesser.js'; 5 | import InputGuesser from './input/InputGuesser.js'; 6 | import Introspecter from './introspection/Introspecter.js'; 7 | import ListGuesser from './list/ListGuesser.js'; 8 | import ResourceGuesser from './core/ResourceGuesser.js'; 9 | import SchemaAnalyzerContext from './introspection/SchemaAnalyzerContext.js'; 10 | import ShowGuesser from './show/ShowGuesser.js'; 11 | import useIntrospect from './introspection/useIntrospect.js'; 12 | import useIntrospection from './introspection/useIntrospection.js'; 13 | import useMercureSubscription from './mercure/useMercureSubscription.js'; 14 | import useOnSubmit from './useOnSubmit.js'; 15 | 16 | export { 17 | AdminGuesser, 18 | CreateGuesser, 19 | EditGuesser, 20 | FieldGuesser, 21 | InputGuesser, 22 | Introspecter, 23 | ListGuesser, 24 | ResourceGuesser, 25 | SchemaAnalyzerContext, 26 | ShowGuesser, 27 | useIntrospect, 28 | useIntrospection, 29 | useMercureSubscription, 30 | useOnSubmit, 31 | }; 32 | export { 33 | HydraAdmin, 34 | dataProvider as hydraDataProvider, 35 | schemaAnalyzer as hydraSchemaAnalyzer, 36 | fetchHydra, 37 | } from './hydra/index.js'; 38 | export type { HydraAdminProps } from './hydra/index.js'; 39 | export { darkTheme, lightTheme } from './layout/index.js'; 40 | export { 41 | OpenApiAdmin, 42 | dataProvider as openApiDataProvider, 43 | schemaAnalyzer as openApiSchemaAnalyzer, 44 | } from './openapi/index.js'; 45 | export type { OpenApiAdminProps } from './openapi/index.js'; 46 | export * from './types.js'; 47 | -------------------------------------------------------------------------------- /src/mercure/useMercureSubscription.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | import { useDataProvider } from 'react-admin'; 3 | import type { Identifier } from 'react-admin'; 4 | import { useUpdateCache } from '../dataProvider/index.js'; 5 | import type { ApiPlatformAdminDataProvider } from '../types.js'; 6 | 7 | export default function useMercureSubscription( 8 | resource: string | undefined, 9 | idOrIds: Identifier | Identifier[] | undefined, 10 | ) { 11 | const dataProvider: ApiPlatformAdminDataProvider = useDataProvider(); 12 | const updateCache = useUpdateCache(); 13 | 14 | const hasShownNoSubscribeWarning = useRef(false); 15 | 16 | useEffect(() => { 17 | if (!idOrIds || !resource) { 18 | return undefined; 19 | } 20 | const ids = Array.isArray(idOrIds) 21 | ? idOrIds.map((id) => id.toString()) 22 | : [idOrIds.toString()]; 23 | 24 | if ( 25 | !hasShownNoSubscribeWarning.current && 26 | (dataProvider.subscribe === undefined || 27 | dataProvider.unsubscribe === undefined) 28 | ) { 29 | // eslint-disable-next-line no-console 30 | console.warn( 31 | 'subscribe and/or unsubscribe methods were not set in the data provider, Mercure realtime update functionalities will not work. Please use a compatible data provider.', 32 | ); 33 | hasShownNoSubscribeWarning.current = true; 34 | return undefined; 35 | } 36 | 37 | dataProvider.subscribe(ids, (document) => { 38 | updateCache({ resource, id: document.id, data: document }); 39 | }); 40 | 41 | return () => { 42 | if (resource) { 43 | dataProvider.unsubscribe(resource, ids); 44 | } 45 | }; 46 | }, [idOrIds, resource, dataProvider, updateCache]); 47 | } 48 | -------------------------------------------------------------------------------- /api/migrations/Version20250306152729.php: -------------------------------------------------------------------------------- 1 | addSql('CREATE TABLE book (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, isbn VARCHAR(255) DEFAULT NULL, title VARCHAR(255) NOT NULL, description TEXT NOT NULL, author VARCHAR(255) NOT NULL, publication_date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, PRIMARY KEY(id))'); 24 | $this->addSql('CREATE TABLE review (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, rating SMALLINT NOT NULL, body TEXT NOT NULL, author VARCHAR(255) NOT NULL, publication_date TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, book_id INT DEFAULT NULL, PRIMARY KEY(id))'); 25 | $this->addSql('CREATE INDEX IDX_794381C616A2B381 ON review (book_id)'); 26 | $this->addSql('ALTER TABLE review ADD CONSTRAINT FK_794381C616A2B381 FOREIGN KEY (book_id) REFERENCES book (id) NOT DEFERRABLE INITIALLY IMMEDIATE'); 27 | } 28 | 29 | public function down(Schema $schema): void 30 | { 31 | // this down() migration is auto-generated, please modify it to your needs 32 | $this->addSql('CREATE SCHEMA public'); 33 | $this->addSql('ALTER TABLE review DROP CONSTRAINT FK_794381C616A2B381'); 34 | $this->addSql('DROP TABLE book'); 35 | $this->addSql('DROP TABLE review'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/introspection/ResourcesIntrospecter.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { ResourcesIntrospecterProps } from '../types.js'; 3 | 4 | const ResourcesIntrospecter = ({ 5 | component: Component, 6 | schemaAnalyzer, 7 | includeDeprecated, 8 | resource, 9 | resources, 10 | loading, 11 | error, 12 | ...rest 13 | }: ResourcesIntrospecterProps) => { 14 | if (loading) { 15 | return null; 16 | } 17 | 18 | if (error) { 19 | if (process.env.NODE_ENV === 'production') { 20 | // eslint-disable-next-line no-console 21 | console.error(error); 22 | } 23 | 24 | throw new Error('API schema is not readable'); 25 | } 26 | 27 | const schema = resources.find((r) => r.name === resource); 28 | 29 | if (!schema?.fields || !schema?.readableFields || !schema?.writableFields) { 30 | if (process.env.NODE_ENV === 'production') { 31 | // eslint-disable-next-line no-console 32 | console.error(`Resource ${resource} not present inside API description`); 33 | } 34 | 35 | throw new Error(`Resource ${resource} not present inside API description`); 36 | } 37 | 38 | const fields = includeDeprecated 39 | ? schema.fields 40 | : schema.fields.filter(({ deprecated }) => !deprecated); 41 | const readableFields = includeDeprecated 42 | ? schema.readableFields 43 | : schema.readableFields.filter(({ deprecated }) => !deprecated); 44 | const writableFields = includeDeprecated 45 | ? schema.writableFields 46 | : schema.writableFields.filter(({ deprecated }) => !deprecated); 47 | 48 | return ( 49 | 58 | ); 59 | }; 60 | 61 | export default ResourcesIntrospecter; 62 | -------------------------------------------------------------------------------- /api/config/packages/security.yaml: -------------------------------------------------------------------------------- 1 | security: 2 | # https://symfony.com/doc/current/security.html#registering-the-user-hashing-passwords 3 | password_hashers: 4 | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto' 5 | # https://symfony.com/doc/current/security.html#loading-the-user-the-user-provider 6 | providers: 7 | users_in_memory: { memory: null } 8 | firewalls: 9 | dev: 10 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 11 | security: false 12 | main: 13 | lazy: true 14 | provider: users_in_memory 15 | 16 | # activate different ways to authenticate 17 | # https://symfony.com/doc/current/security.html#the-firewall 18 | 19 | # https://symfony.com/doc/current/security/impersonating_user.html 20 | # switch_user: true 21 | 22 | # Easy way to control access for large sections of your site 23 | # Note: Only the *first* access control that matches will be used 24 | access_control: 25 | # - { path: ^/admin, roles: ROLE_ADMIN } 26 | # - { path: ^/profile, roles: ROLE_USER } 27 | 28 | when@test: 29 | security: 30 | password_hashers: 31 | # By default, password hashers are resource intensive and take time. This is 32 | # important to generate secure password hashes. In tests however, secure hashes 33 | # are not important, waste resources and increase test times. The following 34 | # reduces the work factor to the lowest possible values. 35 | Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 36 | algorithm: auto 37 | cost: 4 # Lowest possible value for bcrypt 38 | time_cost: 3 # Lowest possible value for argon 39 | memory_cost: 10 # Lowest possible value for argon 40 | -------------------------------------------------------------------------------- /src/list/FilterGuesser.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { Filter, useResourceContext } from 'react-admin'; 3 | import InputGuesser from '../input/InputGuesser.js'; 4 | import Introspecter from '../introspection/Introspecter.js'; 5 | import type { 6 | FilterGuesserProps, 7 | FilterParameter, 8 | IntrospectedFiterGuesserProps, 9 | } from '../types.js'; 10 | 11 | /** 12 | * Adds filters based on the #ApiFilters attribute 13 | * 14 | * @see https://api-platform.com/docs/core/filters/ 15 | */ 16 | export const IntrospectedFilterGuesser = ({ 17 | fields, 18 | readableFields, 19 | writableFields, 20 | schema, 21 | schemaAnalyzer, 22 | ...rest 23 | }: IntrospectedFiterGuesserProps) => { 24 | const [filtersParameters, setFiltersParameters] = useState( 25 | [], 26 | ); 27 | 28 | useEffect(() => { 29 | if (schema) { 30 | schemaAnalyzer 31 | .getFiltersParametersFromSchema(schema) 32 | .then((parameters) => { 33 | setFiltersParameters(parameters); 34 | }); 35 | } 36 | }, [schema, schemaAnalyzer]); 37 | 38 | if (!filtersParameters.length) { 39 | return null; 40 | } 41 | 42 | return ( 43 | 44 | {filtersParameters.map((filter) => ( 45 | 50 | ))} 51 | 52 | ); 53 | }; 54 | 55 | const FilterGuesser = (props: FilterGuesserProps) => { 56 | const resource = useResourceContext(props); 57 | if (!resource) { 58 | throw new Error('FilterGuesser must be used with a resource'); 59 | } 60 | 61 | return ( 62 | 67 | ); 68 | }; 69 | 70 | export default FilterGuesser; 71 | -------------------------------------------------------------------------------- /.github/workflows/storybook.yml: -------------------------------------------------------------------------------- 1 | name: Storybook 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: ~ 8 | workflow_dispatch: ~ 9 | 10 | concurrency: 11 | group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | tests: 16 | name: Tests 17 | runs-on: ubuntu-latest 18 | steps: 19 | - 20 | name: Checkout 21 | uses: actions/checkout@v4 22 | - 23 | name: Set up Docker Buildx 24 | uses: docker/setup-buildx-action@v3 25 | - 26 | name: Build Docker images 27 | uses: docker/bake-action@v5 28 | with: 29 | pull: true 30 | load: true 31 | files: | 32 | compose.yaml 33 | compose.ci.yaml 34 | set: | 35 | *.cache-from=type=gha,scope=${{github.ref}} 36 | *.cache-from=type=gha,scope=refs/heads/main 37 | *.cache-to=type=gha,scope=${{github.ref}},mode=max 38 | - 39 | name: Start services 40 | run: docker compose -f compose.yaml -f compose.ci.yaml up --wait --no-build 41 | - 42 | name: Create test database 43 | run: docker compose exec -T php bin/console -e test doctrine:database:create 44 | - 45 | name: Run migrations 46 | run: docker compose exec -T php bin/console -e test doctrine:migrations:migrate --no-interaction 47 | - 48 | name: Run interactions tests 49 | run: docker compose exec -T pwa yarn storybook:test --url http://127.0.0.1:3000 --maxWorkers 1 50 | 51 | lint: 52 | name: Docker Lint 53 | runs-on: ubuntu-latest 54 | steps: 55 | - 56 | name: Checkout 57 | uses: actions/checkout@v4 58 | - 59 | name: Lint Dockerfiles 60 | uses: hadolint/hadolint-action@v3.1.0 61 | with: 62 | recursive: true 63 | 64 | -------------------------------------------------------------------------------- /src/layout/themes.ts: -------------------------------------------------------------------------------- 1 | import { defaultTheme } from 'react-admin'; 2 | import type { RaThemeOptions } from 'react-admin'; 3 | 4 | export const darkTheme: RaThemeOptions = { 5 | ...defaultTheme, 6 | palette: { 7 | ...defaultTheme.palette, 8 | background: { 9 | default: '#424242', 10 | }, 11 | primary: { 12 | contrastText: '#ffffff', 13 | main: '#52c9d4', 14 | light: '#9bf5fe', 15 | dark: '#21a1ae', 16 | }, 17 | secondary: { 18 | ...defaultTheme.palette?.secondary, 19 | main: '#51b2bc', 20 | }, 21 | mode: 'dark', 22 | }, 23 | components: { 24 | ...defaultTheme.components, 25 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 26 | // @ts-ignore react-admin doesn't add its own components 27 | RaMenuItemLink: { 28 | styleOverrides: { 29 | root: { 30 | borderLeft: '3px solid #000', 31 | '&.RaMenuItemLink-active': { 32 | borderLeft: '3px solid #52c9d4', 33 | }, 34 | }, 35 | }, 36 | }, 37 | MuiFilledInput: { 38 | styleOverrides: undefined, 39 | }, 40 | }, 41 | }; 42 | 43 | export const lightTheme: RaThemeOptions = { 44 | ...defaultTheme, 45 | palette: { 46 | ...defaultTheme.palette, 47 | primary: { 48 | contrastText: '#ffffff', 49 | main: '#38a9b4', 50 | light: '#74dde7', 51 | dark: '#006a75', 52 | }, 53 | secondary: { 54 | ...defaultTheme.palette?.secondary, 55 | main: '#288690', 56 | }, 57 | mode: 'light', 58 | }, 59 | components: { 60 | ...defaultTheme.components, 61 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 62 | // @ts-ignore react-admin doesn't add its own components 63 | RaMenuItemLink: { 64 | styleOverrides: { 65 | root: { 66 | borderLeft: '3px solid #fff', 67 | '&.RaMenuItemLink-active': { 68 | borderLeft: '3px solid #38a9b4', 69 | }, 70 | }, 71 | }, 72 | }, 73 | }, 74 | }; 75 | -------------------------------------------------------------------------------- /src/openapi/schemaAnalyzer.ts: -------------------------------------------------------------------------------- 1 | import type { Field, Resource } from '@api-platform/api-doc-parser'; 2 | import { 3 | getFiltersParametersFromSchema, 4 | getOrderParametersFromSchema, 5 | } from '../introspection/schemaAnalyzer.js'; 6 | import type { SchemaAnalyzer } from '../types.js'; 7 | 8 | /** 9 | * @param schema The schema of a resource 10 | * 11 | * @returns The name of the reference field 12 | */ 13 | const getFieldNameFromSchema = (schema: Resource) => { 14 | if (!schema.fields?.[0]) { 15 | return ''; 16 | } 17 | 18 | if (schema.fields.find((schemaField) => schemaField.name === 'id')) { 19 | return 'id'; 20 | } 21 | 22 | return schema.fields[0].name; 23 | }; 24 | 25 | /** 26 | * @returns The type of the field 27 | */ 28 | const getFieldType = (field: Field) => { 29 | switch (field.type) { 30 | case 'array': 31 | return 'array'; 32 | case 'string': 33 | case 'byte': 34 | case 'binary': 35 | case 'hexBinary': 36 | case 'base64Binary': 37 | case 'uuid': 38 | case 'password': 39 | return 'text'; 40 | case 'integer': 41 | case 'negativeInteger': 42 | case 'nonNegativeInteger': 43 | case 'positiveInteger': 44 | case 'nonPositiveInteger': 45 | return 'integer'; 46 | case 'number': 47 | case 'decimal': 48 | case 'double': 49 | case 'float': 50 | return 'float'; 51 | case 'boolean': 52 | return 'boolean'; 53 | case 'date': 54 | return 'date'; 55 | case 'dateTime': 56 | case 'duration': 57 | case 'time': 58 | return 'dateTime'; 59 | case 'email': 60 | return 'email'; 61 | case 'url': 62 | return 'url'; 63 | default: 64 | return 'text'; 65 | } 66 | }; 67 | 68 | const getSubmissionErrors = () => null; 69 | 70 | export default function schemaAnalyzer(): SchemaAnalyzer { 71 | return { 72 | getFieldNameFromSchema, 73 | getOrderParametersFromSchema, 74 | getFiltersParametersFromSchema, 75 | getFieldType, 76 | getSubmissionErrors, 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/core/__snapshots__/AdminGuesser.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[` renders loading 1`] = ``; 4 | 5 | exports[` renders with custom resources 1`] = ` 6 | 25 | 28 | 29 | `; 30 | 31 | exports[` renders without custom resources 1`] = ` 32 | 51 | 54 | 55 | `; 56 | 57 | exports[` renders without data 1`] = ` 58 | 77 | `; 78 | -------------------------------------------------------------------------------- /api/src/Entity/Review.php: -------------------------------------------------------------------------------- 1 | 'ASC', 20 | 'rating' => 'ASC', 21 | 'author' => 'ASC', 22 | 'publicationDate' => 'DESC' 23 | ])] 24 | #[ApiFilter(SearchFilter::class, properties: [ 25 | 'id' => 'exact', 26 | 'body' => 'ipartial', 27 | 'author' => 'ipartial' 28 | ])] 29 | #[ApiFilter(NumericFilter::class, properties: ['rating'])] 30 | #[ApiFilter(DateFilter::class, properties: ['publicationDate'])] 31 | class Review 32 | { 33 | /** The ID of this review */ 34 | #[ORM\Id] 35 | #[ORM\Column] 36 | #[ORM\GeneratedValue] 37 | private ?int $id = null; 38 | 39 | /** The rating of this review (between 0 and 5) */ 40 | #[ORM\Column(type: 'smallint')] 41 | #[Assert\Range(min: 0, max: 5)] 42 | public int $rating = 0; 43 | 44 | /** The body of this review */ 45 | #[ORM\Column(type: 'text')] 46 | #[Assert\NotBlank] 47 | public string $body = ''; 48 | 49 | /** The author of this review */ 50 | #[ORM\Column] 51 | #[Assert\NotBlank] 52 | public string $author = ''; 53 | 54 | /** The publication date of this review */ 55 | #[ORM\Column] 56 | #[Assert\NotNull] 57 | #[ApiProperty(iris: ['http://schema.org/name'])] 58 | public ?\DateTimeImmutable $publicationDate = null; 59 | 60 | /** The book this review is about */ 61 | #[ORM\ManyToOne(inversedBy: 'reviews')] 62 | #[Assert\NotNull] 63 | public ?Book $book = null; 64 | 65 | public function getId(): ?int 66 | { 67 | return $this->id; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/introspection/schemaAnalyzer.ts: -------------------------------------------------------------------------------- 1 | import type { Resource } from '@api-platform/api-doc-parser'; 2 | import type { FilterParameter } from '../types.js'; 3 | 4 | /** 5 | * @param schema The schema of a resource 6 | * 7 | * @returns The filter parameters 8 | */ 9 | export const resolveSchemaParameters = (schema: Resource) => { 10 | if (!schema.parameters || !schema.getParameters) { 11 | return Promise.resolve([]); 12 | } 13 | 14 | return !schema.parameters.length 15 | ? schema.getParameters() 16 | : Promise.resolve(schema.parameters); 17 | }; 18 | 19 | const ORDER_MARKER = 'order['; 20 | 21 | /** 22 | * @param schema The schema of a resource 23 | * 24 | * @returns The order filter parameters 25 | */ 26 | export const getOrderParametersFromSchema = ( 27 | schema: Resource, 28 | ): Promise => { 29 | if (!schema.fields) { 30 | return Promise.resolve([]); 31 | } 32 | 33 | const authorizedFields = schema.fields.map((field) => field.name); 34 | return resolveSchemaParameters(schema).then((parameters) => 35 | parameters 36 | .map((filter) => filter.variable) 37 | .filter((filter) => filter.includes(ORDER_MARKER)) 38 | .map((orderFilter) => 39 | orderFilter.replace(ORDER_MARKER, '').replace(']', ''), 40 | ) 41 | .filter((filter) => 42 | authorizedFields.includes( 43 | filter.split('.')[0] ?? '', // split to manage nested properties 44 | ), 45 | ), 46 | ); 47 | }; 48 | 49 | /** 50 | * @param schema The schema of a resource 51 | * 52 | * @returns The filter parameters without the order ones 53 | */ 54 | export const getFiltersParametersFromSchema = ( 55 | schema: Resource, 56 | ): Promise => { 57 | if (!schema.fields) { 58 | return Promise.resolve([]); 59 | } 60 | 61 | const authorizedFields = schema.fields.map((field) => field.name); 62 | return resolveSchemaParameters(schema).then((parameters) => 63 | parameters 64 | .map((filter) => ({ 65 | name: filter.variable, 66 | isRequired: filter.required, 67 | })) 68 | .filter((filter) => !filter.name.includes(ORDER_MARKER)) 69 | .filter((filter) => authorizedFields.includes(filter.name)), 70 | ); 71 | }; 72 | -------------------------------------------------------------------------------- /api/config/packages/monolog.yaml: -------------------------------------------------------------------------------- 1 | monolog: 2 | channels: 3 | - deprecation # Deprecations are logged in the dedicated "deprecation" channel when it exists 4 | 5 | when@dev: 6 | monolog: 7 | handlers: 8 | main: 9 | type: stream 10 | path: "%kernel.logs_dir%/%kernel.environment%.log" 11 | level: debug 12 | channels: ["!event"] 13 | # uncomment to get logging in your browser 14 | # you may have to allow bigger header sizes in your Web server configuration 15 | #firephp: 16 | # type: firephp 17 | # level: info 18 | #chromephp: 19 | # type: chromephp 20 | # level: info 21 | console: 22 | type: console 23 | process_psr_3_messages: false 24 | channels: ["!event", "!doctrine", "!console"] 25 | 26 | when@test: 27 | monolog: 28 | handlers: 29 | main: 30 | type: fingers_crossed 31 | action_level: error 32 | handler: nested 33 | excluded_http_codes: [404, 405] 34 | channels: ["!event"] 35 | nested: 36 | type: stream 37 | path: "%kernel.logs_dir%/%kernel.environment%.log" 38 | level: debug 39 | 40 | when@prod: 41 | monolog: 42 | handlers: 43 | main: 44 | type: fingers_crossed 45 | action_level: error 46 | handler: nested 47 | excluded_http_codes: [404, 405] 48 | buffer_size: 50 # How many messages should be saved? Prevent memory leaks 49 | nested: 50 | type: stream 51 | path: php://stderr 52 | level: debug 53 | formatter: monolog.formatter.json 54 | console: 55 | type: console 56 | process_psr_3_messages: false 57 | channels: ["!event", "!doctrine"] 58 | deprecation: 59 | type: stream 60 | channels: [deprecation] 61 | path: php://stderr 62 | formatter: monolog.formatter.json 63 | -------------------------------------------------------------------------------- /src/dataProvider/useUpdateCache.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import type { GetListResult, Identifier } from 'react-admin'; 3 | import { useQueryClient } from '@tanstack/react-query'; 4 | import type { ApiPlatformAdminRecord } from '../types.js'; 5 | 6 | const useUpdateCache = () => { 7 | const queryClient = useQueryClient(); 8 | 9 | // From https://github.com/marmelab/react-admin/blob/next/packages/ra-core/src/dataProvider/useUpdate.ts 10 | return useCallback( 11 | ({ 12 | resource, 13 | id, 14 | data, 15 | }: { 16 | resource: string; 17 | id: Identifier; 18 | data: ApiPlatformAdminRecord; 19 | }) => { 20 | const updateColl = (old: ApiPlatformAdminRecord[]) => { 21 | const index = old.findIndex( 22 | // eslint-disable-next-line eqeqeq 23 | (record) => record.id == id, 24 | ); 25 | if (index === -1) { 26 | return old; 27 | } 28 | return [ 29 | ...old.slice(0, index), 30 | { ...old[index], ...data }, 31 | ...old.slice(index + 1), 32 | ]; 33 | }; 34 | 35 | queryClient.setQueryData( 36 | [resource, 'getOne', { id: String(id) }], 37 | (record: ApiPlatformAdminRecord | undefined) => ({ 38 | ...record, 39 | ...data, 40 | }), 41 | ); 42 | queryClient.setQueriesData( 43 | { queryKey: [resource, 'getList'] }, 44 | (res: GetListResult | undefined) => 45 | res?.data 46 | ? { data: updateColl(res.data), total: res.total } 47 | : { data: [data] }, 48 | ); 49 | queryClient.setQueriesData( 50 | { queryKey: [resource, 'getMany'] }, 51 | (coll: ApiPlatformAdminRecord[] | undefined) => 52 | coll && coll.length > 0 ? updateColl(coll) : [data], 53 | ); 54 | queryClient.setQueriesData( 55 | { queryKey: [resource, 'getManyReference'] }, 56 | (res: GetListResult | undefined) => 57 | res?.data 58 | ? { data: updateColl(res.data), total: res.total } 59 | : { data: [data] }, 60 | ); 61 | }, 62 | [queryClient], 63 | ); 64 | }; 65 | 66 | export default useUpdateCache; 67 | -------------------------------------------------------------------------------- /api/src/Entity/Book.php: -------------------------------------------------------------------------------- 1 | 'ASC', 20 | 'isbn' => 'ASC', 21 | 'title' => 'ASC', 22 | 'author' => 'ASC', 23 | 'publicationDate' => 'DESC' 24 | ])] 25 | #[ApiFilter(SearchFilter::class, properties: [ 26 | 'id' => 'exact', 27 | 'title' => 'ipartial', 28 | 'author' => 'ipartial' 29 | ])] 30 | #[ApiFilter(DateFilter::class, properties: ['publicationDate'])] 31 | class Book 32 | { 33 | /** The ID of this book */ 34 | #[ORM\Id] 35 | #[ORM\Column] 36 | #[ORM\GeneratedValue] 37 | private ?int $id = null; 38 | 39 | /** The ISBN of this book (or null if doesn't have one) */ 40 | #[ORM\Column(nullable: true)] 41 | public ?string $isbn = null; 42 | 43 | /** The title of this book */ 44 | #[ORM\Column] 45 | #[Assert\NotBlank] 46 | #[ApiProperty(iris: ['http://schema.org/name'])] 47 | public string $title = ''; 48 | 49 | /** The description of this book */ 50 | #[ORM\Column(type: 'text')] 51 | #[Assert\NotBlank] 52 | public string $description = ''; 53 | 54 | /** The author of this book */ 55 | #[ORM\Column] 56 | #[Assert\NotBlank] 57 | public string $author = ''; 58 | 59 | /** The publication date of this book */ 60 | #[ORM\Column] 61 | #[Assert\NotNull] 62 | public ?\DateTimeImmutable $publicationDate = null; 63 | 64 | /** @var Review[] Available reviews for this book */ 65 | #[ORM\OneToMany(mappedBy: 'book', targetEntity: Review::class, cascade: ['persist', 'remove'])] 66 | public iterable $reviews; 67 | 68 | public function __construct() 69 | { 70 | $this->reviews = new ArrayCollection(); 71 | } 72 | 73 | public function getId(): ?int 74 | { 75 | return $this->id; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['tree-shaking', 'prettier'], 5 | extends: [ 6 | 'airbnb', 7 | 'airbnb/hooks', 8 | 'prettier', 9 | 'plugin:markdown/recommended', 10 | 'plugin:storybook/recommended' 11 | ], 12 | rules: { 13 | 'prettier/prettier': 'error', 14 | }, 15 | overrides: [ 16 | { 17 | files: ['*.ts', '*.tsx'], 18 | excludedFiles: ['jest.config.ts', 'jest.setup.ts'], 19 | extends: [ 20 | 'airbnb', 21 | 'airbnb-typescript', 22 | 'airbnb/hooks', 23 | 'plugin:@typescript-eslint/recommended', 24 | // @TODO Fix the problems when activating this config. 25 | // 'plugin:@typescript-eslint/recommended-requiring-type-checking', 26 | 'prettier', 27 | ], 28 | parserOptions: { 29 | project: ['./tsconfig.eslint.json'], 30 | }, 31 | rules: { 32 | '@typescript-eslint/no-use-before-define': 'error', 33 | '@typescript-eslint/consistent-type-imports': 'error', 34 | '@typescript-eslint/prefer-nullish-coalescing': 'error', 35 | '@typescript-eslint/no-confusing-void-expression': 'error', 36 | '@typescript-eslint/prefer-optional-chain': 'error', 37 | '@typescript-eslint/no-unused-vars': [ 38 | 'warn', 39 | { ignoreRestSiblings: true, argsIgnorePattern: '^_' }, 40 | ], 41 | 'sort-imports': ['error', { ignoreDeclarationSort: true }], 42 | 'react/function-component-definition': [ 43 | 'error', 44 | { 45 | namedComponents: 'arrow-function', 46 | unnamedComponents: 'function-expression', 47 | }, 48 | ], 49 | 50 | 'import/no-extraneous-dependencies': 'off', 51 | 'react/require-default-props': 'off', 52 | 'react/jsx-props-no-spreading': 'off', 53 | 'react/forbid-prop-types': 'off', 54 | 'react/default-props-match-prop-types': 'off', 55 | }, 56 | }, 57 | { 58 | files: ['*.ts', '*.tsx'], 59 | excludedFiles: [ 60 | 'jest.config.ts', 61 | 'jest.setup.ts', 62 | '*.test.ts', 63 | '*.test.tsx', 64 | ], 65 | rules: { 66 | 'tree-shaking/no-side-effects-in-initialization': 'error', 67 | }, 68 | }, 69 | ], 70 | }; 71 | -------------------------------------------------------------------------------- /src/mercure/manager.ts: -------------------------------------------------------------------------------- 1 | import createSubscription from './createSubscription.js'; 2 | import type { 3 | ApiPlatformAdminRecord, 4 | DataTransformer, 5 | MercureOptions, 6 | MercureSubscription, 7 | } from '../types.js'; 8 | 9 | // store mercure subscriptions 10 | const subscriptions: Record = {}; 11 | let mercure: MercureOptions | null = null; 12 | let dataTransform: DataTransformer = (parsedData) => 13 | parsedData as ApiPlatformAdminRecord; 14 | 15 | const stopSubscription = (sub: MercureSubscription) => { 16 | if (sub.subscribed && sub.eventSource && sub.eventListener) { 17 | sub.eventSource.removeEventListener('message', sub.eventListener); 18 | sub.eventSource.close(); 19 | } 20 | }; 21 | 22 | export default { 23 | subscribe: ( 24 | resourceId: string, 25 | topic: string, 26 | callback: (document: ApiPlatformAdminRecord) => void, 27 | ) => { 28 | if (mercure === null) { 29 | return; 30 | } 31 | 32 | const sub = subscriptions[resourceId]; 33 | if (sub !== undefined) { 34 | sub.count += 1; 35 | return; 36 | } 37 | 38 | subscriptions[resourceId] = createSubscription( 39 | mercure, 40 | topic, 41 | callback, 42 | dataTransform, 43 | ); 44 | }, 45 | unsubscribe: (resourceId: string) => { 46 | const sub = subscriptions[resourceId]; 47 | if (sub === undefined) { 48 | return; 49 | } 50 | 51 | sub.count -= 1; 52 | 53 | if (sub.count <= 0) { 54 | stopSubscription(sub); 55 | delete subscriptions[resourceId]; 56 | } 57 | }, 58 | initSubscriptions: () => { 59 | const mercureOptions = mercure; 60 | if (mercureOptions === null) { 61 | return; 62 | } 63 | 64 | const subKeys = Object.keys(subscriptions); 65 | subKeys.forEach((subKey) => { 66 | const sub = subscriptions[subKey]; 67 | if (sub && !sub.subscribed) { 68 | subscriptions[subKey] = createSubscription( 69 | mercureOptions, 70 | sub.topic, 71 | sub.callback, 72 | dataTransform, 73 | ); 74 | } 75 | }); 76 | }, 77 | setMercureOptions: (mercureOptions: MercureOptions | null) => { 78 | mercure = mercureOptions; 79 | }, 80 | setDataTransformer: (dataTransformer: DataTransformer) => { 81 | dataTransform = dataTransformer; 82 | }, 83 | }; 84 | -------------------------------------------------------------------------------- /src/getIdentifierValue.test.ts: -------------------------------------------------------------------------------- 1 | import { Field } from '@api-platform/api-doc-parser'; 2 | import getIdentifierValue from './getIdentifierValue.js'; 3 | import { 4 | getFiltersParametersFromSchema, 5 | getOrderParametersFromSchema, 6 | } from './introspection/schemaAnalyzer.js'; 7 | import type { SchemaAnalyzer } from './types.js'; 8 | 9 | const schemaAnalyzer: SchemaAnalyzer = { 10 | getFiltersParametersFromSchema, 11 | getOrderParametersFromSchema, 12 | getFieldNameFromSchema: () => 'fieldName', 13 | getSubmissionErrors: () => null, 14 | getFieldType: (field) => { 15 | if (field.name === 'stringId') { 16 | return 'id'; 17 | } 18 | if (field.name === 'intId') { 19 | return 'integer_id'; 20 | } 21 | 22 | return 'text'; 23 | }, 24 | }; 25 | 26 | test('Get identifier from a non string value', () => { 27 | expect(getIdentifierValue(schemaAnalyzer, 'foo', [], 'description', 46)).toBe( 28 | 46, 29 | ); 30 | }); 31 | 32 | test('Get identifier from a non prefixed value', () => { 33 | expect( 34 | getIdentifierValue(schemaAnalyzer, 'foo', [], 'description', 'lorem'), 35 | ).toBe('lorem'); 36 | }); 37 | 38 | test('Get identifier from a not found field', () => { 39 | expect( 40 | getIdentifierValue(schemaAnalyzer, 'foo', [], 'id', '/foo/fooId'), 41 | ).toBe('/foo/fooId'); 42 | }); 43 | 44 | test('Get identifier from a non identifier field', () => { 45 | expect( 46 | getIdentifierValue( 47 | schemaAnalyzer, 48 | 'foo', 49 | [new Field('description')], 50 | 'description', 51 | '/foo/fooId', 52 | ), 53 | ).toBe('/foo/fooId'); 54 | }); 55 | 56 | test('Get identifier from an identifier field', () => { 57 | expect( 58 | getIdentifierValue( 59 | schemaAnalyzer, 60 | 'foo', 61 | [new Field('stringId')], 62 | 'stringId', 63 | '/foo/fooId', 64 | ), 65 | ).toBe('fooId'); 66 | }); 67 | 68 | test('Get identifier from an "id" field', () => { 69 | expect( 70 | getIdentifierValue( 71 | schemaAnalyzer, 72 | 'foo', 73 | [new Field('id')], 74 | 'id', 75 | '/foo/fooId', 76 | ), 77 | ).toBe('fooId'); 78 | }); 79 | 80 | test('Get identifier from an integer identifier field', () => { 81 | expect( 82 | getIdentifierValue( 83 | schemaAnalyzer, 84 | 'foo', 85 | [new Field('intId')], 86 | 'intId', 87 | '/foo/76', 88 | ), 89 | ).toBe(76); 90 | }); 91 | -------------------------------------------------------------------------------- /src/introspection/Introspecter.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useMemo } from 'react'; 2 | import { useLogoutIfAccessDenied } from 'react-admin'; 3 | 4 | import SchemaAnalyzerContext from './SchemaAnalyzerContext.js'; 5 | import useIntrospect from './useIntrospect.js'; 6 | import type { IntrospecterProps, SchemaAnalyzer } from '../types.js'; 7 | import ResourcesIntrospecter from './ResourcesIntrospecter.js'; 8 | 9 | const Introspecter = ({ 10 | component, 11 | includeDeprecated = false, 12 | resource, 13 | ...rest 14 | }: IntrospecterProps) => { 15 | const logoutIfAccessDenied = useLogoutIfAccessDenied(); 16 | const schemaAnalyzer = useContext( 17 | SchemaAnalyzerContext, 18 | ); 19 | const schemaAnalyzerProxy = useMemo(() => { 20 | if (!schemaAnalyzer) { 21 | return null; 22 | } 23 | return new Proxy(schemaAnalyzer, { 24 | get: (target, key: keyof SchemaAnalyzer) => { 25 | if (typeof target[key] !== 'function') { 26 | return target[key]; 27 | } 28 | 29 | return (...args: never[]) => { 30 | // eslint-disable-next-line prefer-spread,@typescript-eslint/ban-types 31 | const result = (target[key] as Function).apply(target, args); 32 | 33 | if (result && typeof result.then === 'function') { 34 | return result.catch((e: Error) => { 35 | logoutIfAccessDenied(e).then((loggedOut) => { 36 | if (loggedOut) { 37 | return; 38 | } 39 | 40 | throw e; 41 | }); 42 | }); 43 | } 44 | 45 | return result; 46 | }; 47 | }, 48 | }); 49 | }, [schemaAnalyzer, logoutIfAccessDenied]); 50 | 51 | const { refetch, data, isPending, error } = useIntrospect(); 52 | const resources = data ? data.data.resources : null; 53 | 54 | useEffect(() => { 55 | if (!error && !resources) { 56 | refetch(); 57 | } 58 | }, [refetch, error, resources]); 59 | 60 | if (!schemaAnalyzerProxy) { 61 | return null; 62 | } 63 | 64 | return ( 65 | 75 | ); 76 | }; 77 | 78 | export default Introspecter; 79 | -------------------------------------------------------------------------------- /api/.env: -------------------------------------------------------------------------------- 1 | # In all environments, the following files are loaded if they exist, 2 | # the latter taking precedence over the former: 3 | # 4 | # * .env contains default values for the environment variables needed by the app 5 | # * .env.local uncommitted file with local overrides 6 | # * .env.$APP_ENV committed environment-specific defaults 7 | # * .env.$APP_ENV.local uncommitted environment-specific overrides 8 | # 9 | # Real environment variables win over .env files. 10 | # 11 | # DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES. 12 | # https://symfony.com/doc/current/configuration/secrets.html 13 | # 14 | # Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2). 15 | # https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration 16 | 17 | # API Platform distribution 18 | TRUSTED_PROXIES=127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 19 | TRUSTED_HOSTS=^(localhost|php)$ 20 | 21 | ###> symfony/framework-bundle ### 22 | APP_ENV=dev 23 | APP_SECRET=!ChangeMe! 24 | ###< symfony/framework-bundle ### 25 | 26 | ###> doctrine/doctrine-bundle ### 27 | # Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url 28 | # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml 29 | # 30 | # DATABASE_URL="sqlite:///%kernel.project_dir%/var/data.db" 31 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4" 32 | # DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4" 33 | DATABASE_URL="postgresql://app:!ChangeMe!@database:5432/app?serverVersion=15&charset=utf8" 34 | ###< doctrine/doctrine-bundle ### 35 | 36 | ###> nelmio/cors-bundle ### 37 | CORS_ALLOW_ORIGIN='^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$' 38 | ###< nelmio/cors-bundle ### 39 | 40 | ###> symfony/mercure-bundle ### 41 | # See https://symfony.com/doc/current/mercure.html#configuration 42 | # The URL of the Mercure hub, used by the app to publish updates (can be a local URL) 43 | MERCURE_URL=http://php/.well-known/mercure 44 | # The public URL of the Mercure hub, used by the browser to connect 45 | MERCURE_PUBLIC_URL=https://localhost/.well-known/mercure 46 | # The secret used to sign the JWTs 47 | MERCURE_JWT_SECRET="!ChangeThisMercureHubJWTSecretKey!" 48 | ###< symfony/mercure-bundle ### 49 | -------------------------------------------------------------------------------- /src/openapi/dataProvider.ts: -------------------------------------------------------------------------------- 1 | import { parseOpenApi3Documentation } from '@api-platform/api-doc-parser'; 2 | import { fetchUtils } from 'react-admin'; 3 | import { adminDataProvider } from '../dataProvider/index.js'; 4 | import type { 5 | ApiPlatformAdminDataProvider, 6 | HttpClientOptions, 7 | MercureOptions, 8 | OpenApiDataProviderFactoryParams, 9 | } from '../types.js'; 10 | 11 | const fetchJson = (url: URL, options: HttpClientOptions = {}) => { 12 | let { headers } = options; 13 | if (!headers) { 14 | headers = {}; 15 | } 16 | headers = typeof headers === 'function' ? headers() : headers; 17 | headers = new Headers(headers); 18 | 19 | return fetchUtils.fetchJson(url, { ...options, headers }); 20 | }; 21 | 22 | const defaultParams: Required< 23 | Omit< 24 | OpenApiDataProviderFactoryParams, 25 | 'entrypoint' | 'docEntrypoint' | 'dataProvider' 26 | > 27 | > = { 28 | httpClient: fetchJson, 29 | apiDocumentationParser: parseOpenApi3Documentation, 30 | mercure: false, 31 | }; 32 | 33 | function dataProvider( 34 | factoryParams: OpenApiDataProviderFactoryParams, 35 | ): ApiPlatformAdminDataProvider { 36 | const { 37 | dataProvider: { 38 | getList, 39 | getOne, 40 | getMany, 41 | getManyReference, 42 | update, 43 | updateMany, 44 | create, 45 | delete: deleteFn, 46 | deleteMany, 47 | }, 48 | entrypoint, 49 | docEntrypoint, 50 | httpClient, 51 | apiDocumentationParser, 52 | }: Required = { 53 | ...defaultParams, 54 | ...factoryParams, 55 | }; 56 | const entrypointUrl = new URL(entrypoint, window.location.href); 57 | const mercure: MercureOptions | null = factoryParams.mercure 58 | ? { 59 | hub: null, 60 | jwt: null, 61 | topicUrl: entrypointUrl, 62 | ...(factoryParams.mercure === true ? {} : factoryParams.mercure), 63 | } 64 | : null; 65 | 66 | const { introspect, subscribe, unsubscribe } = adminDataProvider({ 67 | entrypoint, 68 | docEntrypoint, 69 | httpClient, 70 | apiDocumentationParser, 71 | mercure: factoryParams.mercure ?? false, 72 | }); 73 | 74 | return { 75 | getList, 76 | getOne, 77 | getMany, 78 | getManyReference, 79 | update, 80 | updateMany, 81 | create, 82 | delete: deleteFn, 83 | deleteMany, 84 | introspect, 85 | subscribe: (resourceIds, callback) => { 86 | if (mercure?.hub === null) { 87 | return Promise.resolve({ data: null }); 88 | } 89 | 90 | return subscribe(resourceIds, callback); 91 | }, 92 | unsubscribe, 93 | }; 94 | } 95 | 96 | export default dataProvider; 97 | -------------------------------------------------------------------------------- /api/frankenphp/Caddyfile: -------------------------------------------------------------------------------- 1 | { 2 | {$CADDY_GLOBAL_OPTIONS} 3 | 4 | frankenphp { 5 | {$FRANKENPHP_CONFIG} 6 | } 7 | 8 | # https://caddyserver.com/docs/caddyfile/directives#sorting-algorithm 9 | order mercure after encode 10 | order vulcain after reverse_proxy 11 | order php_server before file_server 12 | } 13 | 14 | {$CADDY_EXTRA_CONFIG} 15 | 16 | {$SERVER_NAME:localhost} { 17 | log { 18 | # Redact the authorization query parameter that can be set by Mercure 19 | format filter { 20 | wrap console 21 | fields { 22 | uri query { 23 | replace authorization REDACTED 24 | } 25 | } 26 | } 27 | } 28 | 29 | root * /app/public 30 | encode { 31 | zstd 32 | br 33 | gzip 34 | 35 | match { 36 | header Content-Type text/* 37 | header Content-Type application/json* 38 | header Content-Type application/javascript* 39 | header Content-Type application/xhtml+xml* 40 | header Content-Type application/atom+xml* 41 | header Content-Type application/rss+xml* 42 | header Content-Type image/svg+xml* 43 | # Custom formats supported 44 | header Content-Type application/ld+json* 45 | } 46 | } 47 | 48 | mercure { 49 | # Transport to use (default to Bolt) 50 | transport_url {$MERCURE_TRANSPORT_URL:bolt:///data/mercure.db} 51 | # Publisher JWT key 52 | publisher_jwt {env.MERCURE_PUBLISHER_JWT_KEY} {env.MERCURE_PUBLISHER_JWT_ALG} 53 | # Subscriber JWT key 54 | subscriber_jwt {env.MERCURE_SUBSCRIBER_JWT_KEY} {env.MERCURE_SUBSCRIBER_JWT_ALG} 55 | # Allow anonymous subscribers (double-check that it's what you want) 56 | anonymous 57 | # Enable the subscription API (double-check that it's what you want) 58 | subscriptions 59 | # Extra directives 60 | {$MERCURE_EXTRA_DIRECTIVES} 61 | } 62 | 63 | vulcain 64 | 65 | # Add links to the API docs and to the Mercure Hub if not set explicitly (e.g. the PWA) 66 | header ?Link `; rel="http://www.w3.org/ns/hydra/core#apiDocumentation", ; rel="mercure"` 67 | # Disable Topics tracking if not enabled explicitly: https://github.com/jkarlin/topics 68 | header ?Permissions-Policy "browsing-topics=()" 69 | 70 | # Matches requests for HTML documents, for static files and for Storybook files, 71 | # except for known API paths and paths with extensions handled by API Platform 72 | @pwa expression `(!header({'Accept': 'application/ld+json'}) && path('*.js', '*.html', '/', '*.json', '*.svg', '*.css', '*.woff2', '/__webpack_hmr', '/iframe')) || (header({'Connection': '*Upgrade*'}) && header({'Upgrade': 'websocket'}))` 73 | 74 | # Comment the following line if you don't want Next.js to catch requests for HTML documents. 75 | # In this case, they will be handled by the PHP app. 76 | # reverse_proxy @pwa http://{$PWA_UPSTREAM} 77 | 78 | php_server 79 | } 80 | -------------------------------------------------------------------------------- /src/dataProvider/adminDataProvider.ts: -------------------------------------------------------------------------------- 1 | import type { Api, Resource } from '@api-platform/api-doc-parser'; 2 | import { mercureManager } from '../mercure/index.js'; 3 | import type { 4 | ApiDocumentationParserResponse, 5 | ApiPlatformAdminDataProviderFactoryParams, 6 | ApiPlatformAdminRecord, 7 | MercureOptions, 8 | } from '../types.js'; 9 | 10 | export default ( 11 | factoryParams: Required, 12 | ) => { 13 | const { entrypoint, docEntrypoint, apiDocumentationParser } = factoryParams; 14 | const entrypointUrl = new URL(entrypoint, window.location.href); 15 | const docEntrypointUrl = new URL(docEntrypoint, window.location.href); 16 | const mercure: MercureOptions | null = factoryParams.mercure 17 | ? { 18 | hub: null, 19 | jwt: null, 20 | topicUrl: entrypointUrl, 21 | ...(factoryParams.mercure === true ? {} : factoryParams.mercure), 22 | } 23 | : null; 24 | mercureManager.setMercureOptions(mercure); 25 | 26 | let apiSchema: Api & { resources: Resource[] }; 27 | 28 | return { 29 | introspect: (_resource = '', _params = {}) => 30 | apiSchema 31 | ? Promise.resolve({ data: apiSchema }) 32 | : apiDocumentationParser(docEntrypointUrl.toString()) 33 | .then(({ api }: ApiDocumentationParserResponse) => { 34 | if (api.resources && api.resources.length > 0) { 35 | apiSchema = { ...api, resources: api.resources }; 36 | } 37 | return { data: api }; 38 | }) 39 | .catch((err) => { 40 | const { status, error } = err; 41 | let { message } = err; 42 | // Note that the `api-doc-parser` rejects with a non-standard error object hence the check 43 | if (error?.message) { 44 | message = error.message; 45 | } 46 | 47 | throw new Error( 48 | `Cannot fetch API documentation:\n${ 49 | message 50 | ? `${message}\nHave you verified that CORS is correctly configured in your API?\n` 51 | : '' 52 | }${status ? `Status: ${status}` : ''}`, 53 | ); 54 | }), 55 | subscribe: ( 56 | resourceIds: string[], 57 | callback: (document: ApiPlatformAdminRecord) => void, 58 | ) => { 59 | resourceIds.forEach((resourceId) => { 60 | mercureManager.subscribe(resourceId, resourceId, callback); 61 | }); 62 | 63 | return Promise.resolve({ data: null }); 64 | }, 65 | unsubscribe: (_resource: string, resourceIds: string[]) => { 66 | resourceIds.forEach((resourceId) => { 67 | mercureManager.unsubscribe(resourceId); 68 | }); 69 | 70 | return Promise.resolve({ data: null }); 71 | }, 72 | }; 73 | }; 74 | -------------------------------------------------------------------------------- /src/introspection/getRoutesAndResourcesFromNodes.tsx: -------------------------------------------------------------------------------- 1 | import React, { Children, Fragment } from 'react'; 2 | import type { ReactElement } from 'react'; 3 | import type { 4 | AdminChildren, 5 | CustomRoutesProps, 6 | RenderResourcesFunction, 7 | ResourceProps, 8 | } from 'react-admin'; 9 | 10 | type RaComponent = { 11 | raName?: string; 12 | }; 13 | 14 | // From https://github.com/marmelab/react-admin/blob/next/packages/ra-core/src/core/useConfigureAdminRouterFromChildren.tsx 15 | 16 | export const getSingleChildFunction = ( 17 | children: AdminChildren, 18 | ): RenderResourcesFunction | null => { 19 | const childrenArray = Array.isArray(children) ? children : [children]; 20 | 21 | const functionChildren = childrenArray.filter( 22 | (child) => typeof child === 'function', 23 | ); 24 | 25 | if (functionChildren.length > 1) { 26 | throw new Error('You can only provide one function child to AdminRouter'); 27 | } 28 | 29 | if (functionChildren.length === 0) { 30 | return null; 31 | } 32 | 33 | return functionChildren[0] as RenderResourcesFunction; 34 | }; 35 | 36 | export const isSingleChildFunction = ( 37 | children: AdminChildren, 38 | ): children is RenderResourcesFunction => !!getSingleChildFunction(children); 39 | 40 | /** 41 | * Inspect the children and return an object with the following keys: 42 | * - customRoutes: an array of the custom routes 43 | * - resources: an array of resources elements 44 | */ 45 | const getRoutesAndResourcesFromNodes = (children: AdminChildren) => { 46 | const customRoutes: ReactElement[] = []; 47 | const resources: ReactElement[] = []; 48 | 49 | if (isSingleChildFunction(children)) { 50 | return { 51 | customRoutes, 52 | resources, 53 | }; 54 | } 55 | 56 | // @ts-expect-error for some reason, typescript doesn't narrow down the type after calling the isSingleChildFunction type guard 57 | Children.forEach(children, (element) => { 58 | if (!React.isValidElement(element)) { 59 | // Ignore non-elements. This allows people to more easily inline 60 | // conditionals in their route config. 61 | return; 62 | } 63 | if (element.type === Fragment) { 64 | const customRoutesFromFragment = getRoutesAndResourcesFromNodes( 65 | element.props.children, 66 | ); 67 | customRoutes.push(...customRoutesFromFragment.customRoutes); 68 | resources.push(...customRoutesFromFragment.resources); 69 | } 70 | 71 | if ((element.type as RaComponent).raName === 'CustomRoutes') { 72 | const customRoutesElement = element as ReactElement; 73 | 74 | customRoutes.push(customRoutesElement); 75 | } else if ((element.type as RaComponent).raName === 'Resource') { 76 | resources.push(element as ReactElement); 77 | } 78 | }); 79 | 80 | return { 81 | customRoutes, 82 | resources, 83 | }; 84 | }; 85 | 86 | export default getRoutesAndResourcesFromNodes; 87 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | #syntax=docker/dockerfile:1.4 2 | 3 | # Adapted from https://github.com/dunglas/symfony-docker 4 | 5 | 6 | # Versions 7 | # hadolint ignore=DL3007 8 | FROM dunglas/frankenphp:1-php8.3-alpine AS frankenphp_upstream 9 | 10 | 11 | # The different stages of this Dockerfile are meant to be built into separate images 12 | # https://docs.docker.com/develop/develop-images/multistage-build/#stop-at-a-specific-build-stage 13 | # https://docs.docker.com/compose/compose-file/#target 14 | 15 | 16 | # Base FrankenPHP image 17 | FROM frankenphp_upstream AS frankenphp_base 18 | 19 | WORKDIR /app 20 | 21 | # persistent / runtime deps 22 | # hadolint ignore=DL3018 23 | RUN apk add --no-cache \ 24 | acl \ 25 | file \ 26 | gettext \ 27 | git \ 28 | ; 29 | 30 | # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser 31 | ENV COMPOSER_ALLOW_SUPERUSER=1 32 | 33 | RUN set -eux; \ 34 | install-php-extensions \ 35 | @composer \ 36 | apcu \ 37 | intl \ 38 | opcache \ 39 | zip \ 40 | ; 41 | 42 | ###> recipes ### 43 | ###> doctrine/doctrine-bundle ### 44 | RUN set -eux; \ 45 | install-php-extensions pdo_pgsql 46 | ###< doctrine/doctrine-bundle ### 47 | ###< recipes ### 48 | 49 | COPY --link frankenphp/conf.d/app.ini $PHP_INI_DIR/conf.d/ 50 | COPY --link --chmod=755 frankenphp/docker-entrypoint.sh /usr/local/bin/docker-entrypoint 51 | COPY --link frankenphp/Caddyfile /etc/caddy/Caddyfile 52 | 53 | ENTRYPOINT ["docker-entrypoint"] 54 | 55 | HEALTHCHECK --start-period=60s CMD curl -f http://localhost:2019/metrics || exit 1 56 | CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile" ] 57 | 58 | # Dev FrankenPHP image 59 | FROM frankenphp_base AS frankenphp_dev 60 | 61 | ENV APP_ENV=dev XDEBUG_MODE=off 62 | VOLUME /app/var/ 63 | 64 | RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini" 65 | 66 | RUN set -eux; \ 67 | install-php-extensions \ 68 | xdebug \ 69 | ; 70 | 71 | COPY --link frankenphp/conf.d/app.dev.ini $PHP_INI_DIR/conf.d/ 72 | 73 | CMD [ "frankenphp", "run", "--config", "/etc/caddy/Caddyfile", "--watch" ] 74 | 75 | # Prod FrankenPHP image 76 | FROM frankenphp_base AS frankenphp_prod 77 | 78 | ENV APP_ENV=prod 79 | ENV FRANKENPHP_CONFIG="import worker.Caddyfile" 80 | 81 | RUN mv "$PHP_INI_DIR/php.ini-production" "$PHP_INI_DIR/php.ini" 82 | 83 | COPY --link frankenphp/conf.d/app.prod.ini $PHP_INI_DIR/conf.d/ 84 | COPY --link frankenphp/worker.Caddyfile /etc/caddy/worker.Caddyfile 85 | 86 | # prevent the reinstallation of vendors at every changes in the source code 87 | COPY --link composer.* symfony.* ./ 88 | RUN set -eux; \ 89 | composer install --no-cache --prefer-dist --no-dev --no-autoloader --no-scripts --no-progress 90 | 91 | # copy sources 92 | COPY --link . ./ 93 | RUN rm -Rf frankenphp/ 94 | 95 | RUN set -eux; \ 96 | mkdir -p var/cache var/log; \ 97 | composer dump-autoload --classmap-authoritative --no-dev; \ 98 | composer dump-env prod; \ 99 | composer run-script --no-dev post-install-cmd; \ 100 | chmod +x bin/console; sync; 101 | -------------------------------------------------------------------------------- /api/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "project", 3 | "license": "MIT", 4 | "require": { 5 | "php": ">=8.2", 6 | "ext-ctype": "*", 7 | "ext-iconv": "*", 8 | "api-platform/core": "^4.0.6", 9 | "doctrine/doctrine-bundle": "^2.7", 10 | "doctrine/doctrine-migrations-bundle": "^3.2", 11 | "doctrine/orm": "^3.0", 12 | "nelmio/cors-bundle": "^2.2", 13 | "phpstan/phpdoc-parser": "^1.16", 14 | "runtime/frankenphp-symfony": "^0.2", 15 | "symfony/asset": "6.4.*", 16 | "symfony/console": "6.4.*", 17 | "symfony/dotenv": "6.4.*", 18 | "symfony/expression-language": "6.4.*", 19 | "symfony/flex": "^2.2", 20 | "symfony/framework-bundle": "6.4.*", 21 | "symfony/mercure-bundle": "^0.3.5", 22 | "symfony/monolog-bundle": "^3.8", 23 | "symfony/property-access": "6.4.*", 24 | "symfony/property-info": "6.4.*", 25 | "symfony/runtime": "6.4.*", 26 | "symfony/security-bundle": "6.4.*", 27 | "symfony/serializer": "6.4.*", 28 | "symfony/twig-bundle": "6.4.*", 29 | "symfony/validator": "6.4.*", 30 | "symfony/yaml": "6.4.*", 31 | "twig/extra-bundle": "^2.12|^3.0", 32 | "twig/twig": "^2.12|^3.0" 33 | }, 34 | "require-dev": { 35 | "symfony/debug-bundle": "6.4.*", 36 | "symfony/stopwatch": "6.4.*", 37 | "symfony/var-dumper": "6.4.*", 38 | "symfony/web-profiler-bundle": "6.4.*" 39 | }, 40 | "config": { 41 | "optimize-autoloader": true, 42 | "preferred-install": { 43 | "*": "dist" 44 | }, 45 | "sort-packages": true, 46 | "allow-plugins": { 47 | "symfony/flex": true, 48 | "symfony/runtime": true 49 | } 50 | }, 51 | "autoload": { 52 | "psr-4": { 53 | "App\\": "src/" 54 | } 55 | }, 56 | "autoload-dev": { 57 | "psr-4": { 58 | "App\\Tests\\": "tests/" 59 | } 60 | }, 61 | "replace": { 62 | "paragonie/random_compat": "2.*", 63 | "symfony/polyfill-ctype": "*", 64 | "symfony/polyfill-iconv": "*", 65 | "symfony/polyfill-intl-grapheme": "*", 66 | "symfony/polyfill-intl-normalizer": "*", 67 | "symfony/polyfill-mbstring": "*", 68 | "symfony/polyfill-php82": "*", 69 | "symfony/polyfill-php81": "*", 70 | "symfony/polyfill-php80": "*", 71 | "symfony/polyfill-php72": "*" 72 | }, 73 | "scripts": { 74 | "auto-scripts": { 75 | "cache:clear": "symfony-cmd", 76 | "assets:install %PUBLIC_DIR%": "symfony-cmd" 77 | }, 78 | "post-install-cmd": [ 79 | "@auto-scripts" 80 | ], 81 | "post-update-cmd": [ 82 | "@auto-scripts" 83 | ] 84 | }, 85 | "conflict": { 86 | "symfony/symfony": "*" 87 | }, 88 | "extra": { 89 | "symfony": { 90 | "allow-contrib": false, 91 | "require": "6.4.*", 92 | "docker": false 93 | } 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/core/AdminResourcesGuesser.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AdminUI, Loading } from 'react-admin'; 3 | import type { ComponentType } from 'react'; 4 | import type { AdminProps } from 'react-admin'; 5 | import type { Resource } from '@api-platform/api-doc-parser'; 6 | 7 | import ResourceGuesser from './ResourceGuesser.js'; 8 | 9 | import getRoutesAndResourcesFromNodes, { 10 | isSingleChildFunction, 11 | } from '../introspection/getRoutesAndResourcesFromNodes.js'; 12 | import useDisplayOverrideCode from '../useDisplayOverrideCode.js'; 13 | import type { ApiPlatformAdminDataProvider, SchemaAnalyzer } from '../types.js'; 14 | 15 | export interface AdminGuesserProps extends AdminProps { 16 | admin?: ComponentType; 17 | dataProvider: ApiPlatformAdminDataProvider; 18 | schemaAnalyzer: SchemaAnalyzer; 19 | includeDeprecated?: boolean; 20 | } 21 | 22 | interface AdminResourcesGuesserProps extends Omit { 23 | admin?: ComponentType; 24 | includeDeprecated: boolean; 25 | loading: boolean; 26 | loadingPage?: ComponentType; 27 | resources: Resource[]; 28 | } 29 | 30 | const getOverrideCode = (resources: Resource[]) => { 31 | let code = 32 | 'If you want to override at least one resource, paste this content in the component of your app:\n\n'; 33 | 34 | resources.forEach((r) => { 35 | code += `\n`; 36 | }); 37 | 38 | return code; 39 | }; 40 | 41 | /** 42 | * AdminResourcesGuesser automatically renders an `` component 43 | * for resources exposed by a web API documented with Hydra, OpenAPI 44 | * or any other format supported by `@api-platform/api-doc-parser`. 45 | * 46 | * If child components are passed (usually `` or `` 47 | * components, but it can be any other React component), they are rendered in 48 | * the given order. 49 | * If no children are passed, a `` component is created for 50 | * each resource type exposed by the API, in the order they are specified in 51 | * the API documentation. 52 | */ 53 | export const AdminResourcesGuesser = ({ 54 | // Admin props 55 | loadingPage: LoadingPage = Loading, 56 | admin: AdminEl = AdminUI, 57 | // Props 58 | children, 59 | includeDeprecated, 60 | resources, 61 | loading, 62 | ...rest 63 | }: AdminResourcesGuesserProps) => { 64 | const displayOverrideCode = useDisplayOverrideCode(); 65 | 66 | if (loading) { 67 | return ; 68 | } 69 | 70 | let adminChildren = children; 71 | const { resources: resourceChildren, customRoutes } = 72 | getRoutesAndResourcesFromNodes(children); 73 | if ( 74 | !isSingleChildFunction(adminChildren) && 75 | resourceChildren.length === 0 && 76 | resources 77 | ) { 78 | const guessedResources = includeDeprecated 79 | ? resources 80 | : resources.filter((r) => !r.deprecated); 81 | adminChildren = [ 82 | ...customRoutes, 83 | ...guessedResources.map((r) => ( 84 | 85 | )), 86 | ]; 87 | displayOverrideCode(getOverrideCode(guessedResources)); 88 | } 89 | 90 | return ( 91 | 92 | {adminChildren} 93 | 94 | ); 95 | }; 96 | -------------------------------------------------------------------------------- /src/core/ResourceGuesser.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { 3 | Resource, 4 | useResourceDefinition, 5 | useResourceDefinitionContext, 6 | } from 'react-admin'; 7 | import type { ResourceDefinition, ResourceProps } from 'react-admin'; 8 | import ListGuesser from '../list/ListGuesser.js'; 9 | import CreateGuesser from '../create/CreateGuesser.js'; 10 | import EditGuesser from '../edit/EditGuesser.js'; 11 | import ShowGuesser from '../show/ShowGuesser.js'; 12 | import Introspecter from '../introspection/Introspecter.js'; 13 | import type { 14 | IntrospectedResourceGuesserProps, 15 | ResourceGuesserProps, 16 | } from '../types.js'; 17 | 18 | export const IntrospectedResourceGuesser = ({ 19 | resource, 20 | schema, 21 | schemaAnalyzer, 22 | list = ListGuesser, 23 | edit = EditGuesser, 24 | create = CreateGuesser, 25 | show = ShowGuesser, 26 | ...props 27 | }: IntrospectedResourceGuesserProps) => { 28 | const { register } = useResourceDefinitionContext(); 29 | const registeredDefinition = useResourceDefinition({ resource }); 30 | 31 | let hasList = false; 32 | let hasEdit = false; 33 | let hasCreate = false; 34 | let hasShow = false; 35 | schema.operations?.forEach((operation) => { 36 | if (operation.type === 'list') { 37 | hasList = true; 38 | } 39 | if (operation.type === 'edit') { 40 | hasEdit = true; 41 | } 42 | if (operation.type === 'create') { 43 | hasCreate = true; 44 | } 45 | if (operation.type === 'show') { 46 | hasShow = true; 47 | } 48 | }); 49 | 50 | useEffect(() => { 51 | if ( 52 | registeredDefinition.hasList !== hasList || 53 | registeredDefinition.hasEdit !== hasEdit || 54 | registeredDefinition.hasCreate !== hasCreate || 55 | registeredDefinition.hasShow !== hasShow 56 | ) { 57 | register({ 58 | name: resource, 59 | icon: props.icon, 60 | options: props.options, 61 | recordRepresentation: props.recordRepresentation, 62 | hasList, 63 | hasEdit, 64 | hasCreate, 65 | hasShow, 66 | }); 67 | } 68 | }, [ 69 | register, 70 | resource, 71 | props.icon, 72 | props.options, 73 | props.recordRepresentation, 74 | hasList, 75 | hasEdit, 76 | hasCreate, 77 | hasShow, 78 | registeredDefinition, 79 | ]); 80 | 81 | return ( 82 | 90 | ); 91 | }; 92 | 93 | const ResourceGuesser = ({ name, ...props }: ResourceGuesserProps) => ( 94 | 99 | ); 100 | 101 | ResourceGuesser.raName = 'Resource'; 102 | 103 | ResourceGuesser.registerResource = ( 104 | props: ResourceProps, 105 | ): ResourceDefinition => ({ 106 | name: props.name, 107 | icon: props.icon, 108 | options: props.options, 109 | recordRepresentation: props.recordRepresentation, 110 | hasList: true, 111 | hasEdit: true, 112 | hasCreate: true, 113 | hasShow: true, 114 | }); 115 | 116 | export default ResourceGuesser; 117 | -------------------------------------------------------------------------------- /src/show/ShowGuesser.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Show, 4 | SimpleShowLayout, 5 | Tab, 6 | TabbedShowLayout, 7 | useResourceContext, 8 | } from 'react-admin'; 9 | import { useParams } from 'react-router-dom'; 10 | import type { Field, Resource } from '@api-platform/api-doc-parser'; 11 | 12 | import FieldGuesser from '../field/FieldGuesser.js'; 13 | import Introspecter from '../introspection/Introspecter.js'; 14 | import useMercureSubscription from '../mercure/useMercureSubscription.js'; 15 | import useDisplayOverrideCode from '../useDisplayOverrideCode.js'; 16 | import type { 17 | IntrospectedShowGuesserProps, 18 | ShowGuesserProps, 19 | } from '../types.js'; 20 | 21 | const getOverrideCode = (schema: Resource, fields: Field[]) => { 22 | let code = `If you want to override at least one field, create a ${schema.title}Show component with this content:\n`; 23 | code += `\n`; 24 | code += `import { ShowGuesser, FieldGuesser } from "@api-platform/admin";\n`; 25 | code += `\n`; 26 | code += `export const ${schema.title}Show = () => (\n`; 27 | code += ` \n`; 28 | fields.forEach((field) => { 29 | code += ` \n`; 30 | }); 31 | code += ` \n`; 32 | code += `);\n`; 33 | code += `\n`; 34 | code += `Then, update your main admin component:\n`; 35 | code += `\n`; 36 | code += `import { HydraAdmin, ResourceGuesser } from "@api-platform/admin";\n`; 37 | code += `import { ${schema.title}Show } from './${schema.title}Show';\n`; 38 | code += `\n`; 39 | code += `const App = () => (\n`; 40 | code += ` \n`; 41 | code += ` \n`; 42 | code += ` {/* ... */}\n`; 43 | code += ` \n`; 44 | code += `);\n`; 45 | 46 | return code; 47 | }; 48 | 49 | export const IntrospectedShowGuesser = ({ 50 | fields, 51 | readableFields, 52 | writableFields, 53 | schema, 54 | schemaAnalyzer, 55 | viewComponent, 56 | children, 57 | ...props 58 | }: IntrospectedShowGuesserProps) => { 59 | const { id: routeId } = useParams<'id'>(); 60 | const id = decodeURIComponent(routeId ?? ''); 61 | useMercureSubscription(props.resource, id); 62 | 63 | const displayOverrideCode = useDisplayOverrideCode(); 64 | 65 | let fieldChildren = children; 66 | if (!fieldChildren) { 67 | fieldChildren = readableFields.map((field) => ( 68 | 69 | )); 70 | displayOverrideCode(getOverrideCode(schema, readableFields)); 71 | } 72 | 73 | const hasTab = 74 | Array.isArray(fieldChildren) && 75 | fieldChildren.some( 76 | (child) => 77 | typeof child === 'object' && 'type' in child && child.type === Tab, 78 | ); 79 | const ShowLayout = hasTab ? TabbedShowLayout : SimpleShowLayout; 80 | 81 | return ( 82 | 83 | {fieldChildren} 84 | 85 | ); 86 | }; 87 | 88 | const ShowGuesser = (props: ShowGuesserProps) => { 89 | const resource = useResourceContext(props); 90 | if (!resource) { 91 | throw new Error('ShowGuesser must be used with a resource'); 92 | } 93 | 94 | return ( 95 | 100 | ); 101 | }; 102 | 103 | export default ShowGuesser; 104 | -------------------------------------------------------------------------------- /src/hydra/fetchHydra.ts: -------------------------------------------------------------------------------- 1 | import { HttpError } from 'react-admin'; 2 | import { 3 | fetchJsonLd, 4 | getDocumentationUrlFromHeaders, 5 | } from '@api-platform/api-doc-parser'; 6 | import jsonld from 'jsonld'; 7 | import type { ContextDefinition, NodeObject } from 'jsonld'; 8 | import type { JsonLdObj } from 'jsonld/jsonld-spec'; 9 | import type { HttpClientOptions, HydraHttpClientResponse } from '../types.js'; 10 | 11 | /** 12 | * Sends HTTP requests to a Hydra API. 13 | */ 14 | function fetchHydra( 15 | url: URL, 16 | options: HttpClientOptions = {}, 17 | ): Promise { 18 | let requestHeaders = options.headers ?? new Headers(); 19 | 20 | if ( 21 | typeof requestHeaders !== 'function' && 22 | options.user && 23 | options.user.authenticated && 24 | options.user.token 25 | ) { 26 | requestHeaders = new Headers(requestHeaders); 27 | requestHeaders.set('Authorization', options.user.token); 28 | } 29 | 30 | const authOptions = { ...options, headers: requestHeaders }; 31 | 32 | return fetchJsonLd(url.href, authOptions).then((data) => { 33 | const { status, statusText, headers } = data.response; 34 | const body = 'body' in data ? data.body : undefined; 35 | 36 | if (status < 200 || status >= 300) { 37 | if (!body) { 38 | return Promise.reject(new HttpError(statusText, status)); 39 | } 40 | 41 | delete (body as NodeObject).trace; 42 | 43 | const documentLoader = (input: string) => { 44 | const loaderOptions = authOptions; 45 | loaderOptions.method = 'GET'; 46 | delete loaderOptions.body; 47 | 48 | return fetchJsonLd(input, loaderOptions).then((response) => { 49 | if (!('body' in response)) { 50 | throw new Error( 51 | 'An empty response was received when expanding JSON-LD error document.', 52 | ); 53 | } 54 | return response; 55 | }); 56 | }; 57 | const base = getDocumentationUrlFromHeaders(headers); 58 | 59 | return ( 60 | '@context' in body 61 | ? jsonld.expand(body, { 62 | base, 63 | documentLoader, 64 | }) 65 | : documentLoader(base).then((response) => 66 | jsonld.expand(body, { 67 | expandContext: response.document as ContextDefinition, 68 | }), 69 | ) 70 | ) 71 | .then((json) => 72 | Promise.reject( 73 | new HttpError( 74 | ( 75 | json[0]?.[ 76 | 'http://www.w3.org/ns/hydra/core#description' 77 | ] as JsonLdObj[] 78 | )?.[0]?.['@value'], 79 | status, 80 | json, 81 | ), 82 | ), 83 | ) 84 | .catch((e) => { 85 | if ('body' in e) { 86 | return Promise.reject(e); 87 | } 88 | 89 | return Promise.reject(new HttpError(statusText, status)); 90 | }); 91 | } 92 | 93 | if (Array.isArray(body)) { 94 | return Promise.reject( 95 | new Error('Hydra response should not be an array.'), 96 | ); 97 | } 98 | if (body && !('@id' in body)) { 99 | return Promise.reject( 100 | new Error('Hydra response needs to have an @id member.'), 101 | ); 102 | } 103 | 104 | return { 105 | status, 106 | headers, 107 | json: body as NodeObject, 108 | }; 109 | }); 110 | } 111 | 112 | export default fetchHydra; 113 | -------------------------------------------------------------------------------- /src/hydra/schemaAnalyzer.ts: -------------------------------------------------------------------------------- 1 | import type { Field, Resource } from '@api-platform/api-doc-parser'; 2 | import type { HttpError } from 'react-admin'; 3 | import type { JsonLdObj } from 'jsonld/jsonld-spec'; 4 | import { 5 | getFiltersParametersFromSchema, 6 | getOrderParametersFromSchema, 7 | } from '../introspection/schemaAnalyzer.js'; 8 | import type { SchemaAnalyzer, SubmissionErrors } from '../types.js'; 9 | 10 | const withHttpScheme = (value: string | null | undefined) => 11 | value?.startsWith('https://') ? value.replace(/^https/, 'http') : value; 12 | 13 | /** 14 | * @param schema The schema of a resource 15 | * 16 | * @returns The name of the reference field 17 | */ 18 | const getFieldNameFromSchema = (schema: Resource) => { 19 | if (!schema.fields) { 20 | return ''; 21 | } 22 | 23 | const field = schema.fields.find( 24 | (schemaField) => 25 | withHttpScheme(schemaField.id) === 'http://schema.org/name', 26 | ); 27 | 28 | return field ? field.name : 'id'; 29 | }; 30 | 31 | /** 32 | * @returns The type of the field 33 | */ 34 | const getFieldType = (field: Field) => { 35 | switch (withHttpScheme(field.id)) { 36 | case 'http://schema.org/identifier': 37 | return withHttpScheme(field.range) === 38 | 'http://www.w3.org/2001/XMLSchema#integer' 39 | ? 'integer_id' 40 | : 'id'; 41 | case 'http://schema.org/email': 42 | return 'email'; 43 | case 'http://schema.org/url': 44 | return 'url'; 45 | default: 46 | } 47 | 48 | if (field.embedded !== null && field.maxCardinality !== 1) { 49 | return 'array'; 50 | } 51 | 52 | switch (withHttpScheme(field.range)) { 53 | case 'http://www.w3.org/2001/XMLSchema#array': 54 | return 'array'; 55 | case 'http://www.w3.org/2001/XMLSchema#integer': 56 | return 'integer'; 57 | case 'http://www.w3.org/2001/XMLSchema#decimal': 58 | case 'http://www.w3.org/2001/XMLSchema#float': 59 | return 'float'; 60 | case 'http://www.w3.org/2001/XMLSchema#boolean': 61 | return 'boolean'; 62 | case 'http://www.w3.org/2001/XMLSchema#date': 63 | return 'date'; 64 | case 'http://www.w3.org/2001/XMLSchema#dateTime': 65 | return 'dateTime'; 66 | default: 67 | return 'text'; 68 | } 69 | }; 70 | 71 | const getViolationMessage = (violation: JsonLdObj, base: string) => 72 | violation[`${base}#message`] ?? 73 | violation[`${base}#ConstraintViolation/message`]; 74 | 75 | const getSubmissionErrors = (error: HttpError) => { 76 | if (!error.body?.[0]) { 77 | return null; 78 | } 79 | 80 | const content = error.body[0]; 81 | const violationKey = Object.keys(content).find((key) => 82 | key.includes('violations'), 83 | ); 84 | if (!violationKey) { 85 | return null; 86 | } 87 | const base = violationKey.substring(0, violationKey.indexOf('#')); 88 | 89 | const violations: SubmissionErrors = content[violationKey].reduce( 90 | (previousViolations: SubmissionErrors, violation: JsonLdObj) => 91 | !violation[`${base}#propertyPath`] || 92 | !getViolationMessage(violation, base) 93 | ? previousViolations 94 | : { 95 | ...previousViolations, 96 | [(violation[`${base}#propertyPath`] as JsonLdObj[])[0]?.[ 97 | '@value' 98 | ] as string]: ( 99 | getViolationMessage(violation, base) as JsonLdObj[] 100 | )[0]?.['@value'], 101 | }, 102 | {}, 103 | ); 104 | if (Object.keys(violations).length === 0) { 105 | return null; 106 | } 107 | 108 | return violations; 109 | }; 110 | 111 | export default function schemaAnalyzer(): SchemaAnalyzer { 112 | return { 113 | getFieldNameFromSchema, 114 | getOrderParametersFromSchema, 115 | getFiltersParametersFromSchema, 116 | getFieldType, 117 | getSubmissionErrors, 118 | }; 119 | } 120 | -------------------------------------------------------------------------------- /compose.yaml: -------------------------------------------------------------------------------- 1 | services: 2 | php: 3 | build: 4 | context: ./api 5 | target: frankenphp_dev 6 | image: ${IMAGES_PREFIX:-}storybook-php 7 | depends_on: 8 | - database 9 | restart: unless-stopped 10 | environment: 11 | PWA_UPSTREAM: pwa:3000 12 | SERVER_NAME: ${SERVER_NAME:-localhost}, php:80 13 | MERCURE_PUBLISHER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!} 14 | MERCURE_SUBSCRIBER_JWT_KEY: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!} 15 | TRUSTED_PROXIES: ${TRUSTED_PROXIES:-127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16} 16 | TRUSTED_HOSTS: ^${SERVER_NAME:-example\.com|localhost}|php$$ 17 | DATABASE_URL: postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-15}&charset=${POSTGRES_CHARSET:-utf8} 18 | MERCURE_URL: ${CADDY_MERCURE_URL:-http://php/.well-known/mercure} 19 | MERCURE_PUBLIC_URL: https://${SERVER_NAME:-localhost}/.well-known/mercure 20 | MERCURE_JWT_SECRET: ${CADDY_MERCURE_JWT_SECRET:-!ChangeThisMercureHubJWTSecretKey!} 21 | MERCURE_EXTRA_DIRECTIVES: demo 22 | # See https://xdebug.org/docs/all_settings#mode 23 | XDEBUG_MODE: "${XDEBUG_MODE:-off}" 24 | ports: 25 | # HTTP 26 | - target: 80 27 | published: ${HTTP_PORT:-80} 28 | protocol: tcp 29 | # HTTPS 30 | - target: 443 31 | published: ${HTTPS_PORT:-443} 32 | protocol: tcp 33 | # HTTP/3 34 | - target: 443 35 | published: ${HTTP3_PORT:-443} 36 | protocol: udp 37 | volumes: 38 | - caddy_data:/data 39 | - caddy_config:/config 40 | - ./api:/app 41 | - /app/var 42 | - ./api/frankenphp/Caddyfile:/etc/caddy/Caddyfile:ro 43 | - ./api/frankenphp/conf.d/app.dev.ini:/usr/local/etc/php/conf.d/app.dev.ini:ro 44 | # If you develop on Mac or Windows you can remove the vendor/ directory 45 | # from the bind-mount for better performance by enabling the next line: 46 | #- /app/vendor 47 | extra_hosts: 48 | # Ensure that host.docker.internal is correctly defined on Linux 49 | - host.docker.internal:host-gateway 50 | tty: true 51 | 52 | pwa: 53 | image: ${IMAGES_PREFIX:-}storybook-pwa 54 | build: 55 | context: . 56 | target: dev 57 | environment: 58 | ENTRYPOINT: ${ENTRYPOINT:-https://localhost} 59 | volumes: 60 | - .:/srv/app 61 | - pwa_node_modules:/srv/app/node_modules 62 | healthcheck: 63 | test: ["CMD", "curl", "-f", "http://127.0.0.1:3000"] 64 | interval: 10s 65 | timeout: 10s 66 | retries: 20 67 | start_period: 20s 68 | ports: 69 | - target: 3000 70 | published: ${PWA_PORT:-3000} 71 | 72 | ###> doctrine/doctrine-bundle ### 73 | database: 74 | image: postgres:${POSTGRES_VERSION:-15}-alpine 75 | environment: 76 | - POSTGRES_DB=${POSTGRES_DB:-app} 77 | # You should definitely change the password in production 78 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-!ChangeMe!} 79 | - POSTGRES_USER=${POSTGRES_USER:-app} 80 | volumes: 81 | - db_data:/var/lib/postgresql/data 82 | # you may use a bind-mounted host directory instead, so that it is harder to accidentally remove the volume and lose all your data! 83 | # - ./api/docker/db/data:/var/lib/postgresql/data 84 | ports: 85 | - target: 5432 86 | published: 5432 87 | protocol: tcp 88 | ###< doctrine/doctrine-bundle ### 89 | 90 | # Mercure is installed as a Caddy module, prevent the Flex recipe from installing another service 91 | ###> symfony/mercure-bundle ### 92 | ###< symfony/mercure-bundle ### 93 | 94 | volumes: 95 | caddy_data: 96 | caddy_config: 97 | ###> doctrine/doctrine-bundle ### 98 | db_data: 99 | ###< doctrine/doctrine-bundle ### 100 | ###> symfony/mercure-bundle ### 101 | ###< symfony/mercure-bundle ### 102 | pwa_node_modules: 103 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@api-platform/admin", 3 | "version": "4.0.7", 4 | "description": "Automatic administration interface for Hydra-enabled APIs.", 5 | "files": [ 6 | "*.md", 7 | "docs/*.md", 8 | "lib", 9 | "src" 10 | ], 11 | "type": "module", 12 | "exports": "./lib/index.js", 13 | "main": "./lib/index.js", 14 | "repository": "api-platform/admin", 15 | "homepage": "https://github.com/api-platform/admin", 16 | "bugs": "https://github.com/api-platform/admin/issues", 17 | "author": "Kévin Dunglas", 18 | "license": "MIT", 19 | "sideEffects": false, 20 | "dependencies": { 21 | "@api-platform/api-doc-parser": "^0.16.8", 22 | "jsonld": "^8.3.2", 23 | "lodash.isplainobject": "^4.0.6", 24 | "react-admin": "^5.8.3" 25 | }, 26 | "devDependencies": { 27 | "@babel/preset-env": "^7.23.3", 28 | "@babel/preset-react": "^7.23.3", 29 | "@babel/preset-typescript": "^7.23.3", 30 | "@storybook/addon-essentials": "^8.0.5", 31 | "@storybook/addon-interactions": "^8.0.5", 32 | "@storybook/addon-links": "^8.0.5", 33 | "@storybook/addon-mdx-gfm": "^8.0.5", 34 | "@storybook/addon-onboarding": "^8.0.5", 35 | "@storybook/addon-webpack5-compiler-babel": "^3.0.3", 36 | "@storybook/blocks": "^8.0.5", 37 | "@storybook/react": "^8.0.5", 38 | "@storybook/react-webpack5": "^8.0.5", 39 | "@storybook/test": "^8.0.5", 40 | "@storybook/test-runner": "^0.17.0", 41 | "@tanstack/react-query-devtools": "^5.80.6", 42 | "@testing-library/jest-dom": "^6.1.0", 43 | "@testing-library/react": "^14.0.0", 44 | "@testing-library/user-event": "^14.0.0", 45 | "@types/jest": "^29.0.0", 46 | "@types/jsonld": "^1.5.0", 47 | "@types/lodash.isplainobject": "^4.0.0", 48 | "@types/node": "^20.11.30", 49 | "@types/react-test-renderer": "^18.0.0", 50 | "@typescript-eslint/eslint-plugin": "^7.1.1", 51 | "@typescript-eslint/parser": "^7.1.1", 52 | "cross-fetch": "^4.0.0", 53 | "eslint": "^8.0.0", 54 | "eslint-config-airbnb": "^19.0.0", 55 | "eslint-config-airbnb-typescript": "^18.0.0", 56 | "eslint-config-prettier": "^9.0.0", 57 | "eslint-plugin-import": "^2.14.0", 58 | "eslint-plugin-jsx-a11y": "^6.1.0", 59 | "eslint-plugin-markdown": "^3.0.0", 60 | "eslint-plugin-prettier": "^5.0.0", 61 | "eslint-plugin-react": "^7.12.0", 62 | "eslint-plugin-react-hooks": "^4.2.0", 63 | "eslint-plugin-storybook": "^0.6.15", 64 | "eslint-plugin-tree-shaking": "^1.10.0", 65 | "jest": "^29.0.0", 66 | "jest-environment-jsdom": "^29.0.0", 67 | "jest-fetch-mock": "^3.0.3", 68 | "node-fetch": "^3.2.10", 69 | "playwright": "^1.42.1", 70 | "prettier": "^3.0.0", 71 | "react": "^18.0.0", 72 | "react-dom": "^18.0.0", 73 | "react-test-renderer": "^18.0.0", 74 | "rimraf": "^5.0.0", 75 | "serve": "^14.2.1", 76 | "storybook": "^8.0.5", 77 | "ts-jest": "^29.0.0", 78 | "ts-node": "^10.4.0", 79 | "typescript": "^5.2.0" 80 | }, 81 | "peerDependencies": { 82 | "react": "*", 83 | "react-dom": "*" 84 | }, 85 | "scripts": { 86 | "build": "rimraf ./lib && tsc", 87 | "eslint-check": "eslint-config-prettier .eslintrc.cjs", 88 | "fix": "eslint --ignore-pattern 'lib/*' --ignore-pattern 'api/*' --ext .ts,.tsx,.js --fix .", 89 | "lint": "eslint --ignore-pattern 'lib/*' --ignore-pattern 'api/*' --ext .ts,.tsx,.js .", 90 | "test": "NODE_OPTIONS=--experimental-vm-modules jest src", 91 | "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch src", 92 | "watch": "tsc --watch", 93 | "storybook": "storybook dev -p 3000", 94 | "storybook:build": "storybook build", 95 | "storybook:serve": "serve storybook-static", 96 | "storybook:test": "test-storybook" 97 | }, 98 | "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e", 99 | "resolutions": { 100 | "react-router-dom": "^6", 101 | "react-router": "^6" 102 | } 103 | } -------------------------------------------------------------------------------- /src/stories/custom/UsingGuessers.stories.tsx: -------------------------------------------------------------------------------- 1 | import AutoStoriesIcon from '@mui/icons-material/AutoStories'; 2 | import ReviewsIcon from '@mui/icons-material/Reviews'; 3 | import React from 'react'; 4 | import { HydraAdmin } from '../../hydra'; 5 | import ResourceGuesser from '../../core/ResourceGuesser'; 6 | import ListGuesser from '../../list/ListGuesser'; 7 | import ShowGuesser from '../../show/ShowGuesser'; 8 | import FieldGuesser from '../../field/FieldGuesser'; 9 | import EditGuesser from '../../edit/EditGuesser'; 10 | import InputGuesser from '../../input/InputGuesser'; 11 | import CreateGuesser from '../../create/CreateGuesser'; 12 | import DevtoolsLayout from '../layout/DevtoolsLayout'; 13 | 14 | export default { 15 | title: 'Admin/Custom/UsingGuessers', 16 | parameters: { 17 | layout: 'fullscreen', 18 | }, 19 | }; 20 | 21 | const BookCreate = () => ( 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | 31 | const BookEdit = () => ( 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | ); 40 | 41 | const BookShow = () => ( 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | ); 51 | 52 | const BookList = () => ( 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | ); 61 | 62 | const ReviewCreate = () => ( 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | ); 71 | 72 | const ReviewEdit = () => ( 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | ); 81 | 82 | const ReviewShow = () => ( 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | ); 91 | 92 | const ReviewList = () => ( 93 | 94 | 95 | 96 | 97 | 98 | 99 | ); 100 | 101 | export const UsingGuessers = () => ( 102 | 103 | 111 | 119 | 120 | ); 121 | -------------------------------------------------------------------------------- /src/create/CreateGuesser.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Create, 4 | FormTab, 5 | SimpleForm, 6 | TabbedForm, 7 | useResourceContext, 8 | } from 'react-admin'; 9 | import type { Field, Resource } from '@api-platform/api-doc-parser'; 10 | 11 | import InputGuesser from '../input/InputGuesser.js'; 12 | import Introspecter from '../introspection/Introspecter.js'; 13 | import useDisplayOverrideCode from '../useDisplayOverrideCode.js'; 14 | import useOnSubmit from '../useOnSubmit.js'; 15 | import type { 16 | CreateGuesserProps, 17 | IntrospectedCreateGuesserProps, 18 | } from '../types.js'; 19 | 20 | const getOverrideCode = (schema: Resource, fields: Field[]) => { 21 | let code = `If you want to override at least one input, create a ${schema.title}Create component with this content:\n`; 22 | code += `\n`; 23 | code += `import { CreateGuesser, InputGuesser } from "@api-platform/admin";\n`; 24 | code += `\n`; 25 | code += `export const ${schema.title}Create = () => (\n`; 26 | code += ` \n`; 27 | fields.forEach((field) => { 28 | code += ` \n`; 29 | }); 30 | code += ` \n`; 31 | code += `);\n`; 32 | code += `\n`; 33 | code += `Then, update your main admin component:\n`; 34 | code += `\n`; 35 | code += `import { HydraAdmin, ResourceGuesser } from "@api-platform/admin";\n`; 36 | code += `import { ${schema.title}Create } from './${schema.title}Create';\n`; 37 | code += `\n`; 38 | code += `const App = () => (\n`; 39 | code += ` \n`; 40 | code += ` \n`; 41 | code += ` {/* ... */}\n`; 42 | code += ` \n`; 43 | code += `);\n`; 44 | 45 | return code; 46 | }; 47 | 48 | export const IntrospectedCreateGuesser = ({ 49 | fields, 50 | readableFields, 51 | writableFields, 52 | schema, 53 | schemaAnalyzer, 54 | resource, 55 | mutationOptions, 56 | redirect: redirectTo = 'list', 57 | mode, 58 | defaultValues, 59 | transform, 60 | validate, 61 | toolbar, 62 | warnWhenUnsavedChanges, 63 | sanitizeEmptyValues = true, 64 | formComponent, 65 | viewComponent, 66 | children, 67 | ...props 68 | }: IntrospectedCreateGuesserProps) => { 69 | const save = useOnSubmit({ 70 | resource, 71 | schemaAnalyzer, 72 | fields, 73 | mutationOptions, 74 | transform, 75 | redirectTo, 76 | children: [], 77 | }); 78 | 79 | const displayOverrideCode = useDisplayOverrideCode(); 80 | 81 | let inputChildren = React.Children.toArray(children); 82 | if (inputChildren.length === 0) { 83 | inputChildren = writableFields.map((field) => ( 84 | 85 | )); 86 | displayOverrideCode(getOverrideCode(schema, writableFields)); 87 | } 88 | 89 | const hasFormTab = inputChildren.some( 90 | (child) => 91 | typeof child === 'object' && 'type' in child && child.type === FormTab, 92 | ); 93 | const FormType = hasFormTab ? TabbedForm : SimpleForm; 94 | 95 | return ( 96 | 97 | 106 | {inputChildren} 107 | 108 | 109 | ); 110 | }; 111 | 112 | const CreateGuesser = (props: CreateGuesserProps) => { 113 | const resource = useResourceContext(props); 114 | if (!resource) { 115 | throw new Error('CreateGuesser must be used with a resource'); 116 | } 117 | 118 | return ( 119 | 124 | ); 125 | }; 126 | 127 | export default CreateGuesser; 128 | -------------------------------------------------------------------------------- /src/__fixtures__/parsedData.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line tree-shaking/no-side-effects-in-initialization 2 | import { Api, Field, Parameter, Resource } from '@api-platform/api-doc-parser'; 3 | 4 | export const API_DATA = new Api('entrypoint', { 5 | resources: [ 6 | new Resource('resource', '/resources', { 7 | fields: [new Field('bar')], 8 | }), 9 | new Resource('idSearchFilterResource', '/id_search_filter_resources', { 10 | parameters: [new Parameter('id', 'xmls:string', false, '')], 11 | getParameters: () => Promise.resolve([]), 12 | }), 13 | ], 14 | }); 15 | 16 | const EmbeddedResource = new Resource('embedded', '/embeddeds', { 17 | fields: [new Field('address')], 18 | }); 19 | 20 | export const API_FIELDS_DATA = [ 21 | new Field('id', { 22 | id: 'http://schema.org/id', 23 | range: 'http://www.w3.org/2001/XMLSchema#integer', 24 | reference: null, 25 | embedded: null, 26 | required: false, 27 | }), 28 | new Field('fieldA', { 29 | id: 'http://schema.org/fieldA', 30 | range: 'http://www.w3.org/2001/XMLSchema#string', 31 | reference: null, 32 | embedded: null, 33 | required: true, 34 | }), 35 | new Field('fieldB', { 36 | id: 'http://schema.org/fieldB', 37 | range: 'http://www.w3.org/2001/XMLSchema#string', 38 | reference: null, 39 | embedded: null, 40 | required: true, 41 | }), 42 | new Field('deprecatedField', { 43 | id: 'http://localhost/deprecatedField', 44 | range: 'http://www.w3.org/2001/XMLSchema#string', 45 | reference: null, 46 | embedded: null, 47 | required: true, 48 | deprecated: true, 49 | }), 50 | new Field('title', { 51 | id: 'http://schema.org/title', 52 | range: 'http://www.w3.org/2001/XMLSchema#string', 53 | reference: null, 54 | embedded: null, 55 | required: false, 56 | }), 57 | new Field('description', { 58 | id: 'http://schema.org/description', 59 | range: 'http://www.w3.org/2001/XMLSchema#string', 60 | reference: null, 61 | embedded: null, 62 | required: false, 63 | }), 64 | new Field('nullText', { 65 | id: 'http://schema.org/nullText', 66 | range: 'http://www.w3.org/2001/XMLSchema#string', 67 | reference: null, 68 | embedded: null, 69 | required: false, 70 | }), 71 | new Field('embedded', { 72 | id: 'http://schema.org/embedded', 73 | reference: null, 74 | embedded: EmbeddedResource, 75 | maxCardinality: 1, 76 | required: false, 77 | }), 78 | new Field('embeddeds', { 79 | id: 'http://schema.org/embedded', 80 | reference: null, 81 | embedded: EmbeddedResource, 82 | required: false, 83 | }), 84 | new Field('formatType', { 85 | id: 'https://schema.org/BookFormatType', 86 | range: 'http://www.w3.org/2001/XMLSchema#string', 87 | reference: null, 88 | embedded: null, 89 | enum: { 90 | 'Https://schema.org/ebook': 'https://schema.org/EBook', 91 | 'Https://schema.org/audiobookformat': 92 | 'https://schema.org/AudiobookFormat', 93 | 'Https://schema.org/hardcover': 'https://schema.org/Hardcover', 94 | }, 95 | required: false, 96 | }), 97 | new Field('status', { 98 | id: 'http://localhost/status', 99 | range: 'http://www.w3.org/2001/XMLSchema#string', 100 | reference: null, 101 | embedded: null, 102 | enum: { Available: 'AVAILABLE', 'Sold out': 'SOLD_OUT' }, 103 | required: false, 104 | }), 105 | new Field('genre', { 106 | id: 'http://localhost/tags', 107 | range: 'http://www.w3.org/2001/XMLSchema#array', 108 | reference: null, 109 | embedded: null, 110 | maxCardinality: null, 111 | enum: { Epic: 'EPIC', 'Fairy tale': 'FAIRY_TALE', Myth: 'MYTH' }, 112 | required: false, 113 | }), 114 | new Field('owner', { 115 | id: 'http://localhost/owner', 116 | range: 'https://schema.org/Person', 117 | reference: { 118 | id: 'https://schema.org/Person', 119 | name: 'users', 120 | url: 'http://localhost/users', 121 | fields: [], 122 | }, 123 | embedded: null, 124 | maxCardinality: 1, 125 | required: false, 126 | }), 127 | ]; 128 | -------------------------------------------------------------------------------- /src/hydra/fetchHydra.test.ts: -------------------------------------------------------------------------------- 1 | import type { HttpError } from 'react-admin'; 2 | import fetchMock from 'jest-fetch-mock'; 3 | import fetchHydra from './fetchHydra.js'; 4 | import schemaAnalyzer from './schemaAnalyzer.js'; 5 | 6 | fetchMock.enableMocks(); 7 | 8 | const headers = { 9 | 'Content-Type': 'application/ld+json; charset=utf-8', 10 | Link: '; rel="http://www.w3.org/ns/hydra/core#apiDocumentation"', 11 | }; 12 | 13 | test.each([ 14 | [ 15 | 'ld+json', 16 | { 17 | '@context': '/contexts/ConstraintViolationList', 18 | '@type': 'ConstraintViolationList', 19 | 'hydra:title': 'An error occurred', 20 | 'hydra:description': 21 | 'plainPassword: Password must be at least 6 characters long.', 22 | violations: [ 23 | { 24 | propertyPath: 'plainPassword', 25 | message: 'Password must be at least 6 characters long.', 26 | }, 27 | ], 28 | }, 29 | { plainPassword: 'Password must be at least 6 characters long.' }, 30 | ], 31 | [ 32 | 'problem+json', 33 | { 34 | '@id': '\\/validation_errors\\/6b3befbc-2f01-4ddf-be21-b57898905284', 35 | '@type': 'ConstraintViolationList', 36 | status: 422, 37 | violations: [ 38 | { 39 | propertyPath: 'entitlements', 40 | message: 41 | 'At least one product must be selected if policy is restricted.', 42 | code: '6b3befbc-2f01-4ddf-be21-b57898905284', 43 | }, 44 | ], 45 | detail: 46 | 'entitlements: At least one product must be selected if policy is restricted.', 47 | 'hydra:title': 'An error occurred', 48 | 'hydra:description': 49 | 'entitlements: At least one product must be selected if policy is restricted.', 50 | type: '\\/validation_errors\\/6b3befbc-2f01-4ddf-be21-b57898905284', 51 | title: 'An error occurred', 52 | }, 53 | { 54 | entitlements: 55 | 'At least one product must be selected if policy is restricted.', 56 | }, 57 | ], 58 | [ 59 | 'problem+json', 60 | { 61 | '@context': '/contexts/ConstraintViolation', 62 | '@id': '/validation_errors/2881c032-660f-46b6-8153-d352d9706640', 63 | '@type': 'ConstraintViolation', 64 | status: 422, 65 | violations: [ 66 | { 67 | propertyPath: 'isbn', 68 | 'ConstraintViolation/message': 69 | 'This value is neither a valid ISBN-10 nor a valid ISBN-13.', 70 | 'ConstraintViolation/code': '2881c032-660f-46b6-8153-d352d9706640', 71 | }, 72 | ], 73 | detail: 74 | 'isbn: This value is neither a valid ISBN-10 nor a valid ISBN-13.', 75 | description: 76 | 'isbn: This value is neither a valid ISBN-10 nor a valid ISBN-13.', 77 | type: '/validation_errors/2881c032-660f-46b6-8153-d352d9706640', 78 | title: 'An error occurred', 79 | }, 80 | { 81 | isbn: 'This value is neither a valid ISBN-10 nor a valid ISBN-13.', 82 | }, 83 | ], 84 | ])( 85 | '%s violation list expanding', 86 | async (format: string, resBody: object, expected: object) => { 87 | fetchMock.mockResponses( 88 | [ 89 | JSON.stringify(resBody), 90 | { 91 | status: 422, 92 | statusText: '422 Unprocessable Content', 93 | headers: { 94 | ...headers, 95 | 'Content-Type': `application/${format}; charset=utf-8`, 96 | }, 97 | }, 98 | ], 99 | [ 100 | JSON.stringify({ 101 | '@context': { 102 | '@vocab': 'http://localhost/docs.jsonld#', 103 | hydra: 'http://www.w3.org/ns/hydra/core#', 104 | }, 105 | }), 106 | { 107 | status: 200, 108 | statusText: 'OK', 109 | headers, 110 | }, 111 | ], 112 | ); 113 | 114 | let violations; 115 | try { 116 | await fetchHydra(new URL('http://localhost/users')); 117 | } catch (error) { 118 | violations = schemaAnalyzer().getSubmissionErrors(error as HttpError); 119 | } 120 | expect(violations).toStrictEqual(expected); 121 | }, 122 | ); 123 | -------------------------------------------------------------------------------- /src/useOnSubmit.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { jest } from '@jest/globals'; 3 | import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; 4 | import { render, waitFor } from '@testing-library/react'; 5 | import type { CreateResult, RaRecord, UpdateResult } from 'react-admin'; 6 | import { DataProviderContext, testDataProvider } from 'react-admin'; 7 | import { MemoryRouter, Route, Routes } from 'react-router-dom'; 8 | 9 | import useOnSubmit from './useOnSubmit.js'; 10 | import schemaAnalyzer from './hydra/schemaAnalyzer.js'; 11 | import { API_FIELDS_DATA } from './__fixtures__/parsedData.js'; 12 | 13 | const dataProvider = testDataProvider({ 14 | create: jest.fn(() => Promise.resolve({ data: { id: 1 } } as CreateResult)), 15 | update: jest.fn(() => Promise.resolve({ data: { id: 1 } } as UpdateResult)), 16 | }); 17 | 18 | const onSubmitProps = { 19 | fields: API_FIELDS_DATA, 20 | resource: 'books', 21 | schemaAnalyzer: schemaAnalyzer(), 22 | children: [], 23 | }; 24 | 25 | jest.mock('./getIdentifierValue.js'); 26 | 27 | test.each([ 28 | { 29 | name: 'Book name 1', 30 | authors: ['Author 1', 'Author 2'], 31 | cover: { rawFile: new File(['content'], 'cover.png') }, 32 | }, 33 | { 34 | name: 'Book name 2', 35 | authors: ['Author 1', 'Author 2'], 36 | covers: [ 37 | { rawFile: new File(['content1'], 'cover1.png') }, 38 | { rawFile: new File(['content2'], 'cover2.png') }, 39 | ], 40 | }, 41 | ])( 42 | 'Call create with file input ($name)', 43 | async (values: Omit) => { 44 | let save; 45 | const Dummy = () => { 46 | const onSubmit = useOnSubmit(onSubmitProps); 47 | save = onSubmit; 48 | return ; 49 | }; 50 | render( 51 | 52 | 53 | 54 | 55 | } /> 56 | } /> 57 | } /> 58 | 59 | 60 | 61 | , 62 | ); 63 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 64 | // @ts-ignore 65 | save(values); 66 | await waitFor(() => { 67 | expect(dataProvider.create).toHaveBeenCalledWith('books', { 68 | data: values, 69 | meta: { 70 | hasFileField: true, 71 | }, 72 | previousData: undefined, 73 | }); 74 | }); 75 | }, 76 | ); 77 | 78 | test.each([ 79 | { 80 | id: '1', 81 | name: 'Book name 1', 82 | authors: ['Author 1', 'Author 2'], 83 | }, 84 | { 85 | id: '2', 86 | name: 'Book name 2', 87 | authors: ['Author 1', 'Author 2'], 88 | }, 89 | { 90 | id: '3', 91 | name: 'Book name 3', 92 | authors: ['Author 1', 'Author 2'], 93 | cover: null, 94 | }, 95 | ])('Call update without file inputs ($name)', async (values: RaRecord) => { 96 | let save; 97 | const Dummy = () => { 98 | const onSubmit = useOnSubmit(onSubmitProps); 99 | save = onSubmit; 100 | return ; 101 | }; 102 | render( 103 | 104 | 105 | 106 | 107 | } /> 108 | } /> 109 | 110 | 111 | 112 | , 113 | ); 114 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 115 | // @ts-ignore 116 | save(values); 117 | await waitFor(() => { 118 | expect(dataProvider.update).toHaveBeenCalledWith('books', { 119 | id: values.id, 120 | data: values, 121 | meta: { 122 | hasFileField: false, 123 | }, 124 | previousData: undefined, 125 | }); 126 | }); 127 | }); 128 | -------------------------------------------------------------------------------- /src/core/AdminGuesser.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AdminUI, AuthContext } from 'react-admin'; 3 | import type { AdminProps, AuthProvider } from 'react-admin'; 4 | import ReactTestRenderer from 'react-test-renderer/shallow'; 5 | import AdminGuesser from './AdminGuesser.js'; 6 | import { AdminResourcesGuesser } from './AdminResourcesGuesser.js'; 7 | import ResourceGuesser from './ResourceGuesser.js'; 8 | import schemaAnalyzer from '../hydra/schemaAnalyzer.js'; 9 | import resources from '../__fixtures__/resources.js'; 10 | import { API_DATA } from '../__fixtures__/parsedData.js'; 11 | import type { 12 | ApiPlatformAdminDataProvider, 13 | ApiPlatformAdminRecord, 14 | } from '../types.js'; 15 | 16 | const dataProvider: ApiPlatformAdminDataProvider = { 17 | getList: () => Promise.resolve({ data: [], total: 0 }), 18 | getOne: () => 19 | Promise.resolve({ data: { id: 'id' } } as { data: RecordType }), 20 | getMany: () => Promise.resolve({ data: [] }), 21 | getManyReference: () => Promise.resolve({ data: [], total: 0 }), 22 | update: () => 23 | Promise.resolve({ data: {} } as { data: RecordType }), 24 | updateMany: () => Promise.resolve({ data: [] }), 25 | create: () => 26 | Promise.resolve({ data: {} } as { data: RecordType }), 27 | delete: () => 28 | Promise.resolve({ data: { id: 'id' } } as { data: RecordType }), 29 | deleteMany: () => Promise.resolve({ data: [] }), 30 | introspect: () => Promise.resolve({ data: API_DATA }), 31 | subscribe: () => Promise.resolve({ data: null }), 32 | unsubscribe: () => Promise.resolve({ data: null }), 33 | }; 34 | 35 | describe('', () => { 36 | const renderer = ReactTestRenderer.createRenderer(); 37 | 38 | test('renders loading', () => { 39 | renderer.render( 40 | , 46 | ); 47 | 48 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 49 | }); 50 | 51 | test('renders without custom resources', () => { 52 | renderer.render( 53 | , 59 | ); 60 | 61 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 62 | }); 63 | 64 | test('renders with custom resources', () => { 65 | renderer.render( 66 | 71 | 72 | , 73 | ); 74 | 75 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 76 | }); 77 | 78 | test('renders without data', () => { 79 | renderer.render( 80 | , 86 | ); 87 | 88 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 89 | }); 90 | 91 | test('renders with admin element', () => { 92 | const authProvider: AuthProvider = { 93 | getPermissions: () => Promise.resolve(['user']), 94 | getIdentity: () => 95 | Promise.resolve({ 96 | id: '/users/2', 97 | fullName: 'Test User', 98 | avatar: undefined, 99 | }), 100 | login: () => Promise.resolve(), 101 | logout: () => Promise.resolve(), 102 | checkAuth: () => Promise.resolve(), 103 | checkError: () => Promise.resolve(), 104 | }; 105 | 106 | const AdminEl = (props: AdminProps) => ( 107 | 108 | 109 | 110 | ); 111 | 112 | renderer.render( 113 | , 118 | ); 119 | 120 | expect(renderer.getRenderOutput()).not.toBeNull(); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /src/core/AdminGuesser.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useMemo, useState } from 'react'; 2 | import { 3 | AdminContext, 4 | Loading, 5 | defaultI18nProvider, 6 | /* tree-shaking no-side-effects-when-called */ localStorageStore, 7 | } from 'react-admin'; 8 | 9 | import type { ComponentType } from 'react'; 10 | import type { AdminProps } from 'react-admin'; 11 | import type { Resource } from '@api-platform/api-doc-parser'; 12 | 13 | import { AdminResourcesGuesser } from './AdminResourcesGuesser.js'; 14 | import IntrospectionContext from '../introspection/IntrospectionContext.js'; 15 | import SchemaAnalyzerContext from '../introspection/SchemaAnalyzerContext.js'; 16 | import { 17 | Error as DefaultError, 18 | Layout, 19 | LoginPage, 20 | darkTheme as defaultDarkTheme, 21 | lightTheme as defaultLightTheme, 22 | } from '../layout/index.js'; 23 | import type { ApiPlatformAdminDataProvider, SchemaAnalyzer } from '../types.js'; 24 | 25 | export interface AdminGuesserProps extends AdminProps { 26 | admin?: ComponentType; 27 | dataProvider: ApiPlatformAdminDataProvider; 28 | schemaAnalyzer: SchemaAnalyzer; 29 | includeDeprecated?: boolean; 30 | } 31 | 32 | const defaultStore = localStorageStore(); 33 | 34 | const AdminGuesser = ({ 35 | // Props for SchemaAnalyzerContext 36 | schemaAnalyzer, 37 | // Props for AdminResourcesGuesser 38 | includeDeprecated = false, 39 | // Admin props 40 | basename, 41 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 42 | error = DefaultError as any, 43 | store = defaultStore, 44 | dataProvider, 45 | i18nProvider = defaultI18nProvider, 46 | authProvider, 47 | queryClient, 48 | defaultTheme, 49 | layout = Layout, 50 | loginPage = LoginPage, 51 | loading: loadingPage, 52 | theme = defaultLightTheme, 53 | darkTheme = defaultDarkTheme, 54 | // Other props 55 | children, 56 | ...rest 57 | }: AdminGuesserProps) => { 58 | const [resources, setResources] = useState([]); 59 | const [loading, setLoading] = useState(true); 60 | const [, setError] = useState(); 61 | const [introspect, setIntrospect] = useState(true); 62 | 63 | useEffect(() => { 64 | if (typeof dataProvider.introspect !== 'function') { 65 | throw new Error( 66 | 'The given dataProvider needs to expose an "introspect" function returning a parsed API documentation from api-doc-parser', 67 | ); 68 | } 69 | 70 | if (!introspect) { 71 | return; 72 | } 73 | 74 | dataProvider 75 | .introspect() 76 | .then(({ data }) => { 77 | setResources(data.resources ?? []); 78 | setIntrospect(false); 79 | setLoading(false); 80 | }) 81 | .catch((err) => { 82 | // Allow err to be caught by the error boundary 83 | setError(() => { 84 | throw err; 85 | }); 86 | }); 87 | }, [introspect, dataProvider]); 88 | 89 | const introspectionContext = useMemo( 90 | () => ({ 91 | introspect: () => { 92 | setLoading(true); 93 | setIntrospect(true); 94 | }, 95 | }), 96 | [setLoading, setIntrospect], 97 | ); 98 | 99 | if (loading) { 100 | return ; 101 | } 102 | 103 | return ( 104 | 114 | 115 | 116 | 127 | {children} 128 | 129 | 130 | 131 | 132 | ); 133 | }; 134 | 135 | export default AdminGuesser; 136 | -------------------------------------------------------------------------------- /src/field/FieldGuesser.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AdminContext, Show } from 'react-admin'; 3 | import { Resource } from '@api-platform/api-doc-parser'; 4 | import { render, screen, waitFor } from '@testing-library/react'; 5 | 6 | import FieldGuesser from './FieldGuesser.js'; 7 | import SchemaAnalyzerContext from '../introspection/SchemaAnalyzerContext.js'; 8 | import schemaAnalyzer from '../hydra/schemaAnalyzer.js'; 9 | import type { 10 | ApiPlatformAdminDataProvider, 11 | ApiPlatformAdminRecord, 12 | } from '../types.js'; 13 | 14 | import { API_FIELDS_DATA } from '../__fixtures__/parsedData.js'; 15 | 16 | const hydraSchemaAnalyzer = schemaAnalyzer(); 17 | const dataProvider: ApiPlatformAdminDataProvider = { 18 | getList: () => Promise.resolve({ data: [], total: 0 }), 19 | getMany: () => Promise.resolve({ data: [] }), 20 | getManyReference: () => Promise.resolve({ data: [], total: 0 }), 21 | update: () => 22 | Promise.resolve({ data: { id: '/users/123' } } as { data: RecordType }), 23 | updateMany: () => Promise.resolve({ data: [] }), 24 | create: () => 25 | Promise.resolve({ data: { id: 'id' } } as { data: RecordType }), 26 | delete: () => 27 | Promise.resolve({ data: { id: 'id' } } as { data: RecordType }), 28 | deleteMany: () => Promise.resolve({ data: [] }), 29 | getOne: () => 30 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 31 | // @ts-ignore 32 | Promise.resolve({ 33 | data: { 34 | id: '/users/123', 35 | fieldA: 'fieldA value', 36 | fieldB: 'fieldB value', 37 | deprecatedField: 'deprecatedField value', 38 | title: 'Title', 39 | description: 'Lorem ipsum dolor sit amet', 40 | nullText: null, 41 | embedded: { 42 | address: '91 rue du Temple', 43 | }, 44 | embeddeds: [ 45 | { 46 | address: '16 avenue de Rivoli', 47 | }, 48 | ], 49 | formatType: 'https://schema.org/EBook', 50 | status: 'AVAILABLE', 51 | genre: ['MYTH', 'FAIRY_TALE'], 52 | }, 53 | }), 54 | introspect: () => 55 | Promise.resolve({ 56 | data: { 57 | entrypoint: 'entrypoint', 58 | resources: [ 59 | new Resource('users', '/users', { 60 | fields: API_FIELDS_DATA, 61 | readableFields: API_FIELDS_DATA, 62 | writableFields: API_FIELDS_DATA, 63 | parameters: [], 64 | }), 65 | ], 66 | }, 67 | }), 68 | subscribe: () => Promise.resolve({ data: null }), 69 | unsubscribe: () => Promise.resolve({ data: null }), 70 | }; 71 | 72 | describe('', () => { 73 | test.each([ 74 | // Default enum names. 75 | { 76 | transformEnum: undefined, 77 | expectedValues: [ 78 | 'Https://schema.org/ebook', 79 | 'Available', 80 | 'Myth', 81 | 'Fairy tale', 82 | ], 83 | }, 84 | // Custom transformation. 85 | { 86 | transformEnum: (value: string | number): string => 87 | `${value}` 88 | .split('/') 89 | .slice(-1)[0] 90 | ?.replace(/([a-z])([A-Z])/, '$1_$2') 91 | .toUpperCase() ?? '', 92 | expectedValues: ['EBOOK', 'AVAILABLE', 'MYTH', 'FAIRY_TALE'], 93 | }, 94 | ])( 95 | 'renders enum fields with transformation', 96 | async ({ transformEnum, expectedValues }) => { 97 | const props = transformEnum ? { transformEnum } : {}; 98 | render( 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | , 109 | ); 110 | await waitFor(() => { 111 | expect(screen.queryAllByText('Title')).toHaveLength(1); 112 | expectedValues.forEach((value) => { 113 | expect(screen.queryAllByText(value)).toHaveLength(1); 114 | }); 115 | }); 116 | }, 117 | ); 118 | }); 119 | -------------------------------------------------------------------------------- /src/edit/EditGuesser.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Edit, 4 | FormTab, 5 | SimpleForm, 6 | TabbedForm, 7 | useResourceContext, 8 | } from 'react-admin'; 9 | import { useParams } from 'react-router-dom'; 10 | import type { Field, Resource } from '@api-platform/api-doc-parser'; 11 | 12 | import InputGuesser from '../input/InputGuesser.js'; 13 | import Introspecter from '../introspection/Introspecter.js'; 14 | import useMercureSubscription from '../mercure/useMercureSubscription.js'; 15 | import useDisplayOverrideCode from '../useDisplayOverrideCode.js'; 16 | import useOnSubmit from '../useOnSubmit.js'; 17 | import type { 18 | EditGuesserProps, 19 | IntrospectedEditGuesserProps, 20 | } from '../types.js'; 21 | 22 | const getOverrideCode = (schema: Resource, fields: Field[]) => { 23 | let code = `If you want to override at least one input, create a ${schema.title}Edit component with this content:\n`; 24 | code += `\n`; 25 | code += `import { EditGuesser, InputGuesser } from "@api-platform/admin";\n`; 26 | code += `\n`; 27 | code += `export const ${schema.title}Edit = () => (\n`; 28 | code += ` \n`; 29 | fields.forEach((field) => { 30 | code += ` \n`; 31 | }); 32 | code += ` \n`; 33 | code += `);\n`; 34 | code += `\n`; 35 | code += `Then, update your main admin component:\n`; 36 | code += `\n`; 37 | code += `import { HydraAdmin, ResourceGuesser } from "@api-platform/admin";\n`; 38 | code += `import { ${schema.title}Edit } from './${schema.title}Edit';\n`; 39 | code += `\n`; 40 | code += `const App = () => (\n`; 41 | code += ` \n`; 42 | code += ` \n`; 43 | code += ` {/* ... */}\n`; 44 | code += ` \n`; 45 | code += `);\n`; 46 | 47 | return code; 48 | }; 49 | 50 | export const IntrospectedEditGuesser = ({ 51 | fields, 52 | readableFields, 53 | writableFields, 54 | schema, 55 | schemaAnalyzer, 56 | resource, 57 | mutationMode = 'pessimistic', 58 | mutationOptions, 59 | redirect: redirectTo = 'list', 60 | mode, 61 | defaultValues, 62 | validate, 63 | transform, 64 | toolbar, 65 | warnWhenUnsavedChanges, 66 | formComponent, 67 | viewComponent, 68 | sanitizeEmptyValues = true, 69 | children, 70 | ...props 71 | }: IntrospectedEditGuesserProps) => { 72 | const { id: routeId } = useParams<'id'>(); 73 | const id = decodeURIComponent(routeId ?? ''); 74 | const save = useOnSubmit({ 75 | resource, 76 | schemaAnalyzer, 77 | fields, 78 | mutationOptions, 79 | transform, 80 | redirectTo, 81 | children: [], 82 | }); 83 | useMercureSubscription(resource, id); 84 | 85 | const displayOverrideCode = useDisplayOverrideCode(); 86 | 87 | let inputChildren = React.Children.toArray(children); 88 | if (inputChildren.length === 0) { 89 | inputChildren = writableFields.map((field) => ( 90 | 91 | )); 92 | displayOverrideCode(getOverrideCode(schema, writableFields)); 93 | } 94 | 95 | const hasFormTab = inputChildren.some( 96 | (child) => 97 | typeof child === 'object' && 'type' in child && child.type === FormTab, 98 | ); 99 | const FormType = hasFormTab ? TabbedForm : SimpleForm; 100 | 101 | return ( 102 | 109 | 118 | {inputChildren} 119 | 120 | 121 | ); 122 | }; 123 | 124 | const EditGuesser = (props: EditGuesserProps) => { 125 | const resource = useResourceContext(props); 126 | if (!resource) { 127 | throw new Error('EditGuesser must be used with a resource'); 128 | } 129 | 130 | return ( 131 | 136 | ); 137 | }; 138 | 139 | export default EditGuesser; 140 | -------------------------------------------------------------------------------- /src/useOnSubmit.ts: -------------------------------------------------------------------------------- 1 | import { useCallback } from 'react'; 2 | import { useCreate, useNotify, useRedirect, useUpdate } from 'react-admin'; 3 | import type { HttpError, RaRecord } from 'react-admin'; 4 | import { useParams } from 'react-router-dom'; 5 | import lodashIsPlainObject from 'lodash.isplainobject'; 6 | 7 | import getIdentifierValue from './getIdentifierValue.js'; 8 | import type { SubmissionErrors, UseOnSubmitProps } from './types.js'; 9 | 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | const findFile = (values: any[]): Blob | undefined => 12 | values.find((value) => 13 | Array.isArray(value) 14 | ? findFile(value) 15 | : lodashIsPlainObject(value) && 16 | Object.values(value).find((val) => val instanceof File), 17 | ); 18 | 19 | const useOnSubmit = ({ 20 | resource, 21 | schemaAnalyzer, 22 | fields, 23 | mutationOptions, 24 | transform, 25 | redirectTo = 'list', 26 | }: UseOnSubmitProps): (( 27 | values: Partial, 28 | ) => Promise) => { 29 | const { id: routeId } = useParams<'id'>(); 30 | const id = decodeURIComponent(routeId ?? ''); 31 | const [create] = useCreate(); 32 | const [update] = useUpdate(); 33 | const notify = useNotify(); 34 | const redirect = useRedirect(); 35 | 36 | return useCallback( 37 | async (values: Partial) => { 38 | const isCreate = id === ''; 39 | const data = transform ? transform(values) : values; 40 | 41 | // Identifiers need to be formatted in case they have not been modified in the form. 42 | if (!isCreate) { 43 | Object.entries(values).forEach(([key, value]) => { 44 | const identifierValue = getIdentifierValue( 45 | schemaAnalyzer, 46 | resource, 47 | fields, 48 | key, 49 | value, 50 | ); 51 | if (identifierValue !== value) { 52 | data[key] = identifierValue; 53 | } 54 | }); 55 | } 56 | try { 57 | const response = await (isCreate ? create : update)( 58 | resource, 59 | { 60 | ...(isCreate ? {} : { id }), 61 | data, 62 | meta: { hasFileField: !!findFile(Object.values(values)) }, 63 | }, 64 | { returnPromise: true }, 65 | ); 66 | const success = 67 | mutationOptions?.onSuccess ?? 68 | ((record: RaRecord) => { 69 | notify( 70 | isCreate ? 'ra.notification.created' : 'ra.notification.updated', 71 | { 72 | type: 'info', 73 | messageArgs: { smart_count: 1 }, 74 | }, 75 | ); 76 | redirect(redirectTo, resource, record.id, record); 77 | }); 78 | success( 79 | response, 80 | { 81 | data: response, 82 | ...(isCreate ? {} : { id, previousData: values }), 83 | }, 84 | {}, 85 | ); 86 | 87 | return undefined; 88 | } catch (mutateError) { 89 | const submissionErrors = schemaAnalyzer.getSubmissionErrors( 90 | mutateError as HttpError, 91 | ); 92 | const failure = 93 | mutationOptions?.onError ?? 94 | ((error: string | Error) => { 95 | let message = 'ra.notification.http_error'; 96 | if (!submissionErrors) { 97 | message = 98 | typeof error === 'string' ? error : error.message || message; 99 | } 100 | let errorMessage; 101 | if (typeof error === 'string') { 102 | errorMessage = error; 103 | } else if (error?.message) { 104 | errorMessage = error.message; 105 | } 106 | notify(message, { 107 | type: 'warning', 108 | messageArgs: { _: errorMessage }, 109 | }); 110 | }); 111 | failure( 112 | mutateError as Error, 113 | { 114 | data: values, 115 | ...(isCreate ? {} : { id, previousData: values }), 116 | }, 117 | {}, 118 | ); 119 | if (submissionErrors) { 120 | return submissionErrors; 121 | } 122 | return {}; 123 | } 124 | }, 125 | [ 126 | fields, 127 | id, 128 | mutationOptions, 129 | notify, 130 | redirect, 131 | redirectTo, 132 | resource, 133 | schemaAnalyzer, 134 | transform, 135 | create, 136 | update, 137 | ], 138 | ); 139 | }; 140 | 141 | export default useOnSubmit; 142 | -------------------------------------------------------------------------------- /src/create/CreateGuesser.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { AdminContext, FormTab, TextInput } from 'react-admin'; 3 | import { Resource } from '@api-platform/api-doc-parser'; 4 | import { render, screen, waitFor } from '@testing-library/react'; 5 | import '@testing-library/jest-dom'; 6 | import userEvent from '@testing-library/user-event'; 7 | 8 | import CreateGuesser from './CreateGuesser.js'; 9 | import SchemaAnalyzerContext from '../introspection/SchemaAnalyzerContext.js'; 10 | import schemaAnalyzer from '../hydra/schemaAnalyzer.js'; 11 | import type { 12 | ApiPlatformAdminDataProvider, 13 | ApiPlatformAdminRecord, 14 | } from '../types.js'; 15 | 16 | import { API_FIELDS_DATA } from '../__fixtures__/parsedData.js'; 17 | 18 | const hydraSchemaAnalyzer = schemaAnalyzer(); 19 | const dataProvider: ApiPlatformAdminDataProvider = { 20 | getList: () => Promise.resolve({ data: [], total: 0 }), 21 | getMany: () => Promise.resolve({ data: [] }), 22 | getManyReference: () => Promise.resolve({ data: [], total: 0 }), 23 | update: () => 24 | Promise.resolve({ data: { id: 'id' } } as { data: RecordType }), 25 | updateMany: () => Promise.resolve({ data: [] }), 26 | create: () => 27 | Promise.resolve({ data: { id: 'id' } } as { data: RecordType }), 28 | delete: () => 29 | Promise.resolve({ data: { id: 'id' } } as { data: RecordType }), 30 | deleteMany: () => Promise.resolve({ data: [] }), 31 | getOne: () => 32 | Promise.resolve({ data: { id: 'id' } } as { data: RecordType }), 33 | introspect: () => 34 | Promise.resolve({ 35 | data: { 36 | entrypoint: 'entrypoint', 37 | resources: [ 38 | new Resource('users', '/users', { 39 | fields: API_FIELDS_DATA, 40 | readableFields: API_FIELDS_DATA, 41 | writableFields: API_FIELDS_DATA, 42 | parameters: [], 43 | }), 44 | ], 45 | }, 46 | }), 47 | subscribe: () => Promise.resolve({ data: null }), 48 | unsubscribe: () => Promise.resolve({ data: null }), 49 | }; 50 | 51 | describe('', () => { 52 | test('renders with custom fields', async () => { 53 | render( 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | , 63 | ); 64 | 65 | await waitFor(() => { 66 | expect(screen.queryAllByRole('tab')).toHaveLength(0); 67 | expect(screen.queryByText('label of id')).toBeVisible(); 68 | expect(screen.queryByText('label of title')).toBeVisible(); 69 | expect(screen.queryByText('label of body')).toBeVisible(); 70 | }); 71 | }); 72 | 73 | test.each([0, 1])('renders with tabs', async (tabId) => { 74 | const user = userEvent.setup(); 75 | 76 | render( 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | , 90 | ); 91 | await waitFor(async () => { 92 | expect(screen.queryAllByRole('tab')).toHaveLength(2); 93 | const tab = screen.getAllByRole('tab')[tabId]; 94 | if (tab) { 95 | await user.click(tab); 96 | } 97 | if (tabId === 0) { 98 | // First tab, available. 99 | expect(screen.queryByText('label of id')).toBeVisible(); 100 | expect(screen.queryByText('label of title')).toBeVisible(); 101 | // Second tab, unavailable. 102 | expect(screen.queryByText('label of body')).not.toBeVisible(); 103 | } else { 104 | // First tab, unavailable. 105 | expect(screen.queryByText('label of id')).not.toBeVisible(); 106 | expect(screen.queryByText('label of title')).not.toBeVisible(); 107 | // Second tab, available. 108 | expect(screen.queryByText('label of body')).toBeVisible(); 109 | } 110 | }); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /src/dataProvider/restDataProvider.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from 'query-string'; 2 | import { fetchUtils } from 'react-admin'; 3 | import type { DataProvider } from 'react-admin'; 4 | import { removeTrailingSlash } from '../removeTrailingSlash.js'; 5 | 6 | // Based on https://github.com/marmelab/react-admin/blob/master/packages/ra-data-simple-rest/src/index.ts 7 | 8 | export default ( 9 | entrypoint: string, 10 | httpClient = fetchUtils.fetchJson, 11 | ): DataProvider => { 12 | const apiUrl = new URL(entrypoint, window.location.href); 13 | 14 | return { 15 | getList: async (resource, params) => { 16 | const { page, perPage } = params.pagination ?? { page: 1, perPage: 25 }; 17 | const { field, order } = params.sort ?? { field: 'id', order: 'DESC' }; 18 | 19 | const rangeStart = (page - 1) * perPage; 20 | const rangeEnd = page * perPage - 1; 21 | 22 | const query = { 23 | sort: JSON.stringify([field, order]), 24 | range: JSON.stringify([rangeStart, rangeEnd]), 25 | filter: JSON.stringify(params.filter), 26 | }; 27 | const url = `${removeTrailingSlash( 28 | apiUrl.toString(), 29 | )}/${resource}?${stringify(query)}`; 30 | const { json } = await httpClient(url); 31 | 32 | return { 33 | data: json, 34 | pageInfo: { 35 | hasNextPage: true, 36 | hasPreviousPage: page > 1, 37 | }, 38 | }; 39 | }, 40 | 41 | getOne: async (resource, params) => { 42 | const url = `${removeTrailingSlash(apiUrl.toString())}/${resource}/${ 43 | params.id 44 | }`; 45 | const { json } = await httpClient(url); 46 | 47 | return { 48 | data: json, 49 | }; 50 | }, 51 | 52 | getMany: async (resource, params) => { 53 | const query = { 54 | filter: JSON.stringify({ id: params.ids }), 55 | }; 56 | const url = `${removeTrailingSlash( 57 | apiUrl.toString(), 58 | )}/${resource}?${stringify(query)}`; 59 | const { json } = await httpClient(url); 60 | 61 | return { 62 | data: json, 63 | }; 64 | }, 65 | 66 | getManyReference: async (resource, params) => { 67 | const { page, perPage } = params.pagination; 68 | const { field, order } = params.sort; 69 | 70 | const rangeStart = (page - 1) * perPage; 71 | const rangeEnd = page * perPage - 1; 72 | 73 | const query = { 74 | sort: JSON.stringify([field, order]), 75 | range: JSON.stringify([rangeStart, rangeEnd]), 76 | filter: JSON.stringify({ 77 | ...params.filter, 78 | [params.target]: params.id, 79 | }), 80 | }; 81 | const url = `${removeTrailingSlash( 82 | apiUrl.toString(), 83 | )}/${resource}?${stringify(query)}`; 84 | const { json } = await httpClient(url); 85 | 86 | return { 87 | data: json, 88 | pageInfo: { 89 | hasNextPage: true, 90 | hasPreviousPage: page > 1, 91 | }, 92 | }; 93 | }, 94 | 95 | update: async (resource, params) => { 96 | const url = `${removeTrailingSlash(apiUrl.toString())}/${resource}/${ 97 | params.id 98 | }`; 99 | const { json } = await httpClient(url, { 100 | method: 'PUT', 101 | body: JSON.stringify(params.data), 102 | }); 103 | 104 | return { 105 | data: json, 106 | }; 107 | }, 108 | 109 | updateMany: async (resource, params) => { 110 | const responses = await Promise.all( 111 | params.ids.map((id) => { 112 | const url = `${removeTrailingSlash( 113 | apiUrl.toString(), 114 | )}/${resource}/${id}`; 115 | 116 | return httpClient(url, { 117 | method: 'PUT', 118 | body: JSON.stringify(params.data), 119 | }); 120 | }), 121 | ); 122 | 123 | return { data: responses.map(({ json }) => json.id) }; 124 | }, 125 | 126 | create: async (resource, params) => { 127 | const url = `${removeTrailingSlash(apiUrl.toString())}/${resource}`; 128 | const { json } = await httpClient(url, { 129 | method: 'POST', 130 | body: JSON.stringify(params.data), 131 | }); 132 | 133 | return { 134 | data: json, 135 | }; 136 | }, 137 | 138 | delete: async (resource, params) => { 139 | const url = `${removeTrailingSlash(apiUrl.toString())}/${resource}/${ 140 | params.id 141 | }`; 142 | const { json } = await httpClient(url, { 143 | method: 'DELETE', 144 | }); 145 | 146 | return { 147 | data: json, 148 | }; 149 | }, 150 | 151 | deleteMany: async (resource, params) => { 152 | const responses = await Promise.all( 153 | params.ids.map((id) => { 154 | const url = `${removeTrailingSlash( 155 | apiUrl.toString(), 156 | )}/${resource}/${id}`; 157 | 158 | return httpClient(url, { 159 | method: 'DELETE', 160 | }); 161 | }), 162 | ); 163 | 164 | return { 165 | data: responses.map(({ json }) => json.id), 166 | }; 167 | }, 168 | }; 169 | }; 170 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # API Platform Admin 2 | 3 | [![GitHub Actions](https://github.com/api-platform/admin/workflows/CI/badge.svg?branch=main)](https://github.com/api-platform/admin/actions?query=workflow%3ACI+branch%3Amain) 4 | [![npm version](https://badge.fury.io/js/%40api-platform%2Fadmin.svg)](https://badge.fury.io/js/%40api-platform%2Fadmin) 5 | 6 | API Platform Admin is a tool to automatically create a beautiful (Material Design) and fully-featured administration interface 7 | for any API supporting [the Hydra Core Vocabulary](http://www.hydra-cg.com/) or exposing an [OpenAPI documentation](https://www.openapis.org/), 8 | including but not limited to all APIs created using [the API Platform framework](https://api-platform.com). 9 | 10 | ![Demo of API Platform Admin in action](https://api-platform.com/97cd2738071d63989db0bbcb6ba85a25/admin-demo.gif) 11 | 12 | The generated administration is a 100% standalone Single-Page-Application with no coupling to the server part, according 13 | to the API-first paradigm. 14 | 15 | API Platform Admin parses Hydra or OpenAPI documentations, then uses the awesome [React-admin](https://marmelab.com/react-admin/) 16 | library (and [React](https://facebook.github.io/react/)) to expose a nice, responsive, management interface (Create-Retrieve-Update-Delete) 17 | for all available resources. 18 | 19 | You can also customize all screens by using React-admin components and even raw JavaScript/React code. 20 | 21 | ## Demo 22 | 23 | [Click here](https://demo.api-platform.com/admin) to test API Platform Admin in live. 24 | 25 | The source code of the demo is available [in this repository](https://github.com/api-platform/demo). 26 | 27 | ## Installation 28 | 29 | yarn add @api-platform/admin 30 | 31 | ## Usage 32 | 33 | ```javascript 34 | import React from 'react'; 35 | import ReactDOM from 'react-dom'; 36 | import { HydraAdmin, OpenApiAdmin } from '@api-platform/admin'; 37 | 38 | // To use Hydra: 39 | const Admin = () => ; // Replace with your own API entrypoint 40 | // To use OpenAPI (with a very simple REST data provider): 41 | const Admin = () => ; 45 | 46 | ReactDOM.render(, document.getElementById('root')); 47 | ``` 48 | 49 | Or alternatively: 50 | 51 | ```javascript 52 | import React from 'react'; 53 | import ReactDOM from 'react-dom'; 54 | import { 55 | AdminGuesser, 56 | hydraDataProvider, 57 | hydraSchemaAnalyzer, 58 | openApiDataProvider, 59 | openApiSchemaAnalyzer 60 | } from '@api-platform/admin'; 61 | import simpleRestProvider from 'ra-data-simple-rest'; 62 | 63 | // Use your custom data provider or resource schema analyzer 64 | // Hydra: 65 | const dataProvider = hydraDataProvider({ entrypoint: 'https://demo.api-platform.com' }); 66 | const schemaAnalyzer = hydraSchemaAnalyzer(); 67 | // OpenAPI: 68 | const dataProvider = openApiDataProvider({ 69 | // Use any data provider you like 70 | dataProvider: simpleRestProvider('https://demo.api-platform.com'), 71 | entrypoint: 'https://demo.api-platform.com', 72 | docEntrypoint: 'https://demo.api-platform.com/docs.json', 73 | }); 74 | const schemaAnalyzer = openApiSchemaAnalyzer(); 75 | 76 | const Admin = () => ( 77 | 81 | ); 82 | 83 | ReactDOM.render(, document.getElementById('root')); 84 | ``` 85 | 86 | ## Features 87 | 88 | * Automatically generates an admin interface for all the resources of the API thanks to the hypermedia features of Hydra or to the OpenAPI documentation 89 | * Generates 'list', 'create', 'show', and 'edit' screens, as well as a delete button 90 | * Generates suitable inputs and fields according to the API doc (e.g. number HTML input for numbers, checkbox for booleans, selectbox for relationships...) 91 | * Generates suitable inputs and fields according to Schema.org types if available (e.g. email field for `http://schema.org/email`) 92 | * Handles relationships 93 | * Supports pagination 94 | * Supports filters and ordering 95 | * Automatically validates whether a field is mandatory client-side according to the API description 96 | * Sends proper HTTP requests to the API and decodes them using Hydra and JSON-LD formats if available 97 | * Nicely displays server-side errors (e.g. advanced validation) 98 | * Supports real-time updates with [Mercure](https://mercure.rocks) 99 | * All the [features provided by React-admin](https://marmelab.com/react-admin/Tutorial.html) can also be used 100 | * **100% customizable** 101 | 102 | ## Documentation 103 | 104 | The documentation of API Platform Admin can be browsed [on the official website](https://api-platform.com/docs/admin/). 105 | 106 | Check also the documentation of React-admin [on their official website](https://marmelab.com/react-admin/Tutorial.html). 107 | 108 | ## Credits 109 | 110 | Created by [Kévin Dunglas](https://dunglas.fr). Sponsored by [Les-Tilleuls.coop](https://les-tilleuls.coop). 111 | Commercial support available upon request. 112 | -------------------------------------------------------------------------------- /src/core/ResourceGuesser.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | AdminContext, 4 | useGetOne, 5 | useGetRecordRepresentation, 6 | } from 'react-admin'; 7 | import ReactTestRenderer from 'react-test-renderer/shallow'; 8 | import { Resource } from '@api-platform/api-doc-parser'; 9 | import { render, screen } from '@testing-library/react'; 10 | import '@testing-library/jest-dom'; 11 | import ResourceGuesser from './ResourceGuesser.js'; 12 | import SchemaAnalyzerContext from '../introspection/SchemaAnalyzerContext.js'; 13 | import schemaAnalyzer from '../hydra/schemaAnalyzer.js'; 14 | import type { 15 | ApiPlatformAdminDataProvider, 16 | ApiPlatformAdminRecord, 17 | } from '../types.js'; 18 | import { API_FIELDS_DATA } from '../__fixtures__/parsedData.js'; 19 | 20 | const hydraSchemaAnalyzer = schemaAnalyzer(); 21 | const dataProvider: ApiPlatformAdminDataProvider = { 22 | getList: () => Promise.resolve({ data: [], total: 0 }), 23 | getMany: () => Promise.resolve({ data: [] }), 24 | getManyReference: () => Promise.resolve({ data: [], total: 0 }), 25 | update: () => 26 | Promise.resolve({ data: { id: 'id' } } as { data: RecordType }), 27 | updateMany: () => Promise.resolve({ data: [] }), 28 | create: () => 29 | Promise.resolve({ data: { id: 'id' } } as { data: RecordType }), 30 | delete: () => 31 | Promise.resolve({ data: { id: 'id' } } as { data: RecordType }), 32 | deleteMany: () => Promise.resolve({ data: [] }), 33 | getOne: () => 34 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 35 | // @ts-ignore 36 | Promise.resolve({ 37 | data: { 38 | id: '/users/123', 39 | fieldA: 'fieldA value', 40 | fieldB: 'fieldB value', 41 | deprecatedField: 'deprecatedField value', 42 | title: 'Title', 43 | body: 'Body', 44 | }, 45 | }), 46 | introspect: () => 47 | Promise.resolve({ 48 | data: { 49 | entrypoint: 'entrypoint', 50 | resources: [ 51 | new Resource('users', '/users', { 52 | fields: API_FIELDS_DATA, 53 | readableFields: API_FIELDS_DATA, 54 | writableFields: API_FIELDS_DATA, 55 | parameters: [], 56 | }), 57 | ], 58 | }, 59 | }), 60 | subscribe: () => Promise.resolve({ data: null }), 61 | unsubscribe: () => Promise.resolve({ data: null }), 62 | }; 63 | 64 | describe('', () => { 65 | const renderer = ReactTestRenderer.createRenderer(); 66 | 67 | test('renders with create', () => { 68 | const CustomCreate = () => null; 69 | 70 | renderer.render(); 71 | 72 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 73 | }); 74 | 75 | test('renders without create', () => { 76 | renderer.render(); 77 | 78 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 79 | }); 80 | 81 | test('renders with edit', () => { 82 | const CustomEdit = () => null; 83 | 84 | renderer.render(); 85 | 86 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 87 | }); 88 | 89 | test('renders without edit', () => { 90 | renderer.render(); 91 | 92 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 93 | }); 94 | 95 | test('renders with list', () => { 96 | const CustomList = () => null; 97 | 98 | renderer.render(); 99 | 100 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 101 | }); 102 | 103 | test('renders without list', () => { 104 | renderer.render(); 105 | 106 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 107 | }); 108 | 109 | test('renders with show', () => { 110 | const CustomShow = () => null; 111 | 112 | renderer.render(); 113 | 114 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 115 | }); 116 | 117 | test('renders without show', () => { 118 | renderer.render(); 119 | 120 | expect(renderer.getRenderOutput()).toMatchSnapshot(); 121 | }); 122 | 123 | test('supports recordRepresentation', async () => { 124 | const TestComponent = () => { 125 | const { data: user } = useGetOne('users', { id: '/users/123' }); 126 | const getRecordRepresentation = useGetRecordRepresentation('users'); 127 | if (!user) { 128 | return 'loading'; 129 | } 130 | return getRecordRepresentation(user); 131 | }; 132 | render( 133 | 134 | 135 | } 138 | recordRepresentation="fieldA" 139 | /> 140 | 141 | , 142 | ); 143 | 144 | await screen.findByText('fieldA value'); 145 | }); 146 | }); 147 | -------------------------------------------------------------------------------- /src/list/ListGuesser.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import { 3 | Datagrid, 4 | DatagridBody, 5 | EditButton, 6 | List, 7 | ShowButton, 8 | useResourceContext, 9 | useResourceDefinition, 10 | } from 'react-admin'; 11 | import type { DatagridBodyProps } from 'react-admin'; 12 | import type { Field, Resource } from '@api-platform/api-doc-parser'; 13 | 14 | import FieldGuesser from '../field/FieldGuesser.js'; 15 | import FilterGuesser from './FilterGuesser.js'; 16 | import Introspecter from '../introspection/Introspecter.js'; 17 | import useMercureSubscription from '../mercure/useMercureSubscription.js'; 18 | import useDisplayOverrideCode from '../useDisplayOverrideCode.js'; 19 | import type { 20 | ApiPlatformAdminRecord, 21 | IntrospectedListGuesserProps, 22 | ListGuesserProps, 23 | } from '../types.js'; 24 | 25 | const getOverrideCode = (schema: Resource, fields: Field[]) => { 26 | let code = `If you want to override at least one field, create a ${schema.title}List component with this content:\n`; 27 | code += `\n`; 28 | code += `import { ListGuesser, FieldGuesser } from "@api-platform/admin";\n`; 29 | code += `\n`; 30 | code += `export const ${schema.title}List = () => (\n`; 31 | code += ` \n`; 32 | fields.forEach((field) => { 33 | code += ` \n`; 34 | }); 35 | code += ` \n`; 36 | code += `);\n`; 37 | code += `\n`; 38 | code += `Then, update your main admin component:\n`; 39 | code += `\n`; 40 | code += `import { HydraAdmin, ResourceGuesser } from "@api-platform/admin";\n`; 41 | code += `import { ${schema.title}List } from './${schema.title}List';\n`; 42 | code += `\n`; 43 | code += `const App = () => (\n`; 44 | code += ` \n`; 45 | code += ` \n`; 46 | code += ` {/* ... */}\n`; 47 | code += ` \n`; 48 | code += `);\n`; 49 | 50 | return code; 51 | }; 52 | 53 | export const DatagridBodyWithMercure = (props: DatagridBodyProps) => { 54 | const { data } = props; 55 | const resource = useResourceContext(props); 56 | useMercureSubscription( 57 | resource, 58 | data?.map((record: ApiPlatformAdminRecord) => record.id), 59 | ); 60 | 61 | return ; 62 | }; 63 | 64 | export const IntrospectedListGuesser = ({ 65 | fields, 66 | readableFields, 67 | writableFields, 68 | schema, 69 | schemaAnalyzer, 70 | datagridSx, 71 | bulkActionButtons, 72 | rowClick, 73 | rowStyle, 74 | isRowSelectable, 75 | isRowExpandable, 76 | body = DatagridBodyWithMercure, 77 | header, 78 | empty, 79 | hover, 80 | expand, 81 | expandSingle, 82 | optimized, 83 | size, 84 | children, 85 | ...props 86 | }: IntrospectedListGuesserProps) => { 87 | const { hasShow, hasEdit } = useResourceDefinition(props); 88 | const [orderParameters, setOrderParameters] = useState([]); 89 | 90 | useEffect(() => { 91 | if (schema) { 92 | schemaAnalyzer.getOrderParametersFromSchema(schema).then((parameters) => { 93 | setOrderParameters(parameters); 94 | }); 95 | } 96 | }, [schema, schemaAnalyzer]); 97 | 98 | const displayOverrideCode = useDisplayOverrideCode(); 99 | 100 | let fieldChildren = children; 101 | if (!fieldChildren) { 102 | fieldChildren = readableFields.map((field) => { 103 | const orderField = orderParameters.find( 104 | (orderParameter) => orderParameter.split('.')[0] === field.name, 105 | ); 106 | 107 | return ( 108 | 115 | ); 116 | }); 117 | 118 | displayOverrideCode(getOverrideCode(schema, readableFields)); 119 | } 120 | 121 | return ( 122 | 123 | 138 | {fieldChildren} 139 | {hasShow && } 140 | {hasEdit && } 141 | 142 | 143 | ); 144 | }; 145 | 146 | const ListGuesser = ({ 147 | filters = , 148 | ...props 149 | }: ListGuesserProps) => { 150 | const resource = useResourceContext(props); 151 | if (!resource) { 152 | throw new Error('ListGuesser must be used with a resource'); 153 | } 154 | 155 | return ( 156 | 162 | ); 163 | }; 164 | 165 | export default ListGuesser; 166 | -------------------------------------------------------------------------------- /src/field/FieldGuesser.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | ArrayField, 4 | BooleanField, 5 | ChipField, 6 | DateField, 7 | EmailField, 8 | NumberField, 9 | ReferenceArrayField, 10 | ReferenceField, 11 | SimpleList, 12 | SingleFieldList, 13 | TextField, 14 | UrlField, 15 | useResourceContext, 16 | } from 'react-admin'; 17 | import type { 18 | ArrayFieldProps, 19 | BooleanFieldProps, 20 | DateFieldProps, 21 | EmailFieldProps, 22 | NumberFieldProps, 23 | ReferenceArrayFieldProps, 24 | ReferenceFieldProps, 25 | SingleFieldListProps, 26 | TextFieldProps, 27 | UrlFieldProps, 28 | } from 'react-admin'; 29 | import type { Field, Resource } from '@api-platform/api-doc-parser'; 30 | 31 | import Introspecter from '../introspection/Introspecter.js'; 32 | import EnumField from './EnumField.js'; 33 | import type { 34 | EnumFieldProps, 35 | FieldGuesserProps, 36 | FieldProps, 37 | IntrospectedFieldGuesserProps, 38 | SchemaAnalyzer, 39 | } from '../types.js'; 40 | 41 | const isFieldSortable = (field: Field, schema: Resource) => 42 | !!schema.parameters && 43 | schema.parameters.filter((parameter) => parameter.variable === field.name) 44 | .length > 0 && 45 | schema.parameters.filter( 46 | (parameter) => parameter.variable === `order[${field.name}]`, 47 | ).length > 0; 48 | 49 | const renderField = ( 50 | field: Field, 51 | schemaAnalyzer: SchemaAnalyzer, 52 | props: FieldProps, 53 | ) => { 54 | if (field.reference !== null && typeof field.reference === 'object') { 55 | if (field.maxCardinality === 1) { 56 | return ( 57 | 60 | 63 | 64 | ); 65 | } 66 | 67 | const fieldName = schemaAnalyzer.getFieldNameFromSchema(field.reference); 68 | const { linkType, ...rest } = props as ReferenceArrayFieldProps & 69 | Pick; 70 | return ( 71 | 74 | 75 | 76 | 77 | 78 | ); 79 | } 80 | 81 | if (field.embedded !== null && field.maxCardinality !== 1) { 82 | return ( 83 | 84 | JSON.stringify(record)} 86 | linkType={false} 87 | // Workaround for forcing the list to display underlying data. 88 | total={2} 89 | /> 90 | 91 | ); 92 | } 93 | 94 | if (field.enum) { 95 | return ( 96 | 98 | Object.entries(field.enum ?? {}).find(([, v]) => v === value)?.[0] ?? 99 | value 100 | } 101 | {...(props as EnumFieldProps)} 102 | /> 103 | ); 104 | } 105 | 106 | const fieldType = schemaAnalyzer.getFieldType(field); 107 | 108 | switch (fieldType) { 109 | case 'email': 110 | return ; 111 | 112 | case 'url': 113 | return ; 114 | 115 | case 'integer': 116 | case 'integer_id': 117 | case 'float': 118 | return ; 119 | 120 | case 'boolean': 121 | return ; 122 | 123 | case 'date': 124 | case 'dateTime': 125 | return ; 126 | 127 | default: 128 | return ; 129 | } 130 | }; 131 | 132 | export const IntrospectedFieldGuesser = ({ 133 | fields, 134 | readableFields, 135 | writableFields, 136 | schema, 137 | schemaAnalyzer, 138 | ...props 139 | }: IntrospectedFieldGuesserProps) => { 140 | if (!props.source) { 141 | // eslint-disable-next-line no-console 142 | console.error('FieldGuesser: missing source property.'); 143 | return null; 144 | } 145 | const field = fields.find((f) => f.name === props.source); 146 | 147 | if (!field) { 148 | // eslint-disable-next-line no-console 149 | console.error( 150 | `Field "${props.source}" not present inside API description for the resource "${props.resource}"`, 151 | ); 152 | 153 | return null; 154 | } 155 | 156 | return renderField(field, schemaAnalyzer, { 157 | sortable: isFieldSortable(field, schema), 158 | ...props, 159 | source: props.source, 160 | }); 161 | }; 162 | 163 | const FieldGuesser = (props: FieldGuesserProps) => { 164 | const resource = useResourceContext(props); 165 | if (!resource) { 166 | throw new Error('FieldGuesser must be used with a resource'); 167 | } 168 | 169 | return ( 170 | 176 | ); 177 | }; 178 | 179 | export default FieldGuesser; 180 | --------------------------------------------------------------------------------