Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6238526
feat(Revision): add source_revision_id to fillable fields and impleme…
celikerde Mar 28, 2026
5ef4575
feat(HasRevisions): implement revision management trait for entities
celikerde Mar 28, 2026
2634c48
feat(RevisionsTrait): add trait for managing revisions with creation,…
celikerde Mar 28, 2026
300b757
test(HasRevisionTest, RevisionTest, RevisionsTraitTest): add comprehe…
celikerde Mar 28, 2026
48ba598
feat(ManageUtilities): add revision management data to form response
celikerde Mar 28, 2026
7ee785b
feat(BaseController): add restoreRevision method for handling revisio…
celikerde Mar 28, 2026
8b14cc4
feat(RouteServiceProvider): enable 'restoreRevision' macro for revisi…
celikerde Mar 28, 2026
7fca364
feat(previews): add generic preview components for dynamic module pre…
celikerde Mar 28, 2026
08843f8
feat(RevisionsList): implement RevisionsList component for displaying…
celikerde Mar 28, 2026
34beefd
feat(Form): enhance form component with revision management and previ…
celikerde Mar 28, 2026
4a2e945
refactor(HasRevisions, RevisionsTrait): update visibility and add new…
celikerde Apr 2, 2026
fb48459
feat(ManagePreview): add ManagePreview trait for handling preview and…
celikerde Apr 2, 2026
96972dd
refactor(BaseController): integrate ManagePreview trait and remove ob…
celikerde Apr 2, 2026
0f31863
feat(RevisionHydrate): implement RevisionHydrate class for input hand…
celikerde Apr 2, 2026
17be84b
feat(Module, RouteServiceProvider): add new routes for revision manag…
celikerde Apr 2, 2026
7c4c0d3
feat(Revision): create Revision component for displaying individual r…
celikerde Apr 2, 2026
fa1df1f
Merge branch 'feature/add-cms-module' into feature/add-revision-struc…
oguzhanbukcuoglu Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/Entities/Revision.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Unusualify\Modularity\Entities;

use Illuminate\Database\Eloquent\Model as BaseModel;
use Illuminate\Support\Str;

abstract class Revision extends BaseModel
{
Expand All @@ -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';
}
}
Expand All @@ -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');
}
}
105 changes: 105 additions & 0 deletions src/Entities/Traits/HasRevisions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

namespace Unusualify\Modularity\Entities\Traits;

use Illuminate\Support\Facades\Auth;
use RuntimeException;
use Unusualify\Modularity\Facades\Modularity;

trait HasRevisions
{
/**
* Defines the one-to-many relationship for revisions.
*
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function revisions()
{
return $this->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();
}


public 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.");
}
}
4 changes: 3 additions & 1 deletion src/Http/Controllers/BaseController.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -323,6 +324,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__);

Expand Down
89 changes: 89 additions & 0 deletions src/Http/Controllers/Traits/ManagePreview.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<?php

namespace Unusualify\Modularity\Http\Controllers\Traits;

use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Response;
use Illuminate\Support\Facades\View;
use Illuminate\Support\Str;
use Unusualify\Modularity\Services\MessageStage;

trait ManagePreview
{
public function showView($id)
{
// dd($id);
// dd($this->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()),
]);
}

}
3 changes: 3 additions & 0 deletions src/Http/Controllers/Traits/ManageUtilities.php
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
57 changes: 57 additions & 0 deletions src/Hydrates/Inputs/RevisionHydrate.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
<?php

namespace Unusualify\Modularity\Hydrates\Inputs;

class RevisionHydrate extends InputHydrate
{
/**
* Default values to set before hydrating
*
*
* @var array
*/
public $requirements = [
'name' => '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;
}
}
3 changes: 3 additions & 0 deletions src/Module.php
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ class Module extends NwidartModule
'tagsUpdate',
'assignments',
'createAssignment',
'restoreRevision',
'showView',
'listRevisions',
];

/**
Expand Down
14 changes: 9 additions & 5 deletions src/Providers/RouteServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -361,7 +361,9 @@ protected function bootMacros()
// 'feature',
// 'preview',
// 'bulkFeature',
// 'restoreRevision',
'showView',
'listRevisions',
'restoreRevision',

'restore',
'bulkRestore',
Expand Down Expand Up @@ -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') {
Expand Down Expand Up @@ -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);
}

Expand Down
Loading
Loading