Report Components: Table
SWTOR Logs uses tables extensively in its report UI. They are great for making aggregated data easier for a user to consume, and lots of context can be added when the right styles, icons, and tooltips are used. Table
is exposed as an option in Report Components so that users can easily build their own custom tables in a similar manner to the existing SWTOR Logs UI.
When Should I Use It?
Table
is a good fit when the data you want to display neatly fits into a grid structure. This could be if you need to show a row of data for each player, or for certain timestamps, or even for certain slots of gear that a player has equipped. You can also use the Bar
Enhanced Markdown component inside your table to make a pseudo-bar-chart. This can often be more useful than an actual bar chart due to the ability to add extra columns of data for more context.
What Is Available?
The Table
component lets you render a table with multiple columns. Each column can have its width and alignment customized. You may also group columns together to form header groups. Importantly, every cell in a Table
can use Enhanced Markdown to enrich its content with the correct styling, icons, and tooltips.
Example: Enchant Checker
It's recommended that you follow the example from the Enhanced Markdown article before proceeding with this one.
To demonstrate one possible use of the Table
component, we're going to build an enchant checker. It's recommended to follow along using your own Report Components dashboard.
This example is going to use Warcraft Logs. We're going to build a component that lets us check an individual player to see if their gear is enchanted or not.
The final result will look like this:
You can test this component against this Classic Naxxramas Report.
1. Guard against incorrect use
First of all, we know that we want our component to check a single player's gear for a single fight. If the user has more than one fight or actor selected, we should show them a friendly message instead of trying to run and crashing.
We can check if more than one fight is selected using the reportGroup.fights
array. And we can use reportGroup.fights[0].combatantInfoEvents
to check how many combatant info events are present in the first fight. There is only one combatant info event per player, so this is a flexible way to perform our check.
It is preferred to use fights[0].combatantInfoEvents
over fights[0].events.filter(event => event.type === 'combatantinfo')
as the former is cached and will typically execute faster.
We could have alternatively looked at eventFilters.actorId
to check if the user had filtered to a specific actor. However, this isn't ideal for two reasons:
- This will be populated even if the user filters to an actor without combatant info events (such as the boss).
combatantInfoEvents.length === 1
will still be true in scenarios where the user filters to a specific class but there is only one player of that class.
To help us debug, we can return these checks as an object (note the use of JavaScript object initializer shorthand for convenience).
getComponent = () => {
const onlyOneFightSelected = reportGroup.fights.length === 1;
const onlyOneCombatantInfoEvent =
reportGroup.fights[0].combatantInfoEvents.length === 1;
return {
onlyOneFightSelected,
onlyOneCombatantInfoEvent
}
}
When we run this, we should see something like:
This is just using the default JsonTree
component to render our result. It's a quick way to verify the value of a variable. However, we would rather show a user-friendly message, so let's return some Enhanced Markdown instead:
getComponent = () => {
const onlyOneFightSelected = reportGroup.fights.length === 1;
const onlyOneCombatantInfoEvent =
reportGroup.fights[0].combatantInfoEvents.length === 1;
if (!onlyOneFightSelected || !onlyOneCombatantInfoEvent) {
return {
component: 'EnhancedMarkdown',
props: {
content: 'Please select a single fight and player to check enchants for.'
}
}
}
return 'Not yet implemented';
}
Now, if we are selecting more than one fight or we find more than one combatant info event, we'll render an EnhancedMarkdown
component that lets us show a message to the user. Test this component while selecting multiple/single fights, and while selecting/deselecting a player. When the conditions aren't met, you should see:
2. Filter to the relevant data
In WoW, combatant info events are fired at the start of an encounter and include information such as gear, stats, and auras for each player. The gear information includes things like enchants and gems, so this is the only event we need to build our table. As we've already guarded against there being multiple fights or combatant info events, we can get this data quite simply:
const combatantInfo = reportGroup.fights[0].combatantInfoEvents[0];
However, there's also some information not in the report that we need to build our table: a list of which gear slots we want to inspect. Each gear slot has an ID and a name, and we can represent that with a simple JavaScript object where the keys are the IDs and the values are the names:
const slots = {
1: 'Head',
3: 'Shoulder',
15: 'Cloak',
5: 'Chest',
9: 'Bracer',
10: 'Gloves',
7: 'Legs',
8: 'Boots',
16: 'Main Hand',
17: 'Off Hand',
11: 'Ring 1',
12: 'Ring 2'
}
For now, we can inspect our data by returning it:
getComponent = () => {
// Guard against incorrect use
const onlyOneFightSelected = reportGroup.fights.length === 1;
const onlyOneCombatantInfoEvent =
reportGroup.fights[0].combatantInfoEvents.length === 1;
if (!onlyOneFightSelected || !onlyOneCombatantInfoEvent) {
return {
component: 'EnhancedMarkdown',
props: {
content: 'Please select a single fight and player to check enchants for.'
}
}
}
// Filter to the relevant data
const combatantInfo = reportGroup.fights[0].combatantInfoEvents[0];
return {
slots,
combatantInfo
};
}
const slots = {
1: 'Head',
3: 'Shoulder',
15: 'Cloak',
5: 'Chest',
9: 'Bracer',
10: 'Gloves',
7: 'Legs',
8: 'Boots',
16: 'Main Hand',
17: 'Off Hand',
11: 'Ring 1',
12: 'Ring 2'
}
To see something like:
Note that we have placed our slots
initialization outside of our getComponent
function. When our script is run, SWTOR Logs will evaluate the entire script before executing the getComponent
function, which means that our slots
variable will be defined by the time our getComponent
function needs to use it. Placing helper functions and static data at the bottom of the script is a subjective stylistic choice with the aim of increasing the readability of the main getComponent
function by decluttering it.
3. Build the rows for our table
When passing row data to our Table
component, we need to pass an array where each element is an object representing the row, with the keys of the object representing the column identifiers, and the values of the object representing the cell values for that row/column. For example, if we had three columns with identifiers of slot
, item
, and isEnchanted
, two rows of data might look like this:
const rows = [{
slot: 'Helm',
item: 'Valorous Dreamwalker Headguard',
isEnchanted: false
}, {
slot: 'Shoulders',
item: 'Spaulders of Egotism',
isEnchanted: true
}];
For us, we need to build this array by looking at combatantInfo.gear
, which is an array representing the item in each gear slot:
const rows = combatantInfo.gear
.map((item, index) => {
const slot = index + 1;
const slotIsRelevant = slots[slot] !== undefined;
const itemIsRelevant = item.id > 0;
if (slotIsRelevant && itemIsRelevant) {
return {
slot: slots[slot],
item: item.name,
isEnchanted: item.permanentEnchant
? 'Yes'
: 'No'
};
}
return null;
})
.filter(row => row !== null);
Let's break this down:
The
map
array method lets us build a new array by executing a function on each item in the existing array.SWTOR Logs orders the items by their slot. Slot IDs start at 1, but indexes start at 0. We work out the slot ID using
const slot = index + 1
.We are only interested in the slots that we defined in our
slots
constant (as not every item can be enchanted). We determine if the slot is relevant by checking if ourslots
object has a value for theslot
ID withconst slotIsRelevant = slots[slot] !== undefined
.In WoW, it's possible to equip two one-handed weapons (main hand slot and off-hand slot) or one two-handed weapon (main hand slot). If you are equipping a two-handed weapon, the off-hand slot is still logged, but with no item equipped (represented by an item ID of 0). We use
const itemIsRelevant = item.id > 0
to check for this and similar cases.Once we know that both the slot and item are relevant, we can return a row of data. We get the name of the slot by getting the value from our
slots
object for the key of our slot ID. We get the item name from the item we are mapping from, and we check if the item is enchanted by seeing if it has a value forpermanentEnchant
.if (slotIsRelevant && itemIsRelevant) { return { slot: slots[slot], item: item.name, isEnchanted: item.permanentEnchant ? 'Yes' : 'No' }; }
item.permanentEnchant
actually contains the ID number of the enchant used. We use Javascript truthiness and the ternary operator to use this to determine whether our third column should say "Yes" or "No".If the slot or item isn't relevant, then we
return null
. After themap
, we get rid of thesenull
entries using.filter(row => row !== null)
.
At this point, if we return rows
, we can see the data we want to put in our table:
4. Render our table
Now let's render our data using the Table
Report Component. This requires props of columns
and data
. The data
prop is what we have already built and set our rows
variable to. The columns
prop is an object where each key is the ID of the column (which should match the ID used when we built the data), and the value is an object describing how the column should be configured. The only mandatory option is column.header
which represents what content the header cell for that column should contain.
Returning the relevant Table
component brings our script to:
getComponent = () => {
// Guard against incorrect use
const onlyOneFightSelected = reportGroup.fights.length === 1;
const onlyOneCombatantInfoEvent =
reportGroup.fights[0].combatantInfoEvents.length === 1;
if (!onlyOneFightSelected || !onlyOneCombatantInfoEvent) {
return {
component: 'EnhancedMarkdown',
props: {
content: 'Please select a single fight and player to check enchants for.'
}
}
}
// Filter to the relevant data
const combatantInfo = reportGroup.fights[0].combatantInfoEvents[0];
// Build the rows for our table
const rows = combatantInfo.gear
.map((item, index) => {
const slot = index + 1;
const slotIsRelevant = slots[slot] !== undefined;
const itemIsRelevant = item.id > 0;
if (slotIsRelevant && itemIsRelevant) {
return {
slot: slots[slot],
item: item.name,
isEnchanted: item.permanentEnchant
? 'Yes'
: 'No'
};
}
return null;
})
.filter(row => row !== null);
// Render our table
return {
component: 'Table',
props: {
columns: {
slot: {
header: 'Slot'
},
item: {
header: 'Item'
},
isEnchanted: {
header: 'Enchanted?'
}
},
data: rows
}
}
}
const slots = {
1: 'Head',
3: 'Shoulder',
15: 'Cloak',
5: 'Chest',
9: 'Bracer',
10: 'Gloves',
7: 'Legs',
8: 'Boots',
16: 'Main Hand',
17: 'Off Hand',
11: 'Ring 1',
12: 'Ring 2'
}
Which, when run, will render our enchant checker table!
5. Improve our table UI
While our enchant checker is functional, it's also a little plain. Let's make some improvements to the UI so that the data is easier to consume.
Add item icons and tooltips
Every cell in our table can use Enhanced Markdown. For example, instead of simply rendering the name of each item, we can render a fully-styled tooltip link with the in-game icon using the ItemIcon
Enhanced Markdown component. To build the relevant Enhanced Markdown, let's add a helper function to the bottom of our script that takes an item
(from the combatantInfo.gear
array) and returns a string of Enhanced Markdown:
function convertItemToMarkdown(item) {
const extraInfoParts = [];
if (item.permanentEnchant)
extraInfoParts.push(`ench=${item.permanentEnchant}`)
if (item.gems)
extraInfoParts.push(
`gems=${item.gems.map(gem => gem.id).join(':')}:`
)
const extraInfo = extraInfoParts.join('&');
return `<ItemIcon id="${item.id}" icon="${item.icon}" type="${item.quality}" extraInfo="${extraInfo}">${item.name}</ItemIcon>`;
}
Reading the item's id
, icon
, quality
, and name
is all fairly simple. Warcraft Logs uses Wowhead for its tooltips, which also allows you to specify extra advanced parameters to indicate things like what enchants and gems the item has. We do this by constructing an extraInfo
parameter based on item.permanentEnchant
and item.gems
. You can read more about what extra parameters Wowhead supports for items.
Now we just need to adjust the code we wrote that builds our rows of data to use our new function instead of simply using item.name
:
return {
slot: slots[slot],
item: convertItemToMarkdown(item), // Used to be: item.name
isEnchanted: item.permanentEnchant
? 'Yes'
: 'No'
};
When run, this makes our item column much more useful:
You might notice that if the component becomes thin enough, the item column starts to wrap undesirably:
We can fix this by specifying noWrap: true
on our column configuration for the item
column:
columns: {
slot: {
header: 'Slot'
},
item: {
header: 'Item',
noWrap: true // Defaults to false
},
isEnchanted: {
header: 'Enchanted?'
}
},
Ah, much better:
Add checkmarks and crosses
We can also make our "Enchanted?" column easier for the viewer to parse. Instead of showing "Yes" and "No", let's show green ticks and red crosses to make the result more instantly obvious. Again, we can do this using Enhanced Markdown, this time using the Icon
Enhanced Markdown component (which supports ZMDI Icons) and the Kill
and Wipe
style components:
return {
slot: slots[slot],
item: convertItemToMarkdown(item),
isEnchanted: item.permanentEnchant
? `<Kill><Icon type="check"></Kill>` // Used to be: 'Yes'
: `<Wipe><Icon type="close"></Wipe>` // Used to be: 'No'
};
Next, we can also update our column configuration to center-align the text:
columns: {
slot: {
header: 'Slot'
},
item: {
header: 'Item',
noWrap: true
},
isEnchanted: {
header: 'Enchanted?',
textAlign: 'center' // Defaults to 'left'
}
},
Putting this together gives:
Which should be a lot easier for a user to read at a glance.
Add a title
Lastly, this component is likely going to live in a dashboard with other components, so we should give it a title so that the user has some context of what he is looking at.
The easiest way to add a title to a Table
Report Component is to add a column header group. Each column
configuration has an optional columns
property that specifies the columns within a header group. For example, we can change our columns
prop to have a single top-level column, which then contains our three existing columns:
columns: {
title: {
header: 'Enchant Checker',
columns: {
slot: {
header: 'Slot'
},
item: {
header: 'Item',
noWrap: true
},
isEnchanted: {
header: 'Enchanted?',
textAlign: 'center'
}
}
}
},
Our top-level column needs a unique key. In our example, we've used title
. However, note that you cannot add row data to a column that contains other columns.
This adds the "Enchant Checker" title to our table:
If we want to, we can go one step further by specifying which player the enchant check is for. We could also show a spec icon for the player to show what spec they were playing for the fight.
To get the spec, we need to use the fight.specForPlayer
function. However, that function requires us to pass in the player actor. reportGroup.actors
contains all the actors in the selected reports, so we can get the actor from here. We know the actor ID because it is attached to the combatant info event as source.id
. Putting that together we get:
const player = reportGroup.actors
.find(actor => actor.id === combatantInfo.source.id);
const spec = reportGroup.fights[0].specForPlayer(player);
spec
will contain the name of the spec the actor is playing, such as "Feral". We can get the name of the class the actor is playing from player.subType
, such as "Druid". Putting those together we can create the type
property for the Enhanced Markdown ActorIcon
, which is of the form "Druid-Feral". We can now build our title:
const title = `Enchant check for <ActorIcon type="${player.subType}-${spec}">${player.name}</ActorIcon>`;
Making sure to then update the header
property for our top-level column in our Table
props, we get:
Putting it all together
Putting everything together, we get a full script of:
getComponent = () => {
// Guard against incorrect use
const onlyOneFightSelected = reportGroup.fights.length === 1;
const onlyOneCombatantInfoEvent =
reportGroup.fights[0].combatantInfoEvents.length === 1;
if (!onlyOneFightSelected || !onlyOneCombatantInfoEvent) {
return {
component: 'EnhancedMarkdown',
props: {
content: 'Please select a single fight and player to check enchants for.'
}
}
}
// Filter to the relevant data
const combatantInfo = reportGroup.fights[0].combatantInfoEvents[0];
// Build the rows for our table
const rows = combatantInfo.gear
.map((item, index) => {
const slot = index + 1;
const slotIsRelevant = slots[slot] !== undefined;
const itemIsRelevant = item.id > 0;
if (slotIsRelevant && itemIsRelevant) {
return {
slot: slots[slot],
item: convertItemToMarkdown(item),
isEnchanted: item.permanentEnchant
? `<Kill><Icon type="check"></Kill>`
: `<Wipe><Icon type="close"></Wipe>`
};
}
return null;
})
.filter(row => row !== null);
// Construct a title for our table
const player = reportGroup.actors
.find(actor => actor.id === combatantInfo.source.id);
const spec = reportGroup.fights[0].specForPlayer(player);
const title = `Enchant check for <ActorIcon type="${player.subType}-${spec}">${player.name}</ActorIcon>`;
// Render our table
return {
component: 'Table',
props: {
columns: {
title: {
header: title,
columns: {
slot: {
header: 'Slot'
},
item: {
header: 'Item',
noWrap: true
},
isEnchanted: {
header: 'Enchanted?',
textAlign: 'center'
}
}
}
},
data: rows
}
}
}
const slots = {
1: 'Head',
3: 'Shoulder',
15: 'Cloak',
5: 'Chest',
9: 'Bracer',
10: 'Gloves',
7: 'Legs',
8: 'Boots',
16: 'Main Hand',
17: 'Off Hand',
11: 'Ring 1',
12: 'Ring 2'
}
function convertItemToMarkdown(item) {
const extraInfoParts = [];
if (item.permanentEnchant)
extraInfoParts.push(`ench=${item.permanentEnchant}`)
if (item.gems)
extraInfoParts.push(
`gems=${item.gems.map(gem => gem.id).join(':')}:`
)
const extraInfo = extraInfoParts.join('&');
return `<ItemIcon id="${item.id}" icon="${item.icon}" type="${item.quality}" extraInfo="${extraInfo}">${item.name}</ItemIcon>`;
}
Closing Thoughts
Table
Report Components are very flexible, and let us render all sorts of data, whether it is an aggregated calculation for each player, a check for each gear slot, an insight at various timestamps, or something else. Using Enhanced Markdown cells in our Table
allows us to add important context about abilities, actors, and items via styling, icons, and tooltips.
However, not every set of data is best represented by a table! Our next article looks at the Chart
component, which contains a wealth of visualisations for various use cases.