I ported PingCRM from Laravel to Formidable

Back

I recently added Inertia.js first-class support to Formidable. To test the integration, I decided to port Laravel's PingCRM over to Formidable. The first thing I quickly realized was, I had to make a lot of changes to the framework core code. This was due to the fact that Formidable is an API framework by default, and here I was, trying to use it like its Laravel.

With these changes out of the way, I then proceeded to start porting PingCRM.

Auth Changes

Login

Before doing anything, we needed a way to handle authenticated users. By default, Formidable will return a json response with a success message if the user is successfully authenticated:

{
message: 'success'
}

Now, we don't want Inertia to display a success message when a user logs in, instead we want to redirect the user to a different page:

...
export class AppServiceResolver < ServiceResolver
def boot
...
Auth.onAuthenticated do(request\Request, reply\FastifyReply, user\Object, protocol\String)
if protocol != 'web' then return
die do
Redirect.to(request.session!.pull('redirectPath', URL.route('home')))

In the snippet above, we have an onAuthenticated event and the first thing we do, is check if the authentication happened via the web protocol. We do this to prevent handling api authentication since we just want to focus on the Inertia.js flow.

Next, we have a die helper, inside the helper, we have a Redirect. This redirect will attempt to redirect to a URI the user might have tried to access while logged out, if this URI is not present, we will just redirect the user to the home route.

Logout

We also have a similar flow for the logout event. Because Formidable returns a json response, we basically redirect to the login page after the user has logged out:

...
export class AppServiceResolver < ServiceResolver
def boot
...
Auth.onSessionDestroyed do(request\Request, reply\FastifyReply, protocol\String)
if protocol != 'web' then return
die do
Redirect.route('login.show')

Guests / Authenticated

So what happens if a guest user tries to access a page that requires the user to be authenticated?

Well, in the Exception Handler, we have a code block that handles unauthenticated users:

...
export class Handler < ExceptionHandler
def handle error, request\Request, reply\FastifyReply
if request.expectsHtml! && error instanceof AuthorizationException
request.session!.set('redirectPath', request.url!)
return Redirect.route('login.show')

The first thing we do, is check if the request is expecting an html response, and if it is, we go ahead and check if the current error/exception is an instance of AuthorizationException, and if it is, we set a redirect path in the session to the current URI, then we go ahead and redirect the guest user to the login page.

However, if an authenticated user tries to access a page that only allows guests users, we redirect the user to the home page:

...
export class ErrorIfAuthenticated < Middleware
def onAuthenticated request\Request, reply\FastifyReply, params\any[]|null
if request.expectsHtml! then die do Redirect.to('/')
throw new ForbiddenException 'Action not allowed'

As you can see, we have an if statement that checks if the request is expecting an html response, if it is, we die then Redirect the user to the home page. If the request is not expecting an html response, then we just throw a ForbiddenException which is what the api protocol would expect.

Filters and pagination

Filters

Because Formidble doesn't make use of Eloquent, it was a bit hard to get content filtering working.

I had to create a custom filter class for managed users. This class just makes it easier for allowing custom filters and query search:

...
export class UserFilter
prop users
prop request\Request
def constructor users
self.users = users
static def make users
new self(users)
def using request\Request
self.request = request
self
def filter
self.search!
.role!
.trashed!
.get!
.sort do(a, b)
"{a.attributes.first_name} {a.attributes.last_name}" > "{b.attributes.first_name} {b.attributes.last_name}" ? 1 : -1
def search
self.users = self.users.filter do({ attributes })
if isEmpty(self.request.query('search')) then return true
const query = new RegExp(self.request.query('search'), 'ig')
const name = "{attributes.first_name} {attributes.last_name}"
name.search(query) !== -1 || attributes.first_name.search(query) !== -1 || attributes.last_name.search(query) !== -1 || attributes.email.search(query) !== -1
self
def role
self.users = self.users.filter do({ attributes })
if isEmpty(request.query('role')) then return true
const owner = request.query('role') == 'owner' ? 1 : null
attributes.owner == owner
self
def trashed
self.users = self.users.filter do({ attributes })
if isEmpty(request.query('trashed')) || request.query('trashed') == 'with'
return true
attributes.deleted_at !== null
self
def get
self.users

In this class, we have a make function which expects a collection of users. This function will update the users prop to whatever we pass. Once passed, we can use the using function which expects a request.

After passing the users collection and the request object, we can then use the filter function, which will execute the search, role, trashed, and the get functions. We also do some sorting at the end of filtering.

Pagination

Formidable comes with a basic pagination functionality for Knex.js, this is obviously not enough and since Laravel has a more robust pagination functionality, the Inertia.js Vue.js pagination component was incompatible with what I had.

So to make it work, I added a Pagination class and a custom paginate function to the Formidable Controller class:

...
export class Pagination
static def pages total, perPage
total / perPage
static def links object, request\Request
const all = []
if object.pages > 0
all.push {
active: false
label: "&laquo; Previous"
url: self.getPrevLink(request)
}
for i in [1 .. object.pages]
let url = "{request.urlWithoutQuery!}?page={i}"
Object.keys(request.query!).map do(query, position)
if query !== 'page'
url = url.concat "&{query}={Object.values(request.query!)[position]}"
all.push {
active: i == Number(request.query('page', 1))
label: i
url: url
}
if object.pages > 0
all.push {
active: false
label: "Next &raquo;"
url: self.getNextLink(request, object.pages)
}
all
static def getPrevLink request\Request
let url\String|null = Number(request.query('page', 1)) > 1 ? "{request.urlWithoutQuery!}?page={Number(request.query('page', 1)) - 1 }" : null
if isEmpty(url) then return url
Object.keys(request.query!).map do(query, position)
if query !== 'page'
url = url.concat "&{query}={Object.values(request.query!)[position]}"
url
static def getNextLink request\Request, total\Number
let url\String|null = Number(request.query('page', 1)) < total ? "{request.urlWithoutQuery!}?page={Number(request.query('page', 1)) + 1 }" : null
if isEmpty(url) then return url
Object.keys(request.query!).map do(query, position)
if query !== 'page'
url = url.concat "&{query}={Object.values(request.query!)[position]}"
url

In this class, we have a pages function which returns the total number of pages, we also have a links function which creates a link per page, and finally, we have a getPrevLink and a getNextLink, these functions are responsible for returning a link for the previous page and the next page.

Closing comments

As you can see, a couple of changes were needed to make this possible, and it took less than a day to make changes to the core framework and the actual project. Some features were ported without any code changes and some had some changes.

To browse the code or play around with the project, visit my repo PingCRM.

© Donald Pakkies.
github