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.
Expanding Scheduling Options
As the Meeting Planner alpha testing phase began, the clearest feature gap was the inability to change a meeting after it had been scheduled. But there were also other features missing, like resending an invitation lost in email, rescheduling a meeting completely, or the ability to view and edit the control settings chosen by the organizer.
Interestingly, I also began to realize that the ability to adjust meetings easily after they've been scheduled could make or break the Meeting Planner brand. For example, there's a lot of social engineering in post-scheduling meeting adjustment. Often, you need to ask the participant(s) if it's okay to adjust the time or place. Perhaps you just want to meet 15 minutes earlier or the next day at the same time and place. But you can't always make these changes without consent.
Keeping the site easy to understand and simple to use with all of this capability is my prime directive. How could I add an increasing number of features without cluttering the user interface or overly concealing them? How would this work on both mobile and desktop interfaces?
In today's tutorial, I'll cover expanding the navigation bar using Bootstrap and the basics of building some of the advanced scheduling features within Meeting Planner. Next week, I'll review building the more complex feature for participants to request change(s) and for others to accept or decline them.
I hope you'll try out all the new scheduling features on the live site and 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.
Expanding the Navigation Bar
First, let's look at how to expand the existing navigation bar. Here's a list of the features I'd planned to add:
- Allowing changes to places, dates and times of a meeting after it's been finalized.
- Requesting smaller adjustments to a meeting, such as can we meet an hour earlier? Or at that other suggested place?
- Rescheduling a meeting entirely with the same participants and places.
- Repeating a meeting using participants, places, days of week and times of day from a past meeting to make scheduling a new meeting easier.
- Showing the participants a chronological history of all the planning activities for a meeting.
- Viewing and updating the settings for a meeting.
As you can see, not only was there a lot of functionality to build, but I also had no clear idea where to place it in the user interface without creating a mess.
The command bar also needed to change depending on the status of a meeting. Meetings that were in planning phase had different options than pending, confirmed, or past completed meetings.
Early UX Ideas Led Back to Bootstrap
My initial idea was to provide a small Advanced Settings link that would display a hidden command bar. I experimented with this at first, but it wasn't aesthetically pleasing.
Then, I reviewed the Bootstrap documentation and found the dropdown combo box:
I liked the way it functioned. So I decided to place most of the advanced commands in a left-oriented dropdown button.
Here's an example of what it looks like in the planning phase of a meeting:
Notice that bootstrap offers a class for dropup menus. A command bar placed at the bottom of the page uses dropup as follows:
/* the partial position knows to set the dropclass variable up or down */ echo $this->render('_command_bar_past', [ 'model'=>$model, 'isPast'=>true, 'dropclass'=>'dropup', 'isOwner' => $isOwner, ]); /* the resulting view applies the dropclass */ <div class="command-bar"> <div class="row"> <div class="col-xs-4"> <div class="<?= $dropclass ?>" > <button class="btn btn-default dropdown-toggle" type="button" id="dropdownMenu1" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true"> <?= Yii::t('frontend','Options');?> <span class="caret"></span> </button> <ul class="dropdown-menu" aria-labelledby="dropdownMenu1"> <?php if (!$isPast && ($model->viewer == Meeting::VIEWER_ORGANIZER || $meetingSettings->participant_reopen)) { ?> <li><?= Html::a(Yii::t('frontend', 'Make changes'), ['reopen','id'=>$model->id], ['title'=>Yii::t('frontend','tbd')]); ?></li>
I also created partial view files to be rendered depending on the status of a meeting. For example, in /frontend/views/meeting/view_confirmed.php, you can see either the _command_bar_past.php
or _command_bar_confirmed.php
partial is included:
<?php if ( $model->status >= $model::STATUS_COMPLETED) { ... echo $this->render('_command_bar_past', [ 'model'=>$model, 'isPast'=>true, 'dropclass'=>'dropdown', 'isOwner' => $isOwner, ]); } else { echo $this->render('_command_bar_confirmed', [ 'model'=>$model, 'meetingSettings' => $meetingSettings, 'showRunningLate'=>$showRunningLate, 'isPast'=>$isPast, 'dropclass'=>'dropdown', 'isOwner' => $isOwner, ]); } ?>
Determining Access to Commands for Different Viewers
I didn't want to allow everyone to see all the commands. Organizers would see more commands than participants, but the meeting settings often grant them access too.
Here's an example of determining whether to display the option to reopen a meeting and make changes to it as if it was still in the planning phase:
<ul class="dropdown-menu" aria-labelledby="dropdownMenu1"> <?php if (!$isPast && ($model->viewer == Meeting::VIEWER_ORGANIZER || $meetingSettings->participant_reopen)) { ?> <li><?= Html::a(Yii::t('frontend', 'Make changes'), ['reopen','id'=>$model->id], ['title'=>Yii::t('frontend','tbd')]); ?></li>
The option is included in the Bootstrap dropdown menu if the meeting hasn't passed its start date and the viewer is the organizer or the organizer has allowed participants to make changes.
Now, let's dive into building some of the functionality for these various commands.
Building the Advanced Scheduling Commands
It's not possible to cover all of the new work that went into these scheduling features. For brevity, I'll cover just the basics and any unique aspects of various commands.
Every Feature Takes Longer Now
After implementing some deeper security features within Meeting Planner, I try to adhere to stricter coding standards as I add new features. I spend more time on controller access, model access checks, and rate limiting.
Also, the beta release will support multiple participants in a meeting, so I have to architect the code with that in mind as I go.
Overall, everything I do takes a bit longer than it did in rapid prototyping mode.
As I built all these various meeting features, I frequently felt the increased workload as I added to the codebase. You'll notice these checks in the code below.
There's also attention to the historical Meeting log which we're about to make visible. So every action often requires a log record. And the log records are helpful for supporting rate limiting.
Making Changes to a Meeting
One easy way to allow changes to a meeting that has been finalized is just to allow the organizer to reopen it, taking it back to the planning phase. Then, they can add date times and places, choose new ones, and finalize the meeting again.
I also wanted the organizer to empower their participants to do this. This required finally building the ability to view and update per meeting settings.
I added settings to allow participants to either request changes of organizers or make them directly.
When a meeting is created, its settings are initialized with the user's default settings:
public function initializeMeetingSetting($meeting_id,$owner_id) { $checkMtgStg = MeetingSetting::find()->where(['meeting_id' => $meeting_id])->one(); if (is_null($checkMtgStg)) { // load meeting creator (owner) user settings to initialize meeting_settings UserSetting::initialize($owner_id); // if not initialized $user_setting = UserSetting::find()->where(['user_id' => $owner_id])->one(); $meeting_setting = new MeetingSetting(); $meeting_setting->meeting_id = $meeting_id; $meeting_setting->participant_add_place=$user_setting->participant_add_place; $meeting_setting->participant_add_date_time=$user_setting->participant_add_date_time; $meeting_setting->participant_choose_place=$user_setting->participant_choose_place; $meeting_setting->participant_choose_date_time=$user_setting->participant_choose_date_time; $meeting_setting->participant_finalize=$user_setting->participant_finalize; $meeting_setting->participant_reopen=$user_setting->participant_reopen; $meeting_setting->participant_request_change=$user_setting->participant_request_change; $meeting_setting->save(); } }
You can see the results of the new controller and update view for MeetingSettings below:
Here's /frontend/controllers/MeetingController.php's actionReopen()
:
public function actionReopen($id) { $m = $this->findModel($id); $m->setViewer(); // also check reopen() if ($m->viewer == Meeting::VIEWER_ORGANIZER || $m->meetingSettings->participant_reopen) { if ($m->reopen()) { Yii::$app->getSession()->setFlash('success', Yii::t('frontend','The meeting has now been reopened so you can make changes.')); } else { Yii::$app->getSession()->setFlash('error', Yii::t('frontend','Sorry, you are not allowed to reopen a meeting this many times. Try creating a new meeting.')); } } else { Yii::$app->getSession()->setFlash('error', Yii::t('frontend','Sorry, you are not allowed to do this.')); } return $this->redirect(['view', 'id' => $id]); }
And here's the Meeting.php model code to move the meeting back to planning mode:
public function reopen() { // when organizer or participant with permission asks to make changes if (MeetingLog::withinActionLimit($this->id,MeetingLog::ACTION_REOPEN,Yii::$app->user->getId(),7)) { $this->status = Meeting::STATUS_SENT; $this->update(); $this->increaseSequence(); MeetingLog::add($this->id,MeetingLog::ACTION_REOPEN,Yii::$app->user->getId()); return true; } else { // over limit per meeting return false; } }
The withinActionLimit
is checking for the number of times someone tries to reopen a meeting. IncreaseSequence
is for the .ics file—as the meeting date, time, and place change, the ics file needs to be told.
The image below shows a meeting that's been confirmed with a variety of the advanced options available:
When the user clicks Make Changes in the above menu, the status of the meeting is moved back to planning and they can return to update the date, time, and place:
Reschedule a Meeting
If events have led attendees to realize they just need to start over, the Reschedule option cancels the current meeting and creates a new planning invitation.
Currently, I restrict this feature to organizers (not participants), but I may extend it later. The Meeting.php::Reschedule()
method supports either person performing the action:
public function reschedule() { $newOwner = $user_id = Yii::$app->user->getId(); // user can only cancel their own Meeting if ($this->owner_id == $user_id) { $addParticipant = false; $this->cancel($user_id); MeetingLog::add($this->id,MeetingLog::ACTION_RESCHEDULE,$user_id); } else { // if user is participant - needs to reverse if (!isAttendee($this->id,$user_id)) { // user isn't owner or participant - error return false; } else { // reverse the owner and participant $addParticipant = $this->owner_id; } } // create new meeting - as copy of old meeting $m = new Meeting(); $m->attributes = $this->attributes; $m->owner_id = $newOwner; $m->status = Meeting::STATUS_PLANNING; $m->created_at = time(); $m->updated_at = time(); $m->logged_at = 0; $m->cleared_at = 0; $m->sequence_id = 0; $m->save(); // clone the selected place (not all of them) $chosenPlace = $this->getChosenPlace($this->id); if ($chosenPlace!==false) { $mp = new MeetingPlace; $mp->suggested_by = $newOwner; $mp->attributes = $chosenPlace->attributes; $mp->meeting_id = $m->id; $mp->created_at = time(); $mp->updated_at = time(); $mp->save(); } // clone the participants foreach ($this->participants as $p) { // skip if reschedule new owner was a participant if ($p->participant_id==$user_id) { continue; } // note Participant afterSave will create choices for place $clone_p = new Participant(); $clone_p->attributes = $p->attributes; $clone_p->email = User::findOne($p->participant_id)->email; $clone_p->meeting_id = $m->id; $clone_p->invited_by = $newOwner; $clone_p->status = Participant::STATUS_DEFAULT; $clone_p->created_at = time(); $clone_p->updated_at = time(); $clone_p->save(); } // if participant asked to reschedule - not yet allowed if ($addParticipant!==false) { $newP = new Participant(); $newP->meeting_id = $m->id; $newP->participant_id = $addParticipant; $newP->invited_by = $user_id; $newP->status = Participant::STATUS_DEFAULT; $newP->created_at = time(); $newP->updated_at = time(); $newP->save(); } return $m->id; }
The participants and the selected place are cloned in the new meeting.
Repeat a Meeting
Another approach to scheduling is to allow participants to duplicate past meetings. In the future, I might let the viewer decide which participants, places and times are duplicated, but for now, Meeting Planner creates a new meeting with the same participants at the same place—and an identical day of week and time of day one and two weeks in the future.
public function repeat() { // to do - expand repeat meeting to have more options // e.g. pick same day and time in future week or two // e.g. duplicate chosenplace or all places // e.g. duplicate all participants or just some (complicated if participant duplicates) $newOwner = $user_id = Yii::$app->user->getId(); // if user is participant - needs to reverse if ($this->owner_id == $user_id) { $addParticipant = false; } else { if (!isAttendee($this->id,$user_id)) { // user isn't owner or participant - error return false; } else { // reverse the owner and participant $addParticipant = $this->owner_id; } } // create new meeting - as copy of old meeting $m = new Meeting(); $m->attributes = $this->attributes; $m->owner_id = $newOwner; $m->status = Meeting::STATUS_PLANNING; $m->created_at = time(); $m->updated_at = time(); $m->logged_at = 0; $m->cleared_at = 0; $m->sequence_id = 0; $m->save(); // get prior meetings selected time and create two future times for the next two weeks $chosenTime=$this->getChosenTime($this->id); $mt1 = MeetingTime::createTimePlus($m->id,$m->owner_id,$chosenTime->start,$chosenTime->duration); $mt2 = MeetingTime::createTimePlus($m->id,$m->owner_id,$mt1->start,$chosenTime->duration); // clone the selected place (not all of them) $chosenPlace = $this->getChosenPlace($this->id); if ($chosenPlace!==false) { $mp = new MeetingPlace; $mp->suggested_by = $newOwner; $mp->attributes = $chosenPlace->attributes; $mp->meeting_id = $m->id; $mp->created_at = time(); $mp->updated_at = time(); $mp->save(); } // clone the participants foreach ($this->participants as $p) { // skip if reschedule new owner was a participant if ($p->participant_id==$user_id) { continue; } // note Participant afterSave will create choices for place $clone_p = new Participant(); $clone_p->attributes = $p->attributes; $clone_p->email = User::findOne($p->participant_id)->email; $clone_p->meeting_id = $m->id; $clone_p->status = Participant::STATUS_DEFAULT; $clone_p->created_at = time(); $clone_p->updated_at = time(); $clone_p->save(); } // if participant asked to repeat // add the prior owner as a participant if ($addParticipant!==false) { $newP = new Participant(); $newP->meeting_id = $m->id; $newP->participant_id = $addParticipant; $newP->invited_by = $user_id; $newP->status = Participant::STATUS_DEFAULT; $newP->created_at = time(); $newP->updated_at = time(); $newP->save(); } MeetingLog::add($this->id,MeetingLog::ACTION_REPEAT,$user_id,0); return $m->id; }
MeetingTime::createTimePlus()
below adds a Meeting Time on the same day of week and time of day, but one week in the future, even if the original meeting occurred months ago. The while
loop was needed for older meetings.
public static function createTimePlus($meeting_id,$suggested_by,$start,$duration,$timeInFuture = 604800) { // finds time in multiples of a week or timeInFuture seconds ahead past the present time $newStart = $start+$timeInFuture; while ($newStart<time()) { $newStart+=$timeInFuture; } $mt = new MeetingTime(); $mt->meeting_id = $meeting_id; $mt->start = $newStart; $mt->duration = $duration; $mt->end = $mt->start+($mt->duration*3600); $mt->suggested_by = $suggested_by; $mt->status = MeetingTime::STATUS_SUGGESTED; $mt->updated_at = $mt->created_at = time(); $mt->save(); return $mt; }
Resend Invitations
I also built a resend feature in case a participant didn't receive their original invitation or final confirmation.
public static function resend($id) { $sender_id = Yii::$app->user->getId(); // check if within resend limit $cnt = MeetingLog::find() ->where(['actor_id'=>$sender_id]) ->andWhere(['meeting_id'=>$id]) ->andWhere(['action'=>MeetingLog::ACTION_RESEND]) ->count(); if ($cnt >= Meeting::RESEND_LIMIT ) { return false; } else { $m = Meeting::findOne($id); if ($m->status == Meeting::STATUS_SENT) { $m->send($sender_id,true); // resend the planning invitation } else if ($m->status == Meeting::STATUS_CONFIRMED) { // resend the confirmed invitation $m->finalize($sender_id,true); } MeetingLog::add($id,MeetingLog::ACTION_RESEND,$sender_id,0); return true; } }
I plan to rebuild the outbound email functionality to work asynchronously and make redelivery easy, but fortunately the current methods worked well in the resend scenario with few changes.
Meeting History
Throughout the series, we've built a log of every change made to meetings. There's now a way for attendees to view the planning history. It looks like this:
The MeetingLogController.php checks that the viewer is an attendee of the meeting and prepares the data for viewing the log:
public function actionView($id) { if (!Meeting::isAttendee($id,Yii::$app->user->getId())) { $this->redirect(['site/authfailure']); } $timezone = MiscHelpers::fetchUserTimezone(Yii::$app->user->getId()); Yii::$app->timeZone = $timezone; $searchModel = new MeetingLogSearch(); $dataProvider = $searchModel->search(['MeetingLogSearch'=>['meeting_id'=>$id]]); $m= Meeting::findOne($id); return $this->render('index', [ 'searchModel' => $searchModel, 'dataProvider' => $dataProvider, 'meeting_id' => $id, 'subject' => $m->getMeetingHeader('log'), 'timezone' => $timezone, ]); }
Then the /frontend/views/meeting-log/index.php renders that data shown above:
<?php Pjax::begin(); ?> <div class="meeting-log-index"> <h1><?php echo Html::encode($this->title) ?></h1> <?php echo GridView::widget([ 'dataProvider' => $dataProvider, //'filterModel' => $searchModel, 'columns' => [ [ 'label'=>'Actor', 'attribute' => 'actor_id', 'format' => 'raw', 'value' => function ($model) { return '<div>'.MiscHelpers::getDisplayName($model->actor_id).'</div>'; }, ], [ 'label'=>'Action', 'attribute' => 'action', 'format' => 'raw', 'value' => function ($model) { return '<div>'.$model->getMeetingLogCommand().'</div>'; }, ], [ 'label'=>'Item', 'attribute' => 'item_id', 'format' => 'raw', 'value' => function ($model) { return '<div>'.$model->getMeetingLogItem().'</div>'; }, ], [ 'label'=>'Created', 'attribute' => 'created_at', 'format' => 'raw', 'value' => function ($model) { return '<div>'.Yii::$app->formatter->asDatetime($model->created_at,"hh:ss MMM d").'</div>'; }, ], ], ]); ?> <?= Html::a(Yii::t('frontend', 'Return to Meeting'), ['meeting/view', 'id' => $meeting_id], ['class' => 'btn btn-primary btn-info', 'title'=>Yii::t('frontend','Return to meeting page'), ]); ?> <?php Pjax::end(); ?>
What's Next?
I'm now on an intense code sprint to complete the beta release. The editorial gods at Envato Tuts+ have tried their best to distract me with mind-controlling robots and OKCupid-like beeps from their iOS-powered workflow application, but they've failed. Meeting Planner development continues at a fast pace.
Solving the social engineering and UX for meeting attendees to request and respond to smaller scheduling adjustments could make or break the Meeting Planner brand, and that's what I'm working on the most.
If you haven't yet, go schedule your first meeting with Meeting Planner. I'm also planning to write a tutorial about crowdfunding, so please consider following 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