Menu

23.删除话题

本节说明

  • 对应视频第 23 小节:A User Can Delete Their Threads

本节内容

前面章节的修正

经评论里的同学提醒,前面的章节遗漏了部分细节,特此更正。在之前的章节中,对于Thread模型,我们使用了 $with = ['creator','channel']属性进行预加载creatorchannel(前面章节有所遗漏);对于Reply模型,我们使用了 $with = ['owner','favorites']属性进行预加载ownerfavorites。所以Thread模型的replies模型关联应为:

public function replies()
{
    return $this->hasMany(Reply::class);
}

本节的内容是话题的删除功能。在开始本节内容之前,我们先来对话题页面的布局进行点修改:
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($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">
                                {{ $thread->body }}
                        </div>
                    </div>
                @endforeach
            </div>
        </div>
    </div>
@endsection

刷新页面:
file
接着我们再来对个人页面的布局进行修改:
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($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

修改前的布局:
file
修改后的布局:
file
为了便于维护,我们把导航栏抽取成一个单独的视图文件,并且为个人页面加上入口:
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]($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>

再次刷新,可以看到个人页面的入口已经加上:
file
现在开始本节的内容:话题删除功能。首先新建测试:
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',['id' => $thread->id]);
}

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();
}
.
.

运行测试,初步通过:
file
为什么说是初步通过呢?因为我们想为删除动作附上状态码,这里我们定为 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);
}
.
.

再次测试:
file
现在我们的删除逻辑已经建立了,但是并不完整。试想一下,如果删除了话题,那么与该话题相关的回复也应该被删除。让我们来补充完整相关的测试逻辑:

.
.
/** @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);
}
.
.

再次运行测试:
file
你也可以利用 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();
});
}
.
.

运行测试,仍然通过:
file
你还可以通过重写delete()方法来实现删除话题与相关回复的逻辑。至于具体采用哪种方法来实现,就要看你的个人喜好了。好了,现在我们来进行下一步:删除动作的权限问题。这个问题我们细分成两个点:

  1. 未登录用户不能进行删除动作;
  2. 已登录用户只能删除自己创建的话题;

首先新建测试:
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()
{
.
.

运行测试:
file
测试通过,这是因为我们在控制器中已经做好了防范,除了index()show()方法,其他方法都需要登录:

.
.
class ThreadsController extends Controller
{
    public function __construct()
    {
        $this->middleware('auth')->except(['index','show']);
    }
        .
        .

本节我们先实现删除的功能,下一节我们再来修复权限控制的问题。在页面加上删除按钮:
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>
.
.

查看页面:
file
我们需要修改下控制器:

.
.
public function destroy($channel,Thread $thread)
{
    $thread->delete();

    if(request()->wantsJson()){
        return response([],204);
    }

    return redirect('/threads');
}
.
.

现在我们已经可以删除话题了,但是别忘了我们还未处理权限问题,我们将在下一节进行处理。

本文章首发在 Laravel China 社区
上一篇 下一篇
讨论数量: 2
发起讨论


zh117
同步删除回复报错?
1 个点赞 | 9 个回复 | 问答