Decorative background for title

Report Components: Table

FF 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 FF 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:

  1. This will be populated even if the user filters to an actor without combatant info events (such as the boss).
  2. 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, FF 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:

  1. The map array method lets us build a new array by executing a function on each item in the existing array.

  2. FF 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.

  3. 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 our slots object has a value for the slot ID with const slotIsRelevant = slots[slot] !== undefined.

  4. 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.

  5. 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 for permanentEnchant.

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

  6. If the slot or item isn't relevant, then we return null. After the map, we get rid of these null 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.

Advertisements
Remove Ads