In our previous article, we created an account (hopefully, you did too). In this article, we'll be implementing contact creation, deletion and editing.
At the heart of event sourcing are aggregates, commands and events. An aggregate is simply a domain object e.g. BankAccount
, Order
, Customer
etc. A command is an intention to do something e.g. CreateCustomerAccount
, DebitBankAccount
, CreateOrder
etc. An event is the result of successfully processing a command eg CustomerAccountCreated
,BankAccountDebited
, OrderCreated
etc. In summary, commands are applied on aggregates resulting in events.
Our contacts will have only name and gender attributes.
Let's start by creating a ContactAggregate
. Create a file app/Domain/Contact/ContactAggregate.php
with the following contents:
<?php
namespace App\Domain\Contact;
use Spatie\EventProjector\AggregateRoot;
final class ContactAggregate extends AggregateRoot {
}
Let's add a createContact
method to create ContactCreated
events and anapplyContactCreated
method to update the ContactAggregate
state. Our ContactAggregate
now looks like this:
<?php
namespace App\Domain\Contact;
use Spatie\EventProjector\AggregateRoot;
use App\Domain\Contact\Events\ContactCreated;
final class ContactAggregate extends AggregateRoot {
/** @var string */
public $name;
/** @var string */
public $gender;
/** @var string */
public $userId;
public function createContact(string $userId, string $name, string $gender) {
$this->recordThat(new ContactCreated($userId, $name, $gender));
$this->persist();
}
public function applyContactCreated(ContactCreated $event) {
$this->name = $event->name;
$this->gender = $event->gender;
$this->userId = $event->userId;
$this->persist();
}
}
The persist()
call is to save the aggregate and all recorded events (the ones in the recordThat
calls) to the database and call the required projectors and reactors (think of them as event handlers).
Let's create the ContactCreated
event in app/Domain/Contact/Events/ContactCreated.php
with the following:
<?php
namespace App\Domain\Contact\Events;
use Spatie\EventProjector\ShouldBeStored;
final class ContactCreated implements ShouldBeStored {
/** @var string */
public $name;
/** @var string */
public $gender;
/** @var string */
public $userId;
public function __construct(string $userId, string $name, string $gender) {
$this->userId = $userId;
$this->name = $name;
$this->gender = $gender;
}
}
You might be asking: now that we found an event, what are we gonna do with it? I'm glad you asked (even if you didn't). This is a perfect time to introduce projectors.
A projector is basically an event handler that takes created events and does some CRUD operations on our read models with the contents of the events. While we're here, a reactor is a projector that carries out some job instead of fiddling with databases (e.g. send a welcome mail).
We'll create a projector to listen for ContactCreated
events and create contacts in our database.
Run php artisan make:migration create_contacts_table
and paste the following to the migration file:
<?php
use Illuminate\Support\Facades\Schema;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Database\Migrations\Migration;
class CreateContactsTable extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up() {
Schema::create('contacts', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('user_id');
$table->string('name');
$table->string('gender');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down() {
Schema::dropIfExists('contacts');
}
}
You might have noticed we are not creating any foreign keys. In event sourcing, all business rules and constraints are enforced in commands before events are created. Our read models are basically dumb stores.
Next, create a Contact.php
model in app/Models
with these contents:
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Str;
class Contact extends Model {
use Traits\UsesUuid;
protected $guarded = [];
public $incrementing = false;
public function user() {
return $this->belongsTo(User::class);
}
}
With that out of the way, create a projector app/Domain/Contact/Projectors/ContactProjector.php
with the contents below:
<?php
namespace App\Domain\Contact\Projectors;
use Spatie\EventProjector\Projectors\Projector;
use Spatie\EventProjector\Projectors\ProjectsEvents;
use App\Domain\Contact\Events\ContactCreated;
use App\Models\Contact;
class ContactProjector implements Projector {
use ProjectsEvents;
public function onContactCreated(ContactCreated $event, string $aggregateUuid) {
Contact::create([
'id' => $aggregateUuid,
'gender' => $event->gender,
'name' => $event->name,
'user_id' => $event->userId
]);
}
}
To create a contact, we'll have to issue a command. Let's create a command app/Domain/Contact/Commands/CreateContact.php
with the following contents:
<?php
namespace App\Domain\Contact\Commands;
use Ramsey\Uuid\Uuid;
use App\Models\User;
use App\Models\Contact;
use App\Domain\Command;
use App\Domain\Contact\ContactAggregate;
final class CreateContact extends Command {
public function __construct(array $bag) {
$this->attributes['user_id'] = Uuid::isValid($bag['user_id']) ?
$bag['user_id'] :
Uuid::fromString($bag['user_id']);
$this->attributes['name'] = $bag['name'];
$this->attributes['gender'] = strtolower($bag['gender']);
}
public static function from(array $bag) : CreateContact {
return new CreateContact($bag);
}
public function isValid() : bool {
return ((bool) User::find($this->attributes['user_id'])) &&
! empty($this->attributes['name']) &&
in_array($this->attributes['gender'], ['male', 'female']);
}
public function execute() : ?Contact {
if (! $this->isValid()) return null;
$newId = Uuid::uuid4();
ContactAggregate::retrieve($newId)
->createContact($this->attributes['user_id'], $this->attributes['name'], $this->attributes['gender']);
return Contact::find($newId);
}
}
It's a good idea to put our common command properties in a parent class app/Domain/Command.php
:
<?php
namespace App\Domain;
abstract class Command {
/** @var array */
protected $attributes;
abstract public function isValid() : bool;
abstract public function execute();
public function getAttributes() : array {
return $this->attributes;
}
}
At this point we have wired everything up to make contact creation possible. But, how do we know it works, you might ask (even if you didn't ask)? We'll create tests.
Let's modify tests/TestCase.php
for our purposes:
<?php
namespace Tests;
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
use Faker\Factory as Faker;
use Illuminate\Support\Str;
use App\Models\User;
abstract class TestCase extends BaseTestCase {
use CreatesApplication;
private $faker;
protected function getUserAttributes() {
if (empty($this->faker)) $this->faker = Faker::create();
return [
'id' => Str::uuid(),
'email' => $this->faker->safeEmail,
'name' => $this->faker->name,
'password' => $this->faker->password
];
}
protected function getContactAttributes(User $user) {
if (empty($this->faker)) $this->faker = Faker::create();
return [
'user_id' => $user->id,
'name' => $this->faker->name,
'gender' => rand() % 2 == 0 ? 'male' : 'female'
];
}
}
Rename tests/Feature/ExampleTest.php
to tests/Feature/ContactTest.php
and paste the following code into it:
<?php
namespace Tests\Feature;
use Tests\TestCase;
use Illuminate\Foundation\Testing\RefreshDatabase;
use App\Domain\Contact\Commands\CreateContact;
use App\Models\User;
use App\Models\Contact;
class ContactTest extends TestCase {
use RefreshDatabase;
public function testContactCreation() {
$user = User::create($this->getUserAttributes());
$data = $this->getContactAttributes($user);
$createContactCommand = CreateContact::from($data);
$this->assertEquals(count($createContactCommand->getAttributes()), count($data));
$this->assertTrue($createContactCommand->isValid());
$contact = $createContactCommand->execute();
$this->assertTrue($contact instanceof Contact);
$this->assertEquals($contact->name, $data['name']);
$this->assertEquals($contact->gender, $data['gender']);
$this->assertEquals($contact->user_id, $user->id);
}
}
If you run vendor/bin/phpunit
, our test should pass (don't blame me if yours does not).
Let's move over to building the front-end for this feature.
Add the following code to the User
model to help us fetch a user's contacts:
public function contacts() {
return $this->hasMany(Contact::class);
}
Update the contacts()
route handler of HomeController
to resemble this:
public function contacts() {
return view('contacts', ['contacts' => \Auth::user()->contacts()->get()]);
}
Update resources/views/contacts.blade.php
to:
@section('content')
<div class="container-fluid">
<contact-view :contacts="{{ $contacts }}" />
</div>
@endsection
Change the container
class of the nav
element in resources/views/layouts/app.blade.php
to container-fluid
.
Create app/Http/Controllers/ContactController.php
and paste the following:
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Auth;
use App\Models\Contact;
use App\Domain\Contact\Commands\CreateContact;
class ContactController extends Controller {
public function store(Request $request) {
$data = $request->all();
$data['user_id'] = Auth::guard('api')->user()->id;
$command = CreateContact::from($data);
if (! $command->isValid()) {
return response([
'success' => false,
'message' => 'Invalid params'
], Response::HTTP_UNPROCESSABLE_ENTITY);
}
$contact = $command->execute();
return response([
'success' => true,
'message' => 'Created',
'data' => $contact->jsonSerialize()
], Response::HTTP_CREATED);
}
}
Replace routes/api.php
with:
<?php
use Illuminate\Http\Request;
Route::group(['middleware' => ['auth:api']], function() {
Route::resource('contacts', 'ContactController', ['only' => ['store', 'update', 'destroy']]);
});
Replace resources/js/app.js
with:
/**
* First we will load all of this project's JavaScript dependencies which
* includes Vue and other libraries. It is a great starting point when
* building robust, powerful web applications using Vue and Laravel.
*/
require('./bootstrap');
window.Vue = require('vue');
Vue.component('contact-view', require('./components/ContactView.vue').default);
new Vue({ el: '#app' });
In resources/js/components
, create 3 files ContactView.vue
, ContactList.vue
and CreateContact.vue
.
ContactView.vue
's contents are:
<template>
<div class="row justify-content-center">
<div class="col-md-4">
<create-contact-form @contact-created="handleContactCreated" />
<contact-list :contacts="localContacts" />
</div>
<div class="col-md-8">
<div class="card">
<div class="card-body">
<!-- View contact -->
</div>
</div>
</div>
</div>
</template>
<script>
import CreateContact from './CreateContact';
import ContactList from './ContactList';
export default {
name: 'ContactView',
components: {
CreateContact,
ContactList
},
props: {
contacts: {
type: Array,
default: []
}
},
data() {
return {
localContacts: this.contacts
};
},
methods: {
handleContactCreated(contact) {
this.localContacts.push(contact);
}
}
};
</script>
ContactList.vue
's contents are:
<template>
<div class="card">
<div class="card-body">
<ul class="list-group">
<li class="list-group-item" v-for="contact in contacts" :key="contact.id">
<div class="row">
<div class="col-md-9">
<h5>{{ contact.name }}</h5>
<small>{{ contact.gender }}</small>
</div>
<div class="col-md-3 text-right">
<button class="btn btn-sm btn-outline-secondary">View</button>
</div>
</div>
</li>
</ul>
<h4 v-if="!contacts.length" class="text-muted text-center">No contacts</h4>
</div>
</div>
</template>
<script>
export default {
name: 'ContactList',
props: {
contacts: {
type: Array,
required: true
}
}
}
</script>
CreateContact.vue
's contents are:
<template>
<div class="card mb-4">
<div class="card-body">
<div class="form-group">
<label>Name</label>
<input type="text" v-model="form.name" class="form-control">
</div>
<div class="form-group">
<label>Gender</label>
<select v-model="form.gender" class="form-control">
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</div>
<div class="form-group">
<button :disabled="!formOk" @click.stop="submit()">Create contact</button>
</div>
</div>
</div>
</template>
<script>
export default {
name: 'CreateContact',
data() {
return {
form: {
name: '',
gender: 'male'
}
};
},
computed: {
formOk() {
return !!this.form.name && ['male', 'female'].indexOf(this.form.gender) !== -1;
}
},
methods: {
submit() {
axios.post('/api/contacts', this.form)
.then(({ data: { data } }) => {
this.$emit('contact-created', data);
this.form.name = '';
})
.catch(({ response }) => {
alert('Could not create');
});
}
}
}
</script>
If you don't have npm run watch
running, do so now to compile the Vue files.
Sign in and create contacts to your heart's contents while I stretch out a little...
Ok, I'm back. You should have created a lot of contacts by now and would want to delete some. Let's implement contact deletion.
Create app/Domain/Contact/Events/ContactDeleted.php
and paste the following:
<?php
namespace App\Domain\Contact\Events;
use Spatie\EventProjector\ShouldBeStored;
final class ContactDeleted implements ShouldBeStored {
/** @var string */
public $contactId;
public function __construct(string $contactId) {
$this->contactId = $contactId;
}
}
Then add the following method to the ContactAggregate
(remember to add the use
statement for ContactDeleted
):
public function deleteContact(string $id) {
$this->recordThat(new ContactDeleted($id));
$this->persist();
}
Add the following method (and the accompanying use
statement for ContactDeleted
) to ContactProjector
:
public function onContactDeleted(ContactDeleted $event, string $aggregateUuid) {
Contact::where('id', $aggregateUuid)->delete();
}
Create app/Domain/Contact/Commands/DeleteContact.php
and paste:
<?php
namespace App\Domain\Contact\Commands;
use Ramsey\Uuid\Uuid;
use App\Models\Contact;
use App\Domain\Command;
use App\Domain\Contact\ContactAggregate;
final class DeleteContact extends Command {
public function __construct(array $bag) {
$this->attributes['id'] = Uuid::isValid($bag['id']) ? $bag['id'] : Uuid::fromString($bag['id']);
}
public static function from(array $bag) : DeleteContact {
return new DeleteContact($bag);
}
public function isValid() : bool {
return (bool) Contact::find($this->attributes['id']);
}
public function execute() {
if (! $this->isValid()) return;
ContactAggregate::retrieve($this->attributes['id'])->deleteContact($this->attributes['id']);
}
}
Add the following test to your ContactTest
class to test contact deletion:
public function testContactDeletion() {
$user = User::create($this->getUserAttributes());
$contact = CreateContact::from($this->getContactAttributes($user))->execute();
$this->assertEquals(1, count($user->contacts()->get()));
$command = DeleteContact::from(['id' => $contact->id]);
$this->assertTrue($command->isValid());
$command->execute();
$this->assertEquals(0, count($user->contacts()->get()));
}
The test should pass, so let's add the front-end.
First, add the route handler for deletion to the ContactController
(remember the use
for DeleteContact
):
public function destroy($id) {
$contact = Auth::guard('api')->user()->contacts()->where('id', $id)->first();
if ($contact) {
DeleteContact::from(['id' => $contact->id])->execute();
return response(null, Response::HTTP_NO_CONTENT);
}
return response(null, Response::HTTP_FORBIDDEN);
}
Then, update the ContactList.vue
component to:
<template>
<div class="card">
<div class="card-body">
<ul class="list-group">
<li class="list-group-item" v-for="contact in contacts" :key="contact.id">
<div class="row">
<div class="col-md-9">
<h5>{{ contact.name }}</h5>
</div>
<div class="col-md-3 text-right">
<div class="btn-group">
<button class="btn btn-sm btn-outline-danger" @click="deleteContact(contact.id)">Delete</button>
<button class="btn btn-sm btn-outline-secondary">View</button>
</div>
</div>
</div>
</li>
</ul>
<h4 v-if="!contacts.length" class="text-muted text-center">No contacts</h4>
</div>
</div>
</template>
<script>
export default {
name: 'ContactList',
props: {
contacts: {
type: Array,
required: true
}
},
methods: {
deleteContact(id) {
if (! confirm('Are you sure?')) return;
axios.delete(`/api/contacts/${id}`)
.then(() => {
this.$emit('contact-deleted', id);
})
.catch(({ response }) => {
alert('Could not delete');
});
}
}
}
</script>
And the ContactView.vue
component to:
<template>
<div class="row justify-content-center">
<div class="col-md-4">
<create-contact @contact-created="handleContactCreated" />
<contact-list @contact-deleted="handleContactDeleted" :contacts="localContacts" />
</div>
<div class="col-md-8">
<div class="card">
<div class="card-body">
<!-- View contact -->
</div>
</div>
</div>
</div>
</template>
<script>
import CreateContact from './CreateContact';
import ContactList from './ContactList';
export default {
name: 'ContactView',
components: {
CreateContact,
ContactList
},
props: {
contacts: {
type: Array,
default: []
}
},
data() {
return {
localContacts: this.contacts
};
},
methods: {
handleContactCreated(contact) {
this.localContacts.push(contact);
},
handleContactDeleted(id) {
this.localContacts = this.localContacts.filter(c => c.id !== id);
}
}
};
</script>
That's all for deleting contacts.
To finish off our contacts feature, let's implement support for updating contacts.
For creation and deletion, our commands and events have had a one-to-one mapping. For updates, we'll see an example of creating more than one event from a command.
Each attribute of the contact is updated independently. So let's create the two events ContactNameChanged
and ContactGenderChanged
respectively:
<?php
namespace App\Domain\Contact\Events;
use Spatie\EventProjector\ShouldBeStored;
final class ContactNameChanged implements ShouldBeStored {
/** @var string */
public $name;
/** @var string */
public $contactId;
public function __construct(string $contactId, string $name) {
$this->contactId = $contactId;
$this->name = $name;
}
}
<?php
namespace App\Domain\Contact\Events;
use Spatie\EventProjector\ShouldBeStored;
final class ContactGenderChanged implements ShouldBeStored {
/** @var string */
public $gender;
/** @var string */
public $contactId;
public function __construct(string $contactId, string $gender) {
$this->contactId = $contactId;
$this->gender = $gender;
}
}
Add the following handler methods to the ContactProjector
:
public function onContactNameChanged(ContactNameChanged $event, string $aggregateUuid) {
Contact::where('id', $aggregateUuid)->update([
'name' => $event->name
]);
}
public function onContactGenderChanged(ContactGenderChanged $event, string $aggregateUuid) {
Contact::where('id', $aggregateUuid)->update([
'gender' => $event->gender
]);
}
We'll need to update our ContactAggregate
with these:
public function updateContact(string $contactId, string $name, string $gender) {
$updated = false;
if (! empty($name) && $name != $this->name) {
$this->recordThat(new ContactNameChanged($contactId, $name));
$updated = true;
}
if (! empty($gender) && $gender != $this->gender) {
$this->recordThat(new ContactGenderChanged($contactId, $gender));
$updated = true;
}
if ($updated) $this->persist();
}
public function applyContactNameChanged(ContactNameChanged $event) {
$this->name = $event->name;
$this->persist();
}
public function applyContactGenderChanged(ContactGenderChanged $event) {
$this->gender = $event->gender;
$this->persist();
}
Create a new command in the Contact
domain called UpdateContact
and paste these in:
<?php
namespace App\Domain\Contact\Commands;
use Ramsey\Uuid\Uuid;
use App\Models\User;
use App\Models\Contact;
use App\Domain\Command;
use App\Domain\Contact\ContactAggregate;
final class UpdateContact extends Command {
public function __construct(array $bag) {
$this->attributes['contact_id'] = Uuid::isValid($bag['contact_id']) ?
$bag['contact_id'] :
Uuid::fromString($bag['contact_id']);
if (! empty($bag['name'])) $this->attributes['name'] = $bag['name'];
if (! empty($bag['gender'])) $this->attributes['gender'] = strtolower($bag['gender']);
}
public static function from(array $bag) : UpdateContact {
return new UpdateContact($bag);
}
public function isValid() : bool {
$contactExists = ((bool) Contact::find($this->attributes['contact_id']));
$nameOk = ! empty($this->attributes['name']) || true; // Ignore if not provided
$genderOk = ! empty($this->attributes['gender']) ?
in_array($this->attributes['gender'], ['male', 'female']) : // If provided, check validity
true; // Ignore if not provided
return $contactExists && $nameOk && $genderOk;
}
public function execute() : ?Contact {
if (! $this->isValid()) return null;
ContactAggregate::retrieve($this->attributes['contact_id'])
->updateContact(
$this->attributes['contact_id'],
! empty($this->attributes['name']) ? $this->attributes['name'] : '',
! empty($this->attributes['gender']) ? $this->attributes['gender'] : ''
);
return Contact::find($this->attributes['contact_id']);
}
}
We then add some tests to our ContactTest
to ensure our additions work as expected:
public function testContactNameUpdate() {
$user = User::create($this->getUserAttributes());
$contact = CreateContact::from($this->getContactAttributes($user))->execute();
$data = ['contact_id' => $contact->id];
$data['name'] = $this->getContactAttributes($user)['name'];
$command = UpdateContact::from($data);
$this->assertTrue($command->isValid());
$updatedContact = $command->execute();
$this->assertNotEquals($updatedContact->name, $contact->name);
$this->assertEquals($updatedContact->name, $data['name']);
$this->assertEquals($updatedContact->gender, $contact->gender);
}
public function testContactGenderUpdate() {
$user = User::create($this->getUserAttributes());
$contact = CreateContact::from($this->getContactAttributes($user))->execute();
$data = ['contact_id' => $contact->id];
$genderParam = $this->getContactAttributes($user)['gender'];
// Since we're randomly choosing genders, we want to continue choosing till we get a gender that's different from that of the contact
while ($genderParam == $contact->gender) $genderParam = $this->getContactAttributes($user)['gender'];
$data['gender'] = $genderParam;
$command = UpdateContact::from($data);
$this->assertTrue($command->isValid());
$updatedContact = $command->execute();
$this->assertNotEquals($updatedContact->gender, $contact->gender);
$this->assertEquals($updatedContact->gender, $data['gender']);
$this->assertEquals($updatedContact->name, $contact->name);
}
public function testContactUpdateWithUnchangedData() {
$user = User::create($this->getUserAttributes());
$contact = CreateContact::from($this->getContactAttributes($user))->execute();
$data = ['contact_id' => $contact->id, 'name' => $contact->name, 'gender' => $contact->gender];
$command = UpdateContact::from($data);
$this->assertTrue($command->isValid());
$updatedContact = $command->execute();
$this->assertEquals($updatedContact->updated_at, $contact->updated_at);
}
For the front-end, add the following route handler to ContactController
:
public function update(Request $request, $id) {
$contact = Auth::guard('api')->user()->contacts()->where('id', $id)->first();
if ($contact) {
$contact = UpdateContact::from(array_merge($request->all(), ['contact_id' => $id]))->execute();
return response([
'success' => (bool) $contact,
'data' => $contact,
'message' => ((bool) $contact) ? 'Updated' : 'Not updated'
], ((bool) $contact) ? Response::HTTP_OK : Response::HTTP_BAD_REQUEST);
}
return response(null, Response::HTTP_FORBIDDEN);
}
Since we'll also be using a form identical to the one we created in CreateContact.vue
, let's extract the form into its own component called ContactForm.vue
:
<template>
<form @submit.stop.prevent="submit()">
<div class="form-group">
<label>Name</label>
<input type="text" v-model="form.name" class="form-control">
</div>
<div class="form-group">
<label>Gender</label>
<select v-model="form.gender" class="form-control">
<option value="male">Male</option>
<option value="female">Female</option>
</select>
</div>
<div class="form-group">
<button :disabled="!formOk" type="submit">{{ buttonText }}</button>
</div>
</form>
</template>
<script>
export default {
name: 'ContactForm',
props: {
initialData: {
type: Object,
required: true
},
buttonText: {
type: String,
required: true
}
},
data() {
const form = this.initialData ?
{ name: this.initialData.name, gender: this.initialData.gender } :
{ name: '', gender: '' };
return {
form
};
},
computed: {
formOk() {
return !!this.form.name && ['male', 'female'].indexOf(this.form.gender) !== -1;
}
},
methods: {
submit() {
this.$emit('form-submitted', this.form);
},
resetForm({ name, gender }) {
this.form.name = name;
this.form.gender = gender;
}
}
}
</script>
Our CreateContact.vue
component now becomes:
<template>
<div class="card mb-4">
<div class="card-body">
<contact-form
ref="form"
button-text="Create contact"
:initial-data="form"
@form-submitted="submit" />
</div>
</div>
</template>
<script>
import ContactForm from './ContactForm';
export default {
name: 'CreateContact',
components: {
ContactForm
},
data() {
return {
form: {
name: '',
gender: 'male'
}
};
},
methods: {
submit(form) {
axios.post('/api/contacts', form)
.then(({ data: { data } }) => {
this.$emit('contact-created', data);
this.$refs.form.resetForm(this.form);
})
.catch(({ response }) => {
alert('Could not create');
});
}
}
}
</script>
Update ContactView.vue
to:
<template>
<div class="row justify-content-center">
<div class="col-md-4">
<create-contact @contact-created="handleContactCreated" />
<contact-list
@contact-deleted="handleContactDeleted"
@contact-selected="handleContactSelected"
:contacts="localContacts" />
</div>
<div class="col-md-8">
<contact :contact="selectedContact" @contact-updated="handleContactUpdated" />
</div>
</div>
</template>
<script>
import CreateContact from './CreateContact';
import ContactList from './ContactList';
import Contact from './Contact';
export default {
name: 'ContactView',
components: {
CreateContact,
ContactList,
Contact
},
props: {
contacts: {
type: Array,
default: []
}
},
data() {
return {
localContacts: this.contacts,
selectedContact: null
};
},
methods: {
handleContactCreated(contact) {
this.localContacts.push(contact);
},
handleContactDeleted(id) {
this.localContacts = this.localContacts.filter(c => c.id !== id);
},
handleContactUpdated(contact) {
this.localContacts = this.localContacts.map(c => c.id === contact.id ? contact : c);
if ((this.selectedContact || { id: '' }).id === contact.id) {
this.selectedContact = contact;
}
},
handleContactSelected(id) {
this.selectedContact = this.localContacts.find(c => c.id === id);
}
}
};
</script>
Update ContactList.vue
to:
<template>
<div class="card">
<div class="card-body">
<ul class="list-group">
<li class="list-group-item" v-for="contact in contacts" :key="contact.id">
<div class="row">
<div class="col-md-9">
<h5>{{ contact.name }}</h5>
<small>{{ contact.gender }}</small>
</div>
<div class="col-md-3 text-right">
<div class="btn-group">
<button class="btn btn-sm btn-outline-danger" @click="deleteContact(contact.id)">Delete</button>
<button class="btn btn-sm btn-outline-secondary" @click="selectContact(contact.id)">View</button>
</div>
</div>
</div>
</li>
</ul>
<h4 v-if="!contacts.length" class="text-muted text-center">No contacts</h4>
</div>
</div>
</template>
<script>
export default {
name: 'ContactList',
props: {
contacts: {
type: Array,
required: true
}
},
methods: {
deleteContact(id) {
if (! confirm('Are you sure?')) return;
axios.delete(`/api/contacts/${id}`)
.then(() => {
this.$emit('contact-deleted', id);
})
.catch(({ response }) => {
alert('Could not delete');
});
},
selectContact(id) {
this.$emit('contact-selected', id);
}
}
}
</script>
Create a new component called Contact.vue
with the following contents:
<template>
<div class="card">
<div class="card-body">
<h3 class="text-muted text-center" v-if="!contact">No contact selected</h3>
<div v-else>
<contact-form
ref="form"
button-text="Update contact"
:initial-data="form"
@form-submitted="submit" />
<hr>
</div>
</div>
</div>
</template>
<script>
import ContactForm from './ContactForm';
export default {
name: 'Contact',
components: {
ContactForm
},
props: {
contact: {
type: Object
}
},
data() {
return {
form: { name: '', gender: '' }
};
},
watch: {
contact: {
deep: true,
immediate: true,
handler(c) {
if (! c) return;
const { name, gender } = c || { name: '', gender: '' };
this.form = Object.assign(this.form, { name, gender });
this.resetForm(this.form);
}
}
},
methods: {
submit(form) {
if (! confirm('Are you sure?')) return;
axios.put(`/api/contacts/${this.contact.id}`, form)
.then(({ data: { data } }) => {
this.$emit('contact-updated', data);
this.form.name = data.name;
this.form.gender = data.gender;
this.resetForm(this.form);
})
.catch(({ response }) => {
alert('Could not create');
});
},
resetForm() {
if(! this.$refs.form) return;
this.$refs.form.resetForm(this.form);
}
},
}
</script>
We can now update our contacts.
You can explore all the changes made here.
In the next article, we'll add notes to our contacts.