ParentableContent Module
Provides abstraction layer required to allow models to become parents of other models.
Relationships are configurable which means you can toggle parent/children on and off.
At this stage everything is defined in config but may be moved to features down the track (ref. District/FeatureApi).
How to define a parent model?
A parent model should:
- Implement
ParentableInterface - Use the trait
HasChildContent - Declare a config under
parentable_content.parentables(seeConfig/parentable_content.php) - Register its children relationships by calling
app(parentableContentService::class)->registerParentableChildrenRelationships(new YourParentModel());in theboot()method of its module service provider.
See example under district/engage module.
How to define a child model?
A child model should:
- Have a parent column in its DB definition (
$table->nullableMorphs('parent', 'parent');) - Implement
ChildOfParentableInterface - Use the trait
HasParentable - Add missing methods required by
ChildOfParentableInterface - Declare a
registerRoutesWithParentmethod to define routes for each controller type (dash, frontend, etc...) - Declare a config under
parentable_content.child_content(seeConfig/parentable_content.php)
See example under district/events module.
How to configure parent frontend layout to render child content?
- Add config value
frontend_layout_namewith the name of the frontend layout component - Implement
getFrontendLayoutProps()by passing props specific to the parent layout
Dashboard controllers
- Define an abstract controller (i.e
BaseYourModelController) that will be called by both global and child content. - Define a controller for child routes (i.e
YourModelChildController) that implementsModules\ParentableContent\Contracts\HasParentableDashControllerInterfaceand uses the traitModules\ParentableContent\Traits\HasParentableDashController - Make sure that your base controller extends
Modules\Core\Http\Controllers\Dashboard\BaseDashboardController
or has the following functions (overridden byHasParentableControlleras required):
getModelWithAttrAndRelations()- required byHasParentableControllerInterface
/**
* Used by getModel() so that logic can be updated in traits.
*/
public function getModelWithAttrAndRelations(?Model $model = null, array $append = []): Model
{
// example.
$someModel = is_null($model) ? new SomeModel() : $model;
return $someModel->append(static::$globalAppends)
->load(static::$globalRelationships);
}
getModel()
/**
* Get a new model instance OR append data and relationship to existing one.
*
* Definition follows Modules\ParentableContent\Traits\HasParentableController::getModel()
* to allow overriding at Trait level and setting parent context on model.
*/
protected function getModel(?Model $model = null, array $append = []): Model
{
return $this->getModelWithAttrAndRelations($model);
}
getParentRoute()
/**
* Retrieves the controller parent route.
*/
protected function getParentRoute(): string
{
return $this->parentRoute;
}
- Create a dedicated Request class for creation/edition of your child model to include validation rules of your parent field. It can extend the request used for global creation/edition and use
Modules\ParentableContent\Traits\HasParentableRequest.
Frontend controllers
- Define an abstract controller (i.e
BaseYourModelController) that will be called by both global and child content. - Define a controller for child routes (i.e
YourModelChildController) that uses the traitModules\ParentableContent\Traits\HasParentableFrontendController - Make sure that your base controller extends
Modules\Core\Http\Controllers\Controller - For each controller method, pass an array of props
$extraViewPropsto override frontend layout used with parent layout and pass parent layout specific props. Using controller helper function:$viewProps = $this->getFrontendViewProps($parentableModel);
See example under district/events module at Modules\Event\Http\Controllers\Frontend\FrontendEventChildController
How to add parent context to child blocks used in block builder?
Child blocks listed from parent's block builder
- Make sure global block builder routes (without parentable) are registered without relying on global config to be enabled.
- Add/Update the
blockBuilderIndexfunction of your global controller with logic to support supply of parent context and pass the query to your base controller e.g:
/** @var ParentableContentService $parentableContentService */
$parentableContentService = app(ParentableContentService::class);
if ($parentableModelContext = $parentableContentService->loadParentableModelFromTypeId(
$request->get(BlockService::MODEL_CONTEXT_TYPE_KEY),
(int) $request->get(BlockService::MODEL_CONTEXT_ID_KEY)
)) {
return $this->blockBuilderModelIndex($parentableModelContext->yourChildListRelationship()->getQuery());
}
return $this->blockBuilderModelIndex(YourChildModel::query());
Child blocks listed from other child's block builder with the same parent
- Make sure child block builder routes (with parentable) are registered. This should be done from
registerRoutesWithParent()in the child model class. - Make sure the
optionsUrl()function of your child block plugin (e.gModules\YourModule\Plugins\Blocks\ChildModelBlock) callsModules\ParentableContent\Services\RouteService::makeParentableDashUrlWhenContextRouteParam($routePrefix, $urlParams)to generate a different optionsUrl when in parentable context, e.g:
$urlParams = ['customParam' => 'value']; // optional parameters.
$routePrefix = YourChildRouteService::BLOCK_BUILDER_ROUTE;
return RouteService::makeParentableDashUrlWhenContextRouteParam($routePrefix, $urlParams);
- Add a
blockBuilderIndexfunction to your ChildController passing a query of parent/children relationship to your base controller e.g:
return $this->blockBuilderModelIndex($parentableModel->yourChildListRelationship()->getQuery());
Testing
Ideally the addition of new child content should involve the following tests (at a minimum):
- Feature test classes in your child content module for all child routes (dashboard and frontend) with the parent model set to use
Modules\ParentableContent\Tests\Models\StubParent.
See example atModules\Event\Tests\Feature\Dashboard\DashEventChildRouteTest - Integration tests on parent module for your new child routes in parent context.
See example atModules\Engage\Tests\Integration\manage-project-events.spec.jsandModules\Engage\Tests\Integration\block-builder-child-content-embed.spec.js
How does it work behind the scene?
Dynamic relationships
This is done in parentableContentService::registerParentableChildrenRelationships() called by the parent module service provider, in charge of looping through the configured children of given parent and defining morphMany relationship.
For more advanced useage, a relationship can be further modified (eg adding filters or sorting) by defining a morph_many_alter callback in child config, then a matching method that accepts and returns the morphMany object.
Example
Child config
'child_content' => [
'my_models' => [
...
'morph_many_alter' => 'MyClass::parentableMorphManyAlter',
...
]
]
Callback
class MyClass {
public static function parentableMorphManyAlter(MorphMany $query): MorphMany
{
return $query->where('foo', 'bar');
}
}
Route definition.
Routes files (e.g Routes/dash.php) call Modules\ParentableContent\Services\RouteService in charge of looping through every configured "parentable" model and calling the child model route definition function.
Every route is prefixed with parent route and generic parent route model key ParentableContentService::PARENTABLE_MODEL_ROUTE_KEY that gets resolved via a custom route model binding logic.
Route model binding resolution of ParentableModel.
This is done RouteServiceProvider under registerParentableModelBinding() by looking at the type of model in the URL preceding the key, e.g /projects/{parentableModel}.
TODO
- Add a content bundle as child to validate POC (changes most likely required).
- Review permissions to be follow parent context.