diff --git a/README.md b/README.md index 7898104..06dc7c7 100644 --- a/README.md +++ b/README.md @@ -96,15 +96,16 @@ return [ #### Add the trait -Add the `HasDrafts` trait to your model +Add the `HasDrafts` trait and `Draftable` contact to your model ```php 'published_at', + /* + * Timestamp column that stores the date and time when the row is scheduled for publishing. + */ + 'will_publish_at' => 'will_publish_at', + /* * UUID column that stores the unique identifier of the model drafts. */ diff --git a/database/factories/PostFactory.php b/database/factories/PostFactory.php index 9ab4937..7619735 100644 --- a/database/factories/PostFactory.php +++ b/database/factories/PostFactory.php @@ -11,7 +11,7 @@ class PostFactory extends \Illuminate\Database\Eloquent\Factories\Factory /** * @inheritDoc */ - public function definition() + public function definition(): array { return [ 'title' => $this->faker->sentence, @@ -19,23 +19,19 @@ public function definition() ]; } - public function draft() + public function draft(): PostFactory { - return $this->state(function () { - return [ - 'published_at' => null, - 'is_published' => false, - ]; - }); + return $this->state(fn (): array => [ + 'published_at' => null, + 'is_published' => false, + ]); } - public function published() + public function published(): PostFactory { - return $this->state(function () { - return [ - 'published_at' => now()->toDateTimeString(), - 'is_published' => true, - ]; - }); + return $this->state(fn (): array => [ + 'published_at' => now()->toDateTimeString(), + 'is_published' => true, + ]); } } diff --git a/src/Commands/PublishScheduledDrafts.php b/src/Commands/PublishScheduledDrafts.php new file mode 100644 index 0000000..707823c --- /dev/null +++ b/src/Commands/PublishScheduledDrafts.php @@ -0,0 +1,36 @@ +argument('model'); + + if (! class_exists($class) || ! in_array(Draftable::class, class_implements($class), strict: true)) { + throw new InvalidArgumentException("The model `{$class}` either doesn't exist or doesn't implement `Draftable`."); + } + + $model = new $class(); + + $model::query() + ->onlyDrafts() + ->where($model->getWillPublishAtColumn(), '<', now()) + ->whereNull($model->getPublishedAtColumn()) + ->each(function (Draftable $record): void { + $record->setLive(); + $record->save(); + }); + + return Command::SUCCESS; + } +} diff --git a/src/Concerns/HasDrafts.php b/src/Concerns/HasDrafts.php index e99f0c6..81fe8de 100644 --- a/src/Concerns/HasDrafts.php +++ b/src/Concerns/HasDrafts.php @@ -2,6 +2,8 @@ namespace Oddvalue\LaravelDrafts\Concerns; +use Carbon\CarbonInterface; +use Closure; use Illuminate\Contracts\Database\Query\Builder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -11,13 +13,13 @@ use Illuminate\Database\Eloquent\Relations\MorphToMany; use Illuminate\Support\Arr; use Illuminate\Support\Str; -use JetBrains\PhpStorm\ArrayShape; +use Oddvalue\LaravelDrafts\Contacts\Draftable; use Oddvalue\LaravelDrafts\Facades\LaravelDrafts; /** - * @method void Current(Builder $query) - * @method void WithoutCurrent(Builder $query) - * @method void ExcludeRevision(Builder $query, int | Model $exclude) + * @method \Illuminate\Database\Eloquent\Builder current() + * @method \Illuminate\Database\Eloquent\Builder withoutCurrent() + * @method \Illuminate\Database\Eloquent\Builder excludeRevision(int | Model $exclude) */ trait HasDrafts { @@ -33,7 +35,7 @@ trait HasDrafts |-------------------------------------------------------------------------- */ - public function initializeHasDrafts() + public function initializeHasDrafts(): void { $this->mergeCasts([ $this->getIsCurrentColumn() => 'boolean', @@ -50,7 +52,7 @@ public static function bootHasDrafts(): void } }); - static::creating(function (Model $model): void { + static::creating(function (Draftable | Model $model): void { $model->{$model->getIsCurrentColumn()} = true; $model->setPublisher(); $model->generateUuid(); @@ -59,26 +61,26 @@ public static function bootHasDrafts(): void } }); - static::updating(function (Model $model): void { + static::updating(function (Draftable | Model $model): void { $model->newRevision(); }); - static::publishing(function (Model $model): void { + static::publishing(function (Draftable | Model $model): void { $model->setLive(); }); - static::deleted(function (Model $model): void { + static::deleted(function (Draftable | Model $model): void { $model->revisions()->delete(); }); if (method_exists(static::class, 'restored')) { - static::restored(function (Model $model): void { + static::restored(function (Draftable | Model $model): void { $model->revisions()->restore(); }); } if (method_exists(static::class, 'forceDeleted')) { - static::forceDeleted(function (Model $model): void { + static::forceDeleted(function (Draftable | Model $model): void { $model->revisions()->forceDelete(); }); } @@ -168,6 +170,7 @@ public function setLive(): void if (! $published || $this->is($published)) { $this->{$this->getPublishedAtColumn()} ??= now(); + $this->{$this->getWillPublishAtColumn()} = null; $this->{$this->getIsPublishedColumn()} = true; $this->setCurrent(); @@ -226,11 +229,27 @@ public function setLive(): void $this->{$this->getIsPublishedColumn()} = false; $this->{$this->getPublishedAtColumn()} = null; + $this->{$this->getWillPublishAtColumn()} = null; $this->{$this->getIsCurrentColumn()} = false; $this->timestamps = false; $this->shouldCreateRevision = false; } + public function schedulePublishing(CarbonInterface $date): static + { + $this->{$this->getWillPublishAtColumn()} = $date; + $this->save(); + + return $this; + } + + public function clearScheduledPublishing(): static + { + $this->{$this->getWillPublishAtColumn()} = null; + + return $this; + } + public function getDraftableRelations(): array { return property_exists($this, 'draftableRelations') ? $this->draftableRelations : []; @@ -287,12 +306,12 @@ public function save(array $options = []): bool return parent::save($options); } - public static function savingAsDraft(string|\Closure $callback): void + public static function savingAsDraft(string | Closure $callback): void { static::registerModelEvent('savingAsDraft', $callback); } - public static function savedAsDraft(string|\Closure $callback): void + public static function savedAsDraft(string | Closure $callback): void { static::registerModelEvent('drafted', $callback); } @@ -306,7 +325,7 @@ public function updateAsDraft(array $attributes = [], array $options = []): bool return $this->fill($attributes)->saveAsDraft($options); } - public static function createDraft(...$attributes): self + public static function createDraft(...$attributes): static { return tap(static::make(...$attributes), function ($instance) { $instance->{$instance->getIsPublishedColumn()} = false; @@ -324,7 +343,7 @@ public function setPublisher(): static return $this; } - public function pruneRevisions() + public function pruneRevisions(): void { self::withoutEvents(function () { $revisionsToKeep = $this->revisions() @@ -344,11 +363,11 @@ public function pruneRevisions() } /** - * Get the name of the "publisher" relation columns. - * - * @return array + * @return array{ + * id: string, + * type: string + * } */ - #[ArrayShape(['id' => "string", 'type' => "string"])] public function getPublisherColumns(): array { return [ @@ -362,9 +381,10 @@ public function getPublisherColumns(): array } /** - * Get the fully qualified "publisher" relation columns. - * - * @return array + * @return array{ + * id: string, + * type: string + * } */ public function getQualifiedPublisherColumns(): array { @@ -378,6 +398,13 @@ public function getIsCurrentColumn(): string : config('drafts.column_names.is_current', 'is_current'); } + public function getWillPublishAtColumn(): string + { + return defined(static::class.'::WILL_PUBLISH_AT') + ? static::WILL_PUBLISH_AT + : config('drafts.column_names.will_publish_at', 'will_publish_at'); + } + public function getUuidColumn(): string { return defined(static::class.'::UUID') diff --git a/src/Concerns/Publishes.php b/src/Concerns/Publishes.php index da0fd56..20268ba 100644 --- a/src/Concerns/Publishes.php +++ b/src/Concerns/Publishes.php @@ -2,31 +2,22 @@ namespace Oddvalue\LaravelDrafts\Concerns; +use Closure; use Illuminate\Database\Eloquent\Model; use Oddvalue\LaravelDrafts\Scopes\PublishingScope; /** - * @method static \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withPublished(bool $withPublished = true) - * @method static \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder onlyPublished() - * @method static \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withoutPublished() + * @method static \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withDrafts(bool $withDrafts = true) + * @method static \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder withoutDrafts() + * @method static \Illuminate\Database\Eloquent\Builder|\Illuminate\Database\Query\Builder onlyDrafts() */ trait Publishes { - /** - * Boot the publishes trait for a model. - * - * @return void - */ public static function bootPublishes(): void { static::addGlobalScope(new PublishingScope()); } - /** - * Initialize the publishes trait for an instance. - * - * @return void - */ public function initializePublishes(): void { $this->mergeCasts([ @@ -35,11 +26,6 @@ public function initializePublishes(): void ]); } - /** - * Publish a model instance. - * - * @return static - */ public function publish(): static { if ($this->fireModelEvent('publishing') === false) { @@ -65,43 +51,21 @@ protected function setPublishedAttributes(): void $this->{$this->getIsPublishedColumn()} = true; } - /** - * Determine if the model instance has been published. - * - * @return bool - */ public function isPublished(): bool { return $this->{$this->getIsPublishedColumn()} ?? false; } - /** - * Register a "published" model event callback with the dispatcher. - * - * @param string|\Closure $callback - * @return void - */ - public static function publishing(string|\Closure $callback): void + public static function publishing(string|Closure $callback): void { static::registerModelEvent('publishing', $callback); } - /** - * Register a "softDeleted" model event callback with the dispatcher. - * - * @param string|\Closure $callback - * @return void - */ - public static function published(string|\Closure $callback): void + public static function published(string|Closure $callback): void { static::registerModelEvent('published', $callback); } - /** - * Get the name of the "published at" column. - * - * @return string - */ public function getPublishedAtColumn(): string { return defined(static::class.'::PUBLISHED_AT') @@ -109,21 +73,11 @@ public function getPublishedAtColumn(): string : config('drafts.column_names.published_at', 'published_at'); } - /** - * Get the fully qualified "published at" column. - * - * @return string - */ public function getQualifiedPublishedAtColumn(): string { return $this->qualifyColumn($this->getPublishedAtColumn()); } - /** - * Get the name of the "published at" column. - * - * @return string - */ public function getIsPublishedColumn(): string { return defined(static::class.'::IS_PUBLISHED') @@ -131,11 +85,6 @@ public function getIsPublishedColumn(): string : config('drafts.column_names.is_published', 'is_published'); } - /** - * Get the fully qualified "published at" column. - * - * @return string - */ public function getQualifiedIsPublishedColumn(): string { return $this->qualifyColumn($this->getIsPublishedColumn()); diff --git a/src/Contacts/Draftable.php b/src/Contacts/Draftable.php new file mode 100644 index 0000000..1000359 --- /dev/null +++ b/src/Contacts/Draftable.php @@ -0,0 +1,79 @@ +user(); } diff --git a/src/LaravelDraftsServiceProvider.php b/src/LaravelDraftsServiceProvider.php index 0364fde..9cca336 100644 --- a/src/LaravelDraftsServiceProvider.php +++ b/src/LaravelDraftsServiceProvider.php @@ -5,6 +5,7 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Schema; +use Oddvalue\LaravelDrafts\Commands\PublishScheduledDrafts; use Oddvalue\LaravelDrafts\Http\Middleware\WithDraftsMiddleware; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -21,7 +22,8 @@ public function configurePackage(Package $package): void $package ->name('laravel-drafts') ->hasConfigFile() - ->hasViews(); + ->hasViews() + ->hasCommand(PublishScheduledDrafts::class); } public function packageRegistered() @@ -36,6 +38,7 @@ public function packageRegistered() string $isPublished = null, string $isCurrent = null, string $publisherMorphName = null, + string $willPublishAt = null, ) { /** @var Blueprint $this */ $uuid ??= config('drafts.column_names.uuid', 'uuid'); @@ -43,9 +46,11 @@ public function packageRegistered() $isPublished ??= config('drafts.column_names.is_published', 'is_published'); $isCurrent ??= config('drafts.column_names.is_current', 'is_current'); $publisherMorphName ??= config('drafts.column_names.publisher_morph_name', 'publisher_morph_name'); + $willPublishAt ??= config('drafts.column_names.will_publish_at', 'will_publish_at'); $this->uuid($uuid)->nullable(); $this->timestamp($publishedAt)->nullable(); + $this->timestamp($willPublishAt)->nullable(); $this->boolean($isPublished)->default(false); $this->boolean($isCurrent)->default(false); $this->nullableMorphs($publisherMorphName); diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php index a68c571..9db60c8 100644 --- a/tests/ConfigTest.php +++ b/tests/ConfigTest.php @@ -25,6 +25,7 @@ it('can override columns via class constants', function () { $post = new class () extends Post { public const PUBLISHED_AT = 'published_at_override'; + public const WILL_PUBLISH_AT = 'will_publish_at_overridee'; public const IS_PUBLISHED = 'is_published_override'; public const IS_CURRENT = 'is_current_override'; public const UUID = 'uuid_override'; @@ -34,6 +35,7 @@ expect($post->getPublishedAtColumn())->toBe($post::PUBLISHED_AT) ->and($post->getQualifiedPublishedAtColumn())->toBe($post->qualifyColumn($post::PUBLISHED_AT)) + ->and($post->getWillPublishAtColumn())->toBe($post::WILL_PUBLISH_AT) ->and($post->getIsPublishedColumn())->toBe($post::IS_PUBLISHED) ->and($post->getIsCurrentColumn())->toBe($post::IS_CURRENT) ->and($post->getUuidColumn())->toBe($post::UUID) diff --git a/tests/DraftsTest.php b/tests/DraftsTest.php index 6858c82..a76801e 100644 --- a/tests/DraftsTest.php +++ b/tests/DraftsTest.php @@ -5,7 +5,7 @@ use function Spatie\PestPluginTestTime\testTime; -it('creates drafts', function () { +it('creates drafts', function (): void { config(['drafts.revisions.keep' => 2]); testTime()->freeze(); $post = Post::factory()->published()->create(['title' => 'Foo']); @@ -32,7 +32,7 @@ ]); }); -it('can create drafts when revisions are disabled', function () { +it('can create drafts when revisions are disabled', function (): void { config(['drafts.revisions.keep' => 0]); $post = Post::factory()->create(['title' => 'Foo']); $this->assertDatabaseCount('posts', 1); @@ -44,7 +44,7 @@ $this->assertDatabaseCount('posts', 2); }); -it('can fetch the draft of a published record', function () { +it('can fetch the draft of a published record', function (): void { $post = Post::factory()->create(); $draft = Post::factory()->make(); $post->fresh()->updateAsDraft(['title' => $draft->title]); @@ -53,7 +53,7 @@ expect($post->draft->title)->toBe($draft->title); }); -it('can publish drafts', function () { +it('can publish drafts', function (): void { $post = Post::factory()->create(['title' => 'Foo']); $draft = Post::factory()->make(['title' => 'Bar']); @@ -68,12 +68,12 @@ expect($post->fresh()->title)->toBe($draft->title); }); -it('returns false when calling update on a record that has not been persisted', function () { +it('returns false when calling update on a record that has not been persisted', function (): void { $post = Post::factory()->make(); expect($post->updateAsDraft(['title' => 'Foo']))->toBeFalse(); }); -it('gets draft record from loaded revisions relation', function () { +it('gets draft record from loaded revisions relation', function (): void { $post = Post::factory()->create(['title' => 'Foo']); $draft = Post::factory()->make(['title' => 'Bar']); $post->fresh()->updateAsDraft(['title' => $draft->title]); @@ -82,7 +82,7 @@ expect($post->draft->title)->toBe($draft->title); }); -it('gets draft record from loaded draft relation', function () { +it('gets draft record from loaded draft relation', function (): void { $post = Post::factory()->create(['title' => 'Foo']); $draft = Post::factory()->make(['title' => 'Bar']); $post->fresh()->updateAsDraft(['title' => $draft->title]); @@ -91,7 +91,7 @@ expect($post->draft->title)->toBe($draft->title); }); -it('gets draft record when no relations loaded', function () { +it('gets draft record when no relations loaded', function (): void { $post = Post::factory()->create(['title' => 'Foo']); $draft = Post::factory()->make(['title' => 'Bar']); $post->fresh()->updateAsDraft(['title' => $draft->title]); @@ -99,7 +99,7 @@ expect($post->draft->title)->toBe($draft->title); }); -it('can create draft using default save method', function () { +it('can create draft using default save method', function (): void { $post = Post::factory()->create(['title' => 'Foo']); $draft = Post::factory()->make(['title' => 'Bar']); $post->refresh(); @@ -110,7 +110,7 @@ expect($post->draft->title)->toBe($draft->title); }); -it('creates drafts without altering the original post', function () { +it('creates drafts without altering the original post', function (): void { config(['drafts.revisions.keep' => 10]); $post = Post::factory()->create(['title' => 'Foo']); $originalId = $post->id; diff --git a/tests/RevisionsTest.php b/tests/RevisionsTest.php index 5f2b169..813bd58 100644 --- a/tests/RevisionsTest.php +++ b/tests/RevisionsTest.php @@ -3,6 +3,26 @@ use Oddvalue\LaravelDrafts\Tests\Post; use Oddvalue\LaravelDrafts\Tests\SoftDeletingPost; +it('can fetch revisions', function () { + $post = Post::factory() + ->hasRevisions(3) + ->create(); + + expect($post->revisions()->pluck('id')) + ->toHaveCount(4) + ->toContain($post->id); +}); + +it('can exclude a revision from the fetched revisions', function () { + $post = Post::factory() + ->hasRevisions(3) + ->create(); + + expect($post->revisions()->excludeRevision($post->id)->pluck('id')) + ->toHaveCount(3) + ->not->toContain($post); +}); + it('keeps the correct number of revisions', function () { config(['drafts.revisions.keep' => 3]); $revsExist = function (...$titles) { diff --git a/tests/SchedulingPost.php b/tests/SchedulingPost.php new file mode 100644 index 0000000..8c316cb --- /dev/null +++ b/tests/SchedulingPost.php @@ -0,0 +1,24 @@ +addMonth(); + $post = SchedulingPost::factory()->published()->create(); + $draft = $post->createDraft(['title' => 'Hello World']); + $draft->schedulePublishing($willPublishAt); + $this->assertDatabaseHas('posts', [ + 'title' => 'Hello World', + 'published_at' => null, + 'will_publish_at' => $willPublishAt, + ]); +}); + +it('can publish scheduled drafts', function () { + $willPublishAt = now()->addWeek(); + $post = SchedulingPost::factory()->published()->create(); + $draft = $post->createDraft(['title' => 'Hello World']); + $draft->schedulePublishing($willPublishAt); + + testTime()->addMonth()->freeze(); + + Artisan::call('drafts:publish', ['model' => SchedulingPost::class]); + + $this->assertDatabaseHas('posts', [ + 'title' => 'Hello World', + 'published_at' => now()->toDateTimeString(), + 'will_publish_at' => null, + ]); +}); + +it('fails when the model doesnt implement the contract', function (): void { + expect(static fn () => Artisan::call('drafts:publish', ['model' => Post::class])) + ->toThrow(InvalidArgumentException::class); +}); + +it('can clear the schedule date on a revision', function (): void { + $willPublishAt = now()->addWeek(); + $post = SchedulingPost::factory() + ->published() + ->has( + SchedulingPost::factory(), + 'revisions' + ) + ->create(); + $draft = $post->createDraft(['title' => 'Hello World']); + $draft->schedulePublishing($willPublishAt); + $draft->clearScheduledPublishing()->save(); + + testTime()->addMonth()->freeze(); + + Artisan::call('drafts:publish', ['model' => SchedulingPost::class]); + + $this->assertDatabaseHas('posts', [ + 'title' => 'Hello World', + 'published_at' => null, + 'will_publish_at' => null, + ]); +});