/**
* Chart Block Creator for Lark Block Framework
*
* Creates embeddable chart widgets that can be inserted into Lark Docs.
* Uses the Creator() pattern from Lark's Block SDK.
*/
import type {
ChartType,
ChartConfig,
ChartOptions,
DataSource,
CreatorConfig,
BlockSourceMeta,
LifecycleOptions,
BlockMode,
} from './types';
import {
validateChartConfig,
sanitizeChartConfig,
sanitizeFieldValue,
sanitizeText,
} from './validation';
declare const VChart: any;
export interface ChartBlockCreatorOptions {
apiKey?: string;
defaultChartType?: ChartType;
defaultOptions?: ChartOptions;
refreshInterval?: number;
debug?: boolean;
}
interface ChartBlockState {
chartConfig: ChartConfig | null;
chartInstance: any;
refreshTimer: number | null;
isLoading: boolean;
error: string | null;
mode: BlockMode;
}
export function createChartBlockCreator(options: ChartBlockCreatorOptions = {}): CreatorConfig {
const {
apiKey,
defaultOptions = {
showLegend: true,
showDataLabels: false,
animation: true,
colors: ['#3370FF', '#34C759', '#FF9500', '#FF3B30', '#AF52DE', '#5856D6'],
},
refreshInterval = 30000,
debug = false,
} = options;
const log = (message: string, ...args: any[]) => {
if (debug) {
console.log('[ChartBlock] ' + message, ...args);
}
};
return {
data: {
chartConfig: null as ChartConfig | null,
chartData: [] as any[],
isLoading: false,
error: null as string | null,
mode: 'create' as BlockMode,
},
onLoad(this: any, lifecycleOptions: LifecycleOptions) {
log('onLoad called', lifecycleOptions);
const { blockInfo, host } = lifecycleOptions;
const { mode, setting } = blockInfo;
this.setData({ mode, host });
if (mode === 'setting' && setting?.chartConfig) {
log('Restoring chart config from settings', setting.chartConfig);
this.setData({ chartConfig: setting.chartConfig });
this.methods.loadChartData(setting.chartConfig);
}
},
onReady(this: any) {
log('onReady called');
const state = this.data;
if (state.chartConfig) {
this.methods.renderChart(state.chartConfig, state.chartData);
}
},
onDestroy(this: any) {
log('onDestroy called');
const state = this.data as ChartBlockState;
if (state.refreshTimer) {
clearInterval(state.refreshTimer);
}
if (state.chartInstance) {
state.chartInstance.release();
}
},
onShow(this: any) {
log('onShow called');
const state = this.data as ChartBlockState;
if (state.chartConfig && !state.refreshTimer) {
this.methods.startRefresh();
}
},
onHide(this: any) {
log('onHide called');
const state = this.data as ChartBlockState;
if (state.refreshTimer) {
clearInterval(state.refreshTimer);
this.setData({ refreshTimer: null });
}
},
methods: {
createBlock(this: any, chartConfig: ChartConfig) {
log('createBlock called', chartConfig);
// Validate chart configuration
const validation = validateChartConfig(chartConfig);
if (!validation.valid) {
const errorMsg = 'Invalid chart configuration: ' + validation.errors.join(', ');
console.error(errorMsg, validation.errors);
this.setData({ error: errorMsg });
tt.showToast({ title: 'Invalid configuration', icon: 'error' });
return;
}
// Sanitize configuration to prevent XSS
const sanitizedConfig = sanitizeChartConfig(chartConfig);
const sourceMeta: BlockSourceMeta = {
chartConfig: sanitizedConfig,
version: '1.0.0',
createdAt: Date.now(),
};
tt.setBlockInfo({ sourceMeta });
this.setData({ chartConfig: sanitizedConfig });
this.methods.loadChartData(sanitizedConfig);
},
cancelBlock(this: any) {
log('cancelBlock called');
tt.cancel();
},
updateChartConfig(this: any, updates: Partial<ChartConfig>) {
log('updateChartConfig called', updates);
const currentConfig = this.data.chartConfig;
if (!currentConfig) {
console.error('No chart config to update');
tt.showToast({ title: 'No chart to update', icon: 'error' });
return;
}
const newConfig: ChartConfig = {
...currentConfig,
...updates,
options: {
...currentConfig.options,
...updates.options,
},
};
// Validate updated configuration
const validation = validateChartConfig(newConfig);
if (!validation.valid) {
const errorMsg = 'Invalid chart configuration: ' + validation.errors.join(', ');
console.error(errorMsg, validation.errors);
this.setData({ error: errorMsg });
tt.showToast({ title: 'Invalid update', icon: 'error' });
return;
}
// Sanitize updated configuration
const sanitizedConfig = sanitizeChartConfig(newConfig);
const sourceMeta: BlockSourceMeta = {
chartConfig: sanitizedConfig,
version: '1.0.0',
createdAt: Date.now(),
};
tt.setBlockInfo({ sourceMeta });
this.setData({ chartConfig: sanitizedConfig });
this.methods.loadChartData(sanitizedConfig);
},
loadChartData(this: any, chartConfig: ChartConfig) {
log('loadChartData called', chartConfig);
// Validate configuration before making API request
const validation = validateChartConfig(chartConfig);
if (!validation.valid) {
const errorMsg = 'Invalid configuration: ' + validation.errors.join(', ');
console.error(errorMsg);
this.setData({ error: errorMsg });
tt.showToast({ title: 'Invalid configuration', icon: 'error' });
return;
}
this.setData({ isLoading: true, error: null });
tt.showLoading({ title: 'Loading data...' });
const { dataSource } = chartConfig;
const { appToken, tableId, viewId, fields } = dataSource;
let url = '/open-apis/bitable/v1/apps/' + appToken + '/tables/' + tableId + '/records?page_size=500';
if (viewId) {
url += '&view_id=' + viewId;
}
tt.request<{ items: any[]; total: number }>({
url,
method: 'GET',
header: apiKey ? { 'Authorization': 'Bearer ' + apiKey } : {},
success: (res) => {
tt.hideLoading();
log('Data loaded successfully', res.data);
try {
const records = res.data.items || [];
// Limit records to prevent DoS
if (records.length > 5000) {
console.warn('Too many records, limiting to 5000');
records.splice(5000);
}
const chartData = this.methods.transformData(records, fields);
this.setData({ chartData, isLoading: false });
this.methods.renderChart(chartConfig, chartData);
} catch (error: any) {
console.error('Failed to process chart data:', error);
this.setData({ isLoading: false, error: 'Failed to process data' });
tt.showToast({ title: 'Data processing error', icon: 'error' });
}
},
fail: (error) => {
tt.hideLoading();
console.error('Failed to load chart data:', error);
// Provide more specific error messages
const errorMsg = error?.message || error?.errMsg || 'Failed to load data';
this.setData({ isLoading: false, error: sanitizeText(errorMsg) });
tt.showToast({ title: 'Failed to load data', icon: 'error' });
},
});
},
transformData(this: any, records: any[], fields: DataSource['fields']): any[] {
log('transformData called', { recordCount: records.length, fields });
const { xAxis, yAxis, series } = fields;
const yAxisFields = Array.isArray(yAxis) ? yAxis : [yAxis];
return records.map((record) => {
const fieldValues = record.fields || {};
const dataPoint: Record<string, any> = {
x: this.methods.extractFieldValue(fieldValues[xAxis]),
};
yAxisFields.forEach((yField, index) => {
const key = yAxisFields.length > 1 ? 'y' + (index + 1) : 'y';
dataPoint[key] = this.methods.extractFieldValue(fieldValues[yField]) || 0;
});
if (series) {
dataPoint.series = this.methods.extractFieldValue(fieldValues[series]);
}
return dataPoint;
});
},
extractFieldValue(this: any, fieldValue: any): any {
if (fieldValue === null || fieldValue === undefined) {
return null;
}
// Sanitize all field values to prevent XSS
const sanitized = sanitizeFieldValue(fieldValue);
if (Array.isArray(sanitized)) {
if (sanitized.length === 0) return null;
if (typeof sanitized[0] === 'object' && sanitized[0].text) {
return sanitized.map((v: any) => v.text).join(', ');
}
return sanitized[0];
}
if (typeof sanitized === 'object' && sanitized !== null) {
if (sanitized.text) return sanitized.text;
if (sanitized.name) return sanitized.name;
if (sanitized.value !== undefined) return sanitized.value;
return JSON.stringify(sanitized);
}
return sanitized;
},
renderChart(this: any, chartConfig: ChartConfig, chartData: any[]) {
log('renderChart called', { chartType: chartConfig.chartType, dataPoints: chartData.length });
const state = this.data as ChartBlockState;
if (state.chartInstance) {
try {
state.chartInstance.release();
} catch (error) {
console.warn('Failed to release previous chart instance:', error);
}
}
// Validate data before rendering
if (!chartData || chartData.length === 0) {
console.warn('No data to render');
this.setData({ error: 'No data available for chart' });
return;
}
const { chartType, title, options } = chartConfig;
const mergedOptions = { ...defaultOptions, ...options };
const spec = this.methods.buildVChartSpec(chartType, chartData, title, mergedOptions);
const chartContainer = document.getElementById('chart-container');
if (!chartContainer) {
console.error('Chart container not found');
this.setData({ error: 'Chart container not found' });
return;
}
try {
const chartInstance = new VChart(spec, { dom: chartContainer });
chartInstance.renderSync();
this.setData({ chartInstance, error: null });
log('Chart rendered successfully');
} catch (error: any) {
const errorMsg = error?.message || 'Failed to render chart';
console.error('Failed to render chart:', error);
this.setData({ error: errorMsg });
tt.showToast({ title: 'Failed to render chart', icon: 'error' });
}
},
buildVChartSpec(this: any, chartType: ChartType, data: any[], title: string | undefined, options: ChartOptions): any {
const { colors, showLegend, showDataLabels, animation, stacked } = options;
const baseSpec: any = {
type: chartType,
data: [{ values: data }],
title: title ? { visible: true, text: title } : { visible: false },
legends: { visible: showLegend },
label: { visible: showDataLabels },
animationAppear: animation,
color: colors,
};
switch (chartType) {
case 'bar':
return { ...baseSpec, xField: 'x', yField: 'y', seriesField: data[0]?.series !== undefined ? 'series' : undefined, stack: stacked };
case 'line':
return { ...baseSpec, xField: 'x', yField: 'y', seriesField: data[0]?.series !== undefined ? 'series' : undefined };
case 'area':
return { ...baseSpec, xField: 'x', yField: 'y', seriesField: data[0]?.series !== undefined ? 'series' : undefined, stack: stacked };
case 'pie':
return { ...baseSpec, type: 'pie', valueField: 'y', categoryField: 'x', outerRadius: 0.8, innerRadius: 0 };
case 'scatter':
return { ...baseSpec, xField: 'x', yField: 'y', seriesField: data[0]?.series !== undefined ? 'series' : undefined };
case 'funnel':
return { ...baseSpec, type: 'funnel', categoryField: 'x', valueField: 'y' };
case 'radar':
return { ...baseSpec, type: 'radar', categoryField: 'x', valueField: 'y', seriesField: data[0]?.series !== undefined ? 'series' : undefined };
default:
return baseSpec;
}
},
startRefresh(this: any) {
log('startRefresh called');
const state = this.data as ChartBlockState;
if (!state.chartConfig) return;
const timer = setInterval(() => {
this.methods.loadChartData(state.chartConfig);
}, refreshInterval);
this.setData({ refreshTimer: timer });
},
stopRefresh(this: any) {
log('stopRefresh called');
const state = this.data as ChartBlockState;
if (state.refreshTimer) {
clearInterval(state.refreshTimer);
this.setData({ refreshTimer: null });
}
},
exportAsImage(this: any) {
log('exportAsImage called');
const state = this.data as ChartBlockState;
if (!state.chartInstance) {
tt.showToast({ title: 'No chart to export', icon: 'error' });
return null;
}
try {
return state.chartInstance.getDataURL();
} catch (error) {
console.error('Failed to export chart:', error);
tt.showToast({ title: 'Export failed', icon: 'error' });
return null;
}
},
},
};
}
export function createQuickChartConfig(params: {
type: ChartType;
appToken: string;
tableId: string;
xAxisField: string;
yAxisField: string | string[];
title?: string;
seriesField?: string;
viewId?: string;
}): ChartConfig {
return {
chartType: params.type,
title: params.title,
dataSource: {
appToken: params.appToken,
tableId: params.tableId,
viewId: params.viewId,
fields: {
xAxis: params.xAxisField,
yAxis: params.yAxisField,
series: params.seriesField,
},
},
options: {
showLegend: true,
showDataLabels: false,
animation: true,
},
};
}
export default createChartBlockCreator;