diff --git a/app/Http/Controllers/Api/V1/Board/ModuleController.php b/app/Http/Controllers/Api/V1/Board/ModuleController.php new file mode 100644 index 0000000..28c65e9 --- /dev/null +++ b/app/Http/Controllers/Api/V1/Board/ModuleController.php @@ -0,0 +1,57 @@ +each(function ($module) use ($board) { + $module->enabled = $board->modules?->find($module->id) != null; + }); + + return ModuleResource::collection($modules); + } + + /* + |-------------------------------------------------------------------------- + | Kanban module + |-------------------------------------------------------------------------- + | + | Endpoints related to Kanban module. + | + */ + + public function kanbanSettings(Board $board) + { + $settings = $this->kanban->getSettings($board); + + return response(['data' => $settings]); + } + + public function enableKanban(EnableKanbanModuleRequest $request, Board $board) + { + $this->kanban->enable($board, $request->validated()); + + return response('', 204); + } + + public function disableKanban(Board $board) + { + $this->kanban->disable($board); + + return response('', 204); + } +} diff --git a/app/Http/Controllers/Api/V1/Project/BoardController.php b/app/Http/Controllers/Api/V1/Project/BoardController.php index a477404..5f2277d 100644 --- a/app/Http/Controllers/Api/V1/Project/BoardController.php +++ b/app/Http/Controllers/Api/V1/Project/BoardController.php @@ -21,6 +21,7 @@ public function index(Project $project) { $boards = QueryBuilder::for($project->boards()) ->allowedFields([BoardResource::class], [BoardResource::class]) + ->allowedIncludes(['modules']) ->get(); return BoardResource::collection($boards); diff --git a/app/Http/Requests/Api/V1/Module/EnableKanbanModuleRequest.php b/app/Http/Requests/Api/V1/Module/EnableKanbanModuleRequest.php new file mode 100644 index 0000000..216a602 --- /dev/null +++ b/app/Http/Requests/Api/V1/Module/EnableKanbanModuleRequest.php @@ -0,0 +1,76 @@ +route('board'); + + return [ + 'todo_column_id' => [ + 'required', 'integer', 'min:0', + Rule::when($this->todo_column_id != 0, $this->getDifferentRule('todo_column_id')), + new ColumnInSameBoard($this, $board), + ], + 'inprogress_column_id' => [ + 'required', 'integer', 'min:0', + Rule::when($this->inprogress_column_id != 0, $this->getDifferentRule('inprogress_column_id')), + new ColumnInSameBoard($this, $board), + ], + 'done_column_id' => [ + 'required', 'integer', 'min:0', + Rule::when($this->done_column_id != 0, $this->getDifferentRule('done_column_id')), + new ColumnInSameBoard($this, $board), + ], + + // Optional columns. + 'onreview_column_id' => [ + 'sometimes', 'required', 'integer', 'min:0', + Rule::when($this->onreview_column_id != 0, $this->getDifferentRule('onreview_column_id')), + new ColumnInSameBoard($this, $board), + ], + ]; + } + + /** + * Configure the validator instance. + * + * @param \Illuminate\Validation\Validator $validator + * @return void + */ + public function withValidator($validator) + { + $data = $validator->getData(); + $newColumns = 0; + + foreach ($data as $key => $value) { + if (array_key_exists($key, KanbanService::$availableColumns) && $value == 0) { + $newColumns++; + } + } + + if ($newColumns != 0) { + $validator->addRules(['board' => [new MaxColumnsPerBoard($this->route('board'), $newColumns)]]); + } + } + + protected function getDifferentRule($column) + { + return array_map(function ($value) { + return 'different:' . $value; + }, array_keys(array_diff_key(KanbanService::$availableColumns, [$column => 0]))); + } +} diff --git a/app/Http/Resources/V1/Board/BoardResource.php b/app/Http/Resources/V1/Board/BoardResource.php index ee49764..4cc357b 100644 --- a/app/Http/Resources/V1/Board/BoardResource.php +++ b/app/Http/Resources/V1/Board/BoardResource.php @@ -22,6 +22,7 @@ public function toArray($request) 'deleted_at' => $this->when($this->trashed(), $this->deleted_at), ], [ 'project' => new ProjectResource($this->whenLoaded('project')), + 'modules' => ModuleResource::collection($this->whenLoaded('modules')), ]); } diff --git a/app/Http/Resources/V1/Board/ModuleResource.php b/app/Http/Resources/V1/Board/ModuleResource.php new file mode 100644 index 0000000..604cab8 --- /dev/null +++ b/app/Http/Resources/V1/Board/ModuleResource.php @@ -0,0 +1,23 @@ + $this->id, + 'name' => $this->name, + 'enabled' => $this->whenNotNull($this->enabled), + ]; + } +} diff --git a/app/Http/Resources/V1/Column/ColumnResource.php b/app/Http/Resources/V1/Column/ColumnResource.php index 486b62e..19985d3 100644 --- a/app/Http/Resources/V1/Column/ColumnResource.php +++ b/app/Http/Resources/V1/Column/ColumnResource.php @@ -16,7 +16,7 @@ class ColumnResource extends JsonResource implements ResourceWithFields public function toArray($request) { return $this->visible([ - 'id', 'name', 'created_at', 'updated_at', + 'id', 'name', 'column_type_id', 'created_at', 'updated_at', ]); } @@ -27,7 +27,7 @@ public static function defaultName(): string public static function defaultFields(): array { - return ['id', 'name']; + return ['id', 'name', 'column_type_id']; } public static function allowedFields(): array diff --git a/app/Models/Board.php b/app/Models/Board.php index 30fb96d..49561b6 100644 --- a/app/Models/Board.php +++ b/app/Models/Board.php @@ -21,6 +21,8 @@ * @property-read \App\Models\Team $team * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\Column[] $columns * @property-read int|null $columns_count + * @property-read \Illuminate\Database\Eloquent\Collection|\App\Models\Module[] $modules + * @property-read int|null $modules_count * @method static \Database\Factories\BoardFactory factory(...$parameters) * @method static \Illuminate\Database\Eloquent\Builder|Board newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|Board newQuery() @@ -66,4 +68,9 @@ public function team() return $this->belongsToThrough(Team::class, Project::class) ->withTrashedParents(); } + + public function modules() + { + return $this->belongsToMany(Module::class); + } } diff --git a/app/Models/Column.php b/app/Models/Column.php index b10fb96..f5bcb61 100644 --- a/app/Models/Column.php +++ b/app/Models/Column.php @@ -3,6 +3,7 @@ namespace App\Models; use App\Traits\HasOrder; +use Illuminate\Contracts\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Model; use Znck\Eloquent\Traits\BelongsToThrough; @@ -15,6 +16,8 @@ * @property double|null $order * @property-read \App\Models\Board $board * @property int $board_id + * @property-read \App\Models\ColumnType $columnType + * @property int $column_type_id * @property \Illuminate\Support\Carbon|null $created_at * @property \Illuminate\Support\Carbon|null $updated_at * @property-read \App\Models\Team $team @@ -24,6 +27,7 @@ * @method static \Illuminate\Database\Eloquent\Builder|Column newModelQuery() * @method static \Illuminate\Database\Eloquent\Builder|Column newQuery() * @method static \Illuminate\Database\Eloquent\Builder|Column query() + * @method static \Illuminate\Database\Eloquent\Builder|Column kanbanRelated() * @mixin \Eloquent */ class Column extends Model @@ -39,6 +43,17 @@ public function getOrderQuery($query) return $query->where('board_id', $this->board_id); } + /* + |------------------------------------------------------------- + | Scopes + |------------------------------------------------------------- + */ + + public function scopeKanbanRelated(Builder $query) + { + return $query->where('column_type_id', '!=', ColumnType::NONE); + } + /* |------------------------------------------------------------- | Relationships @@ -60,4 +75,9 @@ public function team() return $this->belongsToThrough(Team::class, [Project::class, Board::class]) ->withTrashedParents(); } + + public function columnType() + { + return $this->belongsTo(ColumnType::class); + } } diff --git a/app/Models/ColumnType.php b/app/Models/ColumnType.php new file mode 100644 index 0000000..0b6436f --- /dev/null +++ b/app/Models/ColumnType.php @@ -0,0 +1,30 @@ +belongsToMany(Board::class); + } +} diff --git a/app/Policies/CardPolicy.php b/app/Policies/CardPolicy.php index f632ff1..6bd173d 100644 --- a/app/Policies/CardPolicy.php +++ b/app/Policies/CardPolicy.php @@ -5,6 +5,7 @@ use App\Models\Card; use App\Models\Column; use App\Models\User; +use App\Services\Contracts\Modules\KanbanService; use Illuminate\Auth\Access\HandlesAuthorization; class CardPolicy @@ -57,10 +58,21 @@ public function update(User $user, Card $card) return $card->team->isMember($user); } + /** + * Determine whether the user can update the card. + * + * @param \App\Models\User $user + * @param \App\Models\Card $card + * @param \App\Models\Column $column + * @return \Illuminate\Auth\Access\Response|bool + */ public function move(User $user, Card $card, Column $column) { - return $card->team->isMember($user) + $baseCondition = $card->team->isMember($user) && $column->team->is($card->team); + + return $baseCondition + && app(KanbanService::class)->canMoveCardToColumn($card, $column); } /** diff --git a/app/Policies/ColumnPolicy.php b/app/Policies/ColumnPolicy.php index a7913dd..c68962e 100644 --- a/app/Policies/ColumnPolicy.php +++ b/app/Policies/ColumnPolicy.php @@ -4,6 +4,7 @@ use App\Models\Board; use App\Models\Column; +use App\Models\ColumnType; use App\Models\User; use Illuminate\Auth\Access\HandlesAuthorization; @@ -68,6 +69,7 @@ public function update(User $user, Column $column) */ public function delete(User $user, Column $column) { - return $column->team->isMember($user); + return $column->team->isMember($user) + && $column->column_type_id == ColumnType::NONE; } } diff --git a/app/Policies/ModulePolicy.php b/app/Policies/ModulePolicy.php new file mode 100644 index 0000000..59cb8db --- /dev/null +++ b/app/Policies/ModulePolicy.php @@ -0,0 +1,42 @@ +team->isMember($user); + } + + /** + * Authorize get module settings. + * @param User $user + * @param Board $board + */ + public function viewSettings(User $user, Board $board) + { + return $board->team->isMember($user); + } + + public function enableKanban(User $user, Board $board) + { + return $board->team->isMember($user); + } + + public function disableKanban(User $user, Board $board) + { + return $board->team->isMember($user); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 126643c..597004b 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,9 +2,11 @@ namespace App\Providers; +use App\Services\Contracts\Modules\KanbanService as KanbanServiceContract; use App\Services\Contracts\Image\ImageService as ImageServiceContract; use App\Services\Contracts\Projects\LeaderService as LeaderServiceContract; use App\Services\Image\ImageService; +use App\Services\Modules\KanbanService; use App\Services\Projects\LeaderService; use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Http\Resources\Json\JsonResource; @@ -18,6 +20,7 @@ class AppServiceProvider extends ServiceProvider public $singletons = [ ImageServiceContract::class => ImageService::class, LeaderServiceContract::class => LeaderService::class, + KanbanServiceContract::class => KanbanService::class, ]; /** diff --git a/app/Rules/Api/CardInSameColumn.php b/app/Rules/Api/CardInSameColumn.php index daad8c6..e8a0e15 100644 --- a/app/Rules/Api/CardInSameColumn.php +++ b/app/Rules/Api/CardInSameColumn.php @@ -5,25 +5,42 @@ use App\Models\Card; use App\Models\Column; use Illuminate\Contracts\Validation\Rule; +use Illuminate\Contracts\Validation\ValidatorAwareRule; use Illuminate\Http\Request; +use Illuminate\Validation\Validator; -class CardInSameColumn implements Rule +/** + * None: This rule setup actual \App\Models\Card after complete validation. + * If you need to get the original data(id), get it from model. + */ +class CardInSameColumn implements Rule, ValidatorAwareRule { - protected $request; - - protected $column; - protected $attribute; + protected $card; + /** * Create a new rule instance. * * @return void */ - public function __construct(Request $request, Column $column) + public function __construct( + protected Request $request, + protected Column $column + ) { + } + + public function setValidator($validator) { - $this->request = $request; - $this->column = $column; + $validator->after(function (Validator $validator) { + if ($this->card == null) { + return; + } + + $data = [$this->attribute => $this->card]; + $validator->setData(array_merge($validator->getData(), $data)); + $this->request->merge($data); + }); } /** @@ -47,7 +64,7 @@ public function passes($attribute, $value) return false; } - $this->request->merge([$attribute => $card]); + $this->card = $card; return true; } diff --git a/app/Rules/Api/ColumnInSameBoard.php b/app/Rules/Api/ColumnInSameBoard.php index 863358d..dfb7d48 100644 --- a/app/Rules/Api/ColumnInSameBoard.php +++ b/app/Rules/Api/ColumnInSameBoard.php @@ -5,25 +5,38 @@ use App\Models\Board; use App\Models\Column; use Illuminate\Contracts\Validation\Rule; +use Illuminate\Contracts\Validation\ValidatorAwareRule; use Illuminate\Http\Request; +use Illuminate\Validation\Validator; -class ColumnInSameBoard implements Rule +class ColumnInSameBoard implements Rule, ValidatorAwareRule { - protected $request; - - protected $board; - protected $attribute; + protected $column; + /** * Create a new rule instance. * * @return void */ - public function __construct(Request $request, Board $board) + public function __construct( + protected Request $request, + protected Board $board + ) { + } + + public function setValidator($validator) { - $this->request = $request; - $this->board = $board; + $validator->after(function (Validator $validator) { + if ($this->column == null) { + return; + } + + $data = [$this->attribute => $this->column]; + $validator->setData(array_merge($validator->getData(), $data)); + $this->request->merge($data); + }); } /** @@ -47,7 +60,7 @@ public function passes($attribute, $value) return false; } - $this->request->merge([$attribute => $column]); + $this->column = $column; return true; } diff --git a/app/Rules/Api/MaxBoardsPerProject.php b/app/Rules/Api/MaxBoardsPerProject.php index fd701f9..9e57e88 100644 --- a/app/Rules/Api/MaxBoardsPerProject.php +++ b/app/Rules/Api/MaxBoardsPerProject.php @@ -2,45 +2,20 @@ namespace App\Rules\Api; -use App\Models\Project; -use Illuminate\Contracts\Validation\ImplicitRule; +use App\Rules\Base\MaxItemsRule; -class MaxBoardsPerProject implements ImplicitRule +class MaxBoardsPerProject extends MaxItemsRule { /** Max boards per project.*/ - public const MAX_BOARDS = 10; + public const MAX_ITEMS = 10; - protected $project; - - /** - * Create a new rule instance. - * - * @return void - */ - public function __construct(Project $project) - { - $this->project = $project; - } - - /** - * Determine if the validation rule passes. - * - * @param string $attribute - * @param mixed $value - * @return bool - */ - public function passes($attribute, $value) + protected function getCount() { - return $this->project->boards()->count() < static::MAX_BOARDS; + return $this->container->boards()->count(); } - /** - * Get the validation error message. - * - * @return string - */ - public function message() + protected function transKey() { - return trans('validation.max_boards_per_project', ['max' => static::MAX_BOARDS]); + return 'validation.max_boards_per_project'; } } diff --git a/app/Rules/Api/MaxCardsPerColumn.php b/app/Rules/Api/MaxCardsPerColumn.php index 832dcd9..2795530 100644 --- a/app/Rules/Api/MaxCardsPerColumn.php +++ b/app/Rules/Api/MaxCardsPerColumn.php @@ -2,45 +2,20 @@ namespace App\Rules\Api; -use App\Models\Column; -use Illuminate\Contracts\Validation\ImplicitRule; +use App\Rules\Base\MaxItemsRule; -class MaxCardsPerColumn implements ImplicitRule +class MaxCardsPerColumn extends MaxItemsRule { /** Max cards per column.*/ - public const MAX_CARDS = 100; + public const MAX_ITEMS = 100; - protected $column; - - /** - * Create a new rule instance. - * - * @return void - */ - public function __construct(Column $column) - { - $this->column = $column; - } - - /** - * Determine if the validation rule passes. - * - * @param string $attribute - * @param mixed $value - * @return bool - */ - public function passes($attribute, $value) + protected function getCount() { - return $this->column->cards()->count() < static::MAX_CARDS; + return $this->container->cards()->count(); } - /** - * Get the validation error message. - * - * @return string - */ - public function message() + protected function transKey() { - return trans('validation.max_cards_per_column', ['max' => static::MAX_CARDS]); + return 'validation.max_cards_per_column'; } } diff --git a/app/Rules/Api/MaxColumnsPerBoard.php b/app/Rules/Api/MaxColumnsPerBoard.php index c26c13b..1cc2bfc 100644 --- a/app/Rules/Api/MaxColumnsPerBoard.php +++ b/app/Rules/Api/MaxColumnsPerBoard.php @@ -2,45 +2,20 @@ namespace App\Rules\Api; -use App\Models\Board; -use Illuminate\Contracts\Validation\ImplicitRule; +use App\Rules\Base\MaxItemsRule; -class MaxColumnsPerBoard implements ImplicitRule +class MaxColumnsPerBoard extends MaxItemsRule { /** Max columns per board.*/ - public const MAX_COLUMNS = 50; + public const MAX_ITEMS = 50; - protected $board; - - /** - * Create a new rule instance. - * - * @return void - */ - public function __construct(Board $board) - { - $this->board = $board; - } - - /** - * Determine if the validation rule passes. - * - * @param string $attribute - * @param mixed $value - * @return bool - */ - public function passes($attribute, $value) + protected function getCount() { - return $this->board->columns()->count() < static::MAX_COLUMNS; + return $this->container->columns()->count(); } - /** - * Get the validation error message. - * - * @return string - */ - public function message() + protected function transKey() { - return trans('validation.max_columns_per_board', ['max' => static::MAX_COLUMNS]); + return 'validation.max_columns_per_board'; } } diff --git a/app/Rules/Base/MaxItemsRule.php b/app/Rules/Base/MaxItemsRule.php new file mode 100644 index 0000000..5d2237d --- /dev/null +++ b/app/Rules/Base/MaxItemsRule.php @@ -0,0 +1,51 @@ +getCount() + $this->newItemsCount <= static::MAX_ITEMS; + } + + /** + * Get the validation error message. + * + * @return string + */ + public function message() + { + return trans($this->transKey(), ['max' => static::MAX_ITEMS]); + } +} diff --git a/app/Services/Contracts/Modules/KanbanService.php b/app/Services/Contracts/Modules/KanbanService.php new file mode 100644 index 0000000..599a085 --- /dev/null +++ b/app/Services/Contracts/Modules/KanbanService.php @@ -0,0 +1,42 @@ + ToDo <-> InProgress <-> OnReview? <-> Done + * @param Card $card The card. + * @param Column $column The column. + * @return boolean + */ + public function canMoveCardToColumn(Card $card, Column $column); +} diff --git a/app/Services/Modules/KanbanService.php b/app/Services/Modules/KanbanService.php new file mode 100644 index 0000000..468f103 --- /dev/null +++ b/app/Services/Modules/KanbanService.php @@ -0,0 +1,150 @@ + + */ + public static $availableColumns = [ + 'todo_column_id' => ColumnType::TODO, + 'inprogress_column_id' => ColumnType::IN_PROGRESS, + 'done_column_id' => ColumnType::DONE, + 'onreview_column_id' => ColumnType::ON_REVIEW, + ]; + + public function getSettings(Board $board) + { + if (!$board->modules()->where('module_id', Module::KANBAN)->exists()) { + abort(403, "Kanban module is disabled."); + } + + $columns = $board->columns()->kanbanRelated()->get(); + + return collect(static::$availableColumns)->map(fn() => 0)->merge($columns->flatMap(function ($column) { + $index = array_search($column->column_type_id, static::$availableColumns); + + return [$index => $column->id]; + })); + } + + public function enable(Board $board, array $settings) + { + DB::transaction(function () use ($board, $settings) { + $board->modules()->syncWithoutDetaching(Module::KANBAN); + + $this->setupKanbanModule($board, $settings); + }); + } + + public function disable(Board $board) + { + DB::transaction(function () use ($board) { + $board->modules()->detach(Module::KANBAN); + + Column::kanbanRelated()->update(['column_type_id' => ColumnType::NONE]); + }); + } + + public function canMoveCardToColumn(Card $card, Column $column) + { + $order = $this->getColumnsMovementOrder($column->board); + + // Kanban module is disabled (no any columns with functional types). + if (count($order) == 1 && $order[0] == ColumnType::NONE) { + return true; + } + + $oldColumn = $card->column; + $index = array_search($column->column_type_id, $order); + + $allowedTypes = []; + if ($index == 0) { + $allowedTypes[] = $order[$index + 1]; + } elseif ($index == count($order) - 1) { + $allowedTypes[] = $order[$index - 1]; + } else { + $allowedTypes[] = $order[$index - 1]; + $allowedTypes[] = $order[$index + 1]; + } + + $allowedTypes[] = $order[$index]; + + return in_array($oldColumn->column_type_id, $allowedTypes); + } + + /** + * Setup the kanban module with settings. + * @param Board $board + * @param array $settings + */ + protected function setupKanbanModule(Board $board, array $settings) + { + $columns = array_intersect_key($settings, static::$availableColumns); + + // Reset column types only for columns that are not in our settings array. + $ids = collect($columns)->values()->filter(fn($value) => $value instanceof Column)->pluck('id'); + Column::kanbanRelated()->whereNotIn('id', $ids)->update(['column_type_id' => ColumnType::NONE]); + + foreach ($columns as $name => $column) { + $columnType = static::$availableColumns[$name]; + + if ($column instanceof Column) { + $column->columnType()->associate($columnType); + $column->save(); + } elseif ($column === 0) { + $this->createColumn($board, $columnType); + } + } + } + + /** + * Create the new kanban-related column. + * @param Board $board + * @param integer $columnType + */ + protected function createColumn(Board $board, int $columnType) + { + $columnName = match ($columnType) { + ColumnType::TODO => "To Do", + ColumnType::IN_PROGRESS => "In Progress", + ColumnType::DONE => "Done", + ColumnType::ON_REVIEW => "On Review", + }; + + $column = new Column(['name' => $columnName]); + $column->columnType()->associate($columnType); + $column->board()->associate($board); + $column->moveToEnd(); + } + + /** + * Get the order of movement columns. + * @param Board $board + * @return array + */ + protected function getColumnsMovementOrder(Board $board) + { + $baseOrder = collect([ + ColumnType::TODO, + ColumnType::IN_PROGRESS, + ColumnType::ON_REVIEW, + ColumnType::DONE, + ]); + + $columns = $board->columns()->kanbanRelated()->pluck('column_type_id'); + + return $baseOrder->intersect($columns)->prepend(ColumnType::NONE)->values()->toArray(); + } +} diff --git a/database/migrations/2022_05_18_105555_create_column_types_table.php b/database/migrations/2022_05_18_105555_create_column_types_table.php new file mode 100644 index 0000000..493cc69 --- /dev/null +++ b/database/migrations/2022_05_18_105555_create_column_types_table.php @@ -0,0 +1,35 @@ +unsignedTinyInteger('id')->primary(); + $table->string('name'); + $table->timestamps(); + }); + + Artisan::call('db:seed --class=ColumnTypeSeeder'); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('column_types'); + } +}; diff --git a/database/migrations/2022_05_18_111339_add_column_type_id_to_columns_table.php b/database/migrations/2022_05_18_111339_add_column_type_id_to_columns_table.php new file mode 100644 index 0000000..44618b0 --- /dev/null +++ b/database/migrations/2022_05_18_111339_add_column_type_id_to_columns_table.php @@ -0,0 +1,34 @@ +unsignedTinyInteger('column_type_id')->default(0)->after('board_id'); + $table->foreign(['column_type_id'])->references('id')->on('column_types'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('columns', function (Blueprint $table) { + $table->dropForeign(['column_type_id']); + $table->dropColumn('column_type_id'); + }); + } +}; diff --git a/database/migrations/2022_05_18_151800_create_modules_table.php b/database/migrations/2022_05_18_151800_create_modules_table.php new file mode 100644 index 0000000..55115d1 --- /dev/null +++ b/database/migrations/2022_05_18_151800_create_modules_table.php @@ -0,0 +1,34 @@ +unsignedTinyInteger('id')->primary(); + $table->string('name'); + $table->timestamps(); + }); + + Artisan::call('db:seed --class=ModuleSeeder'); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('modules'); + } +}; diff --git a/database/migrations/2022_05_18_151948_create_board_module_table.php b/database/migrations/2022_05_18_151948_create_board_module_table.php new file mode 100644 index 0000000..ae1cdf3 --- /dev/null +++ b/database/migrations/2022_05_18_151948_create_board_module_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('board_id')->constrained()->cascadeOnDelete(); + $table->unsignedTinyInteger('module_id'); + $table->foreign('module_id')->references('id')->on('modules')->cascadeOnDelete(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('board_module'); + } +}; diff --git a/database/seeders/ColumnTypeSeeder.php b/database/seeders/ColumnTypeSeeder.php new file mode 100644 index 0000000..09f1c3a --- /dev/null +++ b/database/seeders/ColumnTypeSeeder.php @@ -0,0 +1,38 @@ + ColumnType::NONE], + ['name' => 'none'] + ); + ColumnType::firstOrCreate( + ['id' => ColumnType::TODO], + ['name' => 'todo'] + ); + ColumnType::firstOrCreate( + ['id' => ColumnType::IN_PROGRESS], + ['name' => 'in_progress'] + ); + ColumnType::firstOrCreate( + ['id' => ColumnType::DONE], + ['name' => 'done'] + ); + ColumnType::firstOrCreate( + ['id' => ColumnType::ON_REVIEW], + ['name' => 'on_review'] + ); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 45f7a79..9707d08 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -21,5 +21,8 @@ public function run() $this->call(BoardSeeder::class); $this->call(ColumnSeeder::class); $this->call(CardSeeder::class); + + $this->call(ColumnTypeSeeder::class); + $this->call(ModuleSeeder::class); } } diff --git a/database/seeders/ModuleSeeder.php b/database/seeders/ModuleSeeder.php new file mode 100644 index 0000000..118c827 --- /dev/null +++ b/database/seeders/ModuleSeeder.php @@ -0,0 +1,22 @@ + Module::KANBAN], + ['name' => 'Kanban'] + ); + } +} diff --git a/routes/api-v1.php b/routes/api-v1.php index bfc8d39..0b7d4ee 100644 --- a/routes/api-v1.php +++ b/routes/api-v1.php @@ -12,6 +12,7 @@ use App\Models\Column as ColumnModel; use App\Models\Invite as InviteModel; use App\Models\LeaderNomination as LeaderNominationModel; +use App\Models\Module as ModuleModel; use App\Models\Project as ProjectModel; use Illuminate\Support\Facades\Route; @@ -177,6 +178,19 @@ Route::post('{board}/columns', 'store')->can('create', [ColumnModel::class, 'board']); }); + // Modules. + Route::group([ + 'prefix' => '{board}/modules', + 'controller' => Board\ModuleController::class, + ], function () { + Route::get('/', 'index')->can('viewAny', [ModuleModel::class, 'board']); + + // Kanban. + Route::get('kanban', 'kanbanSettings')->can('viewSettings', [ModuleModel::class, 'board']); + Route::put('kanban', 'enableKanban')->can('enableKanban', [ModuleModel::class, 'board']); + Route::post('kanban/disable', 'disableKanban')->can('disableKanban', [ModuleModel::class, 'board']); + }); + Route::get('/{anyBoard}', 'show')->can('view', 'anyBoard'); Route::patch('/{board}', 'update')->can('update', 'board'); diff --git a/tests/Feature/Api/V1/Board/BoardControllerTest.php b/tests/Feature/Api/V1/Board/BoardControllerTest.php index 6d76d0a..de175b8 100644 --- a/tests/Feature/Api/V1/Board/BoardControllerTest.php +++ b/tests/Feature/Api/V1/Board/BoardControllerTest.php @@ -224,7 +224,7 @@ public function test_cant_open_with_exceeded_boards_limit() { $team = Team::factory()->create(); $project = Project::factory()->team($team)->create(); - Board::factory(MaxBoardsPerProject::MAX_BOARDS)->project($project)->create(); + Board::factory(MaxBoardsPerProject::MAX_ITEMS)->project($project)->create(); $board = Board::factory()->project($project)->closed()->create(); $user = User::factory()->hasAttached($team)->create(); Sanctum::actingAs($user); @@ -234,7 +234,7 @@ public function test_cant_open_with_exceeded_boards_limit() $response ->assertUnprocessable() ->assertJsonPath('errors.project', [ - trans('validation.max_boards_per_project', ['max' => MaxBoardsPerProject::MAX_BOARDS]) + trans('validation.max_boards_per_project', ['max' => MaxBoardsPerProject::MAX_ITEMS]) ]); } public function test_can_open() @@ -330,7 +330,7 @@ public function test_cant_restore_with_exceeded_boards_limit() { $team = Team::factory()->create(); $project = Project::factory()->team($team)->create(); - Board::factory(MaxBoardsPerProject::MAX_BOARDS)->project($project)->create(); + Board::factory(MaxBoardsPerProject::MAX_ITEMS)->project($project)->create(); $board = Board::factory()->project($project)->deleted()->create(); $user = User::factory()->hasAttached($team)->create(); Sanctum::actingAs($user); @@ -340,7 +340,7 @@ public function test_cant_restore_with_exceeded_boards_limit() $response ->assertUnprocessable() ->assertJsonPath('errors.project', [ - trans('validation.max_boards_per_project', ['max' => MaxBoardsPerProject::MAX_BOARDS]) + trans('validation.max_boards_per_project', ['max' => MaxBoardsPerProject::MAX_ITEMS]) ]); } public function test_can_restore_trashed() diff --git a/tests/Feature/Api/V1/Board/ColumnControllerTest.php b/tests/Feature/Api/V1/Board/ColumnControllerTest.php index 86bea4f..b7bbd8a 100644 --- a/tests/Feature/Api/V1/Board/ColumnControllerTest.php +++ b/tests/Feature/Api/V1/Board/ColumnControllerTest.php @@ -50,7 +50,7 @@ public function test_can_view_any() ->assertOk() ->assertJson(function ($json) { $json->has('data', 3, function ($json) { - $json->hasAll(['id', 'name']); + $json->hasAll(['id', 'name', 'column_type_id']); }); }) ->assertJsonPath('data.0.id', $columns->get(1)->id) @@ -72,7 +72,7 @@ public function test_can_view_any_in_closed_board() ->assertOk() ->assertJson(function ($json) { $json->has('data', 2, function ($json) { - $json->hasAll(['id', 'name']); + $json->hasAll(['id', 'name', 'column_type_id']); }); }) ->assertJsonPath('data.0.id', $columns->first()->id) @@ -93,7 +93,7 @@ public function test_can_view_any_in_trashed_board() ->assertOk() ->assertJson(function ($json) { $json->has('data', 2, function ($json) { - $json->hasAll(['id', 'name']); + $json->hasAll(['id', 'name', 'column_type_id']); }); }) ->assertJsonPath('data.0.id', $columns->first()->id) @@ -117,7 +117,7 @@ public function test_cant_store_with_exceeded_columns_limit() $team = Team::factory()->create(); $project = Project::factory()->team($team)->create(); $board = Board::factory()->project($project)->create(); - Column::factory(MaxColumnsPerBoard::MAX_COLUMNS)->board($board)->create(); + Column::factory(MaxColumnsPerBoard::MAX_ITEMS)->board($board)->create(); $user = User::factory()->hasAttached($team)->create(); $data = [ 'name' => 'Col 1', @@ -129,7 +129,7 @@ public function test_cant_store_with_exceeded_columns_limit() $response ->assertUnprocessable() ->assertJsonPath('errors.board', [ - trans('validation.max_columns_per_board', ['max' => MaxColumnsPerBoard::MAX_COLUMNS]) + trans('validation.max_columns_per_board', ['max' => MaxColumnsPerBoard::MAX_ITEMS]) ]); } public function test_can_store() @@ -153,7 +153,7 @@ public function test_can_store() ->assertCreated() ->assertJson(function ($json) { $json->has('data', function ($json) { - $json->hasAll(['id', 'name']); + $json->hasAll(['id', 'name', 'column_type_id']); }); }); $this->assertDatabaseHas('columns', ['board_id' => $board->id, 'name' => $data['name']]); @@ -191,7 +191,7 @@ public function test_can_store_after_card() ->assertCreated() ->assertJson(function ($json) { $json->has('data', function ($json) { - $json->hasAll(['id', 'name']); + $json->hasAll(['id', 'name', 'column_type_id']); }); }); $this->assertEquals(1, $columns[0]->order); @@ -230,7 +230,7 @@ public function test_can_store_at_first_position() ->assertCreated() ->assertJson(function ($json) { $json->has('data', function ($json) { - $json->hasAll(['id', 'name']); + $json->hasAll(['id', 'name', 'column_type_id']); }); }); $this->assertEquals(1, $column->order); diff --git a/tests/Feature/Api/V1/Board/ModuleControllerTest.php b/tests/Feature/Api/V1/Board/ModuleControllerTest.php new file mode 100644 index 0000000..faac265 --- /dev/null +++ b/tests/Feature/Api/V1/Board/ModuleControllerTest.php @@ -0,0 +1,311 @@ +create(); + $project = Project::factory()->team($team)->create(); + $board = Board::factory()->project($project)->create(); + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/boards/' . $board->id . '/modules'); + + $response->assertForbidden(); + } + public function test_can_view_any() + { + $team = Team::factory()->create(); + $project = Project::factory()->team($team)->create(); + $board = Board::factory()->project($project)->create(); + $user = User::factory()->hasAttached($team)->create(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/boards/' . $board->id . '/modules'); + + $response + ->assertOk() + ->assertJson(function ($json) { + $json->has('data', 1, function ($json) { + $json->hasAll(['id', 'name', 'enabled']); + }); + }) + ->assertJsonPath('data.0.id', Module::KANBAN) + ->assertJsonPath('data.0.enabled', false); + + Module::find(Module::KANBAN)->boards()->attach($board); + + $response = $this->getJson('/api/v1/boards/' . $board->id . '/modules'); + + $response + ->assertOk() + ->assertJson(function ($json) { + $json->has('data', 1, function ($json) { + $json->hasAll(['id', 'name', 'enabled']); + }); + }) + ->assertJsonPath('data.0.id', Module::KANBAN) + ->assertJsonPath('data.0.enabled', true); + } + + public function test_cant_view_settings_in_not_your_team() + { + $team = Team::factory()->create(); + $project = Project::factory()->team($team)->create(); + $board = Board::factory()->project($project)->create(); + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/boards/' . $board->id . '/modules'); + + $response->assertForbidden(); + } + public function test_cant_view_kanban_settings_when_module_disabled() + { + $team = Team::factory()->create(); + $project = Project::factory()->team($team)->create(); + $board = Board::factory()->project($project)->create(); + $user = User::factory()->hasAttached($team)->create(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/boards/' . $board->id . '/modules/kanban'); + + $response + ->assertForbidden() + ->assertJson(['message' => 'Kanban module is disabled.']); + } + public function test_can_view_kanban_settings() + { + $team = Team::factory()->create(); + $project = Project::factory()->team($team)->create(); + $board = Board::factory()->project($project)->create(); + Module::find(Module::KANBAN)->boards()->attach($board); + $columns = Column::factory(3)->sequence( + ['column_type_id' => ColumnType::TODO], + ['column_type_id' => ColumnType::IN_PROGRESS], + ['column_type_id' => ColumnType::DONE], + )->board($board)->create(); + $user = User::factory()->hasAttached($team)->create(); + Sanctum::actingAs($user); + + $response = $this->getJson('/api/v1/boards/' . $board->id . '/modules/kanban'); + + $response + ->assertOk() + ->assertJson(function ($json) { + $json->has('data.todo_column_id'); + $json->has('data.inprogress_column_id'); + $json->has('data.done_column_id'); + $json->has('data.onreview_column_id'); + }) + ->assertJsonPath('data.todo_column_id', $columns[0]->id) + ->assertJsonPath('data.inprogress_column_id', $columns[1]->id) + ->assertJsonPath('data.done_column_id', $columns[2]->id) + ->assertJsonPath('data.onreview_column_id', 0); + + $columns[1]->column_type_id = ColumnType::NONE; + $columns[1]->save(); + + $response = $this->getJson('/api/v1/boards/' . $board->id . '/modules/kanban'); + + $response + ->assertOk() + ->assertJson(function ($json) { + $json->has('data.todo_column_id'); + $json->has('data.inprogress_column_id'); + $json->has('data.done_column_id'); + $json->has('data.onreview_column_id'); + }) + ->assertJsonPath('data.todo_column_id', $columns[0]->id) + ->assertJsonPath('data.inprogress_column_id', 0) + ->assertJsonPath('data.done_column_id', $columns[2]->id) + ->assertJsonPath('data.onreview_column_id', 0); + } + + public function test_cant_enable_kanban_module_in_not_your_team() + { + $team = Team::factory()->create(); + $project = Project::factory()->team($team)->create(); + $board = Board::factory()->project($project)->create(); + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->putJson('/api/v1/boards/' . $board->id . '/modules/kanban'); + + $response->assertForbidden(); + } + public function test_cant_set_column_from_other_board_on_enable_kanban_module() + { + $team = Team::factory()->create(); + $project = Project::factory()->team($team)->create(); + $board = Board::factory()->project($project)->create(); + $column = Column::factory()->board(Board::factory()->project($project))->create(); + $user = User::factory()->hasAttached($team)->create(); + Sanctum::actingAs($user); + + $response = $this->putJson('/api/v1/boards/' . $board->id . '/modules/kanban', ['todo_column_id' => $column->id]); + + $response + ->assertUnprocessable() + ->assertJsonPath('errors.todo_column_id', [ + trans('validation.exists', ['attribute' => 'todo_column_id']) + ]) + ->assertJsonPath('errors.inprogress_column_id', [ + trans('validation.required', ['attribute' => 'inprogress column id']) + ]) + ->assertJsonPath('errors.done_column_id', [ + trans('validation.required', ['attribute' => 'done column id']) + ]); + } + public function test_cant_set_same_values_of_columns_for_kanban_module() + { + $team = Team::factory()->create(); + $project = Project::factory()->team($team)->create(); + $board = Board::factory()->project($project)->create(); + $column = Column::factory()->board($board)->create(); + $user = User::factory()->hasAttached($team)->create(); + Sanctum::actingAs($user); + + $response = $this->putJson('/api/v1/boards/' . $board->id . '/modules/kanban', [ + 'todo_column_id' => $column->id, + 'inprogress_column_id' => $column->id, + 'done_column_id' => $column->id, + 'onreview_column_id' => $column->id, + ]); + + $response + ->assertUnprocessable() + ->assertJsonCount(3, 'errors.todo_column_id') + ->assertJsonCount(3, 'errors.inprogress_column_id') + ->assertJsonCount(3, 'errors.done_column_id') + ->assertJsonCount(3, 'errors.onreview_column_id') + ->assertJsonPath('errors.todo_column_id', [ + trans('validation.different', ['attribute' => 'todo column id', 'other' => 'inprogress column id']), + trans('validation.different', ['attribute' => 'todo column id', 'other' => 'done column id']), + trans('validation.different', ['attribute' => 'todo column id', 'other' => 'onreview column id']) + ]); + } + public function test_cant_create_new_column_with_exceeded_columns_limit() + { + $team = Team::factory()->create(); + $project = Project::factory()->team($team)->create(); + $board = Board::factory()->project($project)->create(); + Column::factory(MaxColumnsPerBoard::MAX_ITEMS - 2)->board($board)->create(); + $user = User::factory()->hasAttached($team)->create(); + Sanctum::actingAs($user); + $data = [ + 'todo_column_id' => 0, + 'inprogress_column_id' => 0, + 'done_column_id' => 0, + ]; + + $response = $this->putJson('/api/v1/boards/' . $board->id . '/modules/kanban', $data); + $response + ->assertUnprocessable() + ->assertJsonPath('errors.board', [ + trans('validation.max_columns_per_board', ['max' => MaxColumnsPerBoard::MAX_ITEMS]) + ]); + + $data = [ + 'todo_column_id' => 0, + 'inprogress_column_id' => Column::first()->id, + 'done_column_id' => 0, + ]; + + $response = $this->putJson('/api/v1/boards/' . $board->id . '/modules/kanban', $data); + + $response->assertNoContent(); + $this->assertDatabaseCount('columns', MaxColumnsPerBoard::MAX_ITEMS); + } + public function test_can_enable_kanban_module() + { + $team = Team::factory()->create(); + $project = Project::factory()->team($team)->create(); + $board = Board::factory()->project($project)->create(); + $columns = Column::factory(2)->board($board)->create(); + $user = User::factory()->hasAttached($team)->create(); + Sanctum::actingAs($user); + $data = [ + 'todo_column_id' => $columns[0]->id, + 'inprogress_column_id' => 0, + 'done_column_id' => $columns[1]->id, + ]; + + $response = $this->putJson('/api/v1/boards/' . $board->id . '/modules/kanban', $data); + + $response->assertNoContent(); + $columns = Column::all(); + $this->assertEquals(ColumnType::TODO, $columns[0]->column_type_id); + $this->assertEquals(ColumnType::IN_PROGRESS, $columns[2]->column_type_id); + $this->assertEquals('In Progress', $columns[2]->name); + $this->assertEquals(ColumnType::DONE, $columns[1]->column_type_id); + $this->assertDatabaseCount('board_module', 1); + + $data = [ + 'todo_column_id' => $columns[0]->id, + 'inprogress_column_id' => $columns[1]->id, + 'done_column_id' => $columns[2]->id, + ]; + + $response = $this->putJson('/api/v1/boards/' . $board->id . '/modules/kanban', $data); + + $response->assertNoContent(); + $columns = Column::all(); + $this->assertEquals(ColumnType::TODO, $columns[0]->column_type_id); + $this->assertEquals(ColumnType::IN_PROGRESS, $columns[1]->column_type_id); + $this->assertEquals(ColumnType::DONE, $columns[2]->column_type_id); + $this->assertDatabaseCount('board_module', 1); + } + + public function test_cant_disable_kanban_module_in_not_your_team() + { + $team = Team::factory()->create(); + $project = Project::factory()->team($team)->create(); + $board = Board::factory()->project($project)->create(); + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/boards/' . $board->id . '/modules/kanban/disable'); + + $response->assertForbidden(); + } + public function test_can_disable_kanban_module() + { + $team = Team::factory()->create(); + $project = Project::factory()->team($team)->create(); + $board = Board::factory()->project($project)->create(); + Module::find(Module::KANBAN)->boards()->attach($board); + Column::factory(5)->sequence( + ['column_type_id' => ColumnType::NONE], + ['column_type_id' => ColumnType::TODO], + ['column_type_id' => ColumnType::IN_PROGRESS], + ['column_type_id' => ColumnType::DONE], + ['column_type_id' => ColumnType::ON_REVIEW], + )->board($board)->create(); + $user = User::factory()->hasAttached($team)->create(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/boards/' . $board->id . '/modules/kanban/disable'); + + $response->assertNoContent(); + $this->assertEquals(0, Column::kanbanRelated()->count()); + $this->assertDatabaseCount('board_module', 0); + } +} diff --git a/tests/Feature/Api/V1/Card/CardControllerTest.php b/tests/Feature/Api/V1/Card/CardControllerTest.php index 976d2d3..e83de4c 100644 --- a/tests/Feature/Api/V1/Card/CardControllerTest.php +++ b/tests/Feature/Api/V1/Card/CardControllerTest.php @@ -9,6 +9,7 @@ use App\Models\Board; use App\Models\Card; use App\Models\Column; +use App\Models\ColumnType; use App\Models\Project; use App\Models\Team; use App\Models\User; @@ -280,7 +281,7 @@ public function test_cant_move_in_column_with_exceed_cards_limit() $board = Board::factory()->project($project)->create(); $column = Column::factory()->board($board)->create(); $newColumn = Column::factory()->board($board)->create(); - Card::factory(MaxCardsPerColumn::MAX_CARDS)->column($newColumn)->create(); + Card::factory(MaxCardsPerColumn::MAX_ITEMS)->column($newColumn)->create(); $card = Card::factory()->column($column)->create(); $user = User::factory()->hasAttached($team)->create(); @@ -291,9 +292,27 @@ public function test_cant_move_in_column_with_exceed_cards_limit() $response ->assertUnprocessable() ->assertJsonPath('errors.column', [ - trans('validation.max_cards_per_column', ['max' => MaxCardsPerColumn::MAX_CARDS]) + trans('validation.max_cards_per_column', ['max' => MaxCardsPerColumn::MAX_ITEMS]) ]); } + public function test_cant_move_in_wrong_column_with_kanban_module() + { + $team = Team::factory()->create(); + $project = Project::factory()->team($team)->create(); + $board = Board::factory()->project($project)->create(); + $column = Column::factory(3)->board($board)->sequence( + ['column_type_id' => ColumnType::TODO], + ['column_type_id' => ColumnType::IN_PROGRESS], + ['column_type_id' => ColumnType::DONE], + )->create(); + $card = Card::factory()->column($column[0])->create(); + $user = User::factory()->hasAttached($team)->create(); + Sanctum::actingAs($user); + + $response = $this->postJson('/api/v1/cards/' . $card->id . '/move/' . $column[2]->id); + + $response->assertForbidden(); + } public function test_can_move() { $team = Team::factory()->create(); @@ -471,6 +490,52 @@ public function test_can_move_between_boards() && $event->column->id == $newColumn->id; }); } + public function test_can_move_in_correct_column_with_kanban_module() + { + $team = Team::factory()->create(); + $project = Project::factory()->team($team)->create(); + $board = Board::factory()->project($project)->create(); + $column = Column::factory(3)->board($board)->sequence( + ['column_type_id' => ColumnType::TODO], + ['column_type_id' => ColumnType::IN_PROGRESS], + ['column_type_id' => ColumnType::DONE], + )->create(); + $card = Card::factory()->column($column[0])->create(); + $user = User::factory()->hasAttached($team)->create(); + Sanctum::actingAs($user); + + Event::fake(); + + $response = $this->postJson('/api/v1/cards/' . $card->id . '/move/' . $column[1]->id); + + $card->refresh(); + + $response->assertNoContent(); + $this->assertDatabaseMissing('cards', ['column_id' => $column[0]->id]); + $this->assertDatabaseHas('cards', ['column_id' => $column[1]->id]); + $this->assertEquals(1, $card->order); + Event::assertDispatched(CardMoved::class, function (CardMoved $event) use ($card, $column) { + return $event->card->id == $card->id + && $event->after == null + && $event->column->id == $column[1]->id; + }); + + Event::fake(); + + $response = $this->postJson('/api/v1/cards/' . $card->id . '/move/' . $column[2]->id); + + $card->refresh(); + + $response->assertNoContent(); + $this->assertDatabaseMissing('cards', ['column_id' => $column[1]->id]); + $this->assertDatabaseHas('cards', ['column_id' => $column[2]->id]); + $this->assertEquals(1, $card->order); + Event::assertDispatched(CardMoved::class, function (CardMoved $event) use ($card, $column) { + return $event->card->id == $card->id + && $event->after == null + && $event->column->id == $column[2]->id; + }); + } public function test_cant_delete_without_permissions() { diff --git a/tests/Feature/Api/V1/Column/CardControllerTest.php b/tests/Feature/Api/V1/Column/CardControllerTest.php index 190140b..fbd4452 100644 --- a/tests/Feature/Api/V1/Column/CardControllerTest.php +++ b/tests/Feature/Api/V1/Column/CardControllerTest.php @@ -80,7 +80,7 @@ public function test_cant_store_with_exceeded_cards_limit() $project = Project::factory()->team($team)->create(); $board = Board::factory()->project($project)->create(); $column = Column::factory()->board($board)->create(); - Card::factory(MaxCardsPerColumn::MAX_CARDS)->column($column)->create(); + Card::factory(MaxCardsPerColumn::MAX_ITEMS)->column($column)->create(); $user = User::factory()->hasAttached($team)->create(); Sanctum::actingAs($user); @@ -89,7 +89,7 @@ public function test_cant_store_with_exceeded_cards_limit() $response ->assertUnprocessable() ->assertJsonPath('errors.column', [ - trans('validation.max_cards_per_column', ['max' => MaxCardsPerColumn::MAX_CARDS]) + trans('validation.max_cards_per_column', ['max' => MaxCardsPerColumn::MAX_ITEMS]) ]); } public function test_can_store() diff --git a/tests/Feature/Api/V1/Column/ColumnControllerTest.php b/tests/Feature/Api/V1/Column/ColumnControllerTest.php index 8d01c31..68d4ff2 100644 --- a/tests/Feature/Api/V1/Column/ColumnControllerTest.php +++ b/tests/Feature/Api/V1/Column/ColumnControllerTest.php @@ -7,6 +7,7 @@ use App\Events\Api\Columns\ColumnUpdated; use App\Models\Board; use App\Models\Column; +use App\Models\ColumnType; use App\Models\Project; use App\Models\Team; use App\Models\User; @@ -48,7 +49,7 @@ public function test_can_view() ->assertJsonPath('data.id', $column->id) ->assertJson(function ($json) { $json->has('data', function ($json) { - $json->hasAll(['id', 'name']); + $json->hasAll(['id', 'name', 'column_type_id']); }); }); } @@ -88,7 +89,7 @@ public function test_can_update() ->assertOk() ->assertJson(function ($json) { $json->has('data', function ($json) { - $json->hasAll(['id', 'name']); + $json->hasAll(['id', 'name', 'column_type_id']); }); }); $this->assertDatabaseHas('columns', ['board_id' => $board->id] + $data); @@ -223,7 +224,7 @@ public function test_can_change_order() }); } - public function test_cant_delete_without_permissions() + public function test_cant_delete_in_not_your_team() { $project = Project::factory()->team(Team::factory())->create(); $board = Board::factory()->project($project)->create(); @@ -235,6 +236,18 @@ public function test_cant_delete_without_permissions() $response->assertForbidden(); } + public function test_cant_delete_kanban_related() + { + $project = Project::factory()->team(Team::factory())->create(); + $board = Board::factory()->project($project)->create(); + $column = Column::factory()->board($board)->create(['column_type_id' => ColumnType::TODO]); + $user = User::factory()->create(); + Sanctum::actingAs($user); + + $response = $this->deleteJson('/api/v1/columns/' . $column->id); + + $response->assertForbidden(); + } public function test_can_delete() { Event::fake(); diff --git a/tests/Feature/Api/V1/Project/BoardControllerTest.php b/tests/Feature/Api/V1/Project/BoardControllerTest.php index 9f9f4b9..6bd0653 100644 --- a/tests/Feature/Api/V1/Project/BoardControllerTest.php +++ b/tests/Feature/Api/V1/Project/BoardControllerTest.php @@ -130,7 +130,7 @@ public function test_cant_store_with_exceeded_boards_limit() { $team = Team::factory()->create(); $project = Project::factory()->team($team)->create(); - Board::factory(MaxBoardsPerProject::MAX_BOARDS)->project($project)->create(); + Board::factory(MaxBoardsPerProject::MAX_ITEMS)->project($project)->create(); $user = User::factory()->hasAttached($team)->create(); Sanctum::actingAs($user); @@ -139,7 +139,7 @@ public function test_cant_store_with_exceeded_boards_limit() $response ->assertUnprocessable() ->assertJsonPath('errors.project', [ - trans('validation.max_boards_per_project', ['max' => MaxBoardsPerProject::MAX_BOARDS]) + trans('validation.max_boards_per_project', ['max' => MaxBoardsPerProject::MAX_ITEMS]) ]); } public function test_can_store() diff --git a/tests/Feature/Services/Modules/KanbanServiceTest.php b/tests/Feature/Services/Modules/KanbanServiceTest.php new file mode 100644 index 0000000..bb9fde6 --- /dev/null +++ b/tests/Feature/Services/Modules/KanbanServiceTest.php @@ -0,0 +1,258 @@ +service = app(KanbanService::class); + } + + public function test_enable_kanban_method_with_single_column() + { + $board = Board::factory()->project(Project::factory()->team(Team::factory()))->create(); + $columns = Column::factory()->board($board)->create(); + + $this->assertDatabaseCount('board_module', 0); + + $this->service->enable($board, [ + 'todo_column_id' => $columns, + ]); + + $this->assertDatabaseCount('board_module', 1); + $columns = Column::all(); + $this->assertEquals(1, $columns->filter(fn($column) => $column->column_type_id != ColumnType::NONE)->count()); + $this->assertEquals(ColumnType::TODO, $columns[0]->column_type_id); + + $this->service->enable($board, [ + 'todo_column_id' => 0, + ]); + + $this->assertDatabaseCount('board_module', 1); + $columns = Column::all(); + $this->assertEquals(1, $columns->filter(fn($column) => $column->column_type_id != ColumnType::NONE)->count()); + $this->assertEquals(ColumnType::NONE, $columns[0]->column_type_id); + $this->assertEquals(ColumnType::TODO, $columns[1]->column_type_id); + + $this->service->enable($board, [ + 'todo_column_id' => $columns[0], + ]); + + $this->assertDatabaseCount('board_module', 1); + $columns = Column::all(); + $this->assertEquals(1, $columns->filter(fn($column) => $column->column_type_id != ColumnType::NONE)->count()); + $this->assertEquals(ColumnType::TODO, $columns[0]->column_type_id); + $this->assertEquals(ColumnType::NONE, $columns[1]->column_type_id); + } + public function test_enable_kanban_method_without_optional_columns() + { + $board = Board::factory()->project(Project::factory()->team(Team::factory()))->create(); + $columns = Column::factory(4)->board($board)->create(); + + $this->assertDatabaseCount('board_module', 0); + + $this->service->enable($board, [ + 'todo_column_id' => $columns[0], + 'inprogress_column_id' => $columns[1], + 'done_column_id' => $columns[2], + ]); + + $this->assertDatabaseCount('board_module', 1); + $columns = Column::all(); + $this->assertEquals(3, $columns->filter(fn($column) => $column->column_type_id != ColumnType::NONE)->count()); + $this->assertEquals(ColumnType::NONE, $columns[3]->column_type_id); + + $this->service->enable($board, [ + 'todo_column_id' => $columns[0], + 'inprogress_column_id' => 0, + 'done_column_id' => $columns[2], + ]); + + $this->assertDatabaseCount('board_module', 1); + $columns = Column::all(); + $this->assertEquals(3, $columns->filter(fn($column) => $column->column_type_id != ColumnType::NONE)->count()); + $this->assertEquals(ColumnType::IN_PROGRESS, $columns[4]->column_type_id); + $this->assertEquals('In Progress', $columns[4]->name); + } + public function test_enable_kanban_method_with_optional_columns() + { + $board = Board::factory()->project(Project::factory()->team(Team::factory()))->create(); + $columns = Column::factory(5)->board($board)->create(); + + $this->assertDatabaseCount('board_module', 0); + + $this->service->enable($board, [ + 'todo_column_id' => $columns[0], + 'inprogress_column_id' => $columns[1], + 'done_column_id' => $columns[2], + 'onreview_column_id' => $columns[3], + ]); + + $this->assertDatabaseCount('board_module', 1); + $columns = Column::all(); + $this->assertEquals(4, $columns->filter(fn($column) => $column->column_type_id != ColumnType::NONE)->count()); + $this->assertEquals(ColumnType::NONE, $columns[4]->column_type_id); + + $this->service->enable($board, [ + 'todo_column_id' => $columns[0], + 'inprogress_column_id' => 0, + 'done_column_id' => $columns[2], + 'onreview_column_id' => 0, + ]); + + $this->assertDatabaseCount('board_module', 1); + $columns = Column::all(); + $this->assertEquals(4, $columns->filter(fn($column) => $column->column_type_id != ColumnType::NONE)->count()); + $this->assertEquals(ColumnType::IN_PROGRESS, $columns[5]->column_type_id); + $this->assertEquals('In Progress', $columns[5]->name); + $this->assertEquals(ColumnType::ON_REVIEW, $columns[6]->column_type_id); + $this->assertEquals('On Review', $columns[6]->name); + + // Dont pass optional columns, so columns with that types should unset. + $this->service->enable($board, [ + 'todo_column_id' => $columns[0], + 'inprogress_column_id' => $columns[5], + 'done_column_id' => $columns[2], + ]); + + $this->assertDatabaseCount('board_module', 1); + $columns = Column::all(); + $this->assertEquals(3, $columns->filter(fn($column) => $column->column_type_id != ColumnType::NONE)->count()); + $this->assertEquals(ColumnType::IN_PROGRESS, $columns[5]->column_type_id); + $this->assertEquals('In Progress', $columns[5]->name); + $this->assertEquals(ColumnType::NONE, $columns[6]->column_type_id); + $this->assertEquals('On Review', $columns[6]->name); + } + + public function test_disable_kanban_method() + { + $board = Board::factory()->project(Project::factory()->team(Team::factory()))->create(); + Module::find(Module::KANBAN)->boards()->attach($board); + $columns = Column::factory(5)->board($board)->sequence( + ['column_type_id' => ColumnType::NONE], + ['column_type_id' => ColumnType::TODO], + ['column_type_id' => ColumnType::IN_PROGRESS], + ['column_type_id' => ColumnType::DONE], + ['column_type_id' => ColumnType::ON_REVIEW], + )->create(); + + $this->assertDatabaseCount('board_module', 1); + + $this->service->disable($board); + + $this->assertDatabaseCount('board_module', 0); + $columns = $columns->fresh(); + $this->assertEquals(0, $columns->filter(fn($column) => $column->column_type_id != ColumnType::NONE)->count()); + } + + public function test_can_move_card_to_column_module() + { + $board = Board::factory()->project(Project::factory()->team(Team::factory()))->create(); + $columns = Column::factory(4)->board($board)->sequence( + ['column_type_id' => ColumnType::NONE], + ['column_type_id' => ColumnType::TODO], + ['column_type_id' => ColumnType::IN_PROGRESS], + ['column_type_id' => ColumnType::DONE], + )->create(); + + // None -> ToDo + $card = Card::factory()->column($columns[0])->create(); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[0])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[1])); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[2])); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[3])); + + // ToDo -> None; ToDo -> InProgress + $card = Card::factory()->column($columns[1])->create(); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[0])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[1])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[2])); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[3])); + + // InProgress -> ToDo; InProgress -> Done + $card = Card::factory()->column($columns[2])->create(); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[0])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[1])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[2])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[3])); + + // Done -> InProgress + $card = Card::factory()->column($columns[3])->create(); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[0])); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[1])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[2])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[3])); + } + public function test_can_move_card_to_column_module_with_on_review_column() + { + $board = Board::factory()->project(Project::factory()->team(Team::factory()))->create(); + $columns = Column::factory(5)->board($board)->sequence( + ['column_type_id' => ColumnType::NONE], + ['column_type_id' => ColumnType::TODO], + ['column_type_id' => ColumnType::IN_PROGRESS], + ['column_type_id' => ColumnType::ON_REVIEW], + ['column_type_id' => ColumnType::DONE], + )->create(); + + // None -> ToDo + $card = Card::factory()->column($columns[0])->create(); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[0])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[1])); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[2])); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[3])); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[4])); + + // ToDo -> None; ToDo -> InProgress + $card = Card::factory()->column($columns[1])->create(); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[0])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[1])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[2])); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[3])); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[4])); + + // InProgress -> ToDo; InProgress -> OnReview + $card = Card::factory()->column($columns[2])->create(); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[0])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[1])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[2])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[3])); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[4])); + + // OnReview -> InProgress; OnReview -> Done + $card = Card::factory()->column($columns[3])->create(); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[0])); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[1])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[2])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[3])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[4])); + + // Done -> OnReview + $card = Card::factory()->column($columns[4])->create(); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[0])); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[1])); + $this->assertFalse($this->service->canMoveCardToColumn($card, $columns[2])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[3])); + $this->assertTrue($this->service->canMoveCardToColumn($card, $columns[4])); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index c5533e3..586f0f9 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -39,6 +39,7 @@ protected function setUpTraits() protected function setupDatabase($schema) { Artisan::call('migrate'); + Artisan::call('db:seed --class=ColumnTypeSeeder'); } /**