Wednesday, September 28, 2022
HomeiOS DevelopmentHow to build a Feather CMS module?

How to build a Feather CMS module?

[ad_1]

In this tutorial I’ll show you how to create a custom user module with an admin interface for Feather using Swift 5 and Vapor 4.

Vapor


Module generation using Swift templates

There is an open source template based generator tool for Swift that I’ve created, because I needed something to quickly set up both VIPER and Feather modules. We are going to use this generator to start building our custom Feather module. You can install Swift template through the command line:


git clone https://github.com/BinaryBirds/swift-template.git
cd swift-template
make install


Now we just need a starter template, fortunately there is a template available on GitHub that you can use for generating modules that are compatible with the latest version of Feather CMS. 🪶

We are going to install this template with the following command:



swift template install https://github.com/feathercms/feather-module-template -g


Now we can bootstrap our custom module via the following command:


swift template generate MyModule --use feather-module --output ~/


You can alter the the name of the module, use an other template (must be installed locally or globally) and specify the output directory where you want to save the module files.





Building a news module for Feather CMS

In Feather CMS you can building a feature rich module in just a few minutes. That’s right, I’ll show you how to make one using Swift template and the Feather module template starter kit. First of all you’ll need to grab Feather CMS from GitHub and generate a new module using the generator.


git clone https://github.com/feathercms/feather/
cd feather
swift template generate News -u feather-module -o ./Sources/Feather/Modules
open Package.swift


Update your Swift package dependencies. You can use the Swift Package Manager and the command line (swift package update) if you are building the server without Xcode. Alternatively you can open the package manifest file and wait until Xcode resolves the dependencies. 📦


Before we run the app, make sure that you have created a local .env or .env.development file that Feather can use to run the server.


BASE_URL="http://localhost:8080"


BASE_PATH="/path/to/feather/"


When using Xcode, please double check that you’ve set a custom working directory. ⚠️

Time to enable our newly created module, open to the main.swift file and append the NewsBuilder() instance to the module configuration array. This will enable the sample news module. Now if you run Feather, the new module should work by default, but before we actually try out everything we are going to alter the generated news module source just a little bit. 🔨



Model definition

Let’s start by altering the model definition for our news entries. This will allow us to store news objects in the persistent database using the underlying Fluent framework. The generated news module will contain a NewsModel, we just need to extend this model with a few more fields.


import FeatherCore

final class NewsModel: ViperModel {
    typealias Module = NewsModule

    static let name = "news"

    struct FieldKeys {
        static var title: FieldKey { "title" }
        static var imageKey: FieldKey { "image_key" }
        static var excerpt: FieldKey { "excerpt" }
        static var content: FieldKey { "content" }
    }

    

    @ID() var id: UUID?
    @Field(key: FieldKeys.title) var title: String
    @Field(key: FieldKeys.imageKey) var imageKey: String
    @Field(key: FieldKeys.excerpt) var excerpt: String
    @Field(key: FieldKeys.content) var content: String

    init() { }

    init(id: UUID? = nil,
         title: String,
         imageKey: String,
         excerpt: String,
         content: String)
    {
        self.id = id
        self.title = title
        self.imageKey = imageKey
        self.excerpt = excerpt
        self.content = content
    }
}


We defined our Fluent database model with the help of Swift property wrappers (@ID, @Field). They will allow Fluent to read and write columns in the represented database table, so we don’t have to write SQL queries, but we can access the entries through a much higher level (ORM) abstraction layer. Pretty standard Vapor and Fluent stuff nowadays. 🙃

The id is a unique identifier, we’re going to save the news title as a String, the imageKey is a special property for saving image URLs and the excerpt is going to be a short “sneak-peak” of the entire content. Now we just have to write a migration script, because in Vapor we have to create or update our database tables before we could use the model.

import Vapor
import Fluent

struct NewsMigration_v1_0_0: Migration {

    func prepare(on db: Database) -> EventLoopFuture<Void> {
        db.schema(NewsModel.schema)
            .id()
            .field(NewsModel.FieldKeys.title, .string, .required)
            .field(NewsModel.FieldKeys.imageKey, .string, .required)
            .field(NewsModel.FieldKeys.excerpt, .string, .required)
            .field(NewsModel.FieldKeys.content, .string, .required)
            .create()
    }

    func revert(on db: Database) -> EventLoopFuture<Void> {
        db.schema(NewsModel.schema).delete()
    }
}

