Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions app/Actions/UpdateScheduledCommitSubmittedAt.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()]);
Expand Down
29 changes: 29 additions & 0 deletions app/Http/Controllers/ScheduledCommitSubmissionController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?php

declare(strict_types=1);

namespace App\Http\Controllers;

use App\Actions\UpdateScheduledCommitSubmittedAt;
use App\Http\Requests\CreateScheduledCommitSubmissionRequest;
use App\Models\ScheduledCommit;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;

final readonly class ScheduledCommitSubmissionController
{
public function store(CreateScheduledCommitSubmissionRequest $request, ScheduledCommit $scheduledCommit, UpdateScheduledCommitSubmittedAt $action): RedirectResponse
{
$submitted = $action->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();
}
}
25 changes: 25 additions & 0 deletions app/Http/Requests/CreateScheduledCommitSubmissionRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace App\Http\Requests;

use App\Models\ScheduledCommit;
use Illuminate\Foundation\Http\FormRequest;

final class CreateScheduledCommitSubmissionRequest extends FormRequest
{
public function authorize(): bool
{
$scheduled = $this->route('scheduledCommit');
assert($scheduled instanceof ScheduledCommit);

return $this->user()?->id === $scheduled->user_id;
}

/** @return array{} */
public function rules(): array
{
return [];
}
}
207 changes: 171 additions & 36 deletions resources/js/pages/scheduled-commits/index.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -34,9 +46,93 @@ export default function Index({
};
}) {
const now = useNow();
const [canceling, setCanceling] = useState<string | null>(null);
const [busy, setBusy] = useState<{
id: string;
action: 'commit' | 'cancel';
} | null>(null);
const returnFocusTo = useRef<string | null>(null);
const [sent, setSent] = useState(new Map<string, ScheduledCommit>());
// 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<HTMLElement>(
`[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<HTMLElement>('[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',
Expand Down Expand Up @@ -86,14 +182,22 @@ export default function Index({
</div>
) : (
<ul className="divide-y">
{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 (
<li
key={scheduled.id}
className="space-y-3 p-5 sm:px-6"
className="space-y-3 p-5 outline-none focus-visible:bg-accent sm:px-6"
tabIndex={-1}
data-scheduled-commit={scheduled.id}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
Expand All @@ -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}
</time>
</div>
{scheduled.status === 'scheduled' && (
<Button
variant="outline"
size="sm"
disabled={canceling !== null}
aria-label={`Cancel the commit scheduled for ${new Date(scheduled.scheduled_at).toLocaleString(dateLocale, { dateStyle: 'medium', timeStyle: 'short' })}`}
onClick={() => {
setCanceling(scheduled.id);
router.delete(
ScheduledCommitController.destroy(
scheduled.id,
),
{
preserveScroll: true,
onFinish: () =>
setCanceling(null),
},
);
}}
>
{canceling === scheduled.id
? 'Canceling…'
: 'Cancel'}
</Button>
<div className="flex shrink-0 gap-2">
<Button
variant="outline"
size="sm"
disabled={busy !== null}
aria-label={`Commit now instead of at ${when}`}
data-commit-now
onClick={() =>
commitNow(scheduled)
}
>
{busy?.id === scheduled.id &&
busy.action === 'commit'
? 'Committing…'
: 'Commit now'}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
disabled={busy !== null}
aria-label={`Cancel the commit scheduled for ${when}`}
onClick={() => {
setBusy({
id: scheduled.id,
action: 'cancel',
});
router.delete(
ScheduledCommitController.destroy(
scheduled.id,
),
{
preserveScroll: true,
onFinish:
() =>
setBusy(
null,
),
},
);
}}
className="self-center rounded-md p-1.5 text-muted-foreground transition hover:bg-destructive/10 hover:text-destructive disabled:pointer-events-none disabled:opacity-50"
>
<Trash2 className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>
Cancel
</TooltipContent>
</Tooltip>
</div>
)}
</div>
{scheduled.body && (
Expand Down Expand Up @@ -180,12 +307,20 @@ export default function Index({
: `${scheduled.image_count} images`}
</p>
)}
{scheduled.status === 'scheduled' && (
{scheduled.status === 'scheduled' ? (
<p className="text-xs text-muted-foreground">
{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.'}
</p>
) : (
sent.has(scheduled.id) && (
<p className="text-xs text-muted-foreground">
Submitted just now, ahead of its
scheduled time. It goes through the
usual review process.
</p>
)
)}
</li>
);
Expand Down
1 change: 1 addition & 0 deletions resources/js/types/global.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ declare module '@inertiajs/core' {
deleted?: DeletedFlash;
restored?: Commit;
scheduled?: boolean;
submitted?: string;
localSignIn?: boolean;
signedUp?: boolean;
};
Expand Down
3 changes: 3 additions & 0 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'])
Expand Down
Loading
Loading