From afa734f96d3f8f239fbda404fb1941b3e7523422 Mon Sep 17 00:00:00 2001 From: William Petit Date: Mon, 17 Feb 2020 22:28:57 +0100 Subject: [PATCH] Base du projet 'application ticketing' --- Makefile | 5 +- README.md | 24 +- backend/.env | 4 + backend/composer.json | 4 + backend/composer.lock | 268 +++++++++++++++++- backend/config/bundles.php | 3 + backend/config/packages/nelmio_cors.yaml | 10 + backend/config/packages/security.yaml | 19 +- .../packages/sensio_framework_extra.yaml | 3 + backend/config/routes.yaml | 30 +- backend/src/Controller/ApiController.php | 33 --- backend/src/Controller/IndexController.php | 20 ++ backend/src/Controller/RequestController.php | 18 ++ backend/src/Controller/SecurityController.php | 24 ++ backend/src/Controller/UserController.php | 72 +++++ backend/src/DataFixtures/AppFixtures.php | 17 ++ .../DataFixtures/RequestStatusFixtures.php | 29 ++ backend/src/DataFixtures/UserFixtures.php | 55 ++++ backend/src/Entity/Comment.php | 93 ++++++ backend/src/Entity/Project.php | 115 ++++++++ backend/src/Entity/Request.php | 154 ++++++++++ backend/src/Entity/RequestStatus.php | 83 ++++++ backend/src/Entity/User.php | 222 +++++++++++++++ backend/src/Http/DataResponse.php | 16 ++ backend/src/Http/ErrorResponse.php | 22 ++ .../src/Migrations/Version20200217203938.php | 57 ++++ .../src/Migrations/Version20200217211954.php | 39 +++ backend/src/Repository/CommentRepository.php | 50 ++++ backend/src/Repository/ProjectRepository.php | 50 ++++ backend/src/Repository/RequestRepository.php | 50 ++++ .../Repository/RequestStatusRepository.php | 50 ++++ backend/src/Repository/UserRepository.php | 39 +++ backend/symfony.lock | 39 +++ docker-compose.yml | 3 + frontend/src/actions/.gitkeep | 0 frontend/src/actions/chat.js | 20 -- frontend/src/actions/login.js | 7 - frontend/src/actions/products.js | 11 - frontend/src/app.js | 12 +- frontend/src/components/.gitkeep | 0 .../__snapshots__/counter.test.js.snap | 21 -- frontend/src/components/clock.js | 32 --- frontend/src/components/counter.js | 33 --- frontend/src/components/counter.test.js | 26 -- frontend/src/components/myform.js | 41 --- frontend/src/index.html | 2 +- frontend/src/index.js | 1 + frontend/src/pages/chat.jsx | 82 ------ frontend/src/pages/home.js | 15 + frontend/src/pages/login.jsx | 53 ---- frontend/src/pages/page.js | 20 +- frontend/src/reducers/.gitkeep | 0 frontend/src/reducers/chat.js | 35 --- frontend/src/reducers/login.js | 16 -- frontend/src/reducers/root.js | 31 -- frontend/src/sagas/chat.js | 98 ------- frontend/src/sagas/login.js | 34 --- frontend/src/sagas/root.js | 9 +- frontend/src/store/store.js | 7 +- frontend/src/store/store.test.js | 39 --- misc/containers/backend/docker-entrypoint.sh | 3 + misc/projects/ticketing_app.http | 44 +++ misc/projects/ticketing_app.md | 71 +++++ misc/projects/ticketing_app.pdf | Bin 0 -> 35662 bytes 64 files changed, 1798 insertions(+), 685 deletions(-) create mode 100644 backend/config/packages/nelmio_cors.yaml create mode 100644 backend/config/packages/sensio_framework_extra.yaml delete mode 100644 backend/src/Controller/ApiController.php create mode 100644 backend/src/Controller/IndexController.php create mode 100644 backend/src/Controller/RequestController.php create mode 100644 backend/src/Controller/SecurityController.php create mode 100644 backend/src/Controller/UserController.php create mode 100644 backend/src/DataFixtures/AppFixtures.php create mode 100644 backend/src/DataFixtures/RequestStatusFixtures.php create mode 100644 backend/src/DataFixtures/UserFixtures.php create mode 100644 backend/src/Entity/Comment.php create mode 100644 backend/src/Entity/Project.php create mode 100644 backend/src/Entity/Request.php create mode 100644 backend/src/Entity/RequestStatus.php create mode 100644 backend/src/Entity/User.php create mode 100644 backend/src/Http/DataResponse.php create mode 100644 backend/src/Http/ErrorResponse.php create mode 100644 backend/src/Migrations/Version20200217203938.php create mode 100644 backend/src/Migrations/Version20200217211954.php create mode 100644 backend/src/Repository/CommentRepository.php create mode 100644 backend/src/Repository/ProjectRepository.php create mode 100644 backend/src/Repository/RequestRepository.php create mode 100644 backend/src/Repository/RequestStatusRepository.php create mode 100644 backend/src/Repository/UserRepository.php create mode 100644 frontend/src/actions/.gitkeep delete mode 100644 frontend/src/actions/chat.js delete mode 100644 frontend/src/actions/login.js delete mode 100644 frontend/src/actions/products.js create mode 100644 frontend/src/components/.gitkeep delete mode 100644 frontend/src/components/__snapshots__/counter.test.js.snap delete mode 100644 frontend/src/components/clock.js delete mode 100644 frontend/src/components/counter.js delete mode 100644 frontend/src/components/counter.test.js delete mode 100644 frontend/src/components/myform.js delete mode 100644 frontend/src/pages/chat.jsx create mode 100644 frontend/src/pages/home.js delete mode 100644 frontend/src/pages/login.jsx create mode 100644 frontend/src/reducers/.gitkeep delete mode 100644 frontend/src/reducers/chat.js delete mode 100644 frontend/src/reducers/login.js delete mode 100644 frontend/src/reducers/root.js delete mode 100644 frontend/src/sagas/chat.js delete mode 100644 frontend/src/sagas/login.js delete mode 100644 frontend/src/store/store.test.js create mode 100644 misc/projects/ticketing_app.http create mode 100644 misc/projects/ticketing_app.md create mode 100644 misc/projects/ticketing_app.pdf diff --git a/Makefile b/Makefile index 43a071c..a4e7cdd 100644 --- a/Makefile +++ b/Makefile @@ -8,4 +8,7 @@ backend-shell: docker-compose exec backend /bin/bash frontend-shell: - docker-compose exec frontend /bin/bash \ No newline at end of file + docker-compose exec frontend /bin/bash + +database-mysql: + docker-compose exec database mysql --default-character-set=utf8 -uroot -proot logo \ No newline at end of file diff --git a/README.md b/README.md index e13277c..a9de07c 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ Squelette applicatif React/Symfony 4 pour la formation React personnalisée Logo - [Docker](https://docs.docker.com/install/) - [Docker Compose](https://docs.docker.com/compose/install/) -``` +```bash git clone https://forge.cadoles.com/wpetit/react-logo.git cd react-logo -docker-compose up +make up # ou docker-compose up --build ``` Une fois la procédure d'initialisation terminée, les différentes parties de l'application devraient être disponibles aux adresses suivantes: @@ -28,28 +28,32 @@ Une fois la procédure d'initialisation terminée, les différentes parties de l Une fois l'application lancée, exécuter: -``` -docker-compose exec backend /bin/bash +```bash +make backend-shell # ou docker-compose exec backend /bin/bash ``` #### Comment ouvrir un shell interactif dans le conteneur "frontend" ? Une fois l'application lancée, exécuter: -``` -docker-compose exec frontend /bin/bash +```bash +make frontend-shell # ou docker-compose exec frontend /bin/bash ``` #### Comment ouvrir une console MySQL ? Une fois l'application lancée, exécuter: -``` -docker-compose exec database mysql -uroot -proot +```bash +make database-mysql # ou docker-compose exec database mysql -uroot -proot logo ``` ### Comment réinitialiser l'environnement ? +```bash +make down # ou docker-compose down -v ``` -docker-compose down -v -``` + +## Cahier des charges + +- [Application de suivi des demandes clients](./misc/projects/ticketing_app.md) diff --git a/backend/.env b/backend/.env index 762e995..323147f 100644 --- a/backend/.env +++ b/backend/.env @@ -27,3 +27,7 @@ APP_SECRET=bc3856916a3206bf0b23356f46c5cf7d # IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name?serverVersion=5.7 ###< doctrine/doctrine-bundle ### + +###> nelmio/cors-bundle ### +CORS_ALLOW_ORIGIN=^https?://(localhost|127\.0\.0\.1)(:[0-9]+)?$ +###< nelmio/cors-bundle ### diff --git a/backend/composer.json b/backend/composer.json index d872882..5d6f65b 100644 --- a/backend/composer.json +++ b/backend/composer.json @@ -5,15 +5,19 @@ "php": "^7.1.3", "ext-ctype": "*", "ext-iconv": "*", + "nelmio/cors-bundle": "^2.0", + "sensio/framework-extra-bundle": "^5.5", "symfony/console": "4.4.*", "symfony/dotenv": "4.4.*", "symfony/flex": "^1.3.1", "symfony/framework-bundle": "4.4.*", + "symfony/inflector": "4.4.*", "symfony/orm-pack": "^1.0", "symfony/security-bundle": "4.4.*", "symfony/yaml": "4.4.*" }, "require-dev": { + "doctrine/doctrine-fixtures-bundle": "^3.3", "symfony/maker-bundle": "^1.14", "symfony/web-server-bundle": "4.4.*" }, diff --git a/backend/composer.lock b/backend/composer.lock index f4c0ac9..a311122 100644 --- a/backend/composer.lock +++ b/backend/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "6421c1affbb7f7322a6499bde9164df1", + "content-hash": "b8a24c5421258bb04d461c7dd0498820", "packages": [ { "name": "doctrine/annotations", @@ -1369,6 +1369,63 @@ ], "time": "2020-01-07T22:58:31+00:00" }, + { + "name": "nelmio/cors-bundle", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/nelmio/NelmioCorsBundle.git", + "reference": "9683e6d30d000ef998919261329d825de7c53499" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/nelmio/NelmioCorsBundle/zipball/9683e6d30d000ef998919261329d825de7c53499", + "reference": "9683e6d30d000ef998919261329d825de7c53499", + "shasum": "" + }, + "require": { + "symfony/framework-bundle": "^4.3 || ^5.0" + }, + "require-dev": { + "mockery/mockery": "^1.2", + "symfony/phpunit-bridge": "^4.3 || ^5.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "2.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Nelmio\\CorsBundle\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nelmio", + "homepage": "http://nelm.io" + }, + { + "name": "Symfony Community", + "homepage": "https://github.com/nelmio/NelmioCorsBundle/contributors" + } + ], + "description": "Adds CORS (Cross-Origin Resource Sharing) headers support in your Symfony application", + "keywords": [ + "api", + "cors", + "crossdomain" + ], + "time": "2019-11-15T08:54:08+00:00" + }, { "name": "ocramius/package-versions", "version": "1.5.1", @@ -1639,6 +1696,84 @@ ], "time": "2019-11-01T11:05:21+00:00" }, + { + "name": "sensio/framework-extra-bundle", + "version": "v5.5.3", + "source": { + "type": "git", + "url": "https://github.com/sensiolabs/SensioFrameworkExtraBundle.git", + "reference": "98f0807137b13d0acfdf3c255a731516e97015de" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sensiolabs/SensioFrameworkExtraBundle/zipball/98f0807137b13d0acfdf3c255a731516e97015de", + "reference": "98f0807137b13d0acfdf3c255a731516e97015de", + "shasum": "" + }, + "require": { + "doctrine/annotations": "^1.0", + "php": ">=7.1.3", + "symfony/config": "^4.3|^5.0", + "symfony/dependency-injection": "^4.3|^5.0", + "symfony/framework-bundle": "^4.3|^5.0", + "symfony/http-kernel": "^4.3|^5.0" + }, + "conflict": { + "doctrine/doctrine-cache-bundle": "<1.3.1" + }, + "require-dev": { + "doctrine/doctrine-bundle": "^1.11|^2.0", + "doctrine/orm": "^2.5", + "nyholm/psr7": "^1.1", + "symfony/browser-kit": "^4.3|^5.0", + "symfony/dom-crawler": "^4.3|^5.0", + "symfony/expression-language": "^4.3|^5.0", + "symfony/finder": "^4.3|^5.0", + "symfony/monolog-bridge": "^4.0|^5.0", + "symfony/monolog-bundle": "^3.2", + "symfony/phpunit-bridge": "^4.3.5|^5.0", + "symfony/psr-http-message-bridge": "^1.1", + "symfony/security-bundle": "^4.3|^5.0", + "symfony/twig-bundle": "^4.3|^5.0", + "symfony/yaml": "^4.3|^5.0", + "twig/twig": "^1.34|^2.4|^3.0" + }, + "suggest": { + "symfony/expression-language": "", + "symfony/psr-http-message-bridge": "To use the PSR-7 converters", + "symfony/security-bundle": "" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "5.5.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sensio\\Bundle\\FrameworkExtraBundle\\": "src/" + }, + "exclude-from-classmap": [ + "/tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + } + ], + "description": "This bundle provides a way to configure your controllers with annotations", + "keywords": [ + "annotations", + "controllers" + ], + "time": "2019-12-27T08:57:19+00:00" + }, { "name": "symfony/cache", "version": "v4.4.4", @@ -4017,6 +4152,137 @@ } ], "packages-dev": [ + { + "name": "doctrine/data-fixtures", + "version": "1.4.2", + "source": { + "type": "git", + "url": "https://github.com/doctrine/data-fixtures.git", + "reference": "39e9777c9089351a468f780b01cffa3cb0a42907" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/data-fixtures/zipball/39e9777c9089351a468f780b01cffa3cb0a42907", + "reference": "39e9777c9089351a468f780b01cffa3cb0a42907", + "shasum": "" + }, + "require": { + "doctrine/common": "^2.11", + "doctrine/persistence": "^1.3.3", + "php": "^7.2" + }, + "conflict": { + "doctrine/phpcr-odm": "<1.3.0" + }, + "require-dev": { + "alcaeus/mongo-php-adapter": "^1.1", + "doctrine/coding-standard": "^6.0", + "doctrine/dbal": "^2.5.4", + "doctrine/mongodb-odm": "^1.3.0", + "doctrine/orm": "^2.7.0", + "phpunit/phpunit": "^7.0" + }, + "suggest": { + "alcaeus/mongo-php-adapter": "For using MongoDB ODM with PHP 7", + "doctrine/mongodb-odm": "For loading MongoDB ODM fixtures", + "doctrine/orm": "For loading ORM fixtures", + "doctrine/phpcr-odm": "For loading PHPCR ODM fixtures" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Common\\DataFixtures\\": "lib/Doctrine/Common/DataFixtures" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jonathan Wage", + "email": "jonwage@gmail.com" + } + ], + "description": "Data Fixtures for all Doctrine Object Managers", + "homepage": "http://www.doctrine-project.org", + "keywords": [ + "database" + ], + "time": "2020-01-17T11:11:28+00:00" + }, + { + "name": "doctrine/doctrine-fixtures-bundle", + "version": "3.3.0", + "source": { + "type": "git", + "url": "https://github.com/doctrine/DoctrineFixturesBundle.git", + "reference": "8f07fcfdac7f3591f3c4bf13a50cbae05f65ed70" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/8f07fcfdac7f3591f3c4bf13a50cbae05f65ed70", + "reference": "8f07fcfdac7f3591f3c4bf13a50cbae05f65ed70", + "shasum": "" + }, + "require": { + "doctrine/data-fixtures": "^1.3", + "doctrine/doctrine-bundle": "^1.11|^2.0", + "doctrine/orm": "^2.6.0", + "php": "^7.1", + "symfony/config": "^3.4|^4.3|^5.0", + "symfony/console": "^3.4|^4.3|^5.0", + "symfony/dependency-injection": "^3.4|^4.3|^5.0", + "symfony/doctrine-bridge": "^3.4|^4.1|^5.0", + "symfony/http-kernel": "^3.4|^4.3|^5.0" + }, + "require-dev": { + "doctrine/coding-standard": "^6.0", + "phpunit/phpunit": "^7.4", + "symfony/phpunit-bridge": "^4.1|^5.0" + }, + "type": "symfony-bundle", + "extra": { + "branch-alias": { + "dev-master": "3.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "Doctrine\\Bundle\\FixturesBundle\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Doctrine Project", + "homepage": "http://www.doctrine-project.org" + }, + { + "name": "Symfony Community", + "homepage": "http://symfony.com/contributors" + } + ], + "description": "Symfony DoctrineFixturesBundle", + "homepage": "http://www.doctrine-project.org", + "keywords": [ + "Fixture", + "persistence" + ], + "time": "2019-11-13T15:46:58+00:00" + }, { "name": "nikic/php-parser", "version": "v4.3.0", diff --git a/backend/config/bundles.php b/backend/config/bundles.php index 119166a..951eca4 100644 --- a/backend/config/bundles.php +++ b/backend/config/bundles.php @@ -7,4 +7,7 @@ return [ Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true], Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true], + Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], + Sensio\Bundle\FrameworkExtraBundle\SensioFrameworkExtraBundle::class => ['all' => true], + Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true], ]; diff --git a/backend/config/packages/nelmio_cors.yaml b/backend/config/packages/nelmio_cors.yaml new file mode 100644 index 0000000..c766508 --- /dev/null +++ b/backend/config/packages/nelmio_cors.yaml @@ -0,0 +1,10 @@ +nelmio_cors: + defaults: + origin_regex: true + allow_origin: ['%env(CORS_ALLOW_ORIGIN)%'] + allow_methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'PATCH', 'DELETE'] + allow_headers: ['Content-Type', 'Authorization'] + expose_headers: ['Link'] + max_age: 3600 + paths: + '^/': null diff --git a/backend/config/packages/security.yaml b/backend/config/packages/security.yaml index ce69ba7..a7ea7e3 100644 --- a/backend/config/packages/security.yaml +++ b/backend/config/packages/security.yaml @@ -1,13 +1,26 @@ security: + encoders: + App\Entity\User: + algorithm: auto + # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers providers: - in_memory: { memory: null } + # used to reload user from session & other features (e.g. switch_user) + app_user_provider: + entity: + class: App\Entity\User + property: username firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ security: false main: - anonymous: lazy + anonymous: ~ + json_login: + check_path: /api/v1/login + logout: + path: /api/v1/logout + target: /api/v1 # activate different ways to authenticate # https://symfony.com/doc/current/security.html#firewalls-authentication @@ -18,5 +31,3 @@ security: # Easy way to control access for large sections of your site # Note: Only the *first* access control that matches will be used access_control: - # - { path: ^/admin, roles: ROLE_ADMIN } - # - { path: ^/profile, roles: ROLE_USER } diff --git a/backend/config/packages/sensio_framework_extra.yaml b/backend/config/packages/sensio_framework_extra.yaml new file mode 100644 index 0000000..1821ccc --- /dev/null +++ b/backend/config/packages/sensio_framework_extra.yaml @@ -0,0 +1,3 @@ +sensio_framework_extra: + router: + annotations: false diff --git a/backend/config/routes.yaml b/backend/config/routes.yaml index d1523d3..781019b 100644 --- a/backend/config/routes.yaml +++ b/backend/config/routes.yaml @@ -2,29 +2,9 @@ # path: / # controller: App\Controller\DefaultController::index -app_home: - path: / - controller: Symfony\Bundle\FrameworkBundle\Controller\RedirectController::urlRedirectAction - defaults: - path: /api/v1 - permanent: true +login: + path: /api/v1/login + controller: App\Controller\SecurityController::login -app_api_login: - path: /api/v1/login - methods: ['POST'] - controller: App\Controller\ApiController::login - -app_api_logout: - path: /api/v1/logout - methods: ['POST'] - controller: App\Controller\ApiController::logout - -app_api_show_info: - path: /api/v1 - methods: ['GET'] - controller: App\Controller\ApiController::showVersionInfo - -app_api_list_users: - path: /api/v1/users - methods: ['GET'] - controller: App\Controller\ApiController::listUsers +logout: + path: /api/v1/logout \ No newline at end of file diff --git a/backend/src/Controller/ApiController.php b/backend/src/Controller/ApiController.php deleted file mode 100644 index a68c3c5..0000000 --- a/backend/src/Controller/ApiController.php +++ /dev/null @@ -1,33 +0,0 @@ - '1', - ]); - } - - public function listUsers() - { - return new JsonResponse([]); - } -} \ No newline at end of file diff --git a/backend/src/Controller/IndexController.php b/backend/src/Controller/IndexController.php new file mode 100644 index 0000000..279476c --- /dev/null +++ b/backend/src/Controller/IndexController.php @@ -0,0 +1,20 @@ + '1', + ]); + } + +} \ No newline at end of file diff --git a/backend/src/Controller/RequestController.php b/backend/src/Controller/RequestController.php new file mode 100644 index 0000000..23f3c5e --- /dev/null +++ b/backend/src/Controller/RequestController.php @@ -0,0 +1,18 @@ +getUser(); + + return new DataResponse([ + 'username' => $user->getUsername(), + 'roles' => $user->getRoles(), + ]); + } +} \ No newline at end of file diff --git a/backend/src/Controller/UserController.php b/backend/src/Controller/UserController.php new file mode 100644 index 0000000..67c8e7d --- /dev/null +++ b/backend/src/Controller/UserController.php @@ -0,0 +1,72 @@ +getUser(); + + return new DataResponse([ + 'username' => $user->getUsername(), + 'roles' => $user->getRoles(), + ]); + } + + /** + * @Route("/api/v1/users", name="api_v1_list_users", methods={"GET"}) + * @IsGranted("ROLE_DEVELOPER") + */ + public function listUsers() + { + /** @var array */ + $users = $this->getDoctrine() + ->getRepository(User::class) + ->findAll() + ; + + $results = []; + foreach($users as $u) { + $results[] = [ + 'id' => $u->getId(), + 'username' => $u->getUsername(), + ]; + } + + return new DataResponse([ + 'users' => $results, + ]); + } + + /** + * @Route("/api/v1/users/{userId}", name="api_v1_get_user", methods={"GET"}, requirements={"userId"="\d+"}) + * @IsGranted("ROLE_DEVELOPER") + */ + public function showUser($userId) + { + /** @var User */ + $user = $this->getDoctrine() + ->getRepository(User::class) + ->find($userId) + ; + + return new DataResponse([ + 'user' => [ + 'id' => $user->getId(), + 'username' => $user->getUsername(), + ] + ]); + } +} \ No newline at end of file diff --git a/backend/src/DataFixtures/AppFixtures.php b/backend/src/DataFixtures/AppFixtures.php new file mode 100644 index 0000000..fece475 --- /dev/null +++ b/backend/src/DataFixtures/AppFixtures.php @@ -0,0 +1,17 @@ +persist($product); + + $manager->flush(); + } +} diff --git a/backend/src/DataFixtures/RequestStatusFixtures.php b/backend/src/DataFixtures/RequestStatusFixtures.php new file mode 100644 index 0000000..1d7a867 --- /dev/null +++ b/backend/src/DataFixtures/RequestStatusFixtures.php @@ -0,0 +1,29 @@ +setLabel($statusLabel); + $manager->persist($status); + } + + $manager->flush(); + } +} diff --git a/backend/src/DataFixtures/UserFixtures.php b/backend/src/DataFixtures/UserFixtures.php new file mode 100644 index 0000000..0447c20 --- /dev/null +++ b/backend/src/DataFixtures/UserFixtures.php @@ -0,0 +1,55 @@ +passwordEncoder = $passwordEncoder; + } + + public function load(ObjectManager $manager) + { + // On créait l'utilisateur client1 et client2 + $client1 = new User(); + $client1->setUsername('client1'); + $client1->setPassword($this->passwordEncoder->encodePassword( + $client1, + 'client1' + )); + $client1->setRoles(['ROLE_CLIENT']); + $manager->persist($client1); + + $client2 = new User(); + $client2->setUsername('client2'); + $client2->setPassword($this->passwordEncoder->encodePassword( + $client2, + 'client2' + )); + $client2->setRoles(['ROLE_CLIENT']); + $manager->persist($client2); + + // On créait l'utilisateur dev1 + $dev1 = new User(); + $dev1->setUsername('dev1'); + $dev1->setPassword($this->passwordEncoder->encodePassword( + $dev1, + 'dev1' + )); + $dev1->setRoles(['ROLE_DEVELOPER']); + $manager->persist($dev1); + + $manager->flush(); + + } + + +} diff --git a/backend/src/Entity/Comment.php b/backend/src/Entity/Comment.php new file mode 100644 index 0000000..13cc96f --- /dev/null +++ b/backend/src/Entity/Comment.php @@ -0,0 +1,93 @@ +id; + } + + public function getRequest(): ?Request + { + return $this->request; + } + + public function setRequest(?Request $request): self + { + $this->request = $request; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeInterface + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeInterface $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getAuthor(): ?User + { + return $this->author; + } + + public function setAuthor(?User $author): self + { + $this->author = $author; + + return $this; + } + + public function getText(): ?string + { + return $this->text; + } + + public function setText(string $text): self + { + $this->text = $text; + + return $this; + } +} diff --git a/backend/src/Entity/Project.php b/backend/src/Entity/Project.php new file mode 100644 index 0000000..725c7b2 --- /dev/null +++ b/backend/src/Entity/Project.php @@ -0,0 +1,115 @@ +request = new ArrayCollection(); + $this->users = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + /** + * @return Collection|Request[] + */ + public function getRequest(): Collection + { + return $this->request; + } + + public function addRequest(Request $request): self + { + if (!$this->request->contains($request)) { + $this->request[] = $request; + $request->setProject($this); + } + + return $this; + } + + public function removeRequest(Request $request): self + { + if ($this->request->contains($request)) { + $this->request->removeElement($request); + // set the owning side to null (unless already changed) + if ($request->getProject() === $this) { + $request->setProject(null); + } + } + + return $this; + } + + /** + * @return Collection|User[] + */ + public function getUsers(): Collection + { + return $this->users; + } + + public function addUser(User $user): self + { + if (!$this->users->contains($user)) { + $this->users[] = $user; + } + + return $this; + } + + public function removeUser(User $user): self + { + if ($this->users->contains($user)) { + $this->users->removeElement($user); + } + + return $this; + } + + public function getName(): ?string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } +} diff --git a/backend/src/Entity/Request.php b/backend/src/Entity/Request.php new file mode 100644 index 0000000..c107f21 --- /dev/null +++ b/backend/src/Entity/Request.php @@ -0,0 +1,154 @@ +comments = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + public function getAuthor(): ?User + { + return $this->author; + } + + public function setAuthor(?User $author): self + { + $this->author = $author; + + return $this; + } + + public function getCreatedAt(): ?\DateTimeInterface + { + return $this->createdAt; + } + + public function setCreatedAt(\DateTimeInterface $createdAt): self + { + $this->createdAt = $createdAt; + + return $this; + } + + public function getProject(): ?Project + { + return $this->project; + } + + public function setProject(?Project $project): self + { + $this->project = $project; + + return $this; + } + + /** + * @return Collection|Comment[] + */ + public function getComments(): Collection + { + return $this->comments; + } + + public function addComment(Comment $comment): self + { + if (!$this->comments->contains($comment)) { + $this->comments[] = $comment; + $comment->setRequest($this); + } + + return $this; + } + + public function removeComment(Comment $comment): self + { + if ($this->comments->contains($comment)) { + $this->comments->removeElement($comment); + // set the owning side to null (unless already changed) + if ($comment->getRequest() === $this) { + $comment->setRequest(null); + } + } + + return $this; + } + + public function getStatus(): ?RequestStatus + { + return $this->status; + } + + public function setStatus(?RequestStatus $status): self + { + $this->status = $status; + + return $this; + } +} diff --git a/backend/src/Entity/RequestStatus.php b/backend/src/Entity/RequestStatus.php new file mode 100644 index 0000000..803adc5 --- /dev/null +++ b/backend/src/Entity/RequestStatus.php @@ -0,0 +1,83 @@ +requests = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getLabel(): ?string + { + return $this->label; + } + + public function setLabel(string $label): self + { + $this->label = $label; + + return $this; + } + + /** + * @return Collection|Request[] + */ + public function getRequests(): Collection + { + return $this->requests; + } + + public function addRequest(Request $request): self + { + if (!$this->requests->contains($request)) { + $this->requests[] = $request; + $request->setStatus($this); + } + + return $this; + } + + public function removeRequest(Request $request): self + { + if ($this->requests->contains($request)) { + $this->requests->removeElement($request); + // set the owning side to null (unless already changed) + if ($request->getStatus() === $this) { + $request->setStatus(null); + } + } + + return $this; + } +} diff --git a/backend/src/Entity/User.php b/backend/src/Entity/User.php new file mode 100644 index 0000000..8ffbdc6 --- /dev/null +++ b/backend/src/Entity/User.php @@ -0,0 +1,222 @@ +requests = new ArrayCollection(); + $this->projects = new ArrayCollection(); + $this->comments = new ArrayCollection(); + } + + public function getId(): ?int + { + return $this->id; + } + + /** + * A visual identifier that represents this user. + * + * @see UserInterface + */ + public function getUsername(): string + { + return (string) $this->username; + } + + public function setUsername(string $username): self + { + $this->username = $username; + + return $this; + } + + /** + * @see UserInterface + */ + public function getRoles(): array + { + $roles = $this->roles; + // guarantee every user at least has ROLE_USER + $roles[] = 'ROLE_USER'; + + return array_unique($roles); + } + + public function setRoles(array $roles): self + { + $this->roles = $roles; + + return $this; + } + + /** + * @see UserInterface + */ + public function getPassword(): string + { + return (string) $this->password; + } + + public function setPassword(string $password): self + { + $this->password = $password; + + return $this; + } + + /** + * @see UserInterface + */ + public function getSalt() + { + // not needed when using the "bcrypt" algorithm in security.yaml + } + + /** + * @see UserInterface + */ + public function eraseCredentials() + { + // If you store any temporary, sensitive data on the user, clear it here + // $this->plainPassword = null; + } + + /** + * @return Collection|Request[] + */ + public function getRequests(): Collection + { + return $this->requests; + } + + public function addRequest(Request $request): self + { + if (!$this->requests->contains($request)) { + $this->requests[] = $request; + $request->setAuthor($this); + } + + return $this; + } + + public function removeRequest(Request $request): self + { + if ($this->requests->contains($request)) { + $this->requests->removeElement($request); + // set the owning side to null (unless already changed) + if ($request->getAuthor() === $this) { + $request->setAuthor(null); + } + } + + return $this; + } + + /** + * @return Collection|Project[] + */ + public function getProjects(): Collection + { + return $this->projects; + } + + public function addProject(Project $project): self + { + if (!$this->projects->contains($project)) { + $this->projects[] = $project; + $project->addUser($this); + } + + return $this; + } + + public function removeProject(Project $project): self + { + if ($this->projects->contains($project)) { + $this->projects->removeElement($project); + $project->removeUser($this); + } + + return $this; + } + + /** + * @return Collection|Comment[] + */ + public function getComments(): Collection + { + return $this->comments; + } + + public function addComment(Comment $comment): self + { + if (!$this->comments->contains($comment)) { + $this->comments[] = $comment; + $comment->setAuthor($this); + } + + return $this; + } + + public function removeComment(Comment $comment): self + { + if ($this->comments->contains($comment)) { + $this->comments->removeElement($comment); + // set the owning side to null (unless already changed) + if ($comment->getAuthor() === $this) { + $comment->setAuthor(null); + } + } + + return $this; + } +} diff --git a/backend/src/Http/DataResponse.php b/backend/src/Http/DataResponse.php new file mode 100644 index 0000000..e52dd0d --- /dev/null +++ b/backend/src/Http/DataResponse.php @@ -0,0 +1,16 @@ + $data], + $status, + $headers, + $json, + ); + } +} \ No newline at end of file diff --git a/backend/src/Http/ErrorResponse.php b/backend/src/Http/ErrorResponse.php new file mode 100644 index 0000000..0d6a53c --- /dev/null +++ b/backend/src/Http/ErrorResponse.php @@ -0,0 +1,22 @@ + [ + 'code' => $code, + 'message' => $message, + 'data' => $data, + ], + ], + $status, + $headers, + $json, + ); + } +} \ No newline at end of file diff --git a/backend/src/Migrations/Version20200217203938.php b/backend/src/Migrations/Version20200217203938.php new file mode 100644 index 0000000..e179c33 --- /dev/null +++ b/backend/src/Migrations/Version20200217203938.php @@ -0,0 +1,57 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('CREATE TABLE user (id INT AUTO_INCREMENT NOT NULL, username VARCHAR(180) NOT NULL, roles LONGTEXT NOT NULL COMMENT \'(DC2Type:json)\', password VARCHAR(255) NOT NULL, UNIQUE INDEX UNIQ_8D93D649F85E0677 (username), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE request_status (id INT AUTO_INCREMENT NOT NULL, label VARCHAR(64) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE comment (id INT AUTO_INCREMENT NOT NULL, request_id INT NOT NULL, author_id INT NOT NULL, created_at DATETIME NOT NULL, text LONGTEXT NOT NULL, INDEX IDX_9474526C427EB8A5 (request_id), INDEX IDX_9474526CF675F31B (author_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE project (id INT AUTO_INCREMENT NOT NULL, name VARCHAR(255) NOT NULL, PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE project_user (project_id INT NOT NULL, user_id INT NOT NULL, INDEX IDX_B4021E51166D1F9C (project_id), INDEX IDX_B4021E51A76ED395 (user_id), PRIMARY KEY(project_id, user_id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('CREATE TABLE request (id INT AUTO_INCREMENT NOT NULL, author_id INT NOT NULL, project_id INT NOT NULL, title VARCHAR(255) NOT NULL, created_at DATETIME NOT NULL, INDEX IDX_3B978F9FF675F31B (author_id), INDEX IDX_3B978F9F166D1F9C (project_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE comment ADD CONSTRAINT FK_9474526C427EB8A5 FOREIGN KEY (request_id) REFERENCES request (id)'); + $this->addSql('ALTER TABLE comment ADD CONSTRAINT FK_9474526CF675F31B FOREIGN KEY (author_id) REFERENCES user (id)'); + $this->addSql('ALTER TABLE project_user ADD CONSTRAINT FK_B4021E51166D1F9C FOREIGN KEY (project_id) REFERENCES project (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE project_user ADD CONSTRAINT FK_B4021E51A76ED395 FOREIGN KEY (user_id) REFERENCES user (id) ON DELETE CASCADE'); + $this->addSql('ALTER TABLE request ADD CONSTRAINT FK_3B978F9FF675F31B FOREIGN KEY (author_id) REFERENCES user (id)'); + $this->addSql('ALTER TABLE request ADD CONSTRAINT FK_3B978F9F166D1F9C FOREIGN KEY (project_id) REFERENCES project (id)'); + } + + public function down(Schema $schema) : void + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE comment DROP FOREIGN KEY FK_9474526CF675F31B'); + $this->addSql('ALTER TABLE project_user DROP FOREIGN KEY FK_B4021E51A76ED395'); + $this->addSql('ALTER TABLE request DROP FOREIGN KEY FK_3B978F9FF675F31B'); + $this->addSql('ALTER TABLE project_user DROP FOREIGN KEY FK_B4021E51166D1F9C'); + $this->addSql('ALTER TABLE request DROP FOREIGN KEY FK_3B978F9F166D1F9C'); + $this->addSql('ALTER TABLE comment DROP FOREIGN KEY FK_9474526C427EB8A5'); + $this->addSql('DROP TABLE user'); + $this->addSql('DROP TABLE request_status'); + $this->addSql('DROP TABLE comment'); + $this->addSql('DROP TABLE project'); + $this->addSql('DROP TABLE project_user'); + $this->addSql('DROP TABLE request'); + } +} diff --git a/backend/src/Migrations/Version20200217211954.php b/backend/src/Migrations/Version20200217211954.php new file mode 100644 index 0000000..1a72a3b --- /dev/null +++ b/backend/src/Migrations/Version20200217211954.php @@ -0,0 +1,39 @@ +abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE request ADD status_id INT DEFAULT NULL'); + $this->addSql('ALTER TABLE request ADD CONSTRAINT FK_3B978F9F6BF700BD FOREIGN KEY (status_id) REFERENCES request_status (id)'); + $this->addSql('CREATE INDEX IDX_3B978F9F6BF700BD ON request (status_id)'); + } + + public function down(Schema $schema) : void + { + // this down() migration is auto-generated, please modify it to your needs + $this->abortIf($this->connection->getDatabasePlatform()->getName() !== 'mysql', 'Migration can only be executed safely on \'mysql\'.'); + + $this->addSql('ALTER TABLE request DROP FOREIGN KEY FK_3B978F9F6BF700BD'); + $this->addSql('DROP INDEX IDX_3B978F9F6BF700BD ON request'); + $this->addSql('ALTER TABLE request DROP status_id'); + } +} diff --git a/backend/src/Repository/CommentRepository.php b/backend/src/Repository/CommentRepository.php new file mode 100644 index 0000000..cddf55d --- /dev/null +++ b/backend/src/Repository/CommentRepository.php @@ -0,0 +1,50 @@ +createQueryBuilder('c') + ->andWhere('c.exampleField = :val') + ->setParameter('val', $value) + ->orderBy('c.id', 'ASC') + ->setMaxResults(10) + ->getQuery() + ->getResult() + ; + } + */ + + /* + public function findOneBySomeField($value): ?Comment + { + return $this->createQueryBuilder('c') + ->andWhere('c.exampleField = :val') + ->setParameter('val', $value) + ->getQuery() + ->getOneOrNullResult() + ; + } + */ +} diff --git a/backend/src/Repository/ProjectRepository.php b/backend/src/Repository/ProjectRepository.php new file mode 100644 index 0000000..50da37f --- /dev/null +++ b/backend/src/Repository/ProjectRepository.php @@ -0,0 +1,50 @@ +createQueryBuilder('p') + ->andWhere('p.exampleField = :val') + ->setParameter('val', $value) + ->orderBy('p.id', 'ASC') + ->setMaxResults(10) + ->getQuery() + ->getResult() + ; + } + */ + + /* + public function findOneBySomeField($value): ?Project + { + return $this->createQueryBuilder('p') + ->andWhere('p.exampleField = :val') + ->setParameter('val', $value) + ->getQuery() + ->getOneOrNullResult() + ; + } + */ +} diff --git a/backend/src/Repository/RequestRepository.php b/backend/src/Repository/RequestRepository.php new file mode 100644 index 0000000..6047434 --- /dev/null +++ b/backend/src/Repository/RequestRepository.php @@ -0,0 +1,50 @@ +createQueryBuilder('r') + ->andWhere('r.exampleField = :val') + ->setParameter('val', $value) + ->orderBy('r.id', 'ASC') + ->setMaxResults(10) + ->getQuery() + ->getResult() + ; + } + */ + + /* + public function findOneBySomeField($value): ?Request + { + return $this->createQueryBuilder('r') + ->andWhere('r.exampleField = :val') + ->setParameter('val', $value) + ->getQuery() + ->getOneOrNullResult() + ; + } + */ +} diff --git a/backend/src/Repository/RequestStatusRepository.php b/backend/src/Repository/RequestStatusRepository.php new file mode 100644 index 0000000..be45c5e --- /dev/null +++ b/backend/src/Repository/RequestStatusRepository.php @@ -0,0 +1,50 @@ +createQueryBuilder('r') + ->andWhere('r.exampleField = :val') + ->setParameter('val', $value) + ->orderBy('r.id', 'ASC') + ->setMaxResults(10) + ->getQuery() + ->getResult() + ; + } + */ + + /* + public function findOneBySomeField($value): ?RequestStatus + { + return $this->createQueryBuilder('r') + ->andWhere('r.exampleField = :val') + ->setParameter('val', $value) + ->getQuery() + ->getOneOrNullResult() + ; + } + */ +} diff --git a/backend/src/Repository/UserRepository.php b/backend/src/Repository/UserRepository.php new file mode 100644 index 0000000..b2bdfd6 --- /dev/null +++ b/backend/src/Repository/UserRepository.php @@ -0,0 +1,39 @@ +setPassword($newEncodedPassword); + $this->_em->persist($user); + $this->_em->flush(); + } + +} diff --git a/backend/symfony.lock b/backend/symfony.lock index d455b1c..aeacb52 100644 --- a/backend/symfony.lock +++ b/backend/symfony.lock @@ -20,6 +20,9 @@ "doctrine/common": { "version": "2.12.0" }, + "doctrine/data-fixtures": { + "version": "1.4.2" + }, "doctrine/dbal": { "version": "v2.10.1" }, @@ -38,6 +41,18 @@ "src/Repository/.gitignore" ] }, + "doctrine/doctrine-fixtures-bundle": { + "version": "3.0", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "master", + "version": "3.0", + "ref": "fc52d86631a6dfd9fdf3381d0b7e3df2069e51b3" + }, + "files": [ + "src/DataFixtures/AppFixtures.php" + ] + }, "doctrine/doctrine-migrations-bundle": { "version": "1.2", "recipe": { @@ -87,6 +102,18 @@ "laminas/laminas-zendframework-bridge": { "version": "1.0.1" }, + "nelmio/cors-bundle": { + "version": "1.5", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "master", + "version": "1.5", + "ref": "6388de23860284db9acce0a7a5d9d13153bcb571" + }, + "files": [ + "config/packages/nelmio_cors.yaml" + ] + }, "nikic/php-parser": { "version": "v4.3.0" }, @@ -108,6 +135,18 @@ "psr/log": { "version": "1.1.2" }, + "sensio/framework-extra-bundle": { + "version": "5.2", + "recipe": { + "repo": "github.com/symfony/recipes", + "branch": "master", + "version": "5.2", + "ref": "fb7e19da7f013d0d422fa9bce16f5c510e27609b" + }, + "files": [ + "config/packages/sensio_framework_extra.yaml" + ] + }, "symfony/cache": { "version": "v4.4.4" }, diff --git a/docker-compose.yml b/docker-compose.yml index fafa672..56890ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,9 @@ services: MYSQL_PASSWORD: logo ports: - 3306:3306 + command: + - "--character-set-server=utf8mb4" + - "--collation-server=utf8mb4_unicode_ci" volumes: - db_data:/var/lib/mysql diff --git a/frontend/src/actions/.gitkeep b/frontend/src/actions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/actions/chat.js b/frontend/src/actions/chat.js deleted file mode 100644 index c2b5742..0000000 --- a/frontend/src/actions/chat.js +++ /dev/null @@ -1,20 +0,0 @@ -export const SEND_MESSAGE = 'SEND_MESSAGE' -export const SEND_MESSAGE_SUCCESS = 'SEND_MESSAGE_SUCCESS'; -export const SEND_MESSAGE_FAILURE = 'SEND_MESSAGE_FAILURE'; - -export function sendMessage (channel, text) { - return { type: SEND_MESSAGE, channel, text } -} - -export const FETCH_MESSAGES = 'FETCH_MESSAGES' -export const FETCH_MESSAGES_SUCCESS = 'FETCH_MESSAGES_SUCCESS'; -export const FETCH_MESSAGES_FAILURE = 'FETCH_MESSAGES_FAILURE'; - -export function fetchMessages (channel) { - return { type: FETCH_MESSAGES, channel } -} - -export const STREAM_EVENTS = 'STREAM_EVENTS' -export function streamEvents (channel) { - return { type: STREAM_EVENTS, channel } -} diff --git a/frontend/src/actions/login.js b/frontend/src/actions/login.js deleted file mode 100644 index 3718de5..0000000 --- a/frontend/src/actions/login.js +++ /dev/null @@ -1,7 +0,0 @@ -export const LOGIN = 'LOGIN' -export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; -export const LOGIN_FAILURE = 'LOGIN_FAILURE'; - -export function login (username, password) { - return { type: LOGIN, username, password } -} diff --git a/frontend/src/actions/products.js b/frontend/src/actions/products.js deleted file mode 100644 index c915260..0000000 --- a/frontend/src/actions/products.js +++ /dev/null @@ -1,11 +0,0 @@ -export const ADD_PRODUCT = 'ADD_PRODUCT' - -export function addProduct (name, price) { - return {type: ADD_PRODUCT, product: {name, price}} -} - -export const REMOVE_PRODUCT = 'REMOVE_PRODUCT' - -export function removeProduct (name) { - return {type: REMOVE_PRODUCT, productName: name} -} \ No newline at end of file diff --git a/frontend/src/app.js b/frontend/src/app.js index 4a3fd6c..886df5a 100644 --- a/frontend/src/app.js +++ b/frontend/src/app.js @@ -1,12 +1,8 @@ - import { Component, Fragment } from 'react' import { hot } from 'react-hot-loader' import { HashRouter } from 'react-router-dom' // ou BrowserRouter import { Route, Switch, Redirect } from 'react-router' -import LoginPage from './pages/login'; -import ChatPage from './pages/chat'; - -require('bulma/css/bulma.min.css') +import HomePage from './pages/home'; class App extends Component { render () { @@ -14,10 +10,8 @@ class App extends Component { - - - - } /> + + } /> diff --git a/frontend/src/components/.gitkeep b/frontend/src/components/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/components/__snapshots__/counter.test.js.snap b/frontend/src/components/__snapshots__/counter.test.js.snap deleted file mode 100644 index 8e0e769..0000000 --- a/frontend/src/components/__snapshots__/counter.test.js.snap +++ /dev/null @@ -1,21 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Counter snapshot 1`] = ` -
- Count: - - 0 - -   - - -
-`; diff --git a/frontend/src/components/clock.js b/frontend/src/components/clock.js deleted file mode 100644 index 71370d7..0000000 --- a/frontend/src/components/clock.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react' - -export default class Clock extends React.Component { - - constructor(props) { - super(props) - // Initialisation du "state" du composant - this.state = { - time: new Date(), - foo: "bar" - } - - this.tick = this.tick.bind(this); - // On appelle la méthode tick() du composant - // toutes les secondes - setInterval(this.tick, this.props.interval); - } - - // Méthode de rendu du composant - render() { - return ( -
Time: { this.state.time.toString() }
- ) - } - - // La méthode tick() met à jour le state du composant avec - // la date courante - tick() { - this.setState({ time: new Date() }); - } - -} diff --git a/frontend/src/components/counter.js b/frontend/src/components/counter.js deleted file mode 100644 index d7b2900..0000000 --- a/frontend/src/components/counter.js +++ /dev/null @@ -1,33 +0,0 @@ -import React from 'react' - -export default class Counter extends React.Component { - constructor(props) { - super(props) - // Initialisation du "state" du composant - this.state = { - count: 0 - } - // On "lie" les méthodes de la classe à l'instance - this.increment = this.increment.bind(this) - this.decrement = this.decrement.bind(this) - } - // Méthode de rendu du composant - render() { - console.log(this.props.match); - return ( -
- Count: { this.state.count }  - - -
- ) - } - // La méthode increment() incrémente la valeur du compteur de 1 - increment() { - this.setState(prevState => ({ count: prevState.count+1 })) - } - // La méthode decrement() décrémente la valeur du compteur de 1 - decrement() { - this.setState(prevState => ({ count: prevState.count-1 })) - } -} \ No newline at end of file diff --git a/frontend/src/components/counter.test.js b/frontend/src/components/counter.test.js deleted file mode 100644 index d83869f..0000000 --- a/frontend/src/components/counter.test.js +++ /dev/null @@ -1,26 +0,0 @@ -/* globals test, expect */ -import React from 'react'; -import Counter from './counter' -import renderer from 'react-test-renderer' - -test('Counter snapshot', () => { - - const component = renderer.create() - - let tree = component.toJSON() - - // Vérifier que le composant n'a pas changé depuis le dernier - // snapshot. - // Voir https://facebook.github.io/jest/docs/en/snapshot-testing.html - // pour plus d'informations - expect(tree).toMatchSnapshot() - - // L'API expect() de Jest est disponible à l'adresse - // https://facebook.github.io/jest/docs/en/expect.html - - // Il est possible d'effectuer des vérifications plus avancées - // grâce au projet Enzyme (vérification du DOM, etc) - // Voir http://airbnb.io/enzyme/ et - // https://facebook.github.io/jest/docs/en/tutorial-react.html#dom-testing - -}) \ No newline at end of file diff --git a/frontend/src/components/myform.js b/frontend/src/components/myform.js deleted file mode 100644 index b5e5842..0000000 --- a/frontend/src/components/myform.js +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react' -import { connect } from 'react-redux' -import { addProduct } from '../actions/products' - - -class MyForm extends React.Component { - - constructor(props) { - super(props); - this.state = {name: ''}; - this.handleChange = this.handleChange.bind(this); - this.handleSubmit = this.handleSubmit.bind(this); - } - - handleChange(evt) { - this.setState({ name: evt.target.value }); - } - - handleSubmit(evt) { - console.log(`Votre nom est ${this.state.name}`); - evt.preventDefault(); - } - - componentDidMount() { - this.props.dispatch(addProduct('pomme', 10)); - } - - render() { - return ( -
- - -
- ); - } -} - -export default connect()(MyForm) diff --git a/frontend/src/index.html b/frontend/src/index.html index c8f67c0..ccc8d7d 100644 --- a/frontend/src/index.html +++ b/frontend/src/index.html @@ -2,7 +2,7 @@ - Insight + PleaseWait <% for (var css in htmlWebpackPlugin.files.css) { %> <% } %> diff --git a/frontend/src/index.js b/frontend/src/index.js index c9bddeb..3c6acab 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -2,6 +2,7 @@ import ReactDOM from 'react-dom' import App from './app' import { configureStore } from './store/store' import { Provider } from 'react-redux' +import 'bulma/css/bulma.min.css'; const store = configureStore() diff --git a/frontend/src/pages/chat.jsx b/frontend/src/pages/chat.jsx deleted file mode 100644 index 0a0470f..0000000 --- a/frontend/src/pages/chat.jsx +++ /dev/null @@ -1,82 +0,0 @@ -import React from 'react' -import Page from './page'; -import { connect } from 'react-redux'; -import { fetchMessages, sendMessage, streamEvents } from '../actions/chat'; - -export class ChatPage extends React.Component { - - constructor(props) { - super(props); - this.state = { - message: '' - } - this.handleSendMessage = this.handleSendMessage.bind(this); - this.handleKeyDown = this.handleKeyDown.bind(this); - } - - render() { - const channel = "default"; - const { chat } = this.props; - const messages = channel in chat.messagesByChannel ? chat.messagesByChannel[channel] : []; - const users = []; - return ( - -
-
-
-
-
-
- Chat -
    - { - messages.map(msg => { - return ( -
  • [{msg.Username}] {msg.Text}
  • - ) - }) - } -
-
-
- -
-
-
-
-
- Utilisateurs -
-
-
-
-
-
- ); - } - - componentDidMount() { - this.props.dispatch(fetchMessages("default")); - this.props.dispatch(streamEvents("default")); - } - - handleSendMessage(evt) { - this.setState({ message: evt.target.value }); - } - - handleKeyDown(evt) { - if (evt.keyCode !== 13) return; - this.props.dispatch(sendMessage("default", this.state.message)); - this.setState({message: ""}); - } - -} - -export default connect(state => { - return { - chat: state.chat - } -})(ChatPage) \ No newline at end of file diff --git a/frontend/src/pages/home.js b/frontend/src/pages/home.js new file mode 100644 index 0000000..4392a47 --- /dev/null +++ b/frontend/src/pages/home.js @@ -0,0 +1,15 @@ +import React from 'react' +import Page from './page'; + +export default class HomePage extends React.PureComponent { + render() { + return ( + +
+

Bienvenue sur PleaseWait !

+

Le gestionnaire de ticket simplifié.

+
+
+ ); + } +} \ No newline at end of file diff --git a/frontend/src/pages/login.jsx b/frontend/src/pages/login.jsx deleted file mode 100644 index f1e23e8..0000000 --- a/frontend/src/pages/login.jsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react' -import Page from './page'; -import { connect } from 'react-redux'; -import { login } from '../actions/login'; - -export class LoginPage extends React.Component { - - constructor(props) { - super(props); - this.login = this.login.bind(this); - } - - render() { - return ( - -
-
-
-
- -
- -
-
-
- -
- -
-
- -
-
-
-
- ); - } - - componentDidUpdate() { - if (this.props.user.isLoggedIn) this.props.history.push("/chat"); - } - - login() { - this.props.dispatch(login("foo", "bar")) - } - - } - - export default connect(state => { - return { - user: state.user - } - })(LoginPage) \ No newline at end of file diff --git a/frontend/src/pages/page.js b/frontend/src/pages/page.js index bca88d5..fb32593 100644 --- a/frontend/src/pages/page.js +++ b/frontend/src/pages/page.js @@ -1,11 +1,13 @@ -import React from 'react' +import React, { useEffect } from 'react' -export default class Page extends React.PureComponent { - render() { - return ( -
- { this.props.children } -
- ); - } +export default function Page({ title, children }) { + useEffect(() => { + document.title = title ? `${title } - PleaseWait` : 'PleaseWait'; + }); + + return ( +
+ { children } +
+ ); } \ No newline at end of file diff --git a/frontend/src/reducers/.gitkeep b/frontend/src/reducers/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/frontend/src/reducers/chat.js b/frontend/src/reducers/chat.js deleted file mode 100644 index 6b0c86a..0000000 --- a/frontend/src/reducers/chat.js +++ /dev/null @@ -1,35 +0,0 @@ -import { LOGIN_SUCCESS, LOGIN_FAILURE } from '../actions/login'; -import { FETCH_MESSAGES_SUCCESS } from '../actions/chat'; - -const defaultState = { - messagesByChannel: {}, -} - -export default function chatReducer(state = defaultState, action) { - switch (action.type) { - case FETCH_MESSAGES_SUCCESS: - return { - ...state, - messagesByChannel: { - ...state.messagesByChannel, - [action.channel]: [...action.data.Messages] - } - }; - case 'CHANNEL_EVENT': - switch(action.event) { - case "message": - return { - ...state, - messagesByChannel: { - ...state.messagesByChannel, - [action.data.Channel]: [ - ...state.messagesByChannel[action.data.Channel], - action.data.Message - ] - } - }; - } - return state; - } - return state; -} diff --git a/frontend/src/reducers/login.js b/frontend/src/reducers/login.js deleted file mode 100644 index bce2420..0000000 --- a/frontend/src/reducers/login.js +++ /dev/null @@ -1,16 +0,0 @@ -import { LOGIN_SUCCESS, LOGIN_FAILURE } from '../actions/login'; - -const defaultState = { - isLoggedIn: false, - username: null, -} - -export default function loginReducer(state = defaultState, action) { - switch (action.type) { - case LOGIN_SUCCESS: - return { ...state, isLoggedIn: true, username: action.data.Username }; - case LOGIN_FAILURE: - return { ...state, isLoggedIn: false, username: null }; - } - return state; -} diff --git a/frontend/src/reducers/root.js b/frontend/src/reducers/root.js deleted file mode 100644 index 01bb407..0000000 --- a/frontend/src/reducers/root.js +++ /dev/null @@ -1,31 +0,0 @@ -import { ADD_PRODUCT, REMOVE_PRODUCT } from '../actions/products'; - -export const rootReducer = (state, action) => { - - console.log(`Action: ${JSON.stringify(action)}`) - - switch (action.type) { - - case ADD_PRODUCT: - // L'action est de type ADD_PRODUCT - // On ajoute le produit dans la liste et - // on retourne un nouvel état modifié - return { - products: [...state.products, action.product] - } - - - case REMOVE_PRODUCT: - // L'action est de type REMOVE_PRODUCT - // On filtre la liste des produits et on - // retourne un nouvel état modifié - return { - products: state.products.filter(p => p.name !== action.productName) - } - } - - // Si l'action n'est pas gérée, on retourne l'état - // sans le modifier - return state - -} \ No newline at end of file diff --git a/frontend/src/sagas/chat.js b/frontend/src/sagas/chat.js deleted file mode 100644 index 272894c..0000000 --- a/frontend/src/sagas/chat.js +++ /dev/null @@ -1,98 +0,0 @@ -import { call, put, take } from 'redux-saga/effects'; -import { eventChannel } from 'redux-saga' -import { - SEND_MESSAGE_FAILURE, SEND_MESSAGE_SUCCESS, - FETCH_MESSAGES_FAILURE, FETCH_MESSAGES_SUCCESS -} from '../actions/chat'; - -export function* sendMessageSaga(action) { - - let result; - try { - result = yield call(sendMessage, action.channel, action.text); - } catch(err) { - yield put({ type: SEND_MESSAGE_FAILURE, err }); - } - - if ('Error' in result) { - yield put({type: SEND_MESSAGE_FAILURE, err: result.Error}); - return - } - - yield put({type: SEND_MESSAGE_SUCCESS, data: result.Data }); - -} - - -function sendMessage(channel, text) { - return fetch(`http://192.168.0.126:3000/channels/${channel}`, { - method: 'POST', - body: JSON.stringify({ - Text: text, - }), - mode: 'cors', - credentials: 'include' -}) -.then(res => res.json()) -} - -export function* fetchMessagesSaga(action) { - - let result; - try { - result = yield call(fetchMessages, action.channel); - } catch(err) { - yield put({ type: FETCH_MESSAGES_FAILURE, err }); - } - - if ('Error' in result) { - yield put({type: FETCH_MESSAGES_FAILURE, err: result.Error}); - return - } - - yield put({type: FETCH_MESSAGES_SUCCESS, channel: action.channel, data: result.Data }); - -} - - -function fetchMessages(channel) { - return fetch(`http://192.168.0.126:3000/channels/${channel}`, { - mode: 'cors', - credentials: 'include' - }) - .then(res => res.json()) -} - -function channelEvents(channel) { - return eventChannel(emitter => { - - const eventSource = new EventSource( - `http://192.168.0.126:3000/channels/${channel}/stream`, - { withCredentials: true } - ); - - const emit = evt => { - emitter({type: evt.type, data: evt.data}); - }; - - eventSource.addEventListener("joined", emit); - eventSource.addEventListener("left", emit); - eventSource.addEventListener("message", emit); - - return () => { - eventSource.removeEventListener("joined", emit) - eventSource.removeEventListener("left", emit) - eventSource.removeEventListener("message", emit) - eventSource.close() - } - } - ) -} - -export function* streamEventsSaga(action) { - const stream = yield call(channelEvents, action.channel) - while (true) { - let event = yield take(stream) - yield put({ type: 'CHANNEL_EVENT', event: event.type, data: JSON.parse(event.data) }); - } -} \ No newline at end of file diff --git a/frontend/src/sagas/login.js b/frontend/src/sagas/login.js deleted file mode 100644 index b96cc93..0000000 --- a/frontend/src/sagas/login.js +++ /dev/null @@ -1,34 +0,0 @@ -import { call, put } from 'redux-saga/effects'; -import { LOGIN_FAILURE, LOGIN_SUCCESS } from '../actions/login'; - -export default function* loginSaga(action) { - - let result; - try { - result = yield call(doLogin, action.username, action.password); - } catch(err) { - yield put({ type: LOGIN_FAILURE, err }); - } - - if ('Error' in result) { - yield put({type: LOGIN_FAILURE, err: result.Error}); - return - } - - yield put({type: LOGIN_SUCCESS, data: result.Data }); - -} - - -function doLogin(username, password) { - return fetch('http://192.168.0.126:3000/login', { - method: 'POST', - body: JSON.stringify({ - Username: username, - Password: password - }), - mode: 'cors', - credentials: 'include' -}) -.then(res => res.json()) -} diff --git a/frontend/src/sagas/root.js b/frontend/src/sagas/root.js index ee0ecd8..879634f 100644 --- a/frontend/src/sagas/root.js +++ b/frontend/src/sagas/root.js @@ -1,14 +1,7 @@ import { all, takeLatest } from 'redux-saga/effects'; -import loginSaga from './login'; -import { LOGIN } from '../actions/login'; -import { SEND_MESSAGE, FETCH_MESSAGES, STREAM_EVENTS } from '../actions/chat'; -import { sendMessageSaga, fetchMessagesSaga, streamEventsSaga } from './chat'; export default function* rootSaga() { yield all([ - takeLatest(LOGIN, loginSaga), - takeLatest(SEND_MESSAGE, sendMessageSaga), - takeLatest(FETCH_MESSAGES, fetchMessagesSaga), - takeLatest(STREAM_EVENTS, streamEventsSaga) + ]); } diff --git a/frontend/src/store/store.js b/frontend/src/store/store.js index 27f65f0..521df0a 100644 --- a/frontend/src/store/store.js +++ b/frontend/src/store/store.js @@ -1,14 +1,11 @@ import { createStore, applyMiddleware, combineReducers, compose } from 'redux' -import loginReducer from '../reducers/login' -import rootSaga from '../sagas/root' import createSagaMiddleware from 'redux-saga' -import chatReducer from '../reducers/chat'; +import rootSaga from '../sagas/root' const sagaMiddleware = createSagaMiddleware() const rootReducer = combineReducers({ - user: loginReducer, - chat: chatReducer, + // Ajouter vos reducers ici }); const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; diff --git a/frontend/src/store/store.test.js b/frontend/src/store/store.test.js deleted file mode 100644 index 34f96f2..0000000 --- a/frontend/src/store/store.test.js +++ /dev/null @@ -1,39 +0,0 @@ -/* globals test, expect, jest */ -import { addProduct, removeProduct } from '../actions/products' -import { configureStore } from './store' - -test('Ajout/suppression des produits', () => { - // On crée une instance de notre store - // avec le state par défaut - const store = configureStore() - - // On crée un "faux" subscriber - // pour vérifier que l'état du store - // a bien été modifié le nombre de fois voulu - const subscriber = jest.fn() - - // On attache notre faux subscriber - // au store - store.subscribe(subscriber) - - // On "dispatch" nos actions - store.dispatch(addProduct('pomme', 5)) - store.dispatch(addProduct('orange', 7)) - store.dispatch(addProduct('orange', 10)) - store.dispatch(removeProduct('pomme')) - - // On s'assure que notre subscriber a bien été - // appelé - expect(subscriber).toHaveBeenCalledTimes(4) - - const state = store.getState() - - // On s'assure que l'état du store correspond - // à ce qu'on attend - expect(state).toMatchObject({ - products: [ - {name: 'orange', price: 7} - ] - }) - -}) \ No newline at end of file diff --git a/misc/containers/backend/docker-entrypoint.sh b/misc/containers/backend/docker-entrypoint.sh index e9e3b2b..c7c7f80 100644 --- a/misc/containers/backend/docker-entrypoint.sh +++ b/misc/containers/backend/docker-entrypoint.sh @@ -12,6 +12,9 @@ if [ ! -e "$FIRST_RUN_FLAG_FILE" ]; then echo "Applying database migrations. Please wait..." bin/console doctrine:migrations:migrate --no-interaction + echo "Loading fixtures. Please wait..." + bin/console doctrine:fixtures:load --no-interaction + touch "$FIRST_RUN_FLAG_FILE" fi diff --git a/misc/projects/ticketing_app.http b/misc/projects/ticketing_app.http new file mode 100644 index 0000000..31169d5 --- /dev/null +++ b/misc/projects/ticketing_app.http @@ -0,0 +1,44 @@ +@baseURL = http://localhost:8001/api/v1 + +### + +GET {{baseURL}} + +### + +// Login as "client1" + +POST {{baseURL}}/login +Content-Type: application/json + +{ "username": "client1", "password": "client1" } + +### + +// Login as "dev1" + +POST {{baseURL}}/login +Content-Type: application/json + +{ "username": "dev1", "password": "dev1" } + +### + +// Logout + +GET {{baseURL}}/logout +Content-Type: application/json + +### + +// Get current user info + +GET {{baseURL}}/me +Content-Type: application/json + +### + +// List users + +GET {{baseURL}}/users +Content-Type: application/json \ No newline at end of file diff --git a/misc/projects/ticketing_app.md b/misc/projects/ticketing_app.md new file mode 100644 index 0000000..91c15f4 --- /dev/null +++ b/misc/projects/ticketing_app.md @@ -0,0 +1,71 @@ +# Application de suivi des demandes client + +## Objectif + +Implémenter une application de suivi des demandes client proposant les fonctionnalités suivantes: + +- Authentification par identifiant et mot de passe +- Autorisation par rôles: + - "client" + - "développeur" +- Interfaces pour le rôle "client": + - Une interface de visualisation des projets qui lui sont associés + - Une interface de création d'une nouvelle demande pour un projet donné + - Une interface de visualisation des demandes en cours/cloturée pour un projet donné + - Une interface de visualisation de détails d'une demande et des commentaires associés, avec possibilité d'ajouter un commentaire + - Une interface de création d'une nouvelle demande pour un projet donné +- Interfaces pour le rôle "développeur": + - Une interface de création d'un nouveau projet + - Une interface de création d'un nouveau compte client + - Une interface de visualisation des clients (listing) + - Une interface de visualisation des projets (listing) + - Une interface de visualisation des dernières demandes ouvertes (multi-projet) + - Une interface de visualisation de détails d'une demande et des commentaires associés, avec possibilité d'ajouter un commentaire et de changer l'état d'une demande + +## Modèle de données + +### User (`User`) + +#### Attributs + +- `id:int (unique)` Identifiant (clé primaire) de l'utilisateur +- `username:string (unique)` Nom de l'utilisateur +- `password:string` Mot de passe de l'utilisateur +- `projects:[]Project` Projets associés à l'utilisateur + +### Projet (`Project`) + +#### Attributs + +- `id:int (unique)` Identifiant (clé primaire) du projet +- `name:string` Nom du projet +- `users:[]User` Utilisateurs attachés au projet + +### Demande (`Request`) + +#### Attributs + +- `id:int (unique)` Identifiant (clé primaire) de la demande +- `title:string` Titre associé à la demande +- `author:User` Auteur de la demande (clé étrangère) +- `project:Project` Identifiant du projet associé (clé étrangère) +- `status:RequestStatus` Identifiant du statut courant de la demande +- `comments:[]Comment` Commentaire associé à la demande +- `createdAt:Date` Date de la création du commentaire + +### Statut de demande (`RequestStatus`) + +#### Attributs + +- `id:int (unique)` Identifiant (clé primaire) du statut de requête +- `label:string` Label associé au statut + +### Commentaire (`Comment`) + +#### Attributs + +- `id:int (unique)` Identifiant (clé primaire) du commentaires +- `request:Request` Identifiant de la demande associé au commentaire (clé étrangère) +- `text:string` Texte du commentaire +- `createdAt:Date` Date de la création du commentaire +- `author:User` Auteur du commentaire (clé étrangère) \ No newline at end of file diff --git a/misc/projects/ticketing_app.pdf b/misc/projects/ticketing_app.pdf new file mode 100644 index 0000000000000000000000000000000000000000..a36feae9746f09b9a27d7fb1008ae23ff509583f GIT binary patch literal 35662 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 z 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 literal 0 HcmV?d00001