Skip to main content

Custom Filter Plugin Development Guide

1. Overview

Custom filter plugins need to be written and registered in the plugin code. As long as the custom filter is registered with BI through openSDK in the plugin code, the corresponding custom filter can be selected and configured when creating a new filter in the dashboard.

This article will guide you on how to write custom filter plugin code through the case study of Multi-select Table Interactive Filter.

2. Usage Guide

Guandata provides a custom filter Plugin Code Template. You can extend development through the plugin code template.

The custom filter plugin development operation steps are as follows:

  1. Write the component basic structure by referring to the Plugin Code Template
  2. Implement component rendering based on WebComponent and Lit framework
  3. Load data through methods and properties provided by BI
  4. After user operations, submit filter conditions in the specified format to BI
  5. When opening next time, parse and process previously saved filter conditions
  6. Complete the Schema writing for the configuration form, and apply the values filled in by users in the configuration form
  7. Supplement operation details and complete the code

3. Development Case: Multi-select Table Interactive Filter

This chapter will demonstrate how to develop a multi-select table interactive filter through filter plugin capabilities using code examples.

Filter Requirements

Functions to be implemented

  1. List all fields from the filter's "Usage Fields" in a table and check them by row, multiple rows can be checked, and the corresponding values of all "Linkage Fields" are used as filter conditions;
  2. When the data volume is large, pagination can be used, and the number of rows per page can be configured by users when configuring the filter.

Filter Configuration

Filter Usage Effect

Code Example

Step 1: Write Component Basic Structure

This part is extended based on the Plugin Code Template, including:

  1. Filter component MultiSelectTable based on Web Component and Lit framework;

  2. Load required resources such as Lit, Shoelace, etc., and register Web Component;

    • Resource loading methods and registration timing can be adjusted as needed;
    • Shoelace components have automatic on-demand loading functionality. When <sl-element> is first rendered, it will automatically send requests to fetch some code. Normal use doesn't require attention, but it won't work inside other shadow roots, so plugins need to manually call the discover method in shoelace-autoloader. For detailed information, see here;
    • Discover needs to pay attention to the calling timing. If some Shoelace components only render when there is data, they may miss the first render discover time point; during development, you can consider calling shoelaceAutoloader.discover multiple times, or add an additional display: none element for discover to use;
    • Shoelace components will move a bit when loading on demand, you can give an appropriate CSS style to cover it, refer to here;
  3. Register the plugin with BI through Register Custom FilterGD.registerCustomSelector. When registering, please note:

    • System internal id and tagName cannot be duplicated. System internal id and tagname can be obtained through Get Custom FiltersGD.getCustomSelectors;
    • tagName also needs to be careful not to duplicate with element names that appear in Frontend Page Transformation, otherwise unwanted effects may be implemented;
    • If you modify the id, custom filter cards already created in the dashboard may become invalid.
const registerMultiSelectTable = async () => {
// Load necessary resources
await GD.asyncLoadScript(GD.getResourceUrl('lit'), { type: 'module' })
await GD.asyncLoadScript(GD.getResourceUrl('shoelace'), { type: 'module' })
await GD.asyncLoadScript(GD.getResourceUrl('shoelace-theme-light'), { tagName: 'link' })
await GD.asyncLoadScript(GD.getResourceUrl('shoelace-theme-dark'), { tagName: 'link' })

const { LitElement, html, css } = window.lit
const { discover } = window.shoelaceAutoloader || {}

// Define Web Component class
class MultiSelectTable extends LitElement {
static properties = {
// Linkage fields
linkColumns: { type: Array },
// Usage fields
allColumns: { type: Array },
// Get detail table data
fetchData: { type: Function },
// Get optional value list for a single field
fetchFieldValues: { type: Function },
// Get optional value list for multiple fields
fetchTreeValues: { type: Function },
// Output filter value, type is the type of combined filter, concatenate according to linkage fields
onConfirm: { type: Function },
// Initialize filter value, type is consistent with onConfirm parameter
initialFilterValue: { type: Array },
// Pagination properties
pageSize: { type: Number },
currentPage: { type: Number },
total: { type: Number },
};

constructor() {
super();
// Initialize properties
this.linkColumns = [];
this.allColumns = [];
this.fetchData = null;
this.onConfirm = null;
this.initialFilterValue = [];
this.pageSize = 10;
this.currentPage = 1;
this.total = 0;
this.data = [];
// Used to store selected data
this._selectedData = new Map();
}

static styles = css`
/* Basic style definitions */
table {
width: 100%;
border-collapse: collapse;
}
/* ... */
`;

// Lifecycle method: component first update
firstUpdated() {
super.firstUpdated()
// Load initial data
this.refreshData()
// Activate Shoelace components
discover?.(this.shadowRoot)
}

// Component render method
render() {
// Render implementation will be added in subsequent steps
return html``;
}
}

// Register Web Component
customElements.define('multi-select-table', MultiSelectTable);
}

