Gitlab Filter Extension: Part 2 — Creating the code
I maintain a Self-Hosted Gitlab server for my company, and whenever there’s a new release announced, I check Gitlabs’ Release Blog, to see what’s new and what affects my tier. But there’s no option to filter the features to my tier, so I lose a considerable amount of time reading through nice features that don’t actually apply to my reality.
Looking back
In Part 1 we analyzed the problem and verified if there was enough information available to us on the release page to enable our project.
And we defined the following areas of work:
- Find the node in the DOM with the tier information (SaaS and Self-Managed)
- For each tier, evaluate the badges for each license and find the ones that are available, given a valid license value (FREE, PREMIUM, ULTIMATE)
- Finally, locate the feature that this Badge belongs to and hide it in the page, without breaking the page layouts
- Make a function that receives two parameters, tier (Saas and Self-Managed) and license (FREE, PREMIUM, ULTIMATE), and uses them to filter out the features using the code created before
- Make sure we can revert the filtering and show every feature on the page again, add a tier and license called
All
Now let’s code
Find the tier
Looking back at the DOM structure we can see that the tiers and license levels are contained within a badge-container
, the tier information itself is inside a div with the badge-container-type
class.
So with the following query we can easily find all the nodes:
document.querySelectorAll('.badge-container-type')
But what happens if we wanted to get only the the nodes for a specific tier? Using a querySelector because we don’t have an expression to evaluate the innerText
of the node, we would need to filter the NodeList after it’s returned from the DOM, with something like this:
Array.from(document.querySelectorAll('.badge-container-type')).filter(x => x.innerText === 'SaaS')
Since the NodeList is an Iterable List, we need to first convert it to an Array, then we can have access to the filter method and define our filter.
But there’s another way of fetching the nodes that allows us to define all of this without having to do conversions or filtering, it’s called XPath , so how would the above line look with an XPath expression?
document.evaluate(`//div[contains(@class, 'badge-container-type') and text()='SaaS']`, document, null, XPathResult.UNORDERED_NODE_ITERATOR_TYPE, null)
This will return an Iterable XPathResult and by calling iterateNext()
we can traverse the Nodes affected.
Filtering out the license
Now that we have all the tiers, we want to also only return the tiers that match the license we are looking for, so we need to modify our code to handle that. So let’s go back to the DOM Structure, all of our badges are inside a div with either the class top-row
or bottom-row
, they are also not nested under the tier, but a sibling to it, similar to the tier, the level of the license is within the innerText
property and what defines if the feature is available for that license is the available
class being present in the div:
//div[contains(@class, 'badge-container-type') and text()='SaaS']/following-sibling::div/a/div[text()='FREE' and not(contains(@class,"available"))]
But, we don’t want to get a list of badges back, what we want is to know what tier is affected and here’s where XPath shines, since it allows a bidirectional querying of the DOM structure, we can write a query that can look at the sibling properties but still return different nodes by traversing back to the ones we actually want to work with:
//div[contains(@class, 'badge-container-type') and text()='SaaS']/following-sibling::div/a/div[text()='FREE' and not(contains(@class,"available"))]/ancestor::div[contains(@class, "badge-container")]/div[contains(@class, 'badge-container-type') and text()='SaaS']
Now we will have all the SaaS tiers where the FREE license is not available.
Locating the Feature
At the end of the day, what we want is to find the feature element, so that we can hide/show it without breaking the page layout, as our XPath query stands we have found all the tier objects that match our query, now we need to change what we are returning so we can get the actual feature.
Fortunately since their DOM is so well organized, Gitlab allows us to easily query the DOM to find those elements, so we can just:
document.querySelectorAll('.release-row')
That gives us all the nodes that have the class release-row
So if we want to get all the releases we can just add this to the query:
//div[contains(@class, 'badge-container-type') and text()='SaaS']/following-sibling::div/a/div[text()='FREE' and not(contains(@class,"available"))]/ancestor::div[contains(@class, "release-row")
Done! Right? Actually no, because if you scroll down a bit more, we find the secondary releases, these ones are contained in their own column, inside a release-row
, so if we want to be able to have a list of the secondary features, we can’t rely on finding the row, we need to query the column:
document.querySelectorAll('.secondary-column-feature');
If we analyze the DOM again we can see a pattern, primary releases are within a release-row
, secondary releases are also within a release-row
but the difference is the addition of the divider
class, so with that knowledge we can differentiate between primary and secondary rows. So for secondary features what we want to target is the column, which has the convenient named class secondary-column-feature
, fortunately XPath allows us to use OR
and AND
operators so we can now change our query to be:
//div[contains(@class, 'badge-container-type') and text()='SaaS']/following-sibling::div/a/div[text()='FREE' and not(contains(@class,"available"))]/ancestor::div[contains(@class, "release-row") and not(contains(@class, "divider")) or contains(@class, "secondary-column-feature")]
Let’s break down each elements of our query, the ancestor::div
tells XPath that we want to find the first ancestor from the point where we are that is a div and that contains(@class, “release-row”)
which filters out to just the ones that have the class release-row
and and not(contains(@class, “divider”))
that do not have the class divider
or or contains(@class, “secondary-column-feature”)]
has the class secondary-column-feature
.
Great! This will give us a list of all features that match our filter criteria!
Finally some coding
With all that exploratory querying we can now write a function that uses that query to find all the unavailable features:
This function will return our XPathResult with the list of unavailable nodes. So now all that’s left to do is to actually hide the features that are unavailable for our tier/license:
Great we are now hidding all the features that are not available to us, all that is left is allow a way for us to revert our filtering so we can see all the features back again:
So, breaking down the final function, what we do is to in the very first line, show all the features in the page, this reverts anything we may have hidden on the page. We then check to see if the both tier and license are not equal to all
, then we need to define the filters, if they are not equal to all
we should filter them.
Then we can assemble the XPath string and continue hidding any node that returned from the query.
So with this you can just open the console on chrome in one of the Gitlab Release Pages, example Gitlab 15.3 Release, and paste the gist, alter the parameters to what you want et voilá, all the features that do not apply to the filter will be hidden from sight.
But what can we do to make this a better experience??!!!
Chrome extensions FTW! See you in Part 3