├── .gitignore ├── README.md ├── backend ├── App │ ├── Data.php │ ├── Type.php │ └── Types │ │ ├── BookType.php │ │ ├── MutationType.php │ │ └── QueryType.php ├── README.md ├── composer.json ├── composer.lock ├── data │ └── books.json └── index.php ├── docker-compose.yml └── frontend ├── .babelrc ├── package-lock.json ├── package.json ├── src ├── api │ └── books.js ├── components │ ├── App │ │ ├── index.js │ │ └── styles.less │ ├── Row │ │ ├── index.js │ │ └── styles.less │ └── SearchBar │ │ ├── index.js │ │ └── styles.less ├── favicon.ico ├── index.html ├── index.js └── utils │ └── graphql.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | backend/vendor 2 | frontend/dist 3 | frontend/node_modules 4 | composer.phar 5 | .idea 6 | .DS_Store 7 | npm-debug.log* 8 | .env -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # graphql-example 2 | To start do `docker-compose up` 3 | 4 | About graphql: 5 | https://medium.com/@weblab_tech/graphql-everything-you-need-to-know-58756ff253d8 6 | -------------------------------------------------------------------------------- /backend/App/Data.php: -------------------------------------------------------------------------------- 1 | items = json_decode($jsonData, true); 26 | 27 | return $data; 28 | } 29 | 30 | /** 31 | * Writing an $ items array to a json file 32 | * 33 | * @void 34 | */ 35 | public function save() 36 | { 37 | $jsonData = json_encode($this->items); 38 | file_put_contents(self::JSON_DATA_PATH, $jsonData); 39 | } 40 | 41 | /** 42 | * Loaded data filtering on an array of parameters from the request 43 | * 44 | * @param array $args 45 | * @return array 46 | */ 47 | public function filter($args) 48 | { 49 | $result = $this->items; 50 | 51 | foreach ($args as $name => $value) { 52 | $result = array_filter($result, function($item) use ($name, $value) { 53 | return $item[$name] == $value; 54 | }); 55 | } 56 | 57 | return $result; 58 | } 59 | 60 | /** 61 | * Update record by a key 62 | * 63 | * @param int $id 64 | * @param array $data 65 | * @return mixed 66 | */ 67 | public function update($id, $data) 68 | { 69 | $index = array_search($id, array_column($this->items, $this->key)); 70 | 71 | foreach ($data as $key => $value) { 72 | if ($key !== $this->key) { 73 | $this->items[$index][$key] = $value; 74 | } 75 | } 76 | 77 | $this->save(); 78 | 79 | return $this->items[$index]; 80 | } 81 | } -------------------------------------------------------------------------------- /backend/App/Type.php: -------------------------------------------------------------------------------- 1 | [ 18 | 'id' => [ 19 | 'type' => Type::id(), 20 | 'description' => 'ID', 21 | ], 22 | 'title' => [ 23 | 'type' => Type::string(), 24 | 'description' => 'Book title', 25 | ], 26 | 27 | 'isbn' => [ 28 | 'type' => Type::string(), 29 | 'description' => 'ISBN', 30 | ], 31 | 'author' => [ 32 | 'type' => Type::string(), 33 | 'description' => 'Book author', 34 | ], 35 | 'author_name' => [ 36 | 'type' => Type::string(), 37 | 'deprecationReason' => 'Deprecated. Use author field', 38 | ], 39 | 'price' => [ 40 | 'type' => Type::float(), 41 | 'description' => 'Book price', 42 | ], 43 | ] 44 | ]; 45 | 46 | parent::__construct($config); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /backend/App/Types/MutationType.php: -------------------------------------------------------------------------------- 1 | function() { 19 | return [ 20 | 'book_update' => [ 21 | 'type' => Type::book(), 22 | 'description' => 'Book update', 23 | 'args' => [ 24 | 'id' => Type::id(), 25 | 'isbn' => Type::string(), 26 | 'title' => Type::string(), 27 | 'author' => Type::string(), 28 | 'price' => Type::float(), 29 | ], 30 | /** 31 | * Closure для возврата данных запроса. Можно легко заменить на любой тип данных, например результат выполнения sql запроса к базе данных. 32 | * Возвращаемые значения: массив объектов, в случае, если тип запроса это список типов объектов (Type::listOf()) или объект в случае, если тип запроса это одиночный тип объекта 33 | * 34 | * 35 | */ 36 | 'resolve' => function ($root, $args) { 37 | if (!isset($args['id'])) { 38 | throw new \Exception('ID parameter is required'); 39 | } 40 | 41 | return Data::load()->update($args['id'], $args); 42 | } 43 | ] 44 | ]; 45 | } 46 | ]; 47 | 48 | parent::__construct($config); 49 | } 50 | } -------------------------------------------------------------------------------- /backend/App/Types/QueryType.php: -------------------------------------------------------------------------------- 1 | function() { 19 | return [ 20 | 'books' => [ 21 | 'type' => Type::listOf(Type::book()), 22 | 'description' => 'Return books list', 23 | 'args' => [ 24 | 'title' => Type::string(), 25 | 'isbn' => Type::int(), 26 | 'author' => Type::string(), 27 | 'price' => Type::float(), 28 | ], 29 | 'resolve' => function ($root, $args) { 30 | return Data::load()->filter($args); 31 | } 32 | ], 33 | ]; 34 | } 35 | ]; 36 | 37 | parent::__construct($config); 38 | } 39 | } -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | # GraphQL work example 2 | 3 | Requests example: 4 | 5 | 1. Records list receive: 6 | query { 7 | books { 8 | id 9 | title 10 | isbn 11 | author 12 | } 13 | } 14 | 2. Search records by author field: 15 | query { 16 | books(author: "Robin Nixon") { 17 | id 18 | title 19 | isbn 20 | author 21 | } 22 | } 23 | 3. Update record attributes: 24 | mutation { 25 | book_update(id: 3, author: "New author", title: "New title") { 26 | id 27 | title 28 | isbn 29 | author 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /backend/composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "require": { 3 | "webonyx/graphql-php": "^0.11.5" 4 | }, 5 | "autoload": { 6 | "psr-4": { 7 | "App\\": "App/" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /backend/composer.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_readme": [ 3 | "This file locks the dependencies of your project to a known state", 4 | "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", 5 | "This file is @generated automatically" 6 | ], 7 | "content-hash": "8ca7b3147be26351b86ae5e5c3449825", 8 | "packages": [ 9 | { 10 | "name": "webonyx/graphql-php", 11 | "version": "v0.11.5", 12 | "source": { 13 | "type": "git", 14 | "url": "https://github.com/webonyx/graphql-php.git", 15 | "reference": "b97cad0f4a50131c85d9224e8e36ebbcf1c6b425" 16 | }, 17 | "dist": { 18 | "type": "zip", 19 | "url": "https://api.github.com/repos/webonyx/graphql-php/zipball/b97cad0f4a50131c85d9224e8e36ebbcf1c6b425", 20 | "reference": "b97cad0f4a50131c85d9224e8e36ebbcf1c6b425", 21 | "shasum": "" 22 | }, 23 | "require": { 24 | "ext-mbstring": "*", 25 | "php": ">=5.5,<8.0-DEV" 26 | }, 27 | "require-dev": { 28 | "phpunit/phpunit": "^4.8", 29 | "psr/http-message": "^1.0" 30 | }, 31 | "suggest": { 32 | "psr/http-message": "To use standard GraphQL server", 33 | "react/promise": "To leverage async resolving on React PHP platform" 34 | }, 35 | "type": "library", 36 | "autoload": { 37 | "files": [ 38 | "src/deprecated.php" 39 | ], 40 | "psr-4": { 41 | "GraphQL\\": "src/" 42 | } 43 | }, 44 | "notification-url": "https://packagist.org/downloads/", 45 | "license": [ 46 | "BSD" 47 | ], 48 | "description": "A PHP port of GraphQL reference implementation", 49 | "homepage": "https://github.com/webonyx/graphql-php", 50 | "keywords": [ 51 | "api", 52 | "graphql" 53 | ], 54 | "time": "2017-12-12T09:03:21+00:00" 55 | } 56 | ], 57 | "packages-dev": [], 58 | "aliases": [], 59 | "minimum-stability": "stable", 60 | "stability-flags": [], 61 | "prefer-stable": false, 62 | "prefer-lowest": false, 63 | "platform": [], 64 | "platform-dev": [] 65 | } 66 | -------------------------------------------------------------------------------- /backend/data/books.json: -------------------------------------------------------------------------------- 1 | [ 2 | {"id":1,"title":"HTML5 for iOS and Android","isbn":" 978-0071756334","author":"Robin Nixon","price":25.6}, 3 | {"id":2,"title":"PHP for the Web: Visual QuickStart Guide (5th Edition)","isbn":"978-0134291253","author":"Larry Ullman","price":45.1}, 4 | {"id":3,"title":"Learning PHP, MySQL & JavaScript","isbn":"978-1491918661","author":"Robin Nixon","price":90.2} 5 | ] -------------------------------------------------------------------------------- /backend/index.php: -------------------------------------------------------------------------------- 1 | new QueryType(), 25 | 'mutation' => new MutationType(), 26 | ]), $query)->toArray(); 27 | 28 | } catch (\Exception $e) { 29 | $result = [ 30 | 'error' => [ 31 | 'message' => $e->getMessage() 32 | ] 33 | ]; 34 | } 35 | 36 | // Result 37 | header('Content-Type: application/json'); 38 | echo json_encode($result); -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | services: 4 | backend: 5 | restart: 'always' 6 | image: php:7.1 7 | command: php -S 0.0.0.0:8888 /backend/index.php 8 | volumes: 9 | - ./backend/:/backend 10 | ports: 11 | - '8888:8888' 12 | working_dir: /backend 13 | composer: 14 | restart: 'no' 15 | image: composer:latest 16 | command: install 17 | volumes: 18 | - ./backend/:/backend 19 | working_dir: /backend 20 | frontend: 21 | image: node:carbon 22 | command: npm run boot 23 | ports: 24 | - '8080:8080' 25 | tty: true 26 | volumes: 27 | - ./frontend:/frontend 28 | depends_on: 29 | - backend 30 | working_dir: /frontend -------------------------------------------------------------------------------- /frontend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015" , "react", "stage-0" 4 | ], 5 | "plugins": ["transform-decorators-legacy"], 6 | "env": { 7 | "test": { 8 | "presets": [["es2015"], "react", "stage-0"] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-example", 3 | "version": "2.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "repository": "git@github.com:weblab-technology/graphql-example.git", 7 | "license": "MIT", 8 | "scripts": { 9 | "start": "NODE_ENV='development' webpack-dev-server --host 0.0.0.0 --inline", 10 | "build": "NODE_ENV=production webpack -p --display-error-details ", 11 | "boot": "npm install && npm start" 12 | }, 13 | "dependencies": { 14 | "classnames": "^2.2.5", 15 | "react": "^16.2.0", 16 | "react-dom": "^16.2.0", 17 | "autoprefixer": "^7.1.2", 18 | "babel-core": "^6.25.0", 19 | "babel-loader": "^7.1.1", 20 | "babel-plugin-check-es2015-constants": "6.22.0", 21 | "babel-plugin-external-helpers": "6.22.0", 22 | "babel-plugin-syntax-jsx": "6.18.0", 23 | "babel-plugin-transform-class-properties": "6.24.1", 24 | "babel-plugin-transform-decorators-legacy": "^1.3.4", 25 | "babel-plugin-transform-es2015-arrow-functions": "6.22.0", 26 | "babel-plugin-transform-es2015-block-scoped-functions": "6.22.0", 27 | "babel-plugin-transform-es2015-block-scoping": "6.24.1", 28 | "babel-plugin-transform-es2015-classes": "6.24.1", 29 | "babel-plugin-transform-es2015-computed-properties": "6.24.1", 30 | "babel-plugin-transform-es2015-destructuring": "6.23.0", 31 | "babel-plugin-transform-es2015-duplicate-keys": "6.24.1", 32 | "babel-plugin-transform-es2015-for-of": "6.23.0", 33 | "babel-plugin-transform-es2015-function-name": "6.24.1", 34 | "babel-plugin-transform-es2015-literals": "6.22.0", 35 | "babel-plugin-transform-es2015-modules-commonjs": "6.24.1", 36 | "babel-plugin-transform-es2015-object-super": "6.24.1", 37 | "babel-plugin-transform-es2015-parameters": "6.24.1", 38 | "babel-plugin-transform-es2015-shorthand-properties": "6.24.1", 39 | "babel-plugin-transform-es2015-spread": "6.22.0", 40 | "babel-plugin-transform-es2015-sticky-regex": "6.24.1", 41 | "babel-plugin-transform-es2015-template-literals": "6.22.0", 42 | "babel-plugin-transform-es2015-typeof-symbol": "6.23.0", 43 | "babel-plugin-transform-es2015-unicode-regex": "6.24.1", 44 | "babel-plugin-transform-export-extensions": "6.22.0", 45 | "babel-preset-es2015": "^6.24.1", 46 | "babel-preset-es2016": "^6.24.1", 47 | "babel-preset-react": "^6.24.1", 48 | "babel-preset-stage-0": "^6.24.1", 49 | "babel-preset-stage-1": "^6.24.1", 50 | "clean-webpack-plugin": "^0.1.16", 51 | "compression-webpack-plugin": "^1.0.0", 52 | "css-loader": "^0.28.4", 53 | "extract-text-webpack-plugin": "^3.0.0", 54 | "file-loader": "^0.11.2", 55 | "html-webpack-plugin": "^2.30.1", 56 | "less": "^3.0.1", 57 | "less-loader": "^4.0.6", 58 | "prop-types": "^15.6.0", 59 | "style-loader": "^0.18.2", 60 | "webpack": "3.4.1", 61 | "webpack-dev-server": "^2.6.1" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /frontend/src/api/books.js: -------------------------------------------------------------------------------- 1 | import {graphql} from './../utils/graphql'; 2 | 3 | const client = graphql(process.env.API_URL || 'http://127.0.0.1:8888'); 4 | 5 | export const loadAll = () => { 6 | const query = ` 7 | query { 8 | books { 9 | id 10 | title 11 | isbn 12 | author 13 | } 14 | }`; 15 | 16 | return client.query(query); 17 | }; 18 | 19 | export const search = (value) => { 20 | const query = ` 21 | query { 22 | books(author: "${value}") { 23 | id 24 | title 25 | isbn 26 | author 27 | } 28 | } 29 | `; 30 | 31 | return client.query(query); 32 | }; 33 | 34 | export const update = (id, author, title) => { 35 | const query = ` 36 | mutation { 37 | book_update(id: ${id}, author: "${author}", title: "${title}") { 38 | id 39 | title 40 | isbn 41 | author 42 | } 43 | } 44 | `; 45 | 46 | return client.query(query); 47 | }; 48 | -------------------------------------------------------------------------------- /frontend/src/components/App/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import {loadAll, search, update} from './../../api/books'; 3 | import SearchBar from './../SearchBar'; 4 | import Row from './../Row'; 5 | import styles from './styles.less'; 6 | 7 | export default class App extends Component { 8 | state = { 9 | books: [], 10 | filterText: '', 11 | }; 12 | 13 | componentDidMount() { 14 | this.getAll(); 15 | } 16 | 17 | getAll = () => { 18 | loadAll().then(response => { 19 | this.setState({ 20 | filterText: '', 21 | books: response.data.books, 22 | }); 23 | }); 24 | }; 25 | 26 | getFiltered = (text) => { 27 | search(text).then( response => { 28 | this.setState({ 29 | filterText: text, 30 | books: response.data.books, 31 | }); 32 | }); 33 | }; 34 | 35 | onFilter = (value) => { 36 | if (value) { 37 | this.getFiltered(value); 38 | } else { 39 | this.getAll(); 40 | } 41 | }; 42 | 43 | onUpdateBook = (id, author, name) => { 44 | update(id, author, name).then( response => { 45 | this.setState({ 46 | books: this.state.books.map( book => book.id !== id? book: response.data.book_update), 47 | }) 48 | }); 49 | }; 50 | 51 | render() { 52 | const { filterText, books } = this.state; 53 | 54 | return ( 55 |
56 | 60 | 61 |
62 | {books.map( book => ( 63 | 68 | ))} 69 |
70 |
71 | ) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /frontend/src/components/App/styles.less: -------------------------------------------------------------------------------- 1 | :global { 2 | /*! normalize.css v8.0.0 | MIT License | github.com/necolas/normalize.css */ 3 | button, hr, input { 4 | overflow: visible 5 | } 6 | progress, sub, sup { 7 | vertical-align: baseline 8 | } 9 | [type=checkbox], [type=radio], legend { 10 | box-sizing: border-box; 11 | padding: 0 12 | } 13 | html { 14 | line-height: 1.15; 15 | -webkit-text-size-adjust: 100% 16 | } 17 | body { 18 | margin: 0 19 | } 20 | h1 { 21 | font-size: 2em; 22 | margin: .67em 0 23 | } 24 | hr { 25 | box-sizing: content-box; 26 | height: 0 27 | } 28 | code, kbd, pre, samp { 29 | font-family: monospace, monospace; 30 | font-size: 1em 31 | } 32 | a { 33 | background-color: transparent 34 | } 35 | abbr[title] { 36 | border-bottom: none; 37 | text-decoration: underline; 38 | text-decoration: underline dotted 39 | } 40 | b, strong { 41 | font-weight: bolder 42 | } 43 | small { 44 | font-size: 80% 45 | } 46 | sub, sup { 47 | font-size: 75%; 48 | line-height: 0; 49 | position: relative 50 | } 51 | sub { 52 | bottom: -.25em 53 | } 54 | sup { 55 | top: -.5em 56 | } 57 | img { 58 | border-style: none 59 | } 60 | button, input, optgroup, select, textarea { 61 | font-family: inherit; 62 | font-size: 100%; 63 | line-height: 1.15; 64 | margin: 0 65 | } 66 | button, select { 67 | text-transform: none 68 | } 69 | [type=button], [type=reset], [type=submit], button { 70 | -webkit-appearance: button 71 | } 72 | [type=button]::-moz-focus-inner, [type=reset]::-moz-focus-inner, [type=submit]::-moz-focus-inner, button::-moz-focus-inner { 73 | border-style: none; 74 | padding: 0 75 | } 76 | [type=button]:-moz-focusring, [type=reset]:-moz-focusring, [type=submit]:-moz-focusring, button:-moz-focusring { 77 | outline: ButtonText dotted 1px 78 | } 79 | fieldset { 80 | padding: .35em .75em .625em 81 | } 82 | legend { 83 | color: inherit; 84 | display: table; 85 | max-width: 100%; 86 | white-space: normal 87 | } 88 | textarea { 89 | overflow: auto 90 | } 91 | [type=number]::-webkit-inner-spin-button, [type=number]::-webkit-outer-spin-button { 92 | height: auto 93 | } 94 | [type=search] { 95 | -webkit-appearance: textfield; 96 | outline-offset: -2px 97 | } 98 | [type=search]::-webkit-search-decoration { 99 | -webkit-appearance: none 100 | } 101 | ::-webkit-file-upload-button { 102 | -webkit-appearance: button; 103 | font: inherit 104 | } 105 | details { 106 | display: block 107 | } 108 | summary { 109 | display: list-item 110 | } 111 | [hidden], template { 112 | display: none 113 | } 114 | 115 | body { 116 | color: #333333; 117 | background: #f0f0f0; 118 | font-family: Arial, sans-serif; 119 | font-size: 14px; 120 | line-height: 1.3; 121 | } 122 | } 123 | 124 | .container { 125 | max-width: 500px; 126 | margin: 0 auto; 127 | background: #ffffff; 128 | height: 100vh; 129 | display: flex; 130 | flex-direction: column; 131 | } 132 | 133 | .list { 134 | flex: 1 1 auto; 135 | overflow: auto; 136 | } 137 | 138 | .list::-webkit-scrollbar { 139 | width: 4px; 140 | } 141 | 142 | .list::-webkit-scrollbar-track { 143 | background: #ffffff; 144 | width: 4px; 145 | } 146 | 147 | .list::-webkit-scrollbar-thumb { 148 | background-color: #666; 149 | border-radius: 4px; 150 | } -------------------------------------------------------------------------------- /frontend/src/components/Row/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styles from './styles.less'; 4 | 5 | export default class Row extends Component { 6 | static propTypes = { 7 | book: PropTypes.shape({ 8 | id: PropTypes.string, 9 | author: PropTypes.string, 10 | title: PropTypes.string, 11 | isbn: PropTypes.string, 12 | }), 13 | onUpdate: PropTypes.func, 14 | }; 15 | 16 | constructor(props) { 17 | super(props); 18 | 19 | this.state = { 20 | isEditing: false, 21 | book: this.props.book, 22 | } 23 | } 24 | 25 | handleSubmit = (e) => { 26 | e.preventDefault(); 27 | const { book: { id, title, author, isbn } } = this.state; 28 | 29 | if (title && author && isbn) { 30 | this.setState({ isEditing: false }); 31 | this.props.onUpdate(id, author, title, isbn); 32 | } 33 | }; 34 | 35 | handleChange = (fieldName) => (e) => { 36 | this.setState({ 37 | book: { 38 | ...this.state.book, 39 | [fieldName]: e.target.value, 40 | } 41 | }) 42 | }; 43 | 44 | handleEdit = (e) => { 45 | e.preventDefault(); 46 | 47 | this.setState({ 48 | isEditing: true, 49 | }) 50 | }; 51 | 52 | handleCancel = (e) => { 53 | e.preventDefault(); 54 | 55 | this.setState({ 56 | isEditing: false, 57 | book: this.props.book, 58 | }) 59 | }; 60 | 61 | renderEdit() { 62 | const {book: {author, title, isbn}} = this.state; 63 | 64 | return ( 65 |
66 |
67 | 68 | 69 |
70 |
71 | 72 | 73 |
74 |
75 | 76 | 77 |
78 |
79 | 82 | 85 |
86 |
87 | ) 88 | } 89 | 90 | renderRow(){ 91 | const { book } = this.props; 92 | 93 | return ( 94 |
95 |
96 | {book.author} - {book.title} 97 |
98 |
99 | {book.isbn} 100 |
101 | Edit 102 |
103 | ) 104 | } 105 | 106 | render(){ 107 | const { isEditing } = this.state; 108 | 109 | return ( 110 |
111 | {isEditing ? this.renderEdit() : this.renderRow()} 112 |
113 | ) 114 | } 115 | } -------------------------------------------------------------------------------- /frontend/src/components/Row/styles.less: -------------------------------------------------------------------------------- 1 | .container { 2 | padding: 10px 15px; 3 | border-bottom: 1px solid #aaa; 4 | } 5 | 6 | .name { 7 | font-size: 16px; 8 | margin-bottom: 2px; 9 | } 10 | 11 | .isbn { 12 | color: #999999; 13 | font-size: 14px; 14 | margin-bottom: 5px; 15 | } 16 | 17 | .edit { 18 | color: #17a2b8 !important; 19 | text-decoration: underline !important; 20 | -webkit-transition: all .15s; 21 | -moz-transition: all .15s; 22 | -ms-transition: all .15s; 23 | -o-transition: all .15s; 24 | transition: all .15s; 25 | cursor: pointer; 26 | } 27 | 28 | .edit:hover { 29 | opacity: .85; 30 | } 31 | 32 | .form { 33 | 34 | } 35 | 36 | .item { 37 | margin-bottom: 10px; 38 | } 39 | 40 | .item label { 41 | font-size: 14px; 42 | color: #999999; 43 | margin-bottom: 5px; 44 | } 45 | 46 | .input { 47 | height: 40px; 48 | border-radius: 4px; 49 | background: #ffffff; 50 | font-family: Arial, sans-serif; 51 | box-sizing: border-box; 52 | width: 100%; 53 | padding: 0 15px; 54 | color: #333333; 55 | box-shadow: none; 56 | border: 1px solid #999; 57 | } 58 | 59 | .input:focus { 60 | outline: none; 61 | border-color: #666; 62 | } 63 | 64 | .controls { 65 | display: flex; 66 | justify-content: space-between; 67 | } 68 | 69 | .submit, 70 | .cancel { 71 | flex: 1 1 0%; 72 | height: 40px; 73 | line-height: 38px; 74 | border-radius: 4px; 75 | font-size: 14px; 76 | text-align: center; 77 | color: #ffffff !important; 78 | text-decoration: none !important; 79 | border: none !important; 80 | cursor: pointer; 81 | } 82 | 83 | .submit:focus, 84 | .cancel:focus { 85 | outline: none; 86 | } 87 | 88 | .submit { 89 | margin-right: 15px; 90 | background-color: #007bff; 91 | } 92 | 93 | .cancel { 94 | background-color: #6c757d; 95 | } -------------------------------------------------------------------------------- /frontend/src/components/SearchBar/index.js: -------------------------------------------------------------------------------- 1 | import React, {Component} from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import styles from './styles.less'; 4 | 5 | export default class SearchBar extends Component { 6 | static propTypes = { 7 | filterText: PropTypes.string, 8 | onFilter: PropTypes.func, 9 | }; 10 | 11 | static defaultProps = { 12 | value: '' 13 | }; 14 | 15 | state = { 16 | value: this.props.filterText, 17 | }; 18 | 19 | handleChange = (e) => { 20 | this.setState({ 21 | value: e.target.value, 22 | }) 23 | }; 24 | 25 | handleSubmit = (e) => { 26 | e.preventDefault(); 27 | this.props.onFilter(this.state.value); 28 | }; 29 | 30 | render() { 31 | const { value } = this.state; 32 | 33 | return ( 34 |
38 | 45 | 48 |
49 | ) 50 | } 51 | } -------------------------------------------------------------------------------- /frontend/src/components/SearchBar/styles.less: -------------------------------------------------------------------------------- 1 | 2 | .form { 3 | display: flex; 4 | flex: 0 0 50px; 5 | } 6 | 7 | .input { 8 | flex: 1 1 0%; 9 | height: 50px; 10 | background: #fff; 11 | font-family: Arial, sans-serif; 12 | text-align: left; 13 | -webkit-box-sizing: border-box; 14 | -moz-box-sizing: border-box; 15 | box-sizing: border-box; 16 | padding: 0 15px; 17 | font-size: 16px; 18 | color: #333; 19 | border:1px solid #999; 20 | } 21 | 22 | .input:focus { 23 | outline: none !important; 24 | } 25 | 26 | .button { 27 | flex: 0 0 100px; 28 | user-select: none; 29 | -webkit-appearance: none; 30 | -moz-appearance: none; 31 | appearance: none; 32 | height: 50px; 33 | line-height: 48px; 34 | color: #fff !important; 35 | text-decoration: none !important; 36 | border: none; 37 | box-shadow: none; 38 | font-size: 14px; 39 | text-transform: uppercase; 40 | text-align: center; 41 | padding: 0 10px; 42 | background: #007bff; 43 | transition: all 0.15s ease; 44 | cursor: pointer; 45 | } 46 | 47 | .button:hover { 48 | opacity: 0.9; 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/weblab-technology/graphql-example/73aaa93925575faec7748b6892355da4bd551f8c/frontend/src/favicon.ico -------------------------------------------------------------------------------- /frontend/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | GraphQL example 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './components/App'; 4 | 5 | ReactDOM.render( 6 | , 7 | document.getElementById('main') 8 | ); -------------------------------------------------------------------------------- /frontend/src/utils/graphql.js: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch'; 2 | 3 | export const graphql = (url) => { 4 | if (!url) throw new Error('Missing url parameter'); 5 | 6 | let headers = new Headers(); 7 | headers.append('Content-Type', 'application/json'); 8 | 9 | return { 10 | query: function (query, variables) { 11 | 12 | const req = new Request(url, { 13 | method: 'POST', 14 | body: JSON.stringify({ 15 | query: query, 16 | variables: variables 17 | }), 18 | headers: headers, 19 | }); 20 | 21 | return fetch(req).then(res => res.json()); 22 | } 23 | } 24 | }; -------------------------------------------------------------------------------- /frontend/webpack.config.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const webpack = require('webpack'); 4 | const path = require('path'); 5 | const isDEV = process.env.NODE_ENV === 'development'; 6 | const CompressionPlugin = require('compression-webpack-plugin' ); 7 | const HTMLPlugin = require( 'html-webpack-plugin' ); 8 | const CleanWebpackPlugin = require('clean-webpack-plugin'); 9 | const ExtractTextPlugin = require( 'extract-text-webpack-plugin' ); 10 | const options = { 11 | root : __dirname, 12 | appName : 'GraphQl', 13 | }; 14 | 15 | function createPlugins() { 16 | if(isDEV) { 17 | return [ 18 | new HTMLPlugin({ 19 | inject: true, 20 | template: path.resolve(options.root, 'src/index.html' ), 21 | favicon: path.resolve(options.root, 'src/favicon.ico' ) 22 | }), 23 | new ExtractTextPlugin({ filename: '[name].[contenthash].css', disable: false, allChunks: true }), 24 | new webpack.NoEmitOnErrorsPlugin() 25 | ]; 26 | } 27 | return [ 28 | new CleanWebpackPlugin(['dist'], { 29 | root: __dirname, 30 | verbose: true, 31 | }), 32 | new ExtractTextPlugin({ filename: '[name].[contenthash].css', disable: false, allChunks: true }), 33 | new HTMLPlugin({ 34 | inject: true, 35 | template: path.resolve(options.root, 'src/index.html' ), 36 | favicon: path.resolve(options.root, 'src/favicon.ico' ), 37 | minify: { 38 | removeComments: true, 39 | collapseWhitespace: true, 40 | removeRedundantAttributes: true, 41 | useShortDoctype: true, 42 | removeEmptyAttributes: true, 43 | removeStyleLinkTypeAttributes: true, 44 | keepClosingSlash: true, 45 | minifyJS: true, 46 | minifyCSS: true, 47 | minifyURLs: true 48 | } 49 | }), 50 | new webpack.optimize.OccurrenceOrderPlugin( ), 51 | new CompressionPlugin({ test: /\.(js|css|html)$/, threshold: 10240, minRatio: 0.8 }), 52 | new webpack.NoEmitOnErrorsPlugin( ) 53 | ] 54 | } 55 | 56 | const webpackConfig = { 57 | context: options.root, 58 | entry: path.resolve(options.root, 'src/index.js'), 59 | output: { 60 | path: path.join(options.root, './dist'), 61 | publicPath: '/', 62 | filename: '[name].[hash].js', 63 | }, 64 | devtool: !isDEV? false :'inline-source-map', 65 | watch: isDEV, 66 | watchOptions: { 67 | aggregateTimeout: 300 68 | }, 69 | module: { 70 | rules: [ 71 | { 72 | test: /\.js$/, 73 | exclude: /node_modules/, 74 | use: [ 75 | { 76 | loader: 'babel-loader' 77 | } 78 | ] 79 | }, 80 | { 81 | test: /\.less$/, 82 | exclude: /node_modules/, 83 | use: ExtractTextPlugin.extract({ 84 | fallback: 'style-loader', 85 | use: [ 86 | { 87 | loader: 'css-loader', 88 | options: { 89 | modules: true, 90 | localIdentName: '[name]__[local]--[hash:base64:5]', 91 | } 92 | }, 93 | 'less-loader' 94 | ] 95 | }) 96 | } 97 | ] 98 | }, 99 | plugins: createPlugins() 100 | }; 101 | 102 | module.exports = webpackConfig; --------------------------------------------------------------------------------