About Dashboard

The dashboard is implemented by the module a-dashboard. This article is mainly for secondary development users to explain how to make widgets. Therefore, if you are interested in the underlying implementation mechanism, you can directly view the source code of the module a-dashboard

About Widget

Widgets are the basic units that make up the dashboard. The widgets in CabloyJS make full use of the responsive mechanism of Vue component and realize the very flexible data binding features, which can let data flow between the widgets of the dashboard

The module test-party has four built-in widgets: Fruit Sales, Fruit Sales(Line Chart), Fruit Sales(Pie Chart), Snapshots

The widget Fruit Sales(Line Chart) can obtain data from the widget Fruit Sales, and can provide data to the widget “Snapshots”, which has a connecting role. Therefore, we take the widget Fruit Sales(Line Chart) as an example to illustrate how to develop a widget

1. Create a Vue component

The widget itself is a Vue component, but it cleverly uses multiple syntax features of Vue. In order to explain it more clearly, all the source codes are pasted here before detailed explanation

src/module/test-party/front/src/kitchen-sink/components/widgets/salesLine.vue

<template>
  <f7-card>
    <f7-card-header>{{$text('Fruit Sales(Line Chart)')}}</f7-card-header>
    <f7-card-content>
      <canvas ref="chart"></canvas>
      <div class="error" v-if="errorMessage">{{errorMessage}}</div>
    </f7-card-content>
  </f7-card>
</template>
<script>
const propsSchema = {
  type: 'object',
  properties: {
    dataSource: {
      type: 'object',
      ebType: 'text',
      ebTitle: 'Data Source',
      ebBindOnly: true,
      ebClue: 'salesDataSource',
    },
    fruit: {
      type: 'string',
      ebType: 'select',
      ebTitle: 'Fruit',
      ebOptions: [
        { title: 'All', value: 'All' },
        { title: 'Apples', value: 'Apples' },
        { title: 'Pears', value: 'Pears' },
      ],
      ebOptionsBlankAuto: true,
      ebClue: 'salesFruit',
    },
  },
};

const attrsSchema = {
  type: 'object',
  properties: {
    snapshot: {
      ebTitle: 'Snapshot',
      ebClue: 'snapshot',
    },
  },
};

// export
export default {
  install,
};

// install
function install(_Vue) {
  const Vue = _Vue;
  const ebDashboardWidgetBase = Vue.prototype.$meta.module.get('a-dashboard').options.mixins.ebDashboardWidgetBase;
  return {
    meta: {
      widget: {
        schema: {
          props: propsSchema,
          attrs: attrsSchema,
        },
      },
    },
    mixins: [ebDashboardWidgetBase],
    props: {
      dataSource: {
        type: Object,
      },
      fruit: {
        type: String,
      },
    },
    data() {
      return {
        chartjs: null,
        chart: null,
        snapshot: null,
        errorMessage: null,
      };
    },
    watch: {
      dataSource() {
        this.__updateChart();
      },
      fruit() {
        this.__updateChart();
      },
    },
    mounted() {
      this.__init();
    },
    beforeDestroy() {
      if (this.chart) {
        this.chart.destroy();
      }
    },
    methods: {
      __init() {
        this.$meta.module.use('a-chartjs', module => {
          this.chartjs = module.options.utils.chartjs;
          this.__updateChart();
        });
      },
      __prepareData() {
        const fruitIndex = this.dataSource.cols.findIndex(item => item === this.fruit);
        if (fruitIndex === -1) throw new Error();
        const chartData = {
          labels: this.dataSource.rows,
          datasets: [{
            fill: false,
            backgroundColor: this.dataSource.colors[fruitIndex],
            data: this.dataSource.dataset.map(item => item[fruitIndex]),
          }, ],
        };
        return chartData;
      },
      __prepareOptions() {
        const chartOptions = {
          maintainAspectRatio: false,
          responsive: true,
          animation: {
            onComplete: () => {
              this.__createSnapshot();
            }
          },
          title: {
            display: true,
            position: 'top',
            text: this.fruit,
            fontColor: 'rgba(128, 128, 128, 0.6)',
          },
          legend: {
            display: false,
          },
          scales: {
            xAxes: [{
              gridLines: {
                display: false,
              },
              ticks: {
                fontColor: 'rgba(128, 128, 128, 0.6)',
              },
            }],
            yAxes: [{
              gridLines: {
                display: true,
              },
              ticks: {
                fontColor: 'rgba(128, 128, 128, 0.6)',
                stepSize: 200,
              },
            }],
          },
        };
        return chartOptions;
      },
      __createSnapshot() {
        const image = this.chart.toBase64Image();
        this.snapshot = {
          title: this.$text('Fruit Sales(Line Chart)'),
          image,
        };
      },
      __clearChart() {
        if (this.chart) {
          this.chart.clear();
        }
      },
      __updateChart() {
        try {
          if (!this.dataSource || !this.fruit) {
            this.__clearChart();
            this.errorMessage = this.$text('Please set data source');
            return;
          }
          const chartData = this.__prepareData();
          const chartOptions = this.__prepareOptions();
          if (!this.chart) {
            // canvas
            const chartCanvas = this.$refs.chart.getContext('2d');
            // fill
            this.chart = new this.chartjs(chartCanvas, {
              type: 'line',
              data: chartData,
              options: chartOptions,
            });
          } else {
            this.chart.data = this.__prepareData();
            this.chart.options = this.__prepareOptions();
            this.chart.update();
          }
          this.errorMessage = null;
          return;
        } catch (err) {
          this.__clearChart();
          this.errorMessage = this.$text('There may be a binding error');
        }
      },
    },
  };

}

