Matrix Custom Report

Overview

Custom reports are a great way for analyzing and communicating the Enterprise Architecture insights of your organization in an effective way.

In this step-by-step tutorial we create a LeanIX custom report that demonstrates how to design a matrix-layout data visualization. More specifically, we'll display a matrix of applications vs their lifecycle phase start dates, if defined, as in the picture below.

Prerequisites

Getting Started

Initialize a new project by running the following command and answering the questionnaire. For this tutorial we will be using the vue template:

npm init lxr@latest

After this procedure, you should end up with the following project structure:

Adjust the report boilerplate source code

We need to make some modifications in our project's boilerplate code. We start by deleting the unnecessary files:

  • src/assets/logo.png
  • src/components/HelloWorld.vue

Then we add TailwindCSS, a CSS framework that provide several utility classes that we use during our tutorial for styling it. For that we follow the official installation guide and perform the following steps:

  1. Install Tailwind and its peer-dependencies using npm:

    npm install -D tailwindcss@latest postcss@latest autoprefixer@latest
    
  2. Next, generate your tailwind.config.js and postcss.config.js files:

    npx tailwindcss init -p
    
  3. In your tailwind.config.js file, configure the purge option with the paths to all of your pages and components so Tailwind can tree-shake unused styles in production builds:

    // tailwind.config.js
    module.exports = {
    purge: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
    darkMode: false, // or 'media' or 'class'
    theme: {
        extend: {},
    },
    variants: {
        extend: {},
    },
    plugins: [],
    }
    
  4. Additionally, ensure your CSS file is being imported in your ./src/main.js file

    // src/main.js
    import { createApp } from 'vue'
    import App from './App.vue'
    import 'tailwindcss/tailwind.css'
    
    createApp(App).mount('#app')
    
  5. Finally, adjust the ./src/App.vue file and set the template and script tags as follows:

    <template>
    <!-- we'll use this template tag for declaring our custom report html -->
    <div>Hi from LeanIX Custom Report</div>
    </template>
    
    <script setup>
    // all the state variables and business logic will be declared here
    </script>
    
  6. You may now start the development server now by running the following command:

    npm run dev
    

When you run npm run dev, a local webserver is hosted on localhost:3000 that allows connections via HTTPS. But since just a development SSL certificate is created the browser might show a warning that the connection is not secure. You could either allow connections to this host anyways, or create your own self-signed certificate.

If you decide to add a security exception to your localhost, make sure you open a second browser tab and point it to https://localhost:3000. Once the security exception is added to your browser, reload the original url of your development server and open the development console. Your should see a screen similar to the one below:

Nothing very exciting happens here. Notice however that our report loads, and is showing the message we defined inside the template tag of the App.vue file.

Report design

We want to implement a matrix-layout report which will show a list of applications that exist in our workspace versus the start date of each corresponding lifecycle phase, if defined.

We'll divide our report implementation into two parts: querying the workspace data and visualizing the results.

Querying the workspace data

In order to build our application lifecycle matrix, we'll fetch from our workspace a list of applications using the facet filter data fetching interface provided by the leanix-reporting api. We would like also to store the baseUrl of our workspace in a state variable, so that we can navigate later into the applications by clicking on them. Finally, we also will want to extract the color code for the different Application lifecycle phases, which we'll derive from the workspace view model contained in the reportSetup object and store it into the state variable applicationLifecyclePhases. In order to do so, we'll edit our src/App.vue file and include in the script section three state variables: baseUrl, applications and applicationLifecyclePhases. We'll also add two methods: initializeReport and the helper method translateLifecycleField that we'll use for translating our application lifecycle fields in our template:

<script setup>
import { ref } from 'vue'
import '@leanix/reporting'

// variable that will store our workspace's baseUrl
const baseUrl = ref(null)
// variable for storing the workspace's applications
const applications = ref([])
// variable for storing the workspace's application lifecycle phases view model
const applicationLifecyclePhases = ref({})

// the report initialization method
const initializeReport = async () => {
  const reportSetup = await lx.init()

  // we extract our application lifecycle phases color code here...
  applicationLifecyclePhases.value = reportSetup.settings.viewModel.factSheets
    .find(({ type }) => type === 'Application')
    .fieldMetaData.lifecycle.values


  // we extract our workspace's baseUrl from the reportSetup object
  // and store it into the baseUrl state variable
  baseUrl.value = reportSetup.settings.baseUrl

  const config = {
    facets: [
      {
        key: 1,
        fixedFactSheetType: 'Application',
        attributes: ['name', 'type', 'lifecycle {asString phases {phase startDate}}'],
        callback: dataset => { applications.value = dataset }
      }
    ]
  }
  lx.ready(config)
}

// auxiliary method for translating our application lifecycle fields
const translateLifecycleField = ({ type, lifecycle }) => lifecycle
  ? lx.translateFieldValue(type, 'lifecycle', lifecycle.asString)
  : 'n/a'