// Configuration form definition, specific implementation will be added in subsequent steps
const configSchema = []

// Register plugin
GD.registerCustomSelector({
id: 'multi-select-table-selector',
title: 'Multi-select Table Filter',
tagName: 'multi-select-table',
desc: 'Filter that displays data through tables, supports multi-selection',
configFormSchema: configSchema,
load: registerMultiSelectTable
})

Step 2: Implement Table Rendering

  • When using the Lit framework, component rendering is responsible for the render method, and the main part of table rendering is implemented here;
  • Since Lit and Shoelace have been referenced at the beginning of the code, you can use html`` template syntax, @sl-change and other Lit syntax features, as well as <sl-checkbox> and other Shoelace components.
render() {
const totalPages = Math.ceil(this.total / this.pageSize);

return html`
<div>
<table>
<thead>
<tr>
<th>
<sl-checkbox
?checked="${this.isCurrentPageAllSelected()}"
@sl-change="${this.handleSelectAll}"
></sl-checkbox>
</th>
${this.allColumns.map((col) => html`
<th>${col.name}</th>
`)}
</tr>
</thead>
<tbody>
${this.data.map((item, index) => {
const globalIndex = (this.currentPage - 1) * this.pageSize + index;
return html`
<tr>
<td>
<sl-checkbox
?checked="${this._selectedData.has(globalIndex)}"
@sl-change="${(e) => this.handleCheckboxChange(e, item, globalIndex)}"
></sl-checkbox>
</td>
${this.allColumns.map((col, colIndex) => html`
<td>${item[colIndex]}</td>
`)}
</tr>
`;
})}
</tbody>
</table>
<div class="pagination">
<sl-button
?disabled="${this.currentPage === 1}"
@click="${() => this.handlePageChange(this.currentPage - 1)}"
>Previous</sl-button>
<span>Page ${this.currentPage} of ${totalPages}</span>
<sl-button
?disabled="${this.currentPage === totalPages}"
@click="${() => this.handlePageChange(this.currentPage + 1)}"
>Next</sl-button>
</div>
<div class="actions">
<sl-button variant="primary" @click="${this.handleSubmit}">Confirm</sl-button>
</div>
</div>
`;
}

Step 3: Implement Data Loading Functionality

To obtain table detail data, you need to use the fetchData method to request data, then read properties such as usage fields allColumns and initialize linkage conditions initialFilterValue to write data into the component state.

Details of these properties and methods accessible in the component can be consulted here.

// Add refreshData method to the registerMultiSelectTable class
async refreshData() {
if (!this.fetchData) return;

try {
const offset = (this.currentPage - 1) * this.pageSize;
const result = await this.fetchData({
limit: this.pageSize,
offset: offset
});

// Update total data count
this.total = result.rowCount || 0;

// Map data columns
const columnIndexMap = this.allColumns.map(targetCol =>
result.columns.findIndex(col => col.fdId === targetCol.fdId)
);

// Reorganize data according to mapping
this.data = result.data.map(row =>
columnIndexMap.map(index => index >= 0 ? row[index] : '')
);

// Parse initialFilterValue, handle initial values
if (...) {
this.parseInitialFilterValue()
}

this.requestUpdate();
} catch (error) {
console.error('Failed to get data:', error);
}
}

Step 4: Handle Filter Condition Submission

Since the functionality needs to satisfy multi-row selection + multi-field, the final generated filter condition format is also complex. Here you need some code to handle the mutual format conversion between table selected data and output data structure (combined filter format).

Finally, the onConfirm method will be used to submit the currently combined filter conditions.

// Add handleSubmit and other methods to the registerMultiSelectTable class

// Handle submit button click
handleSubmit() {
// Get all selected row data
const selectedData = Array.from(this._selectedData.values()).map(selectedItem => {
return this.linkColumns.map(col => {
const colIndex = this.allColumns.findIndex(allCol => allCol.fdId === col.fdId);
return colIndex >= 0 ? selectedItem[colIndex] : '';
});
});

// Deduplicate data
const uniqueSelectedData = /* data deduplication logic */;

// Convert to filter structure
const filterStructure = this.transformToFilterStructure(uniqueSelectedData);

// Call externally passed onConfirm
if (this.onConfirm) {
this.onConfirm(filterStructure);
}
}

// Generate random ID (for filter conditions)
generateId(length = 6) {
// ...
}

// Convert to filter data format
transformToFilterStructure(selectedData) {
// If no data is selected, return empty array
if (!selectedData.length) return [];

// Create top-level composition
const topComposition = {
type: 'composition',
value: []
};

// Process each group of selected data
selectedData.forEach((group, groupIndex) => {
/*
1. Check if it's single field or multi-field condition
2. Build appropriate condition nodes
3. Add condition nodes to composition
4. Add appropriate operators (and/or)
*/
});

return [topComposition];
}

