PluginProbe
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration / 2.1.0
FluentBoards – Project Management, Task Management, Goal Tracking, Kanban Board, and, Team Collaboration v2.1.0
2.1.0 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 All 42 releases
← All changes | app/Models/Task.php +226 -9 1.91.62.1.0 View file →
@@ -44,10 +44,18 @@
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 +
50 58 protected static $skipTaskCreatedEvent = false;
51 59
52 60 public static function withoutTaskCreatedEvent($callback)
53 61 {
@@ -131,9 +139,27 @@
131 139 return $query->where('status', 'open')
132 140 ->where('due_at', '>=', current_time('mysql'));
133 141 }
134 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);
135 155
156 + return $query->whereNull('last_completed_at')
157 + ->where('status', 'open')
158 + ->whereBetween('due_at', [$startOfToday, $endOfToday]);
159 + }
160 +
161 +
136 162 public function setSettingsAttribute($settings)
137 163 {
138 164 $this->attributes['settings'] = \maybe_serialize($settings);
139 165 }
@@ -196,8 +222,30 @@
196 222 {
197 223 return $this->belongsTo(Board::class, 'board_id', 'id');
198 224 }
199 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 +
200 248 public function assignees()
201 249 {
202 250 return $this->belongsToMany(
203 251 User::class,
@@ -224,8 +272,13 @@
224 272 }
225 273
226 274 public function attachments() //may not need in future
227 275 {
276 + if (!defined('FLUENT_BOARDS_PRO_VERSION')) {
277 + return $this->hasMany(Attachment::class, 'id', 'id')
278 + ->where('id', 0);
279 + }
280 +
228 281 return $this->hasMany(TaskAttachment::class,
229 282 'object_id', 'id')
230 283 ->where('object_type', Constant::OBJECT_TYPE_TASK);
231 284 }
@@ -242,8 +295,44 @@
242 295 Constant::OBJECT_TYPE_USER_TASK_WATCH)
243 296 ->withTimestamps();
244 297 }
245 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 +
246 335 public function parentTask($id)
247 336 {
248 337 return self::find($id);
249 338 }
@@ -307,8 +396,14 @@
307 396
308 397 public function createTask($data)
309 398 {
310 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 +
311 406 $createdTask = Task::create($data);
312 407
313 408 if ( ! empty($data['assignees'])) {
314 409 $assignees = array_filter(array_map('intval', $data['assignees']));
@@ -357,12 +452,12 @@
357 452
358 453 return $operation;
359 454 }
360 455
361 - public function getArchivedAttribute()
362 - {
363 - return (bool) $this->attributes['is_archived'];
364 - }
456 + // public function getArchivedAttribute()
457 + // {
458 + // return (bool) $this->attributes['is_archived'];
459 + // }
365 460
366 461 public function setArchivedAttribute($value)
367 462 {
368 463 if (true === $value) {
@@ -447,8 +542,22 @@
447 542
448 543 return $exist;
449 544 }
450 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 +
451 560 public function moveToNewPosition($newIndex)
452 561 {
453 562 $newIndex = (int) $newIndex;
454 563 if ($newIndex < 1) {
@@ -529,8 +638,116 @@
529 638
530 639 return $this;
531 640 }
532 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 +
533 750 public static function reIndexTasksPositions($task)
534 751 {
535 752 if (isset($task['parent_id'])) {
536 753 $tasksQuery = self::where('parent_id', $task['parent_id']);
@@ -567,9 +784,9 @@
567 784 }
568 785
569 786 public function close()
570 787 {
571 - if ($this->status == 'closed') {
788 + if ($this->status == 'closed' && $this->last_completed_at) {
572 789 return $this;
573 790 }
574 791
575 792 $this->status = 'closed';
@@ -582,14 +799,14 @@
582 799 }
583 800
584 801 public function reopen()
585 802 {
586 - if ($this->status == 'open') {
803 + if ($this->status == 'open' && $this->last_completed_at == null) {
587 804 return $this;
588 805 }
589 806
590 807 $this->status = 'open';
591 - $this->last_completed_at = null;
808 + $this->last_completed_at = NULL;
592 809 $this->save();
593 810 if ($this->parent_id) {
594 811 self::adjustSubtaskCount($this->parent_id);
595 812 }
@@ -660,9 +877,9 @@
660 877 'priority' => [
661 878 'field' => __('Priority', 'fluent-boards'),
662 879 'type' => 'text',
663 880 'rules' => 'optional',
664 - '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'),
665 882 ],
666 883 'due_at' => [
667 884 'field' => __('Due Date', 'fluent-boards'),
668 885 'type' => 'date',