Skip to content

Commit

Permalink
Merge pull request #21 from pinkary-project/feature/pin-question-to-p…
Browse files Browse the repository at this point in the history
…rofile

Allow to pin one question to user profile
  • Loading branch information
nunomaduro authored Mar 22, 2024
2 parents 60996f9 + a7fec6e commit 5553f48
Show file tree
Hide file tree
Showing 14 changed files with 287 additions and 66 deletions.
6 changes: 4 additions & 2 deletions app/Livewire/Questions/Index.php
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,10 @@ public function render(Request $request): View
->where('is_reported', false)
->when(! $user->is($request->user()), function (Builder $query, bool $_): void { // @phpstan-ignore-line
$query->whereNotNull('answer');
})->orderByDesc('updated_at')->simplePaginate($this->perPage),
})
->orderByDesc('pinned')
->orderByDesc('updated_at')
->simplePaginate($this->perPage),
]);
}

Expand All @@ -64,7 +67,6 @@ public function render(Request $request): View
#[On('question.reported')]
public function refresh(): void
{
//
}

/**
Expand Down
45 changes: 45 additions & 0 deletions app/Livewire/Questions/Show.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Livewire\Questions;

use App\Models\Question;
use App\Models\User;
use Illuminate\View\View;
use Livewire\Attributes\Locked;
use Livewire\Attributes\On;
Expand Down Expand Up @@ -88,6 +89,50 @@ public function like(): void
]);
}

/**
* Pin a question.
*/
public function pin(): void
{
if (! auth()->check()) {
redirect()->route('login');

return;
}

$user = auth()->user();
assert($user instanceof User);

$question = Question::findOrFail($this->questionId);

$this->authorize('update', $question);

$user->pinnedQuestion()->update(['pinned' => false]);
$question->update(['pinned' => true]);

$this->dispatch('question.updated');
}

/**
* Unpin a pinned question.
*/
public function unpin(): void
{
if (! auth()->check()) {
redirect()->route('login');

return;
}

$question = Question::findOrFail($this->questionId);

$this->authorize('update', $question);

$question->update(['pinned' => false]);

$this->dispatch('question.updated');
}

/**
* Unlike the question.
*/
Expand Down
2 changes: 2 additions & 0 deletions app/Models/Question.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
* @property string $id
* @property int $from_id
* @property int $to_id
* @property bool $pinned
* @property string $content
* @property bool $anonymously
* @property string|null $answer
Expand Down Expand Up @@ -68,6 +69,7 @@ public function casts(): array
'answered_at' => 'datetime',
'created_at' => 'datetime',
'updated_at' => 'datetime',
'pinned' => 'bool',
];
}

Expand Down
12 changes: 11 additions & 1 deletion app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@

use App\Services\Avatar;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\DatabaseNotification;
use Illuminate\Notifications\Notifiable;
Expand Down Expand Up @@ -38,6 +38,7 @@
* @property-read Collection<int, Link> $links
* @property-read Collection<int, Question> $questionsReceived
* @property-read Collection<int, Question> $questionsSent
* @property-read Question $pinnedQuestion
* @property-read Collection<int, DatabaseNotification> $unreadNotifications
* @property-read Collection<int, DatabaseNotification> $readNotifications
*/
Expand Down Expand Up @@ -85,6 +86,15 @@ public function questionsReceived(): HasMany
return $this->hasMany(Question::class, 'to_id');
}

/**
* @return HasOne<Question>
*/
public function pinnedQuestion(): HasOne
{
return $this->hasOne(Question::class, 'to_id')
->where('pinned', true);
}

