diff --git a/app/Actions/UpdateScheduledCommitSubmittedAt.php b/app/Actions/UpdateScheduledCommitSubmittedAt.php index f9820ac56..fa6a2bf51 100644 --- a/app/Actions/UpdateScheduledCommitSubmittedAt.php +++ b/app/Actions/UpdateScheduledCommitSubmittedAt.php @@ -17,15 +17,18 @@ public function __construct(private CreateCommit $createCommit) // } - public function handle(string $id): bool + /** + * The author can commit it now, before its scheduled time. + */ + public function handle(string $id, bool $early = false): bool { - return DB::transaction(function () use ($id): bool { + return DB::transaction(function () use ($id, $early): bool { // Claim with a conditional write before reading. // Creation and the claim roll back together if either operation fails. // Moderation is checked in the claim too, since a timeout or ban can land after the batch was selected... $claimed = ScheduledCommit::query()->whereKey($id) ->whereNull('submitted_at')->whereNull('canceled_at') - ->where('scheduled_at', '<=', now()) + ->unless($early, fn (Builder $query): Builder => $query->where('scheduled_at', '<=', now())) ->whereDoesntHave('user.timeouts', fn (Builder $query): Builder => $query->where('ends_at', '>', now())) ->whereDoesntHave('user.ban') ->update(['submitted_at' => now()]); diff --git a/app/Http/Controllers/ScheduledCommitSubmissionController.php b/app/Http/Controllers/ScheduledCommitSubmissionController.php new file mode 100644 index 000000000..e4ba429a1 --- /dev/null +++ b/app/Http/Controllers/ScheduledCommitSubmissionController.php @@ -0,0 +1,29 @@ +handle($scheduledCommit->id, early: true); + + if ($submitted) { + Inertia::flash('submitted', $scheduledCommit->id); + } + + Inertia::flash('toast', ['type' => $submitted ? 'success' : 'info', 'message' => $submitted + ? 'Commit submitted. It goes through the usual review process.' + : 'This commit was already submitted or canceled. Refresh to see its status.']); + + return back(); + } +} diff --git a/app/Http/Requests/CreateScheduledCommitSubmissionRequest.php b/app/Http/Requests/CreateScheduledCommitSubmissionRequest.php new file mode 100644 index 000000000..b23d5934a --- /dev/null +++ b/app/Http/Requests/CreateScheduledCommitSubmissionRequest.php @@ -0,0 +1,25 @@ +route('scheduledCommit'); + assert($scheduled instanceof ScheduledCommit); + + return $this->user()?->id === $scheduled->user_id; + } + + /** @return array{} */ + public function rules(): array + { + return []; + } +} diff --git a/resources/js/pages/scheduled-commits/index.tsx b/resources/js/pages/scheduled-commits/index.tsx index 24bcdb584..519cac76d 100644 --- a/resources/js/pages/scheduled-commits/index.tsx +++ b/resources/js/pages/scheduled-commits/index.tsx @@ -1,10 +1,22 @@ import { Head, Link, router, usePoll } from '@inertiajs/react'; -import { ArrowLeft, Clock3, ImageIcon, LockKeyhole } from 'lucide-react'; -import { useState } from 'react'; +import { + ArrowLeft, + Clock3, + ImageIcon, + LockKeyhole, + Trash2, +} from 'lucide-react'; +import { useEffect, useRef, useState } from 'react'; import LogController from '@/actions/App/Http/Controllers/LogController'; import ScheduledCommitController from '@/actions/App/Http/Controllers/ScheduledCommitController'; +import ScheduledCommitSubmissionController from '@/actions/App/Http/Controllers/ScheduledCommitSubmissionController'; import { FoldedCommitBody } from '@/components/commit-body'; import { Button } from '@/components/ui/button'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; import { useNow } from '@/hooks/use-now'; import AppLayout from '@/layouts/app-layout'; import { backgroundPollOptions } from '@/lib/background-poll'; @@ -34,9 +46,93 @@ export default function Index({ }; }) { const now = useNow(); - const [canceling, setCanceling] = useState(null); + const [busy, setBusy] = useState<{ + id: string; + action: 'commit' | 'cancel'; + } | null>(null); + const returnFocusTo = useRef(null); + const [sent, setSent] = useState(new Map()); + // Rows keep the order they had when the page opened, and a row sent with Commit now keeps showing what was sent + // (submission clears it), so it stays where it was at its size instead of jumping below the outstanding ones and + // pulling the rows under it up. Submitted rows sort after older ones, so the refreshed page can leave it out: it + // stays from what was sent. A fresh visit sorts and trims them again. + const [order] = useState(() => + scheduledCommits.data.map((scheduled) => scheduled.id), + ); + const rank = (id: string) => { + const index = order.indexOf(id); + + return index === -1 ? order.length : index; + }; + const missing = [...sent.values()] + .filter( + (before) => + !scheduledCommits.data.some( + (scheduled) => scheduled.id === before.id, + ), + ) + .map((before) => ({ ...before, status: 'pending' as const })); + const rows = [...scheduledCommits.data, ...missing] + .sort((a, b) => rank(a.id) - rank(b.id)) + .map((scheduled) => { + const before = sent.get(scheduled.id); + + return before && scheduled.status !== 'scheduled' + ? { + ...before, + submitted_at: scheduled.submitted_at, + status: scheduled.status, + } + : scheduled; + }); usePoll(30_000, backgroundPollOptions({ only: ['scheduledCommits'] })); + useEffect(() => { + if (busy !== null || returnFocusTo.current === null) { + return; + } + + const row = document.querySelector( + `[data-scheduled-commit="${returnFocusTo.current}"]`, + ); + returnFocusTo.current = null; + // The page has the new props by now: back on Commit now when the commit is still outstanding, on its row once + // the button is gone. + (row?.querySelector('[data-commit-now]') ?? row)?.focus({ + preventScroll: true, + }); + }, [busy]); + + const commitNow = (scheduled: ScheduledCommit) => { + let claimed = false; + returnFocusTo.current = scheduled.id; + // Kept before the response renders, and dropped again unless the server says this request claimed it. + setSent((current) => new Map(current).set(scheduled.id, scheduled)); + setBusy({ id: scheduled.id, action: 'commit' }); + router.post( + ScheduledCommitSubmissionController.store(scheduled.id), + {}, + { + preserveScroll: true, + onSuccess: (page) => { + claimed = page.flash.submitted === scheduled.id; + }, + onFinish: () => { + if (!claimed) { + setSent((current) => { + const next = new Map(current); + next.delete(scheduled.id); + + return next; + }); + } + + setBusy(null); + }, + }, + ); + }; + const labels = { pending: 'Submitted for review', approved: 'Approved', @@ -86,14 +182,22 @@ export default function Index({ ) : (
    - {scheduledCommits.data.map((scheduled) => { + {rows.map((scheduled) => { const due = new Date(scheduled.scheduled_at).getTime() <= now.getTime(); + const when = new Date( + scheduled.scheduled_at, + ).toLocaleString(dateLocale, { + dateStyle: 'medium', + timeStyle: 'short', + }); return (
  • @@ -108,38 +212,61 @@ export default function Index({ dateTime={scheduled.scheduled_at} className="mt-1 block font-mono text-xs text-muted-foreground" > - {new Date( - scheduled.scheduled_at, - ).toLocaleString(dateLocale, { - dateStyle: 'medium', - timeStyle: 'short', - })} + {when}
    {scheduled.status === 'scheduled' && ( - +
    + + + + + + + Cancel + + +
    )}
    {scheduled.body && ( @@ -180,12 +307,20 @@ export default function Index({ : `${scheduled.image_count} images`}

    )} - {scheduled.status === 'scheduled' && ( + {scheduled.status === 'scheduled' ? (

    {due ? 'Waiting for the scheduler. If submission fails, it is retried automatically.' - : 'Private until submitted. You can cancel before submission.'} + : 'Private until submitted. You can commit it now or cancel it before submission.'}

    + ) : ( + sent.has(scheduled.id) && ( +

    + Submitted just now, ahead of its + scheduled time. It goes through the + usual review process. +

    + ) )}
  • ); diff --git a/resources/js/types/global.d.ts b/resources/js/types/global.d.ts index 76c7a78a4..1382a50b0 100644 --- a/resources/js/types/global.d.ts +++ b/resources/js/types/global.d.ts @@ -17,6 +17,7 @@ declare module '@inertiajs/core' { deleted?: DeletedFlash; restored?: Commit; scheduled?: boolean; + submitted?: string; localSignIn?: boolean; signedUp?: boolean; }; diff --git a/routes/web.php b/routes/web.php index 2b7e08984..8b5018685 100644 --- a/routes/web.php +++ b/routes/web.php @@ -84,6 +84,7 @@ use App\Http\Controllers\ReportedCommitController; use App\Http\Controllers\SafetyController; use App\Http\Controllers\ScheduledCommitController; +use App\Http\Controllers\ScheduledCommitSubmissionController; use App\Http\Controllers\SearchController; use App\Http\Controllers\SessionController; use App\Http\Controllers\SiteActivityController; @@ -280,6 +281,8 @@ Route::post('scheduled-commits', [ScheduledCommitController::class, 'store']) ->middleware([PreventPostingInTimeout::class, RequireCommitRules::class, ThrottleCommits::class])->name('scheduled-commit.store'); Route::delete('scheduled-commits/{scheduledCommit}', [ScheduledCommitController::class, 'destroy'])->name('scheduled-commit.destroy'); + Route::post('scheduled-commits/{scheduledCommit}/submission', [ScheduledCommitSubmissionController::class, 'store']) + ->middleware([PreventPostingInTimeout::class, ThrottleCommits::class])->name('scheduled-commit-submission.store'); Route::get('commit-stars', [CommitStarCountController::class, 'index'])->name('commit-star-count.index'); Route::post('commits', [CommitController::class, 'store']) diff --git a/tests/Feature/ScheduledCommitsTest.php b/tests/Feature/ScheduledCommitsTest.php index 67839b43a..360007334 100644 --- a/tests/Feature/ScheduledCommitsTest.php +++ b/tests/Feature/ScheduledCommitsTest.php @@ -16,6 +16,7 @@ use App\Models\Poll; use App\Models\ScheduledCommit; use App\Models\User; +use App\Models\UserTimeout; use App\Queries\UserActivityQuery; use Illuminate\Database\QueryException; use Illuminate\Http\UploadedFile; @@ -155,6 +156,7 @@ $this->get(route('scheduled-commit.index'))->assertRedirectToRoute('home'); $this->post(route('scheduled-commit.store'), [])->assertRedirectToRoute('home'); $this->delete(route('scheduled-commit.destroy', $scheduled))->assertRedirectToRoute('home'); + $this->post(route('scheduled-commit-submission.store', $scheduled))->assertRedirectToRoute('home'); }); it('rejects invalid times and bodies', function (array $payload, string $field): void { @@ -215,6 +217,7 @@ it('shares the existing commit rate limit', function (): void { $user = User::factory()->create(); + $scheduled = ScheduledCommit::factory()->for($user)->create(); for ($i = 0; $i < 10; $i++) { $this->actingAs($user)->post(route('commit.store'), ['body' => 'An ordinary commit.']); } @@ -222,7 +225,10 @@ $this->post(route('scheduled-commit.store'), [ 'body' => 'Must be throttled.', 'scheduled_at' => now()->addDay()->toIso8601String(), ])->assertRedirect(); - expect(ScheduledCommit::query()->count())->toBe(0); + $this->post(route('scheduled-commit-submission.store', $scheduled))->assertRedirect() + ->assertInertiaFlash('toast.type', 'error')->assertInertiaFlashMissing('submitted'); + expect(ScheduledCommit::query()->count())->toBe(1) + ->and($scheduled->fresh()->submitted_at)->toBeNull(); }); it('keeps schedules out of logs profiles activity previews and moderation until due', function (): void { @@ -494,3 +500,47 @@ expect(collect(DB::getQueryLog())->filter(fn (array $query): bool => str_contains($query['query'], 'scheduled_commits')))->toBeEmpty(); }); + +it('commits a scheduled item now, before its time, through the usual review', function (): void { + $scheduled = ScheduledCommit::factory()->create(['scheduled_at' => now()->addDay(), 'body' => 'Shipping early']); + + $this->actingAs($scheduled->user)->post(route('scheduled-commit-submission.store', $scheduled))->assertRedirect() + ->assertInertiaFlash('toast.type', 'success')->assertInertiaFlash('submitted', $scheduled->id); + + $commit = Commit::query()->sole(); + expect($commit->body)->toBe('Shipping early') + ->and($commit->status)->toBe(CommitStatus::Pending) + ->and($scheduled->fresh()->commit_id)->toBe($commit->id) + ->and($scheduled->fresh()->submitted_at->equalTo(now()))->toBeTrue(); + + $this->post(route('scheduled-commit-submission.store', $scheduled))->assertRedirect() + ->assertInertiaFlash('toast.type', 'info'); + $this->travel(1)->day(); + $this->artisan('commits:submit-scheduled')->assertSuccessful(); + expect(Commit::query()->count())->toBe(1); +}); + +it('does not commit now a canceled item or another authors item', function (): void { + $canceled = ScheduledCommit::factory()->create(); + resolve(DeleteScheduledCommit::class)->handle($canceled->user, $canceled); + $other = ScheduledCommit::factory()->create(); + + $this->actingAs($canceled->user)->post(route('scheduled-commit-submission.store', $canceled))->assertRedirect() + ->assertInertiaFlash('toast.type', 'info')->assertInertiaFlashMissing('submitted'); + $this->post(route('scheduled-commit-submission.store', $other))->assertForbidden(); + $this->actingAs(User::factory()->create(['username' => 'admin']))->post(route('scheduled-commit-submission.store', $other))->assertForbidden(); + + expect(Commit::query()->count())->toBe(0) + ->and($other->fresh()->submitted_at)->toBeNull(); +}); + +it('does not commit now while the author is in timeout', function (): void { + $scheduled = ScheduledCommit::factory()->create(); + UserTimeout::factory()->for($scheduled->user)->create(); + + $this->actingAs($scheduled->user)->post(route('scheduled-commit-submission.store', $scheduled))->assertRedirect() + ->assertInertiaFlash('timeout')->assertInertiaFlashMissing('submitted'); + + expect(Commit::query()->count())->toBe(0) + ->and(resolve(UpdateScheduledCommitSubmittedAt::class)->handle($scheduled->id, early: true))->toBeFalse(); +});