I ported PingCRM from Laravel to Formidable
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: "« 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 »" 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.