// we call our report initialization method here
initializeReport()
</script>

In order to take a peek at the application list that is being fetch from our workspace, we'll change the template section of our src/App.vue file as follows:

<template>
  <div class="container mx-auto h-screen">
    <div
      v-for="application in applications"
      :key="application.id"
      class="flex mb-2 text-xs">
      <div class="w-1/3 font-bold mr-6">{{application.name}}</div>
      <div>{{translateLifecycleField(application)}}</div>
    </div>
  </div>
</template>

Your report should now be showing a list of application names and the current lifecycle phase, as in the picture below:

Notice that this list is filterable, and it gets updated as soon as you set a new filtering criteria in the report facet.

Altough the list looks interesting, it is still yet very different from the matrix-view report we aim to implement. Our matrix report will have one column for the application name, and one to each lifecycle phase defined in our workspace. Our next job in this tutorial will be to create a method that maps our application list into a set of matrix rows to be rendered in our report.

Visualizing the results

Edit the script section of our src/Application.vue file and add three new state variables - headerRow, rows and gridStyle, two methods: the computeRows method and the applicationClickEvtHandler method, and a watcher for the applications state variable as follows:

<script setup>
import { ref, watch } from 'vue'
import '@leanix/reporting'

const baseUrl = ref(null)
const applications = ref([])
const applicationLifecyclePhases = ref({})

// the state variable that will hold the matrix top row containing the column headers/names
const headerRow = ref([])
// the state variable that will hold our application rows
const rows = ref([])
// will dynamically set the number of columns in our grid, based on the lifecycle phases fetched from workspace
const gridStyle = ref('')

// the report initialization method
const initializeReport = async () => { /* nothing changes here */ }

// our method for computing our matrix rows
const computeRows = () => {
  const lifecycleColumnKeys = Object.keys(applicationLifecyclePhases.value)

  headerRow.value = Object.entries(applicationLifecyclePhases.value)
    .reduce((accumulator, [key, { bgColor, color }]) => {
      const label = lx.translateFieldValue('Application', 'lifecycle', key)
      accumulator.push({
        key,
        label,
        classes: 'text-center font-bold py-2',
        style: `color:${color};background:${bgColor}`
      })
      return accumulator
    }, [{ key: 'pivot-cell' }])

  gridStyle.value = `grid-template-columns: 250px repeat(${headerRow.value.length - 1}, 150px)`

  rows.value = applications.value
    .map(({ id, type, name, lifecycle }) => {
      // first column containing the name of the application
      const headerColumn = {
        key: id,
        type,
        label: name,
        classes: 'hover:underline cursor-pointer border-r font-bold py-2'
      }
      let lifecyclePhaseColumns = []
      // if application doesn't have a lifecycle defined
      if (lifecycle === null) lifecyclePhaseColumns = lifecycleColumnKeys
        .map(key => ({ key: `${id}_${key}`, label: null, classes: 'border-r last:border-r-0' }))
      else {
        let { asString, phases } = lifecycle
        const { bgColor, color } = applicationLifecyclePhases.value[asString]
        phases = phases.reduce((accumulator, { phase, startDate }) => {
          accumulator[phase] = startDate
          return accumulator
        }, {})
        lifecyclePhaseColumns = lifecycleColumnKeys
          .map(key => ({
            key: `${id}_${key}`,
            label: phases[key] || null,
            classes: `border-r last:border-r-0 py-2`,
            style: key === asString ? `color:${color};background:${bgColor}80` : ''
          }))
        headerColumn.style = `color:${color};background:${bgColor}`
      }
      return [headerColumn, ...lifecyclePhaseColumns]
    })
}

// our application click event handler which will open a factsheet preview on
// when the user clicks on the application name cell
const applicationClickEvtHandler = ({ type = null, key: id }) => {
  if (type === null) return
  const url = `${baseUrl.value}/factsheet/${type}/${id}`
  lx.openLink(url)
}

// we set a watcher here for calling the computeRows method on every update of the applications variable
watch(applications, computeRows)

initializeReport()
</script>

We'll also adjust the template section of our src/Application.vue file as follows:

<template>
  <div class="container mx-auto h-screen text-xs">
    <div class="flex flex-col items-center h-full overflow-hidden">
      <div class="grid border-b border-r" :style="gridStyle">
        <div
          v-for="cell in headerRow"
          :key="cell.key"
          :class="cell.classes"
          :style="cell.style">
          {{cell.label}}
        </div>
      </div>
      <div class="h-full overflow-y-auto overflow-x-hidden">
        <div
          v-for="(row, rowIdx) in rows"
          :key="rowIdx"
          class="grid border-r border-l hover:bg-gray-100 transition-colors duration-150 ease-in-out"
          :style="gridStyle">
          <div
            v-for="cell in row"
            :key="cell.key"
            class="text-center border-r border-b"
            :class="cell.classes"
            :style="cell.style"
            @click="applicationClickEvtHandler(cell)">
            {{cell.label}}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

