diff --git a/config/filament-fabricator.php b/config/filament-fabricator.php
index a3a9fcb..6575906 100644
--- a/config/filament-fabricator.php
+++ b/config/filament-fabricator.php
@@ -39,6 +39,20 @@
'enable-view-page' => false,
+ /**
+ * Whether to hook into artisan's core commands to clear and refresh page route caches along with the rest.
+ * Disable for manual control over cache.
+ *
+ * This is the list of commands that will be hooked into:
+ * - cache:clear -> clear routes cache
+ * - config:cache -> refresh routes cache
+ * - config:clear -> clear routes cache
+ * - optimize -> refresh routes cache
+ * - optimize:clear -> clear routes cache
+ * - route:clear -> clear routes cache
+ */
+ 'hook-to-commands' => true,
+
/*
* This is the name of the table that will be created by the migration and
* used by the above page-model shipped with this package.
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
index 0d4b012..aa658b0 100644
--- a/phpunit.xml.dist
+++ b/phpunit.xml.dist
@@ -20,7 +20,7 @@
>
- tests
+ tests/
diff --git a/src/Commands/ClearRoutesCacheCommand.php b/src/Commands/ClearRoutesCacheCommand.php
new file mode 100644
index 0000000..b5be09a
--- /dev/null
+++ b/src/Commands/ClearRoutesCacheCommand.php
@@ -0,0 +1,68 @@
+option('refresh');
+
+ /**
+ * @var PageContract[] $pages
+ */
+ $pages = FilamentFabricator::getPageModel()::query()
+ ->whereNull('parent_id')
+ ->with('allChildren')
+ ->get();
+
+ foreach ($pages as $page) {
+ $this->clearPageCache($page, $shouldRefresh);
+
+ if ($shouldRefresh) {
+ $this->pageRoutesService->updateUrlsOf($page);
+ }
+ }
+
+ return static::SUCCESS;
+ }
+
+ protected function clearPageCache(PageContract $page, bool $shouldRefresh = false)
+ {
+ $this->pageRoutesService->removeUrlsOf($page);
+ $argSets = $page->getAllUrlCacheKeysArgs();
+
+ foreach ($argSets as $args) {
+ $key = $page->getUrlCacheKey($args);
+ Cache::forget($key);
+
+ if ($shouldRefresh) {
+ // Caches the URL before returning it
+ /* $noop = */ $page->getUrl($args);
+ }
+ }
+
+ $childPages = $page->allChildren;
+
+ if (filled($childPages)) {
+ foreach ($childPages as $childPage) {
+ $this->clearPageCache($childPage, $shouldRefresh);
+ }
+ }
+ }
+}
diff --git a/src/Facades/FilamentFabricator.php b/src/Facades/FilamentFabricator.php
index a6106ca..9f214cf 100644
--- a/src/Facades/FilamentFabricator.php
+++ b/src/Facades/FilamentFabricator.php
@@ -2,6 +2,7 @@
namespace Z3d0X\FilamentFabricator\Facades;
+use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Facade;
use Z3d0X\FilamentFabricator\Models\Contracts\Page as PageContract;
@@ -25,8 +26,8 @@
* @method static array getScripts()
* @method static array getStyles()
* @method static ?string getFavicon()
- * @method static class-string getPageModel()
- * @method static string getRoutingPrefix()
+ * @method static class-string getPageModel()
+ * @method static ?string getRoutingPrefix()
* @method static array getPageUrls()
* @method static ?string getPageUrlFromId(int $id, bool $prefixSlash = false)
*
diff --git a/src/FilamentFabricatorManager.php b/src/FilamentFabricatorManager.php
index 17d53b7..0dfbb34 100644
--- a/src/FilamentFabricatorManager.php
+++ b/src/FilamentFabricatorManager.php
@@ -4,12 +4,12 @@
use Closure;
use Illuminate\Support\Collection;
-use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Str;
use Z3d0X\FilamentFabricator\Layouts\Layout;
use Z3d0X\FilamentFabricator\Models\Contracts\Page as PageContract;
use Z3d0X\FilamentFabricator\Models\Page;
use Z3d0X\FilamentFabricator\PageBlocks\PageBlock;
+use Z3d0X\FilamentFabricator\Services\PageRoutesService;
class FilamentFabricatorManager
{
@@ -33,8 +33,16 @@ class FilamentFabricatorManager
protected array $pageUrls = [];
- public function __construct()
+ /**
+ * @note It's only separated to not cause a major version change.
+ * In the next major release, feel free to make it a constructor promoted property
+ */
+ protected PageRoutesService $routesService;
+
+ public function __construct(?PageRoutesService $routesService = null)
{
+ $this->routesService = $routesService ?? resolve(PageRoutesService::class);
+
/** @var Collection */
$pageBlocks = collect([]);
@@ -172,44 +180,25 @@ public function getRoutingPrefix(): ?string
return null;
}
- return Str::start(config('filament-fabricator.routing.prefix'), '/');
- }
+ $prefix = Str::start($prefix, '/');
- public function getPageUrls(): array
- {
- return Cache::rememberForever('filament-fabricator::page-urls', function () {
- $this->getPageModel()::query()
- ->select('id', 'slug', 'title')
- ->whereNull('parent_id')
- ->with(['allChildren'])
- ->get()
- ->each(fn (PageContract $page) => $this->setPageUrl($page)); // @phpstan-ignore-line
+ if ($prefix === '/') {
+ return $prefix;
+ }
- return $this->pageUrls;
- });
+ return rtrim($prefix, '/');
}
- public function getPageUrlFromId(int|string $id, bool $prefixSlash = false): ?string
+ public function getPageUrls(): array
{
- $url = $this->getPageUrls()[$id];
-
- if ($routingPrefix = $this->getRoutingPrefix()) {
- $url = Str::start($url, $routingPrefix);
- }
-
- return $url;
+ return $this->routesService->getAllUrls();
}
- protected function setPageUrl(PageContract $page, ?string $parentUrl = null): string
+ public function getPageUrlFromId(int|string $id, bool $prefixSlash = false, array $args = []): ?string
{
- $pageUrl = $parentUrl ? $parentUrl . '/' . trim($page->slug, " \n\r\t\v\x00/") : trim($page->slug);
-
- if (filled($page->allChildren)) {
- foreach ($page->allChildren as $child) {
- $this->setPageUrl($child, $pageUrl);
- }
- }
+ /** @var ?PageContract $page */
+ $page = $this->getPageModel()::query()->find($id);
- return $this->pageUrls[$page->id] = Str::start($pageUrl, '/');
+ return $page?->getUrl($args);
}
}
diff --git a/src/FilamentFabricatorServiceProvider.php b/src/FilamentFabricatorServiceProvider.php
index cbbd638..2e68910 100644
--- a/src/FilamentFabricatorServiceProvider.php
+++ b/src/FilamentFabricatorServiceProvider.php
@@ -2,7 +2,9 @@
namespace Z3d0X\FilamentFabricator;
+use Illuminate\Console\Events\CommandFinished;
use Illuminate\Filesystem\Filesystem;
+use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use ReflectionClass;
@@ -12,7 +14,10 @@
use Symfony\Component\Finder\SplFileInfo;
use Z3d0X\FilamentFabricator\Facades\FilamentFabricator;
use Z3d0X\FilamentFabricator\Layouts\Layout;
+use Z3d0X\FilamentFabricator\Listeners\OptimizeWithLaravel;
+use Z3d0X\FilamentFabricator\Observers\PageRoutesObserver;
use Z3d0X\FilamentFabricator\PageBlocks\PageBlock;
+use Z3d0X\FilamentFabricator\Services\PageRoutesService;
class FilamentFabricatorServiceProvider extends PackageServiceProvider
{
@@ -43,6 +48,7 @@ protected function getCommands(): array
$commands = [
Commands\MakeLayoutCommand::class,
Commands\MakePageBlockCommand::class,
+ Commands\ClearRoutesCacheCommand::class,
];
$aliases = [];
@@ -65,7 +71,7 @@ public function packageRegistered(): void
parent::packageRegistered();
$this->app->singleton('filament-fabricator', function () {
- return new FilamentFabricatorManager;
+ return resolve(FilamentFabricatorManager::class);
});
}
@@ -73,17 +79,12 @@ public function bootingPackage(): void
{
if (! $this->app->runningInConsole()) {
Route::bind('filamentFabricatorPage', function ($value) {
- $pageModel = FilamentFabricator::getPageModel();
+ /**
+ * @var PageRoutesService $routesService
+ */
+ $routesService = resolve(PageRoutesService::class);
- $pageUrls = FilamentFabricator::getPageUrls();
-
- $value = Str::start($value, '/');
-
- $pageId = array_search($value, $pageUrls);
-
- return $pageModel::query()
- ->where('id', $pageId)
- ->firstOrFail();
+ return $routesService->findPageOrFail($value);
});
$this->registerComponentsFromDirectory(
@@ -92,7 +93,7 @@ public function bootingPackage(): void
config('filament-fabricator.layouts.path'),
config('filament-fabricator.layouts.namespace')
);
-
+
$this->registerComponentsFromDirectory(
PageBlock::class,
config('filament-fabricator.page-blocks.register'),
@@ -102,6 +103,17 @@ public function bootingPackage(): void
}
}
+ public function packageBooted()
+ {
+ parent::packageBooted();
+
+ FilamentFabricator::getPageModel()::observe(PageRoutesObserver::class);
+
+ if ((bool) config('filament-fabricator.hook-to-commands')) {
+ Event::listen(CommandFinished::class, OptimizeWithLaravel::class);
+ }
+ }
+
protected function registerComponentsFromDirectory(string $baseClass, array $register, ?string $directory, ?string $namespace): void
{
if (blank($directory) || blank($namespace)) {
diff --git a/src/Http/Controllers/PageController.php b/src/Http/Controllers/PageController.php
index 900e2cf..9422b1f 100644
--- a/src/Http/Controllers/PageController.php
+++ b/src/Http/Controllers/PageController.php
@@ -6,6 +6,7 @@
use Z3d0X\FilamentFabricator\Facades\FilamentFabricator;
use Z3d0X\FilamentFabricator\Layouts\Layout;
use Z3d0X\FilamentFabricator\Models\Contracts\Page;
+use Z3d0X\FilamentFabricator\Services\PageRoutesService;
class PageController
{
@@ -13,14 +14,13 @@ public function __invoke(?Page $filamentFabricatorPage = null): string
{
// Handle root (home) page
if (blank($filamentFabricatorPage)) {
- $pageUrls = FilamentFabricator::getPageUrls();
-
- $pageId = array_search('/', $pageUrls);
+ /**
+ * @var PageRoutesService $routesService
+ */
+ $routesService = resolve(PageRoutesService::class);
/** @var Page $filamentFabricatorPage */
- $filamentFabricatorPage = FilamentFabricator::getPageModel()::query()
- ->where('id', $pageId)
- ->firstOrFail();
+ $filamentFabricatorPage = $routesService->findPageOrFail('/');
}
/** @var ?class-string $layout */
diff --git a/src/Listeners/OptimizeWithLaravel.php b/src/Listeners/OptimizeWithLaravel.php
new file mode 100644
index 0000000..6dffeb1
--- /dev/null
+++ b/src/Listeners/OptimizeWithLaravel.php
@@ -0,0 +1,66 @@
+shouldHandleEvent($event)) {
+ return;
+ }
+
+ if ($this->shouldRefresh($event)) {
+ $this->refresh();
+ } else {
+ $this->clear();
+ }
+ }
+
+ public function shouldHandleEvent(CommandFinished $event)
+ {
+ return $event->exitCode === Command::SUCCESS
+ && in_array($event->command, static::COMMANDS);
+ }
+
+ public function shouldRefresh(CommandFinished $event)
+ {
+ return in_array($event->command, static::REFRESH_COMMANDS);
+ }
+
+ public function refresh()
+ {
+ $this->callCommand([
+ '--refresh' => true,
+ ]);
+ }
+
+ public function clear()
+ {
+ $this->callCommand();
+ }
+
+ public function callCommand(array $params = [])
+ {
+ Artisan::call(ClearRoutesCacheCommand::class, $params);
+ }
+}
diff --git a/src/Models/Concerns/HandlesPageUrls.php b/src/Models/Concerns/HandlesPageUrls.php
new file mode 100644
index 0000000..405a422
--- /dev/null
+++ b/src/Models/Concerns/HandlesPageUrls.php
@@ -0,0 +1,112 @@
+ $args
+ */
+ public function getUrlCacheKey(array $args = []): string
+ {
+ // $keyArgs = collect($this->getDefaultUrlArgs())->merge($args)->all();
+ $id = $this->id;
+
+ return "filament-fabricator::page-url--$id";
+ }
+
+ /**
+ * Get the URL determined by this entity and the provided arguments
+ *
+ * @param array $args
+ */
+ public function getUrl(array $args = []): string
+ {
+ $cacheKey = $this->getUrlCacheKey($args);
+
+ //NOTE: Users must run the command that clears the routes cache if the routing prefix ever changes
+
+ return Cache::rememberForever($cacheKey, function () use ($args) {
+ /**
+ * @var ?Page $parent
+ */
+ $parent = $this->parent;
+
+ // If there's no parent page, then the "parent" URI is just the routing prefix.
+ $parentUri = is_null($parent) ? (FilamentFabricator::getRoutingPrefix() ?? '/') : $parent->getUrl($args);
+
+ // Every URI in cache has a leading slash, this ensures it's
+ // present even if the prefix doesn't have it set explicitly
+ $parentUri = Str::start($parentUri, '/');
+
+ // This page's part of the URL (i.e. its URI) is defined as the slug.
+ // For the same reasons as above, we need to add a leading slash.
+ $selfUri = $this->slug;
+ $selfUri = Str::start($selfUri, '/');
+
+ // If the parent URI is the root, then we have nothing to glue on.
+ // Therefore the page's URL is simply its URI.
+ // This avoids having two consecutive slashes.
+ if ($parentUri === '/') {
+ return $selfUri;
+ }
+
+ // Remove any trailing slash in the parent URI since
+ // every URIs we'll use has a leading slash.
+ // This avoids having two consecutive slashes.
+ $parentUri = rtrim($parentUri, '/');
+
+ return "{$parentUri}{$selfUri}";
+ });
+ }
+
+ /**
+ * Get all the available argument sets for the available cache keys
+ *
+ * @return array[]
+ */
+ public function getAllUrlCacheKeysArgs(): array
+ {
+ // By default, the entire list of available URL cache keys
+ // is simply a list containing the default one since we can't
+ // magically infer all the possible state for the library user's customizations.
+ return [
+ $this->getDefaultUrlCacheArgs(),
+ ];
+ }
+
+ /**
+ * Get all the available URLs for this entity
+ *
+ * @return string[]
+ */
+ public function getAllUrls(): array
+ {
+ return array_map([$this, 'getUrl'], $this->getAllUrlCacheKeysArgs());
+ }
+
+ /**
+ * Get all the cache keys for the available URLs for this entity
+ *
+ * @return string[]
+ */
+ public function getAllUrlCacheKeys(): array
+ {
+ return array_map([$this, 'getUrlCacheKey'], $this->getAllUrlCacheKeysArgs());
+ }
+}
diff --git a/src/Models/Contracts/HasPageUrls.php b/src/Models/Contracts/HasPageUrls.php
new file mode 100644
index 0000000..d245a5d
--- /dev/null
+++ b/src/Models/Contracts/HasPageUrls.php
@@ -0,0 +1,44 @@
+table)) {
@@ -32,12 +34,6 @@ public function __construct(array $attributes = [])
'parent_id' => 'integer',
];
- protected static function booted()
- {
- static::saved(fn () => Cache::forget('filament-fabricator::page-urls'));
- static::deleted(fn () => Cache::forget('filament-fabricator::page-urls'));
- }
-
public function parent(): BelongsTo
{
return $this->belongsTo(static::class, 'parent_id');
@@ -50,7 +46,7 @@ public function children(): HasMany
public function allChildren(): HasMany
{
- return $this->hasMany(static::class, 'parent_id')
+ return $this->children()
->select('id', 'slug', 'title', 'parent_id')
->with('allChildren:id,slug,title,parent_id');
}
diff --git a/src/Observers/PageRoutesObserver.php b/src/Observers/PageRoutesObserver.php
new file mode 100644
index 0000000..bfb0d08
--- /dev/null
+++ b/src/Observers/PageRoutesObserver.php
@@ -0,0 +1,112 @@
+pageRoutesService->updateUrlsOf($page);
+ }
+
+ /**
+ * Handle the Page "updated" event.
+ */
+ public function updated(PageContract&Model $page): void
+ {
+ // If the parent_id has changed, and if the relationship has already been loaded
+ // then after an update we might not read the right parent. That's why we always
+ // load it on update, this ensures we clear the old URLs properly (they were cached)
+ // and set the new ones properly (we have the right parent to do so).
+ if ($page->wasChanged('parent_id')) {
+ $page->load('parent');
+ }
+
+ $this->pageRoutesService->updateUrlsOf($page);
+ }
+
+ /**
+ * Handle the Page "deleting" event.
+ */
+ public function deleting(PageContract&Model $page): void
+ {
+ // We do the logic in `deleting` instead of `deleted` since we need access to the object
+ // both in memory and in database (e.g. to load relationship data).
+
+ // Before properly deleting it, remove its URLs from
+ // all the mappings and caches.
+ $this->pageRoutesService->removeUrlsOf($page);
+
+ // Doubly-linked list style deletion:
+ // - Re-attache the given page children to the parent of the given page
+ // - Promote the pages to a "root page" (i.e. page with no parent) if the given page had no parent
+
+ // Only load one level of children since they're the ones that will be re-attached
+ $page->load('children');
+ $children = $page->children;
+
+ foreach ($children as $childPage) {
+ /**
+ * @var Model|PageContract $childPage
+ */
+
+ // We use `?? null` followed by `?: null` to go around the cast to integer
+ // and make sure we have `null` instead of `0` when there's no parent.
+ $parentId = $page->parent_id ?? null;
+ $parentId = $parentId ?: null;
+
+ // Using update, instead of associate or dissociate, we trigger DB events (which we need)
+ $childPage->update([
+ 'parent_id' => $parentId,
+ ]);
+ }
+ }
+
+ /**
+ * Handle the Page "deleted" event.
+ */
+ public function deleted(PageContract&Model $page): void
+ {
+ // Since `deleting` is called before `deleted`, and
+ // since everything is handled there, do nothing.
+ }
+
+ /**
+ * Handle the Page "restored" event.
+ */
+ public function restored(PageContract&Model $page): void
+ {
+ $this->pageRoutesService->updateUrlsOf($page);
+ }
+
+ /**
+ * Handle the Page "force deleted" event.
+ */
+ public function forceDeleting(PageContract&Model $page): void
+ {
+ // You always go through `deleting` before going through `forceDeleting`.
+ // Since everything is properly handled in `deleting`, do nothing here.
+ }
+
+ /**
+ * Handle the Page "force deleted" event.
+ */
+ public function forceDeleted(PageContract&Model $page): void
+ {
+ // You always go through `deleted` before going through `forceDeleted`.
+ // Since everything is properly handled in `deleted`, do nothing here.
+ }
+}
diff --git a/src/Services/PageRoutesService.php b/src/Services/PageRoutesService.php
new file mode 100644
index 0000000..ead360b
--- /dev/null
+++ b/src/Services/PageRoutesService.php
@@ -0,0 +1,332 @@
+ ID) mapping based on the user provided URI.
+ // The mapping expect a URI that starts with a /
+ // thus we "normalize" the URI by ensuring it starts with one.
+ // Not doing so would result in a false negative.
+ $mapping = $this->getUriToIdMapping();
+ $uri = Str::start($uri, '/');
+
+ return $mapping[$uri] ?? -1;
+ }
+
+ /**
+ * Get an instance of your Page model from a URI, or NULL if none matches
+ *
+ * @return null|(Page&Model)
+ */
+ public function getPageFromUri(string $uri): ?Page
+ {
+ $id = $this->getPageIdFromUri($uri);
+
+ // We know the getPageIdFromUri uses -1 as a "sentinel" value
+ // for when the page is not found, so return null in those cases
+ if ($id === -1) {
+ return null;
+ }
+
+ return FilamentFabricator::getPageModel()::find($id);
+ }
+
+ /**
+ * Update the cached URLs for the given page (as well as all its descendants')
+ */
+ public function updateUrlsOf(Page&Model $page): void
+ {
+ // We mutate the mapping without events to ensure we don't have "concurrent"
+ // modifications of the same mapping. This allows us to skip the use of locks
+ // in an environment where only unrelated pages can be modified by separate
+ // users at the same time, which is a responsibility the library users
+ // should enforce themselves.
+ FilamentFabricator::getPageModel()::withoutEvents(function () use ($page) {
+ $mapping = $this->getUriToIdMapping();
+ $this->updateUrlsAndDescendantsOf($page, $mapping);
+ $this->replaceUriToIdMapping($mapping);
+ });
+ }
+
+ /**
+ * Remove the cached URLs for the given page
+ */
+ public function removeUrlsOf(Page $page): void
+ {
+ // First remove the entries from the (ID -> URI) mapping
+ $idToUrlsMapping = $this->getIdToUrisMapping();
+ $urls = $idToUrlsMapping[$page->id];
+ $idToUrlsMapping[$page->id] = null;
+ unset($idToUrlsMapping[$page->id]);
+ $this->replaceIdToUriMapping($idToUrlsMapping);
+
+ // Then remove the entries from the (URI -> ID) mapping
+ $uriToIdMapping = $this->getUriToIdMapping();
+ foreach ($urls as $uri) {
+ unset($uriToIdMapping[$uri]);
+ }
+ $this->replaceUriToIdMapping($uriToIdMapping);
+
+ // Finally, clear the page's local caches of its own URL.
+ // This means that Page::getAllUrls() and such will now compute
+ // fresh values.
+ $this->forgetPageLocalCache($page);
+ }
+
+ /**
+ * Get an instance of your Page model from a URI, or throw if there is none
+ */
+ public function findPageOrFail(string $uri): Page&Model
+ {
+ $id = $this->getPageIdFromUri($uri);
+
+ // If the page doesn't exists, we know getPageIdFromUri
+ // will return -1. Thus, findOrFail will fail as expected.
+ return FilamentFabricator::getPageModel()::findOrFail($id);
+ }
+
+ /**
+ * Get the list of all the registered URLs
+ *
+ * @return string[]
+ */
+ public function getAllUrls(): array
+ {
+ $uriToIdMapping = $this->getUriToIdMapping();
+
+ // $uriToIdMapping is an associative array that maps URIs to IDs.
+ // Thus, the list of URLs is the keys of that array.
+ // Since PHP handles keys very weirdly when using array_keys,
+ // we simply get its array_values to have a truly regular array
+ // instead of an associative array where the keys are all numbers
+ // but possibly non-sorted.
+ return array_values(array_keys($uriToIdMapping));
+ }
+
+ /**
+ * Get the URI -> ID mapping
+ *
+ * @return array
+ */
+ protected function getUriToIdMapping(): array
+ {
+ // The mapping will be cached for most requests.
+ // The very first person hitting the cache when it's not readily available
+ // will sadly have to recompute the whole thing.
+ return Cache::rememberForever(static::URI_TO_ID_MAPPING, function () {
+ // Even though we technically have 2 separate caches
+ // we want them to not really be independent.
+ // Here we ensure our initial state depends on the other
+ // cache's initial state.
+ $idsToUrisMapping = $this->getIdToUrisMapping();
+ $uriToIdMapping = [];
+
+ // We simply "reverse" the one-to-many mapping to a many-to-one
+ foreach ($idsToUrisMapping as $id => $uris) {
+ foreach ($uris as $uri) {
+ $uriToIdMapping[$uri] = $id;
+ }
+ }
+
+ return $uriToIdMapping;
+ });
+ }
+
+ /**
+ * Get the ID -> URI[] mapping
+ *
+ * @return array
+ */
+ protected function getIdToUrisMapping(): array
+ {
+ // The mapping will be cached for most requests.
+ // The very first person hitting the cache when it's not readily available
+ // will sadly have to recompute the whole thing.
+ // This could be a critical section and bottleneck depending on the use cases.
+ // Any optimization to this can greatly improve the entire package's performances
+ // in one fell swoop.
+ return Cache::rememberForever(static::ID_TO_URI_MAPPING, function () {
+ $pages = FilamentFabricator::getPageModel()::all();
+ $mapping = [];
+ $pages->each(function (Page $page) use (&$mapping) {
+ // Note that this also has the benefits of computing
+ // the page's local caches.
+ $mapping[$page->id] = $page->getAllUrls();
+ });
+
+ return $mapping;
+ });
+ }
+
+ /**
+ * Get the cached URIs for the given page
+ *
+ * @return string[]
+ */
+ protected function getUrisForPage(Page $page): array
+ {
+ $mapping = $this->getIdToUrisMapping();
+
+ return $mapping[$page->id] ?? [];
+ }
+
+ /**
+ * Update routine for the given page
+ *
+ * @param array $uriToIdMapping - The URI -> ID mapping (as a reference, to be modified in-place)
+ * @return void
+ */
+ protected function updateUrlsAndDescendantsOf(Page&Model $page, array &$uriToIdMapping)
+ {
+ // First ensure consistency by removing any trace of the old URLs
+ // for the given page. Whether local or in the URI to ID mapping.
+ $this->unsetOldUrlsOf($page, $uriToIdMapping);
+
+ // These URLs will always be fresh since we unset the old ones just above
+ $urls = $page->getAllUrls();
+
+ foreach ($urls as $uri) {
+ $id = $uriToIdMapping[$uri] ?? -1;
+
+ // If while iterating the fresh URLs we encounter one
+ // that is already mapped to the right page ID
+ // then there's nothing to do for this URL
+ // and thus continue onward with the next one.
+ if ($id === $page->id) {
+ continue;
+ }
+
+ // Otherwise, we have a URI that already exists
+ // and is mapped to the wrong ID, or it wasn't
+ // in the mapping yet. In both cases we just need
+ // to add it to the mapping at the correct spot.
+ $uriToIdMapping[$uri] = $page->id;
+ }
+
+ // Since we're recursing down the tree, we preload the relationships
+ // once, and traverse down the tree. This helps with performances.
+ // TODO: Make it work with loadMissing instead of load to reduce the number of useless DB queries
+ $page->load(['allChildren']);
+ foreach ($page->allChildren as $childPage) {
+ /**
+ * @var Page&Model $childPage
+ */
+
+ // A change in a parent page will always result
+ // in a change to its descendant. As such,
+ // we need to recompute everything that's
+ // a descendant of this page.
+ $this->updateUrlsAndDescendantsOf($childPage, $uriToIdMapping);
+ }
+ }
+
+ /**
+ * Remove old URLs of the given page from the cached mappings
+ *
+ * @param array $uriToIdMapping - The URI -> ID mapping (as a reference, to be modified in-place)
+ * @return void
+ */
+ protected function unsetOldUrlsOf(Page $page, array &$uriToIdMapping)
+ {
+ // When we're hitting this path, caches haven't been invalidated yet.
+ // Thus, we don't need to query the mappings to get the old URLs.
+ $oldUrlSet = collect($page->getAllUrls())->lazy()->sort()->all();
+
+ // Once we're done collecting the previous URLs, and since we want
+ // to unset ALL old URLs for this given page, we might as well
+ // forget its local caches here.
+ $this->forgetPageLocalCache($page);
+
+ // Since we just forgot the page's local caches, this doesn't
+ // return the old set of URLs, but instead computes and caches
+ // the new URLs based on the page's currently loaded data.
+ $newUrlSet = collect($page->getAllUrls())->lazy()->sort()->all();
+
+ // The old URLs are those that are present in the $oldUrlSet
+ // but are not present in $newUrlSet. Hence the use of array_diff
+ // whose role is to return exactly that. Also note we sorted the arrays
+ // in order to make sure the diff algorithm has every chances to be
+ // optimal in performance.
+ $oldUrls = array_diff($oldUrlSet, $newUrlSet);
+
+ // Simply go through the list of old URLs and remove them from the mapping.
+ // This is one of the reasons we pass it by reference.
+ foreach ($oldUrls as $oldUrl) {
+ unset($uriToIdMapping[$oldUrl]);
+ }
+
+ $idToUrlsMapping = $this->getIdToUrisMapping();
+ $idToUrlsMapping[$page->id] = $newUrlSet;
+ $this->replaceIdToUriMapping($idToUrlsMapping);
+ }
+
+ /**
+ * Forget all URL caches tied to the page (cf. Page::getAllUrlCacheKeys)
+ */
+ protected function forgetPageLocalCache(Page $page)
+ {
+ // The page local caches are simply those behind the
+ // URL cache keys. Compute the keys, forget the caches.
+ $cacheKeys = $page->getAllUrlCacheKeys();
+ foreach ($cacheKeys as $cacheKey) {
+ Cache::forget($cacheKey);
+ }
+ }
+
+ /**
+ * Completely replaced the cached ID -> URI[] mapping
+ *
+ * @param array $idToUriMapping
+ */
+ protected function replaceIdToUriMapping(array $idToUriMapping): void
+ {
+ // Replace the ID -> URI[] mapping with the given one.
+ // This is done "atomically" with regards to the cache.
+ // Note that concurrent read and writes can result in lost updates.
+ // And thus in an invalid state.
+ Cache::forever(static::ID_TO_URI_MAPPING, $idToUriMapping);
+ }
+
+ /**
+ * Completely replace the cached URI -> ID mapping
+ *
+ * @param array $uriToIdMapping
+ */
+ protected function replaceUriToIdMapping(array $uriToIdMapping): void
+ {
+ // Replace the URI -> ID mapping with the given one.
+ // This is done "atomically" with regards to the cache.
+ // Note that concurrent read and writes can result in lost updates.
+ // And thus in an invalid state.
+ Cache::forever(static::URI_TO_ID_MAPPING, $uriToIdMapping);
+ }
+}
diff --git a/tests/Commands/ClearRoutesCacheCommand.test.php b/tests/Commands/ClearRoutesCacheCommand.test.php
new file mode 100644
index 0000000..abf21e4
--- /dev/null
+++ b/tests/Commands/ClearRoutesCacheCommand.test.php
@@ -0,0 +1,139 @@
+toBeInstanceOf(ClearRoutesCacheCommand::class);
+ });
+
+ it('clears all route caches', function () {
+ /**
+ * @var PageRoutesService $service
+ */
+ $service = resolve(PageRoutesService::class);
+
+ /**
+ * @var Page $page
+ */
+ $page = Page::create([
+ 'title' => 'My title',
+ 'slug' => 'my-slug',
+ 'blocks' => [],
+ 'parent_id' => null,
+ ]);
+
+ /**
+ * @var Page $child
+ */
+ $child = Page::create([
+ 'title' => 'My child page',
+ 'slug' => 'my-child-page',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ $service->getAllUrls(); // ensure everything is cached beforehand
+
+ artisan('filament-fabricator:clear-routes-cache')
+ ->assertSuccessful();
+
+ expect(Cache::get('filament-fabricator::PageRoutesService::uri-to-id'))->toBeEmpty();
+ expect(Cache::get('filament-fabricator::PageRoutesService::id-to-uri'))->toBeEmpty();
+
+ $cacheKeys = [...$page->getAllUrlCacheKeys(), ...$child->getAllUrlCacheKeys()];
+
+ expect($cacheKeys)->not->toBeEmpty();
+
+ expect(
+ collect($cacheKeys)
+ ->every(fn (string $cacheKey) => ! Cache::has($cacheKey))
+ )->toBeTrue();
+ });
+
+ it('refreshes the cache properly', function (string $flag, string $newPrefix) {
+ /**
+ * @var PageRoutesService $service
+ */
+ $service = resolve(PageRoutesService::class);
+
+ /**
+ * @var Page $page
+ */
+ $page = Page::create([
+ 'title' => 'My title',
+ 'slug' => 'my-slug',
+ 'blocks' => [],
+ 'parent_id' => null,
+ ]);
+
+ /**
+ * @var Page $child
+ */
+ $child = Page::create([
+ 'title' => 'My child page',
+ 'slug' => 'my-child-page',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ $urls = collect([...$page->getAllUrls(), ...$child->getAllUrls()])->sort()->toArray();
+
+ $prevUTI = Cache::get('filament-fabricator::PageRoutesService::uri-to-id');
+ $prevUTI = collect($prevUTI)->sort()->toArray();
+
+ $prevITU = Cache::get('filament-fabricator::PageRoutesService::id-to-uri');
+ $prevITU = collect($prevITU)->sort()->toArray();
+
+ Config::set('filament-fabricator.routing.prefix', $newPrefix);
+
+ artisan('filament-fabricator:clear-routes-cache', [
+ $flag => true,
+ ])
+ ->assertSuccessful();
+
+ $newUrls = collect([...$page->getAllUrls(), ...$child->getAllUrls()])->sort()->toArray();
+ expect($newUrls)->not->toEqualCanonicalizing($urls);
+ expect($newUrls)->not->toBeEmpty();
+ expect(
+ collect($newUrls)
+ ->every(fn (string $url) => str_starts_with($url, "/$newPrefix"))
+ )->toBeTrue();
+
+ $newUTI = Cache::get('filament-fabricator::PageRoutesService::uri-to-id');
+ $newUTI = collect($newUTI)->sort()->toArray();
+ expect($newUTI)->not->toEqual($prevUTI);
+ expect($newUTI)->not->toBeEmpty();
+ expect(
+ collect($newUTI)
+ ->keys()
+ ->every(fn (string $uri) => str_starts_with($uri, "/$newPrefix"))
+ );
+
+ $newITU = Cache::get('filament-fabricator::PageRoutesService::id-to-uri');
+ $newITU = collect($newITU)->sort()->toArray();
+ expect($newITU)->not->toEqual($prevITU);
+ expect($newITU)->not->toBeEmpty();
+ expect(
+ collect($newITU)
+ ->values()
+ ->flatten()
+ ->every(fn (string $uri) => str_starts_with($uri, "/$newPrefix"))
+ );
+ })->with([
+ ['--refresh', 'newprefix'],
+ ['-R', 'np'],
+ ]);
+});
diff --git a/tests/Observers/PageRoutesObserver.test.php b/tests/Observers/PageRoutesObserver.test.php
new file mode 100644
index 0000000..dca2218
--- /dev/null
+++ b/tests/Observers/PageRoutesObserver.test.php
@@ -0,0 +1,656 @@
+delete();
+ });
+
+ describe('#created($page)', function () {
+ it('properly adds all the page\'s URLs to the mapping', function () {
+ $beforeUrls = FilamentFabricator::getPageUrls();
+
+ /**
+ * @var Page $page
+ */
+ $page = Page::create([
+ 'title' => 'My title',
+ 'slug' => 'my-slug',
+ 'blocks' => [],
+ 'parent_id' => null,
+ ]);
+
+ $sortUrls = fn (array $urls) => collect($urls)
+ ->sort()
+ ->values()
+ ->toArray();
+
+ $afterUrls = $sortUrls(FilamentFabricator::getPageUrls());
+
+ expect($afterUrls)->not->toEqual($beforeUrls);
+
+ $pageUrls = $sortUrls($page->getAllUrls());
+
+ expect($afterUrls)->toEqual($pageUrls);
+ });
+
+ it('properly works on child pages', function () {
+ /**
+ * @var Page $page
+ */
+ $page = Page::create([
+ 'title' => 'My title',
+ 'slug' => 'my-slug',
+ 'blocks' => [],
+ 'parent_id' => null,
+ ]);
+
+ /**
+ * @var Page $page
+ */
+ $child = Page::create([
+ 'title' => 'My stuff',
+ 'slug' => 'my-stuff',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ $sortUrls = fn (array $urls) => collect($urls)
+ ->sort()
+ ->values()
+ ->toArray();
+
+ $allUrls = FilamentFabricator::getPageUrls();
+ $allUrls = $sortUrls($allUrls);
+
+ $fromPages = $sortUrls([
+ ...$page->getAllUrls(),
+ ...$child->getAllUrls(),
+ ]);
+
+ $expectedUrls = $sortUrls([
+ '/my-slug',
+ '/my-slug/my-stuff',
+ ]);
+
+ expect($allUrls)->toEqual($expectedUrls);
+ expect($fromPages)->toEqual($expectedUrls);
+
+ /**
+ * @var Page $page
+ */
+ $descendant = Page::create([
+ 'title' => 'Abc xyz',
+ 'slug' => 'abc-xyz',
+ 'blocks' => [],
+ 'parent_id' => $child->id,
+ ]);
+
+ $allUrls = FilamentFabricator::getPageUrls();
+ $allUrls = $sortUrls($allUrls);
+
+ $fromPages = $sortUrls([
+ ...$page->getAllUrls(),
+ ...$child->getAllUrls(),
+ ...$descendant->getAllUrls(),
+ ]);
+
+ $expectedUrls = $sortUrls([
+ '/my-slug',
+ '/my-slug/my-stuff',
+ '/my-slug/my-stuff/abc-xyz',
+ ]);
+
+ expect($allUrls)->toEqual($expectedUrls);
+ expect($fromPages)->toEqual($expectedUrls);
+ });
+ });
+
+ describe('#updated($page)', function () {
+ it('removes the old URLs from the mapping', function () {
+ /**
+ * @var Page $page
+ */
+ $page = Page::create([
+ 'title' => 'My title',
+ 'slug' => 'my-slug',
+ 'blocks' => [],
+ 'parent_id' => null,
+ ]);
+
+ $oldUrls = $page->getAllUrls();
+
+ $page->slug = 'not-my-slug';
+ $page->save();
+
+ $allUrls = FilamentFabricator::getPageUrls();
+
+ expect($allUrls)->not->toContain(...$oldUrls);
+ });
+
+ it('adds the new URLs to the mapping', function () {
+ /**
+ * @var Page $page
+ */
+ $page = Page::create([
+ 'title' => 'My title',
+ 'slug' => 'my-slug',
+ 'blocks' => [],
+ 'parent_id' => null,
+ ]);
+
+ $page->slug = 'not-my-slug';
+ $page->save();
+
+ $sortUrls = fn (array $urls) => collect($urls)
+ ->sort()
+ ->values()
+ ->toArray();
+
+ $expected = $sortUrls(['/not-my-slug']);
+
+ $newUrls = $sortUrls($page->getAllUrls());
+
+ $allUrls = $sortUrls(FilamentFabricator::getPageUrls());
+
+ expect($allUrls)->toEqual($expected);
+ expect($newUrls)->toEqual($expected);
+ });
+
+ it('properly updates all child (and descendant) routes', function () {
+ /**
+ * @var Page $page
+ */
+ $page = Page::create([
+ 'title' => 'My title',
+ 'slug' => 'my-slug',
+ 'blocks' => [],
+ 'parent_id' => null,
+ ]);
+
+ $child1 = Page::create([
+ 'title' => 'My child 1',
+ 'slug' => 'child-1',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ $child2 = Page::create([
+ 'title' => 'My child 2',
+ 'slug' => 'child-2',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ $child3 = Page::create([
+ 'title' => 'My child 3',
+ 'slug' => 'child-3',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ $childOfChild = Page::create([
+ 'title' => 'Subchild 1',
+ 'slug' => 'subchild-1',
+ 'blocks' => [],
+ 'parent_id' => $child2->id,
+ ]);
+
+ $sortUrls = fn (array $urls) => collect($urls)
+ ->sort()
+ ->values()
+ ->toArray();
+
+ /**
+ * @var Page[] $descendants
+ */
+ $descendants = [$child1, $child2, $child3, $childOfChild];
+ $pages = [$page, ...$descendants];
+ $oldUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants);
+
+ $page->slug = 'not-my-slug';
+ $page->save();
+
+ foreach ($descendants as $descendant) {
+ $descendant->refresh();
+ }
+
+ $newUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants);
+
+ expect($newUrlSets)->not->toEqual($oldUrlSets);
+
+ $allUrls = $sortUrls(FilamentFabricator::getPageUrls());
+
+ $fromPages = $sortUrls(collect($pages)->flatMap(fn (Page $page) => $page->getAllUrls())->toArray());
+
+ $expectedUrls = $sortUrls([
+ '/not-my-slug',
+ '/not-my-slug/child-1',
+ '/not-my-slug/child-2',
+ '/not-my-slug/child-3',
+ '/not-my-slug/child-2/subchild-1',
+ ]);
+
+ expect($allUrls)->toEqual($expectedUrls);
+ expect($fromPages)->toEqual($expectedUrls);
+
+ $child2->slug = 'not-child-2-xyz';
+ $child2->save();
+
+ foreach ($descendants as $descendant) {
+ $descendant->refresh();
+ }
+
+ $allUrls = $sortUrls(FilamentFabricator::getPageUrls());
+ $fromPages = $sortUrls(collect($pages)->flatMap(fn (Page $page) => $page->getAllUrls())->toArray());
+
+ $expectedUrls = $sortUrls([
+ '/not-my-slug',
+ '/not-my-slug/child-1',
+ '/not-my-slug/not-child-2-xyz',
+ '/not-my-slug/child-3',
+ '/not-my-slug/not-child-2-xyz/subchild-1',
+ ]);
+
+ expect($allUrls)->toEqual($expectedUrls);
+ expect($fromPages)->toEqual($expectedUrls);
+ });
+
+ it('properly updates itself and descendants when changing which page is the parent (BelongsTo#associate and BelongsTo#dissociate)', function () {
+ /**
+ * @var Page $page
+ */
+ $page = Page::create([
+ 'title' => 'My title',
+ 'slug' => 'my-slug',
+ 'blocks' => [],
+ 'parent_id' => null,
+ ]);
+
+ /**
+ * @var Page $child1
+ */
+ $child1 = Page::create([
+ 'title' => 'My child 1',
+ 'slug' => 'child-1',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ /**
+ * @var Page $child2
+ */
+ $child2 = Page::create([
+ 'title' => 'My child 2',
+ 'slug' => 'child-2',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ /**
+ * @var Page $child3
+ */
+ $child3 = Page::create([
+ 'title' => 'My child 3',
+ 'slug' => 'child-3',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ /**
+ * @var Page $childOfChild
+ */
+ $childOfChild = Page::create([
+ 'title' => 'Subchild 1',
+ 'slug' => 'subchild-1',
+ 'blocks' => [],
+ 'parent_id' => $child2->id,
+ ]);
+
+ $sortUrls = fn (array $urls) => collect($urls)
+ ->sort()
+ ->values()
+ ->toArray();
+
+ /**
+ * @var Page[] $descendants
+ */
+ $descendants = [$child1, $child2, $child3, $childOfChild];
+ $pages = [$page, ...$descendants];
+ $oldUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants);
+
+ $child2->parent()->associate($child1);
+ $child2->save();
+
+ $child3->parent()->dissociate();
+ $child3->save();
+
+ foreach ($descendants as $descendant) {
+ $descendant->refresh();
+ }
+
+ $newUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants);
+
+ $fromPages = $sortUrls(collect($pages)->flatMap(fn (Page $page) => $page->getAllUrls())->toArray());
+
+ expect($newUrlSets)->not->toEqual($oldUrlSets);
+
+ $allUrls = $sortUrls(FilamentFabricator::getPageUrls());
+
+ $expectedUrls = $sortUrls([
+ '/my-slug',
+ '/my-slug/child-1',
+ '/my-slug/child-1/child-2',
+ '/child-3',
+ '/my-slug/child-1/child-2/subchild-1',
+ ]);
+
+ expect($allUrls)->toEqual($expectedUrls);
+ expect($fromPages)->toEqual($expectedUrls);
+ });
+
+ it('properly updates itself and descendants when changing which page is the parent (Model#update)', function () {
+ /**
+ * @var Page $page
+ */
+ $page = Page::create([
+ 'title' => 'My title',
+ 'slug' => 'my-slug',
+ 'blocks' => [],
+ 'parent_id' => null,
+ ]);
+
+ /**
+ * @var Page $child1
+ */
+ $child1 = Page::create([
+ 'title' => 'My child 1',
+ 'slug' => 'child-1',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ /**
+ * @var Page $child2
+ */
+ $child2 = Page::create([
+ 'title' => 'My child 2',
+ 'slug' => 'child-2',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ /**
+ * @var Page $child3
+ */
+ $child3 = Page::create([
+ 'title' => 'My child 3',
+ 'slug' => 'child-3',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ /**
+ * @var Page $childOfChild
+ */
+ $childOfChild = Page::create([
+ 'title' => 'Subchild 1',
+ 'slug' => 'subchild-1',
+ 'blocks' => [],
+ 'parent_id' => $child2->id,
+ ]);
+
+ $sortUrls = fn (array $urls) => collect($urls)
+ ->sort()
+ ->values()
+ ->toArray();
+
+ /**
+ * @var Page[] $descendants
+ */
+ $descendants = [$child1, $child2, $child3, $childOfChild];
+ $pages = [$page, ...$descendants];
+ $oldUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants);
+
+ $child2->update([
+ 'parent_id' => $child1->id,
+ ]);
+
+ $child3->update([
+ 'parent_id' => null,
+ ]);
+
+ foreach ($descendants as $descendant) {
+ $descendant->refresh();
+ }
+
+ $newUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants);
+
+ $fromPages = $sortUrls(collect($pages)->flatMap(fn (Page $page) => $page->getAllUrls())->toArray());
+
+ expect($newUrlSets)->not->toEqual($oldUrlSets);
+
+ $allUrls = $sortUrls(FilamentFabricator::getPageUrls());
+
+ $expectedUrls = $sortUrls([
+ '/my-slug',
+ '/my-slug/child-1',
+ '/my-slug/child-1/child-2',
+ '/child-3',
+ '/my-slug/child-1/child-2/subchild-1',
+ ]);
+
+ expect($allUrls)->toEqual($expectedUrls);
+ expect($fromPages)->toEqual($expectedUrls);
+ });
+
+ it('properly updates itself and descendants when changing which page is the parent (manual change and Model#save)', function () {
+ /**
+ * @var Page $page
+ */
+ $page = Page::create([
+ 'title' => 'My title',
+ 'slug' => 'my-slug',
+ 'blocks' => [],
+ 'parent_id' => null,
+ ]);
+
+ /**
+ * @var Page $child1
+ */
+ $child1 = Page::create([
+ 'title' => 'My child 1',
+ 'slug' => 'child-1',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ /**
+ * @var Page $child2
+ */
+ $child2 = Page::create([
+ 'title' => 'My child 2',
+ 'slug' => 'child-2',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ /**
+ * @var Page $child3
+ */
+ $child3 = Page::create([
+ 'title' => 'My child 3',
+ 'slug' => 'child-3',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ /**
+ * @var Page $childOfChild
+ */
+ $childOfChild = Page::create([
+ 'title' => 'Subchild 1',
+ 'slug' => 'subchild-1',
+ 'blocks' => [],
+ 'parent_id' => $child2->id,
+ ]);
+
+ $sortUrls = fn (array $urls) => collect($urls)
+ ->sort()
+ ->values()
+ ->toArray();
+
+ $descendants = [$child1, $child2, $child3, $childOfChild];
+ $pages = [$page, ...$descendants];
+ $oldUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants);
+
+ $child2->parent_id = $child1->id;
+ $child2->save();
+
+ $child3->parent_id = null;
+ $child3->save();
+
+ foreach ($descendants as $descendant) {
+ $descendant->refresh();
+ }
+
+ $newUrlSets = array_map(fn (Page $page) => $page->getAllUrls(), $descendants);
+
+ $fromPages = $sortUrls(collect($pages)->flatMap(fn (Page $page) => $page->getAllUrls())->toArray());
+
+ expect($newUrlSets)->not->toEqual($oldUrlSets);
+
+ $allUrls = FilamentFabricator::getPageUrls();
+ $allUrls = $sortUrls($allUrls);
+
+ $expectedUrls = $sortUrls([
+ '/my-slug',
+ '/my-slug/child-1',
+ '/my-slug/child-1/child-2',
+ '/child-3',
+ '/my-slug/child-1/child-2/subchild-1',
+ ]);
+
+ expect($allUrls)->toEqual($expectedUrls);
+ expect($fromPages)->toEqual($expectedUrls);
+ });
+ });
+
+ describe('#deleting($page)', function () {
+ it('removes the page\'s URLs from the mapping', function () {
+ /**
+ * @var Page $page
+ */
+ $page = Page::create([
+ 'title' => 'My title',
+ 'slug' => 'my-slug',
+ 'blocks' => [],
+ 'parent_id' => null,
+ ]);
+
+ $beforeUrls = FilamentFabricator::getPageUrls();
+
+ $page->delete();
+
+ $afterUrls = FilamentFabricator::getPageUrls();
+
+ expect($afterUrls)->not->toEqual($beforeUrls);
+
+ expect($afterUrls)->toBeEmpty();
+ });
+
+ it('sets the childrens\' parent to null if $page had no parent', function () {
+ /**
+ * @var Page $page
+ */
+ $page = Page::create([
+ 'title' => 'My title',
+ 'slug' => 'my-slug',
+ 'blocks' => [],
+ 'parent_id' => null,
+ ]);
+
+ /**
+ * @var Page $child
+ */
+ $child = Page::create([
+ 'title' => 'My child page',
+ 'slug' => 'my-child-page',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ $page->delete();
+
+ $child->refresh();
+
+ expect($child->parent_id)->toBeNull();
+
+ $urls = FilamentFabricator::getPageUrls();
+
+ $expected = ['/my-child-page'];
+
+ expect($urls)->toEqual($expected);
+ expect($child->getAllUrls())->toEqual($expected);
+ });
+
+ it('attaches the children to $page\'s parent if it had one', function () {
+ /**
+ * @var Page $page
+ */
+ $page = Page::create([
+ 'title' => 'My title',
+ 'slug' => 'my-slug',
+ 'blocks' => [],
+ 'parent_id' => null,
+ ]);
+
+ /**
+ * @var Page $child
+ */
+ $child = Page::create([
+ 'title' => 'My child page',
+ 'slug' => 'my-child-page',
+ 'blocks' => [],
+ 'parent_id' => $page->id,
+ ]);
+
+ /**
+ * @var Page $descendant
+ */
+ $descendant = Page::create([
+ 'title' => 'My sub page',
+ 'slug' => 'my-sub-page',
+ 'blocks' => [],
+ 'parent_id' => $child->id,
+ ]);
+
+ $sortUrls = fn (array $urls) => collect($urls)
+ ->sort()
+ ->values()
+ ->toArray();
+
+ $child->delete();
+ $descendant->refresh();
+ $page->refresh();
+
+ expect($descendant->parent_id)->toBe($page->id);
+
+ $urls = $sortUrls(FilamentFabricator::getPageUrls());
+
+ $expected = $sortUrls([
+ '/my-slug',
+ '/my-slug/my-sub-page',
+ ]);
+
+ $fromPages = $sortUrls(collect([$page, $descendant])->flatMap(fn (Page $page) => $page->getAllUrls())->toArray());
+
+ expect($urls)->toEqual($expected);
+ expect($fromPages)->toEqual($expected);
+ });
+ });
+});
diff --git a/tests/TestCase.php b/tests/TestCase.php
index 102661b..2040ff0 100644
--- a/tests/TestCase.php
+++ b/tests/TestCase.php
@@ -41,9 +41,11 @@ public function getEnvironmentSetUp($app)
{
config()->set('database.default', 'testing');
- /*
- $migration = include __DIR__.'/../database/migrations/create_filament-fabricator_table.php.stub';
+ $migration = include __DIR__ . '/../database/migrations/create_pages_table.php.stub';
$migration->up();
- */
+
+ $migration = include __DIR__ . '/../database/migrations/fix_slug_unique_constraint_on_pages_table.php.stub';
+ $migration->up();
+
}
}