This migration script will create the required fields inside the news table and if necessary we can revert the process by deleting the entire table.


Metadata in Feather CMS

In Feather CMS everything that can be publicly accessed through the web site needs to have an associated metadata object. This metadata object is responsible for managing the public url (slug) and visibility of the referenced entity, it also stores many more SEO related details.

Anything can become a metadata reference, we just have to implement a special protocol on the object that we want to use as a frontend content, plus we have to setup a custom middleware in order to feed the metadata model with some basic information about the refenreced object.

The generated template provides a default metadata represantation for the sample model, we just have to extend the NewsModel+Metadata.swift file with the new fields that we added to our model. This way our referenced metadata can know even more info about the news feed item.


import FeatherCore

extension NewsModel: MetadataRepresentable {

    var metadata: Metadata {
        .init(slug: Self.name + "https://theswiftdev.com/" + title.slugify(),
              title: title,
              excerpt: excerpt,
              imageKey: imageKey)
    }
}

This MetadataRepresentable protocol is used when we save a news model, Feather will create an associated Metadata object with the returned title, excerpt and imageKey values. This connection works automatically if you register a database middleware in the boot function of your module file.


func boot(_ app: Application) throws {
    
    app.databases.middleware.use(MetadataModelMiddleware<NewsModel>())

    
}

Using the metadata API is a great way to have nice SEO-friendly public pages on your site backed by your own business models without thinking too much about the underlying data structure.


Input forms

The default template also gives us the ability to manage the sample model by using the CMS. We have to extend this functionality a bit, because we’ve added some extra fields.

The LeafRepresentable protocol is part of the Leaf framework, it allows us to render models using the template engine. We have to add our own properties inside the NewsModel+View.swift file.

import FeatherCore

extension NewsModel: LeafDataRepresentable {

    var leafData: LeafData {
        .dictionary([
            "id": id,
            "title": title,
            "imageKey": imageKey,
            "excerpt": excerpt,
            "content": content,
        ])
    }
}

This change will allow us to list, create, update or view our model with all the available fields using the Content Management System. The generated template gives us all of the CRUD operations for free, but the interface only works with the title field, so we have to add the other newly created properties if we want to be able to completely manage our model.


The ModelForm protocol allows us to provide edit (create, update) functionality for a given model through the CMS. The form has to define the fields that you can use in the Leaf template file to render them visually. The field definitions in the form are all about data representation, but they don’t specify the look and feel of the items on the admin interface. In other words these fields are not necessary view representations, but more like data transfer objects. We are going to put the actual view into a separate Leaf template file later on. 🍃


The field types are predefined objects in the ViewKit framework, a FormField is an object that encapsulates a generic value and an optional error message. The FileFormField object is used to transfer file data when you want to use a file upload field inside your form. After you specified the keys that you want to use to send the values, you have to list these form fields using the fields variable. Everything what is listed as a field will be automatically validated based on the constraint that you’ve put on each field (required, length, etc.).


If you want to edit a metadata representable model you usually want to send the metadata info with the model data, you can fetch the referenced metadata object by using the findMetadata method on a Fluent model, this will load the reference asynchronously. The initialize method is an ideal place to perform async init tasks. You can also override the leafData variable to send additional information next to the modelId, fields and notification keys.


Since the form is tied to an underlying model, we also have to read the model data before we render our form so we can render original field values, and after the user submits the form we might want to write the input date to the model. Of course the write method will be called only when the incoming form fields are valid. You can perform additional database checks if you have special validation needs before you actually save a model.


The very last thing that we want to do is image processing. We can use the processAfterFields method to upload our image into a temporary location, then before the save method is called (after the fields are validated), we can use the willSave function to save the image to a final location and update our model with the key that represents our uploaded image file. You can use this key later on to render the image file with the help of the Liquid file storage component. 📁


import FeatherCore

final class NewsEditForm: ModelForm {

    typealias Model = NewsModel

    var modelId: UUID?
    var image = FileFormField(key: "image").required()
    var title = FormField<String>(key: "title").required().length(max: 250)
    var excerpt = FormField<String>(key: "excerpt").required().length(max: 250)
    var content = FormField<String>(key: "content").required()
    var notification: String?

    var metadata: Metadata?

    var fields: [FormFieldRepresentable] {
        [image, title, excerpt, content]
    }

