Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
b281789
wip
edalzell Feb 26, 2025
c0488d8
tidy
edalzell Feb 26, 2025
f837723
Add info text
edalzell Feb 26, 2025
f50f330
save `publish_at` in the revision
edalzell Feb 27, 2025
bc3b5da
fix publish_at
edalzell Feb 27, 2025
aeec914
hasRevisions
edalzell Feb 27, 2025
5837d5e
get all the revisions that are ready to be published
edalzell Feb 27, 2025
9f8dccd
not used
edalzell Feb 27, 2025
9a0bfa2
lint
edalzell Feb 27, 2025
ed1fcf9
fluent
edalzell Feb 27, 2025
05546b1
needs to be carbon
edalzell Feb 27, 2025
d6b7dc4
wip
edalzell Feb 28, 2025
b039f5c
is this better?
edalzell Feb 28, 2025
630281a
tests
edalzell Feb 28, 2025
55835fb
gettin’ there
edalzell Feb 28, 2025
ba71eb5
add test
edalzell Feb 28, 2025
f70b8cb
more tests
edalzell Feb 28, 2025
36b4df6
tidy
edalzell Feb 28, 2025
18b3477
Merge branch '5.x' into feature/scheduled-revisions
edalzell Sep 3, 2025
159e64d
Merge branch '6.x' into feature/scheduled-revisions
edalzell Mar 17, 2026
35869c2
use v6 version
edalzell Mar 17, 2026
6fb7cc8
v6 version
edalzell Mar 17, 2026
2483430
whoops
edalzell Mar 17, 2026
5cf91b6
it’s elsewhere now
edalzell Mar 17, 2026
f8a1ae8
it’s in the store now
edalzell Mar 17, 2026
3cb8951
fix merge
edalzell Mar 17, 2026
cd4288d
Update Revision.php
edalzell Mar 17, 2026
e08459a
Update Revision.php
edalzell Mar 17, 2026
766ce1c
Update RevisionRepository.php
edalzell Mar 17, 2026
98b8ff8
fix tests
edalzell Mar 17, 2026
3a6e035
maybe this?
edalzell Mar 17, 2026
a8d599d
proper date
edalzell Mar 17, 2026
6450b67
fix publish revision date
edalzell Mar 18, 2026
4dd897a
leave this, for now
edalzell Mar 18, 2026
0ecf8de
fix test
edalzell Mar 18, 2026
aa630ed
Merge branch '6.x' into feature/scheduled-revisions
edalzell Mar 25, 2026
e624fbb
Merge branch '6.x' into feature/scheduled-revisions
edalzell May 6, 2026
50e7ef2
smaller diff
edalzell May 6, 2026
317fb13
Merge branch '6.x' into feature/scheduled-revisions
edalzell Jun 9, 2026
2a0a5e0
Merge remote-tracking branch 'upstream/6.x' into feature/scheduled-re…
edalzell Sep 2, 2026
f61ff17
Validate publish_at when creating a revision
edalzell Sep 2, 2026
73a1a5c
Remove BC-breaking contract and signature changes
edalzell Sep 2, 2026
64b280e
Output publish_at as a timestamp in toArray
edalzell Sep 2, 2026
545c9c1
Rework scheduled revision publishing
edalzell Sep 2, 2026
659a836
Remove unused revision fixtures
edalzell Sep 2, 2026
a460037
Show scheduled revisions in the CP and require a date before scheduling
edalzell Sep 2, 2026
4d7bb2c
Merge branch '6.x' into feature/scheduled-revisions
edalzell Sep 15, 2026
bbaef36
Drop redundant event dispatch from the revision schedule job
edalzell Oct 26, 2026
057b48f
Remove tests already covered elsewhere
edalzell Oct 26, 2026
f75b612
Merge remote-tracking branch 'upstream/6.x' into feature/scheduled-re…
edalzell Oct 26, 2026
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
1 change: 1 addition & 0 deletions lang/en/messages.php
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@
'publish_actions_current_becomes_draft_because_scheduled' => 'Since the current revision is published and you\'ve selected a date in the future, once you submit, the revision will act like a draft until the selected date.',
'publish_actions_publish' => 'Changes to the working copy will be applied to the entry and it will be published immediately.',
'publish_actions_schedule' => 'Changes to the working copy will be applied to the entry and it will appear published on the selected date.',
'publish_actions_schedule_revision' => 'A revision will be created based off the working copy and published on the selected date.',
'publish_actions_unpublish' => 'The current revision will be unpublished.',
'relationship_item_unavailable' => 'This item is unavailable. It may have been deleted, or you may not have permission to view it.',
'reset_password_notification_body' => 'You are receiving this email because we received a password reset request for your account.',
Expand Down
29 changes: 23 additions & 6 deletions resources/js/components/entries/PublishActions.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
<Select class="w-full" :options v-model="action" />

