diff --git a/resources/js/components/entries/PublishActions.vue b/resources/js/components/entries/PublishActions.vue index 6fb7919f6cb..bf8a8f4cf37 100644 --- a/resources/js/components/entries/PublishActions.vue +++ b/resources/js/components/entries/PublishActions.vue @@ -2,70 +2,74 @@
- -
- {{ __('Publish') }} -
- -
- -
- -
- - + -
+
+ +
@@ -86,6 +90,12 @@ export default { data() { return { action: this.canManagePublishState ? 'publish' : 'revision', + config: { + earliest_date: this.now(), + latest_date: { date: null, time: null}, + time_enabled: true + }, + publishRevisionAt: this.now(), revisionMessage: null, saving: false, } @@ -98,6 +108,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') }); @@ -119,6 +130,8 @@ export default { return __('messages.publish_actions_unpublish'); case 'revision': return __('messages.publish_actions_create_revision'); + case 'publishLater': + return __('messages.publish_actions_schedule_revision'); } }, @@ -130,6 +143,13 @@ export default { methods: { + now() { + return { + date: moment().format('YYYY-MM-DD'), + time: moment().format('HH:mm') + }; + }, + submit() { this.saving = true; this.$emit('saving'); @@ -184,10 +204,6 @@ export default { }).catch(e => {}); }, - submitSchedule() { - // todo - }, - submitUnpublish() { const payload = { message: this.revisionMessage }; @@ -205,9 +221,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; + } + this.$axios.post(this.actions.createRevision, payload).then(response => { this.$toast.success(__('Revision created')); this.revisionMessage = null; diff --git a/resources/lang/en/messages.php b/resources/lang/en/messages.php index 69d3d54fe5e..f89565b22e9 100644 --- a/resources/lang/en/messages.php +++ b/resources/lang/en/messages.php @@ -191,6 +191,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 applied to the entry and it will be published immediately.', 'publish_actions_schedule' => 'Changes to the working copy will applied to the entry and it will be 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.', 'reset_password_notification_body' => 'You are receiving this email because we received a password reset request for your account.', 'reset_password_notification_no_action' => 'If you did not request a password reset, no further action is required.', diff --git a/src/Contracts/Revisions/Revision.php b/src/Contracts/Revisions/Revision.php index 12361213275..53e4988c4d9 100644 --- a/src/Contracts/Revisions/Revision.php +++ b/src/Contracts/Revisions/Revision.php @@ -2,8 +2,12 @@ namespace Statamic\Contracts\Revisions; +use Statamic\Entries\Entry; + interface Revision { + public function entry(): Entry; + public function id($id = null); public function message($message = null); diff --git a/src/Entries/MinuteRevisions.php b/src/Entries/MinuteRevisions.php new file mode 100644 index 00000000000..58aef89f03c --- /dev/null +++ b/src/Entries/MinuteRevisions.php @@ -0,0 +1,27 @@ +filter(fn (Entry $entry) => $entry->hasRevisions()) + ->filter(fn (Entry $entry) => $entry->latestRevision()->publishAt()) + ->filter( + fn (Entry $entry) => $entry + ->latestRevision() + ->publishAt() + ->isSameMinute($this->minute) + ); + } +} diff --git a/src/Http/Controllers/CP/Collections/EntryRevisionsController.php b/src/Http/Controllers/CP/Collections/EntryRevisionsController.php index c1ef85e20f1..236face8f0b 100644 --- a/src/Http/Controllers/CP/Collections/EntryRevisionsController.php +++ b/src/Http/Controllers/CP/Collections/EntryRevisionsController.php @@ -3,6 +3,7 @@ namespace Statamic\Http\Controllers\CP\Collections; use Illuminate\Http\Request; +use Illuminate\Support\Carbon; use Statamic\Facades\Site; use Statamic\Facades\User; use Statamic\Http\Controllers\CP\CpController; @@ -39,12 +40,16 @@ public function index(Request $request, $collection, $entry) public function store(Request $request, $collection, $entry) { - $entry->createRevision([ + $data = [ 'message' => $request->message, 'user' => User::fromUser($request->user()), - ]); + ]; + + if (! is_null($dateTime = $request->publish_at)) { + $data['publish_at'] = Carbon::parse($dateTime['date'].' '.$dateTime['time'] ?? '00:00'); + } - return new EntryResource($entry); + return new EntryResource($entry->createRevision($data)); } public function show(Request $request, $collection, $entry, $revision) diff --git a/src/Jobs/HandleRevisionSchedule.php b/src/Jobs/HandleRevisionSchedule.php new file mode 100644 index 00000000000..5ae2583cc1b --- /dev/null +++ b/src/Jobs/HandleRevisionSchedule.php @@ -0,0 +1,37 @@ +revisions() + ->each(fn (Revision $revision) => EntryScheduleReached::dispatch( + tap($revision->entry()->makeFromRevision($revision))->save() + )); + } + + private function revisions(): Collection + { + // We want to target the PREVIOUS minute because we can be sure that any entries that + // were scheduled for then would now be considered published. If we were targeting + // the current minute and the entry has defined a time with seconds later in the + // same minute, it may still be considered scheduled when it gets dispatched. + $minute = now()->subMinute(); + + return (new MinuteRevisions($minute))(); + } +} diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php index ada744379f5..69cd942a185 100644 --- a/src/Providers/AppServiceProvider.php +++ b/src/Providers/AppServiceProvider.php @@ -17,6 +17,7 @@ use Statamic\Facades\Token; use Statamic\Fields\FieldsetRecursionStack; use Statamic\Jobs\HandleEntrySchedule; +use Statamic\Jobs\HandleRevisionSchedule; use Statamic\Sites\Sites; use Statamic\Statamic; use Statamic\Tokens\Handlers\LivePreview; @@ -104,7 +105,10 @@ public function boot() $this->addAboutCommandInfo(); - $this->app->make(Schedule::class)->job(new HandleEntrySchedule)->everyMinute(); + tap($this->app->make(Schedule::class), function (Schedule $scheduler) { + $scheduler->job(new HandleEntrySchedule)->everyMinute(); + $scheduler->job(new HandleRevisionSchedule)->everyMinute(); + }); } public function register() diff --git a/src/Revisions/Revisable.php b/src/Revisions/Revisable.php index 2c1dfd0efc7..f2211eb6ddc 100644 --- a/src/Revisions/Revisable.php +++ b/src/Revisions/Revisable.php @@ -9,6 +9,11 @@ trait Revisable { + public function hasRevisions(): bool + { + return $this->revisions()->isNotEmpty(); + } + public function revision(string $reference) { return $this->revisions()->get($reference); @@ -162,14 +167,17 @@ public function store($options = []) return $return; } - public function createRevision($options = []) + public function createRevision($options = []): self { $this ->fromWorkingCopy() ->makeRevision() ->user($options['user'] ?? false) ->message($options['message'] ?? false) + ->publishAt($options['publish_at'] ?? null) ->save(); + + return $this; } public function revisionsEnabled() diff --git a/src/Revisions/Revision.php b/src/Revisions/Revision.php index 171c1ca52e0..340fe240d69 100644 --- a/src/Revisions/Revision.php +++ b/src/Revisions/Revision.php @@ -6,10 +6,12 @@ use Statamic\Contracts\Auth\User; use Statamic\Contracts\Revisions\Revision as Contract; use Statamic\Data\ExistsAsFile; +use Statamic\Entries\Entry; use Statamic\Events\RevisionDeleted; use Statamic\Events\RevisionSaved; use Statamic\Events\RevisionSaving; use Statamic\Facades; +use Statamic\Facades\Entry as EntryFacade; use Statamic\Facades\Revision as Revisions; use Statamic\Support\Traits\FluentlyGetsAndSets; @@ -18,14 +20,28 @@ class Revision implements Arrayable, Contract use ExistsAsFile, FluentlyGetsAndSets; protected $id; + protected $key; + protected $date; + protected $user; + protected $userId; + protected $message; + + protected $publishAt; + protected $action = 'revision'; + protected $attributes = []; + public function entry(): Entry + { + return EntryFacade::find($this->attribute('id')); + } + public function id($id = null) { return $this->fluentlyGetOrSet('id')->value($id); @@ -61,6 +77,11 @@ public function message($message = null) return $this->fluentlyGetOrSet('message')->value($message); } + public function publishAt($dateTime = null) + { + return $this->fluentlyGetOrSet('publishAt')->args(func_get_args()); + } + public function attributes($attributes = null) { return $this->fluentlyGetOrSet('attributes')->value($attributes); @@ -103,6 +124,7 @@ public function fileData() 'date' => $this->date->timestamp, 'user' => $this->userId ?: null, 'message' => $this->message ?: null, + 'publish_at' => $this->publishAt?->timestamp, 'attributes' => $this->attributes, ]; } @@ -125,6 +147,7 @@ public function toArray() 'date' => $this->date()->timestamp, 'user' => $user, 'message' => $this->message, + 'publish_at' => $this->publishAt, 'attributes' => $this->attributes, ]; } diff --git a/src/Revisions/RevisionRepository.php b/src/Revisions/RevisionRepository.php index 88641f3268e..3e4cde02bba 100644 --- a/src/Revisions/RevisionRepository.php +++ b/src/Revisions/RevisionRepository.php @@ -8,6 +8,7 @@ use Statamic\Facades\File; use Statamic\Facades\Folder; use Statamic\Facades\YAML; +use Statamic\Support\Arr; use Statamic\Support\FileCollection; use Statamic\Support\Str; @@ -67,7 +68,7 @@ protected function makeRevisionFromFile($key, $path) { $yaml = YAML::parse(File::get($path)); - return (new Revision) + $revision = (new Revision) ->key($key) ->action($yaml['action'] ?? false) ->id($date = $yaml['date']) @@ -75,5 +76,11 @@ protected function makeRevisionFromFile($key, $path) ->user($yaml['user'] ?? false) ->message($yaml['message'] ?? false) ->attributes($yaml['attributes']); + + if (! is_null($timestamp = Arr::get($yaml, 'publish_at'))) { + $revision->publishAt(Carbon::createFromTimestamp($timestamp)); + } + + return $revision; } } diff --git a/tests/Data/Entries/ScheduledRevisionsTest.php b/tests/Data/Entries/ScheduledRevisionsTest.php new file mode 100644 index 00000000000..db76a85aa92 --- /dev/null +++ b/tests/Data/Entries/ScheduledRevisionsTest.php @@ -0,0 +1,67 @@ +dir = __DIR__.'/tmp'; + config(['statamic.revisions.enabled' => true]); + config(['statamic.revisions.path' => $this->dir]); + } + + public function tearDown(): void + { + File::deleteDirectory($this->dir); + parent::tearDown(); + } + + #[Test] + public function it_gets_entries_scheduled_for_given_minute() + { + Carbon::setTestNow($now = now()->setSeconds(2)->toImmutable()); + + Collection::make('revisable')->revisionsEnabled(true)->save(); + + EntryFactory::id(1) + ->collection('revisable') + ->create() + ->makeRevision() + ->publishAt($now->addSeconds(5)) + ->save(); + + EntryFactory::id(2) + ->collection('revisable') + ->create() + ->makeRevision() + ->save(); + + EntryFactory::id(3) + ->collection('revisable') + ->create() + ->makeRevision() + ->publishAt($now->addSeconds(65)) + ->save(); + + $this->assertEquals([1], $this->getRevisionsForMinute($now)); + $this->assertEmpty($this->getRevisionsForMinute($now->addMinutes(2))); + } + + private function getRevisionsForMinute($minute) + { + return (new MinuteRevisions($minute))()->map->id()->all(); + } +} diff --git a/tests/Feature/Entries/EntryRevisionsTest.php b/tests/Feature/Entries/EntryRevisionsTest.php index d9d032ead25..6d76398d062 100644 --- a/tests/Feature/Entries/EntryRevisionsTest.php +++ b/tests/Feature/Entries/EntryRevisionsTest.php @@ -245,7 +245,7 @@ public function it_creates_a_revision() $this ->actingAs($user) - ->post($entry->createRevisionUrl(), ['message' => 'Test!']) + ->post($entry->createRevisionUrl(), ['message' => 'Test!', 'publish_at' => ['date' => '2010-12-29', 'time' => '11:00am']]) ->assertOk(); $entry = Entry::find($entry->id()); @@ -270,6 +270,7 @@ public function it_creates_a_revision() ], $revision->attributes()); $this->assertEquals('user-1', $revision->user()->id()); $this->assertEquals('Test!', $revision->message()); + $this->assertEquals(1293620400, $revision->publishAt()->timestamp); $this->assertEquals('revision', $revision->action()); $this->assertTrue($entry->hasWorkingCopy()); } diff --git a/tests/Feature/Revisions/RevisableTest.php b/tests/Feature/Revisions/RevisableTest.php new file mode 100644 index 00000000000..9d46d91cf11 --- /dev/null +++ b/tests/Feature/Revisions/RevisableTest.php @@ -0,0 +1,68 @@ + [ + 'enabled' => true, + 'path' => __DIR__.'/__fixtures__', + ]]); + + $this->revisable = new class + { + use Revisable; + + protected function revisionKey() + { + return '123'; + } + + protected function revisionAttributes() + { + return [ + 'id' => 123, + ]; + } + + public function makeFromRevision($revision) + { + return new self; + } + }; + } + + #[Test] + public function has_revisions() + { + $this->assertTrue($this->revisable->hasRevisions()); + } + + #[Test] + public function sets_publish_at_from_options() + { + Carbon::setTestNow($now = now()); + + $this->revisable->createRevision(['publish_at' => $now]); + + $revision = $this->revisable->latestRevision(); + Revision::delete($revision); + + $this->assertEquals($revision->publishAt()->timestamp, $now->timestamp); + } +} diff --git a/tests/Feature/Revisions/RevisionsTest.php b/tests/Feature/Revisions/RevisionsTest.php index 50b003faab8..31a3057059a 100644 --- a/tests/Feature/Revisions/RevisionsTest.php +++ b/tests/Feature/Revisions/RevisionsTest.php @@ -60,4 +60,56 @@ public function a_revision_can_be_made_from_a_dated_entry() $this->assertEquals(['id' => '123', 'published' => true, 'slug' => 'my-entry', 'data' => ['foo' => 'bar'], 'date' => '1482624000'], $revision->attributes()); } + + #[Test] + public function can_get_its_entry() + { + config(['statamic.revisions.path' => '/path/to']); + + Carbon::setTestNow($now = Carbon::parse('2019-03-25 13:15')); + + $entry = EntryFactory::id('123') + ->collection(tap(Collection::make('test')->dated(true))->save()) + ->slug('my-entry') + ->data(['foo' => 'bar']) + ->date('2016-12-25') + ->make(); + + $entry->save(); + $revision = $entry->makeRevision(); + + $this->assertEquals($entry->id(), $revision->entry()->id()); + } + + #[Test] + public function converts_publish_at_to_timestamp_when_saving() + { + Carbon::setTestNow(now()); + + $revision = (new Revision) + ->date(now()) + ->publishAt(now()); + + $this->assertEquals(now()->timestamp, $revision->fileData()['publish_at']); + } + + #[Test] + public function converts_publish_at_to_null_when_saving() + { + $revision = (new Revision)->date(now()); + + $this->assertNull($revision->fileData()['publish_at']); + } + + #[Test] + public function outputs_publish_at_when_to_array() + { + Carbon::setTestNow(now()); + + $revision = (new Revision) + ->date(now()) + ->publishAt(now()); + + $this->assertEquals(now(), $revision->toArray()['publish_at']); + } } diff --git a/tests/Feature/Revisions/__fixtures__/123/1553546421.yaml b/tests/Feature/Revisions/__fixtures__/123/1553546421.yaml new file mode 100644 index 00000000000..a19607e74a2 --- /dev/null +++ b/tests/Feature/Revisions/__fixtures__/123/1553546421.yaml @@ -0,0 +1,3 @@ +date: 1553546421 +attributes: + date: 1740528000 diff --git a/tests/Feature/Revisions/__fixtures__/123/1553546422.yaml b/tests/Feature/Revisions/__fixtures__/123/1553546422.yaml new file mode 100644 index 00000000000..e196e528ae6 --- /dev/null +++ b/tests/Feature/Revisions/__fixtures__/123/1553546422.yaml @@ -0,0 +1,4 @@ +date: 1553546422 +publish_at: 1553644800 +attributes: + date: 1740528000 diff --git a/tests/Feature/Revisions/__fixtures__/123/working.yaml b/tests/Feature/Revisions/__fixtures__/123/working.yaml new file mode 100644 index 00000000000..c7e919a4893 --- /dev/null +++ b/tests/Feature/Revisions/__fixtures__/123/working.yaml @@ -0,0 +1,3 @@ +date: 1553546423 +attributes: + id: 123 diff --git a/tests/Revisions/RepositoryTest.php b/tests/Revisions/RepositoryTest.php index aa5a408fb48..9fbc53aa8c5 100644 --- a/tests/Revisions/RepositoryTest.php +++ b/tests/Revisions/RepositoryTest.php @@ -36,4 +36,12 @@ public function it_can_call_to_array_on_a_revision_collection() $this->assertIsArray($revisions->toArray()); } + + #[Test] + public function it_loads_publish_at_as_carbon() + { + $revision = $this->repo->whereKey('123')->last(); + + $this->assertEquals(1553644800, $revision->publishAt()->timestamp); + } } diff --git a/tests/Revisions/__fixtures__/123/1553546422.yaml b/tests/Revisions/__fixtures__/123/1553546422.yaml index af3b65bb764..7a1669b18d1 100644 --- a/tests/Revisions/__fixtures__/123/1553546422.yaml +++ b/tests/Revisions/__fixtures__/123/1553546422.yaml @@ -1,2 +1,3 @@ date: 1553546422 +publish_at: 1553644800 attributes: {}