Skip to content
Closed
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
6 changes: 3 additions & 3 deletions KNOWLEDGE_BASE.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ Sources: [stash hook](resources/js/hooks/use-stash.ts), [image storage](resource

### Images and descriptions

Attach up to **three JPEG, PNG, or WebP images** to a commit or reply. Each image can be at most **10 MB** and **6,000 pixels in either dimension**. Add an image description of up to **1,000 characters** for accessibility. Images can be posted without accompanying text. Image attachments and a poll cannot be combined in the same commit. Open an attached photo to view it in the photo viewer and move through the commit's images.
Attach up to **three JPEG, PNG, or WebP images** to a commit or reply. Each image can be at most **10 MB** and **6,000 pixels in either dimension**. Add an image description of up to **1,000 characters** for accessibility. Images can be posted without accompanying text, unless the commit also has a poll, since the text is the poll's question. Open an attached photo to view it in the photo viewer and move through the commit's images.

Sources: [image picker](resources/js/components/commit-image-picker.tsx), [image limits](app/Actions/CreateCommitImages.php), [attachment validation](app/Http/Requests/CreateCommitRequest.php), [photo controller](app/Http/Controllers/CommitPhotoController.php).

Expand Down Expand Up @@ -170,15 +170,15 @@ Sources: [quote validation](app/Http/Requests/CreateCommitRequest.php), [quoted

### Polls and voting

Add a poll to a top-level commit with **two to four distinct options**, each up to **50 characters**, and a duration of **one hour, one day, three days, or seven days**. Polls cannot accompany images or be added to replies. Voting opens when the commit is approved, so time spent pending does not consume the voting period.
Add a poll to a top-level commit with **two to four distinct options**, each up to **50 characters**, and a duration of **one hour, one day, three days, or seven days**. A poll can go together with images, but it cannot be added to a reply. Voting opens when the commit is approved, so time spent pending does not consume the voting period.

Signed-in readers can choose one option and vote once while an approved, visible poll is open. The author cannot vote in their own poll. Results are visible to the author and people who have voted, and to everyone once the poll closes. Other people's individual votes are not revealed.

Sources: [poll editor](resources/js/components/poll-editor.tsx), [poll results](app/Models/Poll.php), [voting rules](app/Rules/OpenPoll.php), [approval and poll opening](app/Actions/UpdateCommitStatuses.php).

### Scheduled commits

Use the composer's scheduling control to submit a top-level commit later. Pick a future time within **30 days**; you can have at most **10 outstanding scheduled commits**. Scheduled posts can carry images or a poll under the normal attachment rules, but replies cannot be scheduled. The scheduled-commits page is private to you and lets you inspect or cancel queued posts.
Use the composer's scheduling control to submit a top-level commit later. Pick a future time within **30 days**; you can have at most **10 outstanding scheduled commits**. Scheduled posts can carry images, a poll, or both under the normal attachment rules, but replies cannot be scheduled. The scheduled-commits page is private to you and lets you inspect or cancel queued posts.

The scheduler checks due posts every minute. Scheduling submits the post for the normal publishing and moderation flow; it is not a promise that an approved public post will appear at the exact second selected. To change a queued post, cancel it and schedule a replacement.

Expand Down
14 changes: 12 additions & 2 deletions app/Http/Requests/CreateCommitRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ final class CreateCommitRequest extends FormRequest
*/
public const array BODY_WITH_IMAGES_RULES = ['nullable', 'string', 'max:'.Commit::MAX_BODY_LENGTH];

/**
* The body of a poll is its question, so a poll needs a full body even when images come with it.
*
* @return list<string>
*/
public static function bodyRules(FormRequest $request): array
{
return $request->hasFile('images') && ! $request->filled('poll') ? self::BODY_WITH_IMAGES_RULES : self::BODY_RULES;
}

/**
* Blank lines are collapsed before the body is measured, by every request that takes a body with BODY_RULES.
*/
Expand All @@ -48,7 +58,7 @@ public static function normalizeBody(FormRequest $request): void
public static function attachmentRules(): array
{
return [
'poll' => ['nullable', 'array:options,duration', 'prohibits:parent_id,images'],
'poll' => ['nullable', 'array:options,duration', 'prohibits:parent_id'],
'poll.options' => ['required_with:poll', 'list', 'min:'.NewPoll::MinOptions, 'max:'.NewPoll::MaxOptions],
'poll.options.*' => ['required', 'string', 'max:'.NewPoll::MaxOptionLength, 'distinct:ignore_case'],
'poll.duration' => ['required_with:poll', Rule::enum(PollDuration::class)],
Expand Down Expand Up @@ -81,7 +91,7 @@ public function rules(): array
$body = $this->input('body');

return [
'body' => $this->hasFile('images') ? self::BODY_WITH_IMAGES_RULES : self::BODY_RULES,
'body' => self::bodyRules($this),
'quoted_commit_id' => ['nullable', 'uuid', 'prohibits:parent_id', new VisibleCommit($user, 'That commit is no longer available to quote.')],
'link_preview_url' => ['nullable', 'string', Rule::in(is_string($body) ? resolve(Urls::class)->all($body) : [])],
...self::attachmentRules(),
Expand Down
2 changes: 1 addition & 1 deletion app/Http/Requests/CreateScheduledCommitRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ public function rules(): array
$body = $this->input('body');

return [
'body' => $this->hasFile('images') ? CreateCommitRequest::BODY_WITH_IMAGES_RULES : CreateCommitRequest::BODY_RULES,
'body' => CreateCommitRequest::bodyRules($this),
'scheduled_at' => ['required', 'date_format:Y-m-d\TH:i:sP', 'after:now', 'before_or_equal:'.now()->addDays(30)->toIso8601String(), new ScheduledCommitLimit($user)],
'parent_id' => ['prohibited'],
'link_preview_url' => ['nullable', 'string', Rule::in(is_string($body) ? resolve(Urls::class)->all($body) : [])],
Expand Down
30 changes: 10 additions & 20 deletions resources/js/components/commit-composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,9 @@ function CommitComposerBody({
setIsScheduling,
} = schedule;
const hasImages = attachments.images.length > 0;
const isFilled = hasImages || characterCount(body.trim()) >= minBodyLength;
const isFilled =
(hasImages && poll === null) ||
characterCount(body.trim()) >= minBodyLength;
const error = errors.body ?? errors.link_preview_url;
const scheduleError =
errors.scheduled_at ??
Expand All @@ -137,10 +139,7 @@ function CommitComposerBody({
const isIssue = issue.command !== null;

return (
<div
className="relative flex"
{...(poll === null ? attachments.dropzone : {})}
>
<div className="relative flex" {...attachments.dropzone}>
{attachments.isDraggingFiles && (
<ImageDropOverlay className="inset-2" />
)}
Expand Down Expand Up @@ -178,9 +177,7 @@ function CommitComposerBody({
rows={1}
value={body}
onValueChange={issue.change}
onPaste={
poll === null ? attachments.onPaste : undefined
}
onPaste={attachments.onPaste}
onKeyDown={(event) =>
preview.handleShortcut(event) ||
onKeyDown(event)
Expand Down Expand Up @@ -332,25 +329,17 @@ function CommitComposerBody({
<>
<ImagePickerButton
count={attachments.images.length}
disabled={processing || poll !== null}
disabled={processing}
onPick={attachments.attach}
className="-ml-2"
/>
<ActionTooltip
label={
hasImages
? 'A commit can have images or a poll, not both'
: 'Add poll'
}
>
<ActionTooltip label="Add poll">
<Button
type="button"
variant="ghost"
size="icon"
disabled={
processing ||
poll !== null ||
hasImages
processing || poll !== null
}
onClick={() => {
setPoll(emptyPollDraft);
Expand Down Expand Up @@ -502,7 +491,8 @@ export function CommitComposer({

const hasImages = attachments.images.length > 0;
const isCommittable =
(hasImages || characterCount(body.trim()) >= minBodyLength) &&
((hasImages && poll === null) ||
characterCount(body.trim()) >= minBodyLength) &&
(poll === null ||
poll.options.every((option) => option.trim().length > 0));

Expand Down
28 changes: 24 additions & 4 deletions tests/Feature/Controllers/CommitPollsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -165,21 +165,41 @@
expect($parent->replies()->count())->toBe(0);
});

it('may not attach a poll to a commit with images', function (): void {
it('may attach a poll and images to the same commit', function (): void {
Storage::fake();
$user = User::factory()->create();

$this->actingAs($user)
->fromRoute('log.show', Log::Following)
->post(route('commit.store'), [
'body' => 'Which desk setup?',
'images' => [UploadedFile::fake()->image('left.png', 10, 10), UploadedFile::fake()->image('right.png', 10, 10)],
'poll' => ['options' => ['Left', 'Right'], 'duration' => 1440],
])
->assertSessionHasNoErrors()
->assertRedirectToRoute('log.show', Log::Following);

$commit = $user->commits()->sole();

expect($commit->images)->toHaveCount(2)
->and($commit->poll->options->pluck('label')->all())->toBe(['Left', 'Right']);
});

it('requires a question for a poll with images', function (?string $body): void {
Storage::fake();

$this->actingAs(User::factory()->create())
->fromRoute('log.show', Log::Following)
->post(route('commit.store'), [
'body' => 'Tabs or spaces?',
'body' => $body,
'images' => [UploadedFile::fake()->image('desk.png', 10, 10)],
'poll' => ['options' => ['Tabs', 'Spaces'], 'duration' => 1440],
])
->assertSessionHasErrors('poll');
->assertSessionHasErrors('body');

expect(Commit::query()->count())->toBe(0)
->and(Storage::allFiles())->toBeEmpty();
});
})->with(['missing' => null, 'too short' => 'ab']);

it('highlights code in the question of a poll like in any commit', function (): void {
Poll::factory()->for(Commit::factory()->state(['body' => 'Do you still run `composer dump`?']))->withOptions('Yes', 'No')->create();
Expand Down
20 changes: 19 additions & 1 deletion tests/Feature/ScheduledCommitsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,24 @@
->and(Storage::disk()->allFiles())->toBe([]);
});

it('schedules a poll with images and attaches both at the due time', function (): void {
Storage::fake();
$user = User::factory()->create();
$this->actingAs($user)->post(route('scheduled-commit.store'), [
'body' => 'Which desk setup?',
'scheduled_at' => now()->addHour()->toIso8601String(),
'images' => [UploadedFile::fake()->image('left.png', 300, 200)],
'poll' => ['options' => ['Left', 'Right'], 'duration' => 1440],
])->assertSessionHasNoErrors();

$this->travel(1)->hour();
$this->artisan('commits:submit-scheduled')->assertSuccessful();

$commit = Commit::query()->sole();
expect($commit->images)->toHaveCount(1)
->and($commit->poll->options()->orderBy('position')->pluck('label')->all())->toBe(['Left', 'Right']);
});

it('schedules images without a body', function (): void {
Storage::fake();
$user = User::factory()->create();
Expand Down Expand Up @@ -174,7 +192,7 @@
'whitespace' => [['body' => ' '], 'body'],
'too long' => [['body' => str_repeat('a', 4001)], 'body'],
'poll with one option' => [['poll' => ['options' => ['Tabs'], 'duration' => 1440]], 'poll.options'],
'poll with images' => [['poll' => ['options' => ['Tabs', 'Spaces'], 'duration' => 1440], 'images' => [UploadedFile::fake()->image('a.png')]], 'poll'],
'poll with images and no question' => [['body' => null, 'poll' => ['options' => ['Tabs', 'Spaces'], 'duration' => 1440], 'images' => [UploadedFile::fake()->image('a.png')]], 'body'],
'too many images' => [['images' => array_map(fn (int $i): UploadedFile => UploadedFile::fake()->image($i.'.png'), range(1, 4))], 'images'],
'link card not in the body' => [['link_preview_url' => 'https://example.com/elsewhere'], 'link_preview_url'],
'svg image' => [['images' => [UploadedFile::fake()->create('a.svg', 1, 'image/svg+xml')]], 'images.0'],
Expand Down
52 changes: 47 additions & 5 deletions tests/Unit/ComposerDraft.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ test('preserves restored images and link cards and allows scheduling them', () =
fields.find(
(element) => element.props['data-test'] === 'add-poll-button',
)!.props.disabled,
true,
false,
);
assert.equal(
fields.filter((element) => element.type === LinkCardPicker).length,
Expand All @@ -172,7 +172,7 @@ test('preserves restored images and link cards and allows scheduling them', () =
}
});

test('removing restored images enables polls and successful posting resets the poll', () => {
test('a poll keeps images, paste and drop available and successful posting resets the poll', () => {
draft.images = [image];
draft.descriptions = ['A restored screenshot'];
let view = render();
Expand Down Expand Up @@ -202,7 +202,7 @@ test('removing restored images enables polls and successful posting resets the p
assert.equal(
view.fields.find((element) => element.type === ImagePickerButton)!
.props.disabled,
true,
false,
);
assert.equal(
view.fields.find(
Expand All @@ -213,12 +213,12 @@ test('removing restored images enables polls and successful posting resets the p
const textarea = view.fields.find(
(element) => element.type === MentionTextarea,
)!;
assert.equal(textarea.props.onPaste, undefined);
assert.notEqual(textarea.props.onPaste, undefined);
assert.equal(
view.fields.some(
(element) => element.props.onDragEnter !== undefined,
),
false,
true,
);

(editor.props.onChange as (poll: unknown) => void)({
Expand Down Expand Up @@ -259,6 +259,48 @@ test('removing restored images enables polls and successful posting resets the p
}
});

test('a poll with images still needs a question', () => {
draft.images = [image];
draft.body = '';
let view = render();
const submit = () =>
view.fields.find(
(element) => element.props['data-test'] === 'create-commit-button',
)!;
assert.equal(submit().props.disabled, false);

vi.stubGlobal('requestAnimationFrame', vi.fn());
try {
(
view.fields.find(
(element) => element.props['data-test'] === 'add-poll-button',
)!.props.onClick as () => void
)();
view = render();
(
view.fields.find((element) => element.type === PollEditor)!.props
.onChange as (poll: unknown) => void
)({ options: ['Left', 'Right'], duration: 1440 });
view = render();
assert.equal(submit().props.disabled, true);

draft.body = 'Which desk setup?';
view = render();
assert.equal(submit().props.disabled, false);
const data = {
body: draft.body,
poll: { options: ['Left', 'Right'], duration: 1440 },
};
assert.deepEqual(view.form.props.transform(data), {
...data,
images: [image],
image_descriptions: [],
});
} finally {
vi.unstubAllGlobals();
}
});

test('dropping images anywhere on the composer attaches them while dragging text is left alone', () => {
const dragged = (types: string[], files: File[] = []) => ({
dataTransfer: { types, files, dropEffect: 'none' },
Expand Down