<template v-if="action">
<!-- <DatePicker-->
<!-- v-if="action == 'schedule'"-->
<!-- v-model="publishTime"-->
<!-- />-->
<DatePicker
v-if="action == 'publishLater'"
granularity="minute"
v-model="publishRevisionAt"
/>

<Textarea
class="text-sm"
Expand All @@ -23,7 +24,7 @@
:focus="true"
/>

<Button variant="primary" :text="submitButtonText" @click="submit" />
<Button variant="primary" :text="submitButtonText" :disabled="!canSubmit" @click="submit" />

<div class="flex">
<Icon name="info" class="size-4 shrink-0 me-2" />
Expand Down Expand Up @@ -61,6 +62,7 @@ export default {
data() {
return {
action: this.canManagePublishState ? 'publish' : 'revision',
publishRevisionAt: null,
revisionMessage: null,
saving: false,
};
Expand All @@ -72,6 +74,7 @@ export default {

if (this.canManagePublishState) {
options.push({ value: 'publish', label: __('Publish Now') });
options.push({ value: 'publishLater', label: __('Publish Later') });

if (this.published) {
options.push({ value: 'unpublish', label: __('Unpublish') });
Expand All @@ -95,12 +98,18 @@ export default {
return __('messages.publish_actions_unpublish');
case 'revision':
return __('messages.publish_actions_create_revision');
case 'publishLater':
return __('messages.publish_actions_schedule_revision');
}
},

submitButtonText() {
return this.options.find((o) => o.value === this.action).label;
},

canSubmit() {
return this.action !== 'publishLater' || !!this.publishRevisionAt;
},
},

methods: {
Expand Down Expand Up @@ -188,9 +197,17 @@ export default {
.catch((e) => this.handleAxiosError(e));
},

submitRevision() {
submitPublishLater() {
this.submitRevision(this.publishRevisionAt);
},

submitRevision(publishRevisionAt) {
const payload = { message: this.revisionMessage };

if (publishRevisionAt) {
payload.publish_at = publishRevisionAt.toAbsoluteString();
}

this.$axios
.post(this.actions.createRevision, payload)
.then((response) => {
Expand Down
57 changes: 34 additions & 23 deletions resources/js/components/revision-history/Revision.vue
Original file line number Diff line number Diff line change
Expand Up @@ -21,32 +21,15 @@
by {{ revision.user.name || revision.user.email }}
</template>
</Subheading>
<Subheading
v-if="revision.publish_at"
class="text-xs text-gray-500! dark:text-gray-400!"
v-text="__('Scheduled to publish :date', { date: publishAt })"
/>
</div>

<div class="flex items-center gap-1 ml-auto">
<Badge
size="sm"
:color="
revision.action === 'working'
? 'gray'
: {
publish: 'green',
revision: 'gray',
restore: 'gray',
unpublish: 'red',
}[revision.action]
"
:text="
revision.action === 'working'
? __('Working Copy')
: {
publish: __('Published'),
revision: __('Revision'),
restore: __('Restored'),
unpublish: __('Unpublished'),
}[revision.action]
"
/>
<Badge size="sm" :color="badgeColor" :text="badgeText" />
</div>

<revision-preview
Expand Down Expand Up @@ -120,6 +103,34 @@ export default {
},

computed: {
badgeColor() {
if (this.revision.action === 'working') return 'gray';
if (this.revision.publish_at) return 'amber';

return {
publish: 'green',
revision: 'gray',
restore: 'gray',
unpublish: 'red',
}[this.revision.action];
},

badgeText() {
if (this.revision.action === 'working') return __('Working Copy');
if (this.revision.publish_at) return __('Scheduled');

return {
publish: __('Published'),
revision: __('Revision'),
restore: __('Restored'),
unpublish: __('Unpublished'),
}[this.revision.action];
},

publishAt() {
return DateFormatter.format(this.revision.publish_at * 1000, 'datetime');
},

time() {
return DateFormatter.format(this.revision.date * 1000, 'time');
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,12 @@ public function store(Request $request, $collection, $entry)
{
$this->authorize('edit', $entry);

$request->validate(['publish_at' => 'nullable|date|after:now']);

$entry->createRevision([
'message' => $request->message,
'user' => User::fromUser($request->user()),
'publish_at' => $request->date('publish_at'),
]);

return new EntryResource($entry);
Expand Down
33 changes: 33 additions & 0 deletions src/Jobs/HandleRevisionSchedule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

namespace Statamic\Jobs;

use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Support\Collection;
use Statamic\Contracts\Revisions\Revision;
use Statamic\Facades\Entry as Entries;
use Statamic\Facades\Revision as Revisions;

class HandleRevisionSchedule implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable;

public function handle()
{
$this
->dueRevisions()
->each(fn (Revision $revision) => Entries::find($revision->attribute('id'))?->publishRevision($revision));
}

private function dueRevisions(): Collection
{
return Revisions::query()
->whereNotNull('publish_at')
->where('publish_at', '<=', now())
->get()
->sortBy->publishAt();
}
}
5 changes: 5 additions & 0 deletions src/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use Statamic\Http\Middleware\PingOutpost;
use Statamic\Icons\IconManager;
use Statamic\Jobs\HandleEntrySchedule;
use Statamic\Jobs\HandleRevisionSchedule;
use Statamic\Licensing\Radio;
use Statamic\Notifications\ElevatedSessionVerificationCode;
use Statamic\Sites\Sites;
Expand Down Expand Up @@ -152,6 +153,10 @@ public function boot()

if (config('statamic.system.handle_scheduled_entries')) {
$this->app->make(Schedule::class)->job(HandleEntrySchedule::class)->everyMinute();

if (config('statamic.revisions.enabled')) {
$this->app->make(Schedule::class)->job(HandleRevisionSchedule::class)->everyMinute();
}
}

$this->app->make(Schedule::class)
Expand Down
30 changes: 30 additions & 0 deletions src/Revisions/Revisable.php
Original file line number Diff line number Diff line change
Expand Up @@ -178,9 +178,39 @@ public function createRevision($options = [])
->makeRevision()
->user($options['user'] ?? null)
->message($options['message'] ?? null)
->publishAt($options['publish_at'] ?? null)
->save();
}

public function publishRevision($revision)
{
$item = $this->makeFromRevision($revision);

$saved = $item
->published(true)
->updateLastModified($user = $revision->user())
->save();

if (! $saved) {
return false;
}

$item
->makeRevision()
->user($user)
->message($revision->message())
->action('publish')
->save();

$revision->publishAt(null)->save();

if ($item->workingCopy()?->attributes() == $revision->attributes()) {
$item->deleteWorkingCopy();
}

return $item;
}

public function revisionsEnabled()
{
return config('statamic.revisions.enabled') && Statamic::pro();
Expand Down
9 changes: 9 additions & 0 deletions src/Revisions/Revision.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class Revision implements Arrayable, ContainsQueryableValues, Contract
protected $user;
protected $userId;
protected $message;
protected $publishAt;
protected $action = 'revision';
protected $attributes = [];
protected $withEvents = true;
Expand Down Expand Up @@ -67,6 +68,11 @@ public function message($message = null)
return $this->fluentlyGetOrSet('message')->args(func_get_args());
}

public function publishAt($dateTime = null)
{
return $this->fluentlyGetOrSet('publishAt')->args(func_get_args());
}

public function attributes($attributes = null)
{
return $this->fluentlyGetOrSet('attributes')->args(func_get_args());
Expand Down Expand Up @@ -109,6 +115,7 @@ public function fileData()
'date' => $this->date->timestamp,
'user' => $this->userId ?: null,
'message' => $this->message ?: null,
'publish_at' => $this->publishAt?->timestamp,
'attributes' => $this->attributes,
];
}
Expand All @@ -131,6 +138,7 @@ public function toArray()
'date' => $this->date()->timestamp,
'user' => $user,
'message' => $this->message,
'publish_at' => $this->publishAt?->timestamp,
'attributes' => $this->attributes,
];
}
Expand Down Expand Up @@ -192,6 +200,7 @@ public function getQueryableValue(string $field)
'date' => $this->date(),
'user' => $this->userId,
'message' => $this->message(),
'publish_at' => $this->publishAt(),
'path' => $this->path(),
default => throw new \Exception('Field ['.$field.'] is not queryable on Revision.'),
};
Expand Down
1 change: 1 addition & 0 deletions src/Stache/Stores/RevisionsStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public function makeItemFromFile($path, $contents)
->date(($date = $yaml['date'] ?? null) ? Carbon::createFromTimestamp($date, config('app.timezone')) : null)
->user($yaml['user'] ?? false)
->message($yaml['message'] ?? null)
->publishAt(($publishAt = $yaml['publish_at'] ?? null) ? Carbon::createFromTimestamp($publishAt, config('app.timezone')) : null)
->attributes($yaml['attributes'] ?? []);
}
}
36 changes: 35 additions & 1 deletion tests/Feature/Entries/EntryRevisionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -417,9 +417,10 @@ public function it_creates_a_revision()
$this->assertFalse($entry->published());
$this->assertCount(0, $entry->revisions());

$publishAt = now()->addDay()->startOfMinute();
$this
->actingAs($user)
->post($entry->createRevisionUrl(), ['message' => 'Test!'])
->post($entry->createRevisionUrl(), ['message' => 'Test!', 'publish_at' => $publishAt->toIso8601String()])
->assertOk();

$entry = Entry::find($entry->id());
Expand All @@ -444,10 +445,43 @@ public function it_creates_a_revision()
], $revision->attributes());
$this->assertEquals('user-1', $revision->user()->id());
$this->assertEquals('Test!', $revision->message());
$this->assertEquals($publishAt->timestamp, $revision->publishAt()->timestamp);
$this->assertEquals('revision', $revision->action());
$this->assertTrue($entry->hasWorkingCopy());
}

#[Test]
#[DataProvider('invalidPublishAtProvider')]
public function it_validates_publish_at_when_creating_a_revision($publishAt)
{
$this->setTestBlueprint('test', ['foo' => ['type' => 'text']]);
$this->setTestRoles(['test' => ['access cp', 'edit blog entries']]);
$user = User::make()->id('user-1')->assignRole('test')->save();

$entry = EntryFactory::id('1')
->slug('test')
->collection('blog')
->data(['blueprint' => 'test', 'title' => 'Title'])
->create();

$this
->actingAs($user)
->postJson($entry->createRevisionUrl(), ['publish_at' => $publishAt])
->assertUnprocessable()
->assertJsonValidationErrors('publish_at');

$this->assertCount(0, $entry->revisions());
}

public static function invalidPublishAtProvider()
{
return [
'not a date' => ['nope'],
'array' => [['2030-01-01']],
'in the past' => ['2010-12-29T11:00:00Z'],
];
}

#[Test]
public function it_denies_restoring_a_revision_without_permission_to_edit_entry()
{
Expand Down
Loading
Loading