From 4f163d46ee6b8d75192984ece5b228c7153412b1 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Sun, 28 Jun 2020 17:10:38 -0500 Subject: [PATCH 01/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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/24] 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 From 9b25398138edc81cbbee92d9ec5b6b2631e9b112 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Mon, 29 Jun 2020 23:31:49 -0500 Subject: [PATCH 20/24] convert services to a class and es6 cleanup also played around with jest and updating component snapshots --- src/CsvUpload.js | 3 +- src/CsvUploadService.js | 85 ++++++++++++++++++---------------- src/GroupsService.js | 59 ----------------------- src/PeopleService.js | 59 ----------------------- src/Services.js | 73 +++++++++++++++++++++++++++++ src/__tests__/Services.test.js | 23 +++++++++ 6 files changed, 144 insertions(+), 158 deletions(-) delete mode 100644 src/GroupsService.js delete mode 100644 src/PeopleService.js create mode 100644 src/Services.js create mode 100644 src/__tests__/Services.test.js diff --git a/src/CsvUpload.js b/src/CsvUpload.js index 711a157..8ff2da4 100644 --- a/src/CsvUpload.js +++ b/src/CsvUpload.js @@ -10,6 +10,7 @@ class CsvUpload extends Component { this.getFileInputNode = this.getFileInputNode.bind(this); this.state = {}; } + getFileInputNode() { return this.myRef.current.inputRef.current; } @@ -18,7 +19,7 @@ class CsvUpload extends Component { let files = this.getFileInputNode().files; if (files[0]) { - CsvUploadService.upload(files[0]); + new CsvUploadService().upload(files[0]); } } diff --git a/src/CsvUploadService.js b/src/CsvUploadService.js index c8ab583..7b6c1f3 100644 --- a/src/CsvUploadService.js +++ b/src/CsvUploadService.js @@ -1,54 +1,61 @@ +import _ from 'lodash'; import Papaparse from 'papaparse'; -import PeopleService from "./PeopleService"; -import GroupsService from "./GroupsService"; +import { GroupsService, PeopleService } from "./Services"; const enabledServices = [PeopleService, GroupsService]; -/** - * @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, -}; +class CsvUploadService { + #enabledServices = [PeopleService, GroupsService]; + #defaults = { + dynamicTyping: true, + header: true, + quoteChar: '"', + skipEmptyLines: 'greedy', + worker: true, + } -function upload(file, config) { - if (!config) { - config = defaults; + /** + * @see https://www.papaparse.com/docs#config + */ + constructor(config) { + config = config || {}; + this.config = _.assign(this.#defaults, config); } - Papaparse.parse(file, config) -} + upload(file) { + return new Promise((resolve, reject) => { + console.warn('this.config: %o', this.config) + Papaparse.parse(file, _.assign(this.config, {error: reject, complete: resolve})) + }).then(this.onParseComplete.bind(this)) + .catch(this.onFileReaderError) + .then((results) => console.warn(results)); + } -function getCsvService(fields) { - for (let i = 0; i < enabledServices.length; i++) { - if (enabledServices[i].canProcessCsv(fields)) { - console.log(enabledServices[i]); - return enabledServices[i]; + getCsvService(fields) { + for (let i = 0; i < enabledServices.length; i++) { + if (enabledServices[i].canProcessCsv(fields)) { + console.log(enabledServices[i]); + return enabledServices[i]; + } } + return null; } - return null; -} -function onParseComplete(results, file) { - let fields = results.meta.fields; - let service = getCsvService(fields); - if (!service) { - return; // TODO handle unknown CSV format + onParseComplete(results) { + console.log('CSV read complete'); + let fields = results.meta.fields; + let service = this.getCsvService(fields); + if (!service) { + return Promise.reject('unknown CSV format'); + } + + return service.process(results.data); } - service.process(results.data); + onFileReaderError(error, file) { + console.error('file error in %s: %s', file, error); + return Promise.reject(error); + } } -function onFileReaderError(error, file) { - console.error('file error in %s: %s', file, error); -} +export default CsvUploadService; diff --git a/src/GroupsService.js b/src/GroupsService.js deleted file mode 100644 index 11b371c..0000000 --- a/src/GroupsService.js +++ /dev/null @@ -1,59 +0,0 @@ -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; - } - }); -} diff --git a/src/PeopleService.js b/src/PeopleService.js deleted file mode 100644 index 4f6f683..0000000 --- a/src/PeopleService.js +++ /dev/null @@ -1,59 +0,0 @@ -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', 'group_name']); -} - -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; - } - }); -} diff --git a/src/Services.js b/src/Services.js new file mode 100644 index 0000000..4e2361f --- /dev/null +++ b/src/Services.js @@ -0,0 +1,73 @@ +import _ from "lodash"; + +let GroupsService; +let PeopleService; + +class Service { + #baseUrl = 'http://localhost:8000/api/'; + #defaultHeaders = { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }; + + constructor(resource, fields) { + this.resource = resource; + this.fields = fields || []; + } + + fetch(path, method, data) { + let url = this.#baseUrl + path; + return fetch(url,{ + method: method, + body: JSON.stringify(data), + headers: this.#defaultHeaders + }); + } + + process(csvData) { + return Promise.all(csvData.map(this.createOrUpdate.bind(this))) + .then((results) => { + console.log('all data uploaded'); + return results; + }); + } + + canProcessCsv(fields) { + return this.fields.length && _.isEqual(this.fields, fields); + } + + get(id) { + + } + + getAll() { + + } + + create(data) { + return this.fetch(`${this.resource}`, 'POST', data) + } + + createOrUpdate(data) { + return this.update(data) + .then(response => { + if (response.status === 404) { + return this.create(data); + } else if (response.ok) { + return response.status !== 204 ? response.json() : null; + } else { + return Promise.reject('something went wrong creating data: ' + JSON.stringify(data)); + } + }); + } + + update(data) { + return this.fetch(`${this.resource}/${data.id}`, 'PUT', data) + } +} + +GroupsService = new Service('groups', ['id', 'group_name']); +PeopleService = new Service('people', ['id', 'first_name', 'last_name', 'email_address', 'status', 'group_name']); + +export default Service; +export {GroupsService, PeopleService}; diff --git a/src/__tests__/Services.test.js b/src/__tests__/Services.test.js new file mode 100644 index 0000000..bf18512 --- /dev/null +++ b/src/__tests__/Services.test.js @@ -0,0 +1,23 @@ +import Service from "../Services"; + +describe('Services', () => { + test('should be detected for assigned fields', () => { + let expectedFields = ['id', 'example', 'example2']; + let service = new Service('test', expectedFields); + expect(service.canProcessCsv(expectedFields)).toBeTruthy(); + }); + + test('should not be detected when fields differ', () => { + let expectedFields = ['id', 'example', 'example2']; + let csvFields = expectedFields.slice(0, -1); + let service = new Service('test', expectedFields); + expect(service.canProcessCsv(csvFields)).toBeFalsy(); + + }); + + test('should not be detected if no fields assigned', () => { + let expectedFields = []; + let service = new Service('test', expectedFields); + expect(service.canProcessCsv([])).toBeFalsy(); + }); +}); From a30854b8bdfc1c0e5610f9c7fa5e0895f32b0765 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Fri, 3 Jul 2020 21:23:47 -0500 Subject: [PATCH 21/24] indicate when CSV upload is in progress --- src/CsvUpload.js | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/CsvUpload.js b/src/CsvUpload.js index 8ff2da4..6b32d9e 100644 --- a/src/CsvUpload.js +++ b/src/CsvUpload.js @@ -1,5 +1,5 @@ import React, { Component } from 'react' -import { Button, Header, Input, Segment } from 'semantic-ui-react' +import { Button, Dimmer, Header, Input, Loader, Segment } from 'semantic-ui-react' import CsvUploadService from "./CsvUploadService"; class CsvUpload extends Component { @@ -19,7 +19,11 @@ class CsvUpload extends Component { let files = this.getFileInputNode().files; if (files[0]) { - new CsvUploadService().upload(files[0]); + this.setState({uploading: true}) + new CsvUploadService() + .upload(files[0]) + .then(() => this.setState({uploading: false})) + ; } } @@ -27,7 +31,12 @@ class CsvUpload extends Component { return (
Upload CSV File
- Upload}/> + + + Loading + + Upload}/> +
); } From 3da54134ed1e3ca97c8022ffd939faa39b9083b6 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Fri, 3 Jul 2020 21:33:45 -0500 Subject: [PATCH 22/24] add PeopleTable component wrapping the tables in a component also simplifies keeping track of per-table sort state, and once a filter prop is added it can be used to display the group data as well. --- src/PeopleTable.js | 99 ++++++++++++++++++++++++++++++++++++++++++++++ src/ResultsList.js | 31 +-------------- 2 files changed, 101 insertions(+), 29 deletions(-) create mode 100644 src/PeopleTable.js diff --git a/src/PeopleTable.js b/src/PeopleTable.js new file mode 100644 index 0000000..9ce5c67 --- /dev/null +++ b/src/PeopleTable.js @@ -0,0 +1,99 @@ +import _ from 'lodash'; +import React, { Component } from 'react' +import { Table } from 'semantic-ui-react' + +class PeopleTable extends Component { + constructor(props) { + super(props); + this.state = { + data: props.data, + column: null, + direction: null, + }; + } + + static getDerivedStateFromProps(props, state) { + let direction = state.direction === 'ascending' ? 'desc' : 'asc'; + let data = state.column ? _.orderBy(props.data, [state.column], direction) : props.data; + if (data !== state.data) { + return { + data: data + }; + } + return null; + } + + handleSort = (clickedColumn) => () => { + const { column, data, direction } = this.state + + if (column !== clickedColumn) { + this.setState({ + column: clickedColumn, + data: _.sortBy(data, [clickedColumn]), + direction: 'ascending', + }) + + return + } + + this.setState({ + data: data.reverse(), + direction: direction === 'ascending' ? 'descending' : 'ascending', + }) + } + + render() { + const { column,direction } = this.state; + const data = this.state.data || []; + + return ( +
+ + + 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 } + + ); + }) + } + + +
+ ); + } + +} + +export default PeopleTable diff --git a/src/ResultsList.js b/src/ResultsList.js index 666b23b..f324da9 100644 --- a/src/ResultsList.js +++ b/src/ResultsList.js @@ -1,6 +1,7 @@ import _ from 'lodash'; import React, { Component } from 'react' import {Divider, Header, Segment, Table} from 'semantic-ui-react' +import PeopleTable from './PeopleTable' class ResultsList extends Component { constructor(props) { @@ -28,35 +29,7 @@ class ResultsList extends Component { 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
{ From cd57f599f737ad478c087736252b93cbe240cb8b Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Fri, 3 Jul 2020 21:46:57 -0500 Subject: [PATCH 23/24] add filter prop to PeopleTable filter can be any valid [lodash predicate](https://lodash.com/docs/4.17.15#filter) --- src/PeopleTable.js | 2 +- src/ResultsList.js | 31 +++---------------------------- 2 files changed, 4 insertions(+), 29 deletions(-) diff --git a/src/PeopleTable.js b/src/PeopleTable.js index 9ce5c67..b05a3ef 100644 --- a/src/PeopleTable.js +++ b/src/PeopleTable.js @@ -76,7 +76,7 @@ class PeopleTable extends Component { { - data.map((person, index) => { + _.filter(data, this.props.filter).map((person, index) => { return ( { person.first_name } diff --git a/src/ResultsList.js b/src/ResultsList.js index f324da9..5b06461 100644 --- a/src/ResultsList.js +++ b/src/ResultsList.js @@ -1,6 +1,5 @@ -import _ from 'lodash'; import React, { Component } from 'react' -import {Divider, Header, Segment, Table} from 'semantic-ui-react' +import {Divider, Header, Segment} from 'semantic-ui-react' import PeopleTable from './PeopleTable' class ResultsList extends Component { @@ -35,33 +34,9 @@ class ResultsList extends Component { { 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 } - - ); - }) - } - -
+
); }) From 8f713ab6cdc39ca61ec117746ed6977ff71bde86 Mon Sep 17 00:00:00 2001 From: Lehman Black Date: Fri, 3 Jul 2020 22:58:31 -0500 Subject: [PATCH 24/24] show message on upload complete --- src/CsvUpload.js | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/CsvUpload.js b/src/CsvUpload.js index 6b32d9e..1fa9358 100644 --- a/src/CsvUpload.js +++ b/src/CsvUpload.js @@ -1,5 +1,5 @@ import React, { Component } from 'react' -import { Button, Dimmer, Header, Input, Loader, Segment } from 'semantic-ui-react' +import { Button, Dimmer, Header, Input, Loader, Message, Segment, Transition } from 'semantic-ui-react' import CsvUploadService from "./CsvUploadService"; class CsvUpload extends Component { @@ -8,7 +8,9 @@ class CsvUpload extends Component { this.myRef = React.createRef(); this.handleClick = this.handleClick.bind(this); this.getFileInputNode = this.getFileInputNode.bind(this); - this.state = {}; + this.state = { + uploadStatus:false + }; } getFileInputNode() { @@ -22,7 +24,13 @@ class CsvUpload extends Component { this.setState({uploading: true}) new CsvUploadService() .upload(files[0]) - .then(() => this.setState({uploading: false})) + .then((result) => { + console.warn('status: %o', result); + this.setState({ + uploading: false, + uploadStatus: true, + }) + }) ; } } @@ -37,8 +45,16 @@ class CsvUpload extends Component { Upload}/> + + + Upload Complete! +

+ Refresh the page to view the results +

+
+
- ); + ); } }