This tutorial is part of the Building Your Startup With PHP series on Envato Tuts+. In this series, I'm guiding you through launching a startup from concept to reality using my Meeting Planner app as a real-life example. Every step along the way, I'll release the Meeting Planner code as open-source examples you can learn from. I'll also address startup-related business issues as they arise.
Introduction to Group Meetings
Scheduling meetings with multiple participants was always part of my plan—but not part of the earliest Minimum Viable Product (MVP). The alpha release of Meeting Planner launched with only 1:1 scheduling. The goal of supporting group scheduling sat on the task list like Mount Everest to a climber aiming for the seven summits (and I'm not even an outdoor climber).
Multiple participant meetings are the most challenging to schedule and therefore valuable for the Meeting Planner product to offer. I was excited when the beta task list reached the point that I could begin work on this.
I've been planning, architecting and coding with group meetings in mind from nearly the beginning. I hoped that updating the site for this feature would not require significant UX changes or coding updates. It turned out to require a middle path, 7-10 days of very focused work and testing but no major re-architecting.
In fact, testing proved to be the most difficult aspect of building this feature. It also helped reveal shortcomings in earlier code. It's just that it's not easy... sending to multiple email addresses, checking that every one of them receives all the proper notifications but not the wrong notifications—and sees all the correct menu options throughout the site.
In today's tutorial, I'm going to cover enabling multiple participants, upgrading the UX for groups, appointing organizers, removing participants, and sorting date, time and place options by their popularity with participants.
In the next tutorial, I'll describe the rest of the work: reviewing all the areas of the site affected by multiple participant meetings, handling and smartly displaying lists of recipients of various status, properly managing notifications and notification filtering for groups, and finally upgrading the recently launched request meeting changes feature.
Try Scheduling a Group Meeting
Please do schedule a group meeting today! Share your thoughts and feedback in the comments below.
I do participate in the discussions, but you can also reach me @reifman on Twitter. I'm always open to new feature ideas for Meeting Planner as well as suggestions for future series episodes.
As a reminder, all the code for Meeting Planner is provided open source and written in the Yii2 Framework for PHP. If you'd like to learn more about Yii2, check out my parallel series Programming With Yii2. I've heard great things about Laravel, but Yii2 always meets my needs quickly and easily.
Looking Back
When I first designed the Meeting Planner scheduling interface, it showed the current availability of the other participant in its own column. And it was a bit confusing as there were disabled controls.
At the time, I worried about how would I make space for displaying the availability of groups.
Fortunately, when I rebuilt the UX for a better responsive experience, I replaced the participant availability column with a small text summary:
The text summary of availability would coincidentally work well for group meetings.
By redesigning for mobile first, I solved the most significant UX barrier to multiple participant meetings!
Coding for Group Meetings
Let's get started going through all the code and testing that multiple participant meetings required.
Enabling Multiple Participants
The funniest aspect of group meetings is that activating them was straightforward. I just needed to turn off disabling of the plus icon button on the Who panel for meetings in the planning stage:
<div class="col-lg-2 col-md-2 col-xs-2"> <div style="float:right;"> <?= Html::a(Yii::t('frontend', ''), ['/participant/create', 'meeting_id' => $model->id], ['class' => 'btn btn-primary '.($model->status>=$model::STATUS_CONFIRMED?'disabled':'').' glyphicon glyphicon-plus']) ?> </div> </div>
Then, I began by creating a MEETING_LIMIT
in the Participant model:
class Participant extends \yii\db\ActiveRecord { ... const MEETING_LIMIT = 15;
It's used in ParticipantController::actionCreate()
on submit:
public function actionCreate($meeting_id) { if (!Participant::withinLimit($meeting_id)) { Yii::$app->getSession()->setFlash('error', Yii::t('frontend','Sorry, you have reached the maximum number of participants per meeting. Contact support if you need additional help or want to offer feedback.')); return $this->redirect(['/meeting/view', 'id' => $meeting_id]); }
Advancing the UX and Related Features
For a long time, I've wanted to allow meeting organizers to remove participants, places, and date times without cluttering the user interface. Similarly, I realized that there might be several commands to perform on participants.
After finding so much utility in the compact Bootstrap dropdown button in the Advanced Commands tutorial, I decided to use it for displaying meeting attendees:
Organizers are denoted with a star. Attendees that have declined the meeting are displayed in orange. Attendees that organizers remove are displayed in red.
Here's the code in my new partial /frontend/views/participant/_buttons.php:
<div class="btn-group btn-participant"> <button type="button" class="btn btn-default btn-sm dropdown-toggle " data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> <span class="glyphicon glyphicon-star red-star aria-hidden="true"></span> <?= MiscHelpers::getDisplayName($model->owner_id) ?> <span class="caret"></span> </button> <ul class="dropdown-menu"> <li><?= Html::a(Yii::t('frontend','Send a message'),Url::to('mailto:'.$model->owner->email))?></li> </ul> </div>
Anyone can now send a message to any participant (the meeting notes features is currently distributed to all meeting participants).
Organizers see a deeper dropdown which allows them to anoint additional organizers, i.e. Make organizer. This is now a very cool feature. Organizers will receive more complete notifications and have more power throughout the planning phases. They can also Remove participants.
Building AJAX Features Into the Participant Buttons
I decided on a whim to AJAXify all these menu options. That turned out to require several complex hours of coding.
Here's the code that defines the initial button menu and prepares the JavaScript:
<?php if (count($model->participants)>0) { foreach ($model->participants as $p) { if ($p->participant->id==Yii::$app->user->getId()) { continue; } $btn_color = 'btn-default'; if ($p->status == Participant::STATUS_DECLINED) { $btn_color = 'btn-warning'; } else if ($p->status == Participant::STATUS_REMOVED || $p->status == Participant::STATUS_DECLINED_REMOVED) { $btn_color = 'btn-danger'; } ?> <div class="btn-group btn-participant"> <button id="btn_<?= $p->id ?>" type="button" class="btn <?= $btn_color ?> btn-sm dropdown-toggle " data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> <span id="star_<?= $p->id ?>" class="glyphicon glyphicon-star red-star <?= (!$p->isOrganizer())?'hidden':''?>" aria-hidden="true"></span> <?= MiscHelpers::getDisplayName($p->participant->id) ?> <span class="caret"></span> </button> <ul class="dropdown-menu"> <li><?= Html::a(Yii::t('frontend','Send a message'),Url::to('mailto:'.$p->participant->email))?></li> <?php if ($model->isOrganizer()) { ?> <li role="separator" class="divider"></li> <li id="mo_<?= $p->id ?>" class="<?= ($p->isOrganizer())?'hidden':''?>"><?= Html::a(Yii::t('frontend','Make organizer'),'javascript:void(0);',['onclick' => "toggleOrganizer($p->id,true);return false;"]); ?></li> <li id="ro_<?= $p->id ?>" class="<?= (!$p->isOrganizer())?'hidden':''?>"><?= Html::a(Yii::t('frontend','Revoke organizer role'),'javascript:void(0);',['onclick' => "toggleOrganizer($p->id,false);return false;"]); ?></li> <li id="rp_<?= $p->id ?>" class="<?= ($p->status == Participant::STATUS_REMOVED || $p->status == Participant::STATUS_DECLINED_REMOVED)?'hidden':''?>"><?= Html::a(Yii::t('frontend','Remove participant'),'javascript:void(0);',['onclick' => "toggleParticipant($p->id,false,$p->status);return false;"]); ?></li> <li id="rstp_<?= $p->id ?>" class="<?= ($p->status != Participant::STATUS_REMOVED && $p->status != Participant::STATUS_DECLINED_REMOVED)?'hidden':''?>"><?= Html::a(Yii::t('frontend','Restore participant'),'javascript:void(0);',['onclick' => "toggleParticipant($p->id,true,$p->status);return false;"]); ?></li> <?php } ?> </ul> </div>
There are so many button states, colors and stars to update as changes are made interactively on a page that the code becomes fairly intricate. I added functions to the meeting.js JavaScript file for toggleOrganizer()
, i.e. make/unset organizer, and toggleParticipant()
, i.e. remove/restore participant as attendee.
function toggleOrganizer(id, val) { if (val === true) { arg2 = 1; } else { arg2 =0; } $.ajax({ url: $('#url_prefix').val()+'/participant/toggleorganizer', data: {id: id, val: arg2}, success: function(data) { if (data) { if (val===false) { $('#star_'+id).addClass("hidden"); $('#ro_'+id).addClass("hidden"); $('#mo_'+id).removeClass("hidden"); } else { $('#star_'+id).removeClass("hidden"); $('#ro_'+id).removeClass("hidden"); $('#mo_'+id).addClass("hidden"); } } return true; } }); } function toggleParticipant(id, val, original_status) { if (val === true) { arg2 = 1; } else { arg2 =0; } $.ajax({ url: $('#url_prefix').val()+'/participant/toggleparticipant', data: {id: id, val: arg2, original_status: original_status}, success: function(data) { if (data) { if (val===false) { $('#rp_'+id).addClass("hidden"); $('#rstp_'+id).removeClass("hidden"); $('#btn_'+id).addClass("btn-danger"); $('#btn_'+id).removeClass("btn-default"); } else { $('#rp_'+id).removeClass("hidden"); $('#rstp_'+id).addClass("hidden"); if (original_status==100) { $('#btn_'+id).addClass("btn-warning"); $('#btn_'+id).removeClass("btn-danger"); } else { $('#btn_'+id).addClass("btn-default"); $('#btn_'+id).removeClass("btn-danger"); } } } return true; } }); }
These required accompanying JSON controller methods in ParticipantController.php to process the toggle requests and update the databases:
public function actionToggleorganizer($id,$val) { Yii::$app->response->format = \yii\web\Response::FORMAT_JSON; // change setting $p=Participant::findOne($id); if ($p->meeting->isOrganizer()) { $p->email = $p->participant->email; if ($val==1) { $p->participant_type=Participant::TYPE_ORGANIZER; } else { $p->participant_type=Participant::TYPE_DEFAULT; } $p->update(); return true; } else { return false; } } public function actionToggleparticipant($id,$val) { Yii::$app->response->format = \yii\web\Response::FORMAT_JSON; // change setting $p=Participant::findOne($id); if ($p->meeting->isOrganizer()) { $p->email = $p->participant->email; if ($val==0) { if ($p->status == Participant::STATUS_DECLINED) { $p->status=Participant::STATUS_DECLINED_REMOVED; } else { $p->status=Participant::STATUS_REMOVED; } } else { if ($p->status == Participant::STATUS_DECLINED_REMOVED) { $p->status=Participant::STATUS_DECLINED; } else { $p->status=Participant::STATUS_DEFAULT; } } $p->update(); return true; } else { return false; } }
Activating the Accordion Feature on Panels
At this time, I also realized that as meeting plans increased in complexity with more recipients and options, there would be more scrolling. I decided to implement the Bootstrap accordion feature for all the panels on our meeting view.
In other words, you can now click on a heading to collapse or open each and/or all of the panels.
Here are the changes to the partials for meeting place _panel.php:
<div class="panel panel-default"> <!-- Default panel contents --> <div class="panel-heading" role="tab" id="headingWhere"> <div class="row"> <div class="col-lg-10 col-md-10 col-xs-10" ><h4 class="meeting-place"> <a role="button" data-toggle="collapse" data-parent="#accordion" href="#collapseWhere" aria-expanded="true" aria-controls="collapseWhere"><?= Yii::t('frontend','Where') ?></a> </h4><p> <div class="hint-text heading-pad"> <?php if ($placeProvider->count<=1) { ?> <?= Yii::t('frontend','add places for participants or switch to \'virtual\'') ?> <?php } elseif ($placeProvider->count>1) { ?> <?= Yii::t('frontend','are listed places okay? ') ?> <?php } ?> ... <div id="collapseWhere" class="panel-collapse collapse in" role="tabpanel" aria-labelledby="headingWhere"> <div class="panel-body"> <?php $style = ($model->switchVirtual==$model::SWITCH_VIRTUAL?'none':'block'); ?> <div id ="meeting-place-list" style="display:<?php echo $style; ?>"> <?php if ($placeProvider->count>0): ?> <table class="table"> <?= ListView::widget([ 'dataProvider' => $placeProvider, 'itemOptions' => ['class' => 'item'], 'layout' => '{items}', 'itemView' => '_list', 'viewParams' => ['placeCount'=>$placeProvider->count,'isOwner'=>$isOwner,'participant_choose_place'=>$model->meetingSettings['participant_choose_place'],'whereStatus'=>$whereStatus], ]) ?> </table>
Note the settings above on the panel-heading
and then the surrounding div for the later panel-body
. These control the opening and collapsing of each panel.
This led to some small cosmetic problems such as unwanted padding around the list of items, which I'll need to clean up in the future.
Model Infrastructure for Group Meetings
While I'd been planning for multiple participants from nearly the beginning, there were some minor to modest infrastructure enhancements to support them.
While the MeetingTimeChoice
and MeetingPlaceChoice
models keep track of whether participants prefer specific date times and places, I wanted to track the overall availability for all participants at each date time and place. This would allow me to sort places and times by how popular they are—and show the most popular settings at the top of the panels.
First, I created a migration to add this to both models. It's infrequent that a migration of mine affects multiple models, which makes this one kind of special:
<?php use yii\db\Schema; use yii\db\Migration; class m160824_235517_extend_meeting_place_and_time extends Migration { public function up() { $tableOptions = null; if ($this->db->driverName === 'mysql') { $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB'; } $this->addColumn('{{%meeting_time}}','availability',Schema::TYPE_SMALLINT.' NOT NULL DEFAULT 0'); $this->addColumn('{{%meeting_place}}','availability',Schema::TYPE_SMALLINT.' NOT NULL DEFAULT 0'); } public function down() { $this->dropColumn('{{%meeting_time}}','availability'); $this->dropColumn('{{%meeting_place}}','availability'); } }
With this capacity, I was able to begin display possible meeting date times and places sorted by their popularity with participants, from MeetingController::actionView()
:
$timeProvider = new ActiveDataProvider([ 'query' => MeetingTime::find()->where(['meeting_id'=>$id]), 'sort' => [ 'defaultOrder' => [ 'availability'=>SORT_DESC ] ], ]); $placeProvider = new ActiveDataProvider([ 'query' => MeetingPlace::find()->where(['meeting_id'=>$id]), 'sort' => [ 'defaultOrder' => [ 'availability'=>SORT_DESC ] ], ]);
You can see this in action in the below planning screenshot:
To track whether participants are organizers and to allow for future opt-out of a specific meeting's notifications, I added this migration for the Participant table:
<?php use yii\db\Schema; use yii\db\Migration; class m160825_074740_extend_participant_add_type extends Migration { public function up() { $tableOptions = null; if ($this->db->driverName === 'mysql') { $tableOptions = 'CHARACTER SET utf8 COLLATE utf8_unicode_ci ENGINE=InnoDB'; } $this->addColumn('{{%participant}}','participant_type',Schema::TYPE_SMALLINT.' NOT NULL DEFAULT 0'); $this->addColumn('{{%participant}}','notify',Schema::TYPE_SMALLINT.' NOT NULL DEFAULT 0'); } public function down() { $this->dropColumn('{{%participant}}','participant_type'); $this->dropColumn('{{%participant}}','notify'); } }
I also added a number of constants in Participant.php to work with these properties:
class Participant extends \yii\db\ActiveRecord { const TYPE_DEFAULT = 0; const TYPE_ORGANIZER = 10; const NOTIFY_ON = 0; const NOTIFY_OFF = 1; const STATUS_DEFAULT = 0; const STATUS_REMOVED = 90; const STATUS_DECLINED = 100; const STATUS_DECLINED_REMOVED = 110;
And I knew that it would be helpful to have some helper functions within the massive Meeting model. For example, IsOrganizer()
tells me if the current viewer is a meeting organizer:
public function isOrganizer() { $user_id = Yii::$app->user->getId(); if ($user_id == $this->owner_id) { return true; } else { foreach ($this->participants as $p) { if ($user_id == $p->participant_id) { if ($p->participant_type == Participant::TYPE_ORGANIZER) { return true; } else { return false; } } } } return false; }
Wait, There's More?
As you can see, there's a lot of ground to cover to build this feature. In the next episode, I'll cover the second half of development and testing required to launch multiple participant meetings: recipient strings, notifications, requests, and responding to requests.
If you haven't yet, go schedule your first meeting with Meeting Planner and try all this out. Please share your feedback in the comments below.
A tutorial on crowdfunding is also in the works, so please follow our WeFunder Meeting Planner page.
You can also reach out to me @reifman. I'm always open to new feature ideas and topic suggestions for future tutorials.
Stay tuned for all of this and more upcoming tutorials by checking out the Building Your Startup With PHP series.
Comments