    var leafData: LeafData {
        .dictionary([
            "modelId": modelId?.encodeToLeafData() ?? .string(nil),
            "fields": fieldsLeafData,
            "notification": .string(notification),
            "metadata": metadata?.leafData ?? .dictionary(nil),
        ])
    }

    init() {}

    func initialize(req: Request) -> EventLoopFuture<Void> {
        var future = req.eventLoop.future()
        if let id = modelId {
            future = Model.findMetadata(reference: id, on: req.db).map { [unowned self] in metadata = $0 }
        }
        return future
    }

    func processAfterFields(req: Request) -> EventLoopFuture<Void> {
        image.uploadTemporaryFile(req: req)
    }

    func read(from input: Model)  {
        title.value = input.title
        excerpt.value = input.excerpt
        image.value.originalKey = input.imageKey
        content.value = input.content
    }

    func write(to output: Model) {
        output.title = title.value!
        output.excerpt = excerpt.value!
        output.content = content.value!
    }

    func willSave(req: Request, model: Model) -> EventLoopFuture<Void> {
        image.save(to: Model.path, req: req).map { key in
            if let key = key {
                model.imageKey = key
            }
        }
    }
}


The main reason why forms exists is that I wanted to separate responsibilities. A form can help the controller to display a screen inside the CMS using a model, this way our controller files will be smaller and cleaner. Forms can’t render themselves, so they still need a controller object that can control them and a router to register the necessary URLs that we can use to hook them up to the admin interface. Forms usually need one URL that can be reached through a GET request for rendering the initial form and another one that can be used to POST (submit) the form. ↗️




Admin views

The sample module already includes the templates that we’re going to need to support news administration. Templates are located in the Bundle/Templates folder under the module directory. The Admin folder contains subfolders with model names and every single model that we want to manage should have a view, edit, delete and list template file for the CRUD operations. 🔨


These templates follow the same pattern. Every single on of them starts with a dictionary definiton that is used by another template (inlined at the end of the file) to render the view based on the values inside the dictionary. The admin module provides us lots of templates that we can use to simplify things. There are pre-baked templates for input forms (Admin/Form), lists (Admin/Table), to present confirmation before delete (Admin/Delete) operation and to simply view the details (Admin/Detail) of a model. You should take advantage of these templates if possible. 😉


The admin module also gives us reusable form fields. Let’s alter the Edit template, we’re going to add various input fields for the form fields that we defined previously.


#define(fields):
    #var(field = nil)

    #(field = ["id": "image", "data": fields.image, "accept": "image/*", "required": true])
    #inline("Admin/Fields/File")

    #(field = ["id": "title", "required": true, "data": fields.title])
    #inline("Admin/Fields/Text")

    #(field = ["id": "excerpt", "size": "s", "data": fields.excerpt])
    #inline("Admin/Fields/Textarea")

    #(field = ["id": "content", "size": "xl", "data": fields.content])
    #inline("Admin/Fields/Textarea")

#enddefine


Inside the Bundle/Templates/Admin/News/Edit.html file we just have to add three new fields to represent our form fiels as HTML form elements. You can use all kind of built-in form elements, plus every single form is CSRF and double-submission protected, this means that you are safe from CSRF attacks by default if you follow this design pattern.



Now if you run the application most of the functionality should work with the newly created fields, but before we do so, we should talk about admin controllers.



Admin controllers


The underlying ViperKit framework can help us a lot with the necessary controller setup. Fortunately Feather comes with an extension that makes things even more simple if we just want to provide a CRUD interface for a given model. If you take a look at the NewsAdminController you’ll see that you only have to setup the referneced module, model and form types in order to make things work. 💪


You can extend the functionality of controllers by implementing special lifecycle methods, for example we can delete the uploaded image file from the file storage by using the beforeDelete method. It is also possible to extend the list functionality or alter the update, create methods, you should take a look at the AdminViewController protocol if you are interested in the details.


import FeatherCore
import Fluent

struct NewsAdminController: ViperAdminViewController {

    typealias Module = NewsModule
    typealias Model = NewsModel
    typealias CreateForm = NewsEditForm
    typealias UpdateForm = NewsEditForm

    func listQuery(search: String, queryBuilder: QueryBuilder<Model>, req: Request) {
        queryBuilder.filter(\.$title ~~ search)
    }