</script>
<style lang="less" scoped>
.error {
  position: absolute;
  bottom: 6px;
  right: 6px;
  font-size: smaller;
}

</style>

1.1 export default

First of all, we do not export Options object directly like ordinary Vue component, but export an object with the install method like Vue plugin

1.2 mixins: [ebDashboardWidgetBase]

ebDashboardWidgetBase is the base component provided by module a-dashboard. All widgets must mixin this base component

Here we can explain why we can’t export the Options object directly. Because module a-dashboard is a module loaded asynchronously, and all widgets must mixin ebDashboardWidgetBase. If export Options object directly, you will not obtain ebDashboardWidgetBase at compile time, so mixin will fail

1.3 meta

Meta is used to store the meta information of Vue component

1.3.1 meta.widget.schema.props

schema.props is the JSON Schema corresponding to the props of the widget component, which is used to render the widget’s Properties form page

About JSON Schema, Please refer to: Form Validation

Name Default Description
type Prop’s type
ebType Prop’s rendering component type
ebTitle Prop’s title
ebBindOnly false Only dynamic binding is allowed
ebClue Prop’s clue. When binding data between widgets, only the properties with the same ebClue are allowed to be bound, so as to simplify the selection of users and reduce the interference of irrelevant widgets and data

1.3.2 meta.widget.schema.attrs

schema.attrs is the JSON Schema corresponding to the data attributes of the widget component, which is used as data source when data binding

In other words, not all data attributes of a widget can be used as a data source. To be a data source, it must be defined in schema.attrs

1.4 watch

In order to respond to the change of data source in real time, we can use another syntax feature of Vue, watch, to watch the change of all properties in schema.props

2. Reference Vue component

In order for the system to find this Vue component, you need to reference it in the component list

src/module/test-party/front/src/components.js

import widgetSalesLine from './kitchen-sink/components/widgets/salesLine.vue';

export default {
  widgetSalesLine,
};

3. Definition of Function at backend

In a complex system, the problem of data authorization will be involved. We need to control the authority of widgets. Then, we need to define the function corresponding to the widget at backend so that the widget can be authorized

3.1 Definition of Function

src/module/test-party/backend/src/meta.js

...
widgetSalesLine: {
  title: 'Fruit Sales(Line Chart)',
  component: 'widgetSalesLine',
  menu: 3,
  public: 1,
},
...
Name Description
title Widget’s title
component Widget’s Vue component
menu const 3:Function’s type which means widget
public Public or not. If it is public, no authorization is required, and all users can access it

3.2 Initialization

src/module/test-party/backend/src/service/version.js

      if (options.version === 5) {
        // roleFunctions: widgets
        const roleFunctions = [
          { roleName: null, name: 'widgetSales' },
          { roleName: null, name: 'widgetSalesLine' },
          { roleName: null, name: 'widgetSalesPie' },
          { roleName: null, name: 'widgetSnapshot' },
        ];
        await this.ctx.meta.role.addRoleFunctionBatch({ roleFunctions });
      }

We need to add the authorization record of the widget to the database when the module data version is initialized. Because the public of the widget is true, the roleName here should be null

  1. About Module Data Version, please refer to: Module Data Version
  2. About Definition of Function, please refer to: Function & Menu
  3. About Function Authorization, please refer to: Function Authorization