Step 5: Parse and Process Filter Conditions

The filter conditions submitted by onConfirm are saved in BI. When the component is opened next time, they can be read from the initialFilterValue property, and need to be written back to the component state this._selectedData.

// This part is relatively complex, using pseudo-code representation
parseInitialFilterValue() {
/*
1. Check if the initial value format is correct
2. Parse conditions in the top-level composition
3. Distinguish between AND and OR compositions
4. Map conditions to selected state
*/
}

checkCurrentPageMatches() {
/*
1. Calculate global index of current page
2. Traverse current page data
3. Check if each row of data matches filter conditions
4. Mark as selected if matched
*/
}

Step 6: Complete Configuration Form Definition

  • The usage method is consistent with [Chart Configuration Items of Visualization Plugins](../../../../2-Visualization Extensions/2-Visualization Plugins/2-Practical Cases.md#chart-configuration-item-creation);
  • Based on a conventional configuration description, produce an interactive UI for building target objects;
  • Used in plugin development to generate plugin configuration, and can also be used to display some prompt information.
// Configuration form definition
const configSchema = [
{
fieldName: 'pageSize',
label: 'Rows per page',
type: "NUMBER",
defaultValue: 10,
placeholder: 'Please enter the number of data rows displayed per page',
rules: [
{
required: true,
message: 'Please enter the number of data rows displayed per page'
},
{
type: 'number',
min: 1,
max: 100,
message: 'Rows per page must be between 1-100'
}
]
}
]

Step 7: Complete Code

const registerMultiSelectTable = async () => {
// Load necessary resources
await GD.asyncLoadScript(GD.getResourceUrl('lit'), { type: 'module' })
await GD.asyncLoadScript(GD.getResourceUrl('shoelace'), { type: 'module' })
await GD.asyncLoadScript(GD.getResourceUrl('shoelace-theme-light'), { tagName: 'link' })
await GD.asyncLoadScript(GD.getResourceUrl('shoelace-theme-dark'), { tagName: 'link' })

const { LitElement, html, css } = window.lit
const { discover } = window.shoelaceAutoloader || {}

// Define Web Component class
class MultiSelectTable extends LitElement {
static properties = {
// Linkage fields
linkColumns: { type: Array },
// Usage fields
allColumns: { type: Array },
fetchData: { type: Function },
// Get optional value list for a single field
fetchFieldValues: { type: Function },
// Get optional value list for multiple fields
fetchTreeValues: { type: Function },
// Output filter value, type is the type of combined filter, concatenate according to linkage fields
onConfirm: { type: Function },
// Initialize filter value, type is consistent with onConfirm parameter
initialFilterValue: { type: Array },
// Custom configuration
customConfig: { type: Object },
// Pagination properties
pageSize: { type: Number },
currentPage: { type: Number },
total: { type: Number },
};

constructor() {
super();
// Initialize properties
this.linkColumns = [];
this.allColumns = [];
this.fetchData = null;
this.fetchFieldValues = null;
this.fetchTreeValues = null;
this.onConfirm = null;
this.initialFilterValue = [];
this.customConfig = {};
this.pageSize = 10;
this.currentPage = 1;
this.total = 0;
this.data = [];
// Used to store selected data
this._selectedData = new Map();
this._initialFilterValueParsed = false;
this._filterConditions = null;
this._checkedIndices = new Set();
}

static styles = css`
:host {
display: block;
font-family: var(--sl-font-sans);
}

table {
width: 100%;
border-collapse: collapse;
margin-bottom: 16px;
}

th, td {
padding: 8px;
text-align: left;
border: 1px solid var(--sl-color-neutral-300);
}

th {
background-color: var(--sl-color-neutral-100);
font-weight: var(--sl-font-weight-semibold);
}

tr:nth-child(even) {
background-color: var(--sl-color-neutral-50);
}

.pagination {
display: flex;
justify-content: center;
align-items: center;
margin-top: 16px;
gap: 8px;
}

.pagination-info {
margin: 0 16px;
color: var(--sl-color-neutral-700);
}

.actions {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}

sl-checkbox {
--sl-input-border-color: var(--sl-color-neutral-400);
}

sl-button::part(base) {
cursor: pointer;
}
`;

// Lifecycle method: component first update
firstUpdated() {
super.firstUpdated();

// Set page size
if (this.customConfig && this.customConfig.pageSize) {
this.pageSize = this.customConfig.pageSize;
}

// Load initial data
this.refreshData();

// Activate Shoelace components
discover?.(this.shadowRoot);
}

async refreshData() {
if (!this.fetchData) return;

try {
const offset = (this.currentPage - 1) * this.pageSize;
const result = await this.fetchData({
limit: this.pageSize,
offset: offset
});

// Update total data count
this.total = result.rowCount || 0;

// Map data columns
const columnIndexMap = this.allColumns.map(targetCol =>
result.columns.findIndex(col => col.fdId === targetCol.fdId)
);

// Reorganize data according to mapping
this.data = result.data.map(row =>
columnIndexMap.map(index => index >= 0 ? row[index] : '')
);

// Parse initial filter value
if (this.initialFilterValue?.length > 0 && !this._initialFilterValueParsed) {
this.parseInitialFilterValue();
this._initialFilterValueParsed = true;
}

// Check if current page data matches filter conditions
if (this._filterConditions) {
this.checkCurrentPageMatches();
}

this.requestUpdate();
} catch (error) {
console.error('Failed to get data:', error);
}
}

// Check if current page is all selected
isCurrentPageAllSelected() {
if (!this.data.length) return false;
const start = (this.currentPage - 1) * this.pageSize;
return this.data.every((_, index) => this._selectedData.has(start + index));
}

// Handle select all/cancel select all
handleSelectAll(e) {
const start = (this.currentPage - 1) * this.pageSize;
if (e.target.checked) {
// Select all data on current page
this.data.forEach((item, index) => {
this._selectedData.set(start + index, item);
});
} else {
// Cancel selection of all data on current page
this.data.forEach((_, index) => {
this._selectedData.delete(start + index);
});
}
this.requestUpdate();
}

// Handle single checkbox change
handleCheckboxChange(e, item, globalIndex) {
if (e.target.checked) {
this._selectedData.set(globalIndex, item);
} else {
this._selectedData.delete(globalIndex);
}
this.requestUpdate();
}

// Handle page change
async handlePageChange(newPage) {
this.currentPage = newPage;
await this.refreshData();
}

parseInitialFilterValue() {
const topComposition = this.initialFilterValue[0];
if (topComposition.type !== 'composition') return;

// Process top-level OR composition
const orGroups = [];
let currentGroup = [];

for (const item of topComposition.value) {
if (item.type === 'operator' && item.value === 'or') {
if (currentGroup.length > 0) {
orGroups.push([...currentGroup]);
currentGroup = [];
}
} else if (item.type === 'composition') {
// Process AND composition
const conditions = item.value.filter(v => v.type === 'condition');
currentGroup.push(conditions);
} else if (item.type === 'condition') {
// Single condition case
currentGroup.push([item]);
}
}

// Add the last group
if (currentGroup.length > 0) {
orGroups.push(currentGroup);
}

// Save parsed filter conditions
this._filterConditions = orGroups;
}

checkCurrentPageMatches() {
const offset = (this.currentPage - 1) * this.pageSize;

// Check each row of data on current page
this.data.forEach((row, rowIndex) => {
const globalIndex = offset + rowIndex;

// If this row has been checked, skip
if (this._checkedIndices.has(globalIndex)) {
return;
}

// Mark as checked
this._checkedIndices.add(globalIndex);

// Check if matches filter conditions
const matchesAnyOrGroup = this._filterConditions.some(andGroup => {
return andGroup.every(conditions => {
return conditions.every(condition => {
const colIndex = this.allColumns.findIndex(col =>
col.fdId === condition.value.fdId
);
if (colIndex === -1) return false;

const rowValue = row[colIndex];
return condition.value.filterValue.includes(rowValue);
});
});
});

// Only write data if matched
if (matchesAnyOrGroup) {
this._selectedData.set(globalIndex, row);
}
});
}

// Handle submit button click
handleSubmit() {
// Get all selected row data
const selectedData = Array.from(this._selectedData.values()).map(selectedItem => {
return this.linkColumns.map(col => {
const colIndex = this.allColumns.findIndex(allCol => allCol.fdId === col.fdId);
return colIndex >= 0 ? selectedItem[colIndex] : '';
});
});

// Deduplicate data
const uniqueSelectedData = selectedData.filter((item, index) => {
const stringifiedItem = JSON.stringify(item);
return selectedData.findIndex(elem => JSON.stringify(elem) === stringifiedItem) === index;
});

// Convert to filter structure
const filterStructure = this.transformToFilterStructure(uniqueSelectedData);

// Call externally passed onConfirm
if (this.onConfirm) {
this.onConfirm(filterStructure);
}
}

// Generate random ID (for filter conditions)
generateId(length = 6) {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}

// Convert to filter data format
transformToFilterStructure(selectedData) {
// If no data is selected, return empty array
if (!selectedData.length) return [];

// Create top-level composition
const topComposition = {
type: 'composition',
value: []
};

// Process each group of selected data
selectedData.forEach((group, groupIndex) => {
// If there's only one field, directly create condition node
if (this.linkColumns.length === 1) {
const column = this.linkColumns[0];
const condition = {
id: this.generateId(),
type: 'condition',
parentId: this.generateId(),
value: {
...column,
filterType: 'IN',
filterValue: [group[0]],
}
};

// Add condition node to top level
topComposition.value.push(condition);

// If not the last group, add OR operator
if (groupIndex < selectedData.length - 1) {
topComposition.value.push({
type: 'operator',
value: 'or'
});
}
return;
}

// Create inner composition for multiple fields
const innerComposition = {
type: 'composition',
value: []
};

// Create condition for each field
group.forEach((value, index) => {
const column = this.linkColumns[index];

// Create condition node
const condition = {
id: this.generateId(),
type: 'condition',
parentId: this.generateId(),
value: {
...column,
filterType: 'IN',
filterValue: [value],
}
};

// Add condition node
innerComposition.value.push(condition);

// If not the last field, add AND operator
if (index < group.length - 1) {
innerComposition.value.push({
type: 'operator',
value: 'and'
});
}
});

// Add inner composition to top level
topComposition.value.push(innerComposition);

// If not the last group, add OR operator
if (groupIndex < selectedData.length - 1) {
topComposition.value.push({
type: 'operator',
value: 'or'
});
}
});

return [topComposition];
}

// Component render method
render() {
const totalPages = Math.ceil(this.total / this.pageSize);

return html`
<div>
<table>
<thead>
<tr>
<th>
<sl-checkbox
?checked="${this.isCurrentPageAllSelected()}"
@sl-change="${this.handleSelectAll}"
></sl-checkbox>
</th>
${this.allColumns.map((col) => html`
<th>${col.name}</th>
`)}
</tr>
</thead>
<tbody>
${this.data.map((item, index) => {
const globalIndex = (this.currentPage - 1) * this.pageSize + index;
return html`
<tr>
<td>
<sl-checkbox
?checked="${this._selectedData.has(globalIndex)}"
@sl-change="${(e) => this.handleCheckboxChange(e, item, globalIndex)}"
></sl-checkbox>
</td>
${this.allColumns.map((col, colIndex) => html`
<td>${item[colIndex]}</td>
`)}
</tr>
`;
})}
</tbody>
</table>
<div class="pagination">
<sl-button
?disabled="${this.currentPage === 1}"
@click="${() => this.handlePageChange(this.currentPage - 1)}"
>Previous</sl-button>
<span class="pagination-info">Page ${this.currentPage} of ${totalPages}</span>
<sl-button
?disabled="${this.currentPage === totalPages || totalPages === 0}"
@click="${() => this.handlePageChange(this.currentPage + 1)}"
>Next</sl-button>
</div>
<div class="actions">
<sl-button variant="primary" @click="${this.handleSubmit}">Confirm</sl-button>
</div>
</div>
`;
}
}

// Register Web Component
customElements.define('multi-select-table', MultiSelectTable);
}

// Configuration form definition
const configSchema = [
{
fieldName: 'pageSize',
label: 'Rows per page',
type: "NUMBER",
defaultValue: 10,
placeholder: 'Please enter the number of data rows displayed per page',
rules: [
{
required: true,
message: 'Please enter the number of data rows displayed per page'
},
{
type: 'number',
min: 1,
max: 100,
message: 'Rows per page must be between 1-100'
}
]
}
]

// Register plugin
GD.registerCustomSelector({
id: 'multi-select-table-selector',
title: 'Multi-select Table Filter',
tagName: 'multi-select-table',
desc: 'Filter that displays data through tables, supports multi-selection, configurable page size',
configFormSchema: configSchema,
load: registerMultiSelectTable
})

4. Reference Materials

Plugin Code Template

const registerCustomSelector = async () => {
// Load resources provided by BI platform
await GD.asyncLoadScript(GD.getResourceUrl('lit'), { type: 'module' })
await GD.asyncLoadScript(GD.getResourceUrl('shoelace'), { type: 'module' })
await GD.asyncLoadScript(GD.getResourceUrl('shoelace-theme-light'), { tagName: 'link' })
await GD.asyncLoadScript(GD.getResourceUrl('shoelace-theme-dark'), { tagName: 'link' })

const { LitElement, html, css } = window.lit
const { discover } = window.shoelaceAutoloader || {}

// Define WebComponent class SelectableTable
class SelectableTable extends LitElement {
static properties = {
// Linkage fields
linkColumns: { type: Array },
// Usage fields
allColumns: { type: Array },
// Get detail table data
fetchData: { type: Function },
// Get optional value list for a single field
fetchFieldValues: { type: Function },
// Get optional value list for multiple fields
fetchTreeValues: { type: Function },
// Output filter value, type is the type of combined filter, concatenate according to linkage fields
onConfirm: { type: Function },
// Initialize filter value, type is consistent with onConfirm parameter
initialFilterValue: { type: Array },
};

constructor() {
super();
this.linkColumns = [];
this.allColumns = [];
this.fetchData = null;
this.onConfirm = null;
this.initialFilterValue = [];
}

static styles = css`
.custom-selector-container {
width: 100%;
height: 100%;
}
`;

firstUpdated() {
super.firstUpdated()
// If using shoelace components, need to call autoloader.discover method to trigger automatic loading of shoelace components
discover?.(this.shadowRoot)
}

render() {
return html`
<div class="custom-selector-container">
Custom Filter
</div>
`;
}
}

// Register WebComponent to current page
customElements.define('define-your-tagName', SelectableTable);
}

// Used to display text descriptions and fill custom configurations
const legoDefs = [
// Configuration format reference: https://guandata.yuque.com/guandataweb/dsebtl/iefgq3
]

// Register WebComponent and custom configuration form to BI platform
GD.registerCustomSelector({
id: 'unique-id',
title: 'Plugin Title Display',
tagName: 'define-your-tagName',
desc: 'Plugin Function Description',
configFormSchema: legoDefs,
load: registerCustomSelector
})

WebComponent

  • Using Custom Elements - MDN Documentation;
  • The implementation principle of custom filters is to first define WebComponent through customElements.define in the plugin code, then render it to the corresponding filter card by the BI platform.

Lit

  • Lit Framework Official Documentation;
  • WebComponent development framework that greatly improves development experience;
  • BI platform provides v3.2.1 version, with some modifications and adaptations, needs to be introduced through GD.asyncLoadScript and GD.getResourceUrl('lit');
  • Note that Decorator syntax is not supported in BI platform plugin code, because this syntax requires TypeScript or Babel to take effect; other syntax has no restrictions.
await GD.asyncLoadScript(GD.getResourceUrl('lit'), { type: 'module' })

Shoelace

// Even if not using autoloader.discover, still need to reference with type:module
await GD.asyncLoadScript(GD.getResourceUrl('shoelace'), { type: 'module' })
// Light and dark styles need to be introduced simultaneously
await GD.asyncLoadScript(GD.getResourceUrl('shoelace-theme-light'), { tagName: 'link' })
await GD.asyncLoadScript(GD.getResourceUrl('shoelace-theme-dark'), { tagName: 'link' })

Supported Accessible Properties and Methods

class SelectableTable extends LitElement {
static properties = {
// Linkage fields
linkColumns: { type: Array },
// Usage fields
allColumns: { type: Array },
// Get detail table data
fetchData: { type: Function },
// Get optional value list for a single field
fetchFieldValues: { type: Function },
// Get optional value list for multiple fields
fetchTreeValues: { type: Function },
// Output filter value, type is the type of combined filter, concatenate according to linkage fields
onConfirm: { type: Function },
// Initialize filter value, type is consistent with onConfirm parameter
initialFilterValue: { type: Array },
};

// ...
}

Properties

These properties will be passed into the element as HTML element attributes in JSON string format, and can be directly seen in HTML;

If the custom element uses the Lit framework, the component internally doesn't need to handle JSON string issues, directly accessing this.customConfig is the normal JSON structure.

Custom Configuration Content customConfig

User-filled custom configuration content, format is JSON;

The configFormSchema content when registering the component will generate a form for users to fill out, and the filled results are obtained from here.

Usage Fields allColumns

The "Usage Fields" filled in when creating and editing the filter, may be one or multiple;

Usage fields allColumns are only provided for plugin consumption and do not affect BI functions such as linkage, being linked, etc.

Example

{
allColumns: [
{
"fdId": "cbe5217f06de44f0f908232a",
"name": "Province",
"fdType": "STRING",
"metaType": "DIM",
"isAggregated": false,
"calculationType": "normal",
"level": "dataset",
"annotation": "",
"dsId": "k5fa308879b364e5bb628f56"
},
{/*...*/}
]
}
Linkage Fields linkColumns

The "Linkage Fields" filled in when creating and editing the filter, may be one or multiple;

Linkage fields linkColumns must be a subset of usage fields allColumns;

Linkage fields have basically the same function as linkage fields of other filters. When custom filters link other cards, you need to set associations for each linkage field;

When custom filters submit linkage conditions, they also need to match the linkage fields linkColumns.

Example

{
linkColumns: [
{
"fdId": "cbe5217f06de44f0f908232a",
"name": "Province",
"fdType": "STRING",
"metaType": "DIM",
"isAggregated": false,
"calculationType": "normal",
"level": "dataset",
"annotation": "",
"dsId": "k5fa308879b364e5bb628f56"
},
{/*...*/}
]
}
Initialize Linkage Conditions initialFilterValue

The linkage conditions when the filter component initializes, comes from the output of the filter component's last onConfirm, corresponding to the onConfirm item;

The format of linkage conditions is based on the combined filter format, type is ICombinationFilter;

To ensure user experience, when the filter component initializes, it needs to read the filter conditions set last time and display them; it's likely that you need to implement bidirectional conversion between the linkage condition format ICombinationFilter and the filter component's internal state. You can feed the following content to AI and use AI to implement it.

  • ICombinationFilter is as follows:
enum IConditionType {
CONDITION = 'condition',
OPERATOR = 'operator',
COMPOSITION = 'composition', // Nested composition
}

type IConditionSimple = {
not: boolean,
type: IConditionType.CONDITION,
value: any
}

type IConditionOperator = {
type: IConditionType.OPERATOR
value: 'OR' | 'AND'
}

type IConditionComposition = {
type: IConditionType.COMPOSITION
value: (IConditionSimple | IConditionOperator | IConditionComposition)[]
}

type ICondition = (IConditionSimple | IConditionOperator | IConditionComposition)[]

interface ICombinationFilter {
name?: string
condition: ICondition
sourceCdId?: string
}

// Actual filter condition example
const filterValue: ICombinationFilter = {
"condition": [
{
"type": "composition",
"value": [
{
"type": "composition",
"value": [
{
"id": "YetDdI",
"type": "condition",
"parentId": "EjXPJT",
"value": {
"name": "Province",
"fdType": "STRING",
"dsId": "k5fa308879b364e5bb628f56",
"fdId": "cbe5217f06de44f0f908232a",
"metaType": "DIM",
"seqNo": 1,
"isAggregated": false,
"calculationType": "normal",
"level": "dataset",
"annotation": "",
"isDetectionSensitive": false,
"isSensitive": false,
"filterType": "IN",
"filterValue": [
"Sichuan Province"
],
"cdId": "tbb94c8b9bfbd43f49ce58b2"
}
},
{
"type": "operator",
"value": "and"
},
{
"id": "ywfkWx",
"type": "condition",
"parentId": "EjXPJT",
"value": {
"name": "City",
"fdType": "STRING",
"dsId": "k5fa308879b364e5bb628f56",
"fdId": "k31522e65c9ba4fac9e223d7",
"metaType": "DIM",
"seqNo": 3,
"isAggregated": false,
"calculationType": "normal",
"level": "dataset",
"annotation": "",
"isDetectionSensitive": false,
"isSensitive": false,
"filterType": "IN",
"filterValue": [
"Yibin City"
],
"cdId": "tbb94c8b9bfbd43f49ce58b2"
}
}
]
},
{
"type": "operator",
"value": "or"
},
{
"type": "composition",
"value": [
{
"id": "kjmjnK",
"type": "condition",
"parentId": "PFnsKP",
"value": {
"name": "Province",
"fdType": "STRING",
"dsId": "k5fa308879b364e5bb628f56",
"fdId": "cbe5217f06de44f0f908232a",
"metaType": "DIM",
"seqNo": 1,
"isAggregated": false,
"calculationType": "normal",
"level": "dataset",
"annotation": "",
"isDetectionSensitive": false,
"isSensitive": false,
"filterType": "IN",
"filterValue": [
"Zhejiang Province"
],
"cdId": "tbb94c8b9bfbd43f49ce58b2"
}
},
{
"type": "operator",
"value": "and"
},
{
"id": "guTZIF",
"type": "condition",
"parentId": "PFnsKP",
"value": {
"name": "City",
"fdType": "STRING",
"dsId": "k5fa308879b364e5bb628f56",
"fdId": "k31522e65c9ba4fac9e223d7",
"metaType": "DIM",
"seqNo": 3,
"isAggregated": false,
"calculationType": "normal",
"level": "dataset",
"annotation": "",
"isDetectionSensitive": false,
"isSensitive": false,
"filterType": "IN",
"filterValue": [
"Hangzhou City"
],
"cdId": "tbb94c8b9bfbd43f49ce58b2"
}
}
]
}
]
}
],
"sourceCdId": "u8de5094302994ab8a251c18",
"name": "Combined Filter-1"
}
Mobile Rendering isMobile

Whether it's from mobile, bool type.

Methods

These methods are written to element attributes through JavaScript and can be called directly, such as this.fetchData().

Get Detail Data fetchData

Function Description

Used to get dataset detail data.

Usage Instructions

// Parameter example
// filters and treeFilters format same as BI web,
// basically column information + filterType + filterValue
const params = {
offset: 0,
limit: 20,
filters: [
{
fdId: 'dafd2c41b99b44068901b19c',
name: 'Product Name',
fdType: 'STRING',
dsId: 's39796a0f96b145e0ab48dc2',
filterType: 'IN',
filterValue: ['Yili Yogurt']
}
],
treeFilters: [
{
cdId: 'w5e7e168010b64315a052118',
filterType: 'IN',
fields: [
{
fdId: 'bf4121c487a164c02bd81ee8',
name: 'Store',
fdType: 'STRING',
dsId: 's39796a0f96b145e0ab48dc2'
},
{
fdId: 'dafd2c41b99b44068901b19c',
name: 'Product Name',
fdType: 'STRING',
dsId: 's39796a0f96b145e0ab48dc2'
}
],
values: [
[
'Store No.1',
'Lay\'s Chips'
]
],
displayValue: [
[
'Store No.1',
'Lay\'s Chips'
]
]
}
]
}

const response = await this.fetchData(params)

// Return value example
response = {
"columns": [
{
"name": "ID Number",
"fdType": "LONG",
"dsId": "k5fa308879b364e5bb628f56",
"fdId": "s946dbdd2bd644fff93c709c",
"metaType": "METRIC",
},
{
"name": "Province",
"fdType": "STRING",
"dsId": "k5fa308879b364e5bb628f56",
"fdId": "cbe5217f06de44f0f908232a",
"metaType": "DIM",
},
/*...*/
],
"data": [
// Each element in data represents a row in the detail table, array length same as columns length, null values filled with null
[null, "Sichuan Province", "510000", "Chengdu City", "50g Japanese Sakura Moisturizing Cream"],
[null, "Zhejiang Province", "330000", "Jiaxing City", "50ml L'Oreal Deep Whitening Lotion*Day Cream"],
/*...*/
],
"rowCount": 4395, // Total row count, not data length
}

Notes

  1. Return data is detail data, aggregation not supported;

  2. Interface will paginate, need to maintain pagination state yourself, single page limit not exceeding 20000 records;

  3. When the filter is linked by external other filters, the parameters passed to fetchData will be merged with external filter conditions and take effect together.

  4. filters will be merged with filters in external filter conditions;

  5. treeFilters will override treeFilters in external filter conditions.

Get Optional Values for Specified Field fetchFieldValues

Function Description

Get the value list of specified fields, can be used to implement single field filters inside custom filters.

Usage Instructions

// Parameter example
const params = {
"fieldQuery": {
"name": "Province",
"fdType": "STRING",
"dsId": "k5fa308879b364e5bb628f56",
"fdId": "cbe5217f06de44f0f908232a",
// ...
// Above is a single column field
"limit": 50,
"offset": 0,
"search": "" // Used for text search, same as BI's selection filter
}
}

const response = await this.fetchFieldValues(params)

// Return value example
response = {
"offset": 0,
"limit": 50,
"count": 4340,
"result": [
{"value": "Sichuan Province"},
{"value": "Hubei Province"},
{"value": "Zhejiang Province"},
/*...*/
]
}
Get Optional Values for Multiple Fields fetchTreeValues

Function Description

Get the value list of multiple specified fields, can be used to implement multi-field tree filters inside custom filters.

Usage Instructions

// Parameter example
const params = {
"limit": 50,
"offset": 0,
"fieldQuerySeq": [
{
"name": "Province",
"fdType": "STRING",
"fdId": "cbe5217f06de44f0f908232a",
"dsId": "k5fa308879b364e5bb628f56",
// ...
// Above is a single column field
},
{
"name": "City",
"fdType": "STRING",
"fdId": "k31522e65c9ba4fac9e223d7",
"dsId": "k5fa308879b364e5bb628f56"
// ...
// Above is a single column field
}
],
"filters": [], // Same as fetchData's filters
"search": "", // Used for text search, same as BI's tree filter
}

const response = await this.fetchTreeValues(params)

// Return value example
response = {
"offset": 0,
"limit": 50,
"count": 4340,
"result": [
{
"key": "Shanghai_0",
"children": [
{
"key": "Municipal District_1",
"displayValue": "Municipal District"
}
],
"displayValue": "Shanghai"
},
{
"key": "Sichuan Province_0",
"children": [
{
"key": "Leshan City_1",
"displayValue": "Leshan City"
},
{
"key": "Liangshan Yi Autonomous Prefecture_1",
"displayValue": "Liangshan Yi Autonomous Prefecture"
},
{
"key": "Yibin City_1",
"displayValue": "Yibin City"
},
{
"key": "Ganzi Tibetan Autonomous Prefecture_1",
"displayValue": "Ganzi Tibetan Autonomous Prefecture"
},
// ...
],
"displayValue": "Sichuan Province"
},
// ...
]
}
Initiate Linkage onConfirm

Function Description

Submit filter conditions, type requirement is ICombinationFilter, same as combined filter (details can be seen in the initialFilterValue section);

If the custom filter is rendered in Popover, the submit operation will close the Popover.

Usage Instructions

const filterValue: ICombinationFilter = [/*...*/]

this.onConfirm(filterValue)
// No return value

Notes

  1. If submit button is used, the submit button needs to be drawn by the plugin itself;
  2. External operations such as clicking the mask to close Popover will not trigger onConfirm submission.