    func beforeDelete(req: Request, model: Model) -> EventLoopFuture<Model> {
        req.fs.delete(key: model.imageKey).map { model }
    }
}


Long story short, an admin controller has everything that you’ll need to manage your model using the CMS. It’ll provide a list, get, create, update and delete view for your model.




The frontend hooks


If you want to display news entries on the frontend as dedicated pages you have to implement some hook functions in the module file. Fortunately the base template already hooks up everything, so we just have to change our templates to display more data.


The “frontend-page” hook can be used to fetch metadata objects for a given path and return a response if a model exists for a given slug. This is a great way to render news entries in a SEO friendly manner. It is possible to create a separate view object an pass the request and the model to it so it can render the frontend page based on a template file.


We’re going to add just one little extra modification to our frontend view. Wouldn’t be cool if we could support content filters for the news entries? This way we could use the markdown format (if we enable the markdown module) to write the content of a news item. Here’s how to do it.


struct NewsFrontendView {

    

    func news(_ news: NewsModel) -> EventLoopFuture<View> {
        var ctx = news.leafDataWithJoinedMetadata.dictionary!
        ctx["content"] = .string(news.filter(news.content, req: req))
        return render("News", [
            "news": .dictionary(ctx)
        ])
    }
}



You can also create *-page hooks, that you can use to render custom templates with the help of the Frontend module. The frontend module comes with a page model, that you can use to display pages, but it is also possible to connect a page model with Swift and Leaf code, this is how it works in a nutshell. You register a hook function (in our case “news-page”), then you create a new page with the following contents: [news-page]. This syntax means that you want to call a Feather hook, instead of rendering the contents of the page, so the system will look for a hook function with a valid response object and if it finds one, it’ll use it to render the page. 🤓


In our case, the “news-page” hook uses the Frontend/NewsList template, that is responsible for displaying the list of the news entries. If you click on a list item, the frontend-page hook tries to load the news based on the permalink (referenced metadata slug) and if there is a match it’ll render it using the Frontend/News template.


Feather uses the Peacock CSS library to put some style on HTML elements, we can enhance our news list template. Just alter the Frontend/NewList file, like this:


#define(body):

<div class="container">
    <div class="margin margin-bottom-xl">
        <header class="margin-bottom">
            <h1>News list</h1>
            <p class="margin-top-zero">Read the latest news</p>
        </header>

        <section>
            #for(item in news):
            <div class="background margin-bottom padding shadow">
                <a href="/#(item.metadata.slug)">
                    <img src="#(item.imageKey.resolve())" class="size-width-full">
                    <h2 class="margin-top-zero">#(item.title)</h2>
                    <p class="margin-top-zero">#(item.excerpt)</p>
                </a>
            </div>
            #endfor
        </section>
    </div>
</div>

#enddefine

#inline("Frontend/Index")


The very last step is to display a proper news entry with the new fields. In the Frontend/News file update the body and use the following layout for the entry page.


#define(body):

<div class="container">
    <div class="margin margin-bottom-xl">
        <h1>#(news.title)</h1>
        <p class="margin-top-zero">#(news.excerpt)</p>
        <img src="#(news.imageKey.resolve())" class="size-width-full margin-vertical">
        #(news.content)
    </div>
</div>

#enddefine

#inline("Frontend/Index")


Now if you build and run everything, first you’ll have to run the installer, after that you can log in to the admin interface and you can create & publish your very first news entry. You can read more about how to use Feather CMS, just read the user manual. As a gratis you should be able to apply content filters to your news item, so you can take advantage of the built-in markdown or the Swift syntax highlighter filters. 🤩





Summary

In this tutorial we’ve created a brand new module for Feather CMS using plenty of underlying frameworks and tools. This can be hard at first sight, but I really love this approach because I can focus on defining my business models instead of taking care of smaller details such as registering the required routes for editing a database entry. Feather CMS will hide this kind of complexity and provide dynamic extension points for building your admin interfaces. On the frontend side you can easily extend the dynamic routing system, apply content filters or even add your own extension points through hook functions.

There is so much more to talk about, but this time I’ll stop right here, if you enjoyed this tutorial please follow me on twitter, subscribe to my newsletter or consider supporting me by purchasing my Practical Server Side Swift book on Gumroad.


[ad_2]

Source link

RELATED ARTICLES

LEAVE A REPLY

Please enter your comment!
Please enter your name here

Most Popular

Recent Comments