Now just run npm run dev and enjoy your matrix report!

Congratulations, you have finalized this tutorial!

Here is the complete codebase from src/App.vue for your reference:

<script setup>
import { ref, watch } from 'vue'
import '@leanix/reporting'

// variable that will store our workspace's baseUrl
const baseUrl = ref(null)
// variable for storing the workspace's applications
const applications = ref([])
// variable for storing the workspace's application lifecycle phases view model
const applicationLifecyclePhases = ref({})

// the state variable that will hold the matrix top row containing the column headers/names
const headerRow = ref([])
// the state variable that will hold our application rows
const rows = ref([])
// will dynamically set the number of columns in our grid, based on the lifecycle phases fetched from workspace
const gridStyle = ref('')

// the report initialization method
const initializeReport = async () => {
  const reportSetup = await lx.init()

  applicationLifecyclePhases.value = reportSetup.settings.viewModel.factSheets
    .find(({ type }) => type === 'Application')
    .fieldMetaData.lifecycle.values

  // we extract our workspace's baseUrl from the reportSetup object
  // and store it into the baseUrl state variable
  baseUrl.value = reportSetup.settings.baseUrl

  const config = {
    facets: [
      {
        key: 1,
        fixedFactSheetType: 'Application',
        attributes: ['name', 'type', 'lifecycle {asString phases {phase startDate}}'],
        callback: dataset => { applications.value = dataset }
      }
    ]
  }
  lx.ready(config)
}

// auxiliary method for translating our application lifecycle fields
const translateLifecycleField = ({ type, lifecycle }) => lifecycle
  ? lx.translateFieldValue(type, 'lifecycle', lifecycle.asString)
  : 'n/a'

const computeRows = () => {
  const lifecycleColumnKeys = Object.keys(applicationLifecyclePhases.value)

  headerRow.value = Object.entries(applicationLifecyclePhases.value)
    .reduce((accumulator, [key, { bgColor, color }]) => {
      const label = lx.translateFieldValue('Application', 'lifecycle', key)
      accumulator.push({
        key,
        label,
        classes: 'text-center font-bold py-2',
        style: `color:${color};background:${bgColor}`
      })
      return accumulator
    }, [{ key: 'pivot-cell' }])

  gridStyle.value = `grid-template-columns: 250px repeat(${headerRow.value.length - 1}, 150px)`

  rows.value = applications.value
    .map(({ id, type, name, lifecycle }) => {
      // first column containing the name of the application
      const headerColumn = {
        key: id,
        type,
        label: name,
        classes: 'hover:underline cursor-pointer border-r font-bold py-2'
      }
      let lifecyclePhaseColumns = []
      // if application doesn't have a lifecycle defined
      if (lifecycle === null) lifecyclePhaseColumns = lifecycleColumnKeys
        .map(key => ({ key: `${id}_${key}`, label: null, classes: 'border-r last:border-r-0' }))
      else {
        let { asString, phases } = lifecycle
        const { bgColor, color } = applicationLifecyclePhases.value[asString]
        phases = phases.reduce((accumulator, { phase, startDate }) => {
          accumulator[phase] = startDate
          return accumulator
        }, {})
        lifecyclePhaseColumns = lifecycleColumnKeys
          .map(key => ({
            key: `${id}_${key}`,
            label: phases[key] || null,
            classes: `border-r last:border-r-0 py-2`,
            style: key === asString ? `color:${color};background:${bgColor}80` : ''
          }))
        headerColumn.style = `color:${color};background:${bgColor}`
      }
      return [headerColumn, ...lifecyclePhaseColumns]
    })
}

const applicationClickEvtHandler = ({ type = null, key: id }) => {
  if (type === null) return
  const url = `${baseUrl.value}/factsheet/${type}/${id}`
  lx.openLink(url)
}

watch([applications], computeRows)
// we call our report initialization method here
initializeReport()
</script>

<template>
  <div class="container mx-auto h-screen text-xs">
    <div class="flex flex-col items-center h-full overflow-hidden">
      <div class="grid border-b border-r" :style="gridStyle">
        <div
          v-for="cell in headerRow"
          :key="cell.key"
          :class="cell.classes"
          :style="cell.style">
          {{cell.label}}
        </div>
      </div>
      <div class="h-full overflow-y-auto overflow-x-hidden">
        <div
          v-for="(row, rowIdx) in rows"
          :key="rowIdx"
          class="grid border-r border-l hover:bg-gray-100 transition-colors duration-150 ease-in-out"
          :style="gridStyle">
          <div
            v-for="cell in row"
            :key="cell.key"
            class="text-center border-r border-b"
            :class="cell.classes"
            :style="cell.style"
            @click="applicationClickEvtHandler(cell)">
            {{cell.label}}
          </div>
        </div>
      </div>
    </div>
  </div>
</template>