diff --git a/apps/nestjs-backend/package.json b/apps/nestjs-backend/package.json index 03898b21a6..15b2e6acda 100644 --- a/apps/nestjs-backend/package.json +++ b/apps/nestjs-backend/package.json @@ -192,6 +192,7 @@ "handlebars": "4.7.8", "helmet": "7.1.0", "http-proxy-middleware": "3.0.3", + "immer": "10.0.4", "ioredis": "5.4.1", "is-port-reachable": "3.1.0", "joi": "17.12.2", diff --git a/apps/nestjs-backend/src/features/dashboard/chart.service.ts b/apps/nestjs-backend/src/features/dashboard/chart.service.ts new file mode 100644 index 0000000000..afd0e1c7db --- /dev/null +++ b/apps/nestjs-backend/src/features/dashboard/chart.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@teable/db-main-prisma'; +import { ChartType, DataSource, THEMES_KEYS, DEFAULT_SERIES_ARRAY } from '@teable/openapi'; + +@Injectable() +export class ChartService { + constructor(private readonly prismaService: PrismaService) {} + + async getDefaultPluginOptions(baseId: string, pluginId: string, tableId?: string) { + if (pluginId !== 'plgchartV2') { + return; + } + + const tables = await this.prismaService.txClient().tableMeta.findMany({ + where: { + baseId, + deletedTime: null, + }, + select: { + id: true, + }, + orderBy: { + order: 'asc', + }, + }); + + if (tables.length === 0) { + return; + } + + const defaultTableId = tableId || tables[0].id; + + const fields = await this.prismaService.txClient().field.findMany({ + where: { + tableId: defaultTableId, + deletedTime: null, + }, + select: { + id: true, + }, + }); + + const viewIds = await this.prismaService.txClient().view.findMany({ + where: { + tableId: defaultTableId, + deletedTime: null, + }, + select: { + id: true, + }, + }); + + const defaultViewId = viewIds[0].id; + + const defaultFieldId = fields[0].id; + + return { + chartType: ChartType.Bar, + dataSource: DataSource.Table, + query: { + tableId: defaultTableId, + viewId: defaultViewId, + orderBy: { on: 'xAxis', order: 'asc' }, + xAxis: defaultFieldId, + filter: null, + groupBy: null, + seriesArray: DEFAULT_SERIES_ARRAY, + }, + appearance: { + theme: THEMES_KEYS.BLUE, + legendVisible: true, + labelVisible: false, + }, + }; + } +} diff --git a/apps/nestjs-backend/src/features/dashboard/dashboard.module.ts b/apps/nestjs-backend/src/features/dashboard/dashboard.module.ts index 0bcdeb8e19..956d929fec 100644 --- a/apps/nestjs-backend/src/features/dashboard/dashboard.module.ts +++ b/apps/nestjs-backend/src/features/dashboard/dashboard.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { BaseModule } from '../base/base.module'; import { CollaboratorModule } from '../collaborator/collaborator.module'; +import { ChartService } from './chart.service'; import { DashboardController } from './dashboard.controller'; import { DashboardService } from './dashboard.service'; @Module({ imports: [CollaboratorModule, BaseModule], - providers: [DashboardService], + providers: [DashboardService, ChartService], controllers: [DashboardController], exports: [DashboardService], }) diff --git a/apps/nestjs-backend/src/features/dashboard/dashboard.service.ts b/apps/nestjs-backend/src/features/dashboard/dashboard.service.ts index f59c9106d7..d4fa97899d 100644 --- a/apps/nestjs-backend/src/features/dashboard/dashboard.service.ts +++ b/apps/nestjs-backend/src/features/dashboard/dashboard.service.ts @@ -27,6 +27,7 @@ import { CustomHttpException } from '../../custom.exception'; import type { IClsStore } from '../../types/cls'; import { BaseImportService } from '../base/base-import.service'; import { CollaboratorService } from '../collaborator/collaborator.service'; +import { ChartService } from './chart.service'; @Injectable() export class DashboardService { @@ -34,7 +35,8 @@ export class DashboardService { private readonly prismaService: PrismaService, private readonly cls: ClsService, private readonly collaboratorService: CollaboratorService, - private readonly baseImportService: BaseImportService + private readonly baseImportService: BaseImportService, + private readonly chartService: ChartService ) {} async getDashboard(baseId: string): Promise { @@ -223,6 +225,9 @@ export class DashboardService { const userId = this.cls.get('user.id'); await this.validatePluginPublished(baseId, ro.pluginId); + // chartV2 need default options + const defaultOptions = await this.chartService.getDefaultPluginOptions(baseId, ro.pluginId); + return this.prismaService.$tx(async () => { const newInstallPlugin = await this.prismaService.txClient().pluginInstall.create({ data: { @@ -233,6 +238,7 @@ export class DashboardService { name: ro.name, pluginId: ro.pluginId, createdBy: userId, + storage: defaultOptions ? JSON.stringify(defaultOptions) : null, }, select: { id: true, diff --git a/apps/nestjs-backend/src/features/plugin-panel/plugin-panel.module.ts b/apps/nestjs-backend/src/features/plugin-panel/plugin-panel.module.ts index 869c99d9a6..68a5de61f9 100644 --- a/apps/nestjs-backend/src/features/plugin-panel/plugin-panel.module.ts +++ b/apps/nestjs-backend/src/features/plugin-panel/plugin-panel.module.ts @@ -1,13 +1,15 @@ import { Module } from '@nestjs/common'; import { BaseModule } from '../base/base.module'; import { CollaboratorModule } from '../collaborator/collaborator.module'; +import { ChartService } from '../dashboard/chart.service'; +import { DashboardModule } from '../dashboard/dashboard.module'; import { PluginPanelController } from './plugin-panel.controller'; import { PluginPanelService } from './plugin-panel.service'; @Module({ - imports: [CollaboratorModule, BaseModule], + imports: [CollaboratorModule, BaseModule, DashboardModule], controllers: [PluginPanelController], exports: [PluginPanelService], - providers: [PluginPanelService], + providers: [PluginPanelService, ChartService], }) export class PluginPanelModule {} diff --git a/apps/nestjs-backend/src/features/plugin-panel/plugin-panel.service.ts b/apps/nestjs-backend/src/features/plugin-panel/plugin-panel.service.ts index 982d0303cb..2937c45924 100644 --- a/apps/nestjs-backend/src/features/plugin-panel/plugin-panel.service.ts +++ b/apps/nestjs-backend/src/features/plugin-panel/plugin-panel.service.ts @@ -28,6 +28,7 @@ import { CustomHttpException } from '../../custom.exception'; import type { IClsStore } from '../../types/cls'; import { BaseImportService } from '../base/base-import.service'; import { CollaboratorService } from '../collaborator/collaborator.service'; +import { ChartService } from '../dashboard/chart.service'; @Injectable() export class PluginPanelService { @@ -35,7 +36,8 @@ export class PluginPanelService { private readonly prismaService: PrismaService, private readonly cls: ClsService, private readonly collaboratorService: CollaboratorService, - private readonly baseImportService: BaseImportService + private readonly baseImportService: BaseImportService, + private readonly chartService: ChartService ) {} createPluginPanel(tableId: string, createPluginPanelRo: IPluginPanelCreateRo) { @@ -166,7 +168,7 @@ export class PluginPanelService { }; } - private async getBaseId(tableId: string) { + async getBaseId(tableId: string) { const base = await this.prismaService.tableMeta.findUnique({ where: { id: tableId, @@ -193,6 +195,11 @@ export class PluginPanelService { const { pluginId, name } = installPluginPanelRo; const currentUser = this.cls.get('user.id'); const baseId = await this.getBaseId(tableId); + const defaultOptions = await this.chartService.getDefaultPluginOptions( + baseId, + pluginId, + tableId + ); return this.prismaService.$tx(async (prisma) => { const plugin = await prisma.plugin.findUnique({ where: { @@ -215,6 +222,7 @@ export class PluginPanelService { position: PluginPosition.Panel, positionId: pluginPanelId, createdBy: currentUser, + storage: defaultOptions ? JSON.stringify(defaultOptions) : null, }, select: { id: true, diff --git a/apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.controller.ts b/apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.controller.ts index ffdd585408..832d495dc3 100644 --- a/apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.controller.ts +++ b/apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.controller.ts @@ -1,11 +1,13 @@ -import { Controller, Get, Param, Query } from '@nestjs/common'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; import { getDashboardInstallPluginQueryRoSchema, getPluginPanelInstallPluginQueryRoSchema, - IGetDashboardInstallPluginQueryRo, IGetPluginPanelInstallPluginQueryRo, - type IBaseQueryVo, + ITestSqlRo, + testSqlRoSchema, + IGetDashboardInstallPluginQueryRo, } from '@teable/openapi'; +import type { IBaseQueryVoV2, IBaseQueryVo, IBaseTableSchemaVo } from '@teable/openapi'; import { ZodValidationPipe } from '../../../../zod.validation.pipe'; import { Permissions } from '../../../auth/decorators/permissions.decorator'; import { ResourceMeta } from '../../../auth/decorators/resource_meta.decorator'; @@ -50,4 +52,46 @@ export class PluginChartController { cellFormat ); } + + @Get(':pluginInstallId/dashboard/:positionId/query/v2') + @Permissions('base|read') + @ResourceMeta('baseId', 'query') + getDashboardPluginQueryV2( + @Param('pluginInstallId') pluginInstallId: string, + @Param('positionId') positionId: string, + @Query('baseId') baseId: string + ): Promise { + return this.pluginChartService.getDashboardPluginQueryV2(baseId, pluginInstallId, positionId); + } + + @Get(':pluginInstallId/plugin-panel/:positionId/query/v2') + @Permissions('table|read') + @ResourceMeta('tableId', 'query') + getPluginPanelPluginQueryV2( + @Param('pluginInstallId') pluginInstallId: string, + @Param('positionId') positionId: string, + @Query('tableId') tableId: string + ): Promise { + return this.pluginChartService.getPluginPanelPluginQueryV2( + tableId, + pluginInstallId, + positionId + ); + } + + @Post('test-sql') + @Permissions('base|read') + @ResourceMeta('baseId', 'body') + testSql( + @Body(new ZodValidationPipe(testSqlRoSchema)) testSqlRo: ITestSqlRo + ): Promise { + return this.pluginChartService.testSql(testSqlRo); + } + + @Get(':baseId/schema') + @Permissions('base|read') + @ResourceMeta('baseId', 'params') + getSchemaByBaseId(@Param('baseId') baseId: string): Promise { + return this.pluginChartService.getSchemaByBaseId(baseId); + } } diff --git a/apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.module.ts b/apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.module.ts index 8038ba7919..81642ee750 100644 --- a/apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.module.ts +++ b/apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.module.ts @@ -1,12 +1,22 @@ import { Module } from '@nestjs/common'; import { BaseModule } from '../../../base/base.module'; +import { BaseSqlExecutorModule } from '../../../base-sql-executor/base-sql-executor.module'; import { DashboardModule } from '../../../dashboard/dashboard.module'; +import { FieldModule } from '../../../field/field.module'; import { PluginPanelModule } from '../../../plugin-panel/plugin-panel.module'; +import { RecordModule } from '../../../record/record.module'; import { PluginChartController } from './plugin-chart.controller'; import { PluginChartService } from './plugin-chart.service'; @Module({ - imports: [PluginPanelModule, DashboardModule, BaseModule], + imports: [ + PluginPanelModule, + DashboardModule, + BaseModule, + RecordModule, + FieldModule, + BaseSqlExecutorModule, + ], providers: [PluginChartService], exports: [PluginChartService], controllers: [PluginChartController], diff --git a/apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.service.ts b/apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.service.ts index aca4c60f1f..c092134113 100644 --- a/apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.service.ts +++ b/apps/nestjs-backend/src/features/plugin/official/chart/plugin-chart.service.ts @@ -1,17 +1,41 @@ -import { Injectable } from '@nestjs/common'; -import { CellFormat, HttpErrorCode } from '@teable/core'; -import type { IBaseQuery } from '@teable/openapi'; +import { Injectable, Logger, NotFoundException } from '@nestjs/common'; +import type { IFilter, ISortItem } from '@teable/core'; +import { HttpErrorCode, CellFormat, mergeWithDefaultFilter, getFieldRollupKey } from '@teable/core'; +import { PrismaService } from '@teable/db-main-prisma'; +import type { + ISqlQuery, + ITableQuery, + IBaseQuery, + IChartStorage, + IBaseQueryVoV2, + ITestSqlRo, + IStatisticFieldItem, +} from '@teable/openapi'; +import { DataSource, AGGREGATE_COUNT_KEY, FieldRollup } from '@teable/openapi'; +import { Knex } from 'knex'; +import { isNumber, keyBy } from 'lodash'; +import { InjectModel } from 'nest-knexjs'; import { CustomHttpException } from '../../../../custom.exception'; import { BaseQueryService } from '../../../base/base-query/base-query.service'; +import { BaseSqlExecutorService } from '../../../base-sql-executor/base-sql-executor.service'; import { DashboardService } from '../../../dashboard/dashboard.service'; import { PluginPanelService } from '../../../plugin-panel/plugin-panel.service'; +import { RecordService } from '../../../record/record.service'; + +const maxResultLimit = 1000; @Injectable() export class PluginChartService { + private readonly logger = new Logger(PluginChartService.name); + constructor( private readonly baseQueryService: BaseQueryService, private readonly dashboardService: DashboardService, - private readonly pluginPanelService: PluginPanelService + private readonly pluginPanelService: PluginPanelService, + private readonly recordService: RecordService, + private readonly prismaService: PrismaService, + private readonly baseSqlExecutorService: BaseSqlExecutorService, + @InjectModel('CUSTOM_KNEX') private readonly knex: Knex ) {} async getDashboardPluginQuery( @@ -65,4 +89,457 @@ export class PluginChartService { } return this.baseQueryService.baseQuery(baseId, query, cellFormat); } + + async getDashboardPluginQueryV2( + baseId: string, + pluginInstallId: string, + positionId: string + ): Promise { + try { + const { storage } = await this.dashboardService.getPluginInstall( + baseId, + positionId, + pluginInstallId + ); + const { dataSource } = storage as unknown as IChartStorage; + + if (dataSource === DataSource.Sql) { + return await this.getDashboardSqlResult( + baseId, + storage as unknown as IChartStorage + ); + } + + return await this.getTableResult(storage as unknown as IChartStorage); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + this.logger.error(`Error getting dashboard plugin query v2: ${error?.message}`, error?.stack); + return { + result: [], + columns: [], + }; + } + } + + async getPluginPanelPluginQueryV2( + tableId: string, + pluginInstallId: string, + positionId: string + ): Promise { + try { + const baseId = await this.pluginPanelService.getBaseId(tableId); + + const { storage } = await this.pluginPanelService.getPluginPanelPlugin( + tableId, + positionId, + pluginInstallId + ); + + const { dataSource } = storage as unknown as IChartStorage; + + if (dataSource === DataSource.Sql) { + return await this.getDashboardSqlResult( + baseId, + storage as unknown as IChartStorage + ); + } + + return await this.getTableResult(storage as unknown as IChartStorage); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (error: any) { + this.logger.error( + `Error getting plugin panel plugin query v2: ${error?.message}`, + error?.stack + ); + return { + result: [], + columns: [], + }; + } + } + + async testSql(testSqlRo: ITestSqlRo) { + const { baseId, sql } = testSqlRo; + + if (!sql) { + return { + result: [], + columns: [], + }; + } + + const result = await this.baseSqlExecutorService.executeQuerySql<{ [key: string]: unknown }[]>( + baseId, + sql + ); + + if (result.length === 0) { + return { + result: [], + columns: [], + }; + } + + // Convert BigInt to Number for JSON serialization + const convertedResult = result.map((row) => { + const converted: { [key: string]: unknown } = {}; + for (const [key, value] of Object.entries(row)) { + converted[key] = typeof value === 'bigint' ? Number(value) : value; + } + return converted; + }); + + const columnKeys = convertedResult[0] ? Object.keys(convertedResult[0]) : []; + + const columns = columnKeys.map((key) => { + return { + name: key, + isNumber: convertedResult?.some((item) => isNumber(item[key])), + }; + }); + + return { + result: convertedResult, + columns: columns, + }; + } + + // get schema by baseId for sql query assist + async getSchemaByBaseId(baseId: string) { + const tableRecords = await this.prismaService.txClient().tableMeta.findMany({ + where: { + baseId, + deletedTime: null, + }, + select: { + dbTableName: true, + }, + }); + + if (!tableRecords.length) { + return {}; + } + + const tableNames = tableRecords.map((t) => t.dbTableName.split('.').pop()); + + const columnSqlQuery = this.knex + .select({ + tableName: 'table_name', + columnName: 'column_name', + }) + .from('information_schema.columns') + .whereIn('table_name', tableNames as string[]) + .where('table_schema', baseId) + .toQuery(); + + const columns = await this.prismaService + .txClient() + .$queryRawUnsafe<{ tableName: string; columnName: string }[]>(columnSqlQuery); + + const schema: Record = {}; + + for (const table of tableRecords) { + const key = `${table.dbTableName}`; + + const tableColumns = columns + .filter((col) => col.tableName === table.dbTableName.split('.').pop()) + .map((col) => col.columnName); + + schema[key] = tableColumns; + } + + return schema; + } + + private async getDashboardSqlResult(baseId: string, storage: { query: ISqlQuery }) { + const sql = storage?.query?.sql; + + if (!sql) { + return { + result: [], + columns: [], + }; + } + + const result = await this.baseSqlExecutorService.executeQuerySql<{ [key: string]: unknown }[]>( + baseId, + sql + ); + + if (result.length === 0) { + return { + result: [], + columns: [], + }; + } + + // Convert BigInt to Number for JSON serialization + const convertedResult = result.map((row) => { + const converted: { [key: string]: unknown } = {}; + for (const [key, value] of Object.entries(row)) { + converted[key] = typeof value === 'bigint' ? Number(value) : value; + } + return converted; + }); + + const columnKeys = convertedResult[0] ? Object.keys(convertedResult[0]) : []; + + const columns = columnKeys.map((key) => { + return { + name: key, + isNumber: convertedResult?.some((item) => isNumber(item[key])), + }; + }); + + return { + result: convertedResult, + columns: columns, + }; + } + + private async getTableResult(storage: IChartStorage) { + const { query } = storage; + const { tableId, groupBy, seriesArray, xAxis, viewId, filter, orderBy } = query as ITableQuery; + + if (Array.isArray(xAxis) && xAxis.length === 0) { + return { + result: [], + columns: [], + }; + } + + const fields = await this.prismaService.txClient().field.findMany({ + where: { + tableId, + deletedTime: null, + }, + select: { + id: true, + dbFieldName: true, + name: true, + }, + }); + + const fieldsMap = keyBy(fields, 'id'); + + const viewQuery = await this.buildViewQuery(viewId, filter); + + const { queryBuilder: mainQueryBuilder } = await this.recordService.buildFilterSortQuery( + tableId, + { + ...viewQuery, + } + ); + + const dbTableName = await this.prismaService + .txClient() + .tableMeta.findUnique({ + where: { id: tableId }, + select: { dbTableName: true }, + }) + .then((meta) => meta?.dbTableName); + + if (!dbTableName) { + throw new NotFoundException('Table not found'); + } + + const queryBuilder = this.knex.from(mainQueryBuilder.as('filtered_records')); + + this.applyGroupByAndSeries( + queryBuilder, + fields, + xAxis ?? undefined, + groupBy ?? undefined, + seriesArray + ); + + this.applyOrderBy( + queryBuilder, + orderBy, + xAxis ?? undefined, + groupBy ?? undefined, + seriesArray, + fields, + fieldsMap + ); + + queryBuilder.limit(maxResultLimit); + + const sql = queryBuilder.toQuery(); + + const result = await this.prismaService + .txClient() + .$queryRawUnsafe<{ [key: string]: number }[]>(sql); + + return this.convertQueryResult(result, fields); + } + + private convertQueryResult( + result: { [key: string]: number }[], + fields: Array<{ id: string; dbFieldName: string; name: string }> + ): { result: { [key: string]: number }[]; columns: Array<{ name: string; isNumber: boolean }> } { + const fieldNameMap = new Map(); + fields.forEach((field) => { + fieldNameMap.set(field.dbFieldName, field.name); + }); + + const convertedResult = result.map((row) => { + const converted: { [key: string]: number } = {}; + for (const [key, value] of Object.entries(row)) { + const name = fieldNameMap.get(key) || key; + converted[name] = typeof value === 'bigint' ? Number(value) : value; + } + return converted; + }); + + const columnKeys = convertedResult[0] ? Object.keys(convertedResult[0]) : []; + const columns = columnKeys.map((key) => { + return { + name: key, + isNumber: convertedResult?.some((item) => isNumber(item[key])), + }; + }); + + return { + result: convertedResult, + columns, + }; + } + + private async buildViewQuery( + viewId: string | undefined, + filter: IFilter | undefined + ): Promise<{ filter?: IFilter | null; sort?: ISortItem[] | null }> { + const viewQuery = { + filter: filter, + } as { filter?: IFilter | null }; + + if (viewId) { + const { filter: viewFilter } = + (await this.prismaService.txClient().view.findFirst({ + where: { + id: viewId, + deletedTime: null, + }, + select: { + filter: true, + sort: true, + }, + })) || {}; + viewQuery.filter = mergeWithDefaultFilter(viewFilter, filter); + } + + return viewQuery; + } + + private applyGroupByAndSeries( + queryBuilder: Knex.QueryBuilder, + fields: Array<{ id: string; dbFieldName: string }>, + xAxis: string | string[] | undefined, + groupBy: string | undefined, + seriesArray: string | Array + ): void { + if (xAxis && typeof xAxis === 'string') { + const dbFieldName = fields.find((field) => field.id === xAxis)?.dbFieldName; + queryBuilder.select({ [xAxis]: dbFieldName }); + queryBuilder.groupBy(xAxis); + } + if (groupBy) { + const dbFieldName = fields.find((field) => field.id === groupBy)?.dbFieldName; + queryBuilder.select({ [groupBy]: dbFieldName }); + queryBuilder.groupBy(groupBy); + } + if (Array.isArray(seriesArray) && seriesArray.length) { + seriesArray.forEach((item) => { + const field = fields.find((field) => field.id === item.fieldId); + const dbFieldName = field?.dbFieldName; + if (dbFieldName && item?.rollup && field?.id) { + const rollupMethod = item.rollup as FieldRollup; + const rollupKey = getFieldRollupKey(field.id, rollupMethod); + switch (rollupMethod) { + case FieldRollup.Sum: + queryBuilder.sum({ [rollupKey]: dbFieldName }); + break; + case FieldRollup.Avg: + queryBuilder.avg({ [rollupKey]: dbFieldName }); + break; + case FieldRollup.Min: + queryBuilder.min({ [rollupKey]: dbFieldName }); + break; + case FieldRollup.Max: + queryBuilder.max({ [rollupKey]: dbFieldName }); + break; + case FieldRollup.Count: + queryBuilder.count({ [rollupKey]: dbFieldName }); + break; + default: + throw new NotFoundException('Unsupported rollup method'); + } + } + }); + } else { + queryBuilder.count({ [AGGREGATE_COUNT_KEY]: '*' }); + } + } + + private getYColumnForOrderBy( + groupBy: string | undefined, + seriesArray: string | Array, + fields: Array<{ id: string; dbFieldName: string }>, + fieldsMap: Record + ): string[] { + if (groupBy) { + const groupByField = fields.find((field) => field.id === groupBy); + if (!groupByField?.dbFieldName) { + throw new NotFoundException('Group by field not found'); + } + return [groupByField.dbFieldName]; + } + if (Array.isArray(seriesArray)) { + const seriesNames = seriesArray + .map((item) => { + const field = fieldsMap[item.fieldId]; + const rollupKey = getFieldRollupKey(field.id, item.rollup); + return field ? rollupKey : null; + }) + .filter((name): name is string => name !== null); + if (seriesNames.length === 0) { + throw new NotFoundException('Series fields not found'); + } + return seriesNames; + } + return [AGGREGATE_COUNT_KEY]; + } + + private applyOrderBy( + queryBuilder: Knex.QueryBuilder, + orderBy: { on: string; order: string } | undefined, + xAxis: string | string[] | undefined, + groupBy: string | undefined, + seriesArray: string | Array, + fields: Array<{ id: string; dbFieldName: string }>, + fieldsMap: Record + ): void { + if (!orderBy) { + return; + } + + const { on, order } = orderBy; + const xAxisField = + xAxis && typeof xAxis === 'string' ? fields.find((field) => field.id === xAxis) : undefined; + const dbFieldName = xAxisField?.dbFieldName; + + if (!dbFieldName) { + throw new NotFoundException('X-axis field not found'); + } + + const yColumn = this.getYColumnForOrderBy(groupBy, seriesArray, fields, fieldsMap); + if (on === 'xAxis') { + queryBuilder.orderBy(dbFieldName, order); + } else { + yColumn.forEach((column) => { + queryBuilder.orderBy(column, order); + }); + } + } } diff --git a/apps/nestjs-backend/src/features/plugin/official/config/chart2.ts b/apps/nestjs-backend/src/features/plugin/official/config/chart2.ts new file mode 100644 index 0000000000..10bee53dea --- /dev/null +++ b/apps/nestjs-backend/src/features/plugin/official/config/chart2.ts @@ -0,0 +1,31 @@ +import { PluginPosition } from '@teable/openapi'; +import type { IOfficialPluginConfig } from './types'; + +export const chart2Config: IOfficialPluginConfig = { + id: 'plgchartV2', + name: 'Chart V2', + description: 'Visualize your records on a bar, line, pie, base on ECharts', + detailDesc: ` + If you're looking for a colorful way to get a big-picture overview of a table, try a chart app. + + + + The chart app summarizes a table of records and turns it into an interactive bar, line, pie. + + + [Learn more](https://teable.ai) + + `, + helpUrl: 'https://help.teable.ai/en/basic/plugin/chart', + positions: [PluginPosition.Dashboard, PluginPosition.Panel], + i18n: { + zh: { + name: '图表 V2', + helpUrl: 'https://help.teable.cn/zh/basic/plugin/chart', + description: '通过柱状图、折线图、饼图可视化您的记录, 基于 ECharts 实现', + detailDesc: + '如果您想通过色彩丰富的方式从大局上了解表格,试试图表应用。\n\n图表应用汇总表格记录,并将其转换为交互式的柱状图、折线图、饼图。\n\n[了解更多](https://teable.cn)', + }, + }, + logoPath: 'static/plugin/chartV2.png', +}; diff --git a/apps/nestjs-backend/src/features/plugin/official/official-plugin-init.service.ts b/apps/nestjs-backend/src/features/plugin/official/official-plugin-init.service.ts index eccfcffb9e..471730dd24 100644 --- a/apps/nestjs-backend/src/features/plugin/official/official-plugin-init.service.ts +++ b/apps/nestjs-backend/src/features/plugin/official/official-plugin-init.service.ts @@ -14,6 +14,7 @@ import { InjectStorageAdapter } from '../../attachments/plugins/storage'; import { UserService } from '../../user/user.service'; import { generateSecret } from '../utils'; import { chartConfig } from './config/chart'; +import { chart2Config } from './config/chart2'; import { sheetFormConfig } from './config/sheet-form-view'; import type { IOfficialPluginConfig } from './config/types'; @@ -38,6 +39,11 @@ export class OfficialPluginInitService implements OnModuleInit { secret: this.configService.get('PLUGIN_CHART_SECRET') || this.baseConfig.secretKey, url: `/plugin/chart`, }, + { + ...chart2Config, + secret: this.configService.get('PLUGIN_CHART2_SECRET') || this.baseConfig.secretKey, + url: `/plugin/chart2`, + }, { ...sheetFormConfig, secret: diff --git a/apps/nestjs-backend/src/features/record/record.service.ts b/apps/nestjs-backend/src/features/record/record.service.ts index 61f65cd9d0..8bea16acfe 100644 --- a/apps/nestjs-backend/src/features/record/record.service.ts +++ b/apps/nestjs-backend/src/features/record/record.service.ts @@ -1586,7 +1586,6 @@ export class RecordService { useQueryModel = false ): Promise<{ ids: string[]; extra?: IExtraResult }> { const { skip, take = 100, ignoreViewQuery } = query; - if (identify(tableId) !== IdPrefix.Table) { throw new InternalServerErrorException('query collection must be table id'); } diff --git a/apps/nestjs-backend/src/types/i18n.generated.ts b/apps/nestjs-backend/src/types/i18n.generated.ts index f36428ff75..9ab182905b 100644 --- a/apps/nestjs-backend/src/types/i18n.generated.ts +++ b/apps/nestjs-backend/src/types/i18n.generated.ts @@ -104,6 +104,99 @@ export type I18nTranslations = { "area": string; "table": string; }; + "chartV2": { + "dataConfig": string; + "chartAppearance": string; + "goConfig": string; + "appearance": { + "title": string; + "display": string; + "theme": string; + "legend": string; + "coordinateAxis": string; + "label": string; + "showAxisLine": string; + "showAxisTick": string; + "showSplitLine": string; + "backgroundColor": string; + "reset": string; + "style": string; + "padding": string; + "left": string; + "right": string; + "bottom": string; + "top": string; + }; + "noData": string; + "form": { + "name": string; + "chartType": { + "title": string; + "bar": string; + "line": string; + "pie": string; + "donutChart": string; + "area": string; + "table": string; + }; + "dataSource": { + "title": string; + "fromTable": string; + "fromQuery": string; + }; + "label": { + "table": string; + "dataRange": string; + "view": string; + "filter": string; + }; + "axisConfig": { + "noCountFields": string; + "defaultSeriesName": string; + "title": string; + "xAxis": string; + "yAxis": string; + "field": string; + "Statistic": string; + "totalRecords": string; + "fieldValue": string; + "groupBy": string; + "none": string; + "groupAggregation": string; + }; + "view": { + "allData": string; + }; + "dataSourceTitle": string; + "order": { + "orderBy": { + "title": string; + "byXAxis": string; + "byYAxis": string; + }; + "orderType": { + "title": string; + "asc": string; + "desc": string; + }; + }; + "filter": { + "title": string; + "addFilter": string; + "cancel": string; + "confirm": string; + }; + "sql": { + "title": string; + "sqlEditor": string; + "runTest": string; + "saveSql": string; + "aiGenerate": string; + "resultPreview": string; + "addSeries": string; + }; + }; + }; "form": { "chartType": { "placeholder": string; @@ -820,6 +913,7 @@ export type I18nTranslations = { "pluginEmpty": { "title": string; }; + "addPluginTitle": string; }; "automation": { "turnOnTip": string; diff --git a/apps/nestjs-backend/static/plugin/chartV2.png b/apps/nestjs-backend/static/plugin/chartV2.png new file mode 100644 index 0000000000..bd40ab1ad6 Binary files /dev/null and b/apps/nestjs-backend/static/plugin/chartV2.png differ diff --git a/apps/nestjs-backend/test/dashboard.e2e-spec.ts b/apps/nestjs-backend/test/dashboard.e2e-spec.ts index 5f70752a3d..089bc87539 100644 --- a/apps/nestjs-backend/test/dashboard.e2e-spec.ts +++ b/apps/nestjs-backend/test/dashboard.e2e-spec.ts @@ -1,5 +1,6 @@ /* eslint-disable sonarjs/no-duplicate-string */ import type { INestApplication } from '@nestjs/common'; +import { FieldKeyType, isGreater, ViewType } from '@teable/core'; import { PrismaService } from '@teable/db-main-prisma'; import type { ITableFullVo } from '@teable/openapi'; import { @@ -15,6 +16,7 @@ import { duplicateDashboardInstalledPlugin, getDashboard, getDashboardInstallPlugin, + getTableList, getDashboardVoSchema, installPlugin, PluginPosition, @@ -26,6 +28,20 @@ import { submitPlugin, updateDashboardPluginStorage, updateLayoutDashboard, + getDashboardInstallPluginQueryV2, + ChartType, + DataSource, + FieldRollup, + AGGREGATE_COUNT_KEY, + DEFAULT_SERIES_ARRAY, + THEMES_KEYS, + getViewList, + getFields, + getRecords, + updateRecords, + createRecords, + createView, + getDashboardTestSqlResult, } from '@teable/openapi'; import { getError } from './utils/get-error'; import { initApp } from './utils/init-app'; @@ -325,4 +341,840 @@ describe('DashboardController', () => { expect(dashboard?.data?.pluginMap?.[pluginId]).toBeUndefined(); }); }); + + describe('chart2 (plgchartV2)', () => { + let pluginInstallId: string; + + beforeEach(async () => { + const installRes = await installPlugin(baseId, dashboardId, { + name: 'chart2-plugin', + pluginId: 'plgchartV2', + }); + pluginInstallId = installRes.data.pluginInstallId; + }); + + it('/api/plugin/chart/:pluginInstallId/dashboard/:positionId/query/v2 (GET) - create a chart v2 with default configuration', async () => { + const queryRes = ( + await getDashboardInstallPluginQueryV2(pluginInstallId, dashboardId, baseId) + ).data; + const tableList = (await getTableList(baseId)).data; + const dashboardConfig = ( + await getDashboardInstallPlugin(baseId, dashboardId, pluginInstallId) + ).data; + + const defaultTableId = tableList.at(0)?.id; + + const views = await getViewList(defaultTableId!); + const defaultViewId = views.data.at(0)?.id; + + const fields = await getFields(defaultTableId!); + const defaultFieldId = fields.data.at(0)?.id; + + expect(dashboardConfig.storage).toEqual({ + chartType: ChartType.Bar, + dataSource: DataSource.Table, + query: { + tableId: defaultTableId, + viewId: defaultViewId, + filter: null, + groupBy: null, + orderBy: { on: 'xAxis', order: 'asc' }, + xAxis: defaultFieldId, + seriesArray: DEFAULT_SERIES_ARRAY, + }, + appearance: { + theme: THEMES_KEYS.BLUE, + legendVisible: true, + labelVisible: false, + }, + }); + expect(queryRes).toEqual({ + result: [{ [`${defaultFieldId}`]: null, [`${AGGREGATE_COUNT_KEY}`]: 3 }], + columns: [ + { name: defaultFieldId, isNumber: false }, + { name: `${AGGREGATE_COUNT_KEY}`, isNumber: true }, + ], + }); + }); + + it('/api/plugin/chart/:pluginInstallId/dashboard/:positionId/query/v2 (GET) - query data with a common configuration', async () => { + const records = (await getRecords(table.id!, {})).data?.records; + + const textField = table.fields.find((field) => field.name === 'Name')!; + const numberField = table.fields.find((field) => field.name === 'Count')!; + const statusField = table.fields.find((field) => field.name === 'Status')!; + + // create example records + await updateRecords(table.id!, { + typecast: true, + fieldKeyType: FieldKeyType.Id, + records: records.map((record, index) => ({ + id: record.id, + fields: { + [textField.id]: `task${index + 1}`, + [numberField.id]: index, + [statusField.id]: 'To Do', + }, + })), + }); + + await createRecords(table.id!, { + typecast: true, + fieldKeyType: FieldKeyType.Id, + records: [ + { + fields: { + [textField.id]: `task4`, + [numberField.id]: 4, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task5`, + [numberField.id]: 5, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task6`, + [numberField.id]: 6, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task7`, + [numberField.id]: 7, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task8`, + [numberField.id]: 8, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task9`, + [numberField.id]: 9, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task10`, + [numberField.id]: 10, + [statusField.id]: 'Done', + }, + }, + ], + }); + + // update the storage configuration + await updateDashboardPluginStorage(baseId, dashboardId, pluginInstallId, { + chartType: ChartType.Bar, + dataSource: DataSource.Table, + query: { + tableId: table.id, + viewId: table.views[0].id, + xAxis: textField.id, + seriesArray: DEFAULT_SERIES_ARRAY, + groupBy: null, + orderBy: { on: 'xAxis', order: 'asc' }, + }, + appearance: { + theme: THEMES_KEYS.BLUE, + legendVisible: true, + labelVisible: false, + }, + }); + + const queryRes = ( + await getDashboardInstallPluginQueryV2(pluginInstallId, dashboardId, baseId) + ).data; + + expect(queryRes.result).toEqual( + expect.arrayContaining( + Array.from({ length: 10 }, (_, index) => ({ + [`${textField.id}`]: `task${index + 1}`, + [`${AGGREGATE_COUNT_KEY}`]: 1, + })) + ) + ); + expect(queryRes.columns).toEqual( + expect.arrayContaining([ + { name: textField.id, isNumber: false }, + { name: `${AGGREGATE_COUNT_KEY}`, isNumber: true }, + ]) + ); + }); + + it('/api/plugin/chart/:pluginInstallId/dashboard/:positionId/query/v2 (GET) - query data with a filter configuration', async () => { + const records = (await getRecords(table.id!, {})).data?.records; + + const textField = table.fields.find((field) => field.name === 'Name')!; + const numberField = table.fields.find((field) => field.name === 'Count')!; + const statusField = table.fields.find((field) => field.name === 'Status')!; + + // create example records + await updateRecords(table.id!, { + typecast: true, + fieldKeyType: FieldKeyType.Id, + records: records.map((record, index) => ({ + id: record.id, + fields: { + [textField.id]: `task${index + 1}`, + [numberField.id]: index, + [statusField.id]: 'To Do', + }, + })), + }); + + await createRecords(table.id!, { + typecast: true, + fieldKeyType: FieldKeyType.Id, + records: [ + { + fields: { + [textField.id]: `task4`, + [numberField.id]: 4, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task5`, + [numberField.id]: 5, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task6`, + [numberField.id]: 6, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task7`, + [numberField.id]: 7, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task8`, + [numberField.id]: 8, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task9`, + [numberField.id]: 9, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task10`, + [numberField.id]: 10, + [statusField.id]: 'Done', + }, + }, + ], + }); + + // update the storage configuration + await updateDashboardPluginStorage(baseId, dashboardId, pluginInstallId, { + chartType: ChartType.Bar, + dataSource: DataSource.Table, + query: { + tableId: table.id, + viewId: null, + xAxis: statusField.id, + seriesArray: DEFAULT_SERIES_ARRAY, + groupBy: null, + orderBy: { on: 'xAxis', order: 'asc' }, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: numberField.id, + operator: isGreater.value, + value: 5, + }, + ], + }, + }, + appearance: { + theme: THEMES_KEYS.BLUE, + legendVisible: true, + labelVisible: false, + }, + }); + + const queryRes = ( + await getDashboardInstallPluginQueryV2(pluginInstallId, dashboardId, baseId) + ).data; + + expect(queryRes.result).toEqual( + expect.arrayContaining([ + { + [statusField.id]: 'Done', + [`${AGGREGATE_COUNT_KEY}`]: 4, + }, + { + [statusField.id]: 'In Progress', + [`${AGGREGATE_COUNT_KEY}`]: 1, + }, + ]) + ); + expect(queryRes.columns).toEqual( + expect.arrayContaining([ + { name: statusField.id, isNumber: false }, + { name: `${AGGREGATE_COUNT_KEY}`, isNumber: true }, + ]) + ); + }); + + it('/api/plugin/chart/:pluginInstallId/dashboard/:positionId/query/v2 (GET) - query data with a filter configuration base on filter view', async () => { + const records = (await getRecords(table.id!, {})).data?.records; + + const textField = table.fields.find((field) => field.name === 'Name')!; + const numberField = table.fields.find((field) => field.name === 'Count')!; + const statusField = table.fields.find((field) => field.name === 'Status')!; + + const view = ( + await createView(table.id!, { + name: 'Filter View', + type: ViewType.Grid, + filter: { + conjunction: 'and', + filterSet: [{ fieldId: numberField.id, operator: isGreater.value, value: 7 }], + }, + }) + ).data; + + // create example records + await updateRecords(table.id!, { + typecast: true, + fieldKeyType: FieldKeyType.Id, + records: records.map((record, index) => ({ + id: record.id, + fields: { + [textField.id]: `task${index + 1}`, + [numberField.id]: index, + [statusField.id]: 'To Do', + }, + })), + }); + + await createRecords(table.id!, { + typecast: true, + fieldKeyType: FieldKeyType.Id, + records: [ + { + fields: { + [textField.id]: `task4`, + [numberField.id]: 4, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task5`, + [numberField.id]: 5, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task6`, + [numberField.id]: 6, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task7`, + [numberField.id]: 7, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task8`, + [numberField.id]: 8, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task9`, + [numberField.id]: 9, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task10`, + [numberField.id]: 10, + [statusField.id]: 'Done', + }, + }, + ], + }); + + // update the storage configuration + await updateDashboardPluginStorage(baseId, dashboardId, pluginInstallId, { + chartType: ChartType.Bar, + dataSource: DataSource.Table, + query: { + tableId: table.id, + viewId: view.id, + xAxis: statusField.id, + seriesArray: DEFAULT_SERIES_ARRAY, + groupBy: null, + orderBy: { on: 'xAxis', order: 'asc' }, + filter: { + conjunction: 'and', + filterSet: [ + { + fieldId: numberField.id, + operator: isGreater.value, + value: 5, + }, + ], + }, + }, + appearance: { + theme: THEMES_KEYS.BLUE, + legendVisible: true, + labelVisible: false, + }, + }); + + const queryRes = ( + await getDashboardInstallPluginQueryV2(pluginInstallId, dashboardId, baseId) + ).data; + + expect(queryRes.result).toEqual( + expect.arrayContaining([ + { + [statusField.id]: 'Done', + [`${AGGREGATE_COUNT_KEY}`]: 3, + }, + ]) + ); + expect(queryRes.columns).toEqual( + expect.arrayContaining([ + { name: statusField.id, isNumber: false }, + { name: `${AGGREGATE_COUNT_KEY}`, isNumber: true }, + ]) + ); + }); + + it('/api/plugin/chart/:pluginInstallId/dashboard/:positionId/query/v2 (GET) - query data with group configuration', async () => { + const records = (await getRecords(table.id!, {})).data?.records; + + const textField = table.fields.find((field) => field.name === 'Name')!; + const numberField = table.fields.find((field) => field.name === 'Count')!; + const statusField = table.fields.find((field) => field.name === 'Status')!; + + // create example records + await updateRecords(table.id!, { + typecast: true, + fieldKeyType: FieldKeyType.Id, + records: records.map((record, index) => ({ + id: record.id, + fields: { + [textField.id]: `task${index + 1}`, + [numberField.id]: index, + [statusField.id]: 'To Do', + }, + })), + }); + + await createRecords(table.id!, { + typecast: true, + fieldKeyType: FieldKeyType.Id, + records: [ + { + fields: { + [textField.id]: `task4`, + [numberField.id]: 4, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task5`, + [numberField.id]: 5, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task6`, + [numberField.id]: 6, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task7`, + [numberField.id]: 7, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task8`, + [numberField.id]: 8, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task9`, + [numberField.id]: 9, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task1`, + [numberField.id]: 10, + [statusField.id]: 'To Do', + }, + }, + ], + }); + + // update the storage configuration + await updateDashboardPluginStorage(baseId, dashboardId, pluginInstallId, { + chartType: ChartType.Bar, + dataSource: DataSource.Table, + query: { + tableId: table.id, + viewId: null, + xAxis: textField.id, + seriesArray: DEFAULT_SERIES_ARRAY, + groupBy: statusField.id, + orderBy: { on: 'xAxis', order: 'asc' }, + }, + appearance: { + theme: THEMES_KEYS.BLUE, + legendVisible: true, + labelVisible: false, + }, + }); + + const queryRes = ( + await getDashboardInstallPluginQueryV2(pluginInstallId, dashboardId, baseId) + ).data; + + expect(queryRes.result).toEqual( + expect.arrayContaining([ + { + [textField.id]: 'task1', + [statusField.id]: 'To Do', + [`${AGGREGATE_COUNT_KEY}`]: 2, + }, + { + [textField.id]: 'task2', + [statusField.id]: 'To Do', + [`${AGGREGATE_COUNT_KEY}`]: 1, + }, + { + [textField.id]: 'task3', + [statusField.id]: 'To Do', + [`${AGGREGATE_COUNT_KEY}`]: 1, + }, + { + [textField.id]: 'task4', + [statusField.id]: 'In Progress', + [`${AGGREGATE_COUNT_KEY}`]: 1, + }, + { + [textField.id]: 'task5', + [statusField.id]: 'In Progress', + [`${AGGREGATE_COUNT_KEY}`]: 1, + }, + { + [textField.id]: 'task6', + [statusField.id]: 'In Progress', + [`${AGGREGATE_COUNT_KEY}`]: 1, + }, + { + [textField.id]: 'task7', + [statusField.id]: 'Done', + [`${AGGREGATE_COUNT_KEY}`]: 1, + }, + { + [textField.id]: 'task8', + [statusField.id]: 'Done', + [`${AGGREGATE_COUNT_KEY}`]: 1, + }, + { + [textField.id]: 'task9', + [statusField.id]: 'Done', + [`${AGGREGATE_COUNT_KEY}`]: 1, + }, + ]) + ); + expect(queryRes.columns).toEqual( + expect.arrayContaining([ + { name: textField.id, isNumber: false }, + { name: statusField.id, isNumber: false }, + { name: `${AGGREGATE_COUNT_KEY}`, isNumber: true }, + ]) + ); + }); + + it('/api/plugin/chart/:pluginInstallId/dashboard/:positionId/query/v2 (GET) - query data with a value series type configuration', async () => { + const records = (await getRecords(table.id!, {})).data?.records; + + const textField = table.fields.find((field) => field.name === 'Name')!; + const numberField = table.fields.find((field) => field.name === 'Count')!; + const statusField = table.fields.find((field) => field.name === 'Status')!; + + // create example records + await updateRecords(table.id!, { + typecast: true, + fieldKeyType: FieldKeyType.Id, + records: records.map((record, index) => ({ + id: record.id, + fields: { + [textField.id]: `task${index + 1}`, + [numberField.id]: index + 1, + [statusField.id]: 'To Do', + }, + })), + }); + + await createRecords(table.id!, { + typecast: true, + fieldKeyType: FieldKeyType.Id, + records: [ + { + fields: { + [textField.id]: `task4`, + [numberField.id]: 4, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task5`, + [numberField.id]: 5, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task6`, + [numberField.id]: 6, + [statusField.id]: 'In Progress', + }, + }, + { + fields: { + [textField.id]: `task7`, + [numberField.id]: 7, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task8`, + [numberField.id]: 8, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task9`, + [numberField.id]: 9, + [statusField.id]: 'Done', + }, + }, + { + fields: { + [textField.id]: `task10`, + [numberField.id]: 10, + [statusField.id]: 'Done', + }, + }, + ], + }); + + // update the storage configuration + await updateDashboardPluginStorage(baseId, dashboardId, pluginInstallId, { + chartType: ChartType.Bar, + dataSource: DataSource.Table, + query: { + tableId: table.id, + viewId: null, + xAxis: textField.id, + seriesArray: [ + { + fieldId: numberField.id, + rollup: FieldRollup.Sum, + }, + ], + groupBy: null, + orderBy: { on: 'xAxis', order: 'asc' }, + }, + appearance: { + theme: THEMES_KEYS.BLUE, + legendVisible: true, + labelVisible: false, + }, + }); + + const queryRes = ( + await getDashboardInstallPluginQueryV2(pluginInstallId, dashboardId, baseId) + ).data; + + expect(queryRes.result).toEqual( + expect.arrayContaining( + Array.from({ length: 10 }, (_, index) => ({ + [`${textField.id}`]: `task${index + 1}`, + [`${numberField.id}_${FieldRollup.Sum}`]: index + 1, + })) + ) + ); + expect(queryRes.columns).toEqual( + expect.arrayContaining([ + { name: textField.id, isNumber: false }, + { name: `${numberField.id}_${FieldRollup.Sum}`, isNumber: true }, + ]) + ); + }); + + it('/api/plugin/chart/:pluginInstallId/dashboard/:positionId/query/v2 (GET) - sql data source', async () => { + const dbTableName = table.dbTableName; + const [schema, tableName] = dbTableName.split('.'); + const sql = `SELECT * FROM "${schema}"."${tableName}"`; + const textField = table.fields.find((field) => field.name === 'Name')!; + const numberField = table.fields.find((field) => field.name === 'Count')!; + const statusField = table.fields.find((field) => field.name === 'Status')!; + await updateDashboardPluginStorage(baseId, dashboardId, pluginInstallId, { + chartType: ChartType.Bar, + dataSource: DataSource.Sql, + query: { + sql, + }, + appearance: { + theme: THEMES_KEYS.BLUE, + legendVisible: true, + labelVisible: false, + }, + }); + + await createRecords(table.id!, { + typecast: true, + fieldKeyType: FieldKeyType.Id, + records: [ + { fields: { [textField.id]: 'task1', [numberField.id]: 1, [statusField.id]: 'To Do' } }, + ], + }); + + const queryRes = ( + await getDashboardInstallPluginQueryV2(pluginInstallId, dashboardId, baseId) + ).data; + + const filterRes = queryRes.result.map((item) => { + return { + [textField.dbFieldName]: item[textField.dbFieldName], + [numberField.dbFieldName]: item[numberField.dbFieldName], + [statusField.dbFieldName]: item[statusField.dbFieldName], + }; + }); + + expect(filterRes).toEqual( + expect.arrayContaining([ + { + [textField.dbFieldName]: null, + [numberField.dbFieldName]: null, + [statusField.dbFieldName]: null, + }, + { + [textField.dbFieldName]: null, + [numberField.dbFieldName]: null, + [statusField.dbFieldName]: null, + }, + { + [textField.dbFieldName]: null, + [numberField.dbFieldName]: null, + [statusField.dbFieldName]: null, + }, + { + [textField.dbFieldName]: 'task1', + [numberField.dbFieldName]: 1, + [statusField.dbFieldName]: 'To Do', + }, + ]) + ); + }); + + it('/api/plugin/chart/:pluginInstallId/dashboard/:positionId/query/v2 (GET) - test sql', async () => { + const dbTableName = table.dbTableName; + const [schema, tableName] = dbTableName.split('.'); + const sql = `SELECT * FROM "${schema}"."${tableName}"`; + const textField = table.fields.find((field) => field.name === 'Name')!; + const numberField = table.fields.find((field) => field.name === 'Count')!; + const statusField = table.fields.find((field) => field.name === 'Status')!; + + await createRecords(table.id!, { + typecast: true, + fieldKeyType: FieldKeyType.Id, + records: [ + { fields: { [textField.id]: 'task1', [numberField.id]: 1, [statusField.id]: 'To Do' } }, + ], + }); + + const testSqlRes = (await getDashboardTestSqlResult(baseId, sql)).data; + + const filterRes = testSqlRes.result.map((item) => { + return { + [textField.dbFieldName]: item[textField.dbFieldName], + [numberField.dbFieldName]: item[numberField.dbFieldName], + [statusField.dbFieldName]: item[statusField.dbFieldName], + }; + }); + + expect(filterRes).toEqual( + expect.arrayContaining([ + { + [textField.dbFieldName]: null, + [numberField.dbFieldName]: null, + [statusField.dbFieldName]: null, + }, + { + [textField.dbFieldName]: null, + [numberField.dbFieldName]: null, + [statusField.dbFieldName]: null, + }, + { + [textField.dbFieldName]: null, + [numberField.dbFieldName]: null, + [statusField.dbFieldName]: null, + }, + { + [textField.dbFieldName]: 'task1', + [numberField.dbFieldName]: 1, + [statusField.dbFieldName]: 'To Do', + }, + ]) + ); + }); + }); }); diff --git a/apps/nextjs-app/package.json b/apps/nextjs-app/package.json index f21af9b831..60e3d15bbb 100644 --- a/apps/nextjs-app/package.json +++ b/apps/nextjs-app/package.json @@ -96,9 +96,12 @@ "@belgattitude/http-exception": "1.5.0", "@codemirror/autocomplete": "6.15.0", "@codemirror/commands": "6.3.3", + "@codemirror/lang-javascript": "6.2.4", "@codemirror/lang-json": "6.0.1", + "@codemirror/lang-sql": "6.10.0", "@codemirror/language": "6.10.1", "@codemirror/lint": "6.8.2", + "@codemirror/search": "6.5.11", "@codemirror/state": "6.4.1", "@codemirror/view": "6.26.0", "@dnd-kit/core": "6.1.0", @@ -129,6 +132,7 @@ "@teable/openapi": "workspace:^", "@teable/sdk": "workspace:^", "@teable/ui-lib": "workspace:^", + "@uiw/codemirror-theme-vscode": "4.25.3", "allotment": "1.20.0", "axios": "1.7.7", "buffer": "6.0.3", @@ -143,6 +147,7 @@ "filesize": "10.1.1", "fuse.js": "7.0.0", "i18next": "23.10.1", + "immer": "10.0.4", "is-port-reachable": "3.1.0", "jschardet": "3.1.3", "knex": "3.1.0", @@ -175,8 +180,8 @@ "react-syntax-highlighter": "15.5.0", "react-textarea-autosize": "8.5.3", "react-use": "17.5.1", - "reactflow": "11.11.1", "react-virtuoso": "4.7.10", + "reactflow": "11.11.1", "recharts": "2.12.3", "reconnecting-websocket": "4.4.0", "reflect-metadata": "0.2.1", diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/Chart.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/Chart.tsx new file mode 100644 index 0000000000..b1f54d3a9a --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/Chart.tsx @@ -0,0 +1,76 @@ +import { AlertTriangle } from '@teable/icons'; +import { Button, cn, Spin } from '@teable/ui-lib'; +import type { EChartsOption } from 'echarts'; +import dynamic from 'next/dynamic'; +import { useTranslation } from 'next-i18next'; +import React from 'react'; +import { useBridge, useUiConfig } from '../hooks'; + +interface BestPracticeChartProps { + option: EChartsOption; + className?: string; + style?: React.CSSProperties; + theme?: string | object; + renderer?: 'canvas' | 'svg'; + dragging?: boolean; + onChartReady?: (chart: echarts.ECharts) => void; + onError?: (error: Error) => void; +} + +const DynamicEChartsWrapper = dynamic( + () => import('./SimpleEChartsWrapper').then((mod) => ({ default: mod.SimpleEChartsWrapper })), + { + ssr: true, + loading: ({ error }) => { + if (error) { + return ( +
+
{error?.message}
+
+ ); + } + + return ( +
+ +
+ ); + }, + } +); + +export const Chart: React.FC = (props) => { + const { className, style, option } = props; + const { t } = useTranslation('chart'); + const parentBridgeMethods = useBridge(); + const uiConfig = useUiConfig(); + const { isShowingSettings } = uiConfig || {}; + const defaultStyle: React.CSSProperties = { + width: '100%', + height: '400px', + ...style, + }; + + return option ? ( +
+ +
+ ) : ( +
+
+ + {t('chartV2.noData')} +
+ {!isShowingSettings && ( + + )} +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/SimpleEChartsWrapper.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/SimpleEChartsWrapper.tsx new file mode 100644 index 0000000000..6ecb68c819 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/SimpleEChartsWrapper.tsx @@ -0,0 +1,226 @@ +import { cn } from '@teable/ui-lib'; +import type { EChartsOption } from 'echarts'; +import { useEffect, useRef, useState } from 'react'; +import { useSimpleECharts } from './hooks/useSimpleECharts'; + +interface SimpleEChartsWrapperProps { + option: EChartsOption; + className?: string; + style?: React.CSSProperties; + theme?: string | object; + renderer?: 'canvas' | 'svg'; + dragging?: boolean; + onChartReady?: (chart: echarts.ECharts) => void; + onError?: (error: Error) => void; +} + +const mapArrayToZero = (items: unknown[]): unknown[] => + items.map((child) => mapDataItemToZero(child)); + +const mapObjectValueToZero = (record: Record): unknown => { + if (!('value' in record)) { + return record; + } + const value = record.value; + if (typeof value === 'number') { + return { ...record, value: 0 }; + } + if (Array.isArray(value)) { + return { ...record, value: mapArrayToZero(value) }; + } + if (value && typeof value === 'object') { + return { ...record, value: mapDataItemToZero(value) }; + } + return record; +}; + +const mapDataItemToZero = (item: unknown): unknown => { + if (typeof item === 'number') { + return 0; + } + if (Array.isArray(item)) { + return mapArrayToZero(item); + } + if (item && typeof item === 'object') { + return mapObjectValueToZero(item as Record); + } + return item; +}; + +export const SimpleEChartsWrapper: React.FC = ({ + option, + className, + style, + theme, + renderer = 'canvas', + dragging = false, + onChartReady, + onError, +}) => { + const { chartRef, chartInstance, initChart, setOption, isReady, isMounted } = useSimpleECharts({ + theme, + renderer, + dragging, + }); + const [isVisible, setIsVisible] = useState(false); + const raf1Ref = useRef(null); + const raf2Ref = useRef(null); + const didInitialAnimateRef = useRef(false); + const onChartReadyRef = useRef(onChartReady); + const onErrorRef = useRef(onError); + const hasInitializedRef = useRef(false); + + // Keep callback refs up to date + useEffect(() => { + onChartReadyRef.current = onChartReady; + onErrorRef.current = onError; + }, [onChartReady, onError]); + + useEffect(() => { + if (!isMounted) return; + // Only initialize once, don't re-run when initChart reference changes + if (hasInitializedRef.current) return; + + const timer = setTimeout(() => { + try { + const chart = initChart(); + if (chart && onChartReadyRef.current) { + onChartReadyRef.current(chart); + } + hasInitializedRef.current = true; + raf1Ref.current = requestAnimationFrame(() => { + raf2Ref.current = requestAnimationFrame(() => { + setIsVisible(true); + }); + }); + } catch (error) { + console.error('Chart initialization error:', error); + if (onErrorRef.current) { + onErrorRef.current(error as Error); + } + } + }, 0); + + return () => { + clearTimeout(timer); + if (raf1Ref.current) cancelAnimationFrame(raf1Ref.current); + if (raf2Ref.current) cancelAnimationFrame(raf2Ref.current); + }; + }, [isMounted, initChart]); + + useEffect(() => { + if (dragging || !chartInstance || !option || !isReady) return; + + let animationTimer: ReturnType | null = null; + + try { + const isPieChart = (() => { + const rawSeries = (option as { series?: unknown }).series; + if (!rawSeries) return false; + const seriesArray = Array.isArray(rawSeries) ? rawSeries : [rawSeries]; + return seriesArray.some((item) => { + if (!item || typeof item !== 'object') return false; + return (item as { type?: string }).type === 'pie'; + }); + })(); + + if (isPieChart) { + if (!didInitialAnimateRef.current) { + chartInstance.setOption(option, true, false); + didInitialAnimateRef.current = true; + } else { + // 后续更新 + setOption(option); + } + return; + } + + if (!didInitialAnimateRef.current) { + const zeroOption: EChartsOption = (() => { + try { + const cloned = JSON.parse(JSON.stringify(option)) as EChartsOption & { + series?: unknown; + }; + const rawSeries = (cloned as { series?: unknown }).series; + if (Array.isArray(rawSeries)) { + const newSeries = rawSeries.map((s) => { + if (s && typeof s === 'object') { + const sObj = s as Record; + const data = sObj.data as unknown; + if (Array.isArray(data)) { + const mapped = data.map((v) => mapDataItemToZero(v)); + return { ...sObj, data: mapped } as Record; + } + } + return s; + }); + (cloned as { series?: unknown }).series = newSeries as unknown; + } + return cloned as EChartsOption; + } catch { + return option; + } + })(); + + chartInstance.setOption(zeroOption, true, false); + + animationTimer = setTimeout(() => { + if (chartInstance) { + setOption(option); + } + }, 50); + + didInitialAnimateRef.current = true; + } else { + setOption(option); + } + } catch (error) { + console.error('Chart option setting error:', error); + if (onErrorRef.current) { + onErrorRef.current(error as Error); + } + } + + return () => { + if (animationTimer) { + clearTimeout(animationTimer); + } + }; + }, [dragging, chartInstance, option, isReady, setOption]); + + const defaultStyle: React.CSSProperties = { + width: '100%', + height: '400px', + ...style, + }; + + if (!isMounted) { + return ( +
+
+
Loading chart...
+
Initializing ECharts
+
+
+ ); + } + + return ( +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/constant.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/constant.ts new file mode 100644 index 0000000000..b0b5abb080 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/constant.ts @@ -0,0 +1,35 @@ +import { THEMES_KEYS } from '@teable/openapi'; + +const ANIMATION_CONFIG = { + animation: true, + animationDuration: 600, + animationEasing: 'cubicOut' as const, + animationDurationUpdate: 450, + animationEasingUpdate: 'cubicOut' as const, +}; + +export const BASE_CHART_CONFIG = { + ...ANIMATION_CONFIG, +}; + +export const THEMES = { + [THEMES_KEYS.BLUE]: ['#8b9eff', '#4a5aff', '#3a4aff', '#3442ff', '#2d3aff'], + [THEMES_KEYS.GREEN]: ['#a3e7b5', '#40c463', '#2da44e', '#1a7f37', '#0d5c2c'], + [THEMES_KEYS.NEUTRAL]: ['#d97706', '#5a9ca6', '#4b5563', '#e8d44d', '#d4a650'], + [THEMES_KEYS.ORANGE]: ['#f2c76e', '#e89542', '#d97706', '#b45309', '#8b3f0a'], + [THEMES_KEYS.RED]: ['#f5c2b8', '#e06052', '#dc2626', '#b91c1c', '#991b1b'], + [THEMES_KEYS.ROSE]: ['#ffc9c9', '#f87171', '#ef4444', '#dc2626', '#b91c1c'], + [THEMES_KEYS.VIOLET]: ['#e9d5ff', '#c084fc', '#a855f7', '#9333ea', '#7e22ce'], + [THEMES_KEYS.YELLOW]: ['#fef08a', '#fde047', '#eab308', '#ca8a04', '#a16207'], +}; + +export const DARK_THEMES = { + [THEMES_KEYS.BLUE]: ['#8b9eff', '#4a5aff', '#3a4aff', '#3442ff', '#2d3aff'], + [THEMES_KEYS.GREEN]: ['#a3e7b5', '#40c463', '#2da44e', '#1a7f37', '#0d5c2c'], + [THEMES_KEYS.NEUTRAL]: ['#3442ff', '#40c463', '#d4a650', '#c084fc', '#f87171'], + [THEMES_KEYS.ORANGE]: ['#f2c76e', '#e89542', '#d97706', '#b45309', '#8b3f0a'], + [THEMES_KEYS.RED]: ['#f5c2b8', '#e06052', '#dc2626', '#b91c1c', '#991b1b'], + [THEMES_KEYS.ROSE]: ['#ffc9c9', '#f87171', '#ef4444', '#dc2626', '#b91c1c'], + [THEMES_KEYS.VIOLET]: ['#e9d5ff', '#c084fc', '#a855f7', '#9333ea', '#7e22ce'], + [THEMES_KEYS.YELLOW]: ['#fef08a', '#fde047', '#eab308', '#ca8a04', '#a16207'], +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/hooks/useBaseQueryData.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/hooks/useBaseQueryData.ts new file mode 100644 index 0000000000..bc0fbeaba0 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/hooks/useBaseQueryData.ts @@ -0,0 +1,49 @@ +import { useQuery } from '@tanstack/react-query'; +import { + getDashboardInstallPluginQueryV2, + getPluginPanelInstallPluginQueryV2, + PluginPosition, +} from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { useMemo } from 'react'; +import { useEnv } from '../../../chart/hooks/useEnv'; + +export const useBaseQueryData = () => { + const { baseId, positionId, positionType, pluginInstallId, tableId } = useEnv(); + const { + data: dashboardQueryData = { + result: [], + columns: [], + }, + } = useQuery({ + queryKey: ReactQueryKeys.dashboardPluginQueryV2(baseId, positionId, pluginInstallId), + queryFn: () => + getDashboardInstallPluginQueryV2(pluginInstallId, positionId, baseId).then((res) => res.data), + enabled: Boolean( + positionType === PluginPosition.Dashboard && baseId && positionId && pluginInstallId + ), + }); + + const { + data: pluginPanelQueryData = { + result: [], + columns: [], + }, + } = useQuery({ + queryKey: ReactQueryKeys.pluginPanelPluginQueryV2(tableId!, positionId, pluginInstallId), + queryFn: () => + getPluginPanelInstallPluginQueryV2(pluginInstallId, positionId, tableId!).then( + (res) => res.data + ), + enabled: Boolean( + positionType === PluginPosition.Panel && tableId && positionId && pluginInstallId + ), + }); + + return useMemo(() => { + if (positionType === PluginPosition.Dashboard) { + return dashboardQueryData; + } + return pluginPanelQueryData; + }, [positionType, pluginPanelQueryData, dashboardQueryData]); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/hooks/useChartConfig.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/hooks/useChartConfig.ts new file mode 100644 index 0000000000..c2869cc29f --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/hooks/useChartConfig.ts @@ -0,0 +1,18 @@ +import { useTheme } from '@teable/next-themes'; +import type { IChartStorage } from '@teable/openapi'; +import type { ThemeName } from '@/themes/type'; +import { useStorage } from '../../hooks'; +import { getChartConfigByType, getEchartsType, getThemeByName } from '../utils'; +export const useChartConfig = () => { + const { storage } = useStorage(); + const { + chartType, + appearance: { theme }, + } = storage as IChartStorage; + const { resolvedTheme } = useTheme(); + const themeColors = getThemeByName(theme, resolvedTheme as ThemeName); + return { + config: getChartConfigByType(chartType, themeColors), + echartsType: getEchartsType(chartType), + }; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/hooks/useChartIcon.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/hooks/useChartIcon.ts new file mode 100644 index 0000000000..e7da188661 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/hooks/useChartIcon.ts @@ -0,0 +1,44 @@ +import { AreaChart, DonutChart, ColumnChart, LineChart, PieChart } from '@teable/icons'; +import { ChartType } from '@teable/openapi'; +import { useTranslation } from 'next-i18next'; +import { useCallback } from 'react'; + +export const useChartIcon = () => { + const { t } = useTranslation('chart'); + const iconGetter = useCallback((type: ChartType) => { + switch (type) { + case ChartType.Bar: + return ColumnChart; + case ChartType.Line: + return LineChart; + case ChartType.Area: + return AreaChart; + case ChartType.Pie: + return PieChart; + case ChartType.DonutChart: + return DonutChart; + default: + throw new Error(`Unknown chart type: ${type}`); + } + }, []); + const labelGetter = useCallback( + (type: ChartType) => { + switch (type) { + case ChartType.Bar: + return t('chartV2.form.chartType.bar'); + case ChartType.Line: + return t('chartV2.form.chartType.line'); + case ChartType.Area: + return t('chartV2.form.chartType.area'); + case ChartType.Pie: + return t('chartV2.form.chartType.pie'); + case ChartType.DonutChart: + return t('chartV2.form.chartType.donutChart'); + default: + throw new Error(`Unknown chart type: ${type}`); + } + }, + [t] + ); + return { iconGetter, labelGetter }; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/hooks/useSimpleECharts.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/hooks/useSimpleECharts.ts new file mode 100644 index 0000000000..96ca6d8f71 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/hooks/useSimpleECharts.ts @@ -0,0 +1,131 @@ +import * as echarts from 'echarts'; +import type { EChartsOption } from 'echarts'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +interface UseSimpleEChartsOptions { + theme?: string | object; + renderer?: 'canvas' | 'svg'; + dragging?: boolean; +} + +export const useSimpleECharts = (options?: UseSimpleEChartsOptions) => { + const chartRef = useRef(null); + const chartInstanceRef = useRef(null); + const [isReady, setIsReady] = useState(false); + const [isMounted, setIsMounted] = useState(false); + const prevDraggingRef = useRef(options?.dragging); + const draggingRef = useRef(options?.dragging); + + useEffect(() => { + setIsMounted(true); + }, []); + + const initChart = useCallback(() => { + if (!chartRef.current || !isMounted) { + return null; + } + + try { + const existingInstance = echarts.getInstanceByDom(chartRef.current); + chartInstanceRef.current = existingInstance + ? existingInstance + : echarts.init(chartRef.current, options?.theme, { + renderer: options?.renderer || 'canvas', + useDirtyRect: true, + }); + + setIsReady(true); + return chartInstanceRef.current; + } catch (error) { + console.error('ECharts initialization failed:', error); + setIsReady(false); + return null; + } + }, [isMounted, options?.theme, options?.renderer]); + + const setOption = useCallback((option: EChartsOption) => { + if (chartInstanceRef.current) { + try { + chartInstanceRef.current.setOption(option, true, true); + } catch (error) { + console.error('Failed to set chart option:', error); + } + } + }, []); + + const resize = useCallback((opts?: { silent?: boolean; animation?: object }) => { + if (chartInstanceRef.current) { + chartInstanceRef.current.resize({ + silent: true, + ...opts, + }); + } + }, []); + + const dispose = useCallback(() => { + if (chartInstanceRef.current) { + chartInstanceRef.current.dispose(); + chartInstanceRef.current = null; + setIsReady(false); + } + }, []); + + useEffect(() => { + draggingRef.current = options?.dragging; + }, [options?.dragging]); + + useEffect(() => { + if (prevDraggingRef.current === true && options?.dragging === false && isReady) { + const timer = setTimeout(() => { + resize(); + }, 100); + return () => clearTimeout(timer); + } + prevDraggingRef.current = options?.dragging; + }, [options?.dragging, isReady, resize]); + + useEffect(() => { + if (!isReady) return; + + const handleResize = () => { + if (!draggingRef.current) { + resize(); + } + }; + window.addEventListener('resize', handleResize); + + let resizeObserver: ResizeObserver | null = null; + if (chartRef.current) { + resizeObserver = new ResizeObserver(() => { + if (!draggingRef.current) { + resize(); + } + }); + resizeObserver.observe(chartRef.current); + } + + return () => { + window.removeEventListener('resize', handleResize); + if (resizeObserver) { + resizeObserver.disconnect(); + } + }; + }, [isReady, resize]); + + useEffect(() => { + return () => { + dispose(); + }; + }, [dispose]); + + return { + chartRef, + chartInstance: chartInstanceRef.current, + initChart, + setOption, + resize, + dispose, + isReady: isMounted && isReady, + isMounted, + }; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/index.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/index.ts new file mode 100644 index 0000000000..b8fa0fbb43 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/index.ts @@ -0,0 +1,4 @@ +export { ChartType, Statistic } from './types'; +export type { IChartData, IChartOptions, ISeries } from './types'; + +export { getChartConfigByType } from './utils'; diff --git a/apps/nextjs-app/src/features/app/components/Chart/type.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/types.ts similarity index 84% rename from apps/nextjs-app/src/features/app/components/Chart/type.ts rename to apps/nextjs-app/src/features/app/blocks/chart-v2/chart/types.ts index 190d169ade..da1c036f7e 100644 --- a/apps/nextjs-app/src/features/app/components/Chart/type.ts +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/types.ts @@ -2,6 +2,9 @@ export enum ChartType { Bar = 'bar', Pie = 'pie', Line = 'line', + Area = 'area', + DonutChart = 'donutChart', + Table = 'table', } export interface ISeries { diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/utils.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/utils.ts new file mode 100644 index 0000000000..6e7a4709f6 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/chart/utils.ts @@ -0,0 +1,109 @@ +import { ChartType } from '@teable/openapi'; +import { ThemeName } from '@/themes/type'; +import { BASE_CHART_CONFIG, THEMES, DARK_THEMES } from './constant'; + +export const getChartConfigByType = (type: ChartType, theme: string[]) => { + switch (type) { + case ChartType.Line: + case ChartType.Bar: + return { + ...BASE_CHART_CONFIG, + color: theme, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow', + }, + }, + grid: { + left: '3%', + right: '4%', + bottom: '3%', + containLabel: true, + }, + xAxis: { + type: 'category' as const, + data: [], + }, + yAxis: { + type: 'value' as const, + }, + series: [], + }; + case ChartType.Area: { + return { + ...BASE_CHART_CONFIG, + color: theme, + tooltip: { + trigger: 'axis', + axisPointer: { + type: 'shadow', + }, + }, + grid: { + left: '3%', + right: '4%', + bottom: '3%', + containLabel: true, + }, + xAxis: { + type: 'category' as const, + data: [], + boundaryGap: false, + }, + yAxis: { + type: 'value' as const, + }, + series: [], + }; + } + case ChartType.DonutChart: + case ChartType.Pie: + return { + ...BASE_CHART_CONFIG, + color: theme, + tooltip: { + trigger: 'item', + }, + legend: { + orient: 'vertical', + left: 'left', + }, + series: [], + }; + default: + return BASE_CHART_CONFIG; + } +}; + +export const getEchartsType = (type: ChartType) => { + switch (type) { + case ChartType.Line: + return 'line'; + case ChartType.Bar: + return 'bar'; + case ChartType.Area: + return 'line'; + case ChartType.Pie: + return 'pie'; + case ChartType.DonutChart: + return 'pie'; + default: + return type; + } +}; + +export const getThemeByName = (name: string = 'blue', theme: ThemeName = ThemeName.Light) => { + const isDark = theme === ThemeName.Dark; + const themes = isDark ? DARK_THEMES : THEMES; + return themes[name as keyof typeof themes] || themes.blue; +}; + +export const getEchartsTheme = (theme: ThemeName = ThemeName.Light) => { + return Object.keys(THEMES).map((key) => { + return { + name: key, + colors: getThemeByName(key, theme), + }; + }); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/ChartConfig.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/ChartConfig.tsx new file mode 100644 index 0000000000..2be656833c --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/ChartConfig.tsx @@ -0,0 +1,30 @@ +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { ChatAppearance } from './ChatAppearance'; +import { DataConfig } from './DataConfig'; + +export const ChartConfig = () => { + const { t } = useTranslation('chart'); + + return ( +
+ + + + {t('chartV2.dataConfig')} + + + {t('chartV2.chartAppearance')} + + + + + + + + + + +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/ChartPage.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/ChartPage.tsx new file mode 100644 index 0000000000..08a895dcef --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/ChartPage.tsx @@ -0,0 +1,50 @@ +import type { IParentBridgeMethods, IUIConfig } from '@teable/sdk/plugin-bridge'; +import { useEffect, useRef, useState } from 'react'; +import { EnvProvider } from '../../chart/components/EnvProvider'; +import type { IPageParams } from '../../chart/types'; +import { PluginInstallContext } from '../hooks/context'; +import { ChartConfig } from './ChartConfig'; +import { ChartPreview } from './ChartPreview'; + +interface IChartPageProps { + parentBridgeMethods: IParentBridgeMethods; + uiConfig: IUIConfig; + pageParams: IPageParams; + dragging?: boolean; +} + +export const ChartPage = (props: IChartPageProps) => { + const { parentBridgeMethods, uiConfig, pageParams, dragging } = props; + + const { isShowingSettings } = uiConfig; + const [triggerAnimation, setTriggerAnimation] = useState(); + const prevIsShowingSettingsRef = useRef(isShowingSettings); + + useEffect(() => { + if (prevIsShowingSettingsRef.current !== isShowingSettings) { + setTriggerAnimation(Date.now()); + prevIsShowingSettingsRef.current = isShowingSettings; + } + }, [isShowingSettings]); + + return ( + + +
+ + {isShowingSettings && } +
+
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/ChartPreview.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/ChartPreview.tsx new file mode 100644 index 0000000000..4802d4ce5e --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/ChartPreview.tsx @@ -0,0 +1,46 @@ +import { useTheme } from '@teable/next-themes'; +import { cn } from '@teable/ui-lib/shadcn'; +import type { EChartsOption } from 'echarts'; +import { Chart } from '../chart/Chart'; +import { useStorage } from '../hooks'; +import { useChartOption } from '../hooks/useChartOption'; + +interface ChartPreviewProps { + triggerAnimation?: number; + dragging?: boolean; + isShowingSettings?: boolean; +} + +export const ChartPreview = ({ + triggerAnimation, + dragging, + isShowingSettings, +}: ChartPreviewProps) => { + const { resolvedTheme } = useTheme(); + const options = useChartOption(); + const { storage } = useStorage(); + const { appearance } = storage; + + return ( +
+ +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/ChatAppearance.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/ChatAppearance.tsx new file mode 100644 index 0000000000..3301fa0f28 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/ChatAppearance.tsx @@ -0,0 +1,80 @@ +import { Label, Switch, Separator, Input } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useStorage } from '../hooks'; +import { FormLabel } from './form/FormLabel'; +import { ThemeSelect } from './form/ThemeSelect'; + +const PADDING_KEYS = ['top', 'right', 'bottom', 'left'] as const; + +export const ChatAppearance = () => { + const { t } = useTranslation('chart'); + const { storage, updateStorageByPath } = useStorage(); + const { appearance } = storage; + const { + theme, + legendVisible, + labelVisible, + padding = { top: undefined, right: undefined, bottom: undefined, left: undefined }, + } = appearance; + return ( +
+
+ {t('chartV2.appearance.display')} + { + updateStorageByPath('appearance.theme', value); + }} + /> +
+ +
+ + { + updateStorageByPath('appearance.legendVisible', checked); + }} + /> +
+ +
+ + { + updateStorageByPath('appearance.labelVisible', checked); + }} + /> +
+ + + +
+ {t('chartV2.appearance.style')} + +
+ {PADDING_KEYS.map((key) => ( +
+
{t(`chartV2.appearance.${key}`)}
+ { + updateStorageByPath(`appearance.padding.${key}`, e.target.value); + }} + /> +
+ ))} +
+
+
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/DataConfig.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/DataConfig.tsx new file mode 100644 index 0000000000..dd433b2ccb --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/DataConfig.tsx @@ -0,0 +1,50 @@ +import { ChartType } from '@teable/openapi'; +import { Input, Separator } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useStorage } from '../hooks'; +import { usePluginInstall } from '../hooks/usePluginInstall'; +import { ChartTypeSelect } from './form'; +import { DataSourceSelect } from './form/DataSource'; + +export const DataConfig = () => { + const { t } = useTranslation('chart'); + const { updateStorageByPath } = useStorage(); + const { renamePlugin, pluginInstall } = usePluginInstall(); + const { name: pluginName } = pluginInstall || {}; + const { storage } = useStorage(); + const { chartType } = storage; + + return ( +
+
+ {t('chartV2.form.name')} + renamePlugin(e.target.value)} + /> +
+ + { + const paths: Array<{ path: string; value: unknown }> = [ + { path: 'chartType', value: type as ChartType }, + ]; + if ([ChartType.Pie, ChartType.DonutChart].some((item) => item === (type as ChartType))) { + paths.push({ path: 'query.groupBy', value: null }); + paths.push({ path: 'appearance.labelVisible', value: true }); + } + updateStorageByPath(paths); + }} + /> + + + +
+ {t('chartV2.form.dataSource.title')} + +
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/AxisConfig.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/AxisConfig.tsx new file mode 100644 index 0000000000..82d62ed3eb --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/AxisConfig.tsx @@ -0,0 +1,203 @@ +import { CellValueType, FieldType } from '@teable/core'; +import { DEFAULT_SERIES_ARRAY } from '@teable/openapi'; +import type { ITableQuery, IStatisticFieldItem } from '@teable/openapi'; +import { + Label, + Separator, + Switch, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useCallback, useMemo } from 'react'; +import { ChartType } from '../../chart'; +import { useFields, useStorage } from '../../hooks'; +import { ColumnSelect } from './ColumnSelect'; +import { StatisticFieldSelect } from './field-select'; +import { FormLabel } from './FormLabel'; +import { TabSelect } from './TabSelect'; + +export const AxisConfig = () => { + const { t } = useTranslation('chart'); + const { storage, updateStorageByPath } = useStorage(); + const { fields } = useFields(); + const { query, chartType } = storage; + const { xAxis, seriesArray, orderBy, groupBy } = (query || {}) as ITableQuery; + + const pieChartType = [ChartType.Pie, ChartType.DonutChart].includes(chartType); + const countAll = seriesArray === DEFAULT_SERIES_ARRAY; + + const displayGroupBy = useMemo(() => { + if (pieChartType) { + return false; + } + if (!countAll && seriesArray?.length > 1) { + return false; + } + + if (!groupBy) { + return false; + } + return true; + }, [countAll, groupBy, pieChartType, seriesArray?.length]); + + const groupBySwitchVisible = useMemo(() => { + return ![ChartType.Pie, ChartType.DonutChart].includes(chartType); + }, [chartType]); + + const defaultField = useMemo(() => { + return fields?.at(0)?.id || null; + }, [fields]); + + const groupAggregationHandler = useCallback( + (checked: boolean) => { + if (checked) { + updateStorageByPath(`query.groupBy`, defaultField); + } else { + updateStorageByPath(`query.groupBy`, null); + } + }, + [updateStorageByPath, defaultField] + ); + + const countFields = useMemo(() => { + return fields?.filter((f) => f.cellValueType === CellValueType.Number); + }, [fields]); + + return ( +
+ + + { + updateStorageByPath(`query.xAxis`, value); + }} + /> + + + + { + updateStorageByPath(`query.orderBy.on`, value); + }} + /> + + + + { + updateStorageByPath(`query.orderBy.order`, value); + }} + /> + + + + + + {/* y axis */} + + + + + { + updateStorageByPath(`query.seriesArray`, DEFAULT_SERIES_ARRAY); + }} + > + {t('chartV2.form.axisConfig.totalRecords')} + + { + if (countFields.length > 0) { + updateStorageByPath(`query.seriesArray`, [ + { + fieldId: countFields[0].id, + rollup: 'sum', + }, + ]); + } else { + updateStorageByPath(`query.seriesArray`, []); + } + }} + > + {t('chartV2.form.axisConfig.fieldValue')} + + + + + + {!countAll && ( + { + updateStorageByPath(`query.seriesArray`, value); + }} + /> + )} + {countFields?.length === 0 && ( +
+ {t('chartV2.form.axisConfig.noCountFields')} +
+ )} +
+
+
+ + {groupBySwitchVisible && ( +
+ + +
+ )} + + {displayGroupBy && ( + + { + updateStorageByPath(`query.groupBy`, value); + }} + notAllowedFields={[FieldType.Attachment]} + /> + + )} +
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/ChartTypeSelect.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/ChartTypeSelect.tsx new file mode 100644 index 0000000000..a369353dfa --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/ChartTypeSelect.tsx @@ -0,0 +1,124 @@ +import { ChartType } from '@teable/openapi'; +import { + cn, + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectLabel, + SelectTrigger, + SelectValue, +} from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useMemo } from 'react'; +import { useChartIcon } from '../../chart/hooks/useChartIcon'; +import { FormLabel } from './FormLabel'; + +interface IChartTypeSelectProps { + value: string; + onChange: (type: string) => void; +} + +export const ChartTypeSelect = (props: IChartTypeSelectProps) => { + const { onChange, value } = props; + const { t } = useTranslation('chart'); + const { iconGetter, labelGetter } = useChartIcon(); + + const chartGroup = useMemo( + () => [ + { + groupLabel: t('chartV2.form.chartType.bar'), + items: [ + { + label: t('chartV2.form.chartType.bar'), + value: ChartType.Bar, + icon: iconGetter(ChartType.Bar), + }, + ], + }, + { + groupLabel: t('chartV2.form.chartType.line'), + items: [ + { + label: t('chartV2.form.chartType.line'), + value: ChartType.Line, + icon: iconGetter(ChartType.Line), + }, + { + label: t('chartV2.form.chartType.area'), + value: ChartType.Area, + icon: iconGetter(ChartType.Area), + }, + ], + }, + { + groupLabel: t('chartV2.form.chartType.pie'), + items: [ + { + label: t('chartV2.form.chartType.pie'), + value: ChartType.Pie, + icon: iconGetter(ChartType.Pie), + }, + { + label: t('chartV2.form.chartType.donutChart'), + value: ChartType.DonutChart, + icon: iconGetter(ChartType.DonutChart), + }, + ], + }, + ], + [iconGetter, t] + ); + + const selectedIcon = useMemo(() => { + if (!value) return null; + const Icon = iconGetter(value as ChartType); + return ; + }, [iconGetter, value]); + + return ( +
+ + + +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/ColumnSelect.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/ColumnSelect.tsx new file mode 100644 index 0000000000..f52897781e --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/ColumnSelect.tsx @@ -0,0 +1,21 @@ +import type { FieldType } from '@teable/core'; +import { FieldSelect } from './field-select/FieldSelect'; + +interface IColumnSelectProps { + value: string; + onChange: (value: string) => void; + allowType?: FieldType[]; + notAllowedFields?: FieldType[]; +} + +export const ColumnSelect = (props: IColumnSelectProps) => { + const { value, onChange, allowType, notAllowedFields } = props; + return ( + + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/DataSource.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/DataSource.tsx new file mode 100644 index 0000000000..96f5e181dd --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/DataSource.tsx @@ -0,0 +1,174 @@ +import { useMutation } from '@tanstack/react-query'; +import type { ITableQuery } from '@teable/openapi'; +import { DataSource, getFields, DEFAULT_SERIES_ARRAY } from '@teable/openapi'; +import { useTables } from '@teable/sdk/hooks'; +import { Separator, Tabs, TabsContent, TabsList, TabsTrigger } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useStorage } from '../../hooks'; +import { AxisConfig } from './AxisConfig'; +import { FilterButton } from './FilterButton'; +import { FormLabel } from './FormLabel'; +import { ColumnConfig, SqlButton } from './sql-builder'; +import { TableSelect } from './TableSelect'; +import { ViewSelect } from './ViewSelect'; + +export const DataSourceSelect = () => { + const { t } = useTranslation('chart'); + const { updateStorageByPath, storage } = useStorage(); + const { dataSource, query } = storage; + + const tableId = (query as ITableQuery)?.tableId; + const viewId = (query as ITableQuery)?.viewId; + + const tableList = useTables() || []; + + const { mutateAsync: getFieldsFn } = useMutation({ + mutationFn: (tableId: string) => getFields(tableId).then((res) => res.data), + }); + + return ( +
+ + + { + const defaultTableId = tableList?.at(-1)?.id; + if (!defaultTableId) return; + const fields = await getFieldsFn(defaultTableId); + const defaultField = fields?.[0]?.id; + if (!defaultField) return; + const paths = [ + { + path: 'dataSource', + value: DataSource.Table, + }, + { + path: 'config', + value: null, + }, + { + path: 'query.tableId', + value: defaultTableId, + }, + { + path: 'query.viewId', + value: null, + }, + { + path: 'query.xAxis', + value: defaultField, + }, + { + path: 'query.seriesArray', + value: DEFAULT_SERIES_ARRAY, + }, + { + path: 'query.filter', + value: null, + }, + { + path: 'query.groupBy', + value: null, + }, + ]; + updateStorageByPath(paths); + }} + > + {t('chartV2.form.dataSource.fromTable')} + + {/* { + const paths = [ + { + path: 'dataSource', + value: DataSource.Sql, + }, + { + path: 'query', + value: { + sql: null, + }, + }, + { + path: 'config', + value: null, + }, + ]; + updateStorageByPath(paths); + }} + > + {t('chartV2.form.dataSource.fromQuery')} + */} + + + + + { + const fields = await getFieldsFn(value); + const paths = [{ path: 'query.tableId', value }] as Array<{ + path: string; + value: unknown; + }>; + const changePaths = [ + { + path: 'query.viewId', + value: null, + }, + { + path: 'query.filter', + value: null, + }, + { + path: 'query.groupBy', + value: null, + }, + { + path: 'query.seriesArray', + value: DEFAULT_SERIES_ARRAY, + }, + { + path: 'query.xAxis', + value: fields[0].id, + }, + ]; + paths.push(...changePaths); + updateStorageByPath(paths); + }} + /> + + + {tableId && ( +
+ + { + updateStorageByPath('query.viewId', value); + }} + tableId={tableId} + /> + +
+ )} + + + + + + +
+ + + + + +
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/FilterButton.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/FilterButton.tsx new file mode 100644 index 0000000000..8b053adc34 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/FilterButton.tsx @@ -0,0 +1,88 @@ +import type { IFilter } from '@teable/core'; +import { Filter } from '@teable/icons'; +import type { ITableQuery } from '@teable/openapi'; +import { FieldContext } from '@teable/sdk/context'; +import type { IViewFilterConditionItem } from '@teable/sdk/index'; +import { BaseViewFilter, createFieldInstance, useViewFilterLinkContext } from '@teable/sdk/index'; +import { + Button, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@teable/ui-lib/shadcn'; +import { get } from 'lodash'; +import { useTranslation } from 'next-i18next'; +import { useMemo } from 'react'; +import { useFields, useStorage } from '../../hooks'; +import { FormLabel } from './FormLabel'; + +export const FilterButton = () => { + const { t } = useTranslation('chart'); + const { storage, updateStorageByPath } = useStorage(); + const { fields } = useFields(); + const fieldInstances = fields.map((field) => createFieldInstance(field)); + const filter = (get(storage, 'query.filter') as unknown as IFilter) || null; + const tableId = (storage?.query as unknown as ITableQuery)?.tableId; + const viewId = (storage?.query as unknown as ITableQuery)?.viewId; + + const onChange = (value: IFilter | null) => { + updateStorageByPath('query.filter', value); + }; + + const displayFilterLength = useMemo(() => { + if (!filter) { + return false; + } + + if (filter.filterSet.length > 0) { + return true; + } + + return false; + }, [filter]); + + const filterLength = useMemo(() => { + if (!filter) { + return 0; + } + return filter.filterSet.length; + }, [filter]); + + const viewFilterLinkContext = useViewFilterLinkContext(tableId, viewId, { + disabled: false, + preventGlobalError: true, + }); + + return ( + + + + + + + + {t('chartV2.form.filter.title')} + +
+ + + fields={fieldInstances} + value={filter} + onChange={onChange} + viewFilterLinkContext={viewFilterLinkContext} + /> + +
+
+
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/FormLabel.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/FormLabel.tsx new file mode 100644 index 0000000000..6420756116 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/FormLabel.tsx @@ -0,0 +1,17 @@ +import { cn } from '@teable/ui-lib'; + +export const FormLabel = (props: { + label: string; + children: React.ReactNode; + className?: string; + labelClassName?: string; +}) => { + const { children, label, className, labelClassName } = props; + + return ( +
+ {label} + {children} +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/TabSelect.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/TabSelect.tsx new file mode 100644 index 0000000000..5ae46968c5 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/TabSelect.tsx @@ -0,0 +1,38 @@ +import { cn, Tabs, TabsList, TabsTrigger } from '@teable/ui-lib/shadcn'; +import { useMemo } from 'react'; + +interface ITabSelectProps { + options: { + label: string; + value: string; + }[]; + value: string | null; + defaultValue?: string; + onChange: (value: string) => void; + className?: string; +} + +export const TabSelect = (props: ITabSelectProps) => { + const { options, value, defaultValue: propsDefaultValue, onChange, className } = props; + const defaultValue = useMemo(() => { + return propsDefaultValue ?? options.at(0)?.value; + }, [options, propsDefaultValue]); + + return ( + { + onChange(value); + }} + > + + {options.map((option) => ( + + {option.label} + + ))} + + + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/TableSelect.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/TableSelect.tsx new file mode 100644 index 0000000000..190ec0c604 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/TableSelect.tsx @@ -0,0 +1,68 @@ +import { BaseSingleSelect } from '@teable/sdk/components/filter/view-filter/component'; +import { useTables } from '@teable/sdk/hooks'; +import { Table2 } from 'lucide-react'; +import { useTranslation } from 'next-i18next'; +import { useMemo } from 'react'; + +interface ITableSelectProps { + value: string | null; + onChange: (value: string) => void; +} + +export const TableSelect = (props: ITableSelectProps) => { + const table = useTables(); + const { value: originValue, onChange } = props; + + const tableList = useMemo(() => { + return table.map(({ id, name, icon }) => ({ + value: id, + label: name, + icon: icon, + })); + }, [table]); + + const { t } = useTranslation(['chart', 'common']); + + const optionsRender = (option: (typeof tableList)[number]) => { + return ( +
+
{option.icon || }
+
{option.label}
+
+ ); + }; + + const displayRender = (option: (typeof tableList)[number]) => { + const { icon, label } = option; + return ( +
+
{icon || }
+
{label}
+
+ ); + }; + + const selectHandler = (value: string | null) => { + if (value) { + onChange?.(value); + } + }; + + const notFoundRender = useMemo(() => { + return {t('common:noResult')}; + }, [t]); + + return ( + + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/ThemeSelect.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/ThemeSelect.tsx new file mode 100644 index 0000000000..c5367e2d1d --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/ThemeSelect.tsx @@ -0,0 +1,58 @@ +import { useTheme } from '@teable/next-themes'; +import { THEMES_KEYS } from '@teable/openapi'; +import { + SelectTrigger, + SelectValue, + Select, + SelectContent, + SelectItem, +} from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import type { ThemeName } from '@/themes/type'; +import { getEchartsTheme } from '../../chart/utils'; +import { FormLabel } from './FormLabel'; + +interface IThemeSelectProps { + value: string; + onChange: (value: string) => void; +} +export const ThemeSelect = (props: IThemeSelectProps) => { + const { value = THEMES_KEYS.BLUE, onChange } = props; + const { t } = useTranslation('chart'); + const { resolvedTheme } = useTheme(); + const themes = getEchartsTheme(resolvedTheme as ThemeName); + + return ( + +
+ +
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/ViewSelect.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/ViewSelect.tsx new file mode 100644 index 0000000000..dbb4c75dd5 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/ViewSelect.tsx @@ -0,0 +1,81 @@ +import { useQuery } from '@tanstack/react-query'; +import type { ViewType } from '@teable/core'; +import { Sheet } from '@teable/icons'; +import { getViewList } from '@teable/openapi'; +import { BaseSingleSelect } from '@teable/sdk/components/filter/view-filter/component/base/BaseSingleSelect'; +import { VIEW_ICON_MAP } from '@teable/sdk/components/view/constant'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { useTranslation } from 'next-i18next'; +import { useMemo } from 'react'; + +interface ViewSelectProps { + value?: string | null; + tableId: string; + typeFilter?: ViewType; + onChange: (value: string | null) => void; + cancelable?: boolean; +} + +export const ViewSelect = (props: ViewSelectProps) => { + const { value = 'all', onChange, tableId, typeFilter, cancelable = false } = props; + + const { data: viewRawData } = useQuery({ + queryKey: ReactQueryKeys.viewList(tableId), + queryFn: () => getViewList(tableId).then((res) => res.data), + enabled: !!tableId, + meta: { preventGlobalError: true }, + }); + + const { t } = useTranslation(['chart', 'common']); + + const viewList = useMemo(() => { + return ( + (typeFilter ? viewRawData?.filter((view) => view.type === typeFilter) : viewRawData) || [] + ); + }, [viewRawData, typeFilter]); + + const options = useMemo(() => { + const views = viewList.map(({ id, type, name }) => ({ + value: id, + label: name, + icon: VIEW_ICON_MAP[type], + })); + views?.unshift({ + value: 'all', + label: t('chartV2.form.view.allData'), + icon: () => , + }); + return views; + }, [viewList, t]); + + const displayRender = (option: (typeof options)[number]) => { + const { icon: Icon, label } = option; + return ( +
+
{Icon && }
+
{label}
+
+ ); + }; + + const notFoundRender = useMemo(() => { + return {t('common:noResult')}; + }, [t]); + + return ( + { + onChange(newValue === 'all' ? null : newValue); + }} + popoverClassName="w-[350px]" + displayRender={displayRender} + optionRender={displayRender} + cancelable={cancelable} + className="my-1 h-9" + defaultLabel={notFoundRender} + /> + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/FieldSelect.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/FieldSelect.tsx new file mode 100644 index 0000000000..aa83de5e15 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/FieldSelect.tsx @@ -0,0 +1,194 @@ +import type { CellValueType, FieldType } from '@teable/core'; +import { Check, ChevronDown } from '@teable/icons'; +import { useFieldStaticGetter } from '@teable/sdk/hooks'; +import { + Button, + cn, + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, + Popover, + PopoverContent, + PopoverTrigger, +} from '@teable/ui-lib'; +import { useTranslation } from 'next-i18next'; +import { useCallback, useMemo, useState } from 'react'; +import { useFields } from '../../../hooks'; + +interface IFieldSelectProps { + value: string | undefined; + selectedFields?: string[]; + className?: string; + placeholderClassName?: string; + popoverClassName?: string; + notFoundText?: React.ReactNode; + placeholder?: string; + onChange: (value: string) => void; + allowCellValueType?: CellValueType[]; + allowType?: FieldType[]; + notAllowedFields?: FieldType[]; +} +export const FieldSelect = (props: IFieldSelectProps) => { + const { t } = useTranslation(['common', 'chart', 'sdk']); + const fieldStaticGetter = useFieldStaticGetter(); + + const { + value, + selectedFields, + className, + placeholderClassName, + popoverClassName, + placeholder = t('sdk:common.search.placeholder'), + notFoundText = t('sdk:common.noRecords'), + onChange, + allowCellValueType, + allowType, + notAllowedFields, + } = props; + const { fields } = useFields(); + const [open, setOpen] = useState(false); + const [, setSearch] = useState(''); + const [, setIsComposing] = useState(false); + const options = useMemo(() => { + return fields + ?.filter((f) => { + if (!allowType) { + return true; + } + return allowType.some((type) => type === f.type); + }) + ?.filter((f) => { + if (!notAllowedFields) { + return true; + } + return !notAllowedFields.some((type) => type === f.type); + }) + ?.filter((f) => { + if (!allowCellValueType) { + return true; + } + return allowCellValueType.some((value) => value === f.cellValueType); + }) + ?.filter((f) => { + if (!selectedFields) { + return true; + } + return !selectedFields.includes(f.id); + }) + ?.map((f) => ({ + value: f.id, + label: f.name, + })); + }, [allowCellValueType, allowType, fields, notAllowedFields, selectedFields]); + + const optionMap = useMemo(() => { + const map: Record = {}; + options.forEach((option) => { + const key = option.value; + const value = option.label; + map[key] = value; + }); + return map; + }, [options]); + + const commandFilter = useCallback( + (id: string, searchValue: string) => { + const name = optionMap?.[id?.trim()]?.toLowerCase() || ''; + return name.includes(searchValue?.toLowerCase()?.trim()) ? 1 : 0; + }, + [optionMap] + ); + + const selectRender = useMemo(() => { + const selectedField = fields?.find((f) => f.id === value); + if (!selectedField?.name) { + return ( + + {t('sdk:common.selectPlaceHolder')} + + ); + } + + const { type, isLookup, name: label, aiConfig, recordRead } = selectedField; + const { Icon } = fieldStaticGetter(type, { + isLookup, + isConditionalLookup: selectedField.isConditionalLookup, + hasAiConfig: Boolean(aiConfig), + deniedReadRecord: recordRead === false, + }); + return ( +
+ + {label} +
+ ); + }, [fieldStaticGetter, fields, placeholderClassName, t, value]); + + return ( + + + + + + + setIsComposing(true)} + onCompositionEnd={() => setIsComposing(false)} + onValueChange={(value) => setSearch(value)} + /> + {notFoundText} + + {options?.map((option) => { + const selectedField = fields?.find((f) => f.id === option.value); + if (!selectedField?.name) { + return null; + } + const { type, isLookup, name: label, aiConfig, recordRead } = selectedField; + const { Icon } = fieldStaticGetter(type, { + isLookup, + isConditionalLookup: selectedField.isConditionalLookup, + hasAiConfig: Boolean(aiConfig), + deniedReadRecord: recordRead === false, + }); + return ( + { + // support re-select to reset selection when cancelable is enabled + onChange(option.value); + setOpen(false); + }} + className="truncate text-sm" + > + +
+ + {label} +
+
+ ); + })} +
+
+
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/index.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/index.ts new file mode 100644 index 0000000000..75a85ee666 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/index.ts @@ -0,0 +1,2 @@ +export * from './FieldSelect'; +export * from './statistic-field'; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/AddFieldButton.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/AddFieldButton.tsx new file mode 100644 index 0000000000..b556030a2c --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/AddFieldButton.tsx @@ -0,0 +1,72 @@ +import { CellValueType } from '@teable/core'; +import { Plus } from '@teable/icons'; +import { FieldRollup } from '@teable/openapi'; +import type { ITableQuery } from '@teable/openapi'; +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@teable/ui-lib/shadcn'; +import { useFields, useStorage } from '../../../../hooks'; + +export const AddFieldButton = () => { + const { fields } = useFields(); + const { storage, updateStorageByPath } = useStorage(); + const { query } = storage || {}; + const { seriesArray } = (query || {}) as ITableQuery; + const fieldOptions = fields + .filter(({ cellValueType }) => cellValueType === CellValueType.Number) + ?.filter( + (field) => + Array.isArray(seriesArray) && !seriesArray?.some((item) => item.fieldId === field.id) + ); + return ( + fieldOptions.length > 0 && ( + + + + + + {fieldOptions?.map((field) => ( + { + const paths: Array<{ path: string; value: unknown }> = [ + { + path: 'query.seriesArray', + value: [ + ...seriesArray, + { + fieldId: field.id, + rollup: FieldRollup.Sum, + }, + ], + }, + ]; + const newSeriesArray = [ + ...seriesArray, + { + fieldId: field.id, + rollup: FieldRollup.Sum, + }, + ]; + + if (newSeriesArray.length >= 2) { + paths.push({ path: 'query.groupBy', value: null }); + } + + updateStorageByPath(paths); + }} + > + {field.name} + + ))} + + + ) + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/StatisticFieldItem.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/StatisticFieldItem.tsx new file mode 100644 index 0000000000..0a7bc26807 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/StatisticFieldItem.tsx @@ -0,0 +1,53 @@ +import { CellValueType } from '@teable/core'; +import { Trash2 } from '@teable/icons'; +import type { FieldRollup, IStatisticFieldItem } from '@teable/openapi'; +import { Button } from '@teable/ui-lib/shadcn'; +import { FieldSelect } from '../FieldSelect'; +import { StatisticFunSelect } from './StatisticFunSelect'; + +interface IStaticFieldItemProps { + allStaticFields: IStatisticFieldItem[]; + value: IStatisticFieldItem; + onChange: (value: IStatisticFieldItem) => void; + onDelete: () => void; +} +export const StatisticFieldItem = (props: IStaticFieldItemProps) => { + const { allStaticFields, value, onChange, onDelete } = props; + + return ( +
+ field.fieldId)} + value={value?.fieldId} + onChange={(property: string) => { + onChange({ + ...value, + fieldId: property, + } as IStatisticFieldItem); + }} + className="w-full" + allowCellValueType={[CellValueType.Number]} + /> + + { + onChange({ + ...value, + rollup: property as FieldRollup, + } as IStatisticFieldItem); + }} + className="w-full" + /> + + +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/StatisticFieldSelect.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/StatisticFieldSelect.tsx new file mode 100644 index 0000000000..4c29e5db5f --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/StatisticFieldSelect.tsx @@ -0,0 +1,51 @@ +import type { IStatisticFieldItem } from '@teable/openapi'; +import { ChartType } from '@teable/openapi'; +import { useMemo } from 'react'; +import { useStorage } from '../../../../hooks'; +import { AddFieldButton } from './AddFieldButton'; +import { StatisticFieldItem } from './StatisticFieldItem'; + +interface IStatisticFieldSelectProps { + value: IStatisticFieldItem[]; + onChange: (value: IStatisticFieldItem[]) => void; +} + +export const StatisticFieldSelect = (props: IStatisticFieldSelectProps) => { + const { value = [], onChange } = props; + const handleChange = (index: number, valueItem: IStatisticFieldItem) => { + const newValue = [...value]; + newValue.splice(index, 1, valueItem); + onChange(newValue); + }; + const handleDelete = (index: number) => { + const newValue = [...value]; + newValue.splice(index, 1); + onChange(newValue); + }; + const { storage } = useStorage(); + const { chartType } = storage; + + const displayAddFieldButton = useMemo(() => { + const isBarChart = [ChartType.Pie, ChartType.DonutChart].includes(chartType as ChartType); + if (isBarChart && value.length === 1) { + return false; + } + return true; + }, [chartType, value.length]); + + return ( +
+ {value.map((item, index) => ( + handleChange(index, value)} + onDelete={() => handleDelete(index)} + /> + ))} + + {displayAddFieldButton && } +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/StatisticFunSelect.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/StatisticFunSelect.tsx new file mode 100644 index 0000000000..be6a1f9a38 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/StatisticFunSelect.tsx @@ -0,0 +1,29 @@ +import { FieldRollup } from '@teable/openapi'; +import { BaseSingleSelect } from '@teable/sdk/components/filter/view-filter/component'; +import { useMemo } from 'react'; + +interface IStatisticFunSelectProps { + value: FieldRollup; + onChange: (value: FieldRollup) => void; + className?: string; +} + +export const StatisticFunSelect = (props: IStatisticFunSelectProps) => { + const { value, onChange, className } = props; + const options = useMemo(() => { + return Object.values(FieldRollup).map((func) => ({ + value: func, + label: func, + })); + }, []); + return ( + { + onChange(value as FieldRollup); + }} + className={className} + /> + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/index.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/index.ts new file mode 100644 index 0000000000..64f461024b --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/field-select/statistic-field/index.ts @@ -0,0 +1 @@ +export * from './StatisticFieldSelect'; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/index.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/index.ts new file mode 100644 index 0000000000..455ac92698 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/index.ts @@ -0,0 +1,7 @@ +export * from './ChartTypeSelect'; +export * from './TableSelect'; +export * from './DataSource'; +export * from './ViewSelect'; +export * from './AxisConfig'; +export * from './TabSelect'; +export * from './ThemeSelect'; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/AddColumnButton.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/AddColumnButton.tsx new file mode 100644 index 0000000000..0105ff5543 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/AddColumnButton.tsx @@ -0,0 +1,40 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@teable/ui-lib/shadcn'; +import { useBaseQueryData } from '../../../chart/hooks/useBaseQueryData'; +import { useStorage } from '../../../hooks'; + +interface IAddColumnButtonProps { + children: React.ReactNode; +} +export const AddColumnButton = (props: IAddColumnButtonProps) => { + const { children } = props; + const { columns = [] } = useBaseQueryData(); + const { storage, updateStorageByPath } = useStorage(); + const yAxis = storage?.config?.yAxis; + const selectedColumns = yAxis || []; + const numberColumns = columns + .filter((column) => column.isNumber) + .filter((column) => !selectedColumns.some((selectedColumn) => selectedColumn === column.name)); + + return ( + + {children} + + {numberColumns.map((column) => ( + { + updateStorageByPath(`config.yAxis`, [...selectedColumns, column.name]); + }} + > + {column.name} + + ))} + + + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/ColumnConfig.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/ColumnConfig.tsx new file mode 100644 index 0000000000..3d26e492e5 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/ColumnConfig.tsx @@ -0,0 +1,76 @@ +import { Plus, Trash2 } from '@teable/icons'; +import { Button } from '@teable/ui-lib/shadcn'; +import { useTranslation } from 'next-i18next'; +import { useBaseQueryData } from '../../../chart/hooks/useBaseQueryData'; +import { useStorage } from '../../../hooks'; +import { FormLabel } from '../FormLabel'; +import { AddColumnButton } from './AddColumnButton'; +import { ColumnSelect } from './ColumnSelect'; + +export const ColumnConfig = () => { + const { t } = useTranslation('chart'); + const { columns = [] } = useBaseQueryData(); + const { updateStorageByPath, storage } = useStorage(); + const xAxis = storage?.config?.xAxis; + const yAxis = storage?.config?.yAxis; + const options = columns?.map((column) => ({ + value: column.name, + label: column.name, + })); + + const numberColumns = columns.filter((column) => column.isNumber); + const numberColumnsOptions = numberColumns.map((column) => ({ + value: column.name, + label: column.name, + })); + + return ( +
+ {t('chartV2.form.axisConfig.title')} + + + { + updateStorageByPath(`config.xAxis`, value); + }} + /> + + + + <> + {yAxis?.map((item, index) => ( +
+ { + updateStorageByPath(`config.yAxis[${index}]`, value); + }} + /> + + +
+ ))} + + + + +
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/ColumnSelect.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/ColumnSelect.tsx new file mode 100644 index 0000000000..11053f2382 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/ColumnSelect.tsx @@ -0,0 +1,31 @@ +import { BaseSingleSelect } from '@teable/sdk/components/filter/view-filter/component'; +import { useMemo } from 'react'; +interface IColumnSelectProps { + value: string | null; + options: { + value: string; + label: string; + }[]; + onSelect: (value: string) => void; + filterSelected?: boolean; + className?: string; +} +export const ColumnSelect = (props: IColumnSelectProps) => { + const { value, options, onSelect, filterSelected = false, className } = props; + const finalOptions = useMemo(() => { + if (filterSelected && value) { + return options.filter((option) => option.value !== value); + } + return options; + }, [filterSelected, value, options]); + return ( + { + onSelect(value as string); + }} + className={className} + value={value || null} + /> + ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/SqlBuilder.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/SqlBuilder.tsx new file mode 100644 index 0000000000..28c08ad478 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/SqlBuilder.tsx @@ -0,0 +1,137 @@ +import { autocompletion, completionKeymap } from '@codemirror/autocomplete'; +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands'; +import { sql, PostgreSQL } from '@codemirror/lang-sql'; +import { indentOnInput } from '@codemirror/language'; +import { EditorState } from '@codemirror/state'; +import { + EditorView, + keymap, + lineNumbers, + highlightActiveLine, + type ViewUpdate, +} from '@codemirror/view'; +import { useQuery } from '@tanstack/react-query'; +import { useTheme } from '@teable/next-themes'; +import { getBaseTableSchema } from '@teable/openapi'; +import { useBaseId } from '@teable/sdk/hooks'; +import { vscodeLight, vscodeDark } from '@uiw/codemirror-theme-vscode'; +import React, { useEffect, useRef, useCallback } from 'react'; + +interface ISqlBuilderProps { + code?: string; + readOnly?: boolean; + style?: React.CSSProperties; + onChange?: (code: string) => void; +} + +export const SqlBuilder: React.FC = ({ + code = '', + style, + readOnly = false, + onChange, +}) => { + const editorRef = useRef(null); + const viewRef = useRef(); + const onChangeRef = useRef(onChange); + const { theme } = useTheme(); + + const baseId = useBaseId(); + + const { data: schema } = useQuery({ + queryKey: ['base-table-schema', baseId], + queryFn: () => getBaseTableSchema(baseId!).then((res) => res.data), + enabled: !!baseId, + }); + + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + + const createExtensions = useCallback(() => { + return [ + sql({ + dialect: PostgreSQL, + schema: { + ...schema, + }, + }), + lineNumbers(), + highlightActiveLine(), + indentOnInput(), + autocompletion(), + history(), + keymap.of([indentWithTab, ...defaultKeymap, ...historyKeymap, ...completionKeymap]), + ...(theme === 'dark' ? [vscodeDark] : [vscodeLight]), + EditorState.readOnly.of(readOnly), + EditorView.updateListener.of((update: ViewUpdate) => { + if (update.docChanged && onChangeRef.current) { + const currentCode = update.state.doc.toString(); + onChangeRef.current(currentCode); + } + }), + EditorView.theme({ + '&': { + height: '100%', + fontSize: '14px', + }, + '.cm-focused': { + outline: 'none', + }, + '.cm-editor': { + height: '100%', + }, + '.cm-scroller': { + height: '100%', + }, + }), + ]; + }, [readOnly, schema, theme]); + + useEffect(() => { + if (!editorRef.current) return; + + const startState = EditorState.create({ + doc: code, + extensions: createExtensions(), + }); + + const view = new EditorView({ + state: startState, + parent: editorRef.current, + }); + + viewRef.current = view; + + return () => { + view.destroy(); + viewRef.current = undefined; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [createExtensions]); + + useEffect(() => { + if (!viewRef.current) return; + + const currentCode = viewRef.current.state.doc.toString(); + if (currentCode !== code) { + viewRef.current.dispatch({ + changes: { + from: 0, + to: viewRef.current.state.doc.length, + insert: code, + }, + }); + } + }, [code]); + + return ( +
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/SqlButton.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/SqlButton.tsx new file mode 100644 index 0000000000..bfec3c27c4 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/SqlButton.tsx @@ -0,0 +1,161 @@ +import { useMutation } from '@tanstack/react-query'; +import { Plus } from '@teable/icons'; +import { getDashboardTestSqlResult } from '@teable/openapi'; +import type { IBaseQueryVoV2, ITestSqlRo } from '@teable/openapi'; +import { useBaseId } from '@teable/sdk/hooks'; +import { + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, + Tabs, + TabsList, + TabsTrigger, + TabsContent, + cn, +} from '@teable/ui-lib/shadcn'; +import { get } from 'lodash'; +import { useTranslation } from 'next-i18next'; +import { useEffect, useState } from 'react'; +import { useDashboardContext } from '@/features/app/dashboard/context'; +import { useStorage } from '../../../hooks'; +import { SqlBuilder } from './SqlBuilder'; +import { SqlResult } from './SqlResult'; + +export const SqlButton = () => { + const { t } = useTranslation('chart'); + const [activeTab, setActiveTab] = useState('ai'); + const hasModel = true; + const { storage, updateStorageByPath } = useStorage(); + const sql = get(storage, 'query.sql') || ''; + const [editingSql, setEditingSql] = useState(sql); + const baseId = useBaseId(); + const [testData, setTestData] = useState(null); + + const { chatComponent } = useDashboardContext(); + + const { mutateAsync: getDashboardTestSqlResultFn } = useMutation({ + mutationFn: (testSqlRo: ITestSqlRo) => + getDashboardTestSqlResult(testSqlRo.baseId, testSqlRo.sql!).then((res) => res.data), + onSuccess: (data) => { + setActiveTab('result'); + setTestData(data || null); + }, + }); + + useEffect(() => { + if (!hasModel) { + setActiveTab('result'); + } + }, [hasModel]); + + return ( + + + + + + + {t('chartV2.form.sql.title')} + +
+ + + + {t('chartV2.form.sql.sqlEditor')} + +
+ + + +
+
+
+ + { + setEditingSql(value); + }} + style={{ borderRadius: '0 0 12px 12px' }} + /> + +
+ + + + {chatComponent && ( + + {t('chartV2.form.sql.aiGenerate')} + + )} + + {t('chartV2.form.sql.resultPreview')} + + + + {chatComponent && ( + + {chatComponent} + + )} + + + + + + + + + +
+
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/SqlResult.tsx b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/SqlResult.tsx new file mode 100644 index 0000000000..b4958d998d --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/SqlResult.tsx @@ -0,0 +1,39 @@ +import type { IBaseQueryVoV2 } from '@teable/openapi'; +import { TableBody, TableCell, TableHead, TableHeader, TableRow, Table } from '@teable/ui-lib'; + +interface ISqlResultProps { + data: IBaseQueryVoV2 | null; +} +export const SqlResult = (props: ISqlResultProps) => { + const { data: queryData } = props; + if (!queryData) return null; + + const { columns } = queryData; + + return ( +
+ + + + {columns.map(({ name }) => ( + + {name} + + ))} + + + + {queryData?.result.slice(0, 50).map((row, index) => ( + + {columns.map(({ name }) => ( + + {JSON.stringify(row[name]).toString()} + + ))} + + ))} + +
+
+ ); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/index.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/index.ts new file mode 100644 index 0000000000..239db6572b --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/form/sql-builder/index.ts @@ -0,0 +1,3 @@ +export * from './SqlBuilder'; +export * from './SqlButton'; +export * from './ColumnConfig'; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/components/index.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/index.ts new file mode 100644 index 0000000000..52a9986b90 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/components/index.ts @@ -0,0 +1 @@ +export * from './ChartPage'; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/core/adapters/BaseAdapter.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/adapters/BaseAdapter.ts new file mode 100644 index 0000000000..77b66748c2 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/adapters/BaseAdapter.ts @@ -0,0 +1,27 @@ +import type { IChartStorage, ITableQuery, ISqlQuery } from '@teable/openapi'; +import type { ISeriesConfig } from '../types'; + +export interface IChartData { + xData: string[]; + legendData: string[]; + rawData: Record[]; + series: ISeriesConfig[]; +} + +export abstract class BaseAdapter { + protected storage: IChartStorage; + protected result: Record[]; + + constructor(storage: IChartStorage, result: Record[]) { + this.storage = storage; + this.result = result; + } + + abstract getData(): IChartData; + + protected getValueByKey(item: Record, key: string): unknown { + return item?.[key]; + } + + abstract getSeries(xData: string[], legendData: string[]): ISeriesConfig[]; +} diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/core/adapters/SqlAdapter.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/adapters/SqlAdapter.ts new file mode 100644 index 0000000000..0d39796946 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/adapters/SqlAdapter.ts @@ -0,0 +1,44 @@ +import type { ISqlQuery } from '@teable/openapi'; +import { groupBy } from 'lodash'; +import type { ISeriesConfig } from '../types'; +import { BaseAdapter, type IChartData } from './BaseAdapter'; + +export class SqlAdapter extends BaseAdapter { + getData(): IChartData { + const { config } = this.storage; + const { yAxis = [] } = config || {}; + + const xData = this.getXData(); + const legendData = yAxis; + + const series = this.getSeries(xData, legendData); + + return { + xData, + legendData, + rawData: this.result, + series, + }; + } + + private getXData(): string[] { + const { xAxis } = this.storage.config || {}; + if (xAxis) { + return Object.keys(groupBy(this.result, xAxis)); + } + return []; + } + + getSeries(xData: string[], legendData: string[]): ISeriesConfig[] { + const { xAxis } = this.storage.config; + return legendData.map((name) => { + const groupByData = groupBy(this.result, xAxis); + return { + name, + data: xData.map((xName) => { + return groupByData?.[xName]?.find((item) => item?.[xAxis] == xName)?.[name] ?? 0; + }), + }; + }) as ISeriesConfig[]; + } +} diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/core/adapters/TableAdapter.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/adapters/TableAdapter.ts new file mode 100644 index 0000000000..1d61ec5e1a --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/adapters/TableAdapter.ts @@ -0,0 +1,194 @@ +import type { IFieldVo } from '@teable/core'; +import type { FieldRollup, IChartStorage, ITableQuery } from '@teable/openapi'; +import { AGGREGATE_COUNT_KEY, DEFAULT_SERIES_ARRAY } from '@teable/openapi'; +import { get, groupBy } from 'lodash'; +import type { ISeriesConfig } from '../types'; +import { getGroupUniqueKey, getGroupKeyName, getFieldRollupKeyByFieldName } from '../utils'; +import { BaseAdapter, type IChartData } from './BaseAdapter'; + +export const MAX_X_DATA_LENGTH = 50; +export class TableAdapter extends BaseAdapter { + private fields: IFieldVo[]; + + constructor( + storage: IChartStorage, + result: Record[], + fields: IFieldVo[] = [] + ) { + super(storage, result); + this.fields = fields; + } + + private getFieldMap(): Record { + return this.fields.reduce( + (acc, field) => { + acc[field.id] = field.name; + return acc; + }, + {} as Record + ); + } + + getData(): IChartData { + const xData = this.getXData(); + const legendData = this.buildLegendData(); + const series = this.getSeries(xData, legendData); + const finalXData = this.getRealXData(xData); + + return { + xData: finalXData, + legendData, + rawData: this.result, + series, + }; + } + + getRealXData(xData: string[]) { + const { query } = this.storage; + const { xAxis } = query; + const grouped = groupBy(this.result, (item) => getGroupUniqueKey(item[xAxis])); + const field = this.fields.find((field) => field.id === xAxis)!; + return xData.map((x) => { + const groupedItem = grouped[x]?.[0]?.[xAxis]; + + if (groupedItem === null) { + return 'null'; + } + + return getGroupKeyName(field, groupedItem); + }); + } + + private getXData(): string[] { + const { query } = this.storage; + const { xAxis } = query; + + const grouped = groupBy(this.result, (item) => getGroupUniqueKey(item[xAxis])); + + return Object.keys(grouped).slice(0, MAX_X_DATA_LENGTH); + } + + private buildLegendData(): string[] { + const { query } = this.storage; + const { seriesArray } = query; + const countAll = seriesArray === DEFAULT_SERIES_ARRAY; + + // no need legend data + if (countAll) { + return this.getLegendDataByCountAll(); + } + + return this.getLegendDataByFieldValue(); + } + + private getLegendDataByCountAll(): string[] { + const { groupBy: groupByFiledId } = this.storage.query; + + if (groupByFiledId) { + return Object.keys(groupBy(this.result, (item) => getGroupUniqueKey(item[groupByFiledId]))); + } + + return ['count']; + } + + private getLegendDataByFieldValue(): string[] { + const { seriesArray, groupBy: groupByFiledId } = this.storage.query; + const fieldMap = this.getFieldMap(); + + if (groupByFiledId) { + return Object.keys(groupBy(this.result, (item) => getGroupUniqueKey(item[groupByFiledId]))); + } + + return Array.isArray(seriesArray) ? seriesArray.map(({ fieldId }) => fieldMap?.[fieldId]) : []; + } + + getSeries(xData: string[], legendData: string[]): ISeriesConfig[] { + const { query } = this.storage; + const { seriesArray } = query; + const countAll = seriesArray === DEFAULT_SERIES_ARRAY; + + if (countAll) { + return this.getSeriesByCountAll(xData, legendData); + } + + return this.getSeriesByFieldValue(xData, legendData); + } + + private getSeriesByCountAll(xData: string[], legendData: string[]): ISeriesConfig[] { + const { groupBy: groupByFiledId, xAxis } = this.storage.query; + + if (!groupByFiledId) { + const countArray = this.result.map( + (item) => this.getValueByKey(item, AGGREGATE_COUNT_KEY) as number + ); + return legendData.map((name) => ({ + name, + data: countArray, + })); + } + + const groupByData = groupBy(this.result, (item) => getGroupUniqueKey(item[groupByFiledId])); + + return legendData.map((name) => { + const currentGroup = groupByData[name]; + return { + name, + data: xData.map((xName) => { + const item = currentGroup?.find((item) => getGroupUniqueKey(item[xAxis]) === xName); + return item?.[AGGREGATE_COUNT_KEY] || 0; + }), + }; + }) as ISeriesConfig[]; + } + + private getSeriesByFieldValue(xData: string[], legendData: string[]): ISeriesConfig[] { + const { seriesArray, xAxis, groupBy: groupByFiledId } = this.storage.query; + if (Array.isArray(seriesArray) && seriesArray.length < 2) { + if (!groupByFiledId) { + const name = legendData[0]; + const rollup = seriesArray[0]?.rollup; + const key = getFieldRollupKeyByFieldName(this.fields, name, rollup as FieldRollup); + return [ + { + name: legendData[0], + data: this.result.map((item) => get(item, key || '', 0) as number), + }, + ] as ISeriesConfig[]; + } + + const fieldMap = this.getFieldMap(); + const groupByData = groupBy(this.result, (item) => getGroupUniqueKey(item[xAxis])); + + return legendData.map((name) => { + const fieldName = fieldMap?.[seriesArray[0]?.fieldId]; + const key = getFieldRollupKeyByFieldName( + this.fields, + fieldName, + seriesArray[0]?.rollup as FieldRollup + ); + return { + name, + data: xData.map((xName) => { + const item = groupByData?.[xName]?.find( + (item) => getGroupUniqueKey(item[groupByFiledId]) === name + ); + return get(item, key || '', 0); + }), + }; + }) as ISeriesConfig[]; + } + + return legendData.map((name, index) => { + const rollup = Array.isArray(seriesArray) ? seriesArray[index]?.rollup : undefined; + const key = getFieldRollupKeyByFieldName(this.fields, name, rollup as FieldRollup); + + return { + name, + data: xData.map((xName) => { + const item = this.result?.find((item) => getGroupUniqueKey(item[xAxis]) === xName); + return get(item, key || '', 0); + }), + }; + }) as ISeriesConfig[]; + } +} diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/core/charts/BaseChart.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/charts/BaseChart.ts new file mode 100644 index 0000000000..16dcccb90c --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/charts/BaseChart.ts @@ -0,0 +1,137 @@ +import type { IFieldVo } from '@teable/core'; +import type { IChartStorage, IDashboardLayout } from '@teable/openapi'; +import type { IChartData } from '../adapters/BaseAdapter'; +import type { ISeriesConfig } from '../types'; + +export interface IChartOptions { + backgroundColor?: string; + legend: { + show: boolean; + top: number; + data?: string[]; + orient?: 'horizontal' | 'vertical'; + type?: 'plain' | 'scroll'; + pageButtonPosition?: 'start' | 'end'; + }; + tooltip?: { + trigger?: 'item' | 'axis' | 'none'; + confine?: boolean; + textStyle?: { + fontSize?: number; + color?: string; + fontWeight?: string | number; + }; + formatter?: string | ((params: unknown) => string); + }; + xAxis?: { + type: 'category'; + data: string[]; + axisLine?: { + show: boolean; + lineStyle?: { + color?: string; + width?: number; + }; + }; + axisTick?: { + show: boolean; + length?: number; + }; + axisLabel?: { + interval?: number | 'auto'; + rotate?: number; + overflow?: 'truncate' | 'break' | 'breakAll'; + ellipsis?: string; + hideOverlap?: boolean; + formatter?: (value: string) => string; + }; + splitLine?: { + show: boolean; + lineStyle?: { + color?: string; + width?: number; + type?: string; + }; + }; + }; + yAxis?: { + type: 'value'; + axisLine?: { + show: boolean; + lineStyle?: { + color?: string; + width?: number; + }; + }; + axisTick?: { + show: boolean; + length?: number; + }; + splitLine?: { + show: boolean; + lineStyle?: { + color?: string; + width?: number; + type?: string; + }; + }; + }; + grid?: { + left?: string | number; + right?: string | number; + top?: string | number; + bottom?: string | number; + containLabel?: boolean; + }; + series: ISeriesConfig[]; +} + +export abstract class BaseChart { + protected storage: IChartStorage; + protected chartData: IChartData; + protected echartsType: string; + protected fields: IFieldVo[]; + protected layout?: IDashboardLayout[number]; + + constructor( + storage: IChartStorage, + chartData: IChartData, + echartsType: string, + fields: IFieldVo[], + layout?: IDashboardLayout[number] + ) { + this.storage = storage; + this.chartData = chartData; + this.echartsType = echartsType; + this.fields = fields; + this.layout = layout; + } + + abstract generateOptions(): IChartOptions; + + protected abstract buildSeries(series: ISeriesConfig[]): ISeriesConfig[]; + + protected getBaseOptions(): IChartOptions { + return {} as IChartOptions; + } + + protected getTooltipFontSize(): number { + const defaultFontSize = 14; + + if (!this.layout) { + return defaultFontSize; + } + + const estimatedWidth = this.layout.w * 100; + + if (estimatedWidth < 300) { + return 10; + } else if (estimatedWidth < 500) { + return 12; + } else if (estimatedWidth < 700) { + return 14; + } else { + return 16; + } + } +} diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/core/charts/CartesianChart.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/charts/CartesianChart.ts new file mode 100644 index 0000000000..4a27104be0 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/charts/CartesianChart.ts @@ -0,0 +1,121 @@ +import { ChartType } from '@teable/openapi'; +import type { ISeriesConfig } from '../types'; +import { BaseChart, type IChartOptions } from './BaseChart'; + +const DEFAULT_GRID_PADDING = '20px'; +const DEFAULT_GRID_PADDING_TOP = '40px'; + +export class CartesianChart extends BaseChart { + generateOptions(): IChartOptions { + const { legendData, xData, series } = this.chartData; + const { appearance } = this.storage; + const { legendVisible } = appearance; + const completeSeries = this.buildSeries(series); + + const { h } = this.layout || {}; + + return { + legend: { + show: legendVisible ?? true, + top: 0, + type: 'scroll', + data: legendData, + orient: 'horizontal', + pageButtonPosition: 'end', + }, + tooltip: { + trigger: 'axis', + confine: true, + textStyle: { + fontSize: this.getTooltipFontSize(), + }, + }, + xAxis: { + type: 'category' as const, + data: xData, + axisLine: { + show: true, + lineStyle: { + color: '#999', + width: 1, + }, + }, + axisTick: { + show: false, + length: 5, + }, + axisLabel: { + interval: 0, + rotate: 30, + overflow: 'truncate', + ellipsis: '...', + hideOverlap: false, + formatter: (value: string) => { + const maxLength = (h || 0) <= 3 ? 2 : 8; + if (value.length > maxLength) { + return value.substring(0, maxLength) + '...'; + } + return value; + }, + }, + splitLine: { + show: false, + }, + }, + yAxis: { + type: 'value', + axisLine: { + show: true, + lineStyle: { + color: '#999', + width: 1, + }, + }, + axisTick: { + show: false, + length: 5, + }, + splitLine: { + show: false, + lineStyle: { + color: '#E5E7EB', + width: 1, + type: 'dashed', + }, + }, + }, + series: completeSeries, + grid: { + left: DEFAULT_GRID_PADDING, + right: DEFAULT_GRID_PADDING, + top: DEFAULT_GRID_PADDING_TOP, + bottom: DEFAULT_GRID_PADDING, + containLabel: true, + }, + }; + } + + protected buildSeries(series: ISeriesConfig[]): ISeriesConfig[] { + const { chartType } = this.storage; + const { appearance } = this.storage; + const { labelVisible } = appearance; + + return series.map((item: ISeriesConfig) => { + const { name, data } = item; + return { + name, + data, + type: this.echartsType as ChartType, + universalTransition: true, + areaStyle: chartType === ChartType.Area ? { opacity: 0.6 } : undefined, + label: { + show: labelVisible, + position: 'top', + distance: 5, + align: 'center', + verticalAlign: 'middle', + }, + } as ISeriesConfig; + }); + } +} diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/core/charts/PieChart.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/charts/PieChart.ts new file mode 100644 index 0000000000..4126bd8f0e --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/charts/PieChart.ts @@ -0,0 +1,135 @@ +import type { FieldRollup, ITableQuery } from '@teable/openapi'; +import { AGGREGATE_COUNT_KEY, DataSource } from '@teable/openapi'; +import { get } from 'lodash'; +import type { ISeriesConfig } from '../types'; +import { getFieldRollupKeyByFieldName, getGroupKeyName, isCountAllSeries } from '../utils'; +import { BaseChart, type IChartOptions } from './BaseChart'; + +export class PieChart extends BaseChart { + protected radiusConfig: string | string[] = '80%'; + + generateOptions(): IChartOptions { + const { legendVisible } = this.storage.appearance; + + return { + legend: { + show: legendVisible, + top: 0, + type: 'scroll', + orient: 'horizontal', + pageButtonPosition: 'end', + }, + tooltip: { + trigger: 'item', + confine: true, + textStyle: { + fontSize: this.getTooltipFontSize(), + }, + }, + series: this.buildSeries(), + } as IChartOptions; + } + + protected buildSeries(): ISeriesConfig[] { + const data = this.buildPieData(); + const { labelVisible } = this.storage.appearance; + return [ + { + name: this.getKey(), + type: 'pie', + // distance to legend + // center: ['50%', '60%'], + radius: this.radiusConfig, + data, + universalTransition: true, + emphasis: { + itemStyle: { + shadowBlur: 10, + shadowOffsetX: 0, + shadowColor: 'rgba(0, 0, 0, 0.5)', + }, + }, + label: { + show: labelVisible ?? true, + }, + }, + ]; + } + + protected buildPieData(): { value: number; name: string }[] { + const { rawData } = this.chartData; + const { dataSource, query } = this.storage; + + const key = this.getKey(); + + if (dataSource === DataSource.Table) { + return rawData.map((item) => { + const xAxis = (query as ITableQuery).xAxis; + const value = item[key]; + const finalName = + getGroupKeyName(this.fields.find((field) => field.id === xAxis)!, item[xAxis]) || 'null'; + return { + value, + name: finalName, + } as { value: number; name: string }; + }); + } + + const { xAxis } = this.storage.config; + return rawData.map((item) => { + const value = item[key]; + const name = item[xAxis]; + return { + value, + name, + } as { value: number; name: string }; + }); + } + + private getKey(): string { + const { dataSource } = this.storage; + if (dataSource === DataSource.Table) { + const { seriesArray } = this.storage.query as ITableQuery; + const fieldMap = this.getFieldMap(); + + if (isCountAllSeries(seriesArray)) { + return AGGREGATE_COUNT_KEY; + } else { + const fieldId = seriesArray.at(0)?.fieldId || ''; + const name = get(fieldMap, fieldId); + return ( + getFieldRollupKeyByFieldName( + this.fields, + name, + seriesArray.at(0)?.rollup as FieldRollup + ) || '' + ); + } + } + + const { yAxis } = this.storage.config; + return yAxis.at(0) as string; + } + + private getFieldMap(): Record { + return this.fields.reduce( + (acc, field) => { + acc[field.id] = field.name; + return acc; + }, + {} as Record + ); + } +} + +export class DonutChart extends PieChart { + protected radiusConfig = ['40%', '80%']; + + protected buildSeries(): ISeriesConfig[] { + const series = super.buildSeries(); + return series.map((s) => ({ + ...s, + avoidLabelOverlap: false, + })) as ISeriesConfig[]; + } +} diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/core/factory/ChartFactory.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/factory/ChartFactory.ts new file mode 100644 index 0000000000..71a3e64752 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/factory/ChartFactory.ts @@ -0,0 +1,92 @@ +import type { IFieldVo } from '@teable/core'; +import type { IChartStorage, IDashboardLayout, ISqlQuery, ITableQuery } from '@teable/openapi'; +import { ChartType, DataSource } from '@teable/openapi'; +import { getEchartsType, getChartConfigByType, getThemeByName } from '../../chart/utils'; +import type { BaseAdapter, IChartData } from '../adapters/BaseAdapter'; +import { SqlAdapter } from '../adapters/SqlAdapter'; +import { TableAdapter } from '../adapters/TableAdapter'; +import type { BaseChart, IChartOptions } from '../charts/BaseChart'; +import { CartesianChart } from '../charts/CartesianChart'; +import { PieChart, DonutChart } from '../charts/PieChart'; + +export class ChartFactory { + static createAdapter( + dataSource: DataSource, + storage: IChartStorage, + result: Record[], + fields?: IFieldVo[] + ): BaseAdapter { + switch (dataSource) { + case DataSource.Table: + return new TableAdapter(storage as IChartStorage, result, fields); + case DataSource.Sql: + return new SqlAdapter(storage as IChartStorage, result); + default: + throw new Error(`Unsupported data source: ${dataSource}`); + } + } + + static createChart( + chartType: ChartType, + storage: IChartStorage, + chartData: IChartData, + fields: IFieldVo[], + layout?: IDashboardLayout[number] + ): BaseChart { + const echartsType = getEchartsType(chartType); + + switch (chartType) { + case ChartType.Line: + case ChartType.Bar: + case ChartType.Area: + return new CartesianChart(storage, chartData, echartsType, fields, layout); + + case ChartType.Pie: + return new PieChart(storage, chartData, echartsType, fields, layout); + + case ChartType.DonutChart: + return new DonutChart(storage, chartData, echartsType, fields, layout); + + default: + throw new Error(`Unsupported chart type: ${chartType}`); + } + } + + static generateChartOptions( + storage: IChartStorage, + result: Record[], + fields?: IFieldVo[], + layout?: IDashboardLayout[number] + ): IChartOptions | null { + const { dataSource, chartType } = storage; + + if (!chartType || !result) { + return null; + } + + const adapter = this.createAdapter(dataSource, storage, result, fields); + const chartData = adapter.getData(); + + const chart = this.createChart( + chartType, + storage, + chartData, + (fields || []) as IFieldVo[], + layout + ); + const options = chart.generateOptions(); + + const baseConfig = this.getBaseConfig(storage); + + return { + ...baseConfig, + ...options, + }; + } + + private static getBaseConfig(storage: IChartStorage): IChartOptions { + const { chartType, appearance } = storage; + const theme = getThemeByName(appearance?.theme) || getThemeByName('default')!; + return getChartConfigByType(chartType, theme) as unknown as IChartOptions; + } +} diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/core/index.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/index.ts new file mode 100644 index 0000000000..7478315727 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/index.ts @@ -0,0 +1,16 @@ +/** + * 图表核心模块导出 + */ + +// 适配器 +export { BaseAdapter, type IChartData } from './adapters/BaseAdapter'; +export { TableAdapter } from './adapters/TableAdapter'; +export { SqlAdapter } from './adapters/SqlAdapter'; + +// 图表 +export { BaseChart, type IChartOptions } from './charts/BaseChart'; +export { CartesianChart } from './charts/CartesianChart'; +export { PieChart, DonutChart } from './charts/PieChart'; + +// 工厂 +export { ChartFactory } from './factory/ChartFactory'; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/core/types.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/types.ts new file mode 100644 index 0000000000..cf17dc7e50 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/types.ts @@ -0,0 +1,54 @@ +/** + * Unified format for chart data + */ +export interface IChartData { + /** X-axis data */ + xData: string[]; + /** Legend data */ + legendData: string[]; + /** Raw data */ + rawData: Record[]; + /** Field ID to name mapping (only needed for Table data source, not for SQL data source) */ + fieldMap?: Record; +} + +export interface ISeriesConfig { + name: string; + data: unknown[]; + type?: 'line' | 'bar' | 'pie'; + universalTransition?: boolean; + areaStyle?: { + opacity: number; + }; + label?: { + show?: boolean; + position?: + | 'top' + | 'bottom' + | 'left' + | 'right' + | 'inside' + | 'insideLeft' + | 'insideRight' + | 'insideTop' + | 'insideBottom' + | 'insideTopLeft' + | 'insideTopRight' + | 'insideBottomLeft' + | 'insideBottomRight'; + distance?: number; + align?: 'center' | 'left' | 'right'; + verticalAlign?: 'top' | 'bottom' | 'middle'; + formatter?: (value: string) => string; + }; + radius?: string | string[]; + avoidLabelOverlap?: boolean; + startAngle?: number; + emphasis?: { + itemStyle?: { + shadowBlur?: number; + shadowOffsetX?: number; + shadowColor?: string; + }; + }; +} diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/core/utils.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/utils.ts new file mode 100644 index 0000000000..a106200fd5 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/core/utils.ts @@ -0,0 +1,56 @@ +import { getFieldRollupKey, type IFieldVo } from '@teable/core'; +import { DEFAULT_SERIES_ARRAY, type IStatisticFieldItem } from '@teable/openapi'; +import { createFieldInstance } from '@teable/sdk/model'; +import { get } from 'lodash'; + +export const getGroupUniqueKey = (value: unknown) => { + if (value == null) { + return 'null'; + } + + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + if (value.length > 0 && typeof value[0] === 'object') { + return value.map((obj) => getObjectCellValueUniqueKey(obj)).join(','); + } + return value.join(','); + } + + if (typeof value === 'object') { + const objValue = value as Record; + return getObjectCellValueUniqueKey(objValue); + } + + return String(value); +}; + +const getObjectCellValueUniqueKey = (value: Record) => { + return get(value, 'id') || get(value, 'title') || get(value, 'name'); +}; + +export const getGroupKeyName = (field: IFieldVo, value: unknown) => { + const fieldInstance = createFieldInstance(field); + return fieldInstance.cellValue2String(value); +}; + +export const getFieldRollupKeyByFieldName = (fields: IFieldVo[], name: string, rollup: string) => { + const field = fields.find((field) => field.name === name); + + if (!field) { + return null; + } + + return getFieldRollupKey(field.id, rollup); +}; + +export const isCountAllSeries = ( + seriesArray: IStatisticFieldItem[] | string +): seriesArray is string => { + if (typeof seriesArray === 'string') { + return seriesArray === DEFAULT_SERIES_ARRAY; + } + return false; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/context.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/context.ts new file mode 100644 index 0000000000..3487b6064a --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/context.ts @@ -0,0 +1,11 @@ +import type { IParentBridgeMethods, IUIConfig } from '@teable/sdk/plugin-bridge'; +import React from 'react'; +import type { IPageParams } from '../../chart/types'; + +interface IPluginInstallContext { + parentBridgeMethods: IParentBridgeMethods; + pageParams: IPageParams; + uiConfig: IUIConfig; +} + +export const PluginInstallContext = React.createContext(null); diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/index.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/index.ts new file mode 100644 index 0000000000..2054cebb2b --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/index.ts @@ -0,0 +1,7 @@ +export * from './useStorage'; +export * from './usePluginParams'; +export * from './useBridge'; +export * from './context'; +export * from './useFields'; +export * from './useChartOption'; +export * from './useUiConfig'; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useBridge.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useBridge.ts new file mode 100644 index 0000000000..8d7847379d --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useBridge.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { PluginInstallContext } from './context'; + +export const useBridge = () => { + return useContext(PluginInstallContext)?.parentBridgeMethods; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useChartOption.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useChartOption.ts new file mode 100644 index 0000000000..2a993b0dc9 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useChartOption.ts @@ -0,0 +1,53 @@ +import { useQuery } from '@tanstack/react-query'; +import type { ITableQuery } from '@teable/openapi'; +import { DataSource, getFields } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { useMemo } from 'react'; +import { useBaseQueryData } from '../chart/hooks/useBaseQueryData'; +import { ChartFactory } from '../core/factory/ChartFactory'; +import { useLayout } from './useLayout'; +import { useStorage } from './useStorage'; + +export const useChartOption = () => { + const { storage } = useStorage(); + const { result } = useBaseQueryData() || {}; + const { dataSource, query } = storage; + const { layout } = useLayout(); + + const { data: fields = [], isLoading: isFieldsLoading } = useQuery({ + queryKey: ReactQueryKeys.fieldList((query as ITableQuery)?.tableId as string), + queryFn: () => getFields((query as ITableQuery)?.tableId as string).then((res) => res.data), + enabled: Boolean(dataSource === DataSource.Table && (query as ITableQuery)?.tableId), + meta: { preventGlobalError: true }, + }); + + return useMemo(() => { + if (result?.length === 0) { + return null; + } + + if (!result || !layout || (dataSource === DataSource.Table && isFieldsLoading)) { + return null; + } + + if ( + dataSource === DataSource.Table && + Array.isArray((query as ITableQuery)?.seriesArray) && + (query as ITableQuery)?.seriesArray.length === 0 + ) { + return null; + } + + try { + return ChartFactory.generateChartOptions( + storage, + result, + dataSource === DataSource.Table ? fields : undefined, + layout + ); + } catch (error) { + console.error('Failed to generate chart options:', error); + return null; + } + }, [result, isFieldsLoading, layout, query, storage, dataSource, fields]); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useEnv.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useEnv.ts new file mode 100644 index 0000000000..df35c6cf20 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useEnv.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { EnvContext } from '../../chart/components/EnvProvider'; + +export const useEnv = () => { + return useContext(EnvContext); +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useFields.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useFields.ts new file mode 100644 index 0000000000..ffadbc2753 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useFields.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@tanstack/react-query'; +import type { ITableQuery } from '@teable/openapi'; +import { getFields } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { useStorage } from './useStorage'; + +export const useFields = () => { + const { storage } = useStorage(); + const { query } = storage || {}; + const { tableId } = (query || {}) as ITableQuery; + + const { data: fields = [] } = useQuery({ + queryKey: ReactQueryKeys.fieldList(tableId), + queryFn: () => getFields(tableId).then((res) => res.data), + enabled: !!tableId, + meta: { preventGlobalError: true }, + }); + + return { fields }; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useLayout.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useLayout.ts new file mode 100644 index 0000000000..80ea4c669e --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useLayout.ts @@ -0,0 +1,38 @@ +import { useQuery } from '@tanstack/react-query'; +import { getDashboard, getPluginPanel, PluginPosition } from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { useMemo } from 'react'; +import { useEnv } from './useEnv'; + +export const useLayout = () => { + const { baseId, positionId, pluginInstallId, positionType, tableId } = useEnv(); + + const { data: dashboardData, isLoading: isDashboardLoading } = useQuery({ + queryKey: ReactQueryKeys.getDashboard(positionId), + queryFn: () => getDashboard(baseId, positionId).then((res) => res.data), + enabled: Boolean( + positionType === PluginPosition.Dashboard && baseId && positionId && pluginInstallId + ), + }); + + const { data: pluginPanelData, isLoading: isPluginPanelLoading } = useQuery({ + queryKey: ReactQueryKeys.getPluginPanel(tableId!, positionId), + queryFn: () => getPluginPanel(tableId!, positionId).then((res) => res.data), + enabled: Boolean( + positionType === PluginPosition.Panel && tableId && positionId && pluginInstallId + ), + }); + + const layout = useMemo(() => { + if (positionType === PluginPosition.Dashboard) { + return dashboardData?.layout?.find((item) => item.pluginInstallId === pluginInstallId); + } + return pluginPanelData?.layout?.find((item) => item.pluginInstallId === pluginInstallId); + }, [dashboardData?.layout, pluginInstallId, pluginPanelData?.layout, positionType]); + + return { + layout, + isLoading: + positionType === PluginPosition.Dashboard ? isDashboardLoading : isPluginPanelLoading, + }; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/usePluginInstall.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/usePluginInstall.ts new file mode 100644 index 0000000000..8ed76cc9bd --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/usePluginInstall.ts @@ -0,0 +1,106 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import type { IChartStorage, IPluginInstallStorage } from '@teable/openapi'; +import { + getDashboardInstallPlugin, + getPluginPanelPlugin, + PluginPosition, + renamePlugin, + updateDashboardPluginStorage, + updatePluginPanelStorage, +} from '@teable/openapi'; +import { ReactQueryKeys } from '@teable/sdk/config'; +import { useCallback } from 'react'; +import { useEnv } from './useEnv'; + +export const usePluginInstall = () => { + const queryClient = useQueryClient(); + const { baseId, positionId, positionType, tableId, pluginInstallId } = useEnv(); + + const { data: dashboardPluginInstall, isLoading: isDashboardPluginInstallLoading } = useQuery({ + queryKey: ReactQueryKeys.dashboardPluginInstall(baseId, positionId, pluginInstallId), + queryFn: () => + getDashboardInstallPlugin(baseId, positionId, pluginInstallId).then((res) => res.data), + enabled: Boolean( + positionType === PluginPosition.Dashboard && baseId && positionId && pluginInstallId + ), + }); + + const { data: pluginPanelPluginInstall, isLoading: isPluginPanelPluginLoading } = useQuery({ + queryKey: ReactQueryKeys.pluginPanelPluginInstall(tableId!, positionId, pluginInstallId), + queryFn: () => + getPluginPanelPlugin(tableId!, positionId, pluginInstallId).then((res) => res.data), + enabled: Boolean( + positionType === PluginPosition.Panel && tableId && positionId && pluginInstallId + ), + }); + + const { mutate: renamePluginMutate } = useMutation({ + mutationFn: (name: string) => renamePlugin(baseId, positionId, pluginInstallId, name), + onSuccess: () => { + queryClient.invalidateQueries(ReactQueryKeys.getDashboard(positionId)); + }, + }); + + const { mutate: updateDashboardPluginStorageMutate } = useMutation({ + mutationFn: (storage: IChartStorage) => + updateDashboardPluginStorage( + baseId, + positionId, + pluginInstallId, + storage as unknown as IPluginInstallStorage + ), + onSuccess: () => { + queryClient.invalidateQueries( + ReactQueryKeys.dashboardPluginInstall(baseId, positionId, pluginInstallId) + ); + queryClient.invalidateQueries( + ReactQueryKeys.dashboardPluginQueryV2(baseId, positionId, pluginInstallId) + ); + }, + }); + + const { mutate: updatePluginPanelPluginStorageMutate } = useMutation({ + mutationFn: (storage: IChartStorage) => + updatePluginPanelStorage(tableId!, positionId, pluginInstallId, { + storage, + } as unknown as IPluginInstallStorage), + onSuccess: () => { + queryClient.invalidateQueries( + ReactQueryKeys.pluginPanelPluginInstall(tableId!, positionId, pluginInstallId) + ); + queryClient.invalidateQueries( + ReactQueryKeys.pluginPanelPluginQueryV2(tableId!, positionId, pluginInstallId) + ); + }, + }); + + const updateDashboardPluginStorageFn = useCallback( + (storage: IChartStorage) => { + updateDashboardPluginStorageMutate(storage); + }, + [updateDashboardPluginStorageMutate] + ); + + const updatePluginPanelPluginStorageFn = useCallback( + (storage: IChartStorage) => { + updatePluginPanelPluginStorageMutate(storage); + }, + [updatePluginPanelPluginStorageMutate] + ); + + if (positionType === PluginPosition.Dashboard) { + return { + pluginInstall: dashboardPluginInstall, + isLoading: isDashboardPluginInstallLoading, + renamePlugin: renamePluginMutate, + updatePluginStorage: updateDashboardPluginStorageFn, + }; + } + + return { + pluginInstall: pluginPanelPluginInstall, + isLoading: isPluginPanelPluginLoading, + renamePlugin: renamePluginMutate, + updatePluginStorage: updatePluginPanelPluginStorageFn, + }; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/usePluginParams.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/usePluginParams.ts new file mode 100644 index 0000000000..3b13d32c7f --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/usePluginParams.ts @@ -0,0 +1,8 @@ +import { useContext } from 'react'; +import { PluginInstallContext } from './context'; + +export const usePluginParams = () => { + const context = useContext(PluginInstallContext); + const { pageParams } = context || {}; + return pageParams; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useStorage.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useStorage.ts new file mode 100644 index 0000000000..f48d128495 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useStorage.ts @@ -0,0 +1,49 @@ +import { ChartType, DataSource, type IChartStorage } from '@teable/openapi'; +import { produce } from 'immer'; +import { set } from 'lodash'; +import { useCallback } from 'react'; +import { usePluginInstall } from './usePluginInstall'; + +interface UpdateStorageByPath { + (path: string, value: unknown): void; + (updates: Array<{ path: string; value: unknown }>): void; +} + +export const useStorage = () => { + const { + pluginInstall, + updatePluginStorage, + isLoading: isPluginInstallLoading, + } = usePluginInstall(); + const { + storage = { + chartType: ChartType.Bar, + dataSource: DataSource.Table, + config: {}, + appearance: {}, + } as IChartStorage, + } = (pluginInstall || {}) as { storage: IChartStorage }; + + const updateStorageByPath = useCallback( + (pathOrUpdates: string | Array<{ path: string; value: unknown }>, value?: unknown) => { + const next = produce(storage ?? {}, (draft: IChartStorage) => { + if (typeof pathOrUpdates === 'string') { + set(draft, pathOrUpdates, value); + } else { + pathOrUpdates.forEach(({ path, value }) => { + set(draft, path, value); + }); + } + }); + updatePluginStorage(next); + }, + [storage, updatePluginStorage] + ) as UpdateStorageByPath; + + return { + storage, + updateStorageByPath, + updatePluginStorage, + isPluginInstallLoading, + }; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useUiConfig.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useUiConfig.ts new file mode 100644 index 0000000000..d4bb03bed7 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/hooks/useUiConfig.ts @@ -0,0 +1,6 @@ +import { useContext } from 'react'; +import { PluginInstallContext } from './context'; + +export const useUiConfig = () => { + return useContext(PluginInstallContext)?.uiConfig; +}; diff --git a/apps/nextjs-app/src/features/app/blocks/chart-v2/index.ts b/apps/nextjs-app/src/features/app/blocks/chart-v2/index.ts new file mode 100644 index 0000000000..07635cbbc8 --- /dev/null +++ b/apps/nextjs-app/src/features/app/blocks/chart-v2/index.ts @@ -0,0 +1 @@ +export * from './components'; diff --git a/apps/nextjs-app/src/features/app/blocks/dashboard/Dashboard.tsx b/apps/nextjs-app/src/features/app/blocks/dashboard/Dashboard.tsx deleted file mode 100644 index e537932405..0000000000 --- a/apps/nextjs-app/src/features/app/blocks/dashboard/Dashboard.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { getRandomString } from '@teable/core'; -import { useEffect, useRef, useState } from 'react'; -import type { Layout } from 'react-grid-layout'; -import { Responsive, WidthProvider } from 'react-grid-layout'; -import 'react-grid-layout/css/styles.css'; -import 'react-resizable/css/styles.css'; -import type { Bar } from '../../components/Chart/bar'; -import { Chart } from '../../components/Chart/Chart'; -import { createChart } from '../../components/Chart/createChart'; -import type { Line } from '../../components/Chart/line'; -import type { Pie } from '../../components/Chart/pie'; - -const ReactGridLayout = WidthProvider(Responsive); - -class DashboardCharts { - private initialized = false; - - charts: { [chartId: string]: { instance: Bar | Pie | Line } } = {}; - - init() { - if (this.initialized) { - return; - } - const chartsStr = localStorage.getItem('dashboard-charts'); - if (chartsStr) { - const chartsJSonMap = JSON.parse(chartsStr); - Object.keys(chartsJSonMap).forEach((key) => { - const { type, options, data } = chartsJSonMap[key].instance; - this.addChart(createChart(type, { options, data }), key); - }); - } - this.initialized = true; - } - - addChart(instance: Bar | Pie | Line, key?: string) { - this.charts[key || getRandomString(20)] = { instance }; - localStorage.setItem('dashboard-charts', JSON.stringify(this.charts)); - } -} - -export const dashboardCharts = new DashboardCharts(); - -interface ILayout extends Layout { - chartInstance: Bar | Pie | Line | undefined; -} - -export const Dashboard = () => { - const [layout, setLayout] = useState([]); - const [loading, setLoading] = useState(true); - const dashboardRef = useRef(null); - - useEffect(() => { - dashboardCharts.init(); - const values = Object.values(dashboardCharts.charts); - const chartLayout = values.map((item, i) => { - return { - x: ((values.length + i) * 6) % 12, - y: 0, - w: 6, - h: 12, - i: i.toString(), - chartInstance: item.instance, - static: Math.random() < 0.05, - }; - }); - setLayout(chartLayout); - setLoading(false); - }, []); - - useEffect(() => { - const resizeObserver = new ResizeObserver((entries) => { - entries.forEach(() => { - setTimeout(() => { - window.dispatchEvent(new Event('resize')); - }, 200); - }); - }); - - if (dashboardRef.current) { - resizeObserver.observe(dashboardRef.current); - } - - return () => { - resizeObserver.disconnect(); - }; - }, []); - - const layoutChange = (_currentLayout: Layout[], allLayouts: ReactGridLayout.Layouts) => { - const currentLayout = allLayouts['sm']; - if (!layout.length) { - return; - } - setLayout( - currentLayout.map((item: Layout, i) => { - return { - ...layout[i], - x: item.x, - y: item.y, - w: item.w, - h: item.h, - i: item.i, - static: item.static, - }; - }) - ); - }; - return ( -
- {!loading && ( - - {layout.map((v) => ( -
- {v.chartInstance && } -
- ))} -
- )} -
- ); -}; diff --git a/apps/nextjs-app/src/features/app/components/Chart/Chart.tsx b/apps/nextjs-app/src/features/app/components/Chart/Chart.tsx deleted file mode 100644 index 36abdb92fd..0000000000 --- a/apps/nextjs-app/src/features/app/components/Chart/Chart.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import * as echarts from 'echarts'; -import { useCallback, useEffect, useRef } from 'react'; -import type { Bar } from './bar'; -import type { Line } from './line'; -import type { Pie } from './pie'; - -export const Chart = (props: { chartInstance: Pie | Bar | Line }) => { - const { chartInstance } = props; - const chartContainerRef = useRef(null); - - const renderEcharts = useCallback( - ({ width, height }: { width: number; height: number }) => { - if (!chartContainerRef.current) { - return; - } - // eslint-disable-next-line import/namespace - const myChart = echarts.init(chartContainerRef.current); - myChart.setOption(chartInstance.getOptions()); - myChart.resize({ width, height }); - }, - [chartInstance] - ); - - useEffect(() => { - const resizeObserver = new ResizeObserver((entries) => { - entries.forEach((entry) => { - renderEcharts({ width: entry.contentRect.width, height: entry.contentRect.height }); - }); - }); - - if (chartContainerRef.current) { - resizeObserver.observe(chartContainerRef.current); - } - - return () => { - resizeObserver.disconnect(); - }; - }, [chartInstance, renderEcharts]); - - return ( -
- ); -}; diff --git a/apps/nextjs-app/src/features/app/components/Chart/bar.ts b/apps/nextjs-app/src/features/app/components/Chart/bar.ts deleted file mode 100644 index 904386956a..0000000000 --- a/apps/nextjs-app/src/features/app/components/Chart/bar.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { BarSeriesOption, EChartsOption } from 'echarts'; -import { Base } from './base'; -import { ChartType } from './type'; - -export class Bar extends Base { - type = ChartType.Bar; - - getOptions(): EChartsOption { - const _series = this.getSeries(); - const xAxisData: string[] = []; - const series: BarSeriesOption[] = []; - let first = true; - _series.forEach((seriesDataMap) => { - const seriesData: number[] = []; - Object.keys(seriesDataMap).forEach((key) => { - first && xAxisData.push(key); - seriesData.push(seriesDataMap[key]); - }); - series.push({ - type: ChartType.Bar, - data: seriesData, - }); - first = false; - }); - return { - tooltip: { - trigger: 'item', - }, - xAxis: { - type: 'category', - data: xAxisData, - }, - yAxis: { - type: 'value', - }, - series, - }; - } -} diff --git a/apps/nextjs-app/src/features/app/components/Chart/base.ts b/apps/nextjs-app/src/features/app/components/Chart/base.ts deleted file mode 100644 index 11de43ba94..0000000000 --- a/apps/nextjs-app/src/features/app/components/Chart/base.ts +++ /dev/null @@ -1,53 +0,0 @@ -import type { EChartsOption } from 'echarts'; -import { toNumber } from 'lodash'; -import type { ChartType, IChartData, IChartOptions, ISeries } from './type'; -import { Statistic } from './type'; - -export abstract class Base { - abstract type: ChartType; - options: IChartOptions; - data: IChartData; - constructor(options: IChartOptions, data: IChartData) { - this.options = options; - this.data = data; - } - abstract getOptions(): EChartsOption; - - private getSeriesMap(series: ISeries) { - const { statistic, xAxis } = this.options; - const xAxisFieldName = xAxis.fieldName; - const seriesFieldName = series.fieldName; - switch (statistic) { - case Statistic.Count: { - const valueMap: { [key: string]: number } = {}; - this.data.forEach((item) => { - const fieldValue = item[xAxisFieldName || seriesFieldName]?.toString(); - if (!fieldValue) { - return; - } - const count = valueMap[fieldValue] || 0; - valueMap[fieldValue] = count + 1; - }); - return valueMap; - } - case Statistic.Sum: { - const valueMap: { [key: string]: number } = {}; - this.data.forEach((item) => { - const fieldValue = item[xAxisFieldName || seriesFieldName]?.toString(); - if (!fieldValue) { - return; - } - const value = valueMap[fieldValue] || 0; - valueMap[fieldValue] = toNumber(item[seriesFieldName]) + value; - }); - return valueMap; - } - default: - return {}; - } - } - - getSeries() { - return this.options.series.map((item) => this.getSeriesMap(item)); - } -} diff --git a/apps/nextjs-app/src/features/app/components/Chart/createChart.ts b/apps/nextjs-app/src/features/app/components/Chart/createChart.ts deleted file mode 100644 index 999d55ef4a..0000000000 --- a/apps/nextjs-app/src/features/app/components/Chart/createChart.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { Bar } from './bar'; -import { Line } from './line'; -import { Pie } from './pie'; -import type { IChartData, IChartOptions } from './type'; -import { ChartType } from './type'; - -export const createChart = ( - type: ChartType, - context: { options: IChartOptions; data: IChartData } -) => { - const { options, data } = context; - switch (type) { - case ChartType.Bar: - return new Bar(options, data); - case ChartType.Pie: - return new Pie(options, data); - case ChartType.Line: - return new Line(options, data); - default: - throw new Error('Unknown chart type: ' + type); - } -}; diff --git a/apps/nextjs-app/src/features/app/components/Chart/line.tsx b/apps/nextjs-app/src/features/app/components/Chart/line.tsx deleted file mode 100644 index 71f640f5a0..0000000000 --- a/apps/nextjs-app/src/features/app/components/Chart/line.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import type { EChartsOption, LineSeriesOption } from 'echarts'; -import { Base } from './base'; -import { ChartType } from './type'; - -export class Line extends Base { - type = ChartType.Line; - - getOptions(): EChartsOption { - const seriesArr = this.getSeries(); - const xAxisData: string[] = []; - const series: LineSeriesOption[] = []; - let first = true; - seriesArr.forEach((seriesDataMap) => { - const seriesData: number[] = []; - Object.keys(seriesDataMap).forEach((key) => { - first && xAxisData.push(key); - seriesData.push(seriesDataMap[key]); - }); - series.push({ - type: ChartType.Line, - data: seriesData, - }); - first = false; - }); - return { - tooltip: { - trigger: 'item', - }, - xAxis: { - type: 'category', - data: xAxisData, - }, - yAxis: { - type: 'value', - }, - series, - }; - } -} diff --git a/apps/nextjs-app/src/features/app/components/Chart/pie.tsx b/apps/nextjs-app/src/features/app/components/Chart/pie.tsx deleted file mode 100644 index f06a68c92b..0000000000 --- a/apps/nextjs-app/src/features/app/components/Chart/pie.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { EChartsOption } from 'echarts'; -import { Base } from './base'; -import { ChartType } from './type'; - -export class Pie extends Base { - type = ChartType.Pie; - - getOptions(): EChartsOption { - const seriesDataMap = this.getSeries()[0] || {}; - const seriesData = Object.keys(seriesDataMap).map((key) => ({ - name: key, - value: seriesDataMap[key], - })); - - return { - tooltip: { - trigger: 'item', - }, - legend: { - left: 'center', - }, - series: { - type: ChartType.Pie, - radius: '60%', - data: seriesData, - }, - }; - } -} diff --git a/apps/nextjs-app/src/features/app/components/plugin/ComponentPluginRender.tsx b/apps/nextjs-app/src/features/app/components/plugin/ComponentPluginRender.tsx index 603755825a..af399eee22 100644 --- a/apps/nextjs-app/src/features/app/components/plugin/ComponentPluginRender.tsx +++ b/apps/nextjs-app/src/features/app/components/plugin/ComponentPluginRender.tsx @@ -7,6 +7,7 @@ import type { import { useMemo, useRef } from 'react'; import { Chart } from '../../blocks/chart/components/Chart'; import type { IPageParams } from '../../blocks/chart/types'; +import { ChartPage as ChartV2Page } from '../../blocks/chart-v2/components/ChartPage'; import type { IPluginParams } from './types'; type IBaseProps = { @@ -15,11 +16,19 @@ type IBaseProps = { uiEvent: IParentBridgeUIMethods; }; -type IComponentPluginRenderProps = IBaseProps & IPluginParams; +type IComponentPluginRenderProps = IBaseProps & IPluginParams & { dragging?: boolean }; export const ComponentPluginRender = (props: IComponentPluginRenderProps) => { - const { utilsEvent, uiEvent, uiConfig, positionType, pluginId, pluginInstallId, positionId } = - props; + const { + utilsEvent, + uiEvent, + uiConfig, + positionType, + pluginId, + pluginInstallId, + positionId, + dragging, + } = props; const baseId = 'baseId' in props ? props.baseId : ''; const tableId = 'tableId' in props ? props.tableId : ''; const pageParams: IPageParams = useMemo( @@ -43,6 +52,17 @@ export const ComponentPluginRender = (props: IComponentPluginRenderProps) => { ...uiEvent, }; + if (pluginId === 'plgchartV2') { + return ( + + ); + } + return ( {children} + + {t('common:pluginCenter.addPluginTitle')} +
{plugins?.map((plugin) => { const name = get(plugin.i18n, [language, 'name']) ?? plugin.name; @@ -77,7 +86,7 @@ export const PluginCenterDialog = forwardRef setDetailPlugin({ ...plugin, @@ -90,8 +99,8 @@ export const PluginCenterDialog = forwardRef + {t('common:pluginCenter.install')} diff --git a/apps/nextjs-app/src/features/app/components/plugin/PluginContent.tsx b/apps/nextjs-app/src/features/app/components/plugin/PluginContent.tsx index 3f6dd3da1f..1071c522e4 100644 --- a/apps/nextjs-app/src/features/app/components/plugin/PluginContent.tsx +++ b/apps/nextjs-app/src/features/app/components/plugin/PluginContent.tsx @@ -43,6 +43,7 @@ export const PluginContent = (props: IPluginContentProps) => { useSyncBasePermissions(bridge); useSyncSelection(bridge); useSyncUrlParams(bridge); + if (!iframeUrl) { return (
{ const { pluginUrl } = params; diff --git a/apps/nextjs-app/src/features/app/dashboard/DashboardHeader.tsx b/apps/nextjs-app/src/features/app/dashboard/DashboardHeader.tsx index 851590b866..ca05a3c841 100644 --- a/apps/nextjs-app/src/features/app/dashboard/DashboardHeader.tsx +++ b/apps/nextjs-app/src/features/app/dashboard/DashboardHeader.tsx @@ -88,7 +88,7 @@ export const DashboardHeader = (props: { dashboardId: string }) => { }; return ( -
+
{selectedDashboard?.name ? `${selectedDashboard?.name} - ${brandName}` : brandName} @@ -128,7 +128,7 @@ export const DashboardHeader = (props: { dashboardId: string }) => { <div className="flex items-center gap-2"> {canManage && ( <AddPluginDialog dashboardId={dashboardId}> - <Button variant={'outline'} size={'xs'}> + <Button size={'sm'} className="h-9 gap-2 px-3 text-sm"> <Plus /> {t('dashboard:addPlugin')} </Button> @@ -137,7 +137,7 @@ export const DashboardHeader = (props: { dashboardId: string }) => { {canManage && ( <DropdownMenu open={menuOpen} onOpenChange={setMenuOpen}> <DropdownMenuTrigger asChild> - <Button size="icon" variant="outline" className="size-7"> + <Button size="icon" variant="outline" className="size-9"> <MoreHorizontal className="size-3.5" /> </Button> </DropdownMenuTrigger> diff --git a/apps/nextjs-app/src/features/app/dashboard/DashboardMain.tsx b/apps/nextjs-app/src/features/app/dashboard/DashboardMain.tsx index 40d8fa7594..4f670e3581 100644 --- a/apps/nextjs-app/src/features/app/dashboard/DashboardMain.tsx +++ b/apps/nextjs-app/src/features/app/dashboard/DashboardMain.tsx @@ -6,6 +6,7 @@ import { useBaseId, useBasePermission } from '@teable/sdk/hooks'; import { Spin } from '@teable/ui-lib/base'; import { Button } from '@teable/ui-lib/shadcn'; import { isEmpty } from 'lodash'; +import Image from 'next/image'; import { useTranslation } from 'next-i18next'; import { dashboardConfig } from '@/features/i18n/dashboard.config'; import { AddPluginDialog } from './components/AddPluginDialog'; @@ -31,6 +32,12 @@ export const DashboardMain = (props: { dashboardId: string }) => { if (isEmpty(dashboardData?.pluginMap) && !isLoading) { return ( <div className="flex flex-1 flex-col items-center justify-center gap-3"> + <Image + src="/images/layout/empty-dashboard-light.png" + alt="Empty dashboard" + width={240} + height={240} + /> <p>{t('common:pluginCenter.pluginEmpty.title')}</p> {canManage && ( <AddPluginDialog dashboardId={dashboardId}> diff --git a/apps/nextjs-app/src/features/app/dashboard/EmptyDashboard.tsx b/apps/nextjs-app/src/features/app/dashboard/EmptyDashboard.tsx index d4b9365803..fe901cf0cc 100644 --- a/apps/nextjs-app/src/features/app/dashboard/EmptyDashboard.tsx +++ b/apps/nextjs-app/src/features/app/dashboard/EmptyDashboard.tsx @@ -16,7 +16,7 @@ export const EmptyDashboard = () => { const isDark = resolvedTheme === 'dark'; return ( - <div className="flex h-full flex-col items-center justify-center gap-5 px-20"> + <div className="flex h-full flex-col items-center justify-center gap-6 px-20"> <Image src={ isDark @@ -29,13 +29,13 @@ export const EmptyDashboard = () => { className="mb-6" /> <div className="text-center"> - <h3 className="mb-3 text-xl font-semibold text-foreground">{t('dashboard:empty.title')}</h3> + <h3 className="mb-2 text-xl font-semibold text-foreground">{t('dashboard:empty.title')}</h3> <p className="mb-6 max-w-md text-sm text-muted-foreground"> {t('dashboard:empty.description')} </p> {canManage && ( <CreateDashboardDialog> - <Button size="lg" className="px-8"> + <Button size="lg" className="px-4"> <Plus /> {t('dashboard:empty.create')} </Button> </CreateDashboardDialog> diff --git a/apps/nextjs-app/src/features/app/dashboard/Pages.tsx b/apps/nextjs-app/src/features/app/dashboard/Pages.tsx index 7db45a8519..673b93a5a3 100644 --- a/apps/nextjs-app/src/features/app/dashboard/Pages.tsx +++ b/apps/nextjs-app/src/features/app/dashboard/Pages.tsx @@ -6,11 +6,17 @@ import { Spin } from '@teable/ui-lib/base'; import { useRouter } from 'next/router'; import { useEffect } from 'react'; import { useInitializationZodI18n } from '../hooks/useInitializationZodI18n'; +import { DashboardContext } from './context'; import { DashboardHeader } from './DashboardHeader'; import { DashboardMain } from './DashboardMain'; import { EmptyDashboard } from './EmptyDashboard'; -export function DashboardPage() { +interface IDashboardPageProps { + chatComponent?: React.ReactNode; +} + +export function DashboardPage(props: IDashboardPageProps) { + const { chatComponent } = props; const baseId = useBaseId()!; const router = useRouter(); useInitializationZodI18n(); @@ -44,9 +50,11 @@ export function DashboardPage() { const dashboardId = dashboardQueryId ?? dashboardList?.[0]?.id; return ( - <div className="flex h-full flex-col"> - <DashboardHeader dashboardId={dashboardId} /> - <DashboardMain dashboardId={dashboardId} /> - </div> + <DashboardContext.Provider value={{ chatComponent }}> + <div className="flex h-full flex-col"> + <DashboardHeader dashboardId={dashboardId} /> + <DashboardMain dashboardId={dashboardId} /> + </div> + </DashboardContext.Provider> ); } diff --git a/apps/nextjs-app/src/features/app/dashboard/components/CreateDashboardDialog.tsx b/apps/nextjs-app/src/features/app/dashboard/components/CreateDashboardDialog.tsx index 9a0c210a6f..45fc1cd947 100644 --- a/apps/nextjs-app/src/features/app/dashboard/components/CreateDashboardDialog.tsx +++ b/apps/nextjs-app/src/features/app/dashboard/components/CreateDashboardDialog.tsx @@ -65,7 +65,9 @@ export const CreateDashboardDialog = forwardRef< <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger asChild>{props.children}</DialogTrigger> <DialogContent className="sm:max-w-[425px]"> - <DialogHeader>{t('dashboard:createDashboard.title')}</DialogHeader> + <DialogHeader className="text-lg font-semibold"> + {t('dashboard:createDashboard.title')} + </DialogHeader> <div> <Input placeholder={t('dashboard:createDashboard.placeholder')} @@ -74,12 +76,14 @@ export const CreateDashboardDialog = forwardRef< setError(undefined); setName(e.target.value); }} + className="px-2 py-[10px] placeholder:text-sm" /> <Error error={error} /> </div> <DialogFooter> <Button - size={'sm'} + size={'lg'} + className="px-4" onClick={() => { const valid = z .string() diff --git a/apps/nextjs-app/src/features/app/dashboard/components/DashboardSwitcher.tsx b/apps/nextjs-app/src/features/app/dashboard/components/DashboardSwitcher.tsx index 6d2883e230..a3281d7959 100644 --- a/apps/nextjs-app/src/features/app/dashboard/components/DashboardSwitcher.tsx +++ b/apps/nextjs-app/src/features/app/dashboard/components/DashboardSwitcher.tsx @@ -1,5 +1,5 @@ import { useQuery } from '@tanstack/react-query'; -import { Check, ChevronsUpDown, PlusCircle } from '@teable/icons'; +import { Check, ChevronDown, PlusCircle } from '@teable/icons'; import { getDashboardList } from '@teable/openapi'; import { ReactQueryKeys } from '@teable/sdk/config'; import { useBaseId, useBasePermission } from '@teable/sdk/hooks'; @@ -48,13 +48,13 @@ export const DashboardSwitcher = (props: { <Button variant="outline" role="combobox" - size={'sm'} + size={'lg'} aria-expanded={open} aria-label="Select a team" - className={cn('w-[200px] justify-between', className)} + className={cn('w-[200px] justify-between px-3 py-[10px]', className)} > <span className="truncate">{selectedDashboard?.name}</span> - <ChevronsUpDown className="ml-auto size-4 shrink-0 opacity-50" /> + <ChevronDown className="ml-auto size-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> <PopoverContent className="w-[200px] p-0"> diff --git a/apps/nextjs-app/src/features/app/dashboard/context.ts b/apps/nextjs-app/src/features/app/dashboard/context.ts new file mode 100644 index 0000000000..171758aaaf --- /dev/null +++ b/apps/nextjs-app/src/features/app/dashboard/context.ts @@ -0,0 +1,13 @@ +import { createContext, useContext } from 'react'; + +interface IDashboardContext { + chatComponent?: React.ReactNode; +} + +export const DashboardContext = createContext<IDashboardContext>({ + chatComponent: null, +}); + +export const useDashboardContext = () => { + return useContext(DashboardContext); +}; diff --git a/packages/common-i18n/src/locales/de/chart.json b/packages/common-i18n/src/locales/de/chart.json index be69642e04..20581c1fc8 100644 --- a/packages/common-i18n/src/locales/de/chart.json +++ b/packages/common-i18n/src/locales/de/chart.json @@ -16,6 +16,99 @@ "area": "Flächendiagramm", "table": "Tabelle" }, + "chartV2": { + "dataConfig": "Datenkonfiguration", + "chartAppearance": "Diagramm-Erscheinungsbild", + "goConfig": "Zur Konfiguration gehen", + "appearance": { + "title": "Diagramm-Erscheinungsbild", + "display": "Anzeige", + "theme": "Thema", + "legend": "Legende", + "coordinateAxis": "Koordinatenachse", + "label": "Beschriftung", + "showAxisLine": "Achsenlinie anzeigen", + "showAxisTick": "Achsenmarkierung anzeigen", + "showSplitLine": "Geteilte Linie anzeigen", + "backgroundColor": "Hintergrundfarbe", + "reset": "Zurücksetzen", + "style": "Stil", + "padding": "Abstand", + "left": "Links", + "right": "Rechts", + "bottom": "Unten", + "top": "Oben" + }, + "noData": "Keine Daten", + "form": { + "name": "Name", + "chartType": { + "title": "Diagrammtyp", + "bar": "Balkendiagramm", + "line": "Liniendiagramm", + "pie": "Kreisdiagramm", + "donutChart": "Kreisdiagramm", + "area": "Flächendiagramm", + "table": "Tabelle" + }, + "dataSource": { + "title": "Datenquelle", + "fromTable": "Von Tabelle", + "fromQuery": "Von SQL" + }, + "label": { + "table": "Tabelle", + "dataRange": "Datenbereich", + "view": "Ansicht", + "filter": "Filter" + }, + "axisConfig": { + "noCountFields": "Keine Count-Felder", + "defaultSeriesName": "Anzahl", + "title": "Achsenkonfiguration", + "xAxis": "X-Achse", + "yAxis": "Y-Achse", + "field": "Feld", + "Statistic": "Statistik nach", + "totalRecords": "Total Records", + "fieldValue": "Feldwert", + "groupBy": "Gruppieren nach", + "none": "Keine", + "groupAggregation": "Gruppenaggregation" + }, + "view": { + "allData": "Alle Daten" + }, + "dataSourceTitle": "Datenquelle", + "order": { + "orderBy": { + "title": "Sortieren nach", + "byXAxis": "Nach x-Achse", + "byYAxis": "Nach y-Achse" + }, + "orderType": { + "title": "Sortierungstyp", + "asc": "Aufsteigend", + "desc": "Absteigend" + } + }, + "filter": { + "title": "Filter Daten", + "addFilter": "Filter hinzufügen", + "cancel": "Abbrechen", + "confirm": "Bestätigen" + }, + "sql": { + "title": "Datenabfragekonfiguration", + "sqlEditor": "SQL-Editor", + "runTest": "Test ausführen", + "saveSql": "Speichern", + "aiGenerate": "AI generieren", + "resultPreview": "Ergebnisvorschau", + "addSeries": "Serie hinzufügen" + } + } + }, "form": { "chartType": { "placeholder": "Diagrammtyp auswählen", diff --git a/packages/common-i18n/src/locales/en/chart.json b/packages/common-i18n/src/locales/en/chart.json index 841dc3798f..fdcb1dac25 100644 --- a/packages/common-i18n/src/locales/en/chart.json +++ b/packages/common-i18n/src/locales/en/chart.json @@ -16,6 +16,99 @@ "area": "Area", "table": "Table" }, + "chartV2": { + "dataConfig": "Data Config", + "chartAppearance": "Chart Appearance", + "goConfig": "Go to configuration", + "appearance": { + "title": "Chart Appearance", + "display": "Display", + "theme": "Theme", + "legend": "Legend", + "coordinateAxis": "Coordinate axis", + "label": "Label", + "showAxisLine": "Show Axis Line", + "showAxisTick": "Show Axis Tick", + "showSplitLine": "Show Split Line", + "backgroundColor": "Background Color", + "reset": "Reset", + "style": "Style", + "padding": "Padding", + "left": "Left", + "right": "Right", + "bottom": "Bottom", + "top": "Top" + }, + "noData": "No data or configuration error", + "form": { + "name": "Name", + "chartType": { + "title": "Chart Type", + "bar": "Bar", + "line": "Line", + "pie": "Pie", + "donutChart": "Donut Chart", + "area": "Area", + "table": "Table" + }, + "dataSource": { + "title": "Data Source", + "fromTable": "From Table", + "fromQuery": "From SQL" + }, + "label": { + "table": "Table", + "dataRange": "Data Range", + "view": "view", + "filter": "filter" + }, + "axisConfig": { + "noCountFields": "No count fields", + "defaultSeriesName": "Count", + "title": "Axis Config", + "xAxis": "X Axis", + "yAxis": "Y Axis", + "field": "Field", + "Statistic": "Statistic by", + "totalRecords": "Total Records", + "fieldValue": "Field value", + "groupBy": "Group by", + "none": "None", + "groupAggregation": "Group Aggregation" + }, + "view": { + "allData": "All Data" + }, + "dataSourceTitle": "Data Source", + "order": { + "orderBy": { + "title": "Order By", + "byXAxis": "By x axis", + "byYAxis": "By y axis" + }, + "orderType": { + "title": "Order Type", + "asc": "Asc", + "desc": "Desc" + } + }, + "filter": { + "title": "Filter Data", + "addFilter": "Add Filter", + "cancel": "Cancel", + "confirm": "Confirm" + }, + "sql": { + "title": "Config data query", + "sqlEditor": "SQL Editor", + "runTest": "Run Test", + "saveSql": "Save", + "aiGenerate": "AI Generate", + "resultPreview": "Result Preview", + "addSeries": "Add a series" + } + } + }, "form": { "chartType": { "placeholder": "Select Chart Type", diff --git a/packages/common-i18n/src/locales/en/common.json b/packages/common-i18n/src/locales/en/common.json index a01efb47d0..a3d5da0b51 100644 --- a/packages/common-i18n/src/locales/en/common.json +++ b/packages/common-i18n/src/locales/en/common.json @@ -623,6 +623,7 @@ "description": "Data in the trash still occupies record usage and attachment usage." }, "pluginCenter": { + "addPluginTitle": "Add Plugin", "pluginUrlEmpty": "Plugin Not Setting url", "install": "Install", "publisher": "Publisher", diff --git a/packages/common-i18n/src/locales/es/chart.json b/packages/common-i18n/src/locales/es/chart.json index 8477e22563..18280bc39f 100644 --- a/packages/common-i18n/src/locales/es/chart.json +++ b/packages/common-i18n/src/locales/es/chart.json @@ -16,6 +16,99 @@ "area": "Área", "table": "Tabla" }, + "chartV2": { + "dataConfig": "Configuración de datos", + "chartAppearance": "Apariencia del gráfico", + "goConfig": "Ir a configuración", + "appearance": { + "title": "Apariencia del gráfico", + "display": "Mostrar", + "theme": "Tema", + "legend": "Leyenda", + "coordinateAxis": "Eje de coordenadas", + "label": "Etiqueta", + "showAxisLine": "Mostrar línea de eje", + "showAxisTick": "Mostrar marca de eje", + "showSplitLine": "Mostrar línea de división", + "backgroundColor": "Color de fondo", + "reset": "Restablecer", + "style": "Estilo", + "padding": "Relleno", + "left": "Izquierda", + "right": "Derecha", + "bottom": "Inferior", + "top": "Superior" + }, + "noData": "Sin datos", + "form": { + "name": "Nombre", + "chartType": { + "title": "Tipo de gráfico", + "bar": "Barras", + "line": "Líneas", + "pie": "Circular", + "donutChart": "Gráfico de anillo", + "area": "Área", + "table": "Tabla" + }, + "dataSource": { + "title": "Fuente de datos", + "fromTable": "Desde tabla", + "fromQuery": "Desde SQL" + }, + "label": { + "table": "Tabla", + "dataRange": "Rango de datos", + "view": "vista", + "filter": "filtro" + }, + "axisConfig": { + "noCountFields": "Sin campos de conteo", + "defaultSeriesName": "Conteo", + "title": "Configuración de ejes", + "xAxis": "Eje X", + "yAxis": "Eje Y", + "field": "Campo", + "Statistic": "Estadística por", + "totalRecords": "Total de registros", + "fieldValue": "Valor del campo", + "groupBy": "Agrupar por", + "none": "Ninguno", + "groupAggregation": "Agregación de grupo" + }, + "view": { + "allData": "Todos los datos" + }, + "dataSourceTitle": "Fuente de datos", + "order": { + "orderBy": { + "title": "Ordenar por", + "byXAxis": "Por eje x", + "byYAxis": "Por eje y" + }, + "orderType": { + "title": "Tipo de orden", + "asc": "Ascendente", + "desc": "Descendente" + } + }, + "filter": { + "title": "Filtrar datos", + "addFilter": "Añadir filtro", + "cancel": "Cancelar", + "confirm": "Confirmar" + }, + "sql": { + "title": "Configurar consulta de datos", + "sqlEditor": "Editor SQL", + "runTest": "Ejecutar prueba", + "saveSql": "Guardar", + "aiGenerate": "Generar con IA", + "resultPreview": "Vista previa de resultados", + "addSeries": "Añadir una serie" + } + } + }, "form": { "chartType": { "placeholder": "Seleccionar tipo de gráfico", diff --git a/packages/common-i18n/src/locales/fr/chart.json b/packages/common-i18n/src/locales/fr/chart.json index 443179c6ea..66b41558c2 100644 --- a/packages/common-i18n/src/locales/fr/chart.json +++ b/packages/common-i18n/src/locales/fr/chart.json @@ -16,6 +16,99 @@ "area": "Zone", "table": "Tableau" }, + "chartV2": { + "dataConfig": "Configuration des données", + "chartAppearance": "Apparence du graphique", + "goConfig": "Aller à la configuration", + "appearance": { + "title": "Apparence du graphique", + "display": "Affichage", + "theme": "Thème", + "legend": "Légende", + "coordinateAxis": "Axe de coordonnées", + "label": "Étiquette", + "showAxisLine": "Afficher la ligne d'axe", + "showAxisTick": "Afficher les graduations", + "showSplitLine": "Afficher la ligne de séparation", + "backgroundColor": "Couleur d'arrière-plan", + "reset": "Réinitialiser", + "style": "Style", + "padding": "Espacement", + "left": "Gauche", + "right": "Droite", + "bottom": "Bas", + "top": "Haut" + }, + "noData": "Aucune donnée", + "form": { + "name": "Nom", + "chartType": { + "title": "Type de graphique", + "bar": "Barres", + "line": "Ligne", + "pie": "Secteurs", + "donutChart": "Graphique en anneau", + "area": "Zone", + "table": "Tableau" + }, + "dataSource": { + "title": "Source de données", + "fromTable": "Depuis le tableau", + "fromQuery": "Depuis SQL" + }, + "label": { + "table": "Tableau", + "dataRange": "Plage de données", + "view": "vue", + "filter": "filtre" + }, + "axisConfig": { + "noCountFields": "Aucun champ de comptage", + "defaultSeriesName": "Compte", + "title": "Configuration des axes", + "xAxis": "Axe X", + "yAxis": "Axe Y", + "field": "Champ", + "Statistic": "Statistique par", + "totalRecords": "Total des enregistrements", + "fieldValue": "Valeur du champ", + "groupBy": "Grouper par", + "none": "Aucun", + "groupAggregation": "Agrégation de groupe" + }, + "view": { + "allData": "Toutes les données" + }, + "dataSourceTitle": "Source de données", + "order": { + "orderBy": { + "title": "Trier par", + "byXAxis": "Par axe x", + "byYAxis": "Par axe y" + }, + "orderType": { + "title": "Type de tri", + "asc": "Croissant", + "desc": "Décroissant" + } + }, + "filter": { + "title": "Filtrer les données", + "addFilter": "Ajouter un filtre", + "cancel": "Annuler", + "confirm": "Confirmer" + }, + "sql": { + "title": "Configurer la requête de données", + "sqlEditor": "Éditeur SQL", + "runTest": "Exécuter le test", + "saveSql": "Enregistrer", + "aiGenerate": "Générer avec IA", + "resultPreview": "Aperçu des résultats", + "addSeries": "Ajouter une série" + } + } + }, "form": { "chartType": { "placeholder": "Sélectionner le type de graphique", diff --git a/packages/common-i18n/src/locales/it/chart.json b/packages/common-i18n/src/locales/it/chart.json index 0053978321..db9cfdbd74 100644 --- a/packages/common-i18n/src/locales/it/chart.json +++ b/packages/common-i18n/src/locales/it/chart.json @@ -16,6 +16,99 @@ "area": "Area", "table": "Tabella" }, + "chartV2": { + "dataConfig": "Configurazione dati", + "chartAppearance": "Aspetto grafico", + "goConfig": "Vai alla configurazione", + "appearance": { + "title": "Aspetto grafico", + "display": "Visualizzazione", + "theme": "Tema", + "legend": "Leggenda", + "coordinateAxis": "Asse delle coordinate", + "label": "Etichetta", + "showAxisLine": "Mostra linea asse", + "showAxisTick": "Mostra tacche asse", + "showSplitLine": "Mostra linea di divisione", + "backgroundColor": "Colore di sfondo", + "reset": "Ripristina", + "style": "Stile", + "padding": "Spaziatura", + "left": "Sinistra", + "right": "Destra", + "bottom": "Basso", + "top": "Alto" + }, + "noData": "Nessun dato", + "form": { + "name": "Nome", + "chartType": { + "title": "Tipo di grafico", + "bar": "Barre", + "line": "Linee", + "pie": "Torta", + "donutChart": "Grafico ad anello", + "area": "Area", + "table": "Tabella" + }, + "dataSource": { + "title": "Origine dati", + "fromTable": "Da tabella", + "fromQuery": "Da SQL" + }, + "label": { + "table": "Tabella", + "dataRange": "Intervallo dati", + "view": "vista", + "filter": "filtro" + }, + "axisConfig": { + "noCountFields": "Nessun campo di conteggio", + "defaultSeriesName": "Conteggio", + "title": "Configurazione assi", + "xAxis": "Asse X", + "yAxis": "Asse Y", + "field": "Campo", + "Statistic": "Statistica per", + "totalRecords": "Record totali", + "fieldValue": "Valore del campo", + "groupBy": "Raggruppa per", + "none": "Nessuno", + "groupAggregation": "Aggregazione di gruppo" + }, + "view": { + "allData": "Tutti i dati" + }, + "dataSourceTitle": "Origine dati", + "order": { + "orderBy": { + "title": "Ordina per", + "byXAxis": "Per asse x", + "byYAxis": "Per asse y" + }, + "orderType": { + "title": "Tipo di ordinamento", + "asc": "Crescente", + "desc": "Decrescente" + } + }, + "filter": { + "title": "Filtra dati", + "addFilter": "Aggiungi filtro", + "cancel": "Annulla", + "confirm": "Conferma" + }, + "sql": { + "title": "Configura query dati", + "sqlEditor": "Editor SQL", + "runTest": "Esegui test", + "saveSql": "Salva", + "aiGenerate": "Genera con IA", + "resultPreview": "Anteprima risultati", + "addSeries": "Aggiungi una serie" + } + } + }, "form": { "chartType": { "placeholder": "Seleziona tipo di grafico", diff --git a/packages/common-i18n/src/locales/ja/chart.json b/packages/common-i18n/src/locales/ja/chart.json index 91b35c1aed..a952143dc4 100644 --- a/packages/common-i18n/src/locales/ja/chart.json +++ b/packages/common-i18n/src/locales/ja/chart.json @@ -16,6 +16,99 @@ "area": "エリアグラフ", "table": "テーブル" }, + "chartV2": { + "dataConfig": "データ設定", + "chartAppearance": "チャート外観", + "goConfig": "設定に移動", + "appearance": { + "title": "チャート外観", + "display": "表示", + "theme": "テーマ", + "legend": "凡例", + "coordinateAxis": "座標軸", + "label": "ラベル", + "showAxisLine": "軸線を表示", + "showAxisTick": "軸目盛を表示", + "showSplitLine": "分割線を表示", + "backgroundColor": "背景色", + "reset": "リセット", + "style": "スタイル", + "padding": "パディング", + "left": "左", + "right": "右", + "bottom": "下", + "top": "上" + }, + "noData": "データがありません", + "form": { + "name": "名前", + "chartType": { + "title": "チャートタイプ", + "bar": "棒グラフ", + "line": "折れ線グラフ", + "pie": "円グラフ", + "donutChart": "ドーナツグラフ", + "area": "エリアグラフ", + "table": "テーブル" + }, + "dataSource": { + "title": "データソース", + "fromTable": "テーブルから", + "fromQuery": "SQLから" + }, + "label": { + "table": "テーブル", + "dataRange": "データ範囲", + "view": "ビュー", + "filter": "フィルター" + }, + "axisConfig": { + "noCountFields": "カウントフィールドなし", + "defaultSeriesName": "カウント", + "title": "軸設定", + "xAxis": "X軸", + "yAxis": "Y軸", + "field": "フィールド", + "Statistic": "統計", + "totalRecords": "総レコード数", + "fieldValue": "フィールド値", + "groupBy": "グループ化", + "none": "なし", + "groupAggregation": "グループ集計" + }, + "view": { + "allData": "すべてのデータ" + }, + "dataSourceTitle": "データソース", + "order": { + "orderBy": { + "title": "並べ替え", + "byXAxis": "X軸で", + "byYAxis": "Y軸で" + }, + "orderType": { + "title": "並べ替えタイプ", + "asc": "昇順", + "desc": "降順" + } + }, + "filter": { + "title": "データフィルター", + "addFilter": "フィルターを追加", + "cancel": "キャンセル", + "confirm": "確認" + }, + "sql": { + "title": "データクエリ設定", + "sqlEditor": "SQLエディター", + "runTest": "テスト実行", + "saveSql": "保存", + "aiGenerate": "AI生成", + "resultPreview": "結果プレビュー", + "addSeries": "系列を追加" + } + } + }, "form": { "chartType": { "placeholder": "チャートタイプを選択", diff --git a/packages/common-i18n/src/locales/ru/chart.json b/packages/common-i18n/src/locales/ru/chart.json index 58228a83a2..e635e303eb 100644 --- a/packages/common-i18n/src/locales/ru/chart.json +++ b/packages/common-i18n/src/locales/ru/chart.json @@ -16,6 +16,99 @@ "area": "Область", "table": "Таблица" }, + "chartV2": { + "dataConfig": "Конфигурация данных", + "chartAppearance": "Внешний вид диаграммы", + "goConfig": "Перейти к конфигурации", + "appearance": { + "title": "Внешний вид диаграммы", + "display": "Отображение", + "theme": "Тема", + "legend": "Легенда", + "coordinateAxis": "Координатная ось", + "label": "Метка", + "showAxisLine": "Показать линию оси", + "showAxisTick": "Показать деление оси", + "showSplitLine": "Показать разделительную линию", + "backgroundColor": "Цвет фона", + "reset": "Сбросить", + "style": "Стиль", + "padding": "Отступ", + "left": "Слева", + "right": "Справа", + "bottom": "Снизу", + "top": "Сверху" + }, + "noData": "Нет данных", + "form": { + "name": "Название", + "chartType": { + "title": "Тип диаграммы", + "bar": "Столбчатая", + "line": "Линейная", + "pie": "Круговая", + "donutChart": "Кольцевая диаграмма", + "area": "Область", + "table": "Таблица" + }, + "dataSource": { + "title": "Источник данных", + "fromTable": "Из таблицы", + "fromQuery": "Из SQL" + }, + "label": { + "table": "Таблица", + "dataRange": "Диапазон данных", + "view": "представление", + "filter": "фильтр" + }, + "axisConfig": { + "noCountFields": "Нет полей подсчета", + "defaultSeriesName": "Количество", + "title": "Настройка осей", + "xAxis": "Ось X", + "yAxis": "Ось Y", + "field": "Поле", + "Statistic": "Статистика по", + "totalRecords": "Всего записей", + "fieldValue": "Значение поля", + "groupBy": "Группировать по", + "none": "Нет", + "groupAggregation": "Групповая агрегация" + }, + "view": { + "allData": "Все данные" + }, + "dataSourceTitle": "Источник данных", + "order": { + "orderBy": { + "title": "Сортировать по", + "byXAxis": "По оси x", + "byYAxis": "По оси y" + }, + "orderType": { + "title": "Тип сортировки", + "asc": "По возрастанию", + "desc": "По убыванию" + } + }, + "filter": { + "title": "Фильтр данных", + "addFilter": "Добавить фильтр", + "cancel": "Отмена", + "confirm": "Подтвердить" + }, + "sql": { + "title": "Настроить запрос данных", + "sqlEditor": "SQL-редактор", + "runTest": "Запустить тест", + "saveSql": "Сохранить", + "aiGenerate": "Генерация ИИ", + "resultPreview": "Предпросмотр результата", + "addSeries": "Добавить серию" + } + } + }, "form": { "chartType": { "placeholder": "Выберите тип диаграммы", diff --git a/packages/common-i18n/src/locales/tr/chart.json b/packages/common-i18n/src/locales/tr/chart.json index bec04bf6ef..75bbcbe38c 100644 --- a/packages/common-i18n/src/locales/tr/chart.json +++ b/packages/common-i18n/src/locales/tr/chart.json @@ -16,6 +16,99 @@ "area": "Alan", "table": "Tablo" }, + "chartV2": { + "dataConfig": "Veri Yapılandırması", + "chartAppearance": "Grafik Görünümü", + "goConfig": "Yapılandırmaya git", + "appearance": { + "title": "Grafik Görünümü", + "display": "Görünüm", + "theme": "Tema", + "legend": "Açıklama", + "coordinateAxis": "Koordinat ekseni", + "label": "Etiket", + "showAxisLine": "Eksen Çizgisini Göster", + "showAxisTick": "Eksen İşaretini Göster", + "showSplitLine": "Bölme Çizgisini Göster", + "backgroundColor": "Arka Plan Rengi", + "reset": "Sıfırla", + "style": "Stil", + "padding": "Dolgu", + "left": "Sol", + "right": "Sağ", + "bottom": "Alt", + "top": "Üst" + }, + "noData": "Veri yok", + "form": { + "name": "İsim", + "chartType": { + "title": "Grafik Türü", + "bar": "Çubuk", + "line": "Çizgi", + "pie": "Pasta", + "donutChart": "Halka Grafik", + "area": "Alan", + "table": "Tablo" + }, + "dataSource": { + "title": "Veri Kaynağı", + "fromTable": "Tablodan", + "fromQuery": "SQL'den" + }, + "label": { + "table": "Tablo", + "dataRange": "Veri Aralığı", + "view": "görünüm", + "filter": "filtre" + }, + "axisConfig": { + "noCountFields": "Sayım alanı yok", + "defaultSeriesName": "Sayım", + "title": "Eksen Yapılandırması", + "xAxis": "X Ekseni", + "yAxis": "Y Ekseni", + "field": "Alan", + "Statistic": "İstatistik", + "totalRecords": "Toplam Kayıt", + "fieldValue": "Alan değeri", + "groupBy": "Gruplandır", + "none": "Yok", + "groupAggregation": "Grup Toplamı" + }, + "view": { + "allData": "Tüm Veri" + }, + "dataSourceTitle": "Veri Kaynağı", + "order": { + "orderBy": { + "title": "Sıralama Ölçütü", + "byXAxis": "X eksenine göre", + "byYAxis": "Y eksenine göre" + }, + "orderType": { + "title": "Sıralama Türü", + "asc": "Artan", + "desc": "Azalan" + } + }, + "filter": { + "title": "Veriyi Filtrele", + "addFilter": "Filtre Ekle", + "cancel": "İptal", + "confirm": "Onayla" + }, + "sql": { + "title": "Veri sorgusunu yapılandır", + "sqlEditor": "SQL Editörü", + "runTest": "Test Çalıştır", + "saveSql": "Kaydet", + "aiGenerate": "AI Üret", + "resultPreview": "Sonuç Önizlemesi", + "addSeries": "Seri ekle" + } + } + }, "form": { "chartType": { "placeholder": "Grafik Türünü Seçin", diff --git a/packages/common-i18n/src/locales/uk/chart.json b/packages/common-i18n/src/locales/uk/chart.json index 30117a30cd..528cad6f80 100644 --- a/packages/common-i18n/src/locales/uk/chart.json +++ b/packages/common-i18n/src/locales/uk/chart.json @@ -16,6 +16,99 @@ "area": "Область", "table": "Таблиця" }, + "chartV2": { + "dataConfig": "Конфігурація даних", + "chartAppearance": "Зовнішній вигляд діаграми", + "goConfig": "Перейти до конфігурації", + "appearance": { + "title": "Зовнішній вигляд діаграми", + "display": "Відображення", + "theme": "Тема", + "legend": "Легенда", + "coordinateAxis": "Координатна вісь", + "label": "Мітка", + "showAxisLine": "Показати лінію осі", + "showAxisTick": "Показати поділку осі", + "showSplitLine": "Показати роздільну лінію", + "backgroundColor": "Колір фону", + "reset": "Скинути", + "style": "Стиль", + "padding": "Відступ", + "left": "Ліворуч", + "right": "Праворуч", + "bottom": "Знизу", + "top": "Зверху" + }, + "noData": "Немає даних", + "form": { + "name": "Назва", + "chartType": { + "title": "Тип діаграми", + "bar": "Стовпчикова", + "line": "Лінійна", + "pie": "Кругова", + "donutChart": "Кільцева діаграма", + "area": "Область", + "table": "Таблиця" + }, + "dataSource": { + "title": "Джерело даних", + "fromTable": "З таблиці", + "fromQuery": "З SQL" + }, + "label": { + "table": "Таблиця", + "dataRange": "Діапазон даних", + "view": "вигляд", + "filter": "фільтр" + }, + "axisConfig": { + "noCountFields": "Немає полів підрахунку", + "defaultSeriesName": "Кількість", + "title": "Налаштування осей", + "xAxis": "Вісь X", + "yAxis": "Вісь Y", + "field": "Поле", + "Statistic": "Статистика за", + "totalRecords": "Всього записів", + "fieldValue": "Значення поля", + "groupBy": "Групувати за", + "none": "Немає", + "groupAggregation": "Групова агрегація" + }, + "view": { + "allData": "Всі дані" + }, + "dataSourceTitle": "Джерело даних", + "order": { + "orderBy": { + "title": "Сортувати за", + "byXAxis": "За віссю x", + "byYAxis": "За віссю y" + }, + "orderType": { + "title": "Тип сортування", + "asc": "За зростанням", + "desc": "За спаданням" + } + }, + "filter": { + "title": "Фільтр даних", + "addFilter": "Додати фільтр", + "cancel": "Скасувати", + "confirm": "Підтвердити" + }, + "sql": { + "title": "Налаштувати запит даних", + "sqlEditor": "SQL-редактор", + "runTest": "Запустити тест", + "saveSql": "Зберегти", + "aiGenerate": "Генерація ШІ", + "resultPreview": "Попередній перегляд результату", + "addSeries": "Додати серію" + } + } + }, "form": { "chartType": { "placeholder": "Оберіть тип діаграми", diff --git a/packages/common-i18n/src/locales/zh/chart.json b/packages/common-i18n/src/locales/zh/chart.json index 71c7618434..65f767f7d9 100644 --- a/packages/common-i18n/src/locales/zh/chart.json +++ b/packages/common-i18n/src/locales/zh/chart.json @@ -16,6 +16,99 @@ "area": "区域图", "table": "表格" }, + "chartV2": { + "dataConfig": "数据配置", + "chartAppearance": "图表外观", + "goConfig": "前往配置", + "appearance": { + "title": "图表外观", + "display": "显示", + "theme": "主题", + "legend": "图例", + "coordinateAxis": "坐标轴", + "label": "标签", + "showAxisLine": "显示轴线", + "showAxisTick": "显示刻度", + "showSplitLine": "显示分割线", + "backgroundColor": "背景颜色", + "reset": "重置", + "style": "样式", + "padding": "内边距", + "left": "左", + "right": "右", + "bottom": "下", + "top": "上" + }, + "noData": "无数据或配置参数错误", + "form": { + "name": "名称", + "chartType": { + "title": "图表类型", + "bar": "柱状图", + "line": "折线图", + "pie": "饼图", + "donutChart": "环形图", + "area": "区域图", + "table": "表格" + }, + "dataSource": { + "title": "数据源", + "fromTable": "从表格", + "fromQuery": "从SQL" + }, + "label": { + "table": "表格", + "dataRange": "数据范围", + "view": "视图", + "filter": "筛选器" + }, + "axisConfig": { + "noCountFields": "无计数字段", + "defaultSeriesName": "计数", + "title": "坐标轴配置", + "xAxis": "X轴", + "yAxis": "Y轴", + "field": "字段", + "Statistic": "统计方式", + "totalRecords": "总记录数", + "fieldValue": "字段值", + "groupBy": "分组依据", + "none": "无", + "groupAggregation": "分组聚合" + }, + "view": { + "allData": "全部数据" + }, + "dataSourceTitle": "数据源", + "order": { + "orderBy": { + "title": "排序依据", + "byXAxis": "按X轴", + "byYAxis": "按Y轴" + }, + "orderType": { + "title": "排序类型", + "asc": "升序", + "desc": "降序" + } + }, + "filter": { + "title": "筛选数据", + "addFilter": "添加筛选器", + "cancel": "取消", + "confirm": "确认" + }, + "sql": { + "title": "配置数据查询", + "sqlEditor": "SQL编辑器", + "runTest": "运行测试", + "saveSql": "保存", + "aiGenerate": "AI生成", + "resultPreview": "结果预览", + "addSeries": "添加系列" + } + } + }, "form": { "chartType": { "placeholder": "选择图表类型", diff --git a/packages/common-i18n/src/locales/zh/common.json b/packages/common-i18n/src/locales/zh/common.json index 344d1291b2..cd567ba9af 100644 --- a/packages/common-i18n/src/locales/zh/common.json +++ b/packages/common-i18n/src/locales/zh/common.json @@ -620,6 +620,7 @@ "description": "回收站中的数据依然会占用记录用量和附件用量。" }, "pluginCenter": { + "addPluginTitle": "添加插件", "pluginUrlEmpty": "插件未设置 URL", "install": "安装", "publisher": "发布者", diff --git a/packages/core/src/chart/index.ts b/packages/core/src/chart/index.ts new file mode 100644 index 0000000000..04bca77e0d --- /dev/null +++ b/packages/core/src/chart/index.ts @@ -0,0 +1 @@ +export * from './utils'; diff --git a/packages/core/src/chart/utils.ts b/packages/core/src/chart/utils.ts new file mode 100644 index 0000000000..2628a279af --- /dev/null +++ b/packages/core/src/chart/utils.ts @@ -0,0 +1,3 @@ +export const getFieldRollupKey = (fieldId: string, rollup: string) => { + return `${fieldId}_${rollup}`; +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e71d545d2c..fcdfdbdf99 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,3 +16,4 @@ export * from './formula'; export * from './query'; export * from './errors'; export * from './auth'; +export * from './chart'; diff --git a/packages/core/src/models/view/group/group.ts b/packages/core/src/models/view/group/group.ts index f8cab1b9f7..54ab45120d 100644 --- a/packages/core/src/models/view/group/group.ts +++ b/packages/core/src/models/view/group/group.ts @@ -36,7 +36,7 @@ export const groupStringSchema = z.string().transform((val, ctx) => { export function parseGroup(queryGroup?: IGroup): IGroup | undefined { if (queryGroup == null) return; - const parsedGroup = groupSchema.safeParse(queryGroup); + return parsedGroup.success ? parsedGroup.data?.slice(0, 3) : undefined; } diff --git a/packages/icons/src/components/AreaChart.tsx b/packages/icons/src/components/AreaChart.tsx new file mode 100644 index 0000000000..1b3e911b2c --- /dev/null +++ b/packages/icons/src/components/AreaChart.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const AreaChart = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="1em" + height="1em" + fill="none" + viewBox="0 0 40 40" + {...props} + > + <path + d="M26 19C18.5 19 20 8 14 8C8.14849 8.00009 9.9059 19.4132 3.50345 19.9782C3.22837 20.0024 3 20.2237 3 20.4998V36H37V12C30 12 32.5 19 26 19Z" + fill="url(#paint0_linear_488_16583)" + /> + <path + d="M14.3633 6.76074C16.1422 6.86888 17.3987 7.76961 18.3301 8.94043C19.2624 10.1125 19.9428 11.6574 20.5654 12.9619C21.2267 14.3474 21.8534 15.5426 22.6982 16.4072C23.4881 17.2155 24.4854 17.75 26 17.75C27.3723 17.75 28.1416 17.3912 28.6777 16.9453C29.2752 16.4483 29.6854 15.7718 30.2441 14.8516C30.7656 13.9927 31.4191 12.9207 32.4795 12.1035C33.5848 11.2518 35.022 10.75 37 10.75C37.6904 10.75 38.25 11.3096 38.25 12C38.25 12.6904 37.6904 13.25 37 13.25C35.4781 13.25 34.6026 13.6233 34.0049 14.084C33.3623 14.5793 32.9218 15.2575 32.3809 16.1484C31.8772 16.978 31.2559 18.0517 30.2754 18.8672C29.2334 19.7337 27.8776 20.25 26 20.25C23.7648 20.25 22.1368 19.4093 20.9111 18.1553C19.7406 16.9575 18.9607 15.4023 18.3096 14.0381C17.6197 12.5927 17.0813 11.3875 16.373 10.4971C15.7663 9.73432 15.114 9.30539 14.1885 9.25488L14 9.25C13.0287 9.25002 12.3736 9.68765 11.7676 10.5781C11.1071 11.5486 10.6316 12.8739 10.0449 14.4385C9.49218 15.9124 8.83942 17.5981 7.80859 18.9004C6.71835 20.2775 5.18852 21.25 3 21.25C2.30969 21.25 1.75008 20.6903 1.75 20C1.75 19.3096 2.30964 18.75 3 18.75C4.31148 18.75 5.15665 18.2215 5.84766 17.3486C6.59783 16.4011 7.13292 15.0872 7.70508 13.5615C8.24334 12.1262 8.83042 10.4513 9.70117 9.17188C10.6264 7.8124 11.9713 6.75003 14 6.75L14.3633 6.76074Z" + fill="#27272A" + /> + <defs> + <linearGradient + id="paint0_linear_488_16583" + x1="20" + y1="-2.2439" + x2="20" + y2="36" + gradientUnits="userSpaceOnUse" + > + <stop stopColor="#D4D4D8" /> + <stop offset="1" stopColor="#FAFAFA" /> + </linearGradient> + </defs> + </svg> +); +export default AreaChart; diff --git a/packages/icons/src/components/ColumnChart.tsx b/packages/icons/src/components/ColumnChart.tsx new file mode 100644 index 0000000000..66f93741fc --- /dev/null +++ b/packages/icons/src/components/ColumnChart.tsx @@ -0,0 +1,26 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const ColumnChart = (props: SVGProps<SVGSVGElement>) => ( + <svg + width="1em" + height="1em" + viewBox="0 0 40 40" + fill="none" + xmlns="http://www.w3.org/2000/svg" + {...props} + > + <path + d="M4 6C4 4.89543 4.89543 4 6 4H10C11.1046 4 12 4.89543 12 6V35C12 35.5523 11.5523 36 11 36H5C4.44772 36 4 35.5523 4 35V6Z" + fill="#27272A" + /> + <path + d="M16 23.6001C16 22.4955 16.8954 21.6001 18 21.6001H22C23.1046 21.6001 24 22.4955 24 23.6001V35.0001C24 35.5524 23.5523 36.0001 23 36.0001H17C16.4477 36.0001 16 35.5524 16 35.0001V23.6001Z" + fill="#D4D4D8" + /> + <path + d="M28 14.7998C28 13.6952 28.8954 12.7998 30 12.7998H34C35.1046 12.7998 36 13.6952 36 14.7998V34.9998C36 35.5521 35.5523 35.9998 35 35.9998H29C28.4477 35.9998 28 35.5521 28 34.9998V14.7998Z" + fill="#D4D4D8" + /> + </svg> +); +export default ColumnChart; diff --git a/packages/icons/src/components/DonutChart.tsx b/packages/icons/src/components/DonutChart.tsx new file mode 100644 index 0000000000..a2d1f6f874 --- /dev/null +++ b/packages/icons/src/components/DonutChart.tsx @@ -0,0 +1,15 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const DonutChart = (props: SVGProps<SVGSVGElement>) => ( + <svg + viewBox="0 0 1025 1024" + xmlns="http://www.w3.org/2000/svg" + width="1em" + height="1em" + {...props} + > + <path d="M753.664 511.104a240.64 240.64 0 0 1-52.992 151.04l100.992 101.056A384.128 384.128 0 0 0 538.432 128v142.912a241.728 241.728 0 0 1 215.232 240.192z"></path> + <path d="M512 752.768a241.728 241.728 0 0 1-26.56-481.92V128a384.064 384.064 0 1 0 278.848 672.704l-101.056-101.12A240.512 240.512 0 0 1 512 752.768z"></path> + </svg> +); +export default DonutChart; diff --git a/packages/icons/src/components/LineChart.tsx b/packages/icons/src/components/LineChart.tsx new file mode 100644 index 0000000000..4e95a69318 --- /dev/null +++ b/packages/icons/src/components/LineChart.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const LineChart = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="1em" + height="1em" + fill="none" + viewBox="0 0 40 40" + {...props} + > + <path + d="M23.6289 14.0008C24.1824 12.962 25.6831 12.9481 26.2559 13.9764L26.3105 14.0829L31.1387 24.4999H35C35.5523 24.4999 36 24.9476 36 25.4999C36 26.0521 35.5523 26.4999 35 26.4999H30.8193C30.2351 26.4999 29.7037 26.1607 29.458 25.6307L24.96 15.9266L18.248 31.1493C17.7935 32.1796 16.4076 32.3597 15.7041 31.4803L10.5195 24.9999H5C4.44774 24.9999 4.00004 24.5521 4 23.9999C4 23.4476 4.44772 22.9999 5 22.9999H10.7598C11.2152 22.9999 11.646 23.2068 11.9307 23.5624L16.75 29.5868L23.5771 14.1083L23.6289 14.0008Z" + fill="#D4D4D8" + /> + <path + d="M15.3936 8.28313C16.1973 7.11851 18.005 7.35976 18.4756 8.69427L18.5186 8.83098L24.3867 30.6298L30.2568 19.6738L30.3174 19.5693C30.6359 19.0618 31.1951 18.7499 31.7998 18.7499H37C37.6904 18.7499 38.25 19.3096 38.25 19.9999C38.25 20.6903 37.6904 21.2499 37 21.2499H32.249L25.6973 33.4784C24.9488 34.8753 22.8771 34.6375 22.4648 33.1074L16.5713 11.2148L11.2246 20.3818C10.911 20.9194 10.3353 21.2499 9.71289 21.2499H3C2.30967 21.2499 1.75005 20.6902 1.75 19.9999C1.75 19.3096 2.30964 18.7499 3 18.7499H9.28223L15.3174 8.40423L15.3936 8.28313Z" + fill="#27272A" + /> + </svg> +); +export default LineChart; diff --git a/packages/icons/src/components/PieChart.tsx b/packages/icons/src/components/PieChart.tsx new file mode 100644 index 0000000000..36a04f92e6 --- /dev/null +++ b/packages/icons/src/components/PieChart.tsx @@ -0,0 +1,22 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const PieChart = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="1em" + height="1em" + fill="none" + viewBox="0 0 40 40" + {...props} + > + <path + d="M19.0811 22H34.8672C33.8894 29.3387 27.6061 35 20 35C11.7157 35 5 28.2843 5 20C5 16.168 6.43729 12.6719 8.80176 10.0205L19.0811 22Z" + fill="#D4D4D8" + /> + <path + d="M34.9998 20C34.9998 17.1306 34.1768 14.3214 32.6284 11.9057C31.0801 9.48992 28.8713 7.56896 26.2641 6.37066C23.6569 5.17235 20.7607 4.74699 17.919 5.14503C15.0774 5.54307 12.4095 6.7478 10.2319 8.61633L19.7005 19.6512C19.8905 19.8726 20.1677 20 20.4594 20H34.9998Z" + fill="#27272A" + /> + </svg> +); +export default PieChart; diff --git a/packages/icons/src/components/TableChart.tsx b/packages/icons/src/components/TableChart.tsx new file mode 100644 index 0000000000..92821a0e03 --- /dev/null +++ b/packages/icons/src/components/TableChart.tsx @@ -0,0 +1,23 @@ +import * as React from 'react'; +import type { SVGProps } from 'react'; +const TableChart = (props: SVGProps<SVGSVGElement>) => ( + <svg + xmlns="http://www.w3.org/2000/svg" + width="1em" + height="1em" + fill="none" + viewBox="0 0 40 40" + {...props} + > + <rect x="3" y="3" width="34" height="34" rx="4" fill="#E4E4E7" /> + <path + d="M31 8.5C31.8284 8.5 32.5 9.17157 32.5 10C32.5 10.8284 31.8284 11.5 31 11.5H9C8.17157 11.5 7.5 10.8284 7.5 10C7.5 9.17157 8.17157 8.5 9 8.5H31Z" + fill="#27272A" + /> + <path + d="M12 27.5C12.8284 27.5 13.5 28.1716 13.5 29C13.5 29.8284 12.8284 30.5 12 30.5H9C8.17157 30.5 7.5 29.8284 7.5 29C7.5 28.1716 8.17157 27.5 9 27.5H12ZM31 27.5C31.8284 27.5 32.5 28.1716 32.5 29C32.5 29.8284 31.8284 30.5 31 30.5H17C16.1716 30.5 15.5 29.8284 15.5 29C15.5 28.1716 16.1716 27.5 17 27.5H31ZM12 21.5C12.8284 21.5 13.5 22.1716 13.5 23C13.5 23.8284 12.8284 24.5 12 24.5H9C8.17157 24.5 7.5 23.8284 7.5 23C7.5 22.1716 8.17157 21.5 9 21.5H12ZM31 21.5C31.8284 21.5 32.5 22.1716 32.5 23C32.5 23.8284 31.8284 24.5 31 24.5H17C16.1716 24.5 15.5 23.8284 15.5 23C15.5 22.1716 16.1716 21.5 17 21.5H31ZM12 15.5C12.8284 15.5 13.5 16.1716 13.5 17C13.5 17.8284 12.8284 18.5 12 18.5H9C8.17157 18.5 7.5 17.8284 7.5 17C7.5 16.1716 8.17157 15.5 9 15.5H12ZM31 15.5C31.8284 15.5 32.5 16.1716 32.5 17C32.5 17.8284 31.8284 18.5 31 18.5H17C16.1716 18.5 15.5 17.8284 15.5 17C15.5 16.1716 16.1716 15.5 17 15.5H31Z" + fill="#A1A1AA" + /> + </svg> +); +export default TableChart; diff --git a/packages/icons/src/index.ts b/packages/icons/src/index.ts index 0623ec84a7..d6fed0f56b 100644 --- a/packages/icons/src/index.ts +++ b/packages/icons/src/index.ts @@ -185,3 +185,9 @@ export { default as AmazonBedrock } from './components/AmazonBedrock'; export { default as TeableAi } from './components/TeableAi'; export { default as Compose } from './components/Compose'; export { default as MousePointerClick } from './components/MousePointerClick'; +export { default as DonutChart } from './components/DonutChart'; +export { default as ColumnChart } from './components/ColumnChart'; +export { default as LineChart } from './components/LineChart'; +export { default as AreaChart } from './components/AreaChart'; +export { default as TableChart } from './components/TableChart'; +export { default as PieChart } from './components/PieChart'; diff --git a/packages/openapi/src/plugin/chart/constant.ts b/packages/openapi/src/plugin/chart/constant.ts new file mode 100644 index 0000000000..652031f9ea --- /dev/null +++ b/packages/openapi/src/plugin/chart/constant.ts @@ -0,0 +1 @@ +export const AGGREGATE_COUNT_KEY = 'aggregate_count'; diff --git a/packages/openapi/src/plugin/chart/dashboard-query-v2.ts b/packages/openapi/src/plugin/chart/dashboard-query-v2.ts new file mode 100644 index 0000000000..e17c2598b8 --- /dev/null +++ b/packages/openapi/src/plugin/chart/dashboard-query-v2.ts @@ -0,0 +1,149 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import type { IFilter } from '@teable/core'; +import { axios } from '../../axios'; +import { registerRoute, urlBuilder } from '../../utils'; +import { z } from '../../zod'; + +export const GET_DASHBOARD_INSTALL_PLUGIN_QUERY_V2 = + '/plugin/chart/{pluginInstallId}/dashboard/{positionId}/query/v2'; + +export const baseQuerySchemaVoV2 = z.object({ + result: z.array(z.record(z.string(), z.unknown())), + columns: z.array( + z.object({ + name: z.string(), + isNumber: z.boolean(), + }) + ), +}); + +export const DEFAULT_SERIES_ARRAY = 'COUNTA'; + +export type IBaseQueryVoV2 = z.infer<typeof baseQuerySchemaVoV2>; + +export enum THEMES_KEYS { + BLUE = 'blue', + GREEN = 'green', + NEUTRAL = 'neutral', + ORANGE = 'orange', + RED = 'red', + ROSE = 'rose', + VIOLET = 'violet', + YELLOW = 'yellow', +} + +export enum ChartType { + Bar = 'bar', + Pie = 'pie', + Line = 'line', + Area = 'area', + DonutChart = 'donutChart', +} + +export enum DataSource { + Table = 'table', + Sql = 'sql', +} + +export enum FieldRollup { + Sum = 'sum', + Avg = 'avg', + Min = 'min', + Max = 'max', + Count = 'count', +} + +export interface IStatisticFieldItem { + fieldId: string; + rollup: FieldRollup; +} + +export interface ITableQuery { + tableId: string; + viewId: string; + orderBy: { + on: string; + order: 'asc' | 'desc'; + }; + filter: IFilter; + groupBy: string | null; + xAxis: string; + seriesArray: string | IStatisticFieldItem[]; +} + +interface IBaseAppearance { + theme: string; +} + +export interface IChartAppearance extends IBaseAppearance { + legendVisible: boolean; + labelVisible: boolean; + padding?: { + top?: number; + right?: number; + bottom?: number; + left?: number; + }; +} + +export interface ISqlQuery { + sql: string | null; +} + +export interface IBaseConfig { + [key: string]: unknown; +} +export interface ISqlConfig extends IBaseConfig { + xAxis: string; + yAxis: string[]; +} + +export interface IChartStorage<T extends ITableQuery | ISqlQuery = ITableQuery | ISqlQuery> { + chartType: ChartType; + dataSource: DataSource; + query: T; + // config not make the result change + config: ISqlConfig; + appearance: IChartAppearance; +} + +export const GetDashboardInstallPluginQueryV2Route: RouteConfig = registerRoute({ + method: 'get', + path: GET_DASHBOARD_INSTALL_PLUGIN_QUERY_V2, + description: 'Get a dashboard install plugin query by id', + request: { + params: z.object({ + pluginInstallId: z.string(), + positionId: z.string(), + }), + query: z.object({ + baseId: z.string(), + }), + }, + responses: { + 200: { + description: 'Returns data about the dashboard install plugin query.', + content: { + 'application/json': { + schema: baseQuerySchemaVoV2, + }, + }, + }, + }, + tags: ['plugin', 'chart', 'dashboard'], +}); + +export const getDashboardInstallPluginQueryV2 = async ( + pluginInstallId: string, + positionId: string, + baseId: string +) => { + return axios.get<IBaseQueryVoV2>( + urlBuilder(GET_DASHBOARD_INSTALL_PLUGIN_QUERY_V2, { pluginInstallId, positionId }), + { + params: { + baseId, + }, + } + ); +}; diff --git a/packages/openapi/src/plugin/chart/dashboard-sql-test.ts b/packages/openapi/src/plugin/chart/dashboard-sql-test.ts new file mode 100644 index 0000000000..c004ea275e --- /dev/null +++ b/packages/openapi/src/plugin/chart/dashboard-sql-test.ts @@ -0,0 +1,52 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../../axios'; +import { registerRoute, urlBuilder } from '../../utils'; +import { z } from '../../zod'; +import { baseQuerySchemaVoV2, type IBaseQueryVoV2 } from './dashboard-query-v2'; + +export const GET_DASHBOARD_INSTALL_TEST_SQL = '/plugin/chart/test-sql'; + +export const testSqlRoSchema = z.object({ + sql: z.string().nullable(), + baseId: z.string(), +}); + +export type ITestSqlRo = z.infer<typeof testSqlRoSchema>; + +export const GetDashboardInstallPluginSqlRoute: RouteConfig = registerRoute({ + method: 'post', + path: GET_DASHBOARD_INSTALL_TEST_SQL, + description: 'Get a dashboard install plugin query by id', + request: { + params: z.object({ + pluginInstallId: z.string(), + positionId: z.string(), + baseId: z.string(), + }), + body: { + content: { + 'application/json': { + schema: testSqlRoSchema, + }, + }, + }, + }, + responses: { + 200: { + description: 'Returns data about the dashboard install plugin query.', + content: { + 'application/json': { + schema: baseQuerySchemaVoV2, + }, + }, + }, + }, + tags: ['plugin', 'chart', 'dashboard'], +}); + +export const getDashboardTestSqlResult = async (baseId: string, sql: string) => { + return axios.post<IBaseQueryVoV2>(urlBuilder(GET_DASHBOARD_INSTALL_TEST_SQL), { + sql, + baseId, + }); +}; diff --git a/packages/openapi/src/plugin/chart/get-base-table-schema.ts b/packages/openapi/src/plugin/chart/get-base-table-schema.ts new file mode 100644 index 0000000000..d1554b7635 --- /dev/null +++ b/packages/openapi/src/plugin/chart/get-base-table-schema.ts @@ -0,0 +1,36 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../../axios'; +import { registerRoute, urlBuilder } from '../../utils'; +import { z } from '../../zod'; + +export const GET_BASE_TABLE_SCHEMA = '/plugin/chart/{baseId}/schema'; + +export const baseTableSchemaVo = z.record(z.string(), z.array(z.string())); + +export type IBaseTableSchemaVo = z.infer<typeof baseTableSchemaVo>; + +export const GetBaseTableSchemaRoute: RouteConfig = registerRoute({ + method: 'get', + path: GET_BASE_TABLE_SCHEMA, + description: 'Get table schema by base id', + request: { + params: z.object({ + baseId: z.string(), + }), + }, + responses: { + 200: { + description: 'Returns schema of all tables in the base.', + content: { + 'application/json': { + schema: baseTableSchemaVo, + }, + }, + }, + }, + tags: ['plugin', 'chart'], +}); + +export const getBaseTableSchema = async (baseId: string) => { + return axios.get<IBaseTableSchemaVo>(urlBuilder(GET_BASE_TABLE_SCHEMA, { baseId })); +}; diff --git a/packages/openapi/src/plugin/chart/index.ts b/packages/openapi/src/plugin/chart/index.ts index 046d1a4232..557d81d5a2 100644 --- a/packages/openapi/src/plugin/chart/index.ts +++ b/packages/openapi/src/plugin/chart/index.ts @@ -1,2 +1,7 @@ export * from './dashboard-query'; export * from './plugin-panel-query'; +export * from './dashboard-query-v2'; +export * from './dashboard-sql-test'; +export * from './get-base-table-schema'; +export * from './constant'; +export * from './plugin-panel-query-v2'; diff --git a/packages/openapi/src/plugin/chart/plugin-panel-query-v2.ts b/packages/openapi/src/plugin/chart/plugin-panel-query-v2.ts new file mode 100644 index 0000000000..3bf1956884 --- /dev/null +++ b/packages/openapi/src/plugin/chart/plugin-panel-query-v2.ts @@ -0,0 +1,49 @@ +import type { RouteConfig } from '@asteasolutions/zod-to-openapi'; +import { axios } from '../../axios'; +import { registerRoute, urlBuilder } from '../../utils'; +import { z } from '../../zod'; +import { baseQuerySchemaVoV2, type IBaseQueryVoV2 } from './dashboard-query-v2'; + +export const GET_PLUGIN_PANEL_INSTALL_PLUGIN_QUERY_V2 = + '/plugin/chart/{pluginInstallId}/plugin-panel/{positionId}/query/v2'; + +export const GetPluginPanelInstallPluginQueryV2Route: RouteConfig = registerRoute({ + method: 'get', + path: GET_PLUGIN_PANEL_INSTALL_PLUGIN_QUERY_V2, + description: 'Get a plugin panel install plugin query by id', + request: { + params: z.object({ + pluginInstallId: z.string(), + positionId: z.string(), + }), + query: z.object({ + tableId: z.string(), + }), + }, + responses: { + 200: { + description: 'Returns data about the plugin panel install plugin query.', + content: { + 'application/json': { + schema: baseQuerySchemaVoV2, + }, + }, + }, + }, + tags: ['plugin', 'chart', 'plugin-panel'], +}); + +export const getPluginPanelInstallPluginQueryV2 = async ( + pluginInstallId: string, + positionId: string, + tableId: string +) => { + return axios.get<IBaseQueryVoV2>( + urlBuilder(GET_PLUGIN_PANEL_INSTALL_PLUGIN_QUERY_V2, { pluginInstallId, positionId }), + { + params: { + tableId, + }, + } + ); +}; diff --git a/packages/sdk/src/components/filter/view-filter/component/base/BaseSingleSelect.tsx b/packages/sdk/src/components/filter/view-filter/component/base/BaseSingleSelect.tsx index 0eb3c14f16..5321834c39 100644 --- a/packages/sdk/src/components/filter/view-filter/component/base/BaseSingleSelect.tsx +++ b/packages/sdk/src/components/filter/view-filter/component/base/BaseSingleSelect.tsx @@ -93,6 +93,7 @@ function BaseSingleSelect<V extends string, O extends IOption<V> = IOption<V>>( setOpen(false); return; } + console.log('ggggggg111', option); onSelect(option.value); setOpen(false); }} diff --git a/packages/sdk/src/components/filter/view-filter/hooks/useViewFilterLinkContext.ts b/packages/sdk/src/components/filter/view-filter/hooks/useViewFilterLinkContext.ts index 1a7253f59a..24c655101c 100644 --- a/packages/sdk/src/components/filter/view-filter/hooks/useViewFilterLinkContext.ts +++ b/packages/sdk/src/components/filter/view-filter/hooks/useViewFilterLinkContext.ts @@ -8,9 +8,9 @@ import { useViewListener } from '../../../../hooks'; export const useViewFilterLinkContext = ( tableId: string | undefined, viewId: string | undefined, - config: { disabled?: boolean } + config: { disabled?: boolean; preventGlobalError?: boolean } ) => { - const { disabled } = config; + const { disabled, preventGlobalError } = config; const queryClient = useQueryClient(); const enabledQuery = Boolean(!disabled && tableId && viewId); @@ -19,6 +19,7 @@ export const useViewFilterLinkContext = ( queryFn: ({ queryKey }) => getViewFilterLinkRecords(queryKey[1], queryKey[2]).then((data) => data.data), enabled: enabledQuery, + meta: { preventGlobalError }, }); const updateContext = useCallback(() => { diff --git a/packages/sdk/src/config/react-query-keys.ts b/packages/sdk/src/config/react-query-keys.ts index ab5d315c8d..3c314e7663 100644 --- a/packages/sdk/src/config/react-query-keys.ts +++ b/packages/sdk/src/config/react-query-keys.ts @@ -222,4 +222,16 @@ export const ReactQueryKeys = { oauthAppList: () => ['oauth-app-list'] as const, oauthApp: (clientId: string) => ['oauth-app', clientId] as const, + + dashboardPluginQueryV2: (baseId: string, positionId: string, pluginInstallId: string) => + ['dashboard-plugin-query-v2', baseId, positionId, pluginInstallId] as const, + + pluginPanelPluginQueryV2: (tableId: string, positionId: string, pluginInstallId: string) => + ['plugin-panel-plugin-query-v2', tableId, positionId, pluginInstallId] as const, + + dashboardPluginInstall: (baseId: string, positionId: string, pluginInstallId: string) => + ['dashboard-plugin-install', baseId, positionId, pluginInstallId] as const, + + pluginPanelPluginInstall: (tableId: string, positionId: string, pluginInstallId: string) => + ['plugin-panel-plugin-install', tableId, positionId, pluginInstallId] as const, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 61cef4b160..7245192afb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -279,6 +279,9 @@ importers: http-proxy-middleware: specifier: 3.0.3 version: 3.0.3 + immer: + specifier: 10.0.4 + version: 10.0.4 ioredis: specifier: 5.4.1 version: 5.4.1 @@ -637,15 +640,24 @@ importers: '@codemirror/commands': specifier: 6.3.3 version: 6.3.3 + '@codemirror/lang-javascript': + specifier: 6.2.4 + version: 6.2.4 '@codemirror/lang-json': specifier: 6.0.1 version: 6.0.1 + '@codemirror/lang-sql': + specifier: 6.10.0 + version: 6.10.0(@codemirror/view@6.26.0) '@codemirror/language': specifier: 6.10.1 version: 6.10.1 '@codemirror/lint': specifier: 6.8.2 version: 6.8.2 + '@codemirror/search': + specifier: 6.5.11 + version: 6.5.11 '@codemirror/state': specifier: 6.4.1 version: 6.4.1 @@ -736,6 +748,9 @@ importers: '@teable/ui-lib': specifier: workspace:^ version: link:../../packages/ui-lib + '@uiw/codemirror-theme-vscode': + specifier: 4.25.3 + version: 4.25.3(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.26.0) allotment: specifier: 1.20.0 version: 1.20.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) @@ -778,6 +793,9 @@ importers: i18next: specifier: 23.10.1 version: 23.10.1 + immer: + specifier: 10.0.4 + version: 10.0.4 is-port-reachable: specifier: 3.1.0 version: 3.1.0 @@ -3381,15 +3399,24 @@ packages: '@codemirror/commands@6.3.3': resolution: {integrity: sha512-dO4hcF0fGT9tu1Pj1D2PvGvxjeGkbC6RGcZw6Qs74TH+Ed1gw98jmUgd2axWvIZEqTeTuFrg1lEB1KV6cK9h1A==} + '@codemirror/lang-javascript@6.2.4': + resolution: {integrity: sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==} + '@codemirror/lang-json@6.0.1': resolution: {integrity: sha512-+T1flHdgpqDDlJZ2Lkil/rLiRy684WMLc74xUnjJH48GQdfJo/pudlTRreZmKwzP8/tGdKf83wlbAdOCzlJOGQ==} + '@codemirror/lang-sql@6.10.0': + resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} + '@codemirror/language@6.10.1': resolution: {integrity: sha512-5GrXzrhq6k+gL5fjkAwt90nYDmjlzTIJV8THnxNFtNKWotMIlzzN+CpqxqwXOECnUdOndmSeWntVrVcv5axWRQ==} '@codemirror/lint@6.8.2': resolution: {integrity: sha512-PDFG5DjHxSEjOXk9TQYYVjZDqlZTFaDBfhQixHnQOEVDDNHUbEh/hstAjcQJaA6FQdZTD1hquXTK0rVBLADR1g==} + '@codemirror/search@6.5.11': + resolution: {integrity: sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==} + '@codemirror/state@6.4.1': resolution: {integrity: sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==} @@ -4706,6 +4733,9 @@ packages: '@lezer/highlight@1.2.0': resolution: {integrity: sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==} + '@lezer/javascript@1.5.4': + resolution: {integrity: sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==} + '@lezer/json@1.0.3': resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} @@ -8409,6 +8439,16 @@ packages: '@udecode/utils@47.2.7': resolution: {integrity: sha512-tQ8tIcdW+ZqWWrDgyf/moTLWtcErcHxaOfuCD/6qIL5hCq+jZm67nGHQToOT4Czti5Jr7CDPMgr8lYpdTEZcew==} + '@uiw/codemirror-theme-vscode@4.25.3': + resolution: {integrity: sha512-4bKsR1P3Lit/ycJKIOeK1/w28Y8oxhGekLxQw93/2CYXH/B/MyUs1y1vmGCCL5Cq1qIZ+w2nFvXbZYELKFiwXw==} + + '@uiw/codemirror-themes@4.25.3': + resolution: {integrity: sha512-k7/B7Vf4jU/WcdewgJWP9tMFxbjB6UpUymZ3fx/TsbGwt2JXAouw0uyqCn1RlYBfr7YQnvEs3Ju9ECkd2sKzdg==} + peerDependencies: + '@codemirror/language': '>=6.0.0' + '@codemirror/state': '>=6.0.0' + '@codemirror/view': '>=6.0.0' + '@ungap/structured-clone@1.2.1': resolution: {integrity: sha512-fEzPV3hSkSMltkw152tJKNARhOupqbH96MZWyRjNaYZOMIzbrTeQDG+MTc6Mr2pgzFQzFxAfmhGDNP5QK++2ZA==} @@ -8685,7 +8725,6 @@ packages: '@univerjs/sheets-thread-comment-base@0.3.0': resolution: {integrity: sha512-JNAMb52Nqf6KY5Usa3tTqilM0lz76xXEFnkDCOqt6Zv7rbLOpgKw1j9MriEov9lCQVFSnmTmabTOdR7iOXzW6w==} - deprecated: Package no longer supported. peerDependencies: '@univerjs/core': 0.3.0 '@univerjs/engine-formula': 0.3.0 @@ -13998,7 +14037,6 @@ packages: multer@1.4.4-lts.1: resolution: {integrity: sha512-WeSGziVj6+Z2/MwQo3GvqzgR+9Uc+qt8SwHKh3gvNPiISKfsMfG4SvCOFYlxxgkXt7yIV2i1yczehm0EOKIxIg==} engines: {node: '>= 6.0.0'} - deprecated: Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version. multer@1.4.5-lts.1: resolution: {integrity: sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ==} @@ -20463,11 +20501,32 @@ snapshots: '@codemirror/view': 6.26.0 '@lezer/common': 1.2.3 + '@codemirror/lang-javascript@6.2.4': + dependencies: + '@codemirror/autocomplete': 6.15.0(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.26.0)(@lezer/common@1.2.3) + '@codemirror/language': 6.10.1 + '@codemirror/lint': 6.8.2 + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.26.0 + '@lezer/common': 1.2.3 + '@lezer/javascript': 1.5.4 + '@codemirror/lang-json@6.0.1': dependencies: '@codemirror/language': 6.10.1 '@lezer/json': 1.0.3 + '@codemirror/lang-sql@6.10.0(@codemirror/view@6.26.0)': + dependencies: + '@codemirror/autocomplete': 6.15.0(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.26.0)(@lezer/common@1.2.3) + '@codemirror/language': 6.10.1 + '@codemirror/state': 6.4.1 + '@lezer/common': 1.2.3 + '@lezer/highlight': 1.2.0 + '@lezer/lr': 1.4.2 + transitivePeerDependencies: + - '@codemirror/view' + '@codemirror/language@6.10.1': dependencies: '@codemirror/state': 6.4.1 @@ -20483,6 +20542,12 @@ snapshots: '@codemirror/view': 6.26.0 crelt: 1.0.6 + '@codemirror/search@6.5.11': + dependencies: + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.26.0 + crelt: 1.0.6 + '@codemirror/state@6.4.1': {} '@codemirror/view@6.26.0': @@ -21530,6 +21595,12 @@ snapshots: dependencies: '@lezer/common': 1.2.3 + '@lezer/javascript@1.5.4': + dependencies: + '@lezer/common': 1.2.3 + '@lezer/highlight': 1.2.0 + '@lezer/lr': 1.4.2 + '@lezer/json@1.0.3': dependencies: '@lezer/common': 1.2.3 @@ -26392,6 +26463,20 @@ snapshots: '@udecode/utils@47.2.7': {} + '@uiw/codemirror-theme-vscode@4.25.3(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.26.0)': + dependencies: + '@uiw/codemirror-themes': 4.25.3(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.26.0) + transitivePeerDependencies: + - '@codemirror/language' + - '@codemirror/state' + - '@codemirror/view' + + '@uiw/codemirror-themes@4.25.3(@codemirror/language@6.10.1)(@codemirror/state@6.4.1)(@codemirror/view@6.26.0)': + dependencies: + '@codemirror/language': 6.10.1 + '@codemirror/state': 6.4.1 + '@codemirror/view': 6.26.0 + '@ungap/structured-clone@1.2.1': {} '@univerjs/core@0.3.0(@grpc/grpc-js@1.12.4)(react@18.3.1)(rxjs@7.8.1)':