Laravel 项目:使用 TDD 构建论坛 Chapter 23
0.写在前面
- 本系列文章为
laracasts.com
的系列视频教程——Let's Build A Forum with Laravel and TDD 的学习笔记。若喜欢该系列视频,可去该网站订阅后下载该系列视频, 支持正版 。 - 视频源码地址:https://github.com/laracasts/Lets-Build-a-Forum-in-Laravel
- *本项目为一个 forum(论坛)项目,与本站的第二本实战教程 Laravel 教程 - Web 开发实战进阶 ( Laravel 5.5 ) 类似,可互相参照
- 项目开发模式为
TDD
开发,教程简介为:A forum is a deceptively complex thing. Sure, it's made up of threads and replies, but what else might exist as part of a forum? What about profiles, or thread subscriptions, or filtering, or real-time notifications? As it turns out, a forum is the perfect project to stretch your programming muscles. In this series, we'll work together to build one with tests from A to Z.
- 项目版本为 laravel 5.4,教程后面会进行升级到 laravel 5.5 的教学
- 视频教程共计 102 个小节,笔记章节与视频教程一一对应
1.本节说明
- 对应视频第 23 小节:A User Can Delete Their Threads
2.本节内容
本节的内容是话题的删除功能。在开始本节内容之前,我们先来对话题页面的布局进行点修改:forum\resources\views\threads\index.blade.php
@extends('layouts.app')
@section('content')
<div class="container">
<div class="row">
<div class="col-md-8 col-md-offset-2">
[@foreach](https://learnku.com/users/5651) ($threads as $thread)
<div class="panel panel-default">
<div class="panel-heading">
<div class="level">
<h4 class="flex">
<a href="{{ $thread->path() }}">
{{ $thread->title }}
</a>
</h4>
<a href="{{ $thread->path() }}">
{{ $thread->replies_count }} {{ str_plural('reply',$thread->replies_count) }}
</a>
</div>
</div>
<div class="panel-body">
<div class="body">{{ $thread->body }}</div>
</div>
</div>
@endforeach
</div>
</div>
</div>
@endsection
刷新页面:
接着我们再来对个人页面的布局进行修改:forum\resources\views\profiles\show.blade.php
@extends('layouts.app')
@section('content')
<div class="container">
<div class="row">
<div class="col-md-offset-2">
<div class="page-header">
<h1>
{{ $profileUser->name }}
<small>注册于{{ $profileUser->created_at->diffForHumans() }}</small>
</h1>
</div>
[@foreach](https://learnku.com/users/5651)($threads as $thread)
<div class="panel panel-default">
<div class="panel-heading">
<div class="level">
<span class="flex">
<a href="{{ route('profile',$thread->creator) }}">{{ $thread->creator->name }}</a> 发表于
{{ $thread->title }}
</span>
<span>{{ $thread->created_at->diffForHumans() }}</span>
</div>
</div>
<div class="panel-body">
{{ $thread->body }}
</div>
</div>
@endforeach
{{ $threads->links() }}
</div>
</div>
</div>
@endsection
修改前的布局:
修改后的布局:
为了便于维护,我们把导航栏抽取成一个单独的视图文件,并且为个人页面加上入口:forum\resources\views\layouts\app.blade.php
<!DOCTYPE html>
<html lang="{{ app()->getLocale() }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<!-- CSRF Token -->
<meta name="csrf-token" content="{{ csrf_token() }}">
<title>{{ config('app.name', 'Laravel') }}</title>
<!-- Styles -->
<link href="{{ asset('css/app.css') }}" rel="stylesheet">
<script>
window.Laravel = {!! json_encode([
'csrfToken' => csrf_token(),
]) !!};
</script>
<style>
body{ padding-bottom: 100px; }
.level { display: flex;align-items: center; }
.flex { flex: 1 }
</style>
</head>
<body>
<div id="app">
@include('layouts.nav')
@yield('content')
</div>
<!-- Scripts -->
<script src="{{ asset('js/app.js') }}"></script>
</body>
</html>
resources\views\layouts\nav.blade.php
<nav class="navbar navbar-default navbar-static-top">
<div class="container">
<div class="navbar-header">
<!-- Collapsed Hamburger -->
<button type="button" class="navbar-toggle collapsed" data-toggle="collapse" data-target="#app-navbar-collapse">
<span class="sr-only">Toggle Navigation</span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<!-- Branding Image -->
<a class="navbar-brand" href="{{ url('/') }}">
{{ config('app.name', 'Laravel') }}
</a>
</div>
<div class="collapse navbar-collapse" id="app-navbar-collapse">
<!-- Left Side Of Navbar -->
<ul class="nav navbar-nav">
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" aria-hidden="true"
aria-expanded="false">Browse <span class="caret"></span> </a>
<ul class="dropdown-menu">
<li><a href="/threads">ALL Threads</a> </li>
@if(auth()->check())
<li><a href="/threads?by={{ auth()->user()->name }}">My Threads</a> </li>
@endif
<li><a href="/threads?popularity=1">Popular Threads</a> </li>
</ul>
</li>
<li><a href="/threads/create">New Thread</a></li>
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" aria-hidden="true"
aria-expanded="false">Channels <span class="caret"></span> </a>
<ul class="dropdown-menu">
[@foreach](https://learnku.com/users/5651)($channels as $channel)
<li><a href="/threads/{{ $channel->slug }}">{{ $channel->name }}</a> </li>
@endforeach
</ul>
</li>
</ul>
<!-- Right Side Of Navbar -->
<ul class="nav navbar-nav navbar-right">
<!-- Authentication Links -->
@if (Auth::guest())
<li><a href="{{ route('login') }}">Login</a></li>
<li><a href="{{ route('register') }}">Register</a></li>
@else
<li class="dropdown">
<a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false">
{{ Auth::user()->name }} <span class="caret"></span>
</a>
<ul class="dropdown-menu" role="menu">
<li>
<a href="{{ route('profile',Auth::user()) }}">My Profile</a>
</li>
<li>
<a href="{{ route('logout') }}"
onclick="event.preventDefault();
document.getElementById('logout-form').submit();">
Logout
</a>
<form id="logout-form" action="{{ route('logout') }}" method="POST" style="display: none;">
{{ csrf_field() }}
</form>
</li>
</ul>
</li>
@endif
</ul>
</div>
</div>
</nav>
再次刷新,可以看到个人页面的入口已经加上:
现在开始本节的内容:话题删除功能。首先新建测试:C:\Users\meiyiming\Code\forum\tests\Feature\CreateThreadsTest.php
.
.
/** @test */
public function a_thread_can_be_deleted()
{
$this->signIn();
$thread = create('App\Thread');
$this->json('DELETE',$thread->path());
$this->assertDatabaseMissing('threads',$thread->toArray());
}
public function publishThread($overrides = [])
{
.
.
增加路由forum\routes\web.php
.
.
Route::get('threads/{channel}/{thread}','ThreadsController@show');
Route::delete('threads/{channel}/{thread}','ThreadsController@destroy');
.
.
编写destroy()
方法:forum\app\Http\Controllers\ThreadsController.php
.
.
public function show($channel,Thread $thread)
{
return view('threads.show',[
'thread' => $thread,
'replies' => $thread->replies()->paginate(10)
]);
}
public function destroy($channel,Thread $thread)
{
$thread->delete();
}
.
.
运行测试,初步通过:
为什么说是初步通过呢?因为我们想为删除动作附上状态码,这里我们定为 204 :
.
.
/** @test */
public function a_thread_can_be_deleted()
{
$this->signIn();
$thread = create('App\Thread');
$response = $this->json('DELETE',$thread->path());
$response->assertStatus(204);
$this->assertDatabaseMissing('threads',['id' => $thread->id]);
}
.
.
跟着还要修改控制器:
.
.
public function destroy($channel,Thread $thread)
{
$thread->delete();
return response([],204);
}
.
.
再次测试:
现在我们的删除逻辑已经建立了,但是并不完整。试想一下,如果删除了话题,那么与该话题相关的回复也应该被删除。让我们来补充完整相关的测试逻辑:
.
.
/** @test */
public function a_thread_can_be_deleted()
{
$this->signIn();
$thread = create('App\Thread');
$reply = create('App\Reply',['thread_id' => $thread->id]);
$response = $this->json('DELETE',$thread->path());
$response->assertStatus(204);
$this->assertDatabaseMissing('threads',['id' => $thread->id]);
$this->assertDatabaseMissing('replies',['id' => $reply->id]);
}
.
.
再来补充完成控制器:
.
.
public function destroy($channel,Thread $thread)
{
$thread->replies()->delete();
$thread->delete();
return response([],204);
}
.
.
再次运行测试:
你也可以利用 Eloquent 监控器 的deleting
事件来删除相关回复,而不是在控制器中删除:forum\app\Http\Controllers\ThreadsController.php
.
.
public function destroy($channel,Thread $thread)
{
$thread->delete();
return response([],204);
}
.
.
forum\app\Thread.php
.
.
protected static function boot()
{
parent::boot();
static::addGlobalScope('replyCount',function ($builder){
$builder->withCount('replies');
});
static::deleting(function ($thread) {
$thread->replies()->delete();
});
}
.
.
运行测试,仍然通过:
你还可以通过重写delete()
方法来实现删除话题与相关回复的逻辑。至于具体采用哪种方法来实现,就要看你的个人喜好了。好了,现在我们来进行下一步:删除动作的权限问题。这个问题我们细分成两个点:
- 未登录用户不能进行删除动作;
- 已登录用户只能删除自己创建的话题;
首先新建测试:forum\tests\Feature\CreateThreadsTest.php
.
.
/** @test */
public function guests_cannot_delete_threads()
{
$this->withExceptionHandling();
$thread = create('App\Thread');
$response = $this->delete($thread->path());
$response->assertRedirect('/login');
}
/** @test */
public function a_thread_can_be_deleted()
{
.
.
运行测试:
测试通过,这是因为我们在控制器中已经做好了防范,除了index()
跟show()
方法,其他方法都需要登录:
.
.
class ThreadsController extends Controller
{
public function __construct()
{
$this->middleware('auth')->except(['index','show']);
}
.
.
本节我们先实现删除的功能,下一节我们再来修复权限控制的问题。在页面加上删除按钮:C:\Users\meiyiming\Code\forum\resources\views\threads\show.blade.php
.
.
<div class="level">
<span class="flex">
<a href="{{ route('profile',$thread->creator) }}">{{ $thread->creator->name }}</a>
{{ $thread->title }}
</span>
<form action="{{ $thread->path() }}" method="POST">
{{ csrf_field() }}
{{ method_field('DELETE') }}
<button type="submit" class="btn btn-link">Delete Thread</button>
</form>
</div>
.
.
查看页面:
我们需要修改下控制器:
.
.
public function destroy($channel,Thread $thread)
{
$thread->delete();
if(request()->wantsJson()){
return response([],204);
}
return redirect('/threads');
}
.
.
现在我们已经可以删除话题了,但是别忘了我们还未处理权限问题,我们将在下一节进行处理。
3.写在后面
- 如有建议或意见,欢迎指出~
- 如果觉得文章写的不错,请点赞鼓励下哈,你的鼓励将是我的动力!
本作品采用《CC 协议》,转载必须注明作者和本文链接
后续系列文章参见社区文档 使用 TDD 构建 Laravel 论坛课程笔记
佩服楼主的坚持