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.usersIn 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]}" urlIn 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.