Drupal Design App Modules - NCIOCPL/cgov-digital-platform GitHub Wiki
Application Modules allow us to embed applications within a section of the web site. Additionally, content owners should be able to manage the contents above and below the modules as well.
There are key requirements which apply to most of the application modules which will drive the design. Any approach that does not take these requirements into account will not be able to support our needs.
- Applications must live within the navigation structure of the web site
- Many of these are highlighted in the navigation, which only allow nodes as a landing page
- Some components of the modules must be indexable by search engines, and those must be rendered server side.
- Those components that must be indexable also need metadata based on the applications state, not the data within the CMS
An application module should own all the routes it needs under a certain URL. For example the dictionary module owns:
- /publications/dictionaries/cancer-terms
- /publications/dictionaries/cancer-terms/def/*
Within Drupal a single web page exists with a route /node/123 and can have an alias, /foo/bar. So we must make Drupal listen for more than just the alias for our application pages.
This design is borrowing concepts from Views and the Views Reference modules.
- A view is a configuration entity that defines the plugins that will handle the query, manipulation and rendering of data. You can have many View configurations in the system each with its own set of settings. There are common configuration items to all views and handlers, but through the plugins you can radically change how the view is handled. The View module is really just the framework to support all those configurable plugins.
- The views reference module provides a field that allows a content editor to choose which view and display they would like to add to their page, as well as instance specific settings.
- An example of this is the Press Release View, which has an Archive display that takes in a year parameter. We have 5 pages on the site referencing this view, but they each define a different year to display.
- NOTE: The Views Reference module does not change the settings based on the type of view, but instead allows a developer to create new settings for all views references in a plugin format.
The cgov_application_page
is a node type that "hosts" application modules. Since the site navigation only allows nodes to be landing pages, and many of the applications on the site are highlighted in the site navigation.
This entity will allow developers to create a configuration which will represent their application module. This is core to all of the other parts of the app_module module.
- AppModuleInterface - the interface defining any app module instance.
-
AppModule - A loaded instance of an app_module configuration entity.
- This would be defined in a
app_modules.app_module.<id>.yml
config file.
- This would be defined in a
- AppModules - A static helper class to get information about App Modules
This section describes the components in creating and managing the App Module Entities.
The ConfigEntity attribute that identifies a class as a ConfigEntity define the handlers for the listing, add, edit and delete admin interfaces. For the AppModule these are:
- AppModuleListBuilder - UI for listing all the app modules within the admin interface
- AppModuleForm - Form for adding and editing app module entities.
- AppModuleDeleteForm - UI for deleting the app module
Associating an App Module with an entity and managing that association are done through the Field Plugin system. This leverages the EntityReferenceItem Field Plugins.
- AppModuleReferenceFieldFormatter - The field formatter that knows how to get the App Module Plugin to "render" the contents. See section Rendering App Modules below for more details.
-
AppModuleReferenceItem - A field type to store an instance of an App Module and the individual instance settings
- Think about the dictionary here where there is one code base, in which each dictionary is just a setting. So 1 app module, 4 instances. (Dictionary of Cancer Terms, Español Dictionary of Cancer Terms, Drug Dictionary, Genetics Dictionary)
- AppModuleReferenceSelectWidget - This is the editing form with how the editor can select which app module to reference, as well as uses the App Module Plugin to draw the instance settings and save the results.
The application module plugin defines the actual implementation of the application module.
Drupal provides a plugin system that provides a mechanism to create implementions that are "late binding." Although since PHP is interpreted and not compiled, the concept does not really exist in this way. They key thing is that actual PHP code does reference concrete implementations but instead refers to interfaces, the Drupal plugin system provides mechanisms to discover available plugins and provides ways to instantiate the implementations. More can be found at Plugin API if you really don't like my 10,000 overview.
-
Discovery - Used to find the plugins
- AppModulePluginInterface - This is an interface which is used when referring to the app module plugins.
- AppModulePlugin - This is the class that represents the annotation that decorates the implementations of AppModulePluginInterface.
- AppModulePluginManager - The class that is used to discover all concrete implementations of an app module plugin. (What implements AppModulePluginInterface and has an AppModulePlugin annotation)
-
Implementation - What you need to use to create an app module. You implementation needs to fetch data and then create a render array that will be used to render that data.
- AppModulePluginBase - This is the base base class for all app module implementations. This is only used by the most simple implementations, and then you do all the work. Use this for React apps that have no server side routes other than to serve up a React JS App.
- DummyAppModulePlugin - A concrete implementation that does nothing other than act as a placeholder.
-
MultiRouteAppModulePluginBase - This is an app module that is built to support apps that have multiple routes, and therefore multiple user interfaces. Use this for Dictionary, CTS, anything with more than one server-side route.
- MultiRouteAppModuleBuilderInterface - Interface for defining a single route of a MultiRouteAppModulePluginBase.
- MultiRouteAppModuleBuilderBase - A base class for implementing MultiRouteAppModuleBuilderInterface.
NOTE: An astute reader will notice that the AppModulePluginInterface has a getCacheInfo(...) method. This is used to add caching information to the render array that is returned by the AppModuleReferenceFieldFormatter. SO, Even IF you don't implement any caching, please remember to add cache tags so that we may clear out the cache.
Before we delve into the topics of routing and rendering you need to understand the relationship of how you get from a node to a plugin. Some of this has been covered already, some has not.
- A node has 1 alias.
- Technically it can have more than one, but you will only ever be able to get to one when you do lookups, so assume it only has 1.
- A node has a single AppModuleReferenceItem field that has a cardinality of 1.
- Don't try to have multiple app module fields on a node. Will it break? It should not. However, you have not figured out how routing works yet, so you don't know how to do it correctly.
- In the case of cgov_application_page that field is
field_application_module
.
- That AppModuleReferenceItem has a
target_id
property that holds the machine name of the AppModule config entity, which can be used to load that AppModule entity. - With a loaded AppModule entity you can load the AppModulePluginInterface which knows how to render the app module.
This is a giant PITA. Mainly because we need to be fast and lightweight when working with anything in the Drupal route processing. Why? Because all requests will flow through our code, not just the requests for app modules.
Our App Module plugin needs to define the paths which that app module supports. If a user requests a url that would be a candidate for being an app module, but is not found in the known paths, then the app module should not handle the request. (Which should most likely result in a 404) Take the following for examples:
- A cgov_application_page node type is created at
/about-cancer/treatment/clinical-trials/search
. - That page is configured to point to the CTS app module
- The CTS app module supports
/
,/advanced-search
,/r
,/v
. - A user visits the url
/about-cancer/treatment/clinical-trials/search/r
.- The application page is loaded and the results display is rendered to the user.
- A user visits the url
/about-cancer/treatment/clinical-trials/search/asdf
- This is not a valid route for the app module, so the application page is not loaded.
Much of this design was due to a request so that Drupal could handle 404s outside of the application modules. This is less of a concern for server-side apps that can throw NotFoundExceptions, but for client-side apps that would have to implement wild card routes in order to handle 404s. So take care to only define the routes that your module should handle.
Each Node on our site is required to have an alias, as such, the system "knows" the base url of any application. When a url is requested, Drupal manipulates the incoming path in order to determine which route should be used. This is done through a class implementing the InboundPathProcessorInterface. See Drupaly Things for more information on this interaction.
As discussed previously, all requests go through the path processor. So we must be quick. As such, we have to create a lookup of the Entity's (e.g. Node) alias, route, app_module_id and the instance data for the app module. Then we a request comes in, we see if the URL starts with on of our app module's alias, then we load the plugin and ask the plugin if the route is valid.
Oh, and if it was a valid we add a query param to store the app_module_route
to the request so that other "things" know what route to use.
TODO: Add a sequence diagram here.
HOOOOOOOKS!
I put rendering in quotes as what we really mean is creating a render array that will go through the Drupal rendering system and produce HTML.
Normall, this is done at a page level by creating a .routing.yml file that defines the route and the controller which will generate the render array. The controller is of course a method of a class, or other magic sometimes (e.g. form) that takes inputs and returns a render array or a response.
We need to reproduce this on a smaller scale through our AppModuleReferenceFieldFormatter.
-
AppModuleRenderArrayBuilder - The guts behind the build array generation. This creates a wrapping
#theme => 'app_module'
build object that encapsulates the result of an AppModulePluginInterface::buildForRoute method.- This uses the
app_module_route
request parameter set in the routing to create the correct build.
- This uses the
- Template
- Theme & Suggestion Hooks
The best approach for managing JS & CSS is to just use the built-in system that already exists. A developer would just create a .library.yml file and reference the JS & CSS needed within the template files for the app.
NOTES: This will need handlers kind of like entity. Done through annotations on the AppModule. Handlers:
- Instance Settings Form Handling (used by AppModuleReferenceSelectWidget)
- Metadata Handler - Used for setting metadata
- Controller Handler - Used for loading the various models?
- As a module may have different screens to display based on the path, how can we make a mini-mvc?
- Display Handler - Used for displaying the results
- How does this interact with the Controller Handler? Maybe there is no difference between this and the controller. Maybe we need a routing handler - this loads the data and passes it to the correct controller method. Can we leverage the symfony routing crap and loaders?
It will not be good enough to have a class implementing InboundPathProcessorInterface since it will need to be quick as every request will go through the interface. This method will need to:
- Load all the aliases that are nodes "hosting" an app module. (Let's not limit it to cgov_application_page please)
- Then it will ensure the requested url begins with one of those aliases.
- Then it will load the app module for that node.
- It ensures the the url matches on of the module routes
- It stores off the app module portion of the route and then sets the path to be that of the "hosting node's" alias.
- Drupal does the rest to load the node and render it.
As many of these steps will be slow to fetch the data needed, we will need to cache:
- The alias name
- The app module id
- The app module instance configuration (?) NOTE: This is pretty much a good portion of what the AliasManager does, FYI, so that pattern should be copied as well. (It is already built for speed)
In order to store that information we will need to implement a method for updating that data when a node is stored.
- Create a Class that implements the InboundPathProcessorInterface
Each Node on our site is required to have an alias, as such, the system "knows" the base url of any application.When a url is requested, Drupal manipulates the incoming path in order to determine which route should be used. This is done through a class implementing the InboundPathProcessorInterface. See Drupaly Things for more information on this interaction.
This could be fetched either by:
- Join the Node table to the alias table and filter on the Node bundle.
For react only apps this is simple as the app handles everything client side.
For these apps the server
-
Each App Module could be created as a Configuration Entity
- This allows for some configuration settings for the specific module, but nothing in the UI - you are just referencing a block of code.
-
OR, the appmodule could be a plugin type, and developers implement plugins
-
How do we associate the node with a module?
- We can create an appmodule field that allows a user to:
- Select an app module -> this is via discovery
- Enter an instance configuration -> this could be built via a method on the plugin. Stored as serialized data.
- This would give us a hook to render the contents as the DisplayWidget needs to build a view.
- Hrmm... when loading the data could we fetch any data??
- We can create an appmodule field that allows a user to:
-
We have the Preprocessor that sets up the App Module Context.
-
We could subscribe to some event if we are a module, which could handle the loading of the requested object. We would have to know the app module on a page to load that.
I am thinking this thing could work like a block. But how do we sneak in page attachment events and such? It really is like we need to load the data and whatnot before we get to the build - of course when does that happen?
I guess we know the node that we are on in the event handlers. So we could lookup the reference field, load the module, initialize it, and then hold the reference for the build.
-
For metadata handling we should probably use tokens.
- This may require the app module to have fetched its data and everything, but before the node, and thus the field, is rendered.
- This will also require a framework for plugging in a AppModule handler class for handling the metadata. The token would be called and then the handler would be called to fetch the metadata for that module.