├── .env ├── .gitignore ├── README.md ├── backend ├── .gitignore ├── pom.xml └── src │ └── main │ ├── java │ └── demo │ │ └── reactAdmin │ │ ├── ReactAdminDemoApplication.java │ │ ├── auth │ │ ├── AccountCredentials.java │ │ ├── IPasswordEncoderProvider.java │ │ ├── JWTAuthenticationFilter.java │ │ ├── JWTLoginFilter.java │ │ ├── MyUserDetailsService.java │ │ ├── MyUserPrincipal.java │ │ ├── PasswordEncoderProvider.java │ │ ├── TokenAuthenticationService.java │ │ ├── WebSecurityConfig.java │ │ └── exceptions │ │ │ └── BadRequestException.java │ │ └── crud │ │ ├── advices │ │ ├── ReactAdminBodyAdvice.java │ │ └── ReactAdminExceptionAdvice.java │ │ ├── controllers │ │ ├── CategoryController.java │ │ ├── CommandController.java │ │ ├── CustomerController.java │ │ ├── FileUploadController.java │ │ ├── ProductController.java │ │ ├── ReviewController.java │ │ └── UserController.java │ │ ├── entities │ │ ├── Category.java │ │ ├── Client.java │ │ ├── Command.java │ │ ├── Customer.java │ │ ├── Example.java │ │ ├── Group.java │ │ ├── PlatformUser.java │ │ ├── Product.java │ │ ├── QuantifiedProduct.java │ │ ├── Review.java │ │ ├── UploadFile.java │ │ └── UserRole.java │ │ ├── enums │ │ └── Role.java │ │ ├── repos │ │ ├── CategoryRepository.java │ │ ├── ClientRepository.java │ │ ├── CommandRepository.java │ │ ├── CustomerRepository.java │ │ ├── ExampleRepository.java │ │ ├── FileRepository.java │ │ ├── GroupRepository.java │ │ ├── ProductRepository.java │ │ ├── ReviewRepository.java │ │ └── UserRepository.java │ │ ├── services │ │ └── DataInitService.java │ │ └── utils │ │ ├── ApiHandler.java │ │ └── ReflectionUtils.java │ ├── resources │ └── application.properties │ └── webapp │ └── WEB-INF │ └── uploaded │ └── data.json ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── App.css ├── App.tsx ├── authProvider.ts ├── categories │ ├── CategoryEdit.tsx │ ├── CategoryList.tsx │ ├── LinkToRelatedProducts.tsx │ └── index.ts ├── configuration │ ├── Configuration.tsx │ └── actions.ts ├── dashboard │ ├── CardIcon.js │ ├── Dashboard.js │ ├── MonthlyRevenue.js │ ├── NbNewOrders.js │ ├── NewCustomers.js │ ├── PendingOrders.js │ ├── PendingReviews.js │ ├── Welcome.js │ └── index.js ├── dataProvider │ ├── graphql.js │ ├── index.js │ └── rest.js ├── fakeServer │ ├── graphql.js │ ├── index.js │ └── rest.js ├── i18n │ ├── en.js │ └── fr.js ├── index.css ├── index.tsx ├── invoices │ ├── InvoiceList.tsx │ ├── InvoiceShow.tsx │ └── index.ts ├── layout │ ├── AppBar.tsx │ ├── Layout.tsx │ ├── Login.tsx │ ├── Logo.tsx │ ├── Menu.tsx │ ├── SubMenu.tsx │ ├── index.ts │ └── themes.ts ├── orders │ ├── Basket.tsx │ ├── MobileGrid.js │ ├── NbItemsField.js │ ├── OrderEdit.js │ ├── OrderList.js │ └── index.js ├── products │ ├── GridList.js │ ├── Poster.js │ ├── ProductCreate.js │ ├── ProductEdit.js │ ├── ProductList.js │ ├── ProductRefField.tsx │ ├── ProductReferenceField.js │ ├── ThumbnailField.tsx │ └── index.js ├── react-admin.d.ts ├── react-app-env.d.ts ├── reviews │ ├── AcceptButton.js │ ├── BulkAcceptButton.js │ ├── BulkRejectButton.js │ ├── RejectButton.js │ ├── ReviewEdit.js │ ├── ReviewEditToolbar.js │ ├── ReviewFilter.js │ ├── ReviewList.js │ ├── ReviewListDesktop.js │ ├── ReviewListMobile.js │ ├── StarRatingField.tsx │ ├── index.js │ └── rowStyle.js ├── routes.js ├── segments │ ├── LinkToRelatedCustomers.js │ ├── Segments.js │ └── data.js ├── themeReducer.js ├── types.ts └── visitors │ ├── AddressField.tsx │ ├── Aside.js │ ├── AvatarField.tsx │ ├── ColoredNumberField.tsx │ ├── CustomerLinkField.tsx │ ├── CustomerReferenceField.tsx │ ├── FullNameField.tsx │ ├── MobileGrid.tsx │ ├── SegmentInput.tsx │ ├── SegmentsField.tsx │ ├── SegmentsInput.tsx │ ├── VisitorCreate.tsx │ ├── VisitorEdit.tsx │ ├── VisitorList.tsx │ ├── index.ts │ └── segments.ts ├── tsconfig.json └── yarn.lock /.env: -------------------------------------------------------------------------------- 1 | SKIP_PREFLIGHT_CHECK=true 2 | NODE_ENV=development 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Admin Demo + Java Spring Boot/MySQL REST Backend 2 | 3 | This is a demo of the [react-admin](https://github.com/marmelab/react-admin) library for React.js. It creates a working administration for a fake poster shop named Posters Galore. You can test it online at http://marmelab.com/react-admin-demo. 4 | 5 | [![react-admin-demo](https://marmelab.com/react-admin/img/react-admin-demo-still.png)](https://vimeo.com/268958716) 6 | 7 | React-admin usually requires a REST/GraphQL server to provide data. In this bundle an exmaple Java spring boot REST api implementation is provided in the /backend folder 8 | 9 | To explore the source code, start with [src/App.js](https://github.com/marmelab/react-admin/blob/master/examples/demo/src/App.js). 10 | 11 | **Note**: This project was bootstrapped with [Create React App](https://github.com/facebookincubator/create-react-app). 12 | 13 | ## How to run (Frontend) 14 | 15 | After having cloned the repository, run the following commands at the repository root: 16 | 17 | ```sh 18 | make install 19 | 20 | make build 21 | 22 | make run-demo 23 | ``` 24 | 25 | ## Available Scripts 26 | 27 | In the project directory, you can run: 28 | 29 | ### `npm start` 30 | 31 | Runs the app in the development mode.
32 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 33 | 34 | The page will reload if you make edits.
35 | You will also see any lint errors in the console. 36 | 37 | ### `npm test` 38 | 39 | Launches the test runner in the interactive watch mode.
40 | See the section about [running tests](#running-tests) for more information. 41 | 42 | ### `npm run build` 43 | 44 | Builds the app for production to the `build` folder.
45 | It correctly bundles React in production mode and optimizes the build for the best performance. 46 | 47 | The build is minified and the filenames include the hashes.
48 | Your app is ready to be deployed! 49 | 50 | ### `npm run deploy` 51 | 52 | Deploy the build to GitHub gh-pages. 53 | 54 | ## Back-End 55 | 56 | This backend implementation is the result of considerable effort to use admin-on-rest as front-end but migrate away from Headless Drupal backend (PHP) to Java Spring backend/MySQL. The main reasons including: lack of versioning for backend changes (we had to take data dumps and keep a txt with Drupal changes), time consuming configuration of Views involving many entities and fields, in need of many (some times non-existing) Plugins to do common things, not native REST implementation, queries involving a ton of tables due to Drupal field reusability among different nodes, difficulty combining drupal tables with flat tables for big-data and analytics, etc.. 57 | 58 | ## Configuration 59 | 60 | You need a database called demo. The credentials are being configured in application.properties. Open the project using existing resources and select Maven from IntelliJ Idea menu. Run ReactAdminDemoApplication.java that will start a Java Spring Boot Application on http://localhost:8080 61 | 62 | ### Features 63 | 64 | - Automatic Generation of database tables according to the Java classes annotated with `@Entity` 65 | - Automatic filling of data from https://raw.githubusercontent.com/zifnab87/react-admin-demo-java-rest/master/backend/src/main/webapp/WEB-INF/uploaded/data.json 66 | - Rest API based on admin-on-rest conventions (e.g resource names and calling signatures: https://marmelab.com/admin-on-rest/RestClients.html) 67 | - Built-in User Authentication (followed this implementation: https://auth0.com/blog/securing-spring-boot-with-jwts/) 68 | - Easily expandable by adding a new `@Entity` class, extending `BaseRepository`, extending `BaseController` both provided by https://github.com/zifnab87/react-admin-java-rest 69 | - Paging and Sorting behind the scenes support using `PagingAndSortingRepository` provided by Java Spring-Data 70 | - Text Search among all text fields using q parameter 71 | - Exact Match filtering among the rest of the fields of a resource 72 | - All query building is happening behind the scenes using Specifications and Criteria API provided by Java Spring-Data 73 | - Ability to support snake_case or camelCase variables by setting (`react-admin-api.use-snake-case = true` (default = false)) in application.properties 74 | - Automatic wrapping of responses in "content" using `@ControllerAdvice` provided by https://github.com/zifnab87/react-admin-java-rest/blob/master/src/main/java/reactAdmin/rest/controllerAdvices/WrapperAdvice.java 75 | - Automatic calculation total number of results returned and addition of that number in `X-Total-Count` response header provided as `@ControllerAdvice` by https://github.com/zifnab87/react-admin-java-rest/blob/master/src/main/java/reactAdmin/rest/controllerAdvices/ResourceSizeAdvice.java 76 | - Automatic deserialization of entities by their ids only during POST/PUT, using `@JsonCreator` annotations over constructors of Entities - see here for explanation: https://stackoverflow.com/questions/46603075/single-custom-deserializer-for-all-objects-as-their-ids-or-embedded-whole-object 77 | 78 | 79 | ### Future work 80 | 81 | - Make the project runnable through Maven - currently it is a IntelliJ Idea Maven project 82 | - ~~Be able to combine results from Text Search and Exact Match filtering~~ **DONE** 83 | - Indexes that might be missing currently 84 | - Swagger-UI needs to be excluded properly from authentication 85 | 86 | 87 | 88 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | ################# 2 | ## Eclipse 3 | ################# 4 | 5 | *.pydevproject 6 | .project 7 | .metadata 8 | bin/ 9 | tmp/ 10 | *.tmp 11 | *.bak 12 | *.swp 13 | *~.nib 14 | local.properties 15 | .classpath 16 | .settings/ 17 | .loadpath 18 | .idea/ 19 | 20 | # External tool builders 21 | .externalToolBuilders/ 22 | 23 | # Locally stored "Eclipse launch configurations" 24 | *.launch 25 | 26 | # CDT-specific 27 | .cproject 28 | 29 | # PDT-specific 30 | .buildpath 31 | 32 | 33 | ################# 34 | ## Visual Studio 35 | ################# 36 | 37 | ## Ignore Visual Studio temporary files, build results, and 38 | ## files generated by popular Visual Studio add-ons. 39 | 40 | # User-specific files 41 | *.suo 42 | *.user 43 | *.sln.docstates 44 | 45 | # Build results 46 | [Dd]ebug/ 47 | [Rr]elease/ 48 | *_i.c 49 | *_p.c 50 | *.ilk 51 | *.meta 52 | *.obj 53 | *.pch 54 | *.pdb 55 | *.pgc 56 | *.pgd 57 | *.rsp 58 | *.sbr 59 | *.tlb 60 | *.tli 61 | *.tlh 62 | *.tmp 63 | *.vspscc 64 | .builds 65 | *.dotCover 66 | 67 | ## TODO: If you have NuGet Package Restore enabled, uncomment this 68 | #packages/ 69 | 70 | # Visual C++ cache files 71 | ipch/ 72 | *.aps 73 | *.ncb 74 | *.opensdf 75 | *.sdf 76 | 77 | # Visual Studio profiler 78 | *.psess 79 | *.vsp 80 | 81 | # ReSharper is a .NET coding add-in 82 | _ReSharper* 83 | 84 | # Installshield output folder 85 | [Ee]xpress 86 | 87 | # DocProject is a documentation generator add-in 88 | DocProject/buildhelp/ 89 | DocProject/Help/*.HxT 90 | DocProject/Help/*.HxC 91 | DocProject/Help/*.hhc 92 | DocProject/Help/*.hhk 93 | DocProject/Help/*.hhp 94 | DocProject/Help/Html2 95 | DocProject/Help/html 96 | 97 | # Click-Once directory 98 | publish 99 | 100 | # Others 101 | [Bb]in 102 | [Oo]bj 103 | sql 104 | TestResults 105 | *.Cache 106 | ClientBin 107 | stylecop.* 108 | ~$* 109 | *.dbmdl 110 | Generated_Code #added for RIA/Silverlight projects 111 | 112 | # Backup & report files from converting an old project file to a newer 113 | # Visual Studio version. Backup files are not needed, because we have git ;-) 114 | _UpgradeReport_Files/ 115 | Backup*/ 116 | UpgradeLog*.XML 117 | 118 | 119 | 120 | ############ 121 | ## Windows 122 | ############ 123 | 124 | # Windows image file caches 125 | Thumbs.db 126 | 127 | # Folder config file 128 | Desktop.ini 129 | 130 | 131 | ############# 132 | ## Python 133 | ############# 134 | 135 | *.py[co] 136 | 137 | # Packages 138 | *.egg 139 | *.egg-info 140 | dist 141 | build 142 | eggs 143 | parts 144 | bin 145 | var 146 | sdist 147 | develop-eggs 148 | .installed.cfg 149 | 150 | # Installer logs 151 | pip-log.txt 152 | 153 | # Unit test / coverage reports 154 | .coverage 155 | .tox 156 | 157 | #Translations 158 | *.mo 159 | 160 | #Mr Developer 161 | .mr.developer.cfg 162 | 163 | # Mac crap 164 | .DS_Store 165 | 166 | .recommenders 167 | 168 | 169 | ############# 170 | ## Java 171 | ############# 172 | 173 | *.class 174 | 175 | 176 | ######################### 177 | ## AOR DEMO SPECIFICS 178 | ######################### 179 | 180 | /target/** 181 | *.iml 182 | .sonar/* 183 | /dumps/** 184 | /data/** 185 | -------------------------------------------------------------------------------- /backend/pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | react-admin-demo-java-rest 8 | react-admin-demo-java-rest 9 | 1.0-SNAPSHOT 10 | 11 | org.springframework.boot 12 | spring-boot-starter-parent 13 | 2.2.1.RELEASE 14 | 15 | 16 | 17 | 18 | jitpack.io 19 | https://jitpack.io 20 | 21 | 22 | 23 | 24 | 25 | org.springframework.boot 26 | spring-boot-maven-plugin 27 | 28 | 29 | 30 | repackage 31 | 32 | 33 | 34 | 35 | 36 | org.apache.maven.plugins 37 | maven-compiler-plugin 38 | 3.8.0 39 | 40 | 11 41 | 11 42 | 11 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | org.springframework.boot 51 | spring-boot-starter-data-jpa 52 | 53 | 54 | org.springframework.boot 55 | spring-boot-starter-web 56 | 57 | 58 | org.springframework.boot 59 | spring-boot-starter-security 60 | 61 | 62 | org.springframework.boot 63 | spring-boot-devtools 64 | runtime 65 | 66 | 67 | org.springframework.boot 68 | spring-boot-starter-test 69 | 70 | 71 | com.vaadin.external.google 72 | android-json 73 | 74 | 75 | test 76 | 77 | 78 | org.apache.commons 79 | commons-lang3 80 | 3.0 81 | 82 | 83 | io.springfox 84 | springfox-swagger2 85 | 2.7.0 86 | 87 | 88 | 89 | io.springfox 90 | springfox-swagger-ui 91 | 2.7.0 92 | 93 | 94 | org.slf4j 95 | slf4j-log4j12 96 | 1.5.2 97 | 98 | 99 | io.github.benas 100 | random-beans 101 | 3.7.0 102 | 103 | 104 | io.jsonwebtoken 105 | jjwt 106 | 0.7.0 107 | 108 | 109 | joda-time 110 | joda-time 111 | 2.9.7 112 | 113 | 114 | mysql 115 | mysql-connector-java 116 | 8.0.7-dmr 117 | 118 | 119 | org.apache.httpcomponents 120 | httpclient 121 | 4.3.6 122 | 123 | 124 | org.json 125 | json 126 | 20160810 127 | 128 | 129 | com.github.zifnab87 130 | spring-boot-rest-api-helpers 131 | e2c6ac0 132 | 133 | 134 | 135 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/ReactAdminDemoApplication.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin; 2 | 3 | import demo.reactAdmin.crud.services.DataInitService; 4 | import com.fasterxml.jackson.databind.*; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.boot.SpringApplication; 7 | import org.springframework.boot.autoconfigure.SpringBootApplication; 8 | import org.springframework.boot.context.event.ApplicationReadyEvent; 9 | import org.springframework.context.annotation.Bean; 10 | import org.springframework.context.event.EventListener; 11 | import org.springframework.web.cors.CorsConfiguration; 12 | import org.springframework.web.cors.UrlBasedCorsConfigurationSource; 13 | import org.springframework.web.filter.CorsFilter; 14 | import org.springframework.web.servlet.ViewResolver; 15 | import org.springframework.web.servlet.view.InternalResourceViewResolver; 16 | import springboot.rest.providers.ObjectMapperProvider; 17 | import springfox.documentation.builders.PathSelectors; 18 | import springfox.documentation.builders.RequestHandlerSelectors; 19 | import springfox.documentation.spi.DocumentationType; 20 | import springfox.documentation.spring.web.paths.RelativePathProvider; 21 | import springfox.documentation.spring.web.plugins.Docket; 22 | import springfox.documentation.swagger2.annotations.EnableSwagger2; 23 | 24 | import javax.servlet.ServletContext; 25 | 26 | @SpringBootApplication(scanBasePackages = {"demo.reactAdmin", "springboot.rest"}) 27 | @EnableSwagger2 28 | public class ReactAdminDemoApplication { 29 | 30 | 31 | @Autowired 32 | private DataInitService dataInitService; 33 | 34 | @Autowired 35 | private ServletContext servletContext; 36 | 37 | @Autowired 38 | private ObjectMapperProvider objMapperProvider; 39 | 40 | 41 | @Bean 42 | public CorsFilter corsFilter() { 43 | UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); 44 | CorsConfiguration config = new CorsConfiguration(); 45 | config.setAllowCredentials(true); 46 | config.addAllowedOrigin("http://localhost:3000"); 47 | config.addAllowedHeader("*"); 48 | config.addExposedHeader("X-Total-Count"); 49 | config.addExposedHeader("Content-Range"); 50 | config.addExposedHeader("Content-Type"); 51 | config.addExposedHeader("Accept"); 52 | config.addExposedHeader("X-Requested-With"); 53 | config.addExposedHeader("remember-me"); 54 | config.addAllowedMethod("*"); 55 | source.registerCorsConfiguration("/**", config); 56 | return new CorsFilter(source); 57 | } 58 | 59 | public static void main(String[] args) { 60 | SpringApplication.run(ReactAdminDemoApplication.class, args); 61 | } 62 | 63 | @EventListener(ApplicationReadyEvent.class) 64 | public void afterReady() { 65 | dataInitService.init(); 66 | } 67 | 68 | @Bean 69 | public ViewResolver internalResourceViewResolver() { 70 | InternalResourceViewResolver bean = new InternalResourceViewResolver(); 71 | //bean.setViewClass(JstlView.class); 72 | bean.setPrefix("/uploaded/"); 73 | //bean.setSuffix(".jsp"); 74 | return bean; 75 | } 76 | 77 | @Bean 78 | public ObjectMapper objectMapper() { 79 | return objMapperProvider.getObjectMapper(); 80 | } 81 | 82 | @Bean 83 | public Docket api() { 84 | return new Docket(DocumentationType.SWAGGER_2) 85 | .pathProvider(new RelativePathProvider(servletContext) { 86 | @Override 87 | public String getApplicationBasePath() { 88 | return "/api/v1"; 89 | } 90 | }) 91 | .select() 92 | .apis(RequestHandlerSelectors.any()) 93 | .paths(PathSelectors.any()) 94 | 95 | .build(); 96 | } 97 | 98 | } 99 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/auth/AccountCredentials.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.auth; 2 | 3 | 4 | public class AccountCredentials { 5 | 6 | private String username; 7 | private String password; 8 | 9 | public String getUsername() { 10 | return username; 11 | } 12 | 13 | public void setUsername(String username) { 14 | this.username = username; 15 | } 16 | 17 | public String getPassword() { 18 | return password; 19 | } 20 | 21 | public void setPassword(String password) { 22 | this.password = password; 23 | } 24 | // getters & setters 25 | } -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/auth/IPasswordEncoderProvider.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.auth; 2 | 3 | import org.springframework.security.crypto.password.PasswordEncoder; 4 | 5 | public interface IPasswordEncoderProvider { 6 | PasswordEncoder getEncoder(); 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/auth/JWTAuthenticationFilter.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.auth; 2 | 3 | 4 | import org.springframework.security.core.context.SecurityContextHolder; 5 | import org.springframework.web.filter.GenericFilterBean; 6 | import org.springframework.security.core.Authentication; 7 | import javax.servlet.FilterChain; 8 | import javax.servlet.ServletException; 9 | import javax.servlet.ServletRequest; 10 | import javax.servlet.ServletResponse; 11 | import javax.servlet.http.HttpServletRequest; 12 | import java.io.IOException; 13 | 14 | public class JWTAuthenticationFilter extends GenericFilterBean { 15 | 16 | @Override 17 | public void doFilter(ServletRequest request, 18 | ServletResponse response, 19 | FilterChain filterChain) 20 | throws IOException, ServletException { 21 | Authentication authentication = TokenAuthenticationService 22 | .getAuthentication((HttpServletRequest)request); 23 | 24 | SecurityContextHolder.getContext() 25 | .setAuthentication(authentication); 26 | filterChain.doFilter(request,response); 27 | } 28 | } -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/auth/JWTLoginFilter.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.auth; 2 | import com.fasterxml.jackson.databind.ObjectMapper; 3 | import org.springframework.security.authentication.AuthenticationManager; 4 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 5 | import org.springframework.security.core.Authentication; 6 | import org.springframework.security.core.AuthenticationException; 7 | import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter; 8 | import org.springframework.security.web.util.matcher.AntPathRequestMatcher; 9 | 10 | import javax.servlet.FilterChain; 11 | import javax.servlet.ServletException; 12 | import javax.servlet.http.HttpServletRequest; 13 | import javax.servlet.http.HttpServletResponse; 14 | import java.io.IOException; 15 | import java.util.Collections; 16 | 17 | public class JWTLoginFilter extends AbstractAuthenticationProcessingFilter { 18 | 19 | public JWTLoginFilter(String url, AuthenticationManager authManager) { 20 | super(new AntPathRequestMatcher(url)); 21 | setAuthenticationManager(authManager); 22 | } 23 | 24 | @Override 25 | public Authentication attemptAuthentication( 26 | HttpServletRequest req, HttpServletResponse res) 27 | throws AuthenticationException, IOException, ServletException { 28 | AccountCredentials creds = new ObjectMapper() 29 | .readValue(req.getInputStream(), AccountCredentials.class); 30 | String username = creds.getUsername(); 31 | String password = creds.getPassword(); 32 | 33 | 34 | 35 | 36 | return getAuthenticationManager().authenticate( 37 | new UsernamePasswordAuthenticationToken( 38 | username, 39 | password, 40 | Collections.emptyList() 41 | ) 42 | ); 43 | } 44 | 45 | @Override 46 | protected void successfulAuthentication( 47 | HttpServletRequest req, 48 | HttpServletResponse res, FilterChain chain, 49 | Authentication auth) throws IOException, ServletException { 50 | TokenAuthenticationService 51 | .addAuthentication(res, auth.getName()); 52 | } 53 | } -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/auth/MyUserDetailsService.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.auth; 2 | 3 | import demo.reactAdmin.crud.entities.PlatformUser; 4 | import demo.reactAdmin.crud.repos.UserRepository; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.security.core.userdetails.UserDetails; 7 | import org.springframework.security.core.userdetails.UserDetailsService; 8 | import org.springframework.security.core.userdetails.UsernameNotFoundException; 9 | import org.springframework.stereotype.Service; 10 | 11 | @Service 12 | public class MyUserDetailsService implements UserDetailsService { 13 | 14 | @Autowired 15 | private UserRepository userRepository; 16 | 17 | @Override 18 | public UserDetails loadUserByUsername(String username) { 19 | PlatformUser user = userRepository.findOneByUsername(username); 20 | if (user == null) { 21 | throw new UsernameNotFoundException(username); 22 | } 23 | return new MyUserPrincipal(user); 24 | } 25 | } -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/auth/MyUserPrincipal.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.auth; 2 | 3 | import demo.reactAdmin.crud.entities.PlatformUser; 4 | import org.springframework.security.core.GrantedAuthority; 5 | import org.springframework.security.core.userdetails.UserDetails; 6 | 7 | import java.util.Collection; 8 | 9 | public class MyUserPrincipal implements UserDetails { 10 | private PlatformUser user; 11 | 12 | public MyUserPrincipal(PlatformUser user) { 13 | this.user = user; 14 | } 15 | 16 | @Override 17 | public Collection getAuthorities() { 18 | return user.getAuthorities(); 19 | } 20 | 21 | @Override 22 | public String getPassword() { 23 | return user.password; 24 | } 25 | 26 | @Override 27 | public String getUsername() { 28 | return user.username; 29 | } 30 | 31 | @Override 32 | public boolean isAccountNonExpired() { 33 | return true; 34 | } 35 | 36 | @Override 37 | public boolean isAccountNonLocked() { 38 | return true; 39 | } 40 | 41 | @Override 42 | public boolean isCredentialsNonExpired() { 43 | return true; 44 | } 45 | 46 | @Override 47 | public boolean isEnabled() { 48 | return true; 49 | } 50 | } -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/auth/PasswordEncoderProvider.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.auth; 2 | 3 | import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 4 | import org.springframework.security.crypto.password.PasswordEncoder; 5 | import org.springframework.stereotype.Service; 6 | 7 | @Service 8 | public class PasswordEncoderProvider implements IPasswordEncoderProvider { 9 | 10 | public PasswordEncoder getEncoder() { 11 | return new BCryptPasswordEncoder(11); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/auth/TokenAuthenticationService.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.auth; 2 | 3 | import io.jsonwebtoken.Jwts; 4 | import io.jsonwebtoken.SignatureAlgorithm; 5 | import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; 6 | import org.springframework.security.core.Authentication; 7 | 8 | import javax.servlet.http.HttpServletRequest; 9 | import javax.servlet.http.HttpServletResponse; 10 | import java.io.IOException; 11 | import java.util.Date; 12 | 13 | import static java.util.Collections.emptyList; 14 | 15 | class TokenAuthenticationService { 16 | static final long EXPIRATIONTIME = 864_000_000; // 10 days 17 | static final String SECRET = "ThisIsASecret"; 18 | static final String TOKEN_PREFIX = "Bearer"; 19 | static final String HEADER_STRING = "X-Authorization"; 20 | 21 | static void addAuthentication(HttpServletResponse res, String username) { 22 | String JWT = Jwts.builder() 23 | .setSubject(username) 24 | .setExpiration(new Date(System.currentTimeMillis() + EXPIRATIONTIME)) 25 | .signWith(SignatureAlgorithm.HS512, SECRET) 26 | .compact(); 27 | res.addHeader(HEADER_STRING, TOKEN_PREFIX + " " + JWT); 28 | try { 29 | res.getOutputStream().write(("{\"token\": \""+ JWT +"\"}").getBytes()); 30 | } catch (IOException e) { 31 | e.printStackTrace(); 32 | } 33 | } 34 | 35 | static Authentication getAuthentication(HttpServletRequest request) { 36 | String token = request.getHeader(HEADER_STRING); 37 | if (token != null) { 38 | // parse the token. 39 | String user = Jwts.parser() 40 | .setSigningKey(SECRET) 41 | .parseClaimsJws(token.replace(TOKEN_PREFIX, "")) 42 | .getBody() 43 | .getSubject(); 44 | 45 | return user != null ? 46 | new UsernamePasswordAuthenticationToken(user, null, emptyList()) : 47 | null; 48 | } 49 | return null; 50 | } 51 | } -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/auth/WebSecurityConfig.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.auth; 2 | 3 | import org.springframework.beans.factory.annotation.Autowired; 4 | import org.springframework.context.annotation.Bean; 5 | import org.springframework.context.annotation.Configuration; 6 | import org.springframework.http.HttpMethod; 7 | import org.springframework.security.authentication.dao.DaoAuthenticationProvider; 8 | import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; 9 | import org.springframework.security.config.annotation.web.builders.HttpSecurity; 10 | import org.springframework.security.config.annotation.web.builders.WebSecurity; 11 | import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; 12 | import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; 13 | import org.springframework.security.crypto.password.PasswordEncoder; 14 | import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 15 | 16 | @Configuration 17 | @EnableWebSecurity 18 | public class WebSecurityConfig extends WebSecurityConfigurerAdapter { 19 | 20 | private static final String LOGIN_ENDPOINT = "/api/v1/auth/login"; 21 | 22 | private static final String FILE_ENDPOINT = "/api/v1/file/**"; 23 | 24 | private static final String SWAGGER_UI_PATH = "/api/v1/swagger-ui.html"; 25 | 26 | @Autowired 27 | private MyUserDetailsService userDetailsService; 28 | 29 | @Autowired 30 | private PasswordEncoderProvider passwordEncoderProvider; 31 | 32 | 33 | @Override 34 | public void configure(WebSecurity web) throws Exception { 35 | web.ignoring().antMatchers(FILE_ENDPOINT, SWAGGER_UI_PATH); 36 | } 37 | 38 | @Override 39 | protected void configure(HttpSecurity http) throws Exception { 40 | http .cors().and() 41 | .csrf().disable().authorizeRequests() 42 | .antMatchers("/").permitAll() 43 | .antMatchers(HttpMethod.POST, LOGIN_ENDPOINT).permitAll() 44 | .antMatchers(HttpMethod.GET, FILE_ENDPOINT).permitAll() 45 | .anyRequest().authenticated() 46 | .and() 47 | // We filter the api/login requests 48 | .addFilterBefore(new JWTLoginFilter(LOGIN_ENDPOINT, authenticationManager()), 49 | UsernamePasswordAuthenticationFilter.class) 50 | // And filter other requests to check the presence of JWT in header 51 | .addFilterBefore(new JWTAuthenticationFilter(), 52 | UsernamePasswordAuthenticationFilter.class); 53 | } 54 | 55 | @Override 56 | protected void configure(AuthenticationManagerBuilder auth) throws Exception { 57 | 58 | auth.authenticationProvider(authenticationProvider()); 59 | } 60 | 61 | 62 | @Bean 63 | public DaoAuthenticationProvider authenticationProvider() { 64 | DaoAuthenticationProvider authProvider 65 | = new DaoAuthenticationProvider(); 66 | authProvider.setUserDetailsService(userDetailsService); 67 | authProvider.setPasswordEncoder(encoder()); 68 | return authProvider; 69 | } 70 | 71 | @Bean 72 | public PasswordEncoder encoder() { 73 | return passwordEncoderProvider.getEncoder(); 74 | } 75 | } -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/auth/exceptions/BadRequestException.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.auth.exceptions; 2 | 3 | public class BadRequestException extends Throwable { 4 | } 5 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/advices/ReactAdminBodyAdvice.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.advices; 2 | 3 | import org.springframework.web.bind.annotation.ControllerAdvice; 4 | import springboot.rest.controllerAdvices.BodyAdvice; 5 | 6 | @ControllerAdvice 7 | public class ReactAdminBodyAdvice extends BodyAdvice { 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/advices/ReactAdminExceptionAdvice.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.advices; 2 | 3 | import org.springframework.web.bind.annotation.ControllerAdvice; 4 | import springboot.rest.controllerAdvices.GlobalExceptionAdvice; 5 | 6 | @ControllerAdvice 7 | public class ReactAdminExceptionAdvice extends GlobalExceptionAdvice { 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/controllers/CategoryController.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.controllers; 2 | 3 | 4 | import demo.reactAdmin.crud.entities.Category; 5 | import demo.reactAdmin.crud.repos.CategoryRepository; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.web.bind.annotation.*; 8 | import springboot.rest.entities.QueryParamWrapper; 9 | import springboot.rest.services.FilterService; 10 | import springboot.rest.utils.QueryParamExtractor; 11 | 12 | @RestController 13 | @RequestMapping("api/v1") 14 | public class CategoryController { 15 | 16 | @Autowired 17 | private FilterService filterService; 18 | 19 | @Autowired 20 | private CategoryRepository repo; 21 | 22 | @RequestMapping(value = "categories", method = RequestMethod.POST) 23 | public Category create(@RequestBody Category category) { 24 | return repo.save(category); 25 | } 26 | 27 | @RequestMapping(value = "categories/{id}", method = RequestMethod.PUT) 28 | public Category update(@RequestBody Category category, @PathVariable int id) { 29 | category.id = id; 30 | return repo.save(category); 31 | } 32 | 33 | @RequestMapping(value = "categories/{id}/published/{value}", method = RequestMethod.POST) 34 | public Category publishedUpdate(@PathVariable int id, @PathVariable boolean value) { 35 | Category category = repo.findById(id).orElseThrow(); 36 | category.published = value; 37 | return repo.save(category); 38 | } 39 | 40 | 41 | @RequestMapping(value = "categories/{id}", method = RequestMethod.GET) 42 | public Category getById(@PathVariable int id) { 43 | return repo.findById(id).orElseThrow(); 44 | } 45 | 46 | @RequestMapping(value = "categories", method = RequestMethod.GET) 47 | public Iterable filterBy( 48 | @RequestParam(required = false, name = "filter") String filterStr, 49 | @RequestParam(required = false, name = "range") String rangeStr, @RequestParam(required = false, name="sort") String sortStr) { 50 | QueryParamWrapper wrapper = QueryParamExtractor.extract(filterStr, rangeStr, sortStr); 51 | return filterService.filterBy(wrapper, repo); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/controllers/CommandController.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.controllers; 2 | 3 | 4 | import demo.reactAdmin.crud.entities.Command; 5 | import demo.reactAdmin.crud.entities.QuantifiedProduct; 6 | import demo.reactAdmin.crud.repos.CommandRepository; 7 | import demo.reactAdmin.crud.repos.ProductRepository; 8 | import org.springframework.beans.factory.annotation.Autowired; 9 | import org.springframework.web.bind.annotation.*; 10 | import springboot.rest.entities.QueryParamWrapper; 11 | import springboot.rest.services.FilterService; 12 | import springboot.rest.utils.QueryParamExtractor; 13 | 14 | @RestController 15 | @RequestMapping("api/v1") 16 | public class CommandController { 17 | 18 | @Autowired 19 | private FilterService filterService; 20 | 21 | @Autowired 22 | private CommandRepository repo; 23 | 24 | @Autowired 25 | private ProductRepository productRepo; 26 | 27 | @RequestMapping(value = "commands", method = RequestMethod.POST) 28 | public Command create(@RequestBody Command command) { 29 | for (QuantifiedProduct qp: command.basket) { 30 | qp.product = productRepo.findById(qp.productId).orElseThrow(); 31 | } 32 | return repo.save(command); 33 | } 34 | 35 | @RequestMapping(value = "commands/{id}", method = RequestMethod.PUT) 36 | public Command update(@RequestBody Command command, @PathVariable int id) { 37 | command.id = id; 38 | for (QuantifiedProduct qp: command.basket) { 39 | qp.product = productRepo.findById(qp.productId).orElseThrow(); 40 | } 41 | return repo.save(command); 42 | } 43 | 44 | @RequestMapping(value = "commands/{id}/published/{value}", method = RequestMethod.POST) 45 | public Command publishedUpdate(@PathVariable int id, @PathVariable boolean value) { 46 | Command command = repo.findById(id).orElseThrow(); 47 | command.published = value; 48 | return repo.save(command); 49 | } 50 | 51 | @RequestMapping(value = "commands/{id}", method = RequestMethod.GET) 52 | public Command getById(@PathVariable int id) { 53 | return repo.findById(id).orElseThrow(); 54 | } 55 | 56 | @RequestMapping(value = "commands", method = RequestMethod.GET) 57 | public Iterable filterBy( 58 | @RequestParam(required = false, name = "filter") String filterStr, 59 | @RequestParam(required = false, name = "range") String rangeStr, @RequestParam(required = false, name="sort") String sortStr) { 60 | QueryParamWrapper wrapper = QueryParamExtractor.extract(filterStr, rangeStr, sortStr); 61 | return filterService.filterBy(wrapper, repo); 62 | } 63 | } -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/controllers/CustomerController.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.controllers; 2 | 3 | 4 | import demo.reactAdmin.crud.entities.Customer; 5 | import demo.reactAdmin.crud.repos.CustomerRepository; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.web.bind.annotation.*; 8 | import springboot.rest.entities.QueryParamWrapper; 9 | import springboot.rest.services.FilterService; 10 | import springboot.rest.utils.QueryParamExtractor; 11 | 12 | @RestController 13 | @RequestMapping("api/v1") 14 | public class CustomerController { 15 | 16 | @Autowired 17 | private FilterService filterService; 18 | 19 | @Autowired 20 | private CustomerRepository repo; 21 | 22 | @RequestMapping(value = "customers", method = RequestMethod.POST) 23 | public Customer create(@RequestBody Customer customer) { 24 | return repo.save(customer); 25 | } 26 | 27 | @RequestMapping(value = "customers/{id}", method = RequestMethod.PUT) 28 | public Customer update(@RequestBody Customer customer, @PathVariable int id) { 29 | customer.id = id; 30 | return repo.save(customer); 31 | } 32 | 33 | @RequestMapping(value = "customers/{id}/published/{value}", method = RequestMethod.POST) 34 | public Customer publishedUpdate(@PathVariable int id, @PathVariable boolean value) { 35 | Customer customer = repo.findById(id).orElseThrow(); 36 | customer.published = value; 37 | return repo.save(customer); 38 | } 39 | 40 | 41 | @RequestMapping(value = "customers/{id}", method = RequestMethod.GET) 42 | public Customer getById(@PathVariable int id) { 43 | return repo.findById(id).orElseThrow(); 44 | } 45 | 46 | @RequestMapping(value = "customers", method = RequestMethod.GET) 47 | public Iterable filterBy( 48 | @RequestParam(required = false, name = "filter") String filterStr, 49 | @RequestParam(required = false, name = "range") String rangeStr, @RequestParam(required = false, name="sort") String sortStr) { 50 | QueryParamWrapper wrapper = QueryParamExtractor.extract(filterStr, rangeStr, sortStr); 51 | return filterService.filterBy(wrapper, repo); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/controllers/FileUploadController.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.controllers; 2 | 3 | import demo.reactAdmin.crud.entities.UploadFile; 4 | import demo.reactAdmin.crud.repos.FileRepository; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.core.env.Environment; 7 | import org.springframework.core.io.InputStreamResource; 8 | import org.springframework.http.HttpHeaders; 9 | import org.springframework.http.HttpStatus; 10 | import org.springframework.http.MediaType; 11 | import org.springframework.http.ResponseEntity; 12 | import org.springframework.web.bind.annotation.*; 13 | import org.springframework.web.multipart.MultipartFile; 14 | 15 | import javax.servlet.ServletContext; 16 | import java.io.File; 17 | import java.io.FileInputStream; 18 | import java.io.IOException; 19 | 20 | @RestController 21 | @RequestMapping("api/v1") 22 | public class FileUploadController { 23 | @Autowired 24 | ServletContext context; 25 | 26 | @Autowired 27 | private FileRepository repo; 28 | 29 | @Autowired 30 | private Environment env; 31 | 32 | private class FileInfo { 33 | 34 | private String fileName; 35 | private long fileSize; 36 | private int id; 37 | private String property; 38 | 39 | 40 | public String getFileName() { 41 | return fileName; 42 | } 43 | 44 | public void setFileName(String fileName) { 45 | this.fileName = fileName; 46 | } 47 | 48 | public long getFileSize() { 49 | return fileSize; 50 | } 51 | 52 | public void setFileSize(long fileSize) { 53 | this.fileSize = fileSize; 54 | } 55 | 56 | public int getId() { 57 | return id; 58 | } 59 | 60 | public void setId(int id) { 61 | this.id = id; 62 | } 63 | 64 | public String getProperty() { 65 | return property; 66 | } 67 | 68 | public void setProperty(String property) { 69 | this.property = property; 70 | } 71 | } 72 | 73 | @RequestMapping(value = "file/{id}", method = RequestMethod.GET) 74 | public ResponseEntity getFile(@PathVariable int id ) 75 | throws IOException { 76 | File file = new File( context.getRealPath(repo.findById(id).orElseThrow().diskPath)); 77 | 78 | 79 | return ResponseEntity 80 | .ok() 81 | .contentLength(file.length()) 82 | .contentType( 83 | MediaType.parseMediaType("application/octet-stream")) 84 | .body(new InputStreamResource(new FileInputStream(file))); 85 | } 86 | 87 | @RequestMapping(value = "upload", headers=("content-type=multipart/*"), method = RequestMethod.POST) 88 | public ResponseEntity upload(@RequestParam("file") MultipartFile inputFile, @RequestParam("property") String property) { 89 | FileInfo fileInfo = new FileInfo(); 90 | HttpHeaders headers = new HttpHeaders(); 91 | if (!inputFile.isEmpty()) { 92 | try { 93 | String originalFilename = inputFile.getOriginalFilename(); 94 | String pathToStore = "/WEB-INF/uploaded" + File.separator + originalFilename; 95 | File destinationFile = new File(context.getRealPath("/WEB-INF/uploaded")+ File.separator + originalFilename); 96 | inputFile.transferTo(destinationFile); 97 | fileInfo.setFileName(pathToStore); 98 | fileInfo.setFileSize(inputFile.getSize()); 99 | 100 | UploadFile file = new UploadFile(); 101 | file.diskPath = pathToStore; 102 | repo.save(file); 103 | file.path = env.getProperty("react-admin-api")+"/api/v1/file/"+ file.id; 104 | repo.save(file); 105 | 106 | fileInfo.setId(file.id); 107 | fileInfo.setProperty(property); 108 | 109 | headers.add("File Uploaded Successfully - ", originalFilename); 110 | return new ResponseEntity<>(fileInfo, headers, HttpStatus.OK); 111 | } catch (Exception e) { 112 | return new ResponseEntity<>(HttpStatus.BAD_REQUEST); 113 | } 114 | }else{ 115 | return new ResponseEntity<>(HttpStatus.BAD_REQUEST); 116 | } 117 | } 118 | } -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/controllers/ProductController.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.controllers; 2 | 3 | 4 | import demo.reactAdmin.crud.entities.Product; 5 | import demo.reactAdmin.crud.repos.ProductRepository; 6 | import org.springframework.beans.factory.annotation.Autowired; 7 | import org.springframework.web.bind.annotation.*; 8 | import springboot.rest.entities.QueryParamWrapper; 9 | import springboot.rest.services.FilterService; 10 | import springboot.rest.utils.QueryParamExtractor; 11 | 12 | @RestController 13 | @RequestMapping("api/v1") 14 | public class ProductController { 15 | 16 | @Autowired 17 | private FilterService filterService; 18 | 19 | @Autowired 20 | private ProductRepository repo; 21 | 22 | @RequestMapping(value = "products", method = RequestMethod.POST) 23 | public Product create(@RequestBody Product product) { 24 | return repo.save(product); 25 | } 26 | 27 | @RequestMapping(value = "products/{id}", method = RequestMethod.PUT) 28 | public Product update(@RequestBody Product product, @PathVariable int id) { 29 | product.id = id; 30 | return repo.save(product); 31 | } 32 | 33 | @RequestMapping(value = "products/{id}/published/{value}", method = RequestMethod.POST) 34 | public Product publishedUpdate(@PathVariable int id, @PathVariable boolean value) { 35 | Product product = repo.findById(id).orElseThrow(); 36 | product.published = value; 37 | return repo.save(product); 38 | } 39 | 40 | @RequestMapping(value = "products/{id}", method = RequestMethod.GET) 41 | public Product getById(@PathVariable int id) { 42 | return repo.findById(id).orElseThrow(); 43 | } 44 | 45 | @RequestMapping(value = "products", method = RequestMethod.GET) 46 | public Iterable filterBy( 47 | @RequestParam(required = false, name = "filter") String filterStr, 48 | @RequestParam(required = false, name = "range") String rangeStr, @RequestParam(required = false, name="sort") String sortStr) { 49 | QueryParamWrapper wrapper = QueryParamExtractor.extract(filterStr, rangeStr, sortStr); 50 | return filterService.filterBy(wrapper, repo); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/controllers/ReviewController.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.controllers; 2 | 3 | import demo.reactAdmin.crud.entities.Review; 4 | import demo.reactAdmin.crud.repos.ReviewRepository; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.web.bind.annotation.*; 7 | import springboot.rest.entities.QueryParamWrapper; 8 | import springboot.rest.services.FilterService; 9 | import springboot.rest.utils.QueryParamExtractor; 10 | 11 | @RestController 12 | @RequestMapping("api/v1") 13 | public class ReviewController { 14 | 15 | @Autowired 16 | private FilterService filterService; 17 | 18 | @Autowired 19 | private ReviewRepository repo; 20 | 21 | @RequestMapping(value = "reviews", method = RequestMethod.POST) 22 | public Review create(@RequestBody Review review) { 23 | return repo.save(review); 24 | } 25 | 26 | @RequestMapping(value = "reviews/{id}", method = RequestMethod.PUT) 27 | public Review update(@RequestBody Review review, @PathVariable int id) { 28 | review.id = id; 29 | return repo.save(review); 30 | } 31 | 32 | @RequestMapping(value = "reviews/{id}/published/{value}", method = RequestMethod.POST) 33 | public Review publishedUpdate(@PathVariable int id, @PathVariable boolean value) { 34 | Review review = repo.findById(id).orElseThrow(); 35 | review.published = value; 36 | return repo.save(review); 37 | } 38 | 39 | @RequestMapping(value = "reviews/{id}", method = RequestMethod.GET) 40 | public Review getById(@PathVariable int id) { 41 | return repo.findById(id).orElseThrow(); 42 | } 43 | 44 | @RequestMapping(value = "reviews", method = RequestMethod.GET) 45 | public Iterable filterBy( 46 | @RequestParam(required = false, name = "filter") String filterStr, 47 | @RequestParam(required = false, name = "range") String rangeStr, @RequestParam(required = false, name="sort") String sortStr) { 48 | QueryParamWrapper wrapper = QueryParamExtractor.extract(filterStr, rangeStr, sortStr); 49 | return filterService.filterBy(wrapper, repo); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/controllers/UserController.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.controllers; 2 | 3 | import demo.reactAdmin.crud.entities.PlatformUser; 4 | import demo.reactAdmin.crud.repos.UserRepository; 5 | import org.springframework.beans.factory.annotation.Autowired; 6 | import org.springframework.security.core.Authentication; 7 | import org.springframework.security.core.context.SecurityContextHolder; 8 | import org.springframework.web.bind.annotation.*; 9 | import springboot.rest.entities.QueryParamWrapper; 10 | import springboot.rest.services.FilterService; 11 | import springboot.rest.utils.QueryParamExtractor; 12 | 13 | @RestController 14 | @RequestMapping("api/v1") 15 | public class UserController { 16 | 17 | @Autowired 18 | private FilterService filterService; 19 | 20 | @Autowired 21 | private UserRepository repo; 22 | 23 | @RequestMapping(value = "current-user", method = RequestMethod.GET) 24 | public PlatformUser getCurrentUser() { 25 | Authentication auth = SecurityContextHolder.getContext().getAuthentication(); 26 | String username = auth.getName(); //get logged in username 27 | return repo.findOneByUsername(username); 28 | } 29 | 30 | @RequestMapping(value = "users/{id}", method = RequestMethod.GET) 31 | public PlatformUser getById(@PathVariable int id) { 32 | return repo.findById(id).orElseThrow(); 33 | } 34 | 35 | 36 | @RequestMapping(value = "users/{id}/published/{value}", method = RequestMethod.POST) 37 | public void publishedUpdate(@PathVariable int id, @PathVariable boolean value) { 38 | PlatformUser user = repo.findById(id).orElseThrow(); 39 | user.published = value; 40 | repo.save(user); 41 | } 42 | 43 | @RequestMapping(value = "users", method = RequestMethod.GET) 44 | public Iterable filterBy( 45 | @RequestParam(required = false, name = "filter") String filterStr, 46 | @RequestParam(required = false, name = "range") String rangeStr, @RequestParam(required = false, name="sort") String sortStr) { 47 | QueryParamWrapper wrapper = QueryParamExtractor.extract(filterStr, rangeStr, sortStr); 48 | return filterService.filterBy(wrapper, repo); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/entities/Category.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.entities; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import org.hibernate.annotations.Where; 5 | 6 | import javax.persistence.*; 7 | 8 | @Entity 9 | @Where(clause="published=1") 10 | public class Category { 11 | @Id @GeneratedValue(strategy = GenerationType.IDENTITY) 12 | public Integer id; 13 | public String name; 14 | public boolean published = true; 15 | 16 | 17 | public Category() {} 18 | 19 | @JsonCreator 20 | public Category(int id) { 21 | this.id = id; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/entities/Client.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.entities; 2 | 3 | import demo.reactAdmin.crud.enums.Role; 4 | import com.fasterxml.jackson.annotation.JsonCreator; 5 | import org.hibernate.annotations.Where; 6 | import org.springframework.security.core.GrantedAuthority; 7 | 8 | import javax.persistence.Entity; 9 | import javax.persistence.PrimaryKeyJoinColumn; 10 | import java.util.List; 11 | 12 | @PrimaryKeyJoinColumn(name="id") 13 | @Entity 14 | @Where(clause="published=1") 15 | public class Client extends PlatformUser { 16 | public String role = getUserRole().getRole().toString().toLowerCase(); 17 | 18 | public Client() { 19 | super(); 20 | 21 | } 22 | public Client(String username, List roles) { 23 | super(username, roles); 24 | } 25 | @Override 26 | public UserRole getUserRole() { 27 | return new UserRole(id, Role.CLIENT); 28 | } 29 | 30 | @JsonCreator 31 | public Client(int id) { 32 | this.id = id; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/entities/Command.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.entities; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import org.hibernate.annotations.Where; 5 | 6 | import javax.persistence.*; 7 | import java.util.HashSet; 8 | import java.util.Set; 9 | 10 | @Entity 11 | @Where(clause="published=1") 12 | public class Command { 13 | @Id 14 | public Integer id; 15 | 16 | public boolean published = true; 17 | public String reference; 18 | public String date; 19 | 20 | @ManyToOne(cascade = {CascadeType.DETACH}) 21 | public Customer customerId; 22 | 23 | public float totalExTaxes; 24 | public float deliveryFees; 25 | public float taxRate; 26 | public float taxes; 27 | public float total; 28 | public String status; 29 | public boolean returned; 30 | 31 | @OneToMany(cascade = {CascadeType.ALL}) 32 | public Set basket = new HashSet<>(); 33 | 34 | 35 | public Command() {} 36 | 37 | @JsonCreator 38 | public Command(int id) { 39 | this.id = id; 40 | } 41 | 42 | 43 | } -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/entities/Customer.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.entities; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import org.hibernate.annotations.Where; 5 | 6 | import javax.persistence.*; 7 | import java.util.Set; 8 | 9 | @Entity 10 | @Where(clause="published=1") 11 | public class Customer { 12 | @Id @GeneratedValue(strategy = GenerationType.IDENTITY) 13 | public Integer id; 14 | public String firstName; 15 | public String lastName; 16 | public String email; 17 | public String birthday; 18 | public String address; 19 | public String zipcode; 20 | public String city; 21 | public String avatar; 22 | public String firstSeen; 23 | public String lastSeen; 24 | public boolean hasNewsletter; 25 | 26 | @ManyToMany(cascade = {CascadeType.DETACH}) 27 | public Set groups; 28 | 29 | public String latestPurchase; 30 | public int nbCommands; 31 | public double totalSpent; 32 | public boolean published = true; 33 | 34 | public Customer() {} 35 | 36 | @JsonCreator 37 | public Customer(int id) { 38 | this.id = id; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/entities/Example.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.entities; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import org.hibernate.annotations.Where; 5 | 6 | import javax.persistence.*; 7 | import java.util.HashSet; 8 | import java.util.Set; 9 | 10 | @Entity 11 | @Where(clause="published=1") 12 | public class Example { 13 | @Id 14 | @GeneratedValue(strategy = GenerationType.IDENTITY) 15 | public int id; 16 | 17 | public String name; 18 | 19 | public boolean published = true; 20 | 21 | 22 | @OneToMany(cascade = {CascadeType.DETACH}) 23 | public Set fileA = new HashSet<>(); 24 | 25 | @OneToMany(cascade = {CascadeType.DETACH}) 26 | public Set fileB = new HashSet<>(); 27 | 28 | @ManyToOne(cascade = {CascadeType.DETACH}) 29 | public Client client; 30 | 31 | @JsonCreator 32 | public Example(int id) { 33 | this.id = id; 34 | } 35 | 36 | public Example() { 37 | this.id = id; 38 | } 39 | 40 | 41 | } 42 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/entities/Group.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.entities; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | 5 | import javax.persistence.Entity; 6 | import javax.persistence.Id; 7 | import javax.persistence.Table; 8 | 9 | @Entity 10 | @Table(name = "`Group`") 11 | public class Group { 12 | @Id 13 | public String id; 14 | 15 | public Group() {} 16 | 17 | @JsonCreator 18 | public Group(String id) { 19 | this.id = id; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/entities/PlatformUser.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.entities; 2 | 3 | import demo.reactAdmin.crud.enums.Role; 4 | import org.apache.commons.lang3.StringUtils; 5 | import org.hibernate.annotations.Where; 6 | import org.springframework.security.core.GrantedAuthority; 7 | import org.springframework.security.core.authority.SimpleGrantedAuthority; 8 | 9 | import javax.persistence.*; 10 | import javax.security.auth.Subject; 11 | import java.security.Principal; 12 | import java.util.HashSet; 13 | import java.util.List; 14 | import java.util.Set; 15 | @Inheritance(strategy = InheritanceType.JOINED) 16 | @Entity 17 | @Where(clause="published=1") 18 | @Table( uniqueConstraints = { 19 | @UniqueConstraint(columnNames = {"username"}), 20 | }) 21 | public class PlatformUser implements Principal { 22 | @Id 23 | @GeneratedValue(strategy = GenerationType.IDENTITY) 24 | public int id; 25 | public String name; 26 | public String username; 27 | public String location; 28 | public String role = getUserRole().getRole().toString().toLowerCase(); 29 | public String password; 30 | public boolean published = true; 31 | 32 | @Transient 33 | private List authorities; 34 | 35 | public PlatformUser(){} 36 | 37 | public PlatformUser(String username, List authorities) { 38 | this.username = username; 39 | this.authorities = authorities; 40 | } 41 | 42 | @Override 43 | public String getName() { 44 | return username; 45 | } 46 | 47 | @Override 48 | public boolean implies(Subject subject) { 49 | return false; 50 | } 51 | 52 | public static PlatformUser create(String username, List authorities) { 53 | if (StringUtils.isBlank(username)) throw new IllegalArgumentException("Username is blank: " + username); 54 | return new PlatformUser(username, authorities); 55 | } 56 | 57 | public Set getAuthorities() { 58 | UserRole role = this.getUserRole(); 59 | Set authorities = new HashSet<>(); 60 | authorities.add(new SimpleGrantedAuthority(role.getRole().authority())); 61 | return authorities; 62 | } 63 | 64 | public void setAuthorities(List authorities) { 65 | this.authorities = authorities; 66 | } 67 | 68 | public UserRole getUserRole() { 69 | return new UserRole(id, Role.ADMINISTRATOR); 70 | 71 | } 72 | 73 | } 74 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/entities/Product.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.entities; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | import org.hibernate.annotations.Where; 5 | 6 | import javax.persistence.*; 7 | 8 | @Entity 9 | @Where(clause="published=1") 10 | public class Product { 11 | @Id @GeneratedValue(strategy = GenerationType.IDENTITY) 12 | public Integer id; 13 | public String reference; 14 | public float width; 15 | public float height; 16 | public float price; 17 | public String thumbnail; 18 | public String iamge; 19 | @Lob @Basic(fetch = FetchType.EAGER) 20 | @Column(length=1000) 21 | public String description; 22 | public int stock; 23 | public boolean published = true; 24 | 25 | @ManyToOne(cascade = {CascadeType.DETACH}) 26 | public Category categoryId; 27 | 28 | public Product() {} 29 | 30 | @JsonCreator 31 | public Product(int id) { 32 | this.id = id; 33 | } 34 | } -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/entities/QuantifiedProduct.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.entities; 2 | 3 | import org.hibernate.annotations.Formula; 4 | 5 | import javax.persistence.*; 6 | 7 | @Entity(name = "QuantifiedProduct") 8 | public class QuantifiedProduct { 9 | 10 | @Id 11 | @GeneratedValue(strategy = GenerationType.IDENTITY) 12 | public int id; 13 | 14 | @Formula("(SELECT p.id FROM product p INNER JOIN quantified_product qp ON p.id = qp.product_id WHERE qp.id = id)") 15 | public Integer productId; 16 | 17 | @ManyToOne 18 | @JoinColumn(name = "product_id") 19 | public Product product; 20 | 21 | public int quantity; 22 | 23 | public QuantifiedProduct() {} 24 | 25 | public QuantifiedProduct(Product product) { 26 | this.product = product; 27 | } 28 | } -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/entities/Review.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.entities; 2 | 3 | import org.hibernate.annotations.Where; 4 | 5 | import javax.persistence.*; 6 | 7 | @Entity 8 | @Where(clause="published=1") 9 | public class Review { 10 | @Id 11 | @GeneratedValue(strategy = GenerationType.IDENTITY) 12 | public Integer id; 13 | public boolean published = true; 14 | public String date; 15 | public String status; 16 | public int rating; 17 | 18 | @ManyToOne(cascade = {CascadeType.DETACH}) 19 | public Command commandId; 20 | 21 | @ManyToOne(cascade = {CascadeType.DETACH}) 22 | public Product productId; 23 | 24 | @ManyToOne(cascade = {CascadeType.DETACH}) 25 | public Customer customerId; 26 | 27 | @Lob @Basic(fetch = FetchType.EAGER) 28 | @Column(length=1000) 29 | public String comment; 30 | 31 | } 32 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/entities/UploadFile.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.entities; 2 | 3 | import com.fasterxml.jackson.annotation.JsonCreator; 4 | 5 | import javax.persistence.Entity; 6 | import javax.persistence.GeneratedValue; 7 | import javax.persistence.GenerationType; 8 | import javax.persistence.Id; 9 | 10 | @Entity 11 | public class UploadFile { 12 | @Id 13 | @GeneratedValue(strategy = GenerationType.IDENTITY) 14 | public Integer id; 15 | public String diskPath; 16 | public String path; 17 | 18 | public UploadFile() { 19 | 20 | } 21 | 22 | @JsonCreator 23 | public UploadFile(int id) { 24 | this.id = id; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/entities/UserRole.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.entities; 2 | 3 | import demo.reactAdmin.crud.enums.Role; 4 | 5 | public class UserRole { 6 | 7 | 8 | 9 | protected int id; 10 | 11 | public UserRole() { 12 | } 13 | 14 | public UserRole(int id, Role role) { 15 | this.id = id; 16 | this.role = role; 17 | } 18 | 19 | protected Role role; 20 | 21 | public Role getRole() { 22 | return role; 23 | } 24 | 25 | } -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/enums/Role.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.enums; 2 | 3 | public enum Role { 4 | ADMINISTRATOR, CLIENT; 5 | 6 | public String authority() { 7 | return this.name().toLowerCase(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/repos/CategoryRepository.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.repos; 2 | 3 | import demo.reactAdmin.crud.entities.Category; 4 | import springboot.rest.repositories.BaseRepository; 5 | 6 | 7 | public interface CategoryRepository extends BaseRepository { 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/repos/ClientRepository.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.repos; 2 | 3 | import demo.reactAdmin.crud.entities.Client; 4 | import springboot.rest.repositories.BaseRepository; 5 | 6 | 7 | public interface ClientRepository extends BaseRepository { 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/repos/CommandRepository.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.repos; 2 | 3 | import demo.reactAdmin.crud.entities.Command; 4 | import springboot.rest.repositories.BaseRepository; 5 | 6 | public interface CommandRepository extends BaseRepository { 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/repos/CustomerRepository.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.repos; 2 | 3 | import demo.reactAdmin.crud.entities.Customer; 4 | import springboot.rest.repositories.BaseRepository; 5 | 6 | public interface CustomerRepository extends BaseRepository { 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/repos/ExampleRepository.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.repos; 2 | 3 | import demo.reactAdmin.crud.entities.Example; 4 | import springboot.rest.repositories.BaseRepository; 5 | 6 | public interface ExampleRepository extends BaseRepository { 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/repos/FileRepository.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.repos; 2 | 3 | import demo.reactAdmin.crud.entities.UploadFile; 4 | import springboot.rest.repositories.BaseRepository; 5 | 6 | public interface FileRepository extends BaseRepository { 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/repos/GroupRepository.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.repos; 2 | 3 | import demo.reactAdmin.crud.entities.Group; 4 | import springboot.rest.repositories.BaseRepository; 5 | 6 | public interface GroupRepository extends BaseRepository { 7 | } -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/repos/ProductRepository.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.repos; 2 | 3 | import demo.reactAdmin.crud.entities.Product; 4 | import springboot.rest.repositories.BaseRepository; 5 | 6 | public interface ProductRepository extends BaseRepository { 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/repos/ReviewRepository.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.repos; 2 | 3 | import demo.reactAdmin.crud.entities.Review; 4 | import springboot.rest.repositories.BaseRepository; 5 | 6 | public interface ReviewRepository extends BaseRepository { 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/repos/UserRepository.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.repos; 2 | 3 | import demo.reactAdmin.crud.entities.PlatformUser; 4 | import springboot.rest.repositories.BaseRepository; 5 | 6 | public interface UserRepository extends BaseRepository { 7 | PlatformUser findOneByUsername(String username); 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/services/DataInitService.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.services; 2 | 3 | 4 | import demo.reactAdmin.auth.PasswordEncoderProvider; 5 | import demo.reactAdmin.crud.entities.Group; 6 | import demo.reactAdmin.crud.entities.PlatformUser; 7 | import demo.reactAdmin.crud.repos.ClientRepository; 8 | import demo.reactAdmin.crud.repos.ExampleRepository; 9 | import demo.reactAdmin.crud.repos.GroupRepository; 10 | import demo.reactAdmin.crud.repos.UserRepository; 11 | import demo.reactAdmin.crud.utils.ApiHandler; 12 | import org.json.JSONArray; 13 | import org.json.JSONObject; 14 | import org.springframework.beans.factory.annotation.Autowired; 15 | import org.springframework.stereotype.Service; 16 | import springboot.rest.utils.JSON; 17 | 18 | import javax.servlet.ServletContext; 19 | import java.io.File; 20 | import java.io.FileInputStream; 21 | import java.io.FileNotFoundException; 22 | import java.io.IOException; 23 | import java.util.*; 24 | 25 | @Service 26 | public class DataInitService { 27 | 28 | @Autowired 29 | private ExampleRepository exampleRepository; 30 | 31 | @Autowired 32 | private ClientRepository clientRepository; 33 | 34 | @Autowired 35 | private UserRepository userRepository; 36 | 37 | @Autowired 38 | private GroupRepository groupRepository; 39 | 40 | @Autowired 41 | private PasswordEncoderProvider passEncodeProvider; 42 | 43 | @Autowired 44 | ServletContext context; 45 | 46 | @Autowired 47 | ApiHandler apiHandler; 48 | 49 | 50 | public void init() { 51 | 52 | 53 | 54 | 55 | 56 | // UploadFile file = new UploadFile(); 57 | // file.path = "C:\\Users\\Michael\\Documents\\GitHub\\aor-demo\\src\\main\\webapp\\WEB-INF\\uploaded\\ParentMapper.txt"; 58 | // fileRepository.save(file); 59 | // 60 | // UploadFile fileRef = new UploadFile(); 61 | // fileRef.id = 1; 62 | 63 | 64 | // 65 | // Client client = new Client(); 66 | // client.name = "client 1"; 67 | // client.password = passEncodeProvider.getEncoder().encode("client1"); 68 | // client.username = "client1"; 69 | // clientRepository.save(client); 70 | // 71 | // Client clientRef = new Client(); 72 | // clientRef.id = 1; 73 | // 74 | // 75 | // Example e1 = new Example(); 76 | // e1.client = clientRef; 77 | // 78 | // 79 | // Example e2 = new Example(); 80 | // e2.client = clientRef; 81 | // 82 | // exampleRepository.save(e1); 83 | // exampleRepository.save(e2); 84 | 85 | //collector,compulsive,regular, returns, reviewer, ordered_once 86 | Group collector = new Group("collector"); 87 | groupRepository.save(collector); 88 | 89 | Group compulsive = new Group("compulsive"); 90 | groupRepository.save(compulsive); 91 | 92 | Group regular = new Group("regular"); 93 | groupRepository.save(regular); 94 | 95 | Group returns = new Group("returns"); 96 | groupRepository.save(returns); 97 | 98 | Group reviewer = new Group("reviewer"); 99 | groupRepository.save(reviewer); 100 | 101 | Group orderedOnce = new Group("ordered_once"); 102 | groupRepository.save(orderedOnce); 103 | 104 | 105 | 106 | 107 | PlatformUser admin = new PlatformUser(); 108 | admin.username = "demo"; 109 | admin.password = passEncodeProvider.getEncoder().encode("demo"); 110 | userRepository.save(admin); 111 | 112 | 113 | File dataFile = new File(context.getRealPath("/WEB-INF/uploaded/data.json")); 114 | FileInputStream fis = null; 115 | JSONObject jsonObj = null; 116 | try { 117 | fis = new FileInputStream(dataFile); 118 | byte[] data = new byte[(int) dataFile.length()]; 119 | fis.read(data); 120 | fis.close(); 121 | String dataStr = new String(data, "UTF-8"); 122 | jsonObj = JSON.toJsonObject(dataStr); 123 | 124 | } catch (FileNotFoundException e) { 125 | e.printStackTrace(); 126 | } catch (IOException e) { 127 | e.printStackTrace(); 128 | } 129 | String token = apiHandler.authenticate("demo", "demo"); 130 | Map headers = new HashMap<>(); 131 | headers.put("X-Authorization", "Bearer "+token); 132 | String[] keys = {"categories", "customers", "products", "commands", "reviews"}; 133 | 134 | 135 | 136 | for (String key : keys) { 137 | JSONArray objects = ((JSONArray)jsonObj.get(key)); 138 | for (int i = 0; i < objects.length(); i++) { 139 | JSONObject object = objects.getJSONObject(i); 140 | incrementValue(object, Arrays.asList("id", "product_id", "category_id", "customer_id", "command_id")); 141 | apiHandler.sendPost("http://localhost:8080/api/v1/"+key+"/",object.toString(), headers); 142 | } 143 | } 144 | } 145 | 146 | //https://stackoverflow.com/a/46633583/986160 147 | public static void incrementValue(JSONObject obj, List keysToIncrementValue) { 148 | Set keys = obj.keySet(); 149 | for (String key : keys) { 150 | Object ob = obj.get(key); 151 | 152 | if (keysToIncrementValue.contains(key)) { 153 | obj.put(key, (Integer)obj.get(key) + 1); 154 | } 155 | 156 | if (ob instanceof JSONObject) { 157 | incrementValue((JSONObject) ob, keysToIncrementValue); 158 | } 159 | else if (ob instanceof JSONArray) { 160 | JSONArray arr = (JSONArray) ob; 161 | for (int i=0; i < arr.length(); i++) { 162 | Object arrObj = arr.get(0); 163 | if (arrObj instanceof JSONObject) { 164 | incrementValue((JSONObject) arrObj, keysToIncrementValue); 165 | } 166 | } 167 | } 168 | } 169 | } 170 | 171 | } 172 | -------------------------------------------------------------------------------- /backend/src/main/java/demo/reactAdmin/crud/utils/ReflectionUtils.java: -------------------------------------------------------------------------------- 1 | package demo.reactAdmin.crud.utils; 2 | 3 | 4 | import java.lang.reflect.Field; 5 | 6 | public class ReflectionUtils { 7 | // https://stackoverflow.com/questions/14374878/using-reflection-to-set-an-object-property 8 | public static boolean set(Object object, String fieldName, Object fieldValue) { 9 | Class clazz = object.getClass(); 10 | while (clazz != null) { 11 | try { 12 | Field field = clazz.getDeclaredField(fieldName); 13 | field.setAccessible(true); 14 | field.set(object, fieldValue); 15 | return true; 16 | } catch (NoSuchFieldException e) { 17 | clazz = clazz.getSuperclass(); 18 | } catch (Exception e) { 19 | throw new IllegalStateException(e); 20 | } 21 | } 22 | return false; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /backend/src/main/resources/application.properties: -------------------------------------------------------------------------------- 1 | spring.datasource.url = jdbc:mysql://localhost:3306/demo?useSSL=false 2 | spring.datasource.username = root 3 | spring.datasource.password = 4 | spring.datasource.testWhileIdle = true 5 | spring.datasource.validationQuery = SELECT 1 6 | 7 | spring.jpa.show-sql = true 8 | spring.jpa.hibernate.ddl-auto = create-drop 9 | spring.jpa.hibernate.naming-strategy = org.hibernate.cfg.ImprovedNamingStrategy 10 | spring.jpa.properties.hibernate.dialect = org.hibernate.dialect.MySQL5Dialect 11 | 12 | 13 | react-admin-api.host = http://localhost:8080 14 | 15 | react-admin-api.use-snake-case = true 16 | 17 | 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "3.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "^4.3.3", 7 | "@material-ui/icons": "^4.2.1", 8 | "core-js": "2", 9 | "data-generator-retail": "^3.0.0", 10 | "fakerest": "~2.1.0", 11 | "fetch-mock": "~6.3.0", 12 | "json-graphql-server": "~2.1.3", 13 | "proxy-polyfill": "^0.3.0", 14 | "ra-data-fakerest": "^3.0.0", 15 | "ra-data-graphql-simple": "^3.0.0", 16 | "ra-data-simple-rest": "^3.0.0", 17 | "ra-i18n-polyglot": "^3.0.0", 18 | "ra-input-rich-text": "^3.0.0", 19 | "ra-language-english": "^3.0.0", 20 | "ra-language-french": "^3.0.0", 21 | "react": "^16.9.0", 22 | "react-admin": "^3.0.0", 23 | "react-app-polyfill": "^1.0.4", 24 | "react-dom": "^16.9.0", 25 | "react-redux": "^7.1.0", 26 | "react-router": "^5.1.0", 27 | "react-router-dom": "^5.1.0", 28 | "react-scripts": "^3.0.0", 29 | "recompose": "~0.26.0", 30 | "redux-saga": "^1.0.0" 31 | }, 32 | "scripts": { 33 | "analyze": "source-map-explorer 'build/static/js/*.js'", 34 | "start": "react-scripts start", 35 | "build": "react-scripts build", 36 | "eject": "react-scripts eject" 37 | }, 38 | "homepage": "https://marmelab.com/react-admin-demo", 39 | "browserslist": [ 40 | ">0.2%", 41 | "not dead", 42 | "not ie <= 10", 43 | "not op_mini all" 44 | ], 45 | "devDependencies": { 46 | "@types/classnames": "^2.2.9", 47 | "@types/jest": "^24.0.23", 48 | "@types/node": "^12.12.14", 49 | "@types/query-string": "5.1.0", 50 | "@types/react": "^16.9.13", 51 | "@types/react-dom": "^16.9.4", 52 | "@types/react-redux": "^7.1.5", 53 | "@types/react-router-dom": "^5.1.3", 54 | "@types/recompose": "^0.30.7", 55 | "source-map-explorer": "^2.0.0", 56 | "typescript": "^3.7.2" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zifnab87/react-admin-demo-java-rest/de6e9b79c581fe07845d92ce7608d7fb779a47d1/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 | 21 | Posters Galore Administration 22 | 112 | 113 | 114 | 115 | 118 |
119 |
120 |
Loading...
121 |
122 |
123 | 124 | 125 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 80px; 8 | } 9 | 10 | .App-header { 11 | background-color: #222; 12 | height: 150px; 13 | padding: 20px; 14 | color: white; 15 | } 16 | 17 | .App-intro { 18 | font-size: large; 19 | } 20 | 21 | @keyframes App-logo-spin { 22 | from { 23 | transform: rotate(0deg); 24 | } 25 | to { 26 | transform: rotate(360deg); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Admin, Resource } from 'react-admin'; 3 | import polyglotI18nProvider from 'ra-i18n-polyglot'; 4 | 5 | import './App.css'; 6 | 7 | import authProvider from './authProvider'; 8 | import themeReducer from './themeReducer'; 9 | import { Login, Layout } from './layout'; 10 | import { Dashboard } from './dashboard'; 11 | import customRoutes from './routes'; 12 | import englishMessages from './i18n/en'; 13 | 14 | import visitors from './visitors'; 15 | import orders from './orders'; 16 | import products from './products'; 17 | import invoices from './invoices'; 18 | import categories from './categories'; 19 | import reviews from './reviews'; 20 | 21 | import dataProviderFactory from './dataProvider'; 22 | import fakeServerFactory from './fakeServer'; 23 | 24 | const i18nProvider = polyglotI18nProvider(locale => { 25 | if (locale === 'fr') { 26 | return import('./i18n/fr').then(messages => messages.default); 27 | } 28 | 29 | // Always fallback on english 30 | return englishMessages; 31 | }, 'en'); 32 | 33 | const App = () => { 34 | const [dataProvider, setDataProvider] = useState(null); 35 | 36 | useEffect(() => { 37 | let restoreFetch; 38 | 39 | const fetchDataProvider = async () => { 40 | restoreFetch = await fakeServerFactory( 41 | process.env.REACT_APP_DATA_PROVIDER 42 | ); 43 | 44 | setDataProvider( 45 | await dataProviderFactory(process.env.REACT_APP_DATA_PROVIDER) 46 | ); 47 | }; 48 | 49 | fetchDataProvider(); 50 | 51 | return restoreFetch; 52 | }, []); 53 | 54 | if (!dataProvider) { 55 | return ( 56 |
57 |
Loading...
58 |
59 | ); 60 | } 61 | 62 | return ( 63 | 74 | 75 | 80 | 81 | 82 | 83 | 84 | 85 | ); 86 | }; 87 | 88 | export default App; 89 | -------------------------------------------------------------------------------- /src/authProvider.ts: -------------------------------------------------------------------------------- 1 | import { AuthProvider } from 'ra-core'; 2 | 3 | const authProvider: AuthProvider = { 4 | login: ({ username }) => { 5 | localStorage.setItem('username', username); 6 | // accept all username/password combinations 7 | return Promise.resolve(); 8 | }, 9 | logout: () => { 10 | localStorage.removeItem('username'); 11 | return Promise.resolve(); 12 | }, 13 | checkError: () => Promise.resolve(), 14 | checkAuth: () => 15 | localStorage.getItem('username') ? Promise.resolve() : Promise.reject(), 16 | getPermissions: () => Promise.reject('Unknown method'), 17 | }; 18 | 19 | export default authProvider; 20 | -------------------------------------------------------------------------------- /src/categories/CategoryEdit.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { 3 | Datagrid, 4 | Edit, 5 | EditButton, 6 | NumberField, 7 | ReferenceManyField, 8 | SimpleForm, 9 | TextInput, 10 | useTranslate, 11 | } from 'react-admin'; 12 | 13 | import ThumbnailField from '../products/ThumbnailField'; 14 | import ProductRefField from '../products/ProductRefField'; 15 | import { FieldProps, Category } from '../types'; 16 | 17 | const CategoryTitle: FC> = ({ record }) => { 18 | const translate = useTranslate(); 19 | return record ? ( 20 | 21 | {translate('resources.categories.name', { smart_count: 1 })} " 22 | {record.name}" 23 | 24 | ) : null; 25 | }; 26 | 27 | const CategoryEdit = (props: any) => ( 28 | } {...props}> 29 | 30 | 31 | 37 | 38 | 39 | 40 | 44 | 48 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | ); 59 | 60 | export default CategoryEdit; 61 | -------------------------------------------------------------------------------- /src/categories/CategoryList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Datagrid, EditButton, List, TextField } from 'react-admin'; 3 | 4 | import LinkToRelatedProducts from './LinkToRelatedProducts'; 5 | 6 | const CategoryList = (props: any) => ( 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | 16 | export default CategoryList; 17 | -------------------------------------------------------------------------------- /src/categories/LinkToRelatedProducts.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import { Link } from 'react-router-dom'; 5 | import { useTranslate } from 'react-admin'; 6 | import { stringify } from 'query-string'; 7 | 8 | import products from '../products'; 9 | import { FieldProps, Category } from '../types'; 10 | 11 | const useStyles = makeStyles({ 12 | icon: { paddingRight: '0.5em' }, 13 | link: { 14 | display: 'inline-flex', 15 | alignItems: 'center', 16 | }, 17 | }); 18 | 19 | const LinkToRelatedProducts: FC> = ({ record }) => { 20 | const translate = useTranslate(); 21 | const classes = useStyles(); 22 | return record ? ( 23 | 42 | ) : null; 43 | }; 44 | 45 | export default LinkToRelatedProducts; 46 | -------------------------------------------------------------------------------- /src/categories/index.ts: -------------------------------------------------------------------------------- 1 | import CategoryIcon from '@material-ui/icons/Bookmark'; 2 | 3 | import CategoryList from './CategoryList'; 4 | import CategoryEdit from './CategoryEdit'; 5 | 6 | export default { 7 | list: CategoryList, 8 | edit: CategoryEdit, 9 | icon: CategoryIcon, 10 | }; 11 | -------------------------------------------------------------------------------- /src/configuration/Configuration.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import Card from '@material-ui/core/Card'; 4 | import CardContent from '@material-ui/core/CardContent'; 5 | import Button from '@material-ui/core/Button'; 6 | import { useTranslate, useLocale, useSetLocale, Title } from 'react-admin'; 7 | import { makeStyles } from '@material-ui/core/styles'; 8 | import { changeTheme } from './actions'; 9 | import { AppState } from '../types'; 10 | 11 | const useStyles = makeStyles({ 12 | label: { width: '10em', display: 'inline-block' }, 13 | button: { margin: '1em' }, 14 | }); 15 | 16 | const Configuration = () => { 17 | const translate = useTranslate(); 18 | const locale = useLocale(); 19 | const setLocale = useSetLocale(); 20 | const classes = useStyles(); 21 | const theme = useSelector((state: AppState) => state.theme); 22 | const dispatch = useDispatch(); 23 | return ( 24 | 25 | 26 | <CardContent> 27 | <div className={classes.label}> 28 | {translate('pos.theme.name')} 29 | </div> 30 | <Button 31 | variant="contained" 32 | className={classes.button} 33 | color={theme === 'light' ? 'primary' : 'default'} 34 | onClick={() => dispatch(changeTheme('light'))} 35 | > 36 | {translate('pos.theme.light')} 37 | </Button> 38 | <Button 39 | variant="contained" 40 | className={classes.button} 41 | color={theme === 'dark' ? 'primary' : 'default'} 42 | onClick={() => dispatch(changeTheme('dark'))} 43 | > 44 | {translate('pos.theme.dark')} 45 | </Button> 46 | </CardContent> 47 | <CardContent> 48 | <div className={classes.label}>{translate('pos.language')}</div> 49 | <Button 50 | variant="contained" 51 | className={classes.button} 52 | color={locale === 'en' ? 'primary' : 'default'} 53 | onClick={() => setLocale('en')} 54 | > 55 | en 56 | </Button> 57 | <Button 58 | variant="contained" 59 | className={classes.button} 60 | color={locale === 'fr' ? 'primary' : 'default'} 61 | onClick={() => setLocale('fr')} 62 | > 63 | fr 64 | </Button> 65 | </CardContent> 66 | </Card> 67 | ); 68 | }; 69 | 70 | export default Configuration; 71 | -------------------------------------------------------------------------------- /src/configuration/actions.ts: -------------------------------------------------------------------------------- 1 | import { ThemeName } from '../types'; 2 | 3 | export const CHANGE_THEME = 'CHANGE_THEME'; 4 | 5 | export const changeTheme = (theme: ThemeName) => ({ 6 | type: CHANGE_THEME, 7 | payload: theme, 8 | }); 9 | -------------------------------------------------------------------------------- /src/dashboard/CardIcon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Card from '@material-ui/core/Card'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | 5 | const useStyles = makeStyles({ 6 | card: { 7 | float: 'left', 8 | margin: '-20px 20px 0 15px', 9 | zIndex: 100, 10 | borderRadius: 3, 11 | }, 12 | icon: { 13 | float: 'right', 14 | width: 54, 15 | height: 54, 16 | padding: 14, 17 | color: '#fff', 18 | }, 19 | }); 20 | 21 | const CardIcon = ({ Icon, bgColor }) => { 22 | const classes = useStyles(); 23 | return ( 24 | <Card className={classes.card} style={{ backgroundColor: bgColor }}> 25 | <Icon className={classes.icon} /> 26 | </Card> 27 | ); 28 | }; 29 | 30 | export default CardIcon; 31 | -------------------------------------------------------------------------------- /src/dashboard/MonthlyRevenue.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Card from '@material-ui/core/Card'; 3 | import DollarIcon from '@material-ui/icons/AttachMoney'; 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | import Typography from '@material-ui/core/Typography'; 6 | import { useTranslate } from 'react-admin'; 7 | 8 | import CardIcon from './CardIcon'; 9 | 10 | const useStyles = makeStyles({ 11 | main: { 12 | flex: '1', 13 | marginRight: '1em', 14 | marginTop: 20, 15 | }, 16 | card: { 17 | overflow: 'inherit', 18 | textAlign: 'right', 19 | padding: 16, 20 | minHeight: 52, 21 | }, 22 | }); 23 | 24 | const MonthlyRevenue = ({ value }) => { 25 | const translate = useTranslate(); 26 | const classes = useStyles(); 27 | return ( 28 | <div className={classes.main}> 29 | <CardIcon Icon={DollarIcon} bgColor="#31708f" /> 30 | <Card className={classes.card}> 31 | <Typography className={classes.title} color="textSecondary"> 32 | {translate('pos.dashboard.monthly_revenue')} 33 | </Typography> 34 | <Typography variant="h5" component="h2"> 35 | {value} 36 | </Typography> 37 | </Card> 38 | </div> 39 | ); 40 | }; 41 | 42 | export default MonthlyRevenue; 43 | -------------------------------------------------------------------------------- /src/dashboard/NbNewOrders.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Card from '@material-ui/core/Card'; 3 | import ShoppingCartIcon from '@material-ui/icons/ShoppingCart'; 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | import Typography from '@material-ui/core/Typography'; 6 | import { useTranslate } from 'react-admin'; 7 | 8 | import CardIcon from './CardIcon'; 9 | 10 | const useStyles = makeStyles({ 11 | main: { 12 | flex: '1', 13 | marginLeft: '1em', 14 | marginTop: 20, 15 | }, 16 | card: { 17 | overflow: 'inherit', 18 | textAlign: 'right', 19 | padding: 16, 20 | minHeight: 52, 21 | }, 22 | }); 23 | 24 | const NbNewOrders = ({ value }) => { 25 | const translate = useTranslate(); 26 | const classes = useStyles(); 27 | return ( 28 | <div className={classes.main}> 29 | <CardIcon Icon={ShoppingCartIcon} bgColor="#ff9800" /> 30 | <Card className={classes.card}> 31 | <Typography className={classes.title} color="textSecondary"> 32 | {translate('pos.dashboard.new_orders')} 33 | </Typography> 34 | <Typography variant="h5" component="h2"> 35 | {value} 36 | </Typography> 37 | </Card> 38 | </div> 39 | ); 40 | }; 41 | 42 | export default NbNewOrders; 43 | -------------------------------------------------------------------------------- /src/dashboard/NewCustomers.js: -------------------------------------------------------------------------------- 1 | import React, { useMemo } from 'react'; 2 | import Card from '@material-ui/core/Card'; 3 | import List from '@material-ui/core/List'; 4 | import ListItem from '@material-ui/core/ListItem'; 5 | import ListItemAvatar from '@material-ui/core/ListItemAvatar'; 6 | import ListItemText from '@material-ui/core/ListItemText'; 7 | import Avatar from '@material-ui/core/Avatar'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import CustomerIcon from '@material-ui/icons/PersonAdd'; 10 | import Divider from '@material-ui/core/Divider'; 11 | import { makeStyles } from '@material-ui/core/styles'; 12 | import { Link } from 'react-router-dom'; 13 | import { useTranslate, useQueryWithStore } from 'react-admin'; 14 | 15 | import CardIcon from './CardIcon'; 16 | 17 | const useStyles = makeStyles({ 18 | main: { 19 | flex: '1', 20 | marginLeft: '1em', 21 | marginTop: 20, 22 | }, 23 | card: { 24 | padding: '16px 0', 25 | overflow: 'inherit', 26 | textAlign: 'right', 27 | }, 28 | title: { 29 | padding: '0 16px', 30 | }, 31 | value: { 32 | padding: '0 16px', 33 | minHeight: 48, 34 | }, 35 | }); 36 | 37 | const NewCustomers = () => { 38 | const classes = useStyles(); 39 | const translate = useTranslate(); 40 | const aMonthAgo = useMemo(() => { 41 | const date = new Date(); 42 | date.setDate(date.getDate() - 30); 43 | date.setHours(0); 44 | date.setMinutes(0); 45 | date.setSeconds(0); 46 | date.setMilliseconds(0); 47 | return date; 48 | }, []); 49 | 50 | const { loaded, data: visitors } = useQueryWithStore({ 51 | type: 'getList', 52 | resource: 'customers', 53 | payload: { 54 | filter: { 55 | has_ordered: true, 56 | first_seen_gte: aMonthAgo.toISOString(), 57 | }, 58 | sort: { field: 'first_seen', order: 'DESC' }, 59 | pagination: { page: 1, perPage: 100 }, 60 | }, 61 | }); 62 | 63 | if (!loaded) return null; 64 | 65 | const nb = visitors ? visitors.reduce(nb => ++nb, 0) : 0; 66 | return ( 67 | <div className={classes.main}> 68 | <CardIcon Icon={CustomerIcon} bgColor="#4caf50" /> 69 | <Card className={classes.card}> 70 | <Typography className={classes.title} color="textSecondary"> 71 | {translate('pos.dashboard.new_customers')} 72 | </Typography> 73 | <Typography 74 | variant="h5" 75 | component="h2" 76 | className={classes.value} 77 | > 78 | {nb} 79 | </Typography> 80 | <Divider /> 81 | <List> 82 | {visitors 83 | ? visitors.map(record => ( 84 | <ListItem 85 | button 86 | to={`/customers/${record.id}`} 87 | component={Link} 88 | key={record.id} 89 | > 90 | <ListItemAvatar> 91 | <Avatar 92 | src={`${record.avatar}?size=32x32`} 93 | /> 94 | </ListItemAvatar> 95 | <ListItemText 96 | primary={`${record.first_name} ${ 97 | record.last_name 98 | }`} 99 | /> 100 | </ListItem> 101 | )) 102 | : null} 103 | </List> 104 | </Card> 105 | </div> 106 | ); 107 | }; 108 | 109 | export default NewCustomers; 110 | -------------------------------------------------------------------------------- /src/dashboard/PendingOrders.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Card from '@material-ui/core/Card'; 3 | import CardHeader from '@material-ui/core/CardHeader'; 4 | import List from '@material-ui/core/List'; 5 | import ListItem from '@material-ui/core/ListItem'; 6 | import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction'; 7 | import ListItemAvatar from '@material-ui/core/ListItemAvatar'; 8 | import ListItemText from '@material-ui/core/ListItemText'; 9 | import Avatar from '@material-ui/core/Avatar'; 10 | import { makeStyles } from '@material-ui/core/styles'; 11 | import { Link } from 'react-router-dom'; 12 | import { useTranslate } from 'react-admin'; 13 | 14 | const useStyles = makeStyles(theme => ({ 15 | root: { 16 | flex: 1, 17 | }, 18 | cost: { 19 | marginRight: '1em', 20 | color: theme.palette.text.primary, 21 | }, 22 | })); 23 | 24 | const PendingOrders = ({ orders = [], customers = {} }) => { 25 | const classes = useStyles(); 26 | const translate = useTranslate(); 27 | return ( 28 | <Card className={classes.root}> 29 | <CardHeader title={translate('pos.dashboard.pending_orders')} /> 30 | <List dense={true}> 31 | {orders.map(record => ( 32 | <ListItem 33 | key={record.id} 34 | button 35 | component={Link} 36 | to={`/commands/${record.id}`} 37 | > 38 | <ListItemAvatar> 39 | {customers[record.customer_id] ? ( 40 | <Avatar 41 | src={`${ 42 | customers[record.customer_id].avatar 43 | }?size=32x32`} 44 | /> 45 | ) : ( 46 | <Avatar /> 47 | )} 48 | </ListItemAvatar> 49 | <ListItemText 50 | primary={new Date(record.date).toLocaleString( 51 | 'en-GB' 52 | )} 53 | secondary={translate('pos.dashboard.order.items', { 54 | smart_count: record.basket.length, 55 | nb_items: record.basket.length, 56 | customer_name: customers[record.customer_id] 57 | ? `${ 58 | customers[record.customer_id] 59 | .first_name 60 | } ${ 61 | customers[record.customer_id] 62 | .last_name 63 | }` 64 | : '', 65 | })} 66 | /> 67 | <ListItemSecondaryAction> 68 | <span className={classes.cost}> 69 | {record.total}$ 70 | </span> 71 | </ListItemSecondaryAction> 72 | </ListItem> 73 | ))} 74 | </List> 75 | </Card> 76 | ); 77 | }; 78 | 79 | export default PendingOrders; 80 | -------------------------------------------------------------------------------- /src/dashboard/PendingReviews.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Card from '@material-ui/core/Card'; 3 | import List from '@material-ui/core/List'; 4 | import ListItem from '@material-ui/core/ListItem'; 5 | import ListItemAvatar from '@material-ui/core/ListItemAvatar'; 6 | import ListItemText from '@material-ui/core/ListItemText'; 7 | import Avatar from '@material-ui/core/Avatar'; 8 | import Typography from '@material-ui/core/Typography'; 9 | import CommentIcon from '@material-ui/icons/Comment'; 10 | import Divider from '@material-ui/core/Divider'; 11 | import { makeStyles } from '@material-ui/core/styles'; 12 | import { Link } from 'react-router-dom'; 13 | import { useTranslate } from 'react-admin'; 14 | 15 | import CardIcon from './CardIcon'; 16 | 17 | import StarRatingField from '../reviews/StarRatingField'; 18 | 19 | const useStyles = makeStyles(theme => ({ 20 | main: { 21 | flex: '1', 22 | marginRight: '1em', 23 | marginTop: 20, 24 | }, 25 | titleLink: { textDecoration: 'none', color: 'inherit' }, 26 | card: { 27 | padding: '16px 0', 28 | overflow: 'inherit', 29 | textAlign: 'right', 30 | }, 31 | title: { 32 | padding: '0 16px', 33 | }, 34 | value: { 35 | padding: '0 16px', 36 | minHeight: 48, 37 | }, 38 | avatar: { 39 | background: theme.palette.background.avatar, 40 | }, 41 | listItemText: { 42 | overflowY: 'hidden', 43 | height: '4em', 44 | display: '-webkit-box', 45 | WebkitLineClamp: 2, 46 | WebkitBoxOrient: 'vertical', 47 | }, 48 | })); 49 | 50 | const location = { 51 | pathname: 'reviews', 52 | query: { filter: JSON.stringify({ status: 'pending' }) }, 53 | }; 54 | 55 | const PendingReviews = ({ reviews = [], customers = {}, nb }) => { 56 | const classes = useStyles(); 57 | const translate = useTranslate(); 58 | return ( 59 | <div className={classes.main}> 60 | <CardIcon Icon={CommentIcon} bgColor="#f44336" /> 61 | <Card className={classes.card}> 62 | <Typography className={classes.title} color="textSecondary"> 63 | {translate('pos.dashboard.pending_reviews')} 64 | </Typography> 65 | <Typography 66 | variant="h5" 67 | component="h2" 68 | className={classes.value} 69 | > 70 | <Link to={location} className={classes.titleLink}> 71 | {nb} 72 | </Link> 73 | </Typography> 74 | <Divider /> 75 | <List> 76 | {reviews.map(record => ( 77 | <ListItem 78 | key={record.id} 79 | button 80 | component={Link} 81 | to={`/reviews/${record.id}`} 82 | alignItems="flex-start" 83 | > 84 | <ListItemAvatar> 85 | {customers[record.customer_id] ? ( 86 | <Avatar 87 | src={`${ 88 | customers[record.customer_id].avatar 89 | }?size=32x32`} 90 | className={classes.avatar} 91 | /> 92 | ) : ( 93 | <Avatar /> 94 | )} 95 | </ListItemAvatar> 96 | 97 | <ListItemText 98 | primary={<StarRatingField record={record} />} 99 | secondary={record.comment} 100 | className={classes.listItemText} 101 | style={{ paddingRight: 0 }} 102 | /> 103 | </ListItem> 104 | ))} 105 | </List> 106 | </Card> 107 | </div> 108 | ); 109 | }; 110 | 111 | export default PendingReviews; 112 | -------------------------------------------------------------------------------- /src/dashboard/Welcome.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Card from '@material-ui/core/Card'; 3 | import CardActions from '@material-ui/core/CardActions'; 4 | import CardContent from '@material-ui/core/CardContent'; 5 | import CardMedia from '@material-ui/core/CardMedia'; 6 | import Button from '@material-ui/core/Button'; 7 | import Typography from '@material-ui/core/Typography'; 8 | import HomeIcon from '@material-ui/icons/Home'; 9 | import CodeIcon from '@material-ui/icons/Code'; 10 | import { makeStyles } from '@material-ui/core/styles'; 11 | import { useTranslate } from 'react-admin'; 12 | 13 | const useStyles = makeStyles({ 14 | media: { 15 | height: '18em', 16 | }, 17 | }); 18 | 19 | const mediaUrl = `https://marmelab.com/posters/beard-${parseInt( 20 | Math.random() * 10, 21 | 10 22 | ) + 1}.jpeg`; 23 | 24 | const Welcome = () => { 25 | const translate = useTranslate(); 26 | const classes = useStyles(); 27 | return ( 28 | <Card> 29 | <CardMedia image={mediaUrl} className={classes.media} /> 30 | <CardContent> 31 | <Typography variant="h5" component="h2"> 32 | {translate('pos.dashboard.welcome.title')} 33 | </Typography> 34 | <Typography component="p"> 35 | {translate('pos.dashboard.welcome.subtitle')} 36 | </Typography> 37 | </CardContent> 38 | <CardActions style={{ justifyContent: 'flex-end' }}> 39 | <Button href="https://marmelab.com/react-admin"> 40 | <HomeIcon style={{ paddingRight: '0.5em' }} /> 41 | {translate('pos.dashboard.welcome.aor_button')} 42 | </Button> 43 | <Button href="https://github.com/marmelab/react-admin/tree/master/examples/demo"> 44 | <CodeIcon style={{ paddingRight: '0.5em' }} /> 45 | {translate('pos.dashboard.welcome.demo_button')} 46 | </Button> 47 | </CardActions> 48 | </Card> 49 | ); 50 | }; 51 | 52 | export default Welcome; 53 | -------------------------------------------------------------------------------- /src/dashboard/index.js: -------------------------------------------------------------------------------- 1 | import DashboardComponent from './Dashboard'; 2 | 3 | export const Dashboard = DashboardComponent; 4 | -------------------------------------------------------------------------------- /src/dataProvider/graphql.js: -------------------------------------------------------------------------------- 1 | import buildApolloClient, { 2 | buildQuery as buildQueryFactory, 3 | } from 'ra-data-graphql-simple'; 4 | import { DELETE } from 'ra-core'; 5 | import gql from 'graphql-tag'; 6 | const getGqlResource = resource => { 7 | switch (resource) { 8 | case 'customers': 9 | return 'Customer'; 10 | 11 | case 'categories': 12 | return 'Category'; 13 | 14 | case 'commands': 15 | return 'Command'; 16 | 17 | case 'products': 18 | return 'Product'; 19 | 20 | case 'reviews': 21 | return 'Review'; 22 | 23 | case 'invoices': 24 | return 'Invoice'; 25 | 26 | default: 27 | throw new Error(`Unknown resource ${resource}`); 28 | } 29 | }; 30 | 31 | const customBuildQuery = introspectionResults => { 32 | const buildQuery = buildQueryFactory(introspectionResults); 33 | 34 | return (type, resource, params) => { 35 | if (type === DELETE) { 36 | return { 37 | query: gql`mutation remove${resource}($id: ID!) { 38 | remove${resource}(id: $id) 39 | }`, 40 | variables: { id: params.id }, 41 | parseResponse: ({ data }) => { 42 | if (data[`remove${resource}`]) { 43 | return { data: { id: params.id } }; 44 | } 45 | 46 | throw new Error(`Could not delete ${resource}`); 47 | }, 48 | }; 49 | } 50 | 51 | return buildQuery(type, resource, params); 52 | }; 53 | }; 54 | 55 | export default () => { 56 | return buildApolloClient({ 57 | clientOptions: { 58 | uri: 'http://localhost:4000/graphql', 59 | }, 60 | introspection: { 61 | operationNames: { 62 | [DELETE]: resource => `remove${resource.name}`, 63 | }, 64 | }, 65 | buildQuery: customBuildQuery, 66 | }).then(dataProvider => (type, resource, params) => 67 | dataProvider(type, getGqlResource(resource), params) 68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /src/dataProvider/index.js: -------------------------------------------------------------------------------- 1 | export default type => { 2 | switch (type) { 3 | case 'graphql': 4 | return import('./graphql').then(factory => factory.default()); 5 | default: 6 | return import('./rest').then(provider => provider.default); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /src/dataProvider/rest.js: -------------------------------------------------------------------------------- 1 | import simpleRestProvider from 'ra-data-simple-rest'; 2 | 3 | const restProvider = simpleRestProvider('http://localhost:4000'); 4 | 5 | const delayedDataProvider = new Proxy(restProvider, { 6 | get: (target, name, self) => 7 | name === 'then' // as we await for the dataProvider, JS calls then on it. We must trap that call or else the dataProvider will be called with the then method 8 | ? self 9 | : (resource, params) => 10 | new Promise(resolve => 11 | setTimeout( 12 | () => resolve(restProvider[name](resource, params)), 13 | 500 14 | ) 15 | ), 16 | }); 17 | 18 | export default delayedDataProvider; 19 | -------------------------------------------------------------------------------- /src/fakeServer/graphql.js: -------------------------------------------------------------------------------- 1 | import JsonGraphqlServer from 'json-graphql-server'; 2 | import generateData from 'data-generator-retail'; 3 | import fetchMock from 'fetch-mock'; 4 | 5 | export default () => { 6 | const data = generateData({ serializeDate: false }); 7 | const restServer = JsonGraphqlServer({ data }); 8 | const handler = restServer.getHandler(); 9 | 10 | fetchMock.mock('begin:http://localhost:4000', handler); 11 | return () => fetchMock.restore(); 12 | }; 13 | -------------------------------------------------------------------------------- /src/fakeServer/index.js: -------------------------------------------------------------------------------- 1 | export default type => { 2 | switch (type) { 3 | case 'graphql': 4 | return import('./graphql').then(factory => factory.default()); 5 | default: 6 | return import('./rest').then(factory => factory.default()); 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /src/fakeServer/rest.js: -------------------------------------------------------------------------------- 1 | import FakeRest from 'fakerest'; 2 | import fetchMock from 'fetch-mock'; 3 | import generateData from 'data-generator-retail'; 4 | 5 | export default () => { 6 | const data = generateData({ serializeDate: true }); 7 | const restServer = new FakeRest.FetchServer('http://localhost:4000'); 8 | if (window) { 9 | window.restServer = restServer; // give way to update data in the console 10 | } 11 | restServer.init(data); 12 | restServer.toggleLogging(); // logging is off by default, enable it 13 | fetchMock.mock('begin:http://localhost:4000', restServer.getHandler()); 14 | return () => fetchMock.restore(); 15 | }; 16 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | font-family: sans-serif; 5 | } 6 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import 'react-app-polyfill/ie11'; 2 | import 'react-app-polyfill/stable'; 3 | import 'proxy-polyfill'; 4 | import React from 'react'; 5 | import ReactDOM from 'react-dom'; 6 | import App from './App'; 7 | import './index.css'; 8 | 9 | ReactDOM.render(<App />, document.getElementById('root')); 10 | -------------------------------------------------------------------------------- /src/invoices/InvoiceList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | List, 4 | Datagrid, 5 | TextField, 6 | DateField, 7 | ReferenceField, 8 | NumberField, 9 | Filter, 10 | DateInput, 11 | } from 'react-admin'; 12 | 13 | import FullNameField from '../visitors/FullNameField'; 14 | import AddressField from '../visitors/AddressField'; 15 | import InvoiceShow from './InvoiceShow'; 16 | 17 | const ListFilters = (props: any) => ( 18 | <Filter {...props}> 19 | <DateInput source="date_gte" alwaysOn /> 20 | <DateInput source="date_lte" alwaysOn /> 21 | </Filter> 22 | ); 23 | 24 | const InvoiceList = (props: any) => ( 25 | <List {...props} filters={<ListFilters />} perPage={25}> 26 | <Datagrid rowClick="expand" expand={<InvoiceShow />}> 27 | <TextField source="id" /> 28 | <DateField source="date" /> 29 | <ReferenceField source="customer_id" reference="customers"> 30 | <FullNameField /> 31 | </ReferenceField> 32 | <ReferenceField 33 | source="customer_id" 34 | reference="customers" 35 | link={false} 36 | label="resources.invoices.fields.address" 37 | > 38 | <AddressField /> 39 | </ReferenceField> 40 | <ReferenceField source="command_id" reference="commands"> 41 | <TextField source="reference" /> 42 | </ReferenceField> 43 | <NumberField source="total_ex_taxes" /> 44 | <NumberField source="delivery_fees" /> 45 | <NumberField source="taxes" /> 46 | <NumberField source="total" /> 47 | </Datagrid> 48 | </List> 49 | ); 50 | 51 | export default InvoiceList; 52 | -------------------------------------------------------------------------------- /src/invoices/InvoiceShow.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import Card from '@material-ui/core/Card'; 3 | import CardContent from '@material-ui/core/CardContent'; 4 | import Grid from '@material-ui/core/Grid'; 5 | import Typography from '@material-ui/core/Typography'; 6 | import { makeStyles } from '@material-ui/core/styles'; 7 | import { useShowController, ReferenceField, TextField } from 'react-admin'; 8 | 9 | import Basket from '../orders/Basket'; 10 | import { FieldProps, Customer } from '../types'; 11 | 12 | const CustomerField: FC<FieldProps<Customer>> = ({ record }) => 13 | record ? ( 14 | <Typography> 15 | {record.first_name} {record.last_name} 16 | <br /> 17 | {record.address} 18 | <br /> 19 | {record.city}, {record.zipcode} 20 | </Typography> 21 | ) : null; 22 | 23 | const InvoiceShow = (props: any) => { 24 | const { record } = useShowController(props); 25 | const classes = useStyles(); 26 | 27 | if (!record) return null; 28 | return ( 29 | <Card className={classes.root}> 30 | <CardContent> 31 | <Grid container spacing={2}> 32 | <Grid item xs={6}> 33 | <Typography variant="h6" gutterBottom> 34 | Posters Galore 35 | </Typography> 36 | </Grid> 37 | <Grid item xs={6}> 38 | <Typography variant="h6" gutterBottom align="right"> 39 | Invoice {record.id} 40 | </Typography> 41 | </Grid> 42 | </Grid> 43 | <Grid container spacing={2}> 44 | <Grid item xs={12} alignContent="flex-end"> 45 | <ReferenceField 46 | resource="invoices" 47 | reference="customers" 48 | source="customer_id" 49 | basePath="/invoices" 50 | record={record} 51 | link={false} 52 | > 53 | <CustomerField /> 54 | </ReferenceField> 55 | </Grid> 56 | </Grid> 57 | <div className={classes.spacer}> </div> 58 | <Grid container spacing={2}> 59 | <Grid item xs={6}> 60 | <Typography variant="h6" gutterBottom align="center"> 61 | Date{' '} 62 | </Typography> 63 | <Typography gutterBottom align="center"> 64 | {new Date(record.date).toLocaleDateString()} 65 | </Typography> 66 | </Grid> 67 | 68 | <Grid item xs={5}> 69 | <Typography variant="h6" gutterBottom align="center"> 70 | Order 71 | </Typography> 72 | <ReferenceField 73 | resource="invoices" 74 | reference="commands" 75 | source="command_id" 76 | basePath="/invoices" 77 | record={record} 78 | link={false} 79 | > 80 | <TextField 81 | source="reference" 82 | align="center" 83 | component="p" 84 | gutterBottom 85 | /> 86 | </ReferenceField> 87 | </Grid> 88 | </Grid> 89 | <div className={classes.invoices}> 90 | <ReferenceField 91 | resource="invoices" 92 | reference="commands" 93 | source="command_id" 94 | basePath="/invoices" 95 | record={record} 96 | link={false} 97 | > 98 | <Basket /> 99 | </ReferenceField> 100 | </div> 101 | </CardContent> 102 | </Card> 103 | ); 104 | }; 105 | 106 | export default InvoiceShow; 107 | 108 | const useStyles = makeStyles({ 109 | root: { width: 600, margin: 'auto' }, 110 | spacer: { height: 20 }, 111 | invoices: { margin: '10px 0' }, 112 | }); 113 | -------------------------------------------------------------------------------- /src/invoices/index.ts: -------------------------------------------------------------------------------- 1 | import InvoiceIcon from '@material-ui/icons/LibraryBooks'; 2 | 3 | import InvoiceList from './InvoiceList'; 4 | 5 | export default { 6 | list: InvoiceList, 7 | icon: InvoiceIcon, 8 | }; 9 | -------------------------------------------------------------------------------- /src/layout/AppBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef } from 'react'; 2 | import { AppBar, UserMenu, MenuItemLink, useTranslate } from 'react-admin'; 3 | import Typography from '@material-ui/core/Typography'; 4 | import SettingsIcon from '@material-ui/icons/Settings'; 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | 7 | import Logo from './Logo'; 8 | 9 | const useStyles = makeStyles({ 10 | title: { 11 | flex: 1, 12 | textOverflow: 'ellipsis', 13 | whiteSpace: 'nowrap', 14 | overflow: 'hidden', 15 | }, 16 | spacer: { 17 | flex: 1, 18 | }, 19 | }); 20 | 21 | const ConfigurationMenu = forwardRef<any, any>((props, ref) => { 22 | const translate = useTranslate(); 23 | return ( 24 | <MenuItemLink 25 | ref={ref} 26 | to="/configuration" 27 | primaryText={translate('pos.configuration')} 28 | leftIcon={<SettingsIcon />} 29 | onClick={props.onClick} 30 | /> 31 | ); 32 | }); 33 | 34 | const CustomUserMenu = (props: any) => ( 35 | <UserMenu {...props}> 36 | <ConfigurationMenu /> 37 | </UserMenu> 38 | ); 39 | 40 | const CustomAppBar = (props: any) => { 41 | const classes = useStyles(); 42 | return ( 43 | <AppBar {...props} userMenu={<CustomUserMenu />}> 44 | <Typography 45 | variant="h6" 46 | color="inherit" 47 | className={classes.title} 48 | id="react-admin-title" 49 | /> 50 | <Logo /> 51 | <span className={classes.spacer} /> 52 | </AppBar> 53 | ); 54 | }; 55 | 56 | export default CustomAppBar; 57 | -------------------------------------------------------------------------------- /src/layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import { Layout, Sidebar } from 'react-admin'; 4 | import AppBar from './AppBar'; 5 | import Menu from './Menu'; 6 | import { darkTheme, lightTheme } from './themes'; 7 | import { AppState } from '../types'; 8 | 9 | const CustomSidebar = (props: any) => <Sidebar {...props} size={200} />; 10 | 11 | export default (props: any) => { 12 | const theme = useSelector((state: AppState) => 13 | state.theme === 'dark' ? darkTheme : lightTheme 14 | ); 15 | return ( 16 | <Layout 17 | {...props} 18 | appBar={AppBar} 19 | sidebar={CustomSidebar} 20 | menu={Menu} 21 | theme={theme} 22 | /> 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /src/layout/Menu.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, useState } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import SettingsIcon from '@material-ui/icons/Settings'; 4 | import LabelIcon from '@material-ui/icons/Label'; 5 | import { useMediaQuery, Theme } from '@material-ui/core'; 6 | import { useTranslate, DashboardMenuItem, MenuItemLink } from 'react-admin'; 7 | 8 | import visitors from '../visitors'; 9 | import orders from '../orders'; 10 | import invoices from '../invoices'; 11 | import products from '../products'; 12 | import categories from '../categories'; 13 | import reviews from '../reviews'; 14 | import SubMenu from './SubMenu'; 15 | import { AppState } from '../types'; 16 | 17 | type MenuName = 'menuCatalog' | 'menuSales' | 'menuCustomers'; 18 | 19 | interface Props { 20 | dense: boolean; 21 | logout: () => void; 22 | onMenuClick: () => void; 23 | } 24 | 25 | const Menu: FC<Props> = ({ onMenuClick, dense, logout }) => { 26 | const [state, setState] = useState({ 27 | menuCatalog: false, 28 | menuSales: false, 29 | menuCustomers: false, 30 | }); 31 | const translate = useTranslate(); 32 | const isXSmall = useMediaQuery((theme: Theme) => 33 | theme.breakpoints.down('xs') 34 | ); 35 | const open = useSelector((state: AppState) => state.admin.ui.sidebarOpen); 36 | useSelector((state: AppState) => state.theme); // force rerender on theme change 37 | 38 | const handleToggle = (menu: MenuName) => { 39 | setState(state => ({ ...state, [menu]: !state[menu] })); 40 | }; 41 | 42 | return ( 43 | <div> 44 | {' '} 45 | <DashboardMenuItem onClick={onMenuClick} sidebarIsOpen={open} /> 46 | <SubMenu 47 | handleToggle={() => handleToggle('menuSales')} 48 | isOpen={state.menuSales} 49 | sidebarIsOpen={open} 50 | name="pos.menu.sales" 51 | icon={<orders.icon />} 52 | dense={dense} 53 | > 54 | <MenuItemLink 55 | to={`/commands`} 56 | primaryText={translate(`resources.commands.name`, { 57 | smart_count: 2, 58 | })} 59 | leftIcon={<orders.icon />} 60 | onClick={onMenuClick} 61 | sidebarIsOpen={open} 62 | dense={dense} 63 | /> 64 | <MenuItemLink 65 | to={`/invoices`} 66 | primaryText={translate(`resources.invoices.name`, { 67 | smart_count: 2, 68 | })} 69 | leftIcon={<invoices.icon />} 70 | onClick={onMenuClick} 71 | sidebarIsOpen={open} 72 | dense={dense} 73 | /> 74 | </SubMenu> 75 | <SubMenu 76 | handleToggle={() => handleToggle('menuCatalog')} 77 | isOpen={state.menuCatalog} 78 | sidebarIsOpen={open} 79 | name="pos.menu.catalog" 80 | icon={<products.icon />} 81 | dense={dense} 82 | > 83 | <MenuItemLink 84 | to={`/products`} 85 | primaryText={translate(`resources.products.name`, { 86 | smart_count: 2, 87 | })} 88 | leftIcon={<products.icon />} 89 | onClick={onMenuClick} 90 | sidebarIsOpen={open} 91 | dense={dense} 92 | /> 93 | <MenuItemLink 94 | to={`/categories`} 95 | primaryText={translate(`resources.categories.name`, { 96 | smart_count: 2, 97 | })} 98 | leftIcon={<categories.icon />} 99 | onClick={onMenuClick} 100 | sidebarIsOpen={open} 101 | dense={dense} 102 | /> 103 | </SubMenu> 104 | <SubMenu 105 | handleToggle={() => handleToggle('menuCustomers')} 106 | isOpen={state.menuCustomers} 107 | sidebarIsOpen={open} 108 | name="pos.menu.customers" 109 | icon={<visitors.icon />} 110 | dense={dense} 111 | > 112 | <MenuItemLink 113 | to={`/customers`} 114 | primaryText={translate(`resources.customers.name`, { 115 | smart_count: 2, 116 | })} 117 | leftIcon={<visitors.icon />} 118 | onClick={onMenuClick} 119 | sidebarIsOpen={open} 120 | dense={dense} 121 | /> 122 | <MenuItemLink 123 | to={`/segments`} 124 | primaryText={translate(`resources.segments.name`, { 125 | smart_count: 2, 126 | })} 127 | leftIcon={<LabelIcon />} 128 | onClick={onMenuClick} 129 | sidebarIsOpen={open} 130 | dense={dense} 131 | /> 132 | </SubMenu> 133 | <MenuItemLink 134 | to={`/reviews`} 135 | primaryText={translate(`resources.reviews.name`, { 136 | smart_count: 2, 137 | })} 138 | leftIcon={<reviews.icon />} 139 | onClick={onMenuClick} 140 | sidebarIsOpen={open} 141 | dense={dense} 142 | /> 143 | {isXSmall && ( 144 | <MenuItemLink 145 | to="/configuration" 146 | primaryText={translate('pos.configuration')} 147 | leftIcon={<SettingsIcon />} 148 | onClick={onMenuClick} 149 | sidebarIsOpen={open} 150 | dense={dense} 151 | /> 152 | )} 153 | {isXSmall && logout} 154 | </div> 155 | ); 156 | }; 157 | 158 | export default Menu; 159 | -------------------------------------------------------------------------------- /src/layout/SubMenu.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC, Fragment, ReactElement } from 'react'; 2 | import ExpandMore from '@material-ui/icons/ExpandMore'; 3 | import List from '@material-ui/core/List'; 4 | import MenuItem from '@material-ui/core/MenuItem'; 5 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 6 | import Typography from '@material-ui/core/Typography'; 7 | import Divider from '@material-ui/core/Divider'; 8 | import Collapse from '@material-ui/core/Collapse'; 9 | import Tooltip from '@material-ui/core/Tooltip'; 10 | import { makeStyles } from '@material-ui/core/styles'; 11 | import { useTranslate } from 'react-admin'; 12 | 13 | const useStyles = makeStyles(theme => ({ 14 | icon: { minWidth: theme.spacing(5) }, 15 | sidebarIsOpen: { 16 | paddingLeft: 25, 17 | transition: 'padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms', 18 | }, 19 | sidebarIsClosed: { 20 | paddingLeft: 0, 21 | transition: 'padding-left 195ms cubic-bezier(0.4, 0, 0.6, 1) 0ms', 22 | }, 23 | })); 24 | 25 | interface Props { 26 | dense: boolean; 27 | handleToggle: () => void; 28 | icon: ReactElement; 29 | isOpen: boolean; 30 | name: string; 31 | sidebarIsOpen: boolean; 32 | } 33 | 34 | const SubMenu: FC<Props> = ({ 35 | handleToggle, 36 | sidebarIsOpen, 37 | isOpen, 38 | name, 39 | icon, 40 | children, 41 | dense, 42 | }) => { 43 | const translate = useTranslate(); 44 | const classes = useStyles(); 45 | 46 | const header = ( 47 | <MenuItem dense={dense} button onClick={handleToggle}> 48 | <ListItemIcon className={classes.icon}> 49 | {isOpen ? <ExpandMore /> : icon} 50 | </ListItemIcon> 51 | <Typography variant="inherit" color="textSecondary"> 52 | {translate(name)} 53 | </Typography> 54 | </MenuItem> 55 | ); 56 | 57 | return ( 58 | <Fragment> 59 | {sidebarIsOpen || isOpen ? ( 60 | header 61 | ) : ( 62 | <Tooltip title={translate(name)} placement="right"> 63 | {header} 64 | </Tooltip> 65 | )} 66 | <Collapse in={isOpen} timeout="auto" unmountOnExit> 67 | <List 68 | dense={dense} 69 | component="div" 70 | disablePadding 71 | className={ 72 | sidebarIsOpen 73 | ? classes.sidebarIsOpen 74 | : classes.sidebarIsClosed 75 | } 76 | > 77 | {children} 78 | </List> 79 | <Divider /> 80 | </Collapse> 81 | </Fragment> 82 | ); 83 | }; 84 | 85 | export default SubMenu; 86 | -------------------------------------------------------------------------------- /src/layout/index.ts: -------------------------------------------------------------------------------- 1 | import AppBar from './AppBar'; 2 | import Layout from './Layout'; 3 | import Login from './Login'; 4 | import Menu from './Menu'; 5 | 6 | export { AppBar, Layout, Login, Menu }; 7 | -------------------------------------------------------------------------------- /src/layout/themes.ts: -------------------------------------------------------------------------------- 1 | export const darkTheme = { 2 | palette: { 3 | type: 'dark', // Switching the dark mode on is a single property value change. 4 | }, 5 | }; 6 | 7 | export const lightTheme = { 8 | palette: { 9 | secondary: { 10 | light: '#5f5fc4', 11 | main: '#283593', 12 | dark: '#001064', 13 | contrastText: '#fff', 14 | }, 15 | }, 16 | overrides: { 17 | MuiFilledInput: { 18 | root: { 19 | backgroundColor: 'rgba(0, 0, 0, 0.04)', 20 | '&$disabled': { 21 | backgroundColor: 'rgba(0, 0, 0, 0.04)', 22 | }, 23 | }, 24 | }, 25 | }, 26 | }; 27 | -------------------------------------------------------------------------------- /src/orders/MobileGrid.js: -------------------------------------------------------------------------------- 1 | // in src/comments.js 2 | import React from 'react'; 3 | import Card from '@material-ui/core/Card'; 4 | import CardHeader from '@material-ui/core/CardHeader'; 5 | import CardContent from '@material-ui/core/CardContent'; 6 | import { makeStyles } from '@material-ui/core/styles'; 7 | import { 8 | DateField, 9 | EditButton, 10 | NumberField, 11 | TextField, 12 | BooleanField, 13 | useTranslate, 14 | } from 'react-admin'; 15 | 16 | import CustomerReferenceField from '../visitors/CustomerReferenceField'; 17 | 18 | const useListStyles = makeStyles(theme => ({ 19 | card: { 20 | height: '100%', 21 | display: 'flex', 22 | flexDirection: 'column', 23 | margin: '0.5rem 0', 24 | }, 25 | cardTitleContent: { 26 | display: 'flex', 27 | flexDirection: 'rows', 28 | alignItems: 'center', 29 | justifyContent: 'space-between', 30 | }, 31 | cardContent: theme.typography.body1, 32 | cardContentRow: { 33 | display: 'flex', 34 | flexDirection: 'rows', 35 | alignItems: 'center', 36 | margin: '0.5rem 0', 37 | }, 38 | })); 39 | 40 | const MobileGrid = ({ ids, data, basePath }) => { 41 | const translate = useTranslate(); 42 | const classes = useListStyles(); 43 | return ( 44 | <div style={{ margin: '1em' }}> 45 | {ids.map(id => ( 46 | <Card key={id} className={classes.card}> 47 | <CardHeader 48 | title={ 49 | <div className={classes.cardTitleContent}> 50 | <span> 51 | {translate('resources.commands.name', 1)} 52 | :  53 | <TextField 54 | record={data[id]} 55 | source="reference" 56 | /> 57 | </span> 58 | <EditButton 59 | resource="commands" 60 | basePath={basePath} 61 | record={data[id]} 62 | /> 63 | </div> 64 | } 65 | /> 66 | <CardContent className={classes.cardContent}> 67 | <span className={classes.cardContentRow}> 68 | {translate('resources.customers.name', 1)}:  69 | <CustomerReferenceField 70 | record={data[id]} 71 | basePath={basePath} 72 | /> 73 | </span> 74 | <span className={classes.cardContentRow}> 75 | {translate('resources.reviews.fields.date')}:  76 | <DateField 77 | record={data[id]} 78 | source="date" 79 | showTime 80 | /> 81 | </span> 82 | <span className={classes.cardContentRow}> 83 | {translate( 84 | 'resources.commands.fields.basket.total' 85 | )} 86 | :  87 | <NumberField 88 | record={data[id]} 89 | source="total" 90 | options={{ style: 'currency', currency: 'USD' }} 91 | className={classes.total} 92 | /> 93 | </span> 94 | <span className={classes.cardContentRow}> 95 | {translate('resources.commands.fields.status')} 96 | :  97 | <TextField source="status" record={data[id]} /> 98 | </span> 99 | <span className={classes.cardContentRow}> 100 | {translate('resources.commands.fields.returned')} 101 | :  102 | <BooleanField record={data[id]} source="returned" /> 103 | </span> 104 | </CardContent> 105 | </Card> 106 | ))} 107 | </div> 108 | ); 109 | }; 110 | 111 | MobileGrid.defaultProps = { 112 | data: {}, 113 | ids: [], 114 | }; 115 | 116 | export default MobileGrid; 117 | -------------------------------------------------------------------------------- /src/orders/NbItemsField.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { FunctionField } from 'react-admin'; 3 | 4 | const render = record => record.basket.length; 5 | 6 | const NbItemsField = props => <FunctionField {...props} render={render} />; 7 | 8 | NbItemsField.defaultProps = { 9 | label: 'Nb Items', 10 | textAlign: 'right', 11 | }; 12 | 13 | export default NbItemsField; 14 | -------------------------------------------------------------------------------- /src/orders/OrderEdit.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | AutocompleteInput, 4 | BooleanInput, 5 | DateInput, 6 | Edit, 7 | ReferenceInput, 8 | SelectInput, 9 | SimpleForm, 10 | useTranslate, 11 | } from 'react-admin'; 12 | import { makeStyles } from '@material-ui/core/styles'; 13 | 14 | import Basket from './Basket'; 15 | 16 | const OrderTitle = ({ record }) => { 17 | const translate = useTranslate(); 18 | return ( 19 | <span> 20 | {translate('resources.commands.title', { 21 | reference: record.reference, 22 | })} 23 | </span> 24 | ); 25 | }; 26 | 27 | const useEditStyles = makeStyles({ 28 | root: { alignItems: 'flex-start' }, 29 | }); 30 | 31 | const OrderEdit = props => { 32 | const classes = useEditStyles(); 33 | return ( 34 | <Edit 35 | title={<OrderTitle />} 36 | aside={<Basket />} 37 | classes={classes} 38 | {...props} 39 | > 40 | <SimpleForm> 41 | <DateInput source="date" /> 42 | <ReferenceInput source="customer_id" reference="customers"> 43 | <AutocompleteInput 44 | optionText={choice => 45 | `${choice.first_name} ${choice.last_name}` 46 | } 47 | /> 48 | </ReferenceInput> 49 | <SelectInput 50 | source="status" 51 | choices={[ 52 | { id: 'delivered', name: 'delivered' }, 53 | { id: 'ordered', name: 'ordered' }, 54 | { id: 'cancelled', name: 'cancelled' }, 55 | { 56 | id: 'unknown', 57 | name: 'unknown', 58 | disabled: true, 59 | }, 60 | ]} 61 | /> 62 | <BooleanInput source="returned" /> 63 | </SimpleForm> 64 | </Edit> 65 | ); 66 | }; 67 | 68 | export default OrderEdit; 69 | -------------------------------------------------------------------------------- /src/orders/index.js: -------------------------------------------------------------------------------- 1 | import OrderIcon from '@material-ui/icons/AttachMoney'; 2 | 3 | import OrderList from './OrderList'; 4 | import OrderEdit from './OrderEdit'; 5 | 6 | export default { 7 | list: OrderList, 8 | edit: OrderEdit, 9 | icon: OrderIcon, 10 | }; 11 | -------------------------------------------------------------------------------- /src/products/GridList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import MuiGridList from '@material-ui/core/GridList'; 3 | import GridListTile from '@material-ui/core/GridListTile'; 4 | import GridListTileBar from '@material-ui/core/GridListTileBar'; 5 | import { makeStyles } from '@material-ui/core/styles'; 6 | import withWidth from '@material-ui/core/withWidth'; 7 | import { Link } from 'react-router-dom'; 8 | import { NumberField } from 'react-admin'; 9 | import { linkToRecord } from 'ra-core'; 10 | 11 | const useStyles = makeStyles(theme => ({ 12 | root: { 13 | margin: '-2px', 14 | }, 15 | gridList: { 16 | width: '100%', 17 | margin: 0, 18 | }, 19 | tileBar: { 20 | background: 21 | 'linear-gradient(to top, rgba(0,0,0,0.8) 0%,rgba(0,0,0,0.4) 70%,rgba(0,0,0,0) 100%)', 22 | }, 23 | placeholder: { 24 | backgroundColor: theme.palette.grey[300], 25 | height: '100%', 26 | }, 27 | price: { 28 | display: 'inline', 29 | fontSize: '1em', 30 | }, 31 | link: { 32 | color: '#fff', 33 | }, 34 | })); 35 | 36 | const getColsForWidth = width => { 37 | if (width === 'xs') return 2; 38 | if (width === 'sm') return 3; 39 | if (width === 'md') return 4; 40 | if (width === 'lg') return 5; 41 | return 6; 42 | }; 43 | 44 | const times = (nbChildren, fn) => 45 | Array.from({ length: nbChildren }, (_, key) => fn(key)); 46 | 47 | const LoadingGridList = ({ width, nbItems = 10 }) => { 48 | const classes = useStyles(); 49 | return ( 50 | <div className={classes.root}> 51 | <MuiGridList 52 | cellHeight={180} 53 | cols={getColsForWidth(width)} 54 | className={classes.gridList} 55 | > 56 | {' '} 57 | {times(nbItems, key => ( 58 | <GridListTile key={key}> 59 | <div className={classes.placeholder} /> 60 | </GridListTile> 61 | ))} 62 | </MuiGridList> 63 | </div> 64 | ); 65 | }; 66 | 67 | const LoadedGridList = ({ ids, data, basePath, width }) => { 68 | const classes = useStyles(); 69 | return ( 70 | <div className={classes.root}> 71 | <MuiGridList 72 | cellHeight={180} 73 | cols={getColsForWidth(width)} 74 | className={classes.gridList} 75 | > 76 | {ids.map(id => ( 77 | <GridListTile 78 | component={Link} 79 | key={id} 80 | to={linkToRecord(basePath, data[id].id)} 81 | > 82 | <img src={data[id].thumbnail} alt="" /> 83 | <GridListTileBar 84 | className={classes.tileBar} 85 | title={data[id].reference} 86 | subtitle={ 87 | <span> 88 | {data[id].width}x{data[id].height},{' '} 89 | <NumberField 90 | className={classes.price} 91 | source="price" 92 | record={data[id]} 93 | color="inherit" 94 | options={{ 95 | style: 'currency', 96 | currency: 'USD', 97 | }} 98 | /> 99 | </span> 100 | } 101 | /> 102 | </GridListTile> 103 | ))} 104 | </MuiGridList> 105 | </div> 106 | ); 107 | }; 108 | 109 | const GridList = ({ loaded, ...props }) => 110 | loaded ? <LoadedGridList {...props} /> : <LoadingGridList {...props} />; 111 | 112 | export default withWidth()(GridList); 113 | -------------------------------------------------------------------------------- /src/products/Poster.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Card from '@material-ui/core/Card'; 3 | import CardContent from '@material-ui/core/CardContent'; 4 | import { makeStyles } from '@material-ui/core/styles'; 5 | 6 | const useStyles = makeStyles({ 7 | root: { display: 'inline-block', marginTop: '1em', zIndex: 2 }, 8 | content: { padding: 0, '&:last-child': { padding: 0 } }, 9 | img: { 10 | width: 'initial', 11 | minWidth: 'initial', 12 | maxWidth: '42em', 13 | maxHeight: '15em', 14 | }, 15 | }); 16 | 17 | const Poster = ({ record }) => { 18 | const classes = useStyles(); 19 | return ( 20 | <Card className={classes.root}> 21 | <CardContent className={classes.content}> 22 | <img src={record.image} alt="" className={classes.img} /> 23 | </CardContent> 24 | </Card> 25 | ); 26 | }; 27 | 28 | export default Poster; 29 | -------------------------------------------------------------------------------- /src/products/ProductCreate.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Create, 4 | FormTab, 5 | NumberInput, 6 | ReferenceInput, 7 | SelectInput, 8 | TabbedForm, 9 | TextInput, 10 | required, 11 | } from 'react-admin'; 12 | import { InputAdornment } from '@material-ui/core'; 13 | import { makeStyles } from '@material-ui/core/styles'; 14 | import RichTextInput from 'ra-input-rich-text'; 15 | 16 | export const styles = { 17 | price: { width: '7em' }, 18 | width: { width: '7em' }, 19 | height: { width: '7em' }, 20 | stock: { width: '7em' }, 21 | widthFormGroup: { display: 'inline-block' }, 22 | heightFormGroup: { display: 'inline-block', marginLeft: 32 }, 23 | }; 24 | 25 | const useStyles = makeStyles(styles); 26 | 27 | const ProductCreate = props => { 28 | const classes = useStyles(); 29 | return ( 30 | <Create {...props}> 31 | <TabbedForm> 32 | <FormTab label="resources.products.tabs.image"> 33 | <TextInput 34 | autoFocus 35 | source="image" 36 | fullWidth 37 | validate={required()} 38 | /> 39 | <TextInput 40 | source="thumbnail" 41 | fullWidth 42 | validate={required()} 43 | /> 44 | </FormTab> 45 | <FormTab label="resources.products.tabs.details" path="details"> 46 | <TextInput source="reference" validate={required()} /> 47 | <NumberInput 48 | source="price" 49 | validate={required()} 50 | className={classes.price} 51 | InputProps={{ 52 | startAdornment: ( 53 | <InputAdornment position="start"> 54 | € 55 | </InputAdornment> 56 | ), 57 | }} 58 | /> 59 | <NumberInput 60 | source="width" 61 | validate={required()} 62 | className={classes.width} 63 | formClassName={classes.widthFormGroup} 64 | InputProps={{ 65 | endAdornment: ( 66 | <InputAdornment position="start"> 67 | cm 68 | </InputAdornment> 69 | ), 70 | }} 71 | /> 72 | <NumberInput 73 | source="height" 74 | validate={required()} 75 | className={classes.height} 76 | formClassName={classes.heightFormGroup} 77 | InputProps={{ 78 | endAdornment: ( 79 | <InputAdornment position="start"> 80 | cm 81 | </InputAdornment> 82 | ), 83 | }} 84 | /> 85 | <ReferenceInput 86 | source="category_id" 87 | reference="categories" 88 | allowEmpty 89 | > 90 | <SelectInput source="name" /> 91 | </ReferenceInput> 92 | <NumberInput 93 | source="stock" 94 | validate={required()} 95 | className={classes.stock} 96 | /> 97 | </FormTab> 98 | <FormTab 99 | label="resources.products.tabs.description" 100 | path="description" 101 | > 102 | <RichTextInput source="description" label="" /> 103 | </FormTab> 104 | </TabbedForm> 105 | </Create> 106 | ); 107 | }; 108 | 109 | export default ProductCreate; 110 | -------------------------------------------------------------------------------- /src/products/ProductEdit.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Datagrid, 4 | DateField, 5 | Edit, 6 | EditButton, 7 | FormTab, 8 | NumberInput, 9 | Pagination, 10 | ReferenceInput, 11 | ReferenceManyField, 12 | SelectInput, 13 | TabbedForm, 14 | TextField, 15 | TextInput, 16 | } from 'react-admin'; 17 | import { InputAdornment } from '@material-ui/core'; 18 | import { makeStyles } from '@material-ui/core/styles'; 19 | import RichTextInput from 'ra-input-rich-text'; 20 | 21 | import CustomerReferenceField from '../visitors/CustomerReferenceField'; 22 | import StarRatingField from '../reviews/StarRatingField'; 23 | import Poster from './Poster'; 24 | import { styles as createStyles } from './ProductCreate'; 25 | 26 | const ProductTitle = ({ record }) => <span>Poster #{record.reference}</span>; 27 | 28 | const styles = { 29 | ...createStyles, 30 | comment: { 31 | maxWidth: '20em', 32 | overflow: 'hidden', 33 | textOverflow: 'ellipsis', 34 | whiteSpace: 'nowrap', 35 | }, 36 | }; 37 | 38 | const useStyles = makeStyles(styles); 39 | 40 | const ProductEdit = props => { 41 | const classes = useStyles(); 42 | return ( 43 | <Edit {...props} title={<ProductTitle />}> 44 | <TabbedForm> 45 | <FormTab label="resources.products.tabs.image"> 46 | <Poster /> 47 | <TextInput source="image" fullWidth /> 48 | <TextInput source="thumbnail" fullWidth /> 49 | </FormTab> 50 | <FormTab label="resources.products.tabs.details" path="details"> 51 | <TextInput source="reference" /> 52 | <NumberInput 53 | source="price" 54 | className={classes.price} 55 | InputProps={{ 56 | startAdornment: ( 57 | <InputAdornment position="start"> 58 | € 59 | </InputAdornment> 60 | ), 61 | }} 62 | /> 63 | <NumberInput 64 | source="width" 65 | className={classes.width} 66 | formClassName={classes.widthFormGroup} 67 | InputProps={{ 68 | endAdornment: ( 69 | <InputAdornment position="start"> 70 | cm 71 | </InputAdornment> 72 | ), 73 | }} 74 | /> 75 | <NumberInput 76 | source="height" 77 | className={classes.height} 78 | formClassName={classes.heightFormGroup} 79 | InputProps={{ 80 | endAdornment: ( 81 | <InputAdornment position="start"> 82 | cm 83 | </InputAdornment> 84 | ), 85 | }} 86 | /> 87 | <ReferenceInput source="category_id" reference="categories"> 88 | <SelectInput source="name" /> 89 | </ReferenceInput> 90 | <NumberInput source="stock" className={classes.stock} /> 91 | </FormTab> 92 | <FormTab 93 | label="resources.products.tabs.description" 94 | path="description" 95 | > 96 | <RichTextInput source="description" label="" /> 97 | </FormTab> 98 | <FormTab label="resources.products.tabs.reviews" path="reviews"> 99 | <ReferenceManyField 100 | reference="reviews" 101 | target="product_id" 102 | addLabel={false} 103 | pagination={<Pagination />} 104 | fullWidth 105 | > 106 | <Datagrid> 107 | <DateField source="date" /> 108 | <CustomerReferenceField /> 109 | <StarRatingField /> 110 | <TextField 111 | source="comment" 112 | cellClassName={classes.comment} 113 | /> 114 | <TextField source="status" /> 115 | <EditButton /> 116 | </Datagrid> 117 | </ReferenceManyField> 118 | </FormTab> 119 | </TabbedForm> 120 | </Edit> 121 | ); 122 | }; 123 | 124 | export default ProductEdit; 125 | -------------------------------------------------------------------------------- /src/products/ProductList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Filter, 4 | List, 5 | NumberInput, 6 | ReferenceInput, 7 | SearchInput, 8 | SelectInput, 9 | useTranslate, 10 | } from 'react-admin'; 11 | import Chip from '@material-ui/core/Chip'; 12 | import { makeStyles } from '@material-ui/core/styles'; 13 | import GridList from './GridList'; 14 | 15 | const useQuickFilterStyles = makeStyles(theme => ({ 16 | root: { 17 | marginBottom: theme.spacing(3), 18 | }, 19 | })); 20 | 21 | const QuickFilter = ({ label }) => { 22 | const translate = useTranslate(); 23 | const classes = useQuickFilterStyles(); 24 | return <Chip className={classes.root} label={translate(label)} />; 25 | }; 26 | 27 | export const ProductFilter = props => ( 28 | <Filter {...props}> 29 | <SearchInput source="q" alwaysOn /> 30 | <ReferenceInput 31 | source="category_id" 32 | reference="categories" 33 | sort={{ field: 'id', order: 'ASC' }} 34 | > 35 | <SelectInput source="name" /> 36 | </ReferenceInput> 37 | <NumberInput source="width_gte" /> 38 | <NumberInput source="width_lte" /> 39 | <NumberInput source="height_gte" /> 40 | <NumberInput source="height_lte" /> 41 | <QuickFilter 42 | label="resources.products.fields.stock_lte" 43 | source="stock_lte" 44 | defaultValue={10} 45 | /> 46 | </Filter> 47 | ); 48 | 49 | const ProductList = props => ( 50 | <List 51 | {...props} 52 | filters={<ProductFilter />} 53 | perPage={20} 54 | sort={{ field: 'id', order: 'ASC' }} 55 | > 56 | <GridList /> 57 | </List> 58 | ); 59 | 60 | export default ProductList; 61 | -------------------------------------------------------------------------------- /src/products/ProductRefField.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { FieldProps, Product } from '../types'; 4 | 5 | const ProductRefField: FC<FieldProps<Product>> = ({ record }) => 6 | record ? ( 7 | <Link to={`products/${record.id}`}>{record.reference}</Link> 8 | ) : null; 9 | 10 | ProductRefField.defaultProps = { 11 | source: 'id', 12 | label: 'Reference', 13 | }; 14 | 15 | export default ProductRefField; 16 | -------------------------------------------------------------------------------- /src/products/ProductReferenceField.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ReferenceField, TextField } from 'react-admin'; 3 | 4 | const ProductReferenceField = props => ( 5 | <ReferenceField 6 | label="Product" 7 | source="product_id" 8 | reference="products" 9 | {...props} 10 | > 11 | <TextField source="reference" /> 12 | </ReferenceField> 13 | ); 14 | 15 | ProductReferenceField.defaultProps = { 16 | source: 'product_id', 17 | addLabel: true, 18 | }; 19 | 20 | export default ProductReferenceField; 21 | -------------------------------------------------------------------------------- /src/products/ThumbnailField.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import { FieldProps, Product } from '../types'; 4 | 5 | const useStyles = makeStyles({ 6 | root: { width: 25, maxWidth: 25, maxHeight: 25 }, 7 | }); 8 | 9 | const ThumbnailField: FC<FieldProps<Product>> = ({ record }) => { 10 | const classes = useStyles(); 11 | return record ? ( 12 | <img src={record.thumbnail} className={classes.root} alt="" /> 13 | ) : null; 14 | }; 15 | 16 | export default ThumbnailField; 17 | -------------------------------------------------------------------------------- /src/products/index.js: -------------------------------------------------------------------------------- 1 | import ProductIcon from '@material-ui/icons/Collections'; 2 | import ProductList from './ProductList'; 3 | import ProductEdit from './ProductEdit'; 4 | import ProductCreate from './ProductCreate'; 5 | 6 | export default { 7 | list: ProductList, 8 | create: ProductCreate, 9 | edit: ProductEdit, 10 | icon: ProductIcon, 11 | }; 12 | -------------------------------------------------------------------------------- /src/react-admin.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'react-admin'; 2 | -------------------------------------------------------------------------------- /src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// <reference types="react-scripts" /> 2 | -------------------------------------------------------------------------------- /src/reviews/AcceptButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Button from '@material-ui/core/Button'; 4 | import ThumbUp from '@material-ui/icons/ThumbUp'; 5 | import { useTranslate, useUpdate, useNotify, useRedirect } from 'react-admin'; 6 | 7 | /** 8 | * This custom button demonstrate using useUpdate to update data 9 | */ 10 | const AcceptButton = ({ record }) => { 11 | const translate = useTranslate(); 12 | const notify = useNotify(); 13 | const redirectTo = useRedirect(); 14 | 15 | const [approve, { loading }] = useUpdate( 16 | 'reviews', 17 | record.id, 18 | { status: 'accepted' }, 19 | record, 20 | { 21 | undoable: true, 22 | onSuccess: () => { 23 | notify( 24 | 'resources.reviews.notification.approved_success', 25 | 'info', 26 | {}, 27 | true 28 | ); 29 | redirectTo('/reviews'); 30 | }, 31 | onFailure: () => { 32 | notify( 33 | 'resources.reviews.notification.approved_error', 34 | 'warning' 35 | ); 36 | }, 37 | } 38 | ); 39 | return record && record.status === 'pending' ? ( 40 | <Button 41 | variant="outlined" 42 | color="primary" 43 | size="small" 44 | onClick={approve} 45 | disabled={loading} 46 | > 47 | <ThumbUp 48 | color="primary" 49 | style={{ paddingRight: '0.5em', color: 'green' }} 50 | /> 51 | {translate('resources.reviews.action.accept')} 52 | </Button> 53 | ) : ( 54 | <span /> 55 | ); 56 | }; 57 | 58 | AcceptButton.propTypes = { 59 | record: PropTypes.object, 60 | }; 61 | 62 | export default AcceptButton; 63 | -------------------------------------------------------------------------------- /src/reviews/BulkAcceptButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ThumbUp from '@material-ui/icons/ThumbUp'; 4 | import { 5 | Button, 6 | useUpdateMany, 7 | useNotify, 8 | useRedirect, 9 | useUnselectAll, 10 | CRUD_UPDATE_MANY, 11 | } from 'react-admin'; 12 | 13 | const BulkAcceptButton = ({ selectedIds }) => { 14 | const notify = useNotify(); 15 | const redirectTo = useRedirect(); 16 | const unselectAll = useUnselectAll('reviews'); 17 | 18 | const [approve, { loading }] = useUpdateMany( 19 | 'reviews', 20 | selectedIds, 21 | { status: 'accepted' }, 22 | { 23 | action: CRUD_UPDATE_MANY, 24 | undoable: true, 25 | onSuccess: () => { 26 | notify( 27 | 'resources.reviews.notification.approved_success', 28 | 'info', 29 | {}, 30 | true 31 | ); 32 | redirectTo('/reviews'); 33 | unselectAll(); 34 | }, 35 | onFailure: () => { 36 | notify( 37 | 'resources.reviews.notification.approved_error', 38 | 'warning' 39 | ); 40 | }, 41 | } 42 | ); 43 | 44 | return ( 45 | <Button 46 | label="resources.reviews.action.accept" 47 | onClick={approve} 48 | disabled={loading} 49 | > 50 | <ThumbUp /> 51 | </Button> 52 | ); 53 | }; 54 | 55 | BulkAcceptButton.propTypes = { 56 | selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired, 57 | }; 58 | 59 | export default BulkAcceptButton; 60 | -------------------------------------------------------------------------------- /src/reviews/BulkRejectButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import ThumbDown from '@material-ui/icons/ThumbDown'; 4 | import { 5 | Button, 6 | useUpdateMany, 7 | useNotify, 8 | useRedirect, 9 | useUnselectAll, 10 | CRUD_UPDATE_MANY, 11 | } from 'react-admin'; 12 | 13 | const BulkRejectButton = ({ selectedIds }) => { 14 | const notify = useNotify(); 15 | const redirectTo = useRedirect(); 16 | const unselectAll = useUnselectAll('reviews'); 17 | 18 | const [reject, { loading }] = useUpdateMany( 19 | 'reviews', 20 | selectedIds, 21 | { status: 'rejected' }, 22 | { 23 | action: CRUD_UPDATE_MANY, 24 | undoable: true, 25 | onSuccess: () => { 26 | notify( 27 | 'resources.reviews.notification.approved_success', 28 | 'info', 29 | {}, 30 | true 31 | ); 32 | redirectTo('/reviews'); 33 | unselectAll(); 34 | }, 35 | onFailure: () => { 36 | notify( 37 | 'resources.reviews.notification.approved_error', 38 | 'warning' 39 | ); 40 | }, 41 | } 42 | ); 43 | 44 | return ( 45 | <Button 46 | label="resources.reviews.action.reject" 47 | onClick={reject} 48 | disabled={loading} 49 | > 50 | <ThumbDown /> 51 | </Button> 52 | ); 53 | }; 54 | 55 | BulkRejectButton.propTypes = { 56 | selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired, 57 | }; 58 | 59 | export default BulkRejectButton; 60 | -------------------------------------------------------------------------------- /src/reviews/RejectButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import Button from '@material-ui/core/Button'; 4 | import ThumbDown from '@material-ui/icons/ThumbDown'; 5 | import { useTranslate, useUpdate, useNotify, useRedirect } from 'react-admin'; 6 | 7 | /** 8 | * This custom button demonstrate using a custom action to update data 9 | */ 10 | const RejectButton = ({ record }) => { 11 | const translate = useTranslate(); 12 | const notify = useNotify(); 13 | const redirectTo = useRedirect(); 14 | 15 | const [reject, { loading }] = useUpdate( 16 | 'reviews', 17 | record.id, 18 | { status: 'rejected' }, 19 | record, 20 | { 21 | undoable: true, 22 | onSuccess: () => { 23 | notify( 24 | 'resources.reviews.notification.rejected_success', 25 | 'info', 26 | {}, 27 | true 28 | ); 29 | redirectTo('/reviews'); 30 | }, 31 | onFailure: () => { 32 | notify( 33 | 'resources.reviews.notification.rejected_error', 34 | 'warning' 35 | ); 36 | }, 37 | } 38 | ); 39 | 40 | return record && record.status === 'pending' ? ( 41 | <Button 42 | variant="outlined" 43 | color="primary" 44 | size="small" 45 | onClick={reject} 46 | disabled={loading} 47 | > 48 | <ThumbDown 49 | color="primary" 50 | style={{ paddingRight: '0.5em', color: 'red' }} 51 | /> 52 | {translate('resources.reviews.action.reject')} 53 | </Button> 54 | ) : ( 55 | <span /> 56 | ); 57 | }; 58 | 59 | RejectButton.propTypes = { 60 | record: PropTypes.object, 61 | }; 62 | 63 | export default RejectButton; 64 | -------------------------------------------------------------------------------- /src/reviews/ReviewEdit.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | useEditController, 4 | useTranslate, 5 | TextInput, 6 | SimpleForm, 7 | DateField, 8 | } from 'react-admin'; 9 | import IconButton from '@material-ui/core/IconButton'; 10 | import Typography from '@material-ui/core/Typography'; 11 | import { makeStyles } from '@material-ui/core/styles'; 12 | import CloseIcon from '@material-ui/icons/Close'; 13 | 14 | import ProductReferenceField from '../products/ProductReferenceField'; 15 | import CustomerReferenceField from '../visitors/CustomerReferenceField'; 16 | import StarRatingField from './StarRatingField'; 17 | import ReviewEditToolbar from './ReviewEditToolbar'; 18 | 19 | const useStyles = makeStyles(theme => ({ 20 | root: { 21 | paddingTop: 40, 22 | }, 23 | title: { 24 | display: 'flex', 25 | alignItems: 'center', 26 | justifyContent: 'space-between', 27 | margin: '1em', 28 | }, 29 | form: { 30 | [theme.breakpoints.up('xs')]: { 31 | width: 400, 32 | }, 33 | [theme.breakpoints.down('xs')]: { 34 | width: '100vw', 35 | marginTop: -30, 36 | }, 37 | }, 38 | inlineField: { 39 | display: 'inline-block', 40 | width: '50%', 41 | }, 42 | })); 43 | 44 | const ReviewEdit = ({ onCancel, ...props }) => { 45 | const classes = useStyles(); 46 | const controllerProps = useEditController(props); 47 | const translate = useTranslate(); 48 | if (!controllerProps.record) { 49 | return null; 50 | } 51 | return ( 52 | <div className={classes.root}> 53 | <div className={classes.title}> 54 | <Typography variant="h6"> 55 | {translate('resources.reviews.detail')} 56 | </Typography> 57 | <IconButton onClick={onCancel}> 58 | <CloseIcon /> 59 | </IconButton> 60 | </div> 61 | <SimpleForm 62 | className={classes.form} 63 | basePath={controllerProps.basePath} 64 | record={controllerProps.record} 65 | save={controllerProps.save} 66 | version={controllerProps.version} 67 | redirect="list" 68 | resource="reviews" 69 | toolbar={<ReviewEditToolbar />} 70 | > 71 | <CustomerReferenceField formClassName={classes.inlineField} /> 72 | 73 | <ProductReferenceField formClassName={classes.inlineField} /> 74 | <DateField source="date" formClassName={classes.inlineField} /> 75 | <StarRatingField formClassName={classes.inlineField} /> 76 | <TextInput source="comment" rowsMax={15} multiline fullWidth /> 77 | </SimpleForm> 78 | </div> 79 | ); 80 | }; 81 | 82 | export default ReviewEdit; 83 | -------------------------------------------------------------------------------- /src/reviews/ReviewEditToolbar.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import MuiToolbar from '@material-ui/core/Toolbar'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | 5 | import { SaveButton, DeleteButton } from 'react-admin'; 6 | import AcceptButton from './AcceptButton'; 7 | import RejectButton from './RejectButton'; 8 | 9 | const useStyles = makeStyles({ 10 | root: { 11 | backgroundColor: 'white', 12 | display: 'flex', 13 | justifyContent: 'space-between', 14 | }, 15 | }); 16 | 17 | const ReviewEditToolbar = ({ 18 | basePath, 19 | handleSubmitWithRedirect, 20 | invalid, 21 | record, 22 | resource, 23 | saving, 24 | }) => { 25 | const classes = useStyles(); 26 | return ( 27 | <MuiToolbar className={classes.root}> 28 | {record.status === 'pending' ? ( 29 | <Fragment> 30 | <AcceptButton record={record} /> 31 | <RejectButton record={record} /> 32 | </Fragment> 33 | ) : ( 34 | <Fragment> 35 | <SaveButton 36 | handleSubmitWithRedirect={handleSubmitWithRedirect} 37 | invalid={invalid} 38 | saving={saving} 39 | redirect="list" 40 | submitOnEnter={true} 41 | /> 42 | <DeleteButton 43 | basePath={basePath} 44 | record={record} 45 | resource={resource} 46 | /> 47 | </Fragment> 48 | )} 49 | </MuiToolbar> 50 | ); 51 | }; 52 | 53 | export default ReviewEditToolbar; 54 | -------------------------------------------------------------------------------- /src/reviews/ReviewFilter.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | AutocompleteInput, 4 | DateInput, 5 | Filter, 6 | ReferenceInput, 7 | SearchInput, 8 | SelectInput, 9 | } from 'react-admin'; 10 | import { makeStyles } from '@material-ui/core/styles'; 11 | 12 | const useFilterStyles = makeStyles({ 13 | status: { width: 150 }, 14 | }); 15 | 16 | const ReviewFilter = props => { 17 | const classes = useFilterStyles(); 18 | return ( 19 | <Filter {...props}> 20 | <SearchInput source="q" alwaysOn /> 21 | <SelectInput 22 | source="status" 23 | choices={[ 24 | { id: 'accepted', name: 'Accepted' }, 25 | { id: 'pending', name: 'Pending' }, 26 | { id: 'rejected', name: 'Rejected' }, 27 | ]} 28 | className={classes.status} 29 | /> 30 | <ReferenceInput source="customer_id" reference="customers"> 31 | <AutocompleteInput 32 | optionText={choice => 33 | `${choice.first_name} ${choice.last_name}` 34 | } 35 | /> 36 | </ReferenceInput> 37 | <ReferenceInput source="product_id" reference="products"> 38 | <AutocompleteInput optionText="reference" /> 39 | </ReferenceInput> 40 | <DateInput source="date_gte" /> 41 | <DateInput source="date_lte" /> 42 | </Filter> 43 | ); 44 | }; 45 | 46 | export default ReviewFilter; 47 | -------------------------------------------------------------------------------- /src/reviews/ReviewList.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useCallback } from 'react'; 2 | import classnames from 'classnames'; 3 | import { BulkDeleteButton, List } from 'react-admin'; 4 | import { Route, useHistory } from 'react-router-dom'; 5 | import { Drawer, useMediaQuery, makeStyles } from '@material-ui/core'; 6 | import BulkAcceptButton from './BulkAcceptButton'; 7 | import BulkRejectButton from './BulkRejectButton'; 8 | import ReviewListMobile from './ReviewListMobile'; 9 | import ReviewListDesktop from './ReviewListDesktop'; 10 | import ReviewFilter from './ReviewFilter'; 11 | import ReviewEdit from './ReviewEdit'; 12 | 13 | const ReviewsBulkActionButtons = props => ( 14 | <Fragment> 15 | <BulkAcceptButton {...props} /> 16 | <BulkRejectButton {...props} /> 17 | <BulkDeleteButton {...props} /> 18 | </Fragment> 19 | ); 20 | 21 | const useStyles = makeStyles(theme => ({ 22 | root: { 23 | display: 'flex', 24 | }, 25 | list: { 26 | flexGrow: 1, 27 | transition: theme.transitions.create(['all'], { 28 | duration: theme.transitions.duration.enteringScreen, 29 | }), 30 | marginRight: 0, 31 | }, 32 | listWithDrawer: { 33 | marginRight: 400, 34 | }, 35 | drawerPaper: { 36 | zIndex: 100, 37 | }, 38 | })); 39 | 40 | const ReviewList = props => { 41 | const classes = useStyles(); 42 | const isXSmall = useMediaQuery(theme => theme.breakpoints.down('xs')); 43 | const history = useHistory(); 44 | 45 | const handleClose = useCallback(() => { 46 | history.push('/reviews'); 47 | }, [history]); 48 | 49 | return ( 50 | <div className={classes.root}> 51 | <Route path="/reviews/:id"> 52 | {({ match }) => { 53 | const isMatch = !!( 54 | match && 55 | match.params && 56 | match.params.id !== 'create' 57 | ); 58 | 59 | return ( 60 | <Fragment> 61 | <List 62 | {...props} 63 | className={classnames(classes.list, { 64 | [classes.listWithDrawer]: isMatch, 65 | })} 66 | bulkActionButtons={<ReviewsBulkActionButtons />} 67 | filters={<ReviewFilter />} 68 | perPage={25} 69 | sort={{ field: 'date', order: 'DESC' }} 70 | > 71 | {isXSmall ? ( 72 | <ReviewListMobile /> 73 | ) : ( 74 | <ReviewListDesktop /> 75 | )} 76 | </List> 77 | <Drawer 78 | variant="persistent" 79 | open={isMatch} 80 | anchor="right" 81 | onClose={handleClose} 82 | classes={{ 83 | paper: classes.drawerPaper, 84 | }} 85 | > 86 | {/* To avoid any errors if the route does not match, we don't render at all the component in this case */} 87 | {isMatch ? ( 88 | <ReviewEdit 89 | id={match.params.id} 90 | onCancel={handleClose} 91 | {...props} 92 | /> 93 | ) : null} 94 | </Drawer> 95 | </Fragment> 96 | ); 97 | }} 98 | </Route> 99 | </div> 100 | ); 101 | }; 102 | 103 | export default ReviewList; 104 | -------------------------------------------------------------------------------- /src/reviews/ReviewListDesktop.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Datagrid, DateField, TextField } from 'react-admin'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | 5 | import ProductReferenceField from '../products/ProductReferenceField'; 6 | import CustomerReferenceField from '../visitors/CustomerReferenceField'; 7 | import StarRatingField from './StarRatingField'; 8 | 9 | import rowStyle from './rowStyle'; 10 | 11 | const useListStyles = makeStyles({ 12 | headerRow: { 13 | borderLeftColor: 'white', 14 | borderLeftWidth: 5, 15 | borderLeftStyle: 'solid', 16 | }, 17 | headerCell: { 18 | padding: '6px 8px 6px 8px', 19 | }, 20 | rowCell: { 21 | padding: '6px 8px 6px 8px', 22 | }, 23 | comment: { 24 | maxWidth: '18em', 25 | overflow: 'hidden', 26 | textOverflow: 'ellipsis', 27 | whiteSpace: 'nowrap', 28 | }, 29 | }); 30 | 31 | const ReviewListDesktop = props => { 32 | const classes = useListStyles(); 33 | return ( 34 | <Datagrid 35 | rowClick="edit" 36 | rowStyle={rowStyle} 37 | classes={{ 38 | headerRow: classes.headerRow, 39 | headerCell: classes.headerCell, 40 | rowCell: classes.rowCell, 41 | }} 42 | optimized 43 | {...props} 44 | > 45 | <DateField source="date" /> 46 | <CustomerReferenceField link={false} /> 47 | <ProductReferenceField link={false} /> 48 | <StarRatingField size="small" /> 49 | <TextField source="comment" cellClassName={classes.comment} /> 50 | <TextField source="status" /> 51 | </Datagrid> 52 | ); 53 | }; 54 | 55 | export default ReviewListDesktop; 56 | -------------------------------------------------------------------------------- /src/reviews/ReviewListMobile.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import List from '@material-ui/core/List'; 4 | import ListItem from '@material-ui/core/ListItem'; 5 | import ListItemAvatar from '@material-ui/core/ListItemAvatar'; 6 | import ListItemText from '@material-ui/core/ListItemText'; 7 | import { makeStyles } from '@material-ui/core/styles'; 8 | import { Link } from 'react-router-dom'; 9 | import { 10 | linkToRecord, 11 | ReferenceField, 12 | FunctionField, 13 | TextField, 14 | } from 'react-admin'; 15 | 16 | import AvatarField from '../visitors/AvatarField'; 17 | 18 | const useStyles = makeStyles({ 19 | root: { 20 | width: '100vw', 21 | }, 22 | link: { 23 | textDecoration: 'none', 24 | color: 'inherit', 25 | }, 26 | inline: { 27 | display: 'inline', 28 | }, 29 | }); 30 | 31 | const ReviewMobileList = ({ basePath, data, ids, loading, total }) => { 32 | const classes = useStyles(); 33 | return ( 34 | (loading || total > 0) && ( 35 | <List className={classes.root}> 36 | {ids.map(id => ( 37 | <Link 38 | to={linkToRecord(basePath, id)} 39 | className={classes.link} 40 | key={id} 41 | > 42 | <ListItem button> 43 | <ListItemAvatar> 44 | <ReferenceField 45 | record={data[id]} 46 | source="customer_id" 47 | reference="customers" 48 | basePath={basePath} 49 | linkType={false} 50 | > 51 | <AvatarField size={40} /> 52 | </ReferenceField> 53 | </ListItemAvatar> 54 | <ListItemText 55 | primary={ 56 | <Fragment> 57 | <ReferenceField 58 | record={data[id]} 59 | source="customer_id" 60 | reference="customers" 61 | basePath={basePath} 62 | linkType={false} 63 | > 64 | <FunctionField 65 | render={record => 66 | `${record.first_name} ${ 67 | record.last_name 68 | }` 69 | } 70 | variant="subtitle1" 71 | className={classes.inline} 72 | /> 73 | </ReferenceField>{' '} 74 | on{' '} 75 | <ReferenceField 76 | record={data[id]} 77 | source="product_id" 78 | reference="products" 79 | basePath={basePath} 80 | linkType={false} 81 | > 82 | <TextField 83 | source="reference" 84 | variant="subtitle1" 85 | className={classes.inline} 86 | /> 87 | </ReferenceField> 88 | </Fragment> 89 | } 90 | secondary={data[id].comment} 91 | secondaryTypographyProps={{ noWrap: true }} 92 | /> 93 | </ListItem> 94 | </Link> 95 | ))} 96 | </List> 97 | ) 98 | ); 99 | }; 100 | 101 | ReviewMobileList.propTypes = { 102 | basePath: PropTypes.string, 103 | data: PropTypes.object, 104 | hasBulkActions: PropTypes.bool.isRequired, 105 | ids: PropTypes.array, 106 | leftAvatar: PropTypes.func, 107 | leftIcon: PropTypes.func, 108 | linkType: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]) 109 | .isRequired, 110 | onToggleItem: PropTypes.func, 111 | primaryText: PropTypes.func, 112 | rightAvatar: PropTypes.func, 113 | rightIcon: PropTypes.func, 114 | secondaryText: PropTypes.func, 115 | selectedIds: PropTypes.arrayOf(PropTypes.any).isRequired, 116 | tertiaryText: PropTypes.func, 117 | }; 118 | 119 | ReviewMobileList.defaultProps = { 120 | linkType: 'edit', 121 | hasBulkActions: false, 122 | selectedIds: [], 123 | }; 124 | 125 | export default ReviewMobileList; 126 | -------------------------------------------------------------------------------- /src/reviews/StarRatingField.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import Icon from '@material-ui/icons/Stars'; 3 | import { makeStyles } from '@material-ui/core'; 4 | 5 | import { FieldProps } from '../types'; 6 | 7 | const useStyles = makeStyles({ 8 | root: { 9 | opacity: 0.87, 10 | whiteSpace: 'nowrap', 11 | }, 12 | large: { 13 | width: 20, 14 | height: 20, 15 | }, 16 | small: { 17 | width: 15, 18 | haight: 15, 19 | }, 20 | }); 21 | 22 | interface OwnProps { 23 | size: 'large' | 'small'; 24 | } 25 | 26 | const StarRatingField: FC<FieldProps & OwnProps> = ({ 27 | record, 28 | size = 'large', 29 | }) => { 30 | const classes = useStyles(); 31 | return record ? ( 32 | <span> 33 | {Array(record.rating) 34 | .fill(true) 35 | .map((_, i) => ( 36 | <Icon 37 | key={i} 38 | className={ 39 | size === 'large' ? classes.large : classes.small 40 | } 41 | /> 42 | ))} 43 | </span> 44 | ) : null; 45 | }; 46 | 47 | StarRatingField.defaultProps = { 48 | label: 'resources.reviews.fields.rating', 49 | source: 'rating', 50 | addLabel: true, 51 | }; 52 | 53 | export default StarRatingField; 54 | -------------------------------------------------------------------------------- /src/reviews/index.js: -------------------------------------------------------------------------------- 1 | import ReviewIcon from '@material-ui/icons/Comment'; 2 | import ReviewList from './ReviewList'; 3 | 4 | export default { 5 | icon: ReviewIcon, 6 | list: ReviewList, 7 | }; 8 | -------------------------------------------------------------------------------- /src/reviews/rowStyle.js: -------------------------------------------------------------------------------- 1 | import green from '@material-ui/core/colors/green'; 2 | import orange from '@material-ui/core/colors/orange'; 3 | import red from '@material-ui/core/colors/red'; 4 | 5 | const rowStyle = (record, index, defaultStyle = {}) => { 6 | if (record.status === 'accepted') 7 | return { 8 | ...defaultStyle, 9 | borderLeftColor: green[500], 10 | borderLeftWidth: 5, 11 | borderLeftStyle: 'solid', 12 | }; 13 | if (record.status === 'pending') 14 | return { 15 | ...defaultStyle, 16 | borderLeftColor: orange[500], 17 | borderLeftWidth: 5, 18 | borderLeftStyle: 'solid', 19 | }; 20 | if (record.status === 'rejected') 21 | return { 22 | ...defaultStyle, 23 | borderLeftColor: red[500], 24 | borderLeftWidth: 5, 25 | borderLeftStyle: 'solid', 26 | }; 27 | return defaultStyle; 28 | }; 29 | 30 | export default rowStyle; 31 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Route } from 'react-router-dom'; 3 | import Configuration from './configuration/Configuration'; 4 | import Segments from './segments/Segments'; 5 | 6 | export default [ 7 | <Route exact path="/configuration" render={() => <Configuration />} />, 8 | <Route exact path="/segments" render={() => <Segments />} />, 9 | ]; 10 | -------------------------------------------------------------------------------- /src/segments/LinkToRelatedCustomers.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Button from '@material-ui/core/Button'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import { Link } from 'react-router-dom'; 5 | import { useTranslate } from 'react-admin'; 6 | import { stringify } from 'query-string'; 7 | 8 | import visitors from '../visitors'; 9 | 10 | const useStyles = makeStyles({ 11 | icon: { paddingRight: '0.5em' }, 12 | link: { 13 | display: 'inline-flex', 14 | alignItems: 'center', 15 | }, 16 | }); 17 | 18 | const LinkToRelatedCustomers = ({ segment }) => { 19 | const translate = useTranslate(); 20 | const classes = useStyles(); 21 | return ( 22 | <Button 23 | size="small" 24 | color="primary" 25 | component={Link} 26 | to={{ 27 | pathname: '/customers', 28 | search: stringify({ 29 | page: 1, 30 | perPage: 25, 31 | filter: JSON.stringify({ groups: segment }), 32 | }), 33 | }} 34 | className={classes.link} 35 | > 36 | <visitors.icon className={classes.icon} /> 37 | {translate('resources.segments.fields.customers')} 38 | </Button> 39 | ); 40 | }; 41 | 42 | export default LinkToRelatedCustomers; 43 | -------------------------------------------------------------------------------- /src/segments/Segments.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Card from '@material-ui/core/Card'; 3 | import Table from '@material-ui/core/Table'; 4 | import TableBody from '@material-ui/core/TableBody'; 5 | import TableCell from '@material-ui/core/TableCell'; 6 | import TableHead from '@material-ui/core/TableHead'; 7 | import TableRow from '@material-ui/core/TableRow'; 8 | import { makeStyles } from '@material-ui/core/styles'; 9 | import { useTranslate, Title } from 'react-admin'; 10 | 11 | import LinkToRelatedCustomers from './LinkToRelatedCustomers'; 12 | import segments from './data'; 13 | 14 | const useStyles = makeStyles({ 15 | root: { 16 | marginTop: 16, 17 | }, 18 | }); 19 | 20 | const Segments = () => { 21 | const translate = useTranslate(); 22 | const classes = useStyles(); 23 | return ( 24 | <Card className={classes.root}> 25 | <Title title={translate('resources.segments.name')} /> 26 | <Table size="small"> 27 | <TableHead> 28 | <TableRow> 29 | <TableCell> 30 | {translate('resources.segments.fields.name')} 31 | </TableCell> 32 | <TableCell /> 33 | </TableRow> 34 | </TableHead> 35 | <TableBody> 36 | {segments.map(segment => ( 37 | <TableRow key={segment.id}> 38 | <TableCell>{translate(segment.name)}</TableCell> 39 | <TableCell> 40 | <LinkToRelatedCustomers segment={segment.id} /> 41 | </TableCell> 42 | </TableRow> 43 | ))} 44 | </TableBody> 45 | </Table> 46 | </Card> 47 | ); 48 | }; 49 | 50 | export default Segments; 51 | -------------------------------------------------------------------------------- /src/segments/data.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { id: 'compulsive', name: 'resources.segments.data.compulsive' }, 3 | { id: 'collector', name: 'resources.segments.data.collector' }, 4 | { id: 'ordered_once', name: 'resources.segments.data.ordered_once' }, 5 | { id: 'regular', name: 'resources.segments.data.regular' }, 6 | { id: 'returns', name: 'resources.segments.data.returns' }, 7 | { id: 'reviewer', name: 'resources.segments.data.reviewer' }, 8 | ]; 9 | -------------------------------------------------------------------------------- /src/themeReducer.js: -------------------------------------------------------------------------------- 1 | import { CHANGE_THEME } from './configuration/actions'; 2 | 3 | export default (previousState = 'light', { type, payload }) => { 4 | if (type === CHANGE_THEME) { 5 | return payload; 6 | } 7 | return previousState; 8 | }; 9 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { ReduxState, Record, Identifier } from 'ra-core'; 2 | 3 | export type ThemeName = 'light' | 'dark'; 4 | 5 | export interface AppState extends ReduxState { 6 | theme: ThemeName; 7 | } 8 | 9 | export interface Category extends Record { 10 | name: string; 11 | } 12 | 13 | export interface Product extends Record { 14 | category_id: Identifier; 15 | description: string; 16 | height: number; 17 | image: string; 18 | price: number; 19 | reference: string; 20 | stock: number; 21 | thumbnail: string; 22 | width: number; 23 | } 24 | 25 | export interface Customer extends Record { 26 | first_name: string; 27 | last_name: string; 28 | address: string; 29 | city: string; 30 | zipcode: string; 31 | avatar: string; 32 | birthday: string; 33 | first_seen: string; 34 | last_seen: string; 35 | has_ordered: boolean; 36 | latest_purchase: string; 37 | has_newsletter: boolean; 38 | groups: string[]; 39 | nb_commands: number; 40 | total_spent: number; 41 | } 42 | 43 | export interface Order extends Record { 44 | basket: BasketItem[]; 45 | } 46 | 47 | export interface BasketItem { 48 | product_id: string; 49 | quantity: number; 50 | } 51 | 52 | /** 53 | * Types to eventually add in react-admin 54 | */ 55 | export interface FieldProps<T extends Record = Record> { 56 | addLabel?: boolean; 57 | label?: string; 58 | record?: T; 59 | source?: string; 60 | } 61 | -------------------------------------------------------------------------------- /src/visitors/AddressField.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { FieldProps, Customer } from '../types'; 3 | 4 | const AddressField: FC<FieldProps<Customer>> = ({ record }) => 5 | record ? ( 6 | <span> 7 | {record.address}, {record.city} {record.zipcode} 8 | </span> 9 | ) : null; 10 | 11 | export default AddressField; 12 | -------------------------------------------------------------------------------- /src/visitors/AvatarField.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import Avatar from '@material-ui/core/Avatar'; 3 | import { Customer, FieldProps } from '../types'; 4 | 5 | interface Props extends FieldProps<Customer> { 6 | className?: string; 7 | size?: string; 8 | } 9 | const AvatarField: FC<Props> = ({ record, size, className }) => 10 | record ? ( 11 | <Avatar 12 | src={`${record.avatar}?size=${size}x${size}`} 13 | sizes={size} 14 | style={{ width: size, height: size }} 15 | className={className} 16 | /> 17 | ) : null; 18 | 19 | AvatarField.defaultProps = { 20 | size: '25', 21 | }; 22 | 23 | export default AvatarField; 24 | -------------------------------------------------------------------------------- /src/visitors/ColoredNumberField.tsx: -------------------------------------------------------------------------------- 1 | import React, { ComponentType } from 'react'; 2 | import { NumberField } from 'react-admin'; 3 | import { FieldProps } from '../types'; 4 | 5 | const colored = <T extends FieldProps>( 6 | WrappedComponent: ComponentType<T> 7 | ): ComponentType<T> => { 8 | const Colored = (props: T) => 9 | props.record && props.source ? ( 10 | props.record[props.source] > 500 ? ( 11 | <span style={{ color: 'red' }}> 12 | <WrappedComponent {...props} /> 13 | </span> 14 | ) : ( 15 | <WrappedComponent {...props} /> 16 | ) 17 | ) : null; 18 | 19 | Colored.displayName = `Colored(${WrappedComponent.displayName})`; 20 | 21 | return Colored; 22 | }; 23 | 24 | const ColoredNumberField = colored<typeof NumberField>(NumberField); 25 | ColoredNumberField.defaultProps = NumberField.defaultProps; 26 | 27 | export default ColoredNumberField; 28 | -------------------------------------------------------------------------------- /src/visitors/CustomerLinkField.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { Link } from 'react-admin'; 3 | 4 | import FullNameField from './FullNameField'; 5 | import { FieldProps, Customer } from '../types'; 6 | 7 | const CustomerLinkField: FC<FieldProps<Customer>> = props => 8 | props.record ? ( 9 | <Link to={`/customers/${props.record.id}`}> 10 | <FullNameField {...props} /> 11 | </Link> 12 | ) : null; 13 | 14 | CustomerLinkField.defaultProps = { 15 | source: 'customer_id', 16 | addLabel: true, 17 | }; 18 | 19 | export default CustomerLinkField; 20 | -------------------------------------------------------------------------------- /src/visitors/CustomerReferenceField.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { ReferenceField } from 'react-admin'; 3 | 4 | import FullNameField from './FullNameField'; 5 | import { FieldProps } from '../types'; 6 | 7 | const CustomerReferenceField: FC<FieldProps> = props => ( 8 | <ReferenceField source="customer_id" reference="customers" {...props}> 9 | <FullNameField /> 10 | </ReferenceField> 11 | ); 12 | 13 | CustomerReferenceField.defaultProps = { 14 | source: 'customer_id', 15 | addLabel: true, 16 | }; 17 | 18 | export default CustomerReferenceField; 19 | -------------------------------------------------------------------------------- /src/visitors/FullNameField.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { makeStyles } from '@material-ui/core/styles'; 3 | import pure from 'recompose/pure'; 4 | 5 | import AvatarField from './AvatarField'; 6 | import { FieldProps, Customer } from '../types'; 7 | 8 | const useStyles = makeStyles(theme => ({ 9 | root: { 10 | display: 'flex', 11 | flexWrap: 'nowrap', 12 | alignItems: 'center', 13 | }, 14 | avatar: { 15 | marginRight: theme.spacing(1), 16 | }, 17 | })); 18 | 19 | interface Props extends FieldProps<Customer> { 20 | size?: string; 21 | } 22 | 23 | const FullNameField: FC<Props> = ({ record, size }) => { 24 | const classes = useStyles(); 25 | return record ? ( 26 | <div className={classes.root}> 27 | <AvatarField 28 | className={classes.avatar} 29 | record={record} 30 | size={size} 31 | /> 32 | {record.first_name} {record.last_name} 33 | </div> 34 | ) : null; 35 | }; 36 | 37 | const PureFullNameField = pure(FullNameField); 38 | 39 | PureFullNameField.defaultProps = { 40 | source: 'last_name', 41 | label: 'resources.customers.fields.name', 42 | }; 43 | 44 | export default PureFullNameField; 45 | -------------------------------------------------------------------------------- /src/visitors/MobileGrid.tsx: -------------------------------------------------------------------------------- 1 | // in src/comments.js 2 | import React from 'react'; 3 | import Card from '@material-ui/core/Card'; 4 | import CardContent from '@material-ui/core/CardContent'; 5 | import CardHeader from '@material-ui/core/CardHeader'; 6 | import { makeStyles } from '@material-ui/core/styles'; 7 | 8 | import { DateField, EditButton, useTranslate, NumberField } from 'react-admin'; 9 | 10 | import AvatarField from './AvatarField'; 11 | import ColoredNumberField from './ColoredNumberField'; 12 | import SegmentsField from './SegmentsField'; 13 | import { Identifier } from 'ra-core'; 14 | import { Customer } from '../types'; 15 | import { FC } from 'react'; 16 | 17 | const useStyles = makeStyles(theme => ({ 18 | root: { margin: '1em' }, 19 | card: { 20 | height: '100%', 21 | display: 'flex', 22 | flexDirection: 'column', 23 | margin: '0.5rem 0', 24 | }, 25 | cardTitleContent: { 26 | display: 'flex', 27 | flexDirection: 'row', 28 | alignItems: 'center', 29 | justifyContent: 'space-between', 30 | }, 31 | cardContent: { 32 | ...theme.typography.body1, 33 | display: 'flex', 34 | flexDirection: 'column', 35 | }, 36 | })); 37 | 38 | interface Props { 39 | ids?: Identifier[]; 40 | data?: { [key: string]: Customer }; 41 | basePath?: string; 42 | } 43 | 44 | const MobileGrid: FC<Props> = ({ ids, data, basePath }) => { 45 | const translate = useTranslate(); 46 | const classes = useStyles(); 47 | 48 | if (!ids || !data) { 49 | return null; 50 | } 51 | 52 | return ( 53 | <div className={classes.root}> 54 | {ids.map(id => ( 55 | <Card key={id} className={classes.card}> 56 | <CardHeader 57 | title={ 58 | <div className={classes.cardTitleContent}> 59 | <h2>{`${data[id].first_name} ${ 60 | data[id].last_name 61 | }`}</h2> 62 | <EditButton 63 | resource="visitors" 64 | basePath={basePath} 65 | record={data[id]} 66 | /> 67 | </div> 68 | } 69 | avatar={<AvatarField record={data[id]} size="45" />} 70 | /> 71 | <CardContent className={classes.cardContent}> 72 | <div> 73 | {translate( 74 | 'resources.customers.fields.last_seen_gte' 75 | )} 76 |   77 | <DateField 78 | record={data[id]} 79 | source="last_seen" 80 | type="date" 81 | /> 82 | </div> 83 | <div> 84 | {translate( 85 | 'resources.commands.name', 86 | data[id].nb_commands || 1 87 | )} 88 |  :  89 | <NumberField 90 | record={data[id]} 91 | source="nb_commands" 92 | label="resources.customers.fields.commands" 93 | /> 94 | </div> 95 | <div> 96 | {translate( 97 | 'resources.customers.fields.total_spent' 98 | )} 99 |   :{' '} 100 | <ColoredNumberField 101 | record={data[id]} 102 | source="total_spent" 103 | options={{ style: 'currency', currency: 'USD' }} 104 | /> 105 | </div> 106 | </CardContent> 107 | {data[id].groups && data[id].groups.length > 0 && ( 108 | <CardContent className={classes.cardContent}> 109 | <SegmentsField record={data[id]} /> 110 | </CardContent> 111 | )} 112 | </Card> 113 | ))} 114 | </div> 115 | ); 116 | }; 117 | 118 | MobileGrid.defaultProps = { 119 | data: {}, 120 | ids: [], 121 | }; 122 | 123 | export default MobileGrid; 124 | -------------------------------------------------------------------------------- /src/visitors/SegmentInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { useTranslate, SelectInput } from 'react-admin'; 3 | import { makeStyles } from '@material-ui/core/styles'; 4 | import { InputProps } from 'ra-core'; 5 | 6 | import segments from '../segments/data'; 7 | 8 | const useStyles = makeStyles({ 9 | input: { width: 150 }, 10 | }); 11 | 12 | interface Props extends Omit<InputProps, 'source'> { 13 | source?: string; 14 | } 15 | 16 | const SegmentInput: FC<Props> = props => { 17 | const translate = useTranslate(); 18 | const classes = useStyles(); 19 | return ( 20 | <SelectInput 21 | {...props} 22 | choices={segments.map(segment => ({ 23 | id: segment.id, 24 | name: translate(segment.name), 25 | }))} 26 | className={classes.input} 27 | /> 28 | ); 29 | }; 30 | 31 | SegmentInput.defaultProps = { 32 | source: 'groups', 33 | }; 34 | 35 | export default SegmentInput; 36 | -------------------------------------------------------------------------------- /src/visitors/SegmentsField.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import Chip from '@material-ui/core/Chip'; 3 | import { useTranslate } from 'react-admin'; 4 | import segments from '../segments/data'; 5 | import { FieldProps, Customer } from '../types'; 6 | import { makeStyles } from '@material-ui/core'; 7 | 8 | const useStyles = makeStyles({ 9 | main: { 10 | display: 'flex', 11 | flexWrap: 'wrap', 12 | marginTop: -8, 13 | marginBottom: -8, 14 | }, 15 | chip: { margin: 4 }, 16 | }); 17 | 18 | const SegmentsField: FC<FieldProps<Customer>> = ({ record }) => { 19 | const translate = useTranslate(); 20 | const classes = useStyles(); 21 | 22 | return record ? ( 23 | <span className={classes.main}> 24 | {record.groups && 25 | record.groups.map(segmentId => { 26 | const segment = segments.find(s => s.id === segmentId); 27 | 28 | return segment ? ( 29 | <Chip 30 | key={segment.id} 31 | className={classes.chip} 32 | label={translate(segment.name)} 33 | /> 34 | ) : null; 35 | })} 36 | </span> 37 | ) : null; 38 | }; 39 | 40 | SegmentsField.defaultProps = { 41 | addLabel: true, 42 | source: 'groups', 43 | }; 44 | 45 | export default SegmentsField; 46 | -------------------------------------------------------------------------------- /src/visitors/SegmentsInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { FC } from 'react'; 2 | import { useTranslate, SelectArrayInput } from 'react-admin'; 3 | import { InputProps } from 'ra-core'; 4 | 5 | import segments from '../segments/data'; 6 | 7 | interface Props extends Omit<InputProps, 'source'> { 8 | source?: string; 9 | } 10 | 11 | const SegmentsInput: FC<Props> = ({ addField, ...rest }) => { 12 | const translate = useTranslate(); 13 | return ( 14 | <SelectArrayInput 15 | {...rest} 16 | choices={segments.map(segment => ({ 17 | id: segment.id, 18 | name: translate(segment.name), 19 | }))} 20 | /> 21 | ); 22 | }; 23 | 24 | SegmentsInput.defaultProps = { 25 | addField: true, 26 | source: 'groups', 27 | resource: 'customers', 28 | }; 29 | 30 | export default SegmentsInput; 31 | -------------------------------------------------------------------------------- /src/visitors/VisitorCreate.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | Create, 4 | DateInput, 5 | SimpleForm, 6 | TextInput, 7 | useTranslate, 8 | PasswordInput, 9 | required, 10 | } from 'react-admin'; 11 | import { Typography, Box } from '@material-ui/core'; 12 | import { makeStyles, Theme } from '@material-ui/core/styles'; 13 | import { Styles } from '@material-ui/styles/withStyles'; 14 | 15 | export const styles: Styles<Theme, any> = { 16 | first_name: { display: 'inline-block' }, 17 | last_name: { display: 'inline-block', marginLeft: 32 }, 18 | email: { width: 544 }, 19 | address: { maxWidth: 544 }, 20 | zipcode: { display: 'inline-block' }, 21 | city: { display: 'inline-block', marginLeft: 32 }, 22 | comment: { 23 | maxWidth: '20em', 24 | overflow: 'hidden', 25 | textOverflow: 'ellipsis', 26 | whiteSpace: 'nowrap', 27 | }, 28 | password: { display: 'inline-block' }, 29 | confirm_password: { display: 'inline-block', marginLeft: 32 }, 30 | }; 31 | 32 | const useStyles = makeStyles(styles); 33 | 34 | export const validatePasswords = ({ 35 | password, 36 | confirm_password, 37 | }: { 38 | password: string; 39 | confirm_password: string; 40 | }) => { 41 | const errors = {} as any; 42 | 43 | if (password && confirm_password && password !== confirm_password) { 44 | errors.confirm_password = [ 45 | 'resources.customers.errors.password_mismatch', 46 | ]; 47 | } 48 | 49 | return errors; 50 | }; 51 | 52 | const VisitorCreate = (props: any) => { 53 | const classes = useStyles(); 54 | 55 | return ( 56 | <Create {...props}> 57 | <SimpleForm validate={validatePasswords}> 58 | <SectionTitle label="resources.customers.fieldGroups.identity" /> 59 | <TextInput 60 | autoFocus 61 | source="first_name" 62 | formClassName={classes.first_name} 63 | validate={requiredValidate} 64 | /> 65 | <TextInput 66 | source="last_name" 67 | formClassName={classes.last_name} 68 | validate={requiredValidate} 69 | /> 70 | <TextInput 71 | type="email" 72 | source="email" 73 | validation={{ email: true }} 74 | fullWidth={true} 75 | formClassName={classes.email} 76 | validate={requiredValidate} 77 | /> 78 | <DateInput source="birthday" /> 79 | <Separator /> 80 | <SectionTitle label="resources.customers.fieldGroups.address" /> 81 | <TextInput 82 | source="address" 83 | formClassName={classes.address} 84 | multiline={true} 85 | fullWidth={true} 86 | /> 87 | <TextInput source="zipcode" formClassName={classes.zipcode} /> 88 | <TextInput source="city" formClassName={classes.city} /> 89 | <Separator /> 90 | <SectionTitle label="resources.customers.fieldGroups.password" /> 91 | <PasswordInput 92 | source="password" 93 | formClassName={classes.password} 94 | /> 95 | <PasswordInput 96 | source="confirm_password" 97 | formClassName={classes.confirm_password} 98 | /> 99 | </SimpleForm> 100 | </Create> 101 | ); 102 | }; 103 | 104 | const requiredValidate = [required()]; 105 | 106 | const SectionTitle = ({ label }: { label: string }) => { 107 | const translate = useTranslate(); 108 | 109 | return ( 110 | <Typography variant="h6" gutterBottom> 111 | {translate(label)} 112 | </Typography> 113 | ); 114 | }; 115 | 116 | const Separator = () => <Box pt="1em" />; 117 | 118 | export default VisitorCreate; 119 | -------------------------------------------------------------------------------- /src/visitors/VisitorList.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { 3 | BooleanField, 4 | Datagrid, 5 | DateField, 6 | DateInput, 7 | Filter, 8 | List, 9 | NullableBooleanInput, 10 | NumberField, 11 | SearchInput, 12 | } from 'react-admin'; 13 | import { useMediaQuery, makeStyles, Theme } from '@material-ui/core'; 14 | 15 | import SegmentsField from './SegmentsField'; 16 | import SegmentInput from './SegmentInput'; 17 | import CustomerLinkField from './CustomerLinkField'; 18 | import ColoredNumberField from './ColoredNumberField'; 19 | import MobileGrid from './MobileGrid'; 20 | 21 | const VisitorFilter = (props: any) => ( 22 | <Filter {...props}> 23 | <SearchInput source="q" alwaysOn /> 24 | <DateInput source="last_seen_gte" /> 25 | <NullableBooleanInput source="has_ordered" /> 26 | <NullableBooleanInput source="has_newsletter" defaultValue /> 27 | <SegmentInput /> 28 | </Filter> 29 | ); 30 | 31 | const useStyles = makeStyles({ 32 | nb_commands: { color: 'purple' }, 33 | }); 34 | 35 | const VisitorList = (props: any) => { 36 | const classes = useStyles(); 37 | const isXsmall = useMediaQuery<Theme>(theme => 38 | theme.breakpoints.down('xs') 39 | ); 40 | return ( 41 | <List 42 | {...props} 43 | filters={<VisitorFilter />} 44 | sort={{ field: 'last_seen', order: 'DESC' }} 45 | perPage={25} 46 | > 47 | {isXsmall ? ( 48 | <MobileGrid /> 49 | ) : ( 50 | <Datagrid optimized rowClick="edit"> 51 | <CustomerLinkField /> 52 | <DateField source="last_seen" type="date" /> 53 | <NumberField 54 | source="nb_commands" 55 | label="resources.customers.fields.commands" 56 | className={classes.nb_commands} 57 | /> 58 | <ColoredNumberField 59 | source="total_spent" 60 | options={{ style: 'currency', currency: 'USD' }} 61 | /> 62 | <DateField source="latest_purchase" showTime /> 63 | <BooleanField source="has_newsletter" label="News." /> 64 | <SegmentsField /> 65 | </Datagrid> 66 | )} 67 | </List> 68 | ); 69 | }; 70 | 71 | export default VisitorList; 72 | -------------------------------------------------------------------------------- /src/visitors/index.ts: -------------------------------------------------------------------------------- 1 | import VisitorIcon from '@material-ui/icons/People'; 2 | 3 | import VisitorList from './VisitorList'; 4 | import VisitorCreate from './VisitorCreate'; 5 | import VisitorEdit from './VisitorEdit'; 6 | 7 | export default { 8 | list: VisitorList, 9 | create: VisitorCreate, 10 | edit: VisitorEdit, 11 | icon: VisitorIcon, 12 | }; 13 | -------------------------------------------------------------------------------- /src/visitors/segments.ts: -------------------------------------------------------------------------------- 1 | const segments = [ 2 | 'compulsive', 3 | 'collector', 4 | 'ordered_once', 5 | 'regular', 6 | 'returns', 7 | 'reviewer', 8 | ]; 9 | 10 | function capitalizeFirstLetter(string: string) { 11 | return string.charAt(0).toUpperCase() + string.slice(1); 12 | } 13 | 14 | export default segments.map(segment => ({ 15 | id: segment, 16 | name: capitalizeFirstLetter(segment), 17 | })); 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "module": "esnext", 16 | "moduleResolution": "node", 17 | "resolveJsonModule": true, 18 | "isolatedModules": true, 19 | "noEmit": true, 20 | "jsx": "react" 21 | }, 22 | "include": [ 23 | "src" 24 | ] 25 | } 26 | --------------------------------------------------------------------------------