From 4f163d46ee6b8d75192984ece5b228c7153412b1 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Sun, 28 Jun 2020 17:10:38 -0500 Subject: [PATCH 01/19] update README greeting This change can be used as a starting timestamp to get a rough idea of how long the coding challenge took --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8604b43..d280bbe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Coding Exercise -Hello, _______________! +Hello, Lehman Black! Below is a coding exercise that we believe will allow you to show off your amazing development skills! From 84548a0835c8978025bf9e878e969a3df4376b61 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Sun, 28 Jun 2020 17:20:11 -0500 Subject: [PATCH 02/19] add resource route api/groups --- app/Group.php | 10 +++ app/Http/Controllers/GroupsController.php | 85 +++++++++++++++++++++++ routes/api.php | 1 + 3 files changed, 96 insertions(+) create mode 100644 app/Group.php create mode 100644 app/Http/Controllers/GroupsController.php diff --git a/app/Group.php b/app/Group.php new file mode 100644 index 0000000..6a61248 --- /dev/null +++ b/app/Group.php @@ -0,0 +1,10 @@ + Date: Sun, 28 Jun 2020 17:23:58 -0500 Subject: [PATCH 03/19] fix Group model path artisan defaults to putting generated models in the app root for some reason --- app/Http/Controllers/GroupsController.php | 10 +++++----- app/{ => Models}/Group.php | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) rename app/{ => Models}/Group.php (79%) diff --git a/app/Http/Controllers/GroupsController.php b/app/Http/Controllers/GroupsController.php index f2914cf..11253f9 100644 --- a/app/Http/Controllers/GroupsController.php +++ b/app/Http/Controllers/GroupsController.php @@ -2,7 +2,7 @@ namespace App\Http\Controllers; -use App\Group; +use App\Models\Group; use Illuminate\Http\Request; class GroupsController extends Controller @@ -41,7 +41,7 @@ public function store(Request $request) /** * Display the specified resource. * - * @param \App\Group $group + * @param \App\Models\Group $group * @return \Illuminate\Http\Response */ public function show(Group $group) @@ -52,7 +52,7 @@ public function show(Group $group) /** * Show the form for editing the specified resource. * - * @param \App\Group $group + * @param \App\Models\Group $group * @return \Illuminate\Http\Response */ public function edit(Group $group) @@ -64,7 +64,7 @@ public function edit(Group $group) * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request - * @param \App\Group $group + * @param \App\Models\Group $group * @return \Illuminate\Http\Response */ public function update(Request $request, Group $group) @@ -75,7 +75,7 @@ public function update(Request $request, Group $group) /** * Remove the specified resource from storage. * - * @param \App\Group $group + * @param \App\Models\Group $group * @return \Illuminate\Http\Response */ public function destroy(Group $group) diff --git a/app/Group.php b/app/Models/Group.php similarity index 79% rename from app/Group.php rename to app/Models/Group.php index 6a61248..cf9a09a 100644 --- a/app/Group.php +++ b/app/Models/Group.php @@ -1,6 +1,6 @@ Date: Sun, 28 Jun 2020 17:33:34 -0500 Subject: [PATCH 04/19] add group attributes --- app/Models/Group.php | 4 ++- .../2020_06_28_222744_create_groups_table.php | 34 +++++++++++++++++++ 2 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2020_06_28_222744_create_groups_table.php diff --git a/app/Models/Group.php b/app/Models/Group.php index cf9a09a..ef3534e 100644 --- a/app/Models/Group.php +++ b/app/Models/Group.php @@ -6,5 +6,7 @@ class Group extends Model { - // + protected $fillable = [ + 'group_name', + ]; } diff --git a/database/migrations/2020_06_28_222744_create_groups_table.php b/database/migrations/2020_06_28_222744_create_groups_table.php new file mode 100644 index 0000000..3960b33 --- /dev/null +++ b/database/migrations/2020_06_28_222744_create_groups_table.php @@ -0,0 +1,34 @@ +bigIncrements('id'); + $table->string('group_name'); + $table->timestamps(); + }); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('groups'); + } +} From 6b19cf5cea48d7a9c41a52bdfc04ed93017a2b32 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Sun, 28 Jun 2020 17:50:06 -0500 Subject: [PATCH 05/19] add group resource classes --- app/Http/Resources/GroupResource.php | 22 ++++++++++++++++++++++ app/Http/Resources/GroupsCollection.php | 21 +++++++++++++++++++++ database/factories/GroupFactory.php | 12 ++++++++++++ 3 files changed, 55 insertions(+) create mode 100644 app/Http/Resources/GroupResource.php create mode 100644 app/Http/Resources/GroupsCollection.php create mode 100644 database/factories/GroupFactory.php diff --git a/app/Http/Resources/GroupResource.php b/app/Http/Resources/GroupResource.php new file mode 100644 index 0000000..b139bc9 --- /dev/null +++ b/app/Http/Resources/GroupResource.php @@ -0,0 +1,22 @@ + $this->id, + 'group_name' => $this->group_name, + ]; + } +} diff --git a/app/Http/Resources/GroupsCollection.php b/app/Http/Resources/GroupsCollection.php new file mode 100644 index 0000000..b7b41b3 --- /dev/null +++ b/app/Http/Resources/GroupsCollection.php @@ -0,0 +1,21 @@ + $this->collection + ]; + } +} diff --git a/database/factories/GroupFactory.php b/database/factories/GroupFactory.php new file mode 100644 index 0000000..267e996 --- /dev/null +++ b/database/factories/GroupFactory.php @@ -0,0 +1,12 @@ +define(Group::class, function (Faker $faker) { + return [ + 'group_name' => $faker->firstName, + ]; +}); From 1aaf4bc2fdbe050752c3233fa746bbadc976333e Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Sun, 28 Jun 2020 18:48:20 -0500 Subject: [PATCH 06/19] add timestamps to group resource --- app/Http/Resources/GroupResource.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/Http/Resources/GroupResource.php b/app/Http/Resources/GroupResource.php index b139bc9..9201a89 100644 --- a/app/Http/Resources/GroupResource.php +++ b/app/Http/Resources/GroupResource.php @@ -17,6 +17,8 @@ public function toArray($request) return [ 'id' => $this->id, 'group_name' => $this->group_name, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, ]; } } From 3cb0a19dd2682457912dff64af1f0f356b882d0d Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Sun, 28 Jun 2020 19:04:42 -0500 Subject: [PATCH 07/19] implement group API This took longer than usual due to unfamiliarity with the Faker library and troubleshooting why the group_name attribute was not detected by the tests properly --- app/Http/Controllers/GroupsController.php | 46 ++++++++---- database/factories/GroupFactory.php | 2 +- package.json | 3 +- tests/Api/GroupTest.php | 86 +++++++++++++++++++++++ yarn.lock | 5 ++ 5 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 tests/Api/GroupTest.php diff --git a/app/Http/Controllers/GroupsController.php b/app/Http/Controllers/GroupsController.php index 11253f9..31261c3 100644 --- a/app/Http/Controllers/GroupsController.php +++ b/app/Http/Controllers/GroupsController.php @@ -2,8 +2,12 @@ namespace App\Http\Controllers; -use App\Models\Group; use Illuminate\Http\Request; +use Illuminate\Validation\Rule; + +use App\Http\Resources\GroupsCollection; +use App\Http\Resources\GroupResource; +use App\Models\Group; class GroupsController extends Controller { @@ -14,7 +18,7 @@ class GroupsController extends Controller */ public function index() { - // + return new GroupsCollection(Group::all()); } /** @@ -35,27 +39,35 @@ public function create() */ public function store(Request $request) { - // + $request->validate([ + 'group_name' => 'required|max:255', + ]); + + $group = Group::create($request->all()); + + return (new GroupResource($group)) + ->response() + ->setStatusCode(201); } /** * Display the specified resource. * - * @param \App\Models\Group $group + * @param int $id * @return \Illuminate\Http\Response */ - public function show(Group $group) + public function show($id) { - // + return new GroupResource(Group::findOrFail($id)); } /** * Show the form for editing the specified resource. * - * @param \App\Models\Group $group + * @param int $id * @return \Illuminate\Http\Response */ - public function edit(Group $group) + public function edit($id) { // } @@ -64,22 +76,28 @@ public function edit(Group $group) * Update the specified resource in storage. * * @param \Illuminate\Http\Request $request - * @param \App\Models\Group $group + * @param int $id * @return \Illuminate\Http\Response */ - public function update(Request $request, Group $group) + public function update(Request $request, $id) { - // + $group = Group::findOrFail($id); + $group->update($request->all()); + + return response()->json(null, 204); } /** * Remove the specified resource from storage. * - * @param \App\Models\Group $group + * @param int $id * @return \Illuminate\Http\Response */ - public function destroy(Group $group) + public function destroy($id) { - // + $group = Group::findOrFail($id); + $group->delete(); + + return response()->json(null, 204); } } diff --git a/database/factories/GroupFactory.php b/database/factories/GroupFactory.php index 267e996..1320b75 100644 --- a/database/factories/GroupFactory.php +++ b/database/factories/GroupFactory.php @@ -7,6 +7,6 @@ $factory->define(Group::class, function (Faker $faker) { return [ - 'group_name' => $faker->firstName, + 'group_name' => $faker->name, ]; }); diff --git a/package.json b/package.json index 2761705..ccb7d0a 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "react-dom": "^16.8.6", "react-scripts": "3.0.1", "react-test-renderer": "^16.8.6", - "semantic-ui-react": "^0.87.3" + "semantic-ui-react": "^0.87.3", + "yarn": "^1.22.4" }, "scripts": { "start": "react-scripts start", diff --git a/tests/Api/GroupTest.php b/tests/Api/GroupTest.php new file mode 100644 index 0000000..876a04f --- /dev/null +++ b/tests/Api/GroupTest.php @@ -0,0 +1,86 @@ + 'Bible Study', + ]; + $response = $this->json('POST', '/api/groups', $expected); + $response + ->assertStatus(201) + ->assertJsonFragment($expected); + + } + + public function testGroupRetrieved() + { + $group = factory('App\Models\Group')->create(); + + $response = $this->json('GET', '/api/groups/' . $group->id); + $response + ->assertStatus(200) + ->assertJsonStructure([ + 'data' => [ + 'group_name', + 'created_at', + 'updated_at' + ] + ]); + } + + public function testAllGroupsRetrieved() + { + $group = factory('App\Models\Group', 25)->create(); + + $response = $this->json('GET', '/api/groups'); + $response + ->assertStatus(200) + ->assertJsonCount(25, 'data'); + } + + public function testNoGroupRetrieved() + { + $group = factory('App\Models\Group')->create(); + Group::destroy($group->id); + + $response = $this->json('GET', '/api/groups/' . $group->id); + $response->assertStatus(404); + } + + public function testGroupUpdated() + { + $group = factory('App\Models\Group')->create(); + + $updatedGroupName = $this->faker->name(); + $response = $this->json('PUT', '/api/groups/' . $group->id, [ + 'group_name' => $updatedGroupName + ]); + $response->assertStatus(204); + + $updatedGroup = Group::find($group->id); + $this->assertEquals($updatedGroupName, $updatedGroup->group_name); + } + + public function testGroupDeleted() + { + $group = factory('App\Models\Group')->create(); + + $deleteResponse = $this->json('DELETE', '/api/groups/' . $group->id); + $deleteResponse->assertStatus(204); + + $response = $this->json('GET', '/api/groups/' . $group->id); + $response->assertStatus(404); + + } +} diff --git a/yarn.lock b/yarn.lock index f8ffcd6..4c7299f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10358,3 +10358,8 @@ yargs@^13.3.0: which-module "^2.0.0" y18n "^4.0.0" yargs-parser "^13.1.1" + +yarn@^1.22.4: + version "1.22.4" + resolved "https://registry.yarnpkg.com/yarn/-/yarn-1.22.4.tgz#01c1197ca5b27f21edc8bc472cd4c8ce0e5a470e" + integrity sha512-oYM7hi/lIWm9bCoDMEWgffW8aiNZXCWeZ1/tGy0DWrN6vmzjCXIKu2Y21o8DYVBUtiktwKcNoxyGl/2iKLUNGA== From 7351a12cf93dac879c0b09c82fd75a0e0c2a5acb Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Sun, 28 Jun 2020 19:24:16 -0500 Subject: [PATCH 08/19] add CsvUpload component --- src/CsvUpload.js | 16 ++++++++++++++++ src/index.js | 2 ++ 2 files changed, 18 insertions(+) create mode 100644 src/CsvUpload.js diff --git a/src/CsvUpload.js b/src/CsvUpload.js new file mode 100644 index 0000000..900903a --- /dev/null +++ b/src/CsvUpload.js @@ -0,0 +1,16 @@ +import React, { Component } from 'react' +import { Input } from 'semantic-ui-react' + +class CsvUpload extends Component { + constructor(props) { + super(props); + this.state = {}; + } + render() { + return ( + + ); + } +} + +export default CsvUpload; diff --git a/src/index.js b/src/index.js index 4ab037e..28e4a60 100644 --- a/src/index.js +++ b/src/index.js @@ -3,6 +3,7 @@ import ReactDOM from "react-dom"; import { Container, Header } from "semantic-ui-react"; import ResultsList from "./ResultsList"; +import CsvUpload from "./CsvUpload"; const App = ({ children }) => ( @@ -19,6 +20,7 @@ document.head.appendChild(styleLink); ReactDOM.render( + , document.getElementById("root") From ce65df2c1262e607dc0a9aa6409ee56dbbc4c8b9 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Sun, 28 Jun 2020 20:20:22 -0500 Subject: [PATCH 09/19] handle click events in CsvUpload component extra time taken to find out if React had an alternative to getElementById and learning about refs --- src/CsvUpload.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/CsvUpload.js b/src/CsvUpload.js index 900903a..12f24b8 100644 --- a/src/CsvUpload.js +++ b/src/CsvUpload.js @@ -1,14 +1,28 @@ import React, { Component } from 'react' -import { Input } from 'semantic-ui-react' +import { Button, Input } from 'semantic-ui-react' class CsvUpload extends Component { constructor(props) { super(props); + this.myRef = React.createRef(); + this.handleClick = this.handleClick.bind(this); + this.getFileInputNode = this.getFileInputNode.bind(this); this.state = {}; } + getFileInputNode() { + return this.myRef.current.inputRef.current; + } + + handleClick() { + let files = this.getFileInputNode().files; + + if (files.length > 0) { + console.log(files[0]); + } + } render() { return ( - + Upload}/> ); } } From b5b779af777dac9e0cec1cb6855108179869aae6 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Sun, 28 Jun 2020 21:48:42 -0500 Subject: [PATCH 10/19] add service to fetch Person resources --- package.json | 2 ++ src/PeopleService.js | 59 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 src/PeopleService.js diff --git a/package.json b/package.json index ccb7d0a..e124b07 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "dependencies": { "enzyme": "^3.10.0", "enzyme-adapter-react-16": "^1.14.0", + "lodash": "^4.17.15", + "papaparse": "^5.2.0", "react": "^16.8.6", "react-dom": "^16.8.6", "react-scripts": "3.0.1", diff --git a/src/PeopleService.js b/src/PeopleService.js new file mode 100644 index 0000000..5ec4c0e --- /dev/null +++ b/src/PeopleService.js @@ -0,0 +1,59 @@ +import _ from 'lodash'; +export default { + canProcessCsv: canProcessCsv, + process: process, + create: create, + update: update, + createOrUpdate: createOrUpdate +}; + +function canProcessCsv(fields) { + return _.isEqual(fields, ['id', 'first_name', 'last_name', 'email_address', 'status']); +} + +function process(data) { + Promise.all(data.map(createOrUpdate)).then(() => { + console.log('all people uploaded'); + }); + console.log(data); +} + +function update(person) { + return fetch("http://localhost:8000/api/people/" + person.id, { + method: 'PUT', headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(person) + }); +} + +function create(person) { + return fetch("http://localhost:8000/api/people", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(person) + }); +} + +function createOrUpdate(person) { + update(person) + .then(response => { + console.debug(response); + if (response.status == 404) { + console.log('creating person %o', person); + return create(person); + } else if (!response.ok) { + console.error('something went wrong creating person: %o' + person); + return Promise.reject('something went wrong creating person: ' + JSON.stringify(person)); + } else { + let person = response.json(); + console.log('updated person %o', person); + console.debug(person); + return person; + } + }); +} From 42664b7508e5758115932361d6ea2db365199ee9 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Sun, 28 Jun 2020 21:50:16 -0500 Subject: [PATCH 11/19] connect CsvUpload component to API services --- src/CsvUpload.js | 6 +++-- src/CsvUploadService.js | 53 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/CsvUploadService.js diff --git a/src/CsvUpload.js b/src/CsvUpload.js index 12f24b8..12a8c62 100644 --- a/src/CsvUpload.js +++ b/src/CsvUpload.js @@ -1,5 +1,6 @@ import React, { Component } from 'react' import { Button, Input } from 'semantic-ui-react' +import CsvUploadService from "./CsvUploadService"; class CsvUpload extends Component { constructor(props) { @@ -16,10 +17,11 @@ class CsvUpload extends Component { handleClick() { let files = this.getFileInputNode().files; - if (files.length > 0) { - console.log(files[0]); + if (files[0]) { + CsvUploadService.upload(files[0]); } } + render() { return ( Upload}/> diff --git a/src/CsvUploadService.js b/src/CsvUploadService.js new file mode 100644 index 0000000..4aa955a --- /dev/null +++ b/src/CsvUploadService.js @@ -0,0 +1,53 @@ +import Papaparse from 'papaparse'; +import PeopleService from "./PeopleService"; + +const enabledServices = [PeopleService]; + +/** + * @see https://www.papaparse.com/docs#config + */ +var defaults = { + complete: onParseComplete, + dynamicTyping: true, + error: onFileReaderError, + header: true, + quoteChar: '"', + skipEmptyLines: 'greedy', + worker: true, +} + +export default { + upload: upload, +}; + +function upload(file, config) { + if (!config) { + config = defaults; + } + + Papaparse.parse(file, config) +} + +function getCsvService(fields) { + for (let i = 0; i < enabledServices.length; i++) { + if (enabledServices[i].canProcessCsv(fields)) { + console.log(enabledServices[i]); + return enabledServices[i]; + } + } + return null; +} + +function onParseComplete(results, file) { + let fields = results.meta.fields; + let service = getCsvService(fields); + if (!service) { + return; // TODO handle unknown CSV format + } + + service.process(results.data); +} + +function onFileReaderError(error, file) { + console.error('file error in %s: %s', file, error); +} From 595e1b601169f0e92db59a0be3fa56e376ac0d7e Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Sun, 28 Jun 2020 21:59:19 -0500 Subject: [PATCH 12/19] add GroupService --- src/CsvUploadService.js | 3 ++- src/GroupsService.js | 59 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 src/GroupsService.js diff --git a/src/CsvUploadService.js b/src/CsvUploadService.js index 4aa955a..c8ab583 100644 --- a/src/CsvUploadService.js +++ b/src/CsvUploadService.js @@ -1,7 +1,8 @@ import Papaparse from 'papaparse'; import PeopleService from "./PeopleService"; +import GroupsService from "./GroupsService"; -const enabledServices = [PeopleService]; +const enabledServices = [PeopleService, GroupsService]; /** * @see https://www.papaparse.com/docs#config diff --git a/src/GroupsService.js b/src/GroupsService.js new file mode 100644 index 0000000..11b371c --- /dev/null +++ b/src/GroupsService.js @@ -0,0 +1,59 @@ +import _ from 'lodash'; +export default { + canProcessCsv: canProcessCsv, + process: process, + create: create, + update: update, + createOrUpdate: createOrUpdate +}; + +function canProcessCsv(fields) { + return _.isEqual(fields, ['id', 'group_name']); +} + +function process(data) { + Promise.all(data.map(createOrUpdate)).then(() => { + console.log('all people uploaded'); + }); + console.log(data); +} + +function update(group) { + return fetch("http://localhost:8000/api/groups/" + group.id, { + method: 'PUT', headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(group) + }); +} + +function create(group) { + return fetch("http://localhost:8000/api/groups", { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(group) + }); +} + +function createOrUpdate(group) { + update(group) + .then(response => { + console.debug(response); + if (response.status == 404) { + console.log('creating group %o', group); + return create(group); + } else if (!response.ok) { + console.error('something went wrong creating group: %o' + group); + return Promise.reject('something went wrong creating group: ' + JSON.stringify(group)); + } else { + let group = response.json(); + console.log('updated group %o', group); + console.debug(group); + return group; + } + }); +} From 7d87bd7dc14f90627a3d11e0886d3543fc557ed0 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Sun, 28 Jun 2020 22:38:31 -0500 Subject: [PATCH 13/19] add group_name to tie Person resources to Group resources in production this would use a foreign key and possibly a separate mapping table if needed. --- app/Http/Resources/PersonResource.php | 1 + app/Models/Person.php | 3 ++- database/factories/PersonFactory.php | 3 ++- src/PeopleService.js | 2 +- tests/Api/PeopleTest.php | 4 +++- 5 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/Http/Resources/PersonResource.php b/app/Http/Resources/PersonResource.php index 885b36d..02228fe 100644 --- a/app/Http/Resources/PersonResource.php +++ b/app/Http/Resources/PersonResource.php @@ -20,6 +20,7 @@ public function toArray($request) 'last_name' => $this->last_name, 'email_address' => $this->email_address, 'status' => $this->status, + 'group_name' => $this->group_name, 'created_at' => $this->created_at, 'updated_at' => $this->updated_at, ]; diff --git a/app/Models/Person.php b/app/Models/Person.php index 43d08cb..ffccde3 100644 --- a/app/Models/Person.php +++ b/app/Models/Person.php @@ -10,6 +10,7 @@ class Person extends Model 'first_name', 'last_name', 'email_address', - 'status' + 'status', + 'group_name', ]; } diff --git a/database/factories/PersonFactory.php b/database/factories/PersonFactory.php index ccb9195..66987fe 100644 --- a/database/factories/PersonFactory.php +++ b/database/factories/PersonFactory.php @@ -10,6 +10,7 @@ 'first_name' => $faker->firstName, 'last_name' => $faker->lastName, 'email_address' => $faker->email, - 'status' => (bool)random_int(0, 1) ? 'active' : 'archived' + 'status' => (bool)random_int(0, 1) ? 'active' : 'archived', + 'group_name' => $faker->name, ]; }); diff --git a/src/PeopleService.js b/src/PeopleService.js index 5ec4c0e..4f6f683 100644 --- a/src/PeopleService.js +++ b/src/PeopleService.js @@ -8,7 +8,7 @@ export default { }; function canProcessCsv(fields) { - return _.isEqual(fields, ['id', 'first_name', 'last_name', 'email_address', 'status']); + return _.isEqual(fields, ['id', 'first_name', 'last_name', 'email_address', 'status', 'group_name']); } function process(data) { diff --git a/tests/Api/PeopleTest.php b/tests/Api/PeopleTest.php index d5416f5..d5edd90 100644 --- a/tests/Api/PeopleTest.php +++ b/tests/Api/PeopleTest.php @@ -17,7 +17,8 @@ public function testPersonCreated() 'first_name' => 'Sally', 'last_name' => 'Ride', 'email_address' => 'sallyride@nasa.gov', - 'status' => 'archived' + 'status' => 'archived', + 'group_name' => 'Example Group', ]; $response = $this->json('POST', '/api/people', $expected); $response @@ -39,6 +40,7 @@ public function testPersonRetrieved() 'last_name', 'email_address', 'status', + 'group_name', 'created_at', 'updated_at' ] From 69307cfcab901627fbc59ab91b120c2253ab1609 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Sun, 28 Jun 2020 22:44:19 -0500 Subject: [PATCH 14/19] add people.group_name column migrations --- composer.json | 3 +- ..._031153_add_group_name_to_people_table.php | 33 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) create mode 100644 database/migrations/2020_06_29_031153_add_group_name_to_people_table.php diff --git a/composer.json b/composer.json index 7f0695e..a9e2afb 100644 --- a/composer.json +++ b/composer.json @@ -9,8 +9,9 @@ "license": "MIT", "require": { "php": "^7.2.5", - "fruitcake/laravel-cors": "^2.0", + "doctrine/dbal": "^2.10", "fideloper/proxy": "^4.0", + "fruitcake/laravel-cors": "^2.0", "laravel/framework": "^7.0", "laravel/tinker": "^2.0" }, diff --git a/database/migrations/2020_06_29_031153_add_group_name_to_people_table.php b/database/migrations/2020_06_29_031153_add_group_name_to_people_table.php new file mode 100644 index 0000000..6387ae9 --- /dev/null +++ b/database/migrations/2020_06_29_031153_add_group_name_to_people_table.php @@ -0,0 +1,33 @@ +string('group_name')->default(''); + // + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('people', function (Blueprint $table) { + $table->dropColumn('group_name'); + }); + } +} From 922e02d0abcefe3921c0716eef5cc3c51bb3efc9 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Sun, 28 Jun 2020 23:19:47 -0500 Subject: [PATCH 15/19] display group information --- src/CsvUpload.js | 7 ++- src/ResultsList.js | 110 ++++++++++++++++++++++++++++++++------------- src/index.js | 2 +- 3 files changed, 86 insertions(+), 33 deletions(-) diff --git a/src/CsvUpload.js b/src/CsvUpload.js index 12a8c62..711a157 100644 --- a/src/CsvUpload.js +++ b/src/CsvUpload.js @@ -1,5 +1,5 @@ import React, { Component } from 'react' -import { Button, Input } from 'semantic-ui-react' +import { Button, Header, Input, Segment } from 'semantic-ui-react' import CsvUploadService from "./CsvUploadService"; class CsvUpload extends Component { @@ -24,7 +24,10 @@ class CsvUpload extends Component { render() { return ( - Upload}/> + +
Upload CSV File
+ Upload}/> +
); } } diff --git a/src/ResultsList.js b/src/ResultsList.js index 3367c82..46eb01c 100644 --- a/src/ResultsList.js +++ b/src/ResultsList.js @@ -1,50 +1,100 @@ +import _ from 'lodash'; import React, { Component } from 'react' -import { Table } from 'semantic-ui-react' +import {Divider, Header, Segment, Table} from 'semantic-ui-react' class ResultsList extends Component { constructor(props) { super(props); - this.state = { data: [] }; + this.state = { + data: [], + group_data: [], + }; } componentDidMount() { fetch("http://localhost:8000/api/people") .then(response => response.json()) .then(data => this.setState({ data: data.data })); + + fetch("http://localhost:8000/api/groups") + .then(response => response.json()) + .then(data => this.setState({ group_data: data.data })); } render() { var data = this.state.data || []; + var group_data = this.state.group_data || []; - return ( - - - - First Name - Last Name - Email - Status - - - - - - { - data.map((person, index) => { - return ( - - { person.first_name } - { person.last_name } - { person.email_address } - { person.status } - - ); - }) - } + return ( +
+
People
+
+ + + First Name + Last Name + Email + Group + Status + + + + - -
- ); + { + data.map((person, index) => { + return ( + + { person.first_name } + { person.last_name } + { person.email_address } + { person.group_name } + { person.status } + + ); + }) + } + + + + +
Groups
+ { + group_data.map((group, index) => { + return ( + +
{group.group_name}
+ + + + First Name + Last Name + Email + Status + + + + + { + _.filter(data, {status: 'active', group_name: group.group_name}).map((person, index) => { + return ( + + { person.first_name } + { person.last_name } + { person.email_address } + { person.status } + + ); + }) + } + +
+
+ ); + }) + } + + ); } } diff --git a/src/index.js b/src/index.js index 28e4a60..0313080 100644 --- a/src/index.js +++ b/src/index.js @@ -20,7 +20,7 @@ document.head.appendChild(styleLink); ReactDOM.render( - + , document.getElementById("root") From eef99d7e049a51b90d9d6a759f5aae54edabf933 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Mon, 29 Jun 2020 00:50:37 -0500 Subject: [PATCH 16/19] add instructions for running and list changes --- README.md | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/README.md b/README.md index d280bbe..c4fe5cf 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,138 @@ The data will be displayed in a sortable table. You will need to determine the type of data in the CSV file based on the headers in the first row. Your program will output a list of Groups, and for each Group, a list of active People in that Group. +### Validating the changes +#### Running +You will need an empty MySQL database to run the example. I used docker to spin up +a server (I'm using version 5.7 due to the root account not working out of the box in +newer container versions) + +``` +docker run --name breezedb -e MYSQL_ROOT_PASSWORD=root -e MYSQL_DATABASE=laravel -p3306:3306 mysql:5.7 +``` + +I also had to install the php-sqlite3 module to get the tests to run. +The installation of `yarn` was missing from my system too, so I had to use `npm` + +``` +npm install -g yarn +``` + +After that I could follow the set up instructions above without any issues. +1. copy `.env.example` to .env and add database information +2. run `composer install` +3. run `yarn install` +4. start API using `php artisan serve` +5. start react with `yarn start` + +#### Changes Implemented +Added the API endpoint for `api/groups` and supports all the normal CRUD operations. + +The `group_name` column has been added to the `people` table. This can be any string +since it is not checked with the `groups`. In production this should be a foreign key. +This column is also displayed with the other `person` data in the React application. + +Automated testing has been added for the People and Groups APIs. They can be run with +``` +php artisan test +``` + +`curl` can be used to manually check API endpoints. The available routes are: + +``` +# php artisan route:list + ++--------+-----------+--------------------------+----------------+-----------------------------------------------+------------+ +| Domain | Method | URI | Name | Action | Middleware | ++--------+-----------+--------------------------+----------------+-----------------------------------------------+------------+ +| | GET|HEAD | api/groups | groups.index | App\Http\Controllers\GroupsController@index | api | +| | POST | api/groups | groups.store | App\Http\Controllers\GroupsController@store | api | +| | GET|HEAD | api/groups/create | groups.create | App\Http\Controllers\GroupsController@create | api | +| | GET|HEAD | api/groups/{group} | groups.show | App\Http\Controllers\GroupsController@show | api | +| | PUT|PATCH | api/groups/{group} | groups.update | App\Http\Controllers\GroupsController@update | api | +| | DELETE | api/groups/{group} | groups.destroy | App\Http\Controllers\GroupsController@destroy | api | +| | GET|HEAD | api/groups/{group}/edit | groups.edit | App\Http\Controllers\GroupsController@edit | api | +| | GET|HEAD | api/people | people.index | App\Http\Controllers\PeopleController@index | api | +| | POST | api/people | people.store | App\Http\Controllers\PeopleController@store | api | +| | GET|HEAD | api/people/create | people.create | App\Http\Controllers\PeopleController@create | api | +| | GET|HEAD | api/people/{person} | people.show | App\Http\Controllers\PeopleController@show | api | +| | PUT|PATCH | api/people/{person} | people.update | App\Http\Controllers\PeopleController@update | api | +| | DELETE | api/people/{person} | people.destroy | App\Http\Controllers\PeopleController@destroy | api | +| | GET|HEAD | api/people/{person}/edit | people.edit | App\Http\Controllers\PeopleController@edit | api | ++--------+-----------+--------------------------+----------------+-----------------------------------------------+------------+ +``` + +The React application now has a place at the top to upload CSV files. The type of +resource is automatically detected. If the CSV file is not a recognized resource +then nothing happens when you click the upload button (see the tasks remaining section) + +There is some console logs for when the upload finishes, but nothing displays on the screen. +You must also refresh the page to view the data changes + +The CSV files do not depend on each other so you can upload them in any order. +(benefit of using a string for the `person.group_name`. In production, I would +create any missing groups or default missing groups to an empty string) + +Sample CSV files can be found in `tests/sample_data`. + + +#### CSV Format +People +``` +id,first_name,last_name,email_address,status,group_name +1,"Alex","Ortiz-Rosado","alex@breezechms.com",active,"Bible Study" +2,"Jon","VerLee","jon@breezechms.com","archived","Bible Study" +3,"Fred","Flintstone","fredflintstone@example.com","active","Bible Study" +4,"Marie","Bourne","mbourne@example.com","active","Vacation" +5,"Wilma","Flintstone","wilmaflinstone@example.com","active","Elders" +``` + +Groups +``` +id,group_name +1,Volunteers +2,Elders +3,"Bible Study" +``` + +#### Known Issues +* **columns are not sortable. ...It's just a matter of adding the right `sortable` state as given in + https://react.semantic-ui.com/collections/table/#variations-sortable** +* `id` is required, but does not quite work as expected. It is an autoincrementing +column so new ID's may not match if you skip numbers or do not have the IDs in a sorted order +* `jest` tests for ResultsList is broken due to updates. Should just need to update the expected +data with the new JSX changes +* have not checked for how large a CSV can be processed +* uploading multiple files without refreshing didn't work during testing +* The PapaParse JavaScript library can be tweaked to be more friendly, but is kind of strict +* Some console errors due to 204 No Content responses from the API being handled improperly +using the defaults. I have noticed issues with the following: + * Columns should not have spaces between them. (Bad: `id, group_name`, Good: `id,group_name` ) + * PapaParse doesn't allow you set multiple quote characters, so I set it to use `"` + +#### Tasks Remaining +* UI feedback + * Add dimmer to show files are uploading and prevent multiple uploads accidently + * add upload progress (PapaParse lets you have a callback for each row / chain state updates to the fetch promises) + * notify user when upload finishes + * show results of the upload (created, updated, errors) + * add link in upload results to show error details + * automatically refresh tables + * allow user to download sample files + * notify user if no file selected or bad file selected + * breeze demo upload uses popup for uploads, would style to match production conventions +* better error handling +* abstract `PeopleService` and `GroupService` into a factory. All that really changes is the URL, +and the allowed fields +* verify CSRF or other XSS protection beyond React's defaults are needed +* If performance became an issue then the CSV parsing and API requests could be done in batches +with some modifications to the API POST/PUT endpoints. +* Update JS to match coding standards (current company does not use ES6+ so I'm a bit rusty) +* Add missing code documentation + + + + ### Testing We love TDD! So we’d love to see tests for the API and ReactJS application. Write automated tests to verify your results and account for gotchas (handling different column orders, invalid id's in the People CSV file, etc..). Classify your tests as either unit, integration, ui, or acceptance, but it is not required to use every type. From 58d309f490e0a3c2f615ea180290d7861d5286f0 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Mon, 29 Jun 2020 00:54:39 -0500 Subject: [PATCH 17/19] add sample csv files --- tests/sample_data/example-group-update.csv | 4 ++++ tests/sample_data/example-group.csv | 4 ++++ tests/sample_data/people-with-groups.csv | 6 ++++++ 3 files changed, 14 insertions(+) create mode 100644 tests/sample_data/example-group-update.csv create mode 100644 tests/sample_data/example-group.csv create mode 100644 tests/sample_data/people-with-groups.csv diff --git a/tests/sample_data/example-group-update.csv b/tests/sample_data/example-group-update.csv new file mode 100644 index 0000000..ce9362c --- /dev/null +++ b/tests/sample_data/example-group-update.csv @@ -0,0 +1,4 @@ +id,group_name +1,"Volunteers Update" +2,"Elders asdf" +3,"Bible Study ddddd" \ No newline at end of file diff --git a/tests/sample_data/example-group.csv b/tests/sample_data/example-group.csv new file mode 100644 index 0000000..136c9bf --- /dev/null +++ b/tests/sample_data/example-group.csv @@ -0,0 +1,4 @@ +id,group_name +1,"Volunteers" +2,"Elders" +3,"Bible Study" \ No newline at end of file diff --git a/tests/sample_data/people-with-groups.csv b/tests/sample_data/people-with-groups.csv new file mode 100644 index 0000000..bf2395e --- /dev/null +++ b/tests/sample_data/people-with-groups.csv @@ -0,0 +1,6 @@ +id,first_name,last_name,email_address,status,group_name +1,"Alex","Ortiz-Rosado","alex@breezechms.com",active,"Bible Study" +2,"Jon","VerLee","jon@breezechms.com","archived","Bible Study" +3,"Fred","Flintstone","fredflintstone@example.com","active","Bible Study" +4,"Marie","Bourne","mbourne@example.com","active","Vacation" +5,"Wilma","Flintstone","wilmaflinstone@example.com","active","Misc" \ No newline at end of file From 4d0b982ea4b0ed31cf28514ede3c6474b468ee05 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Mon, 29 Jun 2020 01:08:36 -0500 Subject: [PATCH 18/19] add missing setup instructions noticed they were listed above, but missing in my section --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index c4fe5cf..c22a312 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ After that I could follow the set up instructions above without any issues. 1. copy `.env.example` to .env and add database information 2. run `composer install` 3. run `yarn install` +4. run `php artisan key:generate && php artisan migrate` 4. start API using `php artisan serve` 5. start react with `yarn start` From 3b0515a6a06d94afca1a1bd53ffab505e90748d6 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Mon, 29 Jun 2020 20:11:19 -0500 Subject: [PATCH 19/19] remove unimplemented sortable attribute --- src/ResultsList.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ResultsList.js b/src/ResultsList.js index 46eb01c..666b23b 100644 --- a/src/ResultsList.js +++ b/src/ResultsList.js @@ -28,7 +28,7 @@ class ResultsList extends Component { return (
People
- +
First Name