From 6238526c993df4724f9aa3d1fc8b149582bb4d23 Mon Sep 17 00:00:00 2001 From: celikerde Date: Sat, 28 Mar 2026 21:37:19 +0300 Subject: [PATCH 01/16] feat(Revision): add source_revision_id to fillable fields and implement isDraft method --- src/Entities/Revision.php | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/Entities/Revision.php b/src/Entities/Revision.php index 87a501d04..c9cfe04ef 100755 --- a/src/Entities/Revision.php +++ b/src/Entities/Revision.php @@ -3,6 +3,7 @@ namespace Unusualify\Modularity\Entities; use Illuminate\Database\Eloquent\Model as BaseModel; +use Illuminate\Support\Str; abstract class Revision extends BaseModel { @@ -13,15 +14,16 @@ abstract class Revision extends BaseModel protected $fillable = [ 'payload', 'user_id', + 'source_revision_id', ]; public function __construct(array $attributes = []) { parent::__construct($attributes); - // Remember to update this if you had fields to the fillable array here + // Remember to update this if you add fields to the fillable array here // this is to allow child classes to provide a custom foreign key in fillable - if (count($this->fillable) == 2) { + if (count($this->fillable) == 3) { $this->fillable[] = mb_strtolower(str_replace('Revision', '', get_called_class())) . '_id'; } } @@ -35,4 +37,13 @@ public function getByUserAttribute() { return isset($this->user) ? $this->user->name : 'System'; } + + public function isDraft(): bool + { + $data = json_decode($this->payload, true); + + $cmsSaveType = $data['cmsSaveType'] ?? ''; + + return Str::startsWith($cmsSaveType, 'draft-revision'); + } } From 5ef457542f28dc2c9a99c33385fed18146047b8b Mon Sep 17 00:00:00 2001 From: celikerde Date: Sat, 28 Mar 2026 21:38:04 +0300 Subject: [PATCH 02/16] feat(HasRevisions): implement revision management trait for entities --- src/Entities/Traits/HasRevisions.php | 105 +++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100755 src/Entities/Traits/HasRevisions.php diff --git a/src/Entities/Traits/HasRevisions.php b/src/Entities/Traits/HasRevisions.php new file mode 100755 index 000000000..1258561cf --- /dev/null +++ b/src/Entities/Traits/HasRevisions.php @@ -0,0 +1,105 @@ +hasMany($this->getRevisionModel())->orderBy('created_at', 'desc'); + } + + /** + * Scope a query to only include the current user's revisions. + * + * @param \Illuminate\Database\Eloquent\Builder $query + * @return \Illuminate\Database\Eloquent\Builder + */ + public function scopeMine($query) + { + $user = Auth::guard(Modularity::getAuthGuardName())->user(); + + if (! $user) { + return $query->whereRaw('1 = 0'); + } + + return $query->whereHas('revisions', function ($query) { + $query->where('user_id', Auth::guard(Modularity::getAuthGuardName())->id()); + }); + } + + /** + * Returns an array of revisions for the CMS views. + * + * @return array + */ + public function revisionsArray() + { + $revisions = $this->revisions; // ordered DESC (newest first) + $total = $revisions->count(); + + $versionMap = $revisions->mapWithKeys(function ($revision, $index) use ($total) { + return [$revision->id => $total - $index]; + }); + + return $revisions + ->map(function ($revision, $index) use ($total, $versionMap) { + $sourceLabel = $revision->source_revision_id && isset($versionMap[$revision->source_revision_id]) + ? 'V' . $versionMap[$revision->source_revision_id] + : null; + + return [ + 'id' => $revision->id, + 'author' => $revision->user->name ?? 'Unknown', + 'datetime' => $revision->created_at->toIso8601String(), + 'label' => 'V' . ($total - $index), + 'source_label' => $sourceLabel, + ]; + }) + ->toArray(); + } + + /** + * Deletes revisions from specific collection position + * Used to keep max revision on specific Twill's module. + */ + public function deleteSpecificRevisions(int $maxRevisions): void + { + if (isset($this->limitRevisions) && $this->limitRevisions > 0) { + $maxRevisions = $this->limitRevisions; + } + + $this->revisions()->get()->slice($maxRevisions)->each->delete(); + } + + + protected function getRevisionModel() + { + if (property_exists($this, 'revisionModel') && is_string($this->revisionModel) && @class_exists($this->revisionModel)) { + return $this->revisionModel; + } + + $modelClass = get_class($this); + $candidates = [ + preg_replace('/\\\\Entities\\\\([^\\\\]+)$/', '\\Entities\\Revisions\\$1Revision', $modelClass), + modularityConfig('namespace') . "\\Models\\Revisions\\" . class_basename($this) . 'Revision', + ]; + + foreach ($candidates as $candidate) { + if (is_string($candidate) && @class_exists($candidate)) { + return $candidate; + } + } + + throw new RuntimeException("Revision model could not be resolved for [{$modelClass}]. Define a \$revisionModel property."); + } +} From 2634c48b4f3bcf4bc868258bb1e2c5b7dde35efa Mon Sep 17 00:00:00 2001 From: celikerde Date: Sat, 28 Mar 2026 21:38:25 +0300 Subject: [PATCH 03/16] feat(RevisionsTrait): add trait for managing revisions with creation, restoration, and preview functionalities --- src/Repositories/Traits/RevisionsTrait.php | 116 +++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 src/Repositories/Traits/RevisionsTrait.php diff --git a/src/Repositories/Traits/RevisionsTrait.php b/src/Repositories/Traits/RevisionsTrait.php new file mode 100644 index 000000000..5dd88d530 --- /dev/null +++ b/src/Repositories/Traits/RevisionsTrait.php @@ -0,0 +1,116 @@ +createRevisionIfNeeded($object, $fields); + } + + public function createRevisionIfNeeded($object, array $fields): array + { + if ($this->skipRevisionCreation) { + return $fields; + } + + $lastRevision = $object->revisions()->latest('id')->first(); + $lastRevisionPayload = json_decode($lastRevision->payload ?? '{}', true) ?: []; + + $fullPayload = array_replace_recursive($lastRevisionPayload, $fields); + + if ($fullPayload !== $lastRevisionPayload) { + $userId = Auth::guard(Modularity::getAuthGuardName())->id() ?? Auth::id(); + + $object->revisions()->create([ + 'payload' => json_encode($fullPayload), + 'user_id' => $userId, + 'source_revision_id' => $this->pendingSourceRevisionId, + ]); + } + + if (isset($object->limitRevisions) && (int) $object->limitRevisions > 0) { + $object->deleteSpecificRevisions((int) $object->limitRevisions); + } + + return $fields; + } + + public function preview(int $id, array $fields) + { + $object = $this->model->findOrFail($id); + + return $this->hydrateObject($object, $fields); + } + + public function previewForRevision(int $id, int $revisionId) + { + $object = $this->model->findOrFail($id); + $revision = $object->revisions()->where('id', $revisionId)->firstOrFail(); + $fields = json_decode($revision->payload, true) ?: []; + + return $this->hydrateObject($this->model->newInstance()->setAttribute('id', $id), $fields); + } + + public function restoreRevision(int $id, int $revisionId) + { + $object = $this->model->findOrFail($id); + $revision = $object->revisions()->where('id', $revisionId)->firstOrFail(); + $fields = json_decode($revision->payload, true) ?: []; + + // Skip auto-revision creation during update so we can force-create one below, + // ensuring a restore is always recorded even when content is identical to the latest revision. + $this->skipRevisionCreation = true; + $this->update($id, $fields); + $this->skipRevisionCreation = false; + + $userId = Auth::guard(Modularity::getAuthGuardName())->id() ?? Auth::id(); + $object->revisions()->create([ + 'payload' => json_encode($fields), + 'user_id' => $userId, + 'source_revision_id' => $revisionId, + ]); + + return $this->model->findOrFail($id); + } + + public function getRevisionPayload(int $id, int $revisionId): array + { + $object = $this->model->findOrFail($id); + $revision = $object->revisions()->where('id', $revisionId)->firstOrFail(); + + return json_decode($revision->payload, true) ?: []; + } + + public function getCountForMine(): int + { + $query = $this->model->newQuery(); + + return $this->filter($query, $this->countScope)->mine()->count(); + } + + public function getCountByStatusSlugRevisionsTrait(string $slug): int|bool + { + if ($slug === 'mine') { + return $this->getCountForMine(); + } + + return false; + } + + protected function hydrateObject($object, array $fields) + { + $fields = $this->prepareFieldsBeforeSave($object, $fields); + $object->fill(Arr::except($fields, $this->getReservedFields())); + + return $this->hydrate($object, $fields); + } +} From 300b757df2b50290d92db336a8cfe9a599c56127 Mon Sep 17 00:00:00 2001 From: celikerde Date: Sat, 28 Mar 2026 21:39:43 +0300 Subject: [PATCH 04/16] test(HasRevisionTest, RevisionTest, RevisionsTraitTest): add comprehensive tests for Revision and HasRevisions traits --- tests/Models/RevisionTest.php | 160 +++++++++ tests/Models/Traits/HasRevisionsTest.php | 311 +++++++++++++++++ .../Traits/RevisionsTraitTest.php | 328 ++++++++++++++++++ 3 files changed, 799 insertions(+) create mode 100644 tests/Models/RevisionTest.php create mode 100644 tests/Models/Traits/HasRevisionsTest.php create mode 100644 tests/Repositories/Traits/RevisionsTraitTest.php diff --git a/tests/Models/RevisionTest.php b/tests/Models/RevisionTest.php new file mode 100644 index 000000000..77ec59378 --- /dev/null +++ b/tests/Models/RevisionTest.php @@ -0,0 +1,160 @@ +id(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->unsignedBigInteger('source_revision_id')->nullable(); + $table->unsignedBigInteger('article_id')->nullable(); + $table->text('payload')->nullable(); + $table->timestamps(); + }); + } + + protected function tearDown(): void + { + Schema::dropIfExists('test_concrete_revisions'); + parent::tearDown(); + } + + public function test_timestamps_are_enabled(): void + { + $revision = new TestConcreteRevision(); + + $this->assertTrue($revision->timestamps); + } + + public function test_fillable_contains_payload_user_id_and_source_revision_id(): void + { + $revision = new TestConcreteRevision(); + + $this->assertContains('payload', $revision->getFillable()); + $this->assertContains('user_id', $revision->getFillable()); + $this->assertContains('source_revision_id', $revision->getFillable()); + } + + public function test_constructor_does_not_auto_append_foreign_key_when_four_fillable_items(): void + { + // TestConcreteRevision explicitly declares 4 fillable items, so no auto-append + $revision = new TestConcreteRevision(); + + $this->assertCount(4, $revision->getFillable()); + $this->assertContains('article_id', $revision->getFillable()); + } + + public function test_constructor_auto_appends_foreign_key_from_class_name_when_three_fillable_items(): void + { + // When $fillable has exactly 3 items, the constructor appends a derived foreign key + $revision = new TestThreeItemFillableRevision(); + + $fillable = $revision->getFillable(); + $this->assertCount(4, $fillable); + } + + public function test_get_by_user_attribute_returns_system_when_no_user_associated(): void + { + $revision = TestConcreteRevision::create([ + 'payload' => json_encode(['title' => 'Test']), + 'user_id' => null, + ]); + + // Accessor is defined as getByUserAttribute → accessed as $model->by_user + $this->assertEquals('System', $revision->by_user); + } + + public function test_get_by_user_attribute_returns_user_name(): void + { + $userId = DB::table('um_users')->insertGetId([ + 'name' => 'Jane Doe', + 'email' => 'jane@example.com', + 'password' => bcrypt('secret'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $revision = TestConcreteRevision::create([ + 'payload' => json_encode(['title' => 'Test']), + 'user_id' => $userId, + ]); + + // Reload to trigger eager loading of user + $revision = TestConcreteRevision::find($revision->id); + + $this->assertEquals('Jane Doe', $revision->by_user); + } + + public function test_is_draft_returns_true_when_cms_save_type_starts_with_draft_revision(): void + { + $revision = new TestConcreteRevision(); + $revision->payload = json_encode(['cmsSaveType' => 'draft-revision-auto']); + + $this->assertTrue($revision->isDraft()); + } + + public function test_is_draft_returns_true_for_plain_draft_revision_value(): void + { + $revision = new TestConcreteRevision(); + $revision->payload = json_encode(['cmsSaveType' => 'draft-revision']); + + $this->assertTrue($revision->isDraft()); + } + + public function test_is_draft_returns_false_when_cms_save_type_is_published(): void + { + $revision = new TestConcreteRevision(); + $revision->payload = json_encode(['cmsSaveType' => 'published']); + + $this->assertFalse($revision->isDraft()); + } + + public function test_is_draft_returns_false_when_cms_save_type_is_absent(): void + { + $revision = new TestConcreteRevision(); + $revision->payload = json_encode(['title' => 'No save type here']); + + $this->assertFalse($revision->isDraft()); + } + + public function test_user_relationship_is_eager_loaded_via_with(): void + { + $reflection = new \ReflectionClass(TestConcreteRevision::class); + $property = $reflection->getProperty('with'); + $property->setAccessible(true); + + $with = $property->getValue(new TestConcreteRevision()); + + $this->assertContains('user', $with); + } +} + +class TestConcreteRevision extends Revision +{ + protected $table = 'test_concrete_revisions'; + + // 4 items: constructor skips auto-append + protected $fillable = ['payload', 'user_id', 'source_revision_id', 'article_id']; +} + +class TestThreeItemFillableRevision extends Revision +{ + protected $table = 'test_concrete_revisions'; + + // Exactly 3 items: constructor will auto-append a foreign key + protected $fillable = ['payload', 'user_id', 'source_revision_id']; +} diff --git a/tests/Models/Traits/HasRevisionsTest.php b/tests/Models/Traits/HasRevisionsTest.php new file mode 100644 index 000000000..ac3973414 --- /dev/null +++ b/tests/Models/Traits/HasRevisionsTest.php @@ -0,0 +1,311 @@ +id(); + $table->string('title')->nullable(); + $table->timestamps(); + }); + + Schema::create('test_hr_article_revisions', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('test_hr_article_id')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->unsignedBigInteger('source_revision_id')->nullable(); + $table->text('payload')->nullable(); + $table->timestamps(); + }); + } + + protected function tearDown(): void + { + Schema::dropIfExists('test_hr_article_revisions'); + Schema::dropIfExists('test_hr_articles'); + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // Relationship + // ------------------------------------------------------------------------- + + public function test_model_uses_has_revisions_trait(): void + { + $traits = class_uses_recursive(TestHrArticle::class); + + $this->assertContains(HasRevisions::class, $traits); + } + + public function test_revisions_method_returns_has_many_relation(): void + { + $article = TestHrArticle::create(['title' => 'Draft']); + + $this->assertInstanceOf(HasMany::class, $article->revisions()); + } + + public function test_revisions_are_ordered_descending_by_created_at(): void + { + $article = TestHrArticle::create(['title' => 'Post']); + + $older = TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'V1']), + 'created_at' => now()->subMinutes(10), + 'updated_at' => now()->subMinutes(10), + ]); + + $newer = TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'V2']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $revisions = $article->revisions()->get(); + + $this->assertEquals($newer->id, $revisions->first()->id); + $this->assertEquals($older->id, $revisions->last()->id); + } + + // ------------------------------------------------------------------------- + // revisionsArray() + // ------------------------------------------------------------------------- + + public function test_revisions_array_returns_correct_keys(): void + { + $article = TestHrArticle::create(['title' => 'Post']); + $userId = DB::table('um_users')->insertGetId([ + 'name' => 'Alice', + 'email' => 'alice@example.com', + 'password' => bcrypt('secret'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'user_id' => $userId, + 'payload' => json_encode(['title' => 'Hello']), + ]); + + $array = $article->revisionsArray(); + + $this->assertIsArray($array); + $this->assertCount(1, $array); + $this->assertArrayHasKey('id', $array[0]); + $this->assertArrayHasKey('author', $array[0]); + $this->assertArrayHasKey('datetime', $array[0]); + $this->assertArrayHasKey('label', $array[0]); + $this->assertArrayHasKey('source_label', $array[0]); + } + + public function test_revisions_array_assigns_version_labels_newest_first(): void + { + $article = TestHrArticle::create(['title' => 'Post']); + + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'V1']), + 'created_at' => now()->subMinutes(20), + 'updated_at' => now()->subMinutes(20), + ]); + + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'V2']), + 'created_at' => now()->subMinutes(10), + 'updated_at' => now()->subMinutes(10), + ]); + + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'V3']), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $array = $article->revisionsArray(); + + // revisionsArray() returns ordered DESC (newest first), with label V3, V2, V1 + $this->assertEquals('V3', $array[0]['label']); + $this->assertEquals('V2', $array[1]['label']); + $this->assertEquals('V1', $array[2]['label']); + } + + public function test_revisions_array_source_label_is_null_when_no_source_revision(): void + { + $article = TestHrArticle::create(['title' => 'Post']); + + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Original']), + 'source_revision_id' => null, + ]); + + $array = $article->revisionsArray(); + + $this->assertNull($array[0]['source_label']); + } + + public function test_revisions_array_source_label_reflects_source_version(): void + { + $article = TestHrArticle::create(['title' => 'Post']); + + $v1 = TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Original']), + 'created_at' => now()->subMinutes(10), + 'updated_at' => now()->subMinutes(10), + ]); + + // V2 is a restore of V1, so source_revision_id = V1's id + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Restored from V1']), + 'source_revision_id' => $v1->id, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $array = $article->revisionsArray(); + + // V2 (index 0, newest) should have source_label 'V1' + $this->assertEquals('V1', $array[0]['source_label']); + // V1 (index 1, oldest) should have no source_label + $this->assertNull($array[1]['source_label']); + } + + // ------------------------------------------------------------------------- + // deleteSpecificRevisions() + // ------------------------------------------------------------------------- + + public function test_delete_specific_revisions_removes_oldest_beyond_limit(): void + { + $article = TestHrArticle::create(['title' => 'Post']); + + foreach (range(1, 5) as $i) { + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => "V{$i}"]), + 'created_at' => now()->subMinutes(10 - $i), + 'updated_at' => now()->subMinutes(10 - $i), + ]); + } + + $this->assertCount(5, $article->revisions); + + $article->deleteSpecificRevisions(3); + + $this->assertCount(3, $article->fresh()->revisions); + } + + public function test_delete_specific_revisions_respects_model_limit_revisions_property(): void + { + $article = TestHrArticle::create(['title' => 'Limited']); + // Set limitRevisions on the instance — deleteSpecificRevisions reads $this->limitRevisions + $article->limitRevisions = 2; + + foreach (range(1, 4) as $i) { + TestHrArticleRevision::create([ + 'test_hr_article_id' => $article->id, + 'payload' => json_encode(['title' => "V{$i}"]), + 'created_at' => now()->subMinutes(10 - $i), + 'updated_at' => now()->subMinutes(10 - $i), + ]); + } + + // Model has limitRevisions = 2, so passing 10 should still trim to 2 + $article->deleteSpecificRevisions(10); + + $this->assertCount(2, $article->fresh()->revisions); + } + + // ------------------------------------------------------------------------- + // scopeMine() + // ------------------------------------------------------------------------- + + public function test_scope_mine_returns_only_models_with_current_user_revisions(): void + { + $userId = DB::table('um_users')->insertGetId([ + 'name' => 'Bob', + 'email' => 'bob@example.com', + 'password' => bcrypt('secret'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $otherUserId = DB::table('um_users')->insertGetId([ + 'name' => 'Carol', + 'email' => 'carol@example.com', + 'password' => bcrypt('secret'), + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $myArticle = TestHrArticle::create(['title' => 'My Article']); + $otherArticle = TestHrArticle::create(['title' => 'Other Article']); + + TestHrArticleRevision::create([ + 'test_hr_article_id' => $myArticle->id, + 'user_id' => $userId, + 'payload' => json_encode(['title' => 'Mine']), + ]); + + TestHrArticleRevision::create([ + 'test_hr_article_id' => $otherArticle->id, + 'user_id' => $otherUserId, + 'payload' => json_encode(['title' => 'Theirs']), + ]); + + Auth::guard('modularity')->loginUsingId($userId); + + $mine = TestHrArticle::mine()->get(); + + $this->assertCount(1, $mine); + $this->assertEquals($myArticle->id, $mine->first()->id); + } +} + +// --------------------------------------------------------------------------- +// Stubs +// --------------------------------------------------------------------------- + +class TestHrArticle extends Model +{ + use HasRevisions; + + protected $table = 'test_hr_articles'; + protected $fillable = ['title']; + protected $revisionModel = TestHrArticleRevision::class; +} + +class TestHrArticleRevision extends Revision +{ + protected $table = 'test_hr_article_revisions'; + + // Empty $fillable + $guarded = [] → all fields are mass-assignable, + // including created_at/updated_at for ordering tests, without triggering + // the parent Revision constructor's foreign-key auto-append (which fires + // only when count($fillable) == 3). + protected $fillable = []; + protected $guarded = []; +} diff --git a/tests/Repositories/Traits/RevisionsTraitTest.php b/tests/Repositories/Traits/RevisionsTraitTest.php new file mode 100644 index 000000000..8c0f835f5 --- /dev/null +++ b/tests/Repositories/Traits/RevisionsTraitTest.php @@ -0,0 +1,328 @@ +id(); + $table->string('title')->nullable(); + $table->timestamps(); + }); + + Schema::create('test_rt_article_revisions', function (Blueprint $table) { + $table->id(); + $table->unsignedBigInteger('test_rt_article_id')->nullable(); + $table->unsignedBigInteger('user_id')->nullable(); + $table->unsignedBigInteger('source_revision_id')->nullable(); + $table->text('payload')->nullable(); + $table->timestamps(); + }); + + $this->repository = new TestRevisionsRepository(new TestRtArticle()); + } + + protected function tearDown(): void + { + Schema::dropIfExists('test_rt_article_revisions'); + Schema::dropIfExists('test_rt_articles'); + parent::tearDown(); + } + + // ------------------------------------------------------------------------- + // createRevisionIfNeeded() + // ------------------------------------------------------------------------- + + public function test_creates_revision_when_payload_changes(): void + { + $article = TestRtArticle::create(['title' => 'Original']); + + $this->repository->createRevisionIfNeeded($article, ['title' => 'Original']); + + $this->assertCount(1, $article->revisions); + } + + public function test_skips_revision_when_payload_is_unchanged(): void + { + $article = TestRtArticle::create(['title' => 'Same']); + + // First call — creates revision + $this->repository->createRevisionIfNeeded($article, ['title' => 'Same']); + $this->assertCount(1, $article->revisions); + + // Second call with identical payload — no new revision + $this->repository->createRevisionIfNeeded($article->fresh(), ['title' => 'Same']); + $this->assertCount(1, $article->fresh()->revisions); + } + + public function test_creates_new_revision_when_payload_differs_from_last(): void + { + $article = TestRtArticle::create(['title' => 'First']); + + $this->repository->createRevisionIfNeeded($article, ['title' => 'First']); + $this->assertCount(1, $article->revisions); + + $this->repository->createRevisionIfNeeded($article->fresh(), ['title' => 'Second']); + $this->assertCount(2, $article->fresh()->revisions); + } + + public function test_skips_revision_when_skip_revision_creation_flag_is_true(): void + { + $article = TestRtArticle::create(['title' => 'Skip me']); + + $this->repository->setSkipRevisionCreation(true); + $this->repository->createRevisionIfNeeded($article, ['title' => 'Skip me']); + + $this->assertCount(0, $article->revisions); + } + + public function test_sets_source_revision_id_when_pending_source_is_provided(): void + { + $article = TestRtArticle::create(['title' => 'Post']); + + $sourceRevision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Source']), + ]); + + $this->repository->setPendingSourceRevisionId($sourceRevision->id); + $this->repository->createRevisionIfNeeded($article, ['title' => 'Post']); + + $created = $article->revisions()->latest('id')->first(); + $this->assertEquals($sourceRevision->id, $created->source_revision_id); + } + + // ------------------------------------------------------------------------- + // restoreRevision() — regression test for the content-equality bug + // ------------------------------------------------------------------------- + + /** + * Regression test: restoring a revision whose content is identical to the + * most recent revision must still record a new revision entry. + * + * Before the fix, createRevisionIfNeeded() deduplication prevented + * revision creation when content was unchanged (e.g. restoring to the + * same version that is already current). + */ + public function test_restore_always_creates_revision_even_when_content_is_identical_to_latest(): void + { + $article = TestRtArticle::create(['title' => 'Original']); + + // Create an initial revision that matches the current content exactly + $existingRevision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Original']), + ]); + + $this->assertCount(1, $article->revisions); + + // Restore the revision — content is identical to the latest revision. + // Without the fix this would silently skip creation. + $this->repository->restoreRevision($article->id, $existingRevision->id); + + $this->assertCount(2, $article->fresh()->revisions); + } + + public function test_restore_sets_source_revision_id_on_created_revision(): void + { + $article = TestRtArticle::create(['title' => 'Hello']); + + $sourceRevision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Hello']), + ]); + + $this->repository->restoreRevision($article->id, $sourceRevision->id); + + $newRevision = $article->fresh()->revisions()->latest('id')->first(); + $this->assertEquals($sourceRevision->id, $newRevision->source_revision_id); + } + + public function test_restore_applies_revision_fields_to_model(): void + { + $article = TestRtArticle::create(['title' => 'Current']); + + $targetRevision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Restored Title']), + ]); + + // Add a newer revision so the target is not the latest + TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Current']), + ]); + + $this->repository->restoreRevision($article->id, $targetRevision->id); + + $this->assertEquals('Restored Title', $article->fresh()->title); + } + + public function test_restore_returns_updated_model(): void + { + $article = TestRtArticle::create(['title' => 'Before']); + + $revision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Before']), + ]); + + $returned = $this->repository->restoreRevision($article->id, $revision->id); + + $this->assertInstanceOf(TestRtArticle::class, $returned); + $this->assertEquals($article->id, $returned->id); + } + + // ------------------------------------------------------------------------- + // getRevisionPayload() + // ------------------------------------------------------------------------- + + public function test_get_revision_payload_returns_decoded_array(): void + { + $article = TestRtArticle::create(['title' => 'Payload test']); + + $revision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => 'Stored', 'body' => 'Content']), + ]); + + $payload = $this->repository->getRevisionPayload($article->id, $revision->id); + + $this->assertEquals(['title' => 'Stored', 'body' => 'Content'], $payload); + } + + public function test_get_revision_payload_returns_empty_array_for_empty_payload(): void + { + $article = TestRtArticle::create(['title' => 'Empty']); + + $revision = TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => null, + ]); + + $payload = $this->repository->getRevisionPayload($article->id, $revision->id); + + $this->assertEquals([], $payload); + } + + // ------------------------------------------------------------------------- + // afterSaveRevisionsTrait() — invoked via afterSave hook + // ------------------------------------------------------------------------- + + public function test_after_save_hook_creates_revision_on_first_save(): void + { + $article = TestRtArticle::create(['title' => 'New post']); + + $this->repository->afterSave($article, ['title' => 'New post']); + + $this->assertCount(1, $article->revisions); + } + + public function test_after_save_hook_prunes_revisions_beyond_limit(): void + { + $article = TestRtArticle::create(['title' => 'Limited']); + // Set limitRevisions on the instance — afterSaveRevisionsTrait checks $object->limitRevisions + $article->limitRevisions = 2; + + // Manually create 3 existing revisions + foreach (range(1, 3) as $i) { + TestRtArticleRevision::create([ + 'test_rt_article_id' => $article->id, + 'payload' => json_encode(['title' => "V{$i}"]), + 'created_at' => now()->subMinutes(10 - $i), + 'updated_at' => now()->subMinutes(10 - $i), + ]); + } + + // afterSave creates a 4th revision and then prunes down to limitRevisions=2 + $this->repository->afterSave($article, ['title' => 'V4']); + + $this->assertCount(2, $article->fresh()->revisions); + } +} + +// --------------------------------------------------------------------------- +// Stubs +// --------------------------------------------------------------------------- + +class TestRtArticle extends Model +{ + use HasRevisions; + + protected $table = 'test_rt_articles'; + protected $fillable = ['title']; + protected $revisionModel = TestRtArticleRevision::class; +} + +class TestRtArticleRevision extends Revision +{ + protected $table = 'test_rt_article_revisions'; + + // Empty $fillable + $guarded = [] → all fields are mass-assignable, + // including created_at/updated_at for ordering tests, without triggering + // the parent Revision constructor's foreign-key auto-append (count == 3). + protected $fillable = []; + protected $guarded = []; +} + +/** + * Minimal repository stub using RevisionsTrait. + * + * The real Repository::update() involves DB transactions and unrelated + * infrastructure. This stub replaces it with a direct fill + save so + * RevisionsTrait logic can be tested in isolation. + */ +class TestRevisionsRepository +{ + use RevisionsTrait; + + public function __construct(public Model $model) {} + + /** + * Simplified update: fill the model and persist, then fire afterSave hooks. + */ + public function update(int $id, array $fields, $schema = null, $options = []): bool + { + $object = $this->model->findOrFail($id); + $object->fill(array_intersect_key($fields, array_flip($object->getFillable()))); + $object->save(); + $this->afterSave($object, $fields); + + return true; + } + + /** + * Fire all afterSave* trait methods (mirrors Repository::afterSave()). + */ + public function afterSave(Model $object, array $fields): void + { + $this->afterSaveRevisionsTrait($object, $fields); + } + + public function setSkipRevisionCreation(bool $value): void + { + $this->skipRevisionCreation = $value; + } + + public function setPendingSourceRevisionId(?int $id): void + { + $this->pendingSourceRevisionId = $id; + } +} From 48ba5986b87924364f9d5ad84b0c00cf8e29e0e0 Mon Sep 17 00:00:00 2001 From: celikerde Date: Sat, 28 Mar 2026 21:41:50 +0300 Subject: [PATCH 05/16] feat(ManageUtilities): add revision management data to form response - Included 'revisions', 'restoreUrl', and 'previewUrl' in the form data response for enhanced revision handling capabilities. --- src/Http/Controllers/Traits/ManageUtilities.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/Http/Controllers/Traits/ManageUtilities.php b/src/Http/Controllers/Traits/ManageUtilities.php index c3a874b1c..4171cd35c 100755 --- a/src/Http/Controllers/Traits/ManageUtilities.php +++ b/src/Http/Controllers/Traits/ManageUtilities.php @@ -172,6 +172,9 @@ protected function getFormData($id = null) 'actionUrl' => $this->getFormUrl($itemId), 'schema' => $eventualSchema, 'languages' => getLanguagesForVueStore($eventualSchema, $translate)['all'] ?? [], + 'revisions' => $this->routeHasTrait('revisions') && $item ? $item->revisionsArray() : [], + 'restoreUrl' => Route::has($restoreRouteName) && $itemId ? moduleRoute($this->moduleName, $this->routePrefix, 'restoreRevision', [$itemId]) : null, + 'previewUrl' => Route::has($previewRouteName) && $itemId ? moduleRoute($this->moduleName, $this->routePrefix, 'preview', [$itemId]) : null, ], $formAttributes), 'endpoints' => [ ($isEditing ? 'update' : 'store') => $this->getFormUrl($itemId), From 7ee785bc00242250ee443c5a209c99aa74bc9856 Mon Sep 17 00:00:00 2001 From: celikerde Date: Sat, 28 Mar 2026 21:42:06 +0300 Subject: [PATCH 06/16] feat(BaseController): add restoreRevision method for handling revision restoration and preview functionality --- src/Http/Controllers/BaseController.php | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/Http/Controllers/BaseController.php b/src/Http/Controllers/BaseController.php index d453adfab..935cd3bff 100755 --- a/src/Http/Controllers/BaseController.php +++ b/src/Http/Controllers/BaseController.php @@ -323,6 +323,7 @@ public function update($id, $submoduleId = null) $formRequest = $this->validateFormRequest(); $this->repository->update($id, $formRequest->all(), $this->getPreviousRouteSchema()); + $item = $this->repository->getById($id); // $this->handleActionEvent($item, __FUNCTION__); @@ -383,6 +384,39 @@ public function update($id, $submoduleId = null) } } + public function restoreRevision($id) + { + if (! $this->routeHasTrait('revisions')) { + return $this->respondWithError(__('Revisions are not enabled for this route.')); + } + + $params = $this->request->route()->parameters(); + $id = last($params); + $revisionId = (int) $this->request->get('revisionId'); + + if ($revisionId < 1) { + return $this->respondWithError(__('Revision id is required.')); + } + + if ($this->request->get('preview')) { + // dd("preview"); + $rawPayload = $this->repository->getRevisionPayload((int) $id, $revisionId); + + return Response::json([ + 'form_fields' => $rawPayload, + ]); + } + + $item = $this->repository->restoreRevision((int) $id, $revisionId); + + return Response::json([ + 'message' => __('Revision restored successfully.'), + 'variant' => MessageStage::SUCCESS, + 'revisions' => $item->revisionsArray(), + 'form_fields' => $this->repository->getFormFields($item, $this->getPreviousRouteSchema()), + ]); + } + /** * @param int $id * @param int|null $submoduleId From 8b14cc4759e5fdb292a1fbc27d91a8fc1a55ab95 Mon Sep 17 00:00:00 2001 From: celikerde Date: Sat, 28 Mar 2026 21:42:30 +0300 Subject: [PATCH 07/16] feat(RouteServiceProvider): enable 'restoreRevision' macro for revision restoration functionality --- src/Providers/RouteServiceProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Providers/RouteServiceProvider.php b/src/Providers/RouteServiceProvider.php index 9130a193a..355b2d1b3 100755 --- a/src/Providers/RouteServiceProvider.php +++ b/src/Providers/RouteServiceProvider.php @@ -361,7 +361,7 @@ protected function bootMacros() // 'feature', // 'preview', // 'bulkFeature', - // 'restoreRevision', + 'restoreRevision', 'restore', 'bulkRestore', From 7fca3645213694b4b3ceb27864aba33b9bfd85e3 Mon Sep 17 00:00:00 2001 From: celikerde Date: Sat, 28 Mar 2026 21:43:09 +0300 Subject: [PATCH 08/16] feat(previews): add generic preview components for dynamic module previews - Introduced `createPreviewComponent` function to generate Vue components for module previews based on field definitions. - Added `FaqPreview.vue` as a specific implementation of the preview component for FAQs. --- .../components/form/previews/FaqPreview.vue | 21 ++ .../form/previews/GenericModulePreview.vue | 219 ++++++++++++++++++ .../form/previews/createPreviewComponent.js | 73 ++++++ 3 files changed, 313 insertions(+) create mode 100644 vue/src/js/components/form/previews/FaqPreview.vue create mode 100644 vue/src/js/components/form/previews/GenericModulePreview.vue create mode 100644 vue/src/js/components/form/previews/createPreviewComponent.js diff --git a/vue/src/js/components/form/previews/FaqPreview.vue b/vue/src/js/components/form/previews/FaqPreview.vue new file mode 100644 index 000000000..6ddac2e31 --- /dev/null +++ b/vue/src/js/components/form/previews/FaqPreview.vue @@ -0,0 +1,21 @@ + diff --git a/vue/src/js/components/form/previews/GenericModulePreview.vue b/vue/src/js/components/form/previews/GenericModulePreview.vue new file mode 100644 index 000000000..f5cabbb69 --- /dev/null +++ b/vue/src/js/components/form/previews/GenericModulePreview.vue @@ -0,0 +1,219 @@ + + + + + diff --git a/vue/src/js/components/form/previews/createPreviewComponent.js b/vue/src/js/components/form/previews/createPreviewComponent.js new file mode 100644 index 000000000..90976951c --- /dev/null +++ b/vue/src/js/components/form/previews/createPreviewComponent.js @@ -0,0 +1,73 @@ +import { defineComponent, h } from 'vue' +import GenericModulePreview from './GenericModulePreview.vue' + +/** + * Factory that produces a Vue component suitable for the `previewComponent` + * prop of `ue-form` / `Form.vue`. + * + * Each module calls this once with its own field definitions and passes the + * result as a prop — no boilerplate component file needed per-module. + * + * @param {FieldDef[]} fieldDefs + * @returns {import('vue').Component} + * + * @example FAQ module + * const previewComponent = createPreviewComponent([ + * { key: 'category', label: 'Category', resolve: (v) => v?.name || v }, + * { key: 'question', label: 'Question' }, + * { key: 'answer', label: 'Answer', type: 'html' }, + * ]) + * + * @example PressRelease module + * const previewComponent = createPreviewComponent([ + * { key: 'title', label: 'Title', section: 'General' }, + * { key: 'subtitle', label: 'Subtitle', section: 'General' }, + * { key: 'status', label: 'Status', section: 'General', type: 'badge' }, + * { key: 'published_at', label: 'Published At', section: 'General', type: 'date' }, + * { key: 'content', label: 'Content', section: 'Content', type: 'html' }, + * { key: 'tags', label: 'Tags', section: 'Content', type: 'list', + * resolve: (v) => Array.isArray(v) ? v.map(t => t?.name ?? t) : v }, + * ]) + * + * @example Ticket module + * const previewComponent = createPreviewComponent([ + * { key: 'subject', label: 'Subject', section: 'Ticket' }, + * { key: 'priority', label: 'Priority', section: 'Ticket', type: 'badge', + * color: 'warning' }, + * { key: 'status', label: 'Status', section: 'Ticket', type: 'badge' }, + * { key: 'assigned_to',label: 'Assigned To',section: 'Ticket', + * resolve: (v) => v?.name || v }, + * { key: 'body', label: 'Body', section: 'Details', type: 'html' }, + * { key: 'created_at', label: 'Created At', section: 'Details', type: 'date' }, + * { key: 'is_public', label: 'Public', section: 'Details', type: 'boolean' }, + * ]) + */ +export function createPreviewComponent(fieldDefs) { + return defineComponent({ + name: 'ModulePreview', + props: { + data: { + type: Object, + default: () => ({}), + }, + }, + setup(props) { + return () => + h(GenericModulePreview, { + data: props.data, + fields: fieldDefs, + }) + }, + }) +} + +/** + * @typedef {Object} FieldDef + * @property {string} key - Key in the data object (dot-notation supported) + * @property {string} label - Human-readable label shown in the preview table + * @property {'text'|'html'|'badge'|'boolean'|'date'|'list'|'image'} [type='text'] + * @property {function} [resolve] - (value, data) => displayValue custom resolver + * @property {string} [section] - Group fields under a named section heading + * @property {function} [hide] - (data) => boolean hide conditionally + * @property {string} [color] - Vuetify color for 'badge' type + */ From 08843f88ee4a74610e8493b57cee357c76b9279d Mon Sep 17 00:00:00 2001 From: celikerde Date: Sat, 28 Mar 2026 21:43:30 +0300 Subject: [PATCH 09/16] feat(RevisionsList): implement RevisionsList component for displaying and managing revisions --- vue/src/js/components/form/RevisionsList.vue | 287 +++++++++++++++++++ 1 file changed, 287 insertions(+) create mode 100644 vue/src/js/components/form/RevisionsList.vue diff --git a/vue/src/js/components/form/RevisionsList.vue b/vue/src/js/components/form/RevisionsList.vue new file mode 100644 index 000000000..8ff3e104b --- /dev/null +++ b/vue/src/js/components/form/RevisionsList.vue @@ -0,0 +1,287 @@ + + + + + From 34beefd82c7c0be37ef6a0285bd5c6163fcacaf6 Mon Sep 17 00:00:00 2001 From: celikerde Date: Sat, 28 Mar 2026 21:44:23 +0300 Subject: [PATCH 10/16] feat(Form): enhance form component with revision management and preview functionality - Added support for displaying and restoring revisions through new dialog components. - Integrated `RevisionsList` for managing revisions within the form. --- vue/src/js/components/Form.vue | 299 +++++++++++++++++++++++++++++---- vue/src/js/hooks/useForm.js | 161 ++++++++++++++++-- 2 files changed, 415 insertions(+), 45 deletions(-) diff --git a/vue/src/js/components/Form.vue b/vue/src/js/components/Form.vue index ae8b6e0a7..f414c69c7 100755 --- a/vue/src/js/components/Form.vue +++ b/vue/src/js/components/Form.vue @@ -134,7 +134,7 @@
-
@@ -276,13 +276,14 @@ $vuetify.display.smAndDown ? 'd-none' : 'd-flex flex-column', ]" :style="{ - ...(rightSlotWidth ? {width: `${rightSlotWidth}px`} : {}), + width: `${rightSlotWidth || rightSlotMinWidth || 300}px`, ...(rightSlotMinWidth ? {minWidth: `${rightSlotMinWidth}px`} : {}), ...(rightSlotMaxWidth ? {maxWidth: `${rightSlotMaxWidth}px`} : {}) }" > +
@@ -318,6 +326,7 @@ + @@ -402,17 +418,158 @@
+ + + + + + + + mdi-arrow-left + + + + + +
+ + {{ vp.icon }} + +
+ + + + +
+ + + + +
+ + + +
+ + + + + {{ field.label }} + + {{ field.value || '—' }} + + + +
+
+
+ + + + + + + + + + + + + + +
+ +
+ + + + + + + + {{ field.label }} + + {{ field.value || '—' }} + + + +
+ + + + + Approve + + +
+
diff --git a/vue/src/js/hooks/useForm.js b/vue/src/js/hooks/useForm.js index e4309443e..86f733352 100644 --- a/vue/src/js/hooks/useForm.js +++ b/vue/src/js/hooks/useForm.js @@ -175,6 +175,22 @@ export const makeFormProps = propsFactory({ type: Array, default: null, }, + revisions: { + type: Array, + default: () => [], + }, + restoreUrl: { + type: String, + default: null, + }, + previewUrl: { + type: String, + default: null, + }, + previewComponent: { + type: [Object, Function], + default: null, + }, }) export default function useForm(props, context) { @@ -275,11 +291,15 @@ export default function useForm(props, context) { }) || find(formEventSchema.value, checkSubmittable) ? true : false }) + const currentRevisions = ref(props.revisions || []) + const restoringRevisionId = ref(null) + const hasAdditionalSection = computed(() => context.slots.right || context.slots['right.top'] || context.slots['right.bottom'] || context.slots['right.middle'] || ['right-top', 'right-middle', 'right-bottom'].includes(props.actionsPosition) + || (currentRevisions.value && currentRevisions.value.length > 0) ) const states = reactive({ @@ -356,6 +376,10 @@ export default function useForm(props, context) { store.commit(ALERT.SET_ALERT, { message: response.data.message, variant: response.data.variant }) } + if (Object.prototype.hasOwnProperty.call(response.data, 'revisions')) { + currentRevisions.value = response.data.revisions + } + if(props.clearOnSaved) { states.model = getModel(rawSchema.value) resetValidation() @@ -713,23 +737,140 @@ export default function useForm(props, context) { }, { deep: true }) + watch(() => props.revisions, (newVal) => { + if (newVal) { + currentRevisions.value = newVal + } + }) + + const restoreDialogActive = ref(false) + const restorePreviewData = ref(null) + const pendingRestoreRevisionId = ref(null) + + const getRestoreUrl = () => { + if (props.restoreUrl) return props.restoreUrl + if (props.actionUrl) { + const segments = props.actionUrl.replace(/\/+$/, '').split('/') + const id = segments.pop() + return segments.join('/') + '/restore-revision/' + id + } + return null + } + + const normalizeRevisionPayload = (payload) => { + if (!payload) return payload + + const locales = (store.state.language.all || []).map(l => l.value) + if (!locales.length) return payload + + const result = {} + const translatedFields = {} + + for (const key in payload) { + if (locales.includes(key) && payload[key] && typeof payload[key] === 'object' && !Array.isArray(payload[key])) { + const locale = key + for (const field in payload[locale]) { + if (!translatedFields[field]) translatedFields[field] = {} + translatedFields[field][locale] = payload[locale][field] + } + } else { + result[key] = payload[key] + } + } + + return { ...result, ...translatedFields } + } + + const restoreRevision = (revisionId) => { + const url = getRestoreUrl() + if (!url) return + + // Open dialog immediately and remember which revision is being previewed + restorePreviewData.value = null + pendingRestoreRevisionId.value = revisionId + restoreDialogActive.value = true + restoringRevisionId.value = revisionId + + api.get(`${url}?revisionId=${revisionId}&preview=1`, + (response) => { + restoringRevisionId.value = null + + if (response.data.form_fields) { + restorePreviewData.value = normalizeRevisionPayload(response.data.form_fields) + } + }, + () => { + restoringRevisionId.value = null + store.commit(ALERT.SET_ALERT, { message: 'Failed to load revision preview.', variant: 'error' }) + } + ) + } + + const confirmRestore = () => { + const url = getRestoreUrl() + if (!url || !pendingRestoreRevisionId.value) return + + const revisionId = pendingRestoreRevisionId.value + restoreDialogActive.value = false + restoringRevisionId.value = revisionId + formLoading.value = true + + api.get(`${url}?revisionId=${revisionId}`, + (response) => { + formLoading.value = false + restoringRevisionId.value = null + restorePreviewData.value = null + pendingRestoreRevisionId.value = null + + if (response.data.variant) { + store.commit(ALERT.SET_ALERT, { message: response.data.message, variant: response.data.variant }) + } + + if (response.data.revisions) { + currentRevisions.value = response.data.revisions + } + + if (response.data.form_fields) { + model.value = getModel(rawSchema.value, response.data.form_fields, store.state) + inputSchema.value = validations.invokeRuleGenerator( + getSchema(rawSchema.value, { ...model.value, ...response.data.form_fields }, props.isEditing) + ) + context.emit('update:modelValue', response.data.form_fields) + } else if (shouldUseInertia.value) { + router.reload({ only: ['formAttributes'] }) + } else { + window.location.reload(true) + } + }, + () => { + formLoading.value = false + restoringRevisionId.value = null + store.commit(ALERT.SET_ALERT, { message: 'Failed to restore revision.', variant: 'error' }) + } + ) + } + + const cancelRestore = () => { + restoreDialogActive.value = false + restorePreviewData.value = null + pendingRestoreRevisionId.value = null + } + initialize() - // Add resetSchemaErrors to the returned methods return { ...toRefs(states), ...toRefs(methods), ...inputHandlers, ...validations, ...locale, - // ...itemActions, - // handleInput, - // createModel, - // createSchema, - // submit, - // resetValidation, - // initialize, - // resetSchemaError, - // setSchemaErrors, + currentRevisions, + restoringRevisionId, + restoreRevision, + restoreDialogActive, + restorePreviewData, + pendingRestoreRevisionId, + confirmRestore, + cancelRestore, } } From 4a2e9453909fb7ff259db623af1f44e899f22bca Mon Sep 17 00:00:00 2001 From: celikerde Date: Thu, 2 Apr 2026 19:17:22 +0300 Subject: [PATCH 11/16] refactor(HasRevisions, RevisionsTrait): update visibility and add new methods for revision management - Changed `getRevisionModel` method from protected to public for broader access. - Introduced `getFormFieldsRevisionsTrait` method to manipulate form fields based on the object and schema. - Added `getRevisions` method to retrieve revisions for a specific model instance. --- src/Entities/Traits/HasRevisions.php | 2 +- src/Repositories/Traits/RevisionsTrait.php | 26 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Entities/Traits/HasRevisions.php b/src/Entities/Traits/HasRevisions.php index 1258561cf..3132ac30c 100755 --- a/src/Entities/Traits/HasRevisions.php +++ b/src/Entities/Traits/HasRevisions.php @@ -82,7 +82,7 @@ public function deleteSpecificRevisions(int $maxRevisions): void } - protected function getRevisionModel() + public function getRevisionModel() { if (property_exists($this, 'revisionModel') && is_string($this->revisionModel) && @class_exists($this->revisionModel)) { return $this->revisionModel; diff --git a/src/Repositories/Traits/RevisionsTrait.php b/src/Repositories/Traits/RevisionsTrait.php index 5dd88d530..491297e16 100644 --- a/src/Repositories/Traits/RevisionsTrait.php +++ b/src/Repositories/Traits/RevisionsTrait.php @@ -16,6 +16,22 @@ public function afterSaveRevisionsTrait($object, $fields): void $this->createRevisionIfNeeded($object, $fields); } + /** + * @param Model $object + * @param array $fields + * @param array $schema + * @return array + */ + public function getFormFieldsRevisionsTrait($object, $fields, $schema = []) + { + // set, cast, unset or manipulate the fields by using object, fields and schema + if (isset($schema['revisionable_id'])) { + $fields['revisionable_id'] = $object?->id; + } + + return $fields; + } + public function createRevisionIfNeeded($object, array $fields): array { if ($this->skipRevisionCreation) { @@ -113,4 +129,14 @@ protected function hydrateObject($object, array $fields) return $this->hydrate($object, $fields); } + + public function getRevisions(int $id) + { + $revisionModel = $this->model->getRevisionModel(); + $revisions = $revisionModel::where($this->model->getForeignKey(), $id) + ->orderBy('created_at', 'desc') + ->get(); + + return $revisions; + } } From fb48459c06f234c626b87f4c41586a462794cb87 Mon Sep 17 00:00:00 2001 From: celikerde Date: Thu, 2 Apr 2026 19:18:02 +0300 Subject: [PATCH 12/16] feat(ManagePreview): add ManagePreview trait for handling preview and revision functionalities - Implemented methods for showing views, listing revisions, and restoring revisions. - Integrated logic for handling active languages and preview views based on module names. - Enhanced response structure for restoring revisions with success messages and form fields. --- src/Http/Controllers/Traits/ManagePreview.php | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 src/Http/Controllers/Traits/ManagePreview.php diff --git a/src/Http/Controllers/Traits/ManagePreview.php b/src/Http/Controllers/Traits/ManagePreview.php new file mode 100644 index 000000000..1c6d6b9f3 --- /dev/null +++ b/src/Http/Controllers/Traits/ManagePreview.php @@ -0,0 +1,89 @@ +request->has('revisionId')); + if ($this->request->has('revisionId')) { + $item = $this->repository->previewForRevision($id, $this->request->get('revisionId')); + } else { + $formRequest = $this->validateFormRequest(); + $item = $this->repository->preview($id, $formRequest->all()); + } + + if ($this->request->has('activeLanguage')) { + //App::setLocale($this->request->get('activeLanguage')); + } + + // dd($this->previewView); + + $previewView = $this->previewView ?? (Config::get('twill.frontend.views_path', 'site') . '.' . Str::singular( + $this->moduleName + )); + + // dd($previewView); + + return View::exists($previewView) ? View::make( + $previewView, + array_replace([ + 'item' => $item, + ], $this->previewData($item)) + ) : View::make('twill::errors.preview', [ + 'moduleName' => Str::singular($this->moduleName), + ]); + } + + public function listRevisions($id) + { + $revisions = $this->repository->getRevisions($id); + return $revisions; + } + + public function restoreRevision($id) + { + // dd('restoreRevision'); + if (! $this->routeHasTrait('revisions')) { + return $this->respondWithError(__('Revisions are not enabled for this route.')); + } + + $params = $this->request->route()->parameters(); + $id = last($params); + $revisionId = (int) $this->request->get('revisionId'); + // dd($revisionId); + + if ($revisionId < 1) { + return $this->respondWithError(__('Revision id is required.')); + } + + if ($this->request->get('preview')) { + // dd("preview is called for revision id: $revisionId"); + $rawPayload = $this->repository->getRevisionPayload((int) $id, $revisionId); + + return Response::json([ + 'form_fields' => $rawPayload, + ]); + } + + + $item = $this->repository->restoreRevision((int) $id, $revisionId); + // dd($item); + + return Response::json([ + 'message' => __('Revision restored successfully.'), + 'variant' => MessageStage::SUCCESS, + 'revisions' => $item->revisionsArray(), + 'form_fields' => $this->repository->getFormFields($item, $this->getPreviousRouteSchema()), + ]); + } + +} From 96972dd6172f863b356f6d2472d758a8836428ac Mon Sep 17 00:00:00 2001 From: celikerde Date: Thu, 2 Apr 2026 19:18:34 +0300 Subject: [PATCH 13/16] refactor(BaseController): integrate ManagePreview trait and remove obsolete restoreRevision method - Added ManagePreview trait to enhance preview functionalities. - Removed the restoreRevision method to streamline the controller's responsibilities. --- src/Http/Controllers/BaseController.php | 36 ++----------------------- 1 file changed, 2 insertions(+), 34 deletions(-) diff --git a/src/Http/Controllers/BaseController.php b/src/Http/Controllers/BaseController.php index 935cd3bff..28fda76a0 100755 --- a/src/Http/Controllers/BaseController.php +++ b/src/Http/Controllers/BaseController.php @@ -15,6 +15,7 @@ use Illuminate\Support\Str; use Unusualify\Modularity\Http\Controllers\Traits\ManageIndexAjax; use Unusualify\Modularity\Http\Controllers\Traits\ManageInertia; +use Unusualify\Modularity\Http\Controllers\Traits\ManagePreview; use Unusualify\Modularity\Http\Controllers\Traits\ManagePrevious; use Unusualify\Modularity\Http\Controllers\Traits\ManageSingleton; use Unusualify\Modularity\Http\Controllers\Traits\ManageTranslations; @@ -23,7 +24,7 @@ abstract class BaseController extends PanelController { - use ManageIndexAjax, ManagePrevious, ManageUtilities, ManageSingleton, ManageInertia, ManageTranslations; + use ManageIndexAjax, ManagePrevious, ManageUtilities, ManageSingleton, ManageInertia, ManageTranslations, ManagePreview; /** * @var string @@ -384,39 +385,6 @@ public function update($id, $submoduleId = null) } } - public function restoreRevision($id) - { - if (! $this->routeHasTrait('revisions')) { - return $this->respondWithError(__('Revisions are not enabled for this route.')); - } - - $params = $this->request->route()->parameters(); - $id = last($params); - $revisionId = (int) $this->request->get('revisionId'); - - if ($revisionId < 1) { - return $this->respondWithError(__('Revision id is required.')); - } - - if ($this->request->get('preview')) { - // dd("preview"); - $rawPayload = $this->repository->getRevisionPayload((int) $id, $revisionId); - - return Response::json([ - 'form_fields' => $rawPayload, - ]); - } - - $item = $this->repository->restoreRevision((int) $id, $revisionId); - - return Response::json([ - 'message' => __('Revision restored successfully.'), - 'variant' => MessageStage::SUCCESS, - 'revisions' => $item->revisionsArray(), - 'form_fields' => $this->repository->getFormFields($item, $this->getPreviousRouteSchema()), - ]); - } - /** * @param int $id * @param int|null $submoduleId From 0f318639b0c8acc570a9708933a0a210fb741750 Mon Sep 17 00:00:00 2001 From: celikerde Date: Thu, 2 Apr 2026 19:18:49 +0300 Subject: [PATCH 14/16] feat(RevisionHydrate): implement RevisionHydrate class for input handling - Created the RevisionHydrate class to manage input hydration for revision-related functionalities. - Defined default requirements and implemented the hydrate method to manipulate input schema structure. - Added endpoints for restoring revisions, showing views, and listing revisions based on the route name. --- src/Hydrates/Inputs/RevisionHydrate.php | 57 +++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 src/Hydrates/Inputs/RevisionHydrate.php diff --git a/src/Hydrates/Inputs/RevisionHydrate.php b/src/Hydrates/Inputs/RevisionHydrate.php new file mode 100644 index 000000000..6189401b1 --- /dev/null +++ b/src/Hydrates/Inputs/RevisionHydrate.php @@ -0,0 +1,57 @@ + 'revision_id', + 'noSubmit' => true, + 'col' => ['cols' => 12], + 'default' => null, + ]; + + /** + * Manipulate Input Schema Structure + * + * @return void + */ + public function hydrate() + { + $input = $this->input; + + $input['type'] = 'input-revision'; + $input['name'] = 'revisionable_id'; + + + $snakeRouteName = snakeCase($this->routeName); + + $input['restoreEndpoint'] = $this->module->getRouteActionUrl( + $this->routeName, + 'restoreRevision', + [$snakeRouteName => ':id'] + ); + + $input['showViewEndpoint'] = $this->module->getRouteActionUrl( + $this->routeName, + 'showView', + [$snakeRouteName => ':id'] + ); + + $input['listRevisionsEndpoint'] = $this->module->getRouteActionUrl( + $this->routeName, + 'listRevisions', + [$snakeRouteName => ':id'] + ); + + dd($input); + + return $input; + } +} From 17be84b24b02462ff7636d7a13b944fa0d052b60 Mon Sep 17 00:00:00 2001 From: celikerde Date: Thu, 2 Apr 2026 19:19:19 +0300 Subject: [PATCH 15/16] feat(Module, RouteServiceProvider): add new routes for revision management - Introduced 'restoreRevision', 'showView', and 'listRevisions' to the Module class. - Updated RouteServiceProvider to handle new routes for 'showView' and 'listRevisions', enhancing revision management capabilities. --- src/Module.php | 3 +++ src/Providers/RouteServiceProvider.php | 12 ++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/Module.php b/src/Module.php index be4895d4d..2bf605369 100755 --- a/src/Module.php +++ b/src/Module.php @@ -54,6 +54,9 @@ class Module extends NwidartModule 'tagsUpdate', 'assignments', 'createAssignment', + 'restoreRevision', + 'showView', + 'listRevisions', ]; /** diff --git a/src/Providers/RouteServiceProvider.php b/src/Providers/RouteServiceProvider.php index 355b2d1b3..eae4bb3b1 100755 --- a/src/Providers/RouteServiceProvider.php +++ b/src/Providers/RouteServiceProvider.php @@ -361,6 +361,8 @@ protected function bootMacros() // 'feature', // 'preview', // 'bulkFeature', + 'showView', + 'listRevisions', 'restoreRevision', 'restore', @@ -400,8 +402,9 @@ protected function bootMacros() 'uses' => "{$controllerClass}@{$customRoute}", ]; - if ($customRoute === 'assignments') { - Route::get("{$url}/{{$snakeCase}}/assignments", $mapping); + if (in_array($customRoute, ['assignments', 'listRevisions'])) { + // dd($customRoute, $routeSlug, $mapping, $url, $snakeCase); + Route::get("{$url}/{{$snakeCase}}/{$customRouteKebab}", $mapping, ); } if ($customRoute === 'createAssignment') { @@ -429,11 +432,12 @@ protected function bootMacros() Route::put($routeSlug, $mapping); } - if (in_array($customRoute, ['duplicate'])) { + if (in_array($customRoute, ['duplicate', 'preview', 'showView','restoreRevision'])) { Route::put($routeSlug . "/{{$snakeCase}}", $mapping); } - if (in_array($customRoute, ['preview'])) { + if (in_array($customRoute, ['preview', 'showView', 'restoreRevision'])) { + // dd($customRoute, $routeSlug, $routeSlug . "/{{$snakeCase}}", $mapping); Route::put($routeSlug . "/{{$snakeCase}}", $mapping); } From 7c4c0d3eca1a138b0d5871c1164dd43b35e3f602 Mon Sep 17 00:00:00 2001 From: celikerde Date: Thu, 2 Apr 2026 19:19:46 +0300 Subject: [PATCH 16/16] feat(Revision): create Revision component for displaying individual revisions - Added a new Revision component to encapsulate the display logic for each revision, including author initials, date formatting, and a restore button. - Updated RevisionsList component to utilize the new Revision component, improving code organization and readability. --- vue/src/js/components/form/Revision.vue | 81 ++++++++++++++++++++ vue/src/js/components/form/RevisionsList.vue | 61 ++------------- 2 files changed, 88 insertions(+), 54 deletions(-) create mode 100644 vue/src/js/components/form/Revision.vue diff --git a/vue/src/js/components/form/Revision.vue b/vue/src/js/components/form/Revision.vue new file mode 100644 index 000000000..53a1eeef7 --- /dev/null +++ b/vue/src/js/components/form/Revision.vue @@ -0,0 +1,81 @@ + + + diff --git a/vue/src/js/components/form/RevisionsList.vue b/vue/src/js/components/form/RevisionsList.vue index 8ff3e104b..c0cab2d2e 100644 --- a/vue/src/js/components/form/RevisionsList.vue +++ b/vue/src/js/components/form/RevisionsList.vue @@ -36,47 +36,14 @@
- - - - - {{ revision.author }} - - - - - - - {{ formatDate(revision.datetime) }} - - - - + :revision="revision" + :all-revisions="revisions" + :show-restore="windowStart !== 0 || index !== 0" + @restore="selectRevision" + />
@@ -113,6 +80,7 @@