← Back to Posts

I ported PingCRM from Laravel to Formidable

1/4/2022

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: "&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.