/**
* Get the user's links sort attribute.
*
Expand Down
6 changes: 6 additions & 0 deletions app/Policies/QuestionPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,10 @@ public function delete(User $user, Question $question): bool
{
return $user->id === $question->to_id;
}

public function pin(User $user, Question $question): bool
{
return $user->id === $question->to_id
&& $question->answer !== null;
}
}
1 change: 1 addition & 0 deletions database/factories/QuestionFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ public function definition(): array
'anonymously' => $this->faker->boolean,
'answer' => $this->faker->sentence,
'answered_at' => $this->faker->dateTime,
'pinned' => false,
];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('questions', function (Blueprint $table): void {
$table->boolean('pinned')->default(false)->after('to_id');
});
}
};
3 changes: 3 additions & 0 deletions resources/views/components/dropdown-button.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<button {{ $attributes->merge(['class' => 'block w-full px-4 py-2 text-start text-sm leading-5 text-gray-400 hover:bg-gray-900 transition duration-150 ease-in-out']) }}>
{{ $slot }}
</button>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" {{ $attributes }}><path stroke-linecap="round" stroke-linejoin="round" d="M6.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM12.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0ZM18.75 12a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" /></svg>
6 changes: 6 additions & 0 deletions resources/views/components/icons/pin.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<svg viewBox="0 0 24 24" aria-hidden="true" {{ $attributes }}>
<path
d="M7 4.5C7 3.12 8.12 2 9.5 2h5C15.88 2 17 3.12 17 4.5v5.26L20.12 16H13v5l-1 2-1-2v-5H3.88L7 9.76V4.5z"
fill="currentColor"
></path>
</svg>
2 changes: 1 addition & 1 deletion resources/views/livewire/questions/edit.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<div class="mb-1">
<textarea
wire:model="answer"
class="h-24 w-full border-none border-transparent bg-gray-950 text-white focus:border-transparent focus:outline-0 focus:ring-0"
class="h-24 w-full border-none border-transparent bg-transparent text-white focus:border-transparent focus:outline-0 focus:ring-0"
placeholder="Write your answer..."
rows="3"
></textarea>
Expand Down
158 changes: 96 additions & 62 deletions resources/views/livewire/questions/show.blade.php
Original file line number Diff line number Diff line change
@@ -1,45 +1,54 @@
<article class="block">
<div>
@if ($question->anonymously)
<div class="flex items-center gap-3 px-4 text-sm text-slate-500">
<div class="border-1 flex h-10 w-10 items-center justify-center rounded-full border border-dashed border-slate-400">
<span>?</span>
<div class="flex justify-between">
@if ($question->anonymously)
<div class="flex items-center gap-3 px-4 text-sm text-slate-500">
<div class="border-1 flex h-10 w-10 items-center justify-center rounded-full border border-dashed border-slate-400">
<span>?</span>
</div>

<p class="font-medium">Anonymously</p>
</div>
@else
<a
href="{{ route('profile.show', ['user' => $question->from->username]) }}"
class="flex items-center gap-3 px-4"
wire:navigate
>
<figure class="h-10 w-10 flex-shrink-0 rounded-full bg-slate-800 transition-opacity hover:opacity-90">
<img
src="{{ $question->from->avatar ? url($question->from->avatar) : $question->from->avatar_url }}"
alt="{{ $question->from->username }}"
class="h-10 w-10 rounded-full"
/>
</figure>

<p class="font-medium">Anonymously</p>
</div>
@else
<a
href="{{ route('profile.show', ['user' => $question->from->username]) }}"
class="flex items-center gap-3 px-4"
wire:navigate
>
<figure class="h-10 w-10 flex-shrink-0 rounded-full bg-slate-800 transition-opacity hover:opacity-90">
<img
src="{{ $question->from->avatar ? url($question->from->avatar) : $question->from->avatar_url }}"
alt="{{ $question->from->username }}"
class="h-10 w-10 rounded-full"
/>
</figure>
<div class="overflow-hidden text-sm">
<div class="flex">
<p class="truncate font-medium text-slate-50">
{{ $question->from->name }}
<div class="overflow-hidden text-sm">
<div class="flex">
<p class="truncate font-medium text-slate-50">
{{ $question->from->name }}
</p>
@if ($question->from->is_verified)
<x-icons.verified
:color="$question->from->right_color"
class="ml-1 mt-0.5 h-4 w-4 flex-shrink-0"
/>
@endif
</div>

<p class="truncate text-slate-500 transition-colors hover:text-slate-400">
{{ '@'.$question->from->username }}
</p>
@if ($question->from->is_verified)
<x-icons.verified
:color="$question->from->right_color"
class="ml-1 mt-0.5 h-4 w-4 flex-shrink-0"
/>
@endif
</div>

<p class="truncate text-slate-500 transition-colors hover:text-slate-400">
{{ '@'.$question->from->username }}
</p>
</a>
@endif
@if ($question->pinned)
<div class="mb-2 flex items-center space-x-1 px-4 text-sm focus:outline-none">
<x-icons.pin class="h-4 w-4 text-slate-400" />
<span class="text-slate-400">Pinned</span>
</div>
</a>
@endif
@endif
</div>

<p class="mb-4 mt-3 px-4 text-slate-200">
{!! $question->content !!}
Expand All @@ -48,36 +57,61 @@ class="ml-1 mt-0.5 h-4 w-4 flex-shrink-0"

@if ($question->answer)
<div class="answer mt-3 rounded-2xl bg-slate-900 p-4">
<a
href="{{ route('profile.show', ['user' => $question->to->username]) }}"
class="flex items-center gap-3"
wire:navigate
>
<figure class="h-10 w-10 flex-shrink-0 rounded-full bg-slate-800 transition-opacity hover:opacity-90">
<img
src="{{ $question->to->avatar ? url($question->to->avatar) : $question->to->avatar_url }}"
alt="{{ $question->to->username }}"
class="h-10 w-10 rounded-full"
/>
</figure>
<div class="overflow-hidden text-sm">
<div class="items flex">
<p class="truncate font-medium text-slate-50">
{{ $question->to->name }}
<div class="flex justify-between">
<a
href="{{ route('profile.show', ['user' => $question->to->username]) }}"
class="flex items-center gap-3"
wire:navigate
>
<figure class="h-10 w-10 flex-shrink-0 rounded-full bg-slate-800 transition-opacity hover:opacity-90">
<img
src="{{ $question->to->avatar ? url($question->to->avatar) : $question->to->avatar_url }}"
alt="{{ $question->to->username }}"
class="h-10 w-10 rounded-full"
/>
</figure>
<div class="overflow-hidden text-sm">
<div class="items flex">
<p class="truncate font-medium text-slate-50">
{{ $question->to->name }}
</p>
@if ($question->to->is_verified)
<x-icons.verified
:color="$question->to->right_color"
class="ml-1 mt-0.5 h-4 w-4 flex-shrink-0"
/>
@endif
</div>

<p class="truncate text-slate-500 transition-colors hover:text-slate-400">
{{ '@'.$question->to->username }}
</p>
@if ($question->to->is_verified)
<x-icons.verified
:color="$question->to->right_color"
class="ml-1 mt-0.5 h-4 w-4 flex-shrink-0"
/>
@endif
</div>
</a>
@if (auth()->check() && auth()->user()->can('update', $question))
<x-dropdown align="right" width="48">
<x-slot name="trigger">
<button class="inline-flex items-center rounded-md border border-transparent py-1 text-sm text-gray-400 transition duration-150 ease-in-out hover:text-gray-50 focus:outline-none">
<x-icons.ellipsis-horizontal class="h-6 w-6" />
</button>
</x-slot>

<p class="truncate text-slate-500 transition-colors hover:text-slate-400">
{{ '@'.$question->to->username }}
</p>
</div>
</a>
<x-slot name="content">
@if (! $question->pinned && auth()->user()->can('pin', $question))
<x-dropdown-button wire:click="pin" class="flex items-center gap-1.5">
<x-icons.pin class="h-4 w-4 text-slate-50 group-hover:text-slate-400" />
<span class="group-hover:text-slate-400">Pin</span>
</x-dropdown-button>
@elseif ($question->pinned)
<x-dropdown-button wire:click="unpin" class="flex items-center gap-1.5">
<x-icons.pin class="h-4 w-4" />
<span>Unpin</span>
</x-dropdown-button>
@endif
</x-slot>
</x-dropdown>
@endif
</div>

<p class="mt-3 text-slate-200">
{!! $question->answer !!}
Expand Down
20 changes: 20 additions & 0 deletions tests/Unit/Livewire/Questions/IndexTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -142,3 +142,23 @@

$component->assertSet('perPage', 100);
});

test('pinned question is displayed at the top', function () {
$user = User::factory()->create();

$pinnedQuestion = Question::factory()->create([
'pinned' => true,
'to_id' => $user->id,
]);

$otherQuestions = Question::factory()->count(10)->create(['to_id' => $user->id]);

$component = Livewire::actingAs($user)->test(Index::class, [
'userId' => $user->id,
]);

$component->assertSeeInOrder([
$pinnedQuestion->content,
...$otherQuestions->slice(0, 4)->map->content->toArray(),
]);
});
Loading

0 comments on commit 5553f48

Please sign in to comment.