PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / trunk
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration vtrunk
2.0.15 2.0.12 2.0.10 2.0.4 2.0.1 2.0.0 1.95.3 1.95.2 1.95 1.91.6 trunk 1.11 1.12 1.13 1.20 1.21 1.22 1.23 1.30 1.31 1.32 1.35 1.40 1.41 1.45 All 41 releases
← All changes | app/Models/Task.php +305 -23 1.23trunk View file →
@@ -44,17 +44,35 @@
44 44 'comments_count',
45 45 'created_by',
46 46 ];
47 47
48 - protected $appends = ['meta'];
48 + protected $appends = ['meta', 'repeat_task_meta', 'is_pinned'];
49 49
50 + protected $nullableTimestampAttributes = [
51 + 'due_at',
52 + 'started_at',
53 + 'last_completed_at',
54 + 'archived_at',
55 + 'remind_at',
56 + ];
57 +
58 + protected static $skipTaskCreatedEvent = false;
59 +
60 + public static function withoutTaskCreatedEvent($callback)
61 + {
62 + static::$skipTaskCreatedEvent = true;
63 + $result = $callback();
64 + static::$skipTaskCreatedEvent = false;
65 + return $result;
66 + }
67 +
50 68 public static function boot()
51 69 {
52 70 parent::boot();
53 71 static::creating(function ($model) {
72 + $board = Board::find($model->board_id);
54 73 $model->created_by = $model->created_by ?: get_current_user_id();
55 - $model->type = $model->type
56 - ?: 'task'; // default task type is task
74 + $model->type = $board->type === 'roadmap' ? 'roadmap' : 'task'; // default task type is task
57 75
58 76 if (empty($model->slug)) {
59 77 $model->slug = sanitize_title($model->title, 'idea-'.time());
60 78 }
@@ -65,14 +83,15 @@
65 83 'backgroundColor' => '',
66 84 ],
67 85 'subtask_count' => 0,
68 86 'attachment_count' => 0,
87 + 'subtask_completed_count' => 0,
69 88 ];
70 89 $model->position = $model->position
71 90 ?: (new TaskService())->getLastPositionOfTasks($model->stage_id);
72 91 });
73 92 static::created(function ($model) {
74 - if ( ! $model->parent_id) {
93 + if (!$model->parent_id && !static::$skipTaskCreatedEvent) {
75 94 do_action('fluent_boards/task_created', $model);
76 95 if ($model->crm_contact_id) {
77 96 do_action('fluent_boards/contact_added_to_task', $model);
78 97 }
@@ -80,9 +99,9 @@
80 99 self::adjustSubtaskCount($model->parent_id);
81 100 }
82 101 });
83 102
84 - /* global scope for task type which means only task_type = task will be fetched from everywhere in */
103 + /* global scope for task type which means only type = task will be fetched from everywhere in */
85 104 static::addGlobalScope('type', function (Builder $builder) {
86 105 $builder->where('type', '=', 'task')
87 106 ->orWhere('type', '=', 'roadmap');
88 107 });
@@ -120,9 +139,27 @@
120 139 return $query->where('status', 'open')
121 140 ->where('due_at', '>=', current_time('mysql'));
122 141 }
123 142
143 + /**
144 + * scope of getting open tasks due today
145 + *
146 + * @param $query \FluentBoards\Framework\Database\Query\Builder
147 + *
148 + * @return \FluentBoards\Framework\Database\Query\Builder
149 + */
150 + public function scopeDueToday($query)
151 + {
152 + $todayTimestamp = current_time('timestamp');
153 + $startOfToday = gmdate('Y-m-d 00:00:00', $todayTimestamp);
154 + $endOfToday = gmdate('Y-m-d 23:59:59', $todayTimestamp);
124 155
156 + return $query->whereNull('last_completed_at')
157 + ->where('status', 'open')
158 + ->whereBetween('due_at', [$startOfToday, $endOfToday]);
159 + }
160 +
161 +
125 162 public function setSettingsAttribute($settings)
126 163 {
127 164 $this->attributes['settings'] = \maybe_serialize($settings);
128 165 }
@@ -185,8 +222,30 @@
185 222 {
186 223 return $this->belongsTo(Board::class, 'board_id', 'id');
187 224 }
188 225
226 + /**
227 + * Exclude tasks that belong to template boards.
228 + */
229 + public function scopeExcludeTemplateBoards($query)
230 + {
231 + return $query->whereHas('board', function ($boardQuery) {
232 + $boardQuery->excludeTemplates();
233 + });
234 + }
235 +
236 + /**
237 + * Limit tasks to active, non-template boards available in this install.
238 + */
239 + public function scopeOnActiveAvailableBoards($query)
240 + {
241 + return $query->whereHas('board', function ($boardQuery) {
242 + $boardQuery->whereNull('archived_at')
243 + ->excludeTemplates()
244 + ->availableInCurrentInstall();
245 + });
246 + }
247 +
189 248 public function assignees()
190 249 {
191 250 return $this->belongsToMany(
192 251 User::class,
@@ -213,8 +272,13 @@
213 272 }
214 273
215 274 public function attachments() //may not need in future
216 275 {
276 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
277 + return $this->hasMany(Attachment::class, 'id', 'id')
278 + ->where('id', 0);
279 + }
280 +
217 281 return $this->hasMany(TaskAttachment::class,
218 282 'object_id', 'id')
219 283 ->where('object_type', Constant::OBJECT_TYPE_TASK);
220 284 }
@@ -231,8 +295,44 @@
231 295 Constant::OBJECT_TYPE_USER_TASK_WATCH)
232 296 ->withTimestamps();
233 297 }
234 298
299 + /**
300 + * Tasks that must finish before this task can start (this task is the successor/blocked).
301 + * In fbs_relations: object_id = predecessor, foreign_id = this task (successor).
302 + *
303 + * @return \FluentBoards\Framework\Database\Orm\Relations\BelongsToMany
304 + */
305 + public function predecessors()
306 + {
307 + return $this->belongsToMany(
308 + Task::class,
309 + 'fbs_relations',
310 + 'foreign_id',
311 + 'object_id'
312 + )->wherePivot('object_type', Constant::OBJECT_TYPE_TASK_DEPENDENCY)
313 + ->withPivot('settings')
314 + ->withTimestamps();
315 + }
316 +
317 + /**
318 + * Tasks that are waiting on this task to finish (this task is the predecessor/blocker).
319 + * In fbs_relations: object_id = this task (predecessor), foreign_id = successor.
320 + *
321 + * @return \FluentBoards\Framework\Database\Orm\Relations\BelongsToMany
322 + */
323 + public function successors()
324 + {
325 + return $this->belongsToMany(
326 + Task::class,
327 + 'fbs_relations',
328 + 'object_id',
329 + 'foreign_id'
330 + )->wherePivot('object_type', Constant::OBJECT_TYPE_TASK_DEPENDENCY)
331 + ->withPivot('settings')
332 + ->withTimestamps();
333 + }
334 +
235 335 public function parentTask($id)
236 336 {
237 337 return self::find($id);
238 338 }
@@ -246,8 +346,18 @@
246 346 {
247 347 return $this->hasMany(TaskMeta::class, 'task_id', 'id');
248 348 }
249 349
350 + public function getPopularCount()
351 + {
352 + $interactions = $this->taskMeta()->get()->pluck('value', 'key');
353 + if($interactions) {
354 + return ($interactions['upvote'] ?? 0) + ($interactions['comments_count'] ?? 0);
355 + } else {
356 + return 0;
357 + }
358 + }
359 +
250 360 public function getMetaAttribute()
251 361 {
252 362 return $this->taskMeta()->get()->pluck('value', 'key');
253 363 }
@@ -286,10 +396,16 @@
286 396
287 397 public function createTask($data)
288 398 {
289 399 $data = apply_filters('fluent_boards/before_task_create', $data);
400 +
401 + // Keep newly-created tasks unprioritized unless a priority is explicitly selected.
402 + if (!array_key_exists('priority', $data)) {
403 + $data['priority'] = '';
404 + }
405 +
290 406 $createdTask = Task::create($data);
291 -
407 +
292 408 if ( ! empty($data['assignees'])) {
293 409 $assignees = array_filter(array_map('intval', $data['assignees']));
294 410 if ($assignees) {
295 411 $assigneeData = array_fill_keys($assignees,
@@ -294,8 +410,15 @@
294 410 if ($assignees) {
295 411 $assigneeData = array_fill_keys($assignees,
296 412 ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE]);
297 413 $createdTask->assignees()->syncWithoutDetaching($assigneeData);
414 +
415 + // Add assignees as watchers
416 + foreach ($assignees as $assigneeId) {
417 + $createdTask->watchers()->syncWithoutDetaching([
418 + $assigneeId => ['object_type' => Constant::OBJECT_TYPE_USER_TASK_WATCH]
419 + ]);
420 + }
298 421 }
299 422 }
300 423
301 424 if ( ! empty($data['labels'])) {
@@ -329,12 +452,12 @@
329 452
330 453 return $operation;
331 454 }
332 455
333 - public function getArchivedAttribute()
334 - {
335 - return (bool) $this->attributes['is_archived'];
336 - }
456 + // public function getArchivedAttribute()
457 + // {
458 + // return (bool) $this->attributes['is_archived'];
459 + // }
337 460
338 461 public function setArchivedAttribute($value)
339 462 {
340 463 if (true === $value) {
@@ -419,8 +542,22 @@
419 542
420 543 return $exist;
421 544 }
422 545
546 + /**
547 + * Virtual attribute: pinned state from task meta (for API/frontend).
548 + * Tasks without is_pinned meta are treated as unpinned (0).
549 + * Reads from the already-loaded 'meta' attribute to avoid an extra DB query per task.
550 + *
551 + * @return int 1 if pinned, 0 otherwise
552 + */
553 + public function getIsPinnedAttribute()
554 + {
555 + $meta = $this->meta;
556 +
557 + return (int) ($meta[Constant::IS_TASK_PINNED] ?? 0);
558 + }
559 +
423 560 public function moveToNewPosition($newIndex)
424 561 {
425 562 $newIndex = (int) $newIndex;
426 563 if ($newIndex < 1) {
@@ -501,8 +638,116 @@
501 638
502 639 return $this;
503 640 }
504 641
642 + public function moveBetweenTasks($prevTaskId = null, $nextTaskId = null)
643 + {
644 + $prevTaskId = absint($prevTaskId);
645 + $nextTaskId = absint($nextTaskId);
646 + // Exclude the current task so cross-stage moves can reuse the same
647 + // neighbour lookup logic after the stage_id has already been reassigned.
648 + $taskQuery = $this->getTaskOrderingQuery();
649 +
650 + $prevTask = null;
651 + if ($prevTaskId) {
652 + $prevTask = (clone $taskQuery)
653 + ->where('id', $prevTaskId)
654 + ->first();
655 + }
656 +
657 + $nextTask = null;
658 + if ($nextTaskId) {
659 + $nextTask = (clone $taskQuery)
660 + ->where('id', $nextTaskId)
661 + ->first();
662 + }
663 +
664 + if (!$prevTask && !$nextTask) {
665 + $firstItem = (clone $taskQuery)
666 + ->orderBy('position', 'asc')
667 + ->first();
668 +
669 + if (!$firstItem) {
670 + $this->position = 1;
671 + $this->save();
672 +
673 + return $this;
674 + }
675 +
676 + if ($firstItem->position < 0.02) {
677 + self::reIndexTasksPositions($this->toArray());
678 +
679 + return $this->moveBetweenTasks($prevTaskId, $nextTaskId);
680 + }
681 +
682 + $this->position = round($firstItem->position / 2, 2);
683 + $this->save();
684 +
685 + return $this;
686 + }
687 +
688 + if (!$prevTask && $nextTask) {
689 + // Insert before the first visible neighbour by splitting the leading gap.
690 + if ($nextTask->position < 0.02) {
691 + self::reIndexTasksPositions($this->toArray());
692 +
693 + return $this->moveBetweenTasks($prevTaskId, $nextTaskId);
694 + }
695 +
696 + $this->position = round($nextTask->position / 2, 2);
697 + $this->save();
698 +
699 + return $this;
700 + }
701 +
702 + if ($prevTask && !$nextTask) {
703 + // Insert after the last visible neighbour without touching the rest
704 + // of the stage unless the sparse ordering needs a later reindex.
705 + $this->position = $prevTask->position + 1;
706 + $this->save();
707 +
708 + return $this;
709 + }
710 +
711 + if ($prevTask->position >= $nextTask->position) {
712 + self::reIndexTasksPositions($this->toArray());
713 +
714 + return $this->moveBetweenTasks($prevTaskId, $nextTaskId);
715 + }
716 +
717 + // Middle inserts keep reordering cheap by taking the midpoint between
718 + // the two neighbour positions instead of renumbering the whole stage.
719 + $newPosition = ($prevTask->position + $nextTask->position) / 2;
720 +
721 + $exists = (clone $taskQuery)
722 + ->where('position', $newPosition)
723 + ->first();
724 +
725 + if ($exists) {
726 + self::reIndexTasksPositions($this->toArray());
727 +
728 + return $this->moveBetweenTasks($prevTaskId, $nextTaskId);
729 + }
730 +
731 + $this->position = $newPosition;
732 + $this->save();
733 +
734 + return $this;
735 + }
736 +
737 + private function getTaskOrderingQuery()
738 + {
739 + if (isset($this->parent_id)) {
740 + return self::where('parent_id', $this->parent_id)
741 + ->whereNull('archived_at')
742 + ->where('id', '!=', $this->id);
743 + }
744 +
745 + return self::where('stage_id', $this->stage_id)
746 + ->whereNull('archived_at')
747 + ->where('id', '!=', $this->id);
748 + }
749 +
505 750 public static function reIndexTasksPositions($task)
506 751 {
507 752 if (isset($task['parent_id'])) {
508 753 $tasksQuery = self::where('parent_id', $task['parent_id']);
@@ -527,10 +772,14 @@
527 772 $parentTask = Task::find($subTaskParentId);
528 773 if( !$parentTask) {
529 774 return;
530 775 }
776 + $subtasks = $parentTask->subtasks;
531 777 $parentTaskSettings = $parentTask->settings;
532 - $parentTaskSettings['subtask_count'] = $parentTask->subtasks->count();
778 + $parentTaskSettings['subtask_count'] = $subtasks->count();
779 + $parentTaskSettings['subtask_completed_count'] = $subtasks->filter(function ($subtask) {
780 + return $subtask->status === 'closed';
781 + })->count();
533 782 $parentTask->settings = $parentTaskSettings;
534 783 $parentTask->save();
535 784 }
536 785
@@ -535,9 +784,9 @@
535 784 }
536 785
537 786 public function close()
538 787 {
539 - if ($this->status == 'closed') {
788 + if ($this->status == 'closed' && $this->last_completed_at) {
540 789 return $this;
541 790 }
542 791
543 792 $this->status = 'closed';
@@ -542,22 +791,26 @@
542 791
543 792 $this->status = 'closed';
544 793 $this->last_completed_at = current_time('mysql');
545 794 $this->save();
546 -
795 + if ($this->parent_id) {
796 + self::adjustSubtaskCount($this->parent_id);
797 + }
547 798 return $this;
548 799 }
549 800
550 801 public function reopen()
551 802 {
552 - if ($this->status == 'open') {
803 + if ($this->status == 'open' && $this->last_completed_at == null) {
553 804 return $this;
554 805 }
555 806
556 807 $this->status = 'open';
557 - $this->last_completed_at = null;
808 + $this->last_completed_at = NULL;
558 809 $this->save();
559 -
810 + if ($this->parent_id) {
811 + self::adjustSubtaskCount($this->parent_id);
812 + }
560 813 return $this;
561 814 }
562 815
563 816 /*
@@ -569,15 +822,24 @@
569 822 */
570 823 public static function mappables()
571 824 {
572 825 return [
573 - 'title' => __('Title', 'fluent-boards'),
826 + 'task_title' => __('Task Title', 'fluent-boards'),
827 + 'slug' => __('Slug', 'fluent-boards'),
828 + 'board_title' => __('Board Title', 'fluent-boards'),
829 + 'status' => __('Status', 'fluent-boards'),
830 + 'type' => __('Type', 'fluent-boards'),
574 831 'description' => __('Description', 'fluent-boards'),
575 832 'priority' => __('Priority', 'fluent-boards'),
576 833 'due_at' => __('Due Date', 'fluent-boards'),
834 + 'started_at' => __('Start Date', 'fluent-boards'),
835 + 'archived_at' => __('Archive Date', 'fluent-boards'),
577 836 'stage' => __('Stage', 'fluent-boards'),
578 837 'board' => __('Board', 'fluent-boards'),
579 838 'source' => __('Source', 'fluent-boards'),
839 + 'position' => __('Position', 'fluent-boards'),
840 + 'subtasks' => __('Subtasks', 'fluent-boards'),
841 + 'completion' => __('Completion', 'fluent-boards'),
580 842 ];
581 843 }
582 844 public static function mappableFields()
583 845 {
@@ -615,9 +877,9 @@
615 877 'priority' => [
616 878 'field' => __('Priority', 'fluent-boards'),
617 879 'type' => 'text',
618 880 'rules' => 'optional',
619 - 'description' => __('Priority of the task (low | medium | high). Example: "medium" ', 'fluent-boards'),
881 + 'description' => __('Priority of the task (urgent | high | medium | low). Leave empty for no priority. Example: "medium" ', 'fluent-boards'),
620 882 ],
621 883 'due_at' => [
622 884 'field' => __('Due Date', 'fluent-boards'),
623 885 'type' => 'date',
@@ -682,13 +944,8 @@
682 944
683 945 return $fields;
684 946 }
685 947
686 - public function subtasks()
687 - {
688 - return $this->hasMany(Task::class, 'parent_id', 'id');
689 - }
690 -
691 948 public function customFields()
692 949 {
693 950 return $this->belongsToMany(
694 951 CustomField::class,
@@ -697,7 +954,32 @@
697 954 'foreign_id'
698 955 )->withPivot('settings')
699 956 ->wherePivot('object_type', ProConstant::TASK_CUSTOM_FIELD)
700 957 ->withTimestamps();
958 + }
959 +
960 +
961 + public function repeatTaskMeta()
962 + {
963 + return $this->hasOne(Meta::class, 'object_id', 'id')->where('object_type', Constant::REPEAT_TASK_META);
964 + }
965 + public function getRepeatTaskMetaAttribute()
966 + {
967 + return $this->repeatTaskMeta()->first();
968 + }
969 + public function taskCustomFields()
970 + {
971 + return $this->hasMany(Relation::class, 'object_id', 'id')
972 + ->where('object_type', Constant::TASK_CUSTOM_FIELD);
973 + }
974 +
975 + public function subtasks()
976 + {
977 + return $this->hasMany(Task::class, 'parent_id', 'id');
978 + }
979 +
980 + public function subtaskGroup()
981 + {
982 + return $this->hasMany(TaskMeta::class, 'task_id')->where('key', Constant::SUBTASK_GROUP_NAME);
701 983 }
702 984
703 985 }