When launching Tabs, we have written a blog about the design decisions behind tabs. This time, we discuss the technical side of tabs in acreom.
The structure for the tabs, shown above, was chosen because of its simplicity. It always consists of just a single Container which controls the Tab groups, one or more Tab groups, with each containing one or more Tabs. Tabs contain the actual content you can interact with.
The Container has a simple function - render all the Tab groups that exist. Our frontend uses Vue.js, so the main part of the whole component looks basically like this:
<TabGroup
v-for="(groupId, index) in tabGroupsIds"
:id="groupId"
:ref="`tabGroup-${groupId}`"
:key="groupId"
class="group"
:style="getTabStyle(index)"
/>
Notice that we are not passing the Tab group data directly, but rather we pass only the Tab group id. That is one of the reactivity optimizations we have implemented to prevent the app from re-rendering too often - we explain the optimizations later in this blog.
Tab group, similar to the Container element, has the function to render a Tab content. It has some other functions, like displaying the overlay when dragging a tab over, but we won't go into details about those. The relevant Tab group code looks like this:
<component
:is="tabComponent"
v-if="activeTabId"
:id="activeTabId"
:key="activeTabId"
:entity-id="activeTabEntityId"
:group-id="group.id"
:width="groupWidth"
/>
We use a Tab component resolver to always get the relevant tab component to display:
get tabComponent() {
return this.$componentsRepository.getTab(this.activeTabType)
}
Tab group may contain 1..n Tabs, but always renders just a single tab - the active one.
Tab contains the actual content. There are different tabs in acreom - for example Editor tab, View tab, My Day tab.
The Tab works directly with the data in store concerning the specific tab. This way, reactivity of the tab is ensured, while preventing unnecessary re-renders of other parts of the app.
With the displaying explained, we can move on to the more interesting part - the data layer. We store all of the data used for rendering tabs in our vuex store.
When we first started prototyping the tabs, we created a simple structure. It consisted of groups
array containing group objects with each containing its order, width in pixels, activeTabId and tabs array.
We stored tabs objects directly on the group object and each tab object contained the id of the entity it should display, order in its group, and data - metadata specific for a given tab.
The structure of the groups looked like this:
groups: [
{
id: <groupId1>
order: 0,
width: 400,
activeTabId: <tabId2>,
tabs: [
{
id: <tabId1>,
entityId: <uuidv4>,
order: 0,
data: { ...tabSpecificData },
}, {
id: <tabId2>,
entityId: <uuidv4>,
order: 1,
data: { ...tabSpecificData }.
},
]
},
{
id: <groupId2>,
…
],
activeGroupId: <groupId1>,
activeTabId: <tabId2>
This solution performed good enough for prototyping, but was inherently flawed in 2 ways:
the structure was not flat, resulting in higher complexity when updating any data
it triggered re-renders of all groups and tabs, since any change to the elements in groups array would trigger observable on the whole array
The more tabs or groups you had, the higher the cost of re-rendering. The app would become laggy and the whole (development) experience was painful.
The obvious solution was to flatten the structure and create indexes that would describe the structure in a more disconnected way. We moved all the tabs to a separate object, where each tab is indexed by its id. We also changed the groups array to groups object, indexing each group by its id and removing the tabs array from the object all together.
The splitting of the entities immediately brought significant performance gains. Changing data on Tab or Tab group no longer triggers observable on the array, but only on either tabs or groups object. We then added a tabsInGroup index, which connected tabs to the group they belong to. Based on this index, the tabs are rendered for each group.
The tab width being in stored pixels also became an issue. On each app resize, the width of all groups needed to be recomputed and updated in store, which would trigger re-render for each group, resulting in another complete re-render of the whole content.
We changed the stored width from pixels to a percentage of the content width each group should be taking. Instead of js being responsive for managing the width of the groups, we let css do the heavy lifting.
groups: {
<groupId1>: {
id: <groupId1>,
width: 34.615,
order: 0,
activeTab: <tab2>,
},
<groupId2>: {
id: <groupId2>,
width: 65.385,
order: 0,
activeTab: <tab3>,
},
},
tabs: {
<tabId1>: {
id: <tabId1>,
order: 0,
entityId: <uuidv4>,
data: { ...tabSpecificData },
},
<tabId2>: {
id: <tabId2>,
order: 500,
entityId: <uuidv4>,
data: { ...tabSpecificData },
},
},
listTabs: [<tabId1>, <tabId2>, <tabId3>],
listGroups: [<groupId1>, <groupId2>],
tabsInGroup: {
<groupId1>: [<tabId1>, <tabId2>],
<groupId2>: [<tabId3>],
},
activeGroupId: <groupId1>,
activeTabId: <tabId2>
Accessing the data has become streamlined with the process being:
Container component retrieves all groups available using listGroups index
TabGroup component is created for each group
TabGroup retrieves all tabs it should keep track of using the tabsInGroup index and its own id
This simplifies the whole rendering, as components re-render only when their direct observable is triggered. All we need to do is update the tabs and groups in a way that would trigger observables on data directly belonging to them.
Any update that happens on a tab updates only the corresponding tab object, triggering observable for the object and its index. In our case it would be tabs, indexing by tabId, and tabsInGroup indexing by groupId the tab belongs to.
The groups references do not change at the call, so Container and TabGroups that do not include the modified tab do not re-render. TabGroup containing the tab re-renders, but since the tab being modified is most likely active it’s no big deal as it would re-render anyway.
The only issue remaining was ordering. On each reorder of tabs, even when reordering inactive tabs, the whole group and active tab would re-render - caused by modifying order on all tabs in a group.
The approach of setting the order to the index of the tab in a tabGroup array was not working well. We decided on a different solution, where order is first set to an arbitrary number (we chose increments of 500, but it really does not matter). When reordering tabs, the average of the orders of the previous and next tab is chosen as the new order. When reordering the tab at the start or at the end, 500 is subtracted (or added) from the first (last) item order.
Using this approach allowed us to only update the single tab order instead of all of them. What allowed this approach are two assumptions: 1. you won’t reorder the same tabs hundreds of times, and 2. you won’t place different tabs at the exact same order over and over again.
When opening a tab, the following events take place (show in a diagram):
The tab is added to tabs store
The active tab id updates in the store
Render of the active tab is triggered
Upon rendering:
The old tab unmounts, removing its registered shortcuts and listeners
The new tab loads the specific tab data
The new tab mounts, registering its specific shortcuts and listeners
What happens when you split? A new group is created, its order is set so it fits between the group you are splitting and the next one, and then the tab in the tabsGroup index is moved to the new group.
The same goes for moving tabs between groups, all we need to do is move tabId in tabsGroup from one group to the other. In case it's the last tab, we simply delete the group it belonged to after we move it.
This gif shows how the UI is re-rendered when the active tab changes. All three tab groups blink, showing the re-render but only the contents of the relevant tab re-renders.
Adding new Tabs is simple now - all you need to add is add the tab component which displays inside the Tab display and register it, so the resolver can pick up on the new tab type.
When we introduced the Jira integration, all we had to do was add the JiraAppTab.vue
component containing the logic to render the Jira issues and the index.ts file with the resolver registering logic:
registerEntityComponents(JiraIntegrationDataType.ISSUE, {
tab: (_context: Context, _tabId: string) => () =>
import('~/components/entities/jira/JiraAppTab.vue'),
}
This allows for almost effortless extensibility. When we (or anyone else) wants to add a new tab type, they provide these files (along with any other that may be required for correct functioning of the .vue component) and voila! - a new App is born.
It may be clear now that we have built solid grounds for future extensibility - both by us and the community in the form of community apps (plugins), which is one of the most requested features.
This blog is part of acreom dev week. Be sure to check it out and follow along. Check out also our twitter, or join our Discord community to stay in the loop.