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 +268 -20 1.30trunk View file →
@@ -44,17 +44,35 @@
44 44 'comments_count',
45 45 'created_by',
46 46 ];
47 47
48 - protected $appends = ['meta', 'repeat_task_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 }
@@ -71,9 +89,9 @@
71 89 $model->position = $model->position
72 90 ?: (new TaskService())->getLastPositionOfTasks($model->stage_id);
73 91 });
74 92 static::created(function ($model) {
75 - if ( ! $model->parent_id) {
93 + if (!$model->parent_id && !static::$skipTaskCreatedEvent) {
76 94 do_action('fluent_boards/task_created', $model);
77 95 if ($model->crm_contact_id) {
78 96 do_action('fluent_boards/contact_added_to_task', $model);
79 97 }
@@ -81,9 +99,9 @@
81 99 self::adjustSubtaskCount($model->parent_id);
82 100 }
83 101 });
84 102
85 - /* 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 */
86 104 static::addGlobalScope('type', function (Builder $builder) {
87 105 $builder->where('type', '=', 'task')
88 106 ->orWhere('type', '=', 'roadmap');
89 107 });
@@ -121,9 +139,27 @@
121 139 return $query->where('status', 'open')
122 140 ->where('due_at', '>=', current_time('mysql'));
123 141 }
124 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);
125 155
156 + return $query->whereNull('last_completed_at')
157 + ->where('status', 'open')
158 + ->whereBetween('due_at', [$startOfToday, $endOfToday]);
159 + }
160 +
161 +
126 162 public function setSettingsAttribute($settings)
127 163 {
128 164 $this->attributes['settings'] = \maybe_serialize($settings);
129 165 }
@@ -186,8 +222,30 @@
186 222 {
187 223 return $this->belongsTo(Board::class, 'board_id', 'id');
188 224 }
189 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 +
190 248 public function assignees()
191 249 {
192 250 return $this->belongsToMany(
193 251 User::class,
@@ -214,8 +272,13 @@
214 272 }
215 273
216 274 public function attachments() //may not need in future
217 275 {
276 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
277 + return $this->hasMany(Attachment::class, 'id', 'id')
278 + ->where('id', 0);
279 + }
280 +
218 281 return $this->hasMany(TaskAttachment::class,
219 282 'object_id', 'id')
220 283 ->where('object_type', Constant::OBJECT_TYPE_TASK);
221 284 }
@@ -232,8 +295,44 @@
232 295 Constant::OBJECT_TYPE_USER_TASK_WATCH)
233 296 ->withTimestamps();
234 297 }
235 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 +
236 335 public function parentTask($id)
237 336 {
238 337 return self::find($id);
239 338 }
@@ -297,10 +396,16 @@
297 396
298 397 public function createTask($data)
299 398 {
300 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 +
301 406 $createdTask = Task::create($data);
302 -
407 +
303 408 if ( ! empty($data['assignees'])) {
304 409 $assignees = array_filter(array_map('intval', $data['assignees']));
305 410 if ($assignees) {
306 411 $assigneeData = array_fill_keys($assignees,
@@ -305,8 +410,15 @@
305 410 if ($assignees) {
306 411 $assigneeData = array_fill_keys($assignees,
307 412 ['object_type' => Constant::OBJECT_TYPE_TASK_ASSIGNEE]);
308 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 + }
309 421 }
310 422 }
311 423
312 424 if ( ! empty($data['labels'])) {
@@ -340,12 +452,12 @@
340 452
341 453 return $operation;
342 454 }
343 455
344 - public function getArchivedAttribute()
345 - {
346 - return (bool) $this->attributes['is_archived'];
347 - }
456 + // public function getArchivedAttribute()
457 + // {
458 + // return (bool) $this->attributes['is_archived'];
459 + // }
348 460
349 461 public function setArchivedAttribute($value)
350 462 {
351 463 if (true === $value) {
@@ -430,8 +542,22 @@
430 542
431 543 return $exist;
432 544 }
433 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 +
434 560 public function moveToNewPosition($newIndex)
435 561 {
436 562 $newIndex = (int) $newIndex;
437 563 if ($newIndex < 1) {
@@ -512,8 +638,116 @@
512 638
513 639 return $this;
514 640 }
515 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 +
516 750 public static function reIndexTasksPositions($task)
517 751 {
518 752 if (isset($task['parent_id'])) {
519 753 $tasksQuery = self::where('parent_id', $task['parent_id']);
@@ -550,9 +784,9 @@
550 784 }
551 785
552 786 public function close()
553 787 {
554 - if ($this->status == 'closed') {
788 + if ($this->status == 'closed' && $this->last_completed_at) {
555 789 return $this;
556 790 }
557 791
558 792 $this->status = 'closed';
@@ -565,14 +799,14 @@
565 799 }
566 800
567 801 public function reopen()
568 802 {
569 - if ($this->status == 'open') {
803 + if ($this->status == 'open' && $this->last_completed_at == null) {
570 804 return $this;
571 805 }
572 806
573 807 $this->status = 'open';
574 - $this->last_completed_at = null;
808 + $this->last_completed_at = NULL;
575 809 $this->save();
576 810 if ($this->parent_id) {
577 811 self::adjustSubtaskCount($this->parent_id);
578 812 }
@@ -588,15 +822,24 @@
588 822 */
589 823 public static function mappables()
590 824 {
591 825 return [
592 - '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'),
593 831 'description' => __('Description', 'fluent-boards'),
594 832 'priority' => __('Priority', 'fluent-boards'),
595 833 'due_at' => __('Due Date', 'fluent-boards'),
834 + 'started_at' => __('Start Date', 'fluent-boards'),
835 + 'archived_at' => __('Archive Date', 'fluent-boards'),
596 836 'stage' => __('Stage', 'fluent-boards'),
597 837 'board' => __('Board', 'fluent-boards'),
598 838 'source' => __('Source', 'fluent-boards'),
839 + 'position' => __('Position', 'fluent-boards'),
840 + 'subtasks' => __('Subtasks', 'fluent-boards'),
841 + 'completion' => __('Completion', 'fluent-boards'),
599 842 ];
600 843 }
601 844 public static function mappableFields()
602 845 {
@@ -634,9 +877,9 @@
634 877 'priority' => [
635 878 'field' => __('Priority', 'fluent-boards'),
636 879 'type' => 'text',
637 880 'rules' => 'optional',
638 - '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'),
639 882 ],
640 883 'due_at' => [
641 884 'field' => __('Due Date', 'fluent-boards'),
642 885 'type' => 'date',
@@ -701,13 +944,8 @@
701 944
702 945 return $fields;
703 946 }
704 947
705 - public function subtasks()
706 - {
707 - return $this->hasMany(Task::class, 'parent_id', 'id');
708 - }
709 -
710 948 public function customFields()
711 949 {
712 950 return $this->belongsToMany(
713 951 CustomField::class,
@@ -731,7 +969,17 @@
731 969 public function taskCustomFields()
732 970 {
733 971 return $this->hasMany(Relation::class, 'object_id', 'id')
734 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);
735 983 }
736